fix: receipt uses inline styles for PDF/print compatibility, thermal width
- Replace all Tailwind classes with inline styles (fixes oklch color error in html2pdf) - Narrow receipt to 260px / 10px font for 72mm thermal paper - Print uses hidden iframe instead of window.open (fixes Safari about:blank) - PDF canvas width matches thermal format Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { CheckCircle, Printer, Mail } from 'lucide-react'
|
import { CheckCircle, Printer, Mail } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { POSReceipt, downloadReceiptPDF, printReceiptPDF } from './pos-receipt'
|
import { POSReceipt, downloadReceiptPDF, printReceipt } from './pos-receipt'
|
||||||
|
|
||||||
interface POSPaymentDialogProps {
|
interface POSPaymentDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -104,7 +104,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
|
|||||||
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(result.transactionNumber)} className="gap-2">
|
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(result.transactionNumber)} className="gap-2">
|
||||||
Save PDF
|
Save PDF
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => printReceiptPDF(result.transactionNumber)} className="gap-2">
|
<Button size="sm" onClick={printReceipt} className="gap-2">
|
||||||
<Printer className="h-4 w-4" />Print
|
<Printer className="h-4 w-4" />Print
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,58 +130,64 @@ export function POSReceipt({ data, size = 'thermal', footerText, config }: POSRe
|
|||||||
const addr = location.address ?? company.address
|
const addr = location.address ?? company.address
|
||||||
const phone = location.phone ?? company.phone
|
const phone = location.phone ?? company.phone
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
row: { display: 'flex', justifyContent: 'space-between' } as const,
|
||||||
|
section: { padding: '8px 0', borderBottom: '1px dashed #999' } as const,
|
||||||
|
gray: { color: '#666' } as const,
|
||||||
|
light: { color: '#999' } as const,
|
||||||
|
bold: { fontWeight: 'bold' } as const,
|
||||||
|
center: { textAlign: 'center' } as const,
|
||||||
|
nums: { fontVariantNumeric: 'tabular-nums' } as const,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{
|
||||||
className={`bg-white text-black font-mono ${isThermal ? 'w-[300px] text-[11px]' : 'w-full max-w-lg text-sm'} mx-auto`}
|
background: '#fff', color: '#000', fontFamily: 'monospace',
|
||||||
style={{ lineHeight: isThermal ? '1.4' : '1.6' }}
|
width: isThermal ? 260 : '100%', maxWidth: isThermal ? 260 : 480,
|
||||||
>
|
fontSize: isThermal ? 10 : 14, lineHeight: isThermal ? '1.3' : '1.6',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}>
|
||||||
{/* Store header */}
|
{/* Store header */}
|
||||||
<div className="text-center pb-2 border-b border-dashed border-gray-400">
|
<div style={{ ...s.section, ...s.center }}>
|
||||||
{logoSrc ? (
|
{logoSrc ? (
|
||||||
<img src={logoSrc} alt={company.name} className={`mx-auto mb-1 ${isThermal ? 'max-h-12 max-w-[200px]' : 'max-h-16 max-w-[280px]'} object-contain`} />
|
<img src={logoSrc} alt={company.name} style={{ display: 'block', margin: '0 auto 4px', maxHeight: isThermal ? 48 : 64, maxWidth: isThermal ? 200 : 280, objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
<div className={`font-bold ${isThermal ? 'text-sm' : 'text-lg'}`}>{company.name}</div>
|
<div style={{ ...s.bold, fontSize: isThermal ? 14 : 18 }}>{company.name}</div>
|
||||||
)}
|
)}
|
||||||
{location.name !== company.name && (
|
{location.name !== company.name && <div style={s.gray}>{location.name}</div>}
|
||||||
<div className="text-gray-600">{location.name}</div>
|
{addr?.street && <div>{addr.street}</div>}
|
||||||
)}
|
{(addr?.city || addr?.state || addr?.zip) && (
|
||||||
{addr && (
|
<div>{[addr?.city, addr?.state].filter(Boolean).join(', ')} {addr?.zip}</div>
|
||||||
<>
|
|
||||||
{addr.street && <div>{addr.street}</div>}
|
|
||||||
{(addr.city || addr.state || addr.zip) && (
|
|
||||||
<div>{[addr.city, addr.state].filter(Boolean).join(', ')} {addr.zip}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{phone && <div>{phone}</div>}
|
{phone && <div>{phone}</div>}
|
||||||
{config?.header && <div className="mt-1 text-gray-600">{config.header}</div>}
|
{config?.header && <div style={{ ...s.gray, marginTop: 4 }}>{config.header}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction info */}
|
{/* Transaction info */}
|
||||||
<div className="py-2 border-b border-dashed border-gray-400">
|
<div style={s.section}>
|
||||||
<div className="flex justify-between">
|
<div style={s.row}>
|
||||||
<span>{txn.transactionNumber}</span>
|
<span>{txn.transactionNumber}</span>
|
||||||
<span>{txn.transactionType.replace('_', ' ')}</span>
|
<span>{txn.transactionType.replace('_', ' ')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-gray-600">
|
<div style={{ ...s.row, ...s.gray }}>
|
||||||
<span>{date.toLocaleDateString()}</span>
|
<span>{date.toLocaleDateString()}</span>
|
||||||
<span>{date.toLocaleTimeString()}</span>
|
<span>{date.toLocaleTimeString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line items */}
|
{/* Line items */}
|
||||||
<div className="py-2 border-b border-dashed border-gray-400">
|
<div style={s.section}>
|
||||||
{txn.lineItems.map((item, i) => (
|
{txn.lineItems.map((item, i) => (
|
||||||
<div key={i} className="py-0.5">
|
<div key={i} style={{ padding: '2px 0' }}>
|
||||||
<div className="flex justify-between">
|
<div style={s.row}>
|
||||||
<span className="flex-1 pr-2">{item.description}</span>
|
<span style={{ flex: 1, paddingRight: 8 }}>{item.description}</span>
|
||||||
<span className="tabular-nums">${parseFloat(item.lineTotal).toFixed(2)}</span>
|
<span style={s.nums}>${parseFloat(item.lineTotal).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
{(item.qty > 1 || parseFloat(item.discountAmount ?? '0') > 0) && (
|
{(item.qty > 1 || parseFloat(item.discountAmount ?? '0') > 0) && (
|
||||||
<div className="text-gray-500 pl-2">
|
<div style={{ ...s.light, paddingLeft: 8 }}>
|
||||||
{item.qty > 1 && <span>{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}</span>}
|
{item.qty > 1 && <span>{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}</span>}
|
||||||
{parseFloat(item.discountAmount ?? '0') > 0 && (
|
{parseFloat(item.discountAmount ?? '0') > 0 && (
|
||||||
<span className="ml-2">disc -${parseFloat(item.discountAmount!).toFixed(2)}</span>
|
<span style={{ marginLeft: 8 }}>disc -${parseFloat(item.discountAmount!).toFixed(2)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,114 +196,92 @@ export function POSReceipt({ data, size = 'thermal', footerText, config }: POSRe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="py-2 border-b border-dashed border-gray-400">
|
<div style={s.section}>
|
||||||
<div className="flex justify-between">
|
<div style={s.row}><span>Subtotal</span><span style={s.nums}>${subtotal.toFixed(2)}</span></div>
|
||||||
<span>Subtotal</span>
|
|
||||||
<span className="tabular-nums">${subtotal.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
{discountTotal > 0 && (
|
{discountTotal > 0 && (
|
||||||
<div className="flex justify-between">
|
<div style={s.row}><span>Discount</span><span style={s.nums}>-${discountTotal.toFixed(2)}</span></div>
|
||||||
<span>Discount</span>
|
|
||||||
<span className="tabular-nums">-${discountTotal.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div style={s.row}><span>Tax</span><span style={s.nums}>${taxTotal.toFixed(2)}</span></div>
|
||||||
<span>Tax</span>
|
|
||||||
<span className="tabular-nums">${taxTotal.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
{rounding !== 0 && (
|
{rounding !== 0 && (
|
||||||
<div className="flex justify-between text-gray-600">
|
<div style={{ ...s.row, ...s.gray }}><span>Rounding</span><span style={s.nums}>{rounding > 0 ? '+' : ''}{rounding.toFixed(2)}</span></div>
|
||||||
<span>Rounding</span>
|
|
||||||
<span className="tabular-nums">{rounding > 0 ? '+' : ''}{rounding.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className={`flex justify-between font-bold ${isThermal ? 'text-sm' : 'text-base'} pt-1`}>
|
<div style={{ ...s.row, ...s.bold, fontSize: isThermal ? 14 : 16, paddingTop: 4 }}>
|
||||||
<span>TOTAL</span>
|
<span>TOTAL</span><span style={s.nums}>${total.toFixed(2)}</span>
|
||||||
<span className="tabular-nums">${total.toFixed(2)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment */}
|
{/* Payment */}
|
||||||
<div className="py-2 border-b border-dashed border-gray-400">
|
<div style={s.section}>
|
||||||
<div className="flex justify-between">
|
<div style={s.row}><span>Payment</span><span style={{ textTransform: 'capitalize' }}>{txn.paymentMethod?.replace('_', ' ') ?? 'N/A'}</span></div>
|
||||||
<span>Payment</span>
|
|
||||||
<span className="capitalize">{txn.paymentMethod?.replace('_', ' ') ?? 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
{tendered !== null && (
|
{tendered !== null && (
|
||||||
<div className="flex justify-between">
|
<div style={s.row}><span>Tendered</span><span style={s.nums}>${tendered.toFixed(2)}</span></div>
|
||||||
<span>Tendered</span>
|
|
||||||
<span className="tabular-nums">${tendered.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{change !== null && change > 0 && (
|
{change !== null && change > 0 && (
|
||||||
<div className="flex justify-between font-bold">
|
<div style={{ ...s.row, ...s.bold }}><span>Change</span><span style={s.nums}>${change.toFixed(2)}</span></div>
|
||||||
<span>Change</span>
|
|
||||||
<span className="tabular-nums">${change.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barcode */}
|
{/* Barcode */}
|
||||||
<div className="flex justify-center py-3">
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
|
||||||
<svg ref={barcodeRef} />
|
<svg ref={barcodeRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{(config?.footer || footerText) && (
|
{(config?.footer || footerText) && (
|
||||||
<div className="text-center text-gray-500 pb-1">{config?.footer || footerText}</div>
|
<div style={{ ...s.center, ...s.light, paddingBottom: 4 }}>{config?.footer || footerText}</div>
|
||||||
)}
|
)}
|
||||||
{config?.returnPolicy && (
|
{config?.returnPolicy && (
|
||||||
<div className="text-center text-gray-400 text-[10px] pb-1">{config.returnPolicy}</div>
|
<div style={{ ...s.center, color: '#aaa', fontSize: 10, paddingBottom: 4 }}>{config.returnPolicy}</div>
|
||||||
)}
|
)}
|
||||||
{config?.social && (
|
{config?.social && (
|
||||||
<div className="text-center text-gray-500 pb-2">{config.social}</div>
|
<div style={{ ...s.center, ...s.light, paddingBottom: 8 }}>{config.social}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveReceiptPDF(txnNumber?: string): Promise<Blob | null> {
|
export function printReceipt() {
|
||||||
const el = document.getElementById('pos-receipt-print')
|
const el = document.getElementById('pos-receipt-print')
|
||||||
if (!el) return null
|
if (!el) return
|
||||||
|
|
||||||
const html2pdf = (await import('html2pdf.js')).default
|
// Clone the receipt into an iframe for clean printing
|
||||||
const blob: Blob = await html2pdf()
|
const iframe = document.createElement('iframe')
|
||||||
.set({
|
iframe.style.cssText = 'position:fixed;left:-9999px;width:400px;height:800px;border:none;'
|
||||||
margin: [4, 4, 4, 4],
|
document.body.appendChild(iframe)
|
||||||
filename: `receipt-${txnNumber ?? 'unknown'}.pdf`,
|
|
||||||
image: { type: 'jpeg', quality: 0.95 },
|
|
||||||
html2canvas: { scale: 2, useCORS: true },
|
|
||||||
jsPDF: { unit: 'mm', format: [80, 200], orientation: 'portrait' },
|
|
||||||
})
|
|
||||||
.from(el)
|
|
||||||
.outputPdf('blob')
|
|
||||||
|
|
||||||
return blob
|
const doc = iframe.contentDocument
|
||||||
|
if (!doc) { document.body.removeChild(iframe); return }
|
||||||
|
|
||||||
|
doc.open()
|
||||||
|
doc.write(`<!DOCTYPE html><html><head><style>body{margin:0;padding:8px;background:#fff;}</style></head><body>${el.innerHTML}</body></html>`)
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
// Wait for content to render then print
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
iframe.contentWindow?.focus()
|
||||||
|
iframe.contentWindow?.print()
|
||||||
|
} catch {
|
||||||
|
// Fallback: just use window.print
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
setTimeout(() => document.body.removeChild(iframe), 2000)
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadReceiptPDF(txnNumber?: string) {
|
export async function downloadReceiptPDF(txnNumber?: string) {
|
||||||
const blob = await saveReceiptPDF(txnNumber)
|
const el = document.getElementById('pos-receipt-print')
|
||||||
if (!blob) return
|
if (!el) return
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob)
|
const html2pdf = (await import('html2pdf.js')).default
|
||||||
const a = document.createElement('a')
|
html2pdf()
|
||||||
a.href = url
|
.set({
|
||||||
a.download = `receipt-${txnNumber ?? 'unknown'}.pdf`
|
margin: [2, 2, 2, 2],
|
||||||
a.click()
|
filename: `receipt-${txnNumber ?? 'unknown'}.pdf`,
|
||||||
URL.revokeObjectURL(url)
|
image: { type: 'jpeg', quality: 0.95 },
|
||||||
}
|
html2canvas: { scale: 2, useCORS: true, width: 280 },
|
||||||
|
jsPDF: { unit: 'mm', format: [72, 250], orientation: 'portrait' },
|
||||||
export async function printReceiptPDF(txnNumber?: string) {
|
})
|
||||||
const blob = await saveReceiptPDF(txnNumber)
|
.from(el)
|
||||||
if (!blob) return
|
.save()
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const printWindow = window.open(url)
|
|
||||||
if (printWindow) {
|
|
||||||
printWindow.onload = () => {
|
|
||||||
printWindow.print()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clean up after a delay
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 30000)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user