Print CSS: - Hide WooNooW app nav, header, submenu, bottom nav - Set print-a4 to absolute positioning for clean print - Added 15mm padding for print - Hidden min-height for screen container Invoice Component: - Removed inline minHeight to prevent empty second page - Added max-w-3xl and p-8 for screen display - Added print:max-w-none print:p-0 for print mode
212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import { formatMoney } from '@/lib/currency';
|
|
import { ArrowLeft, Printer } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { InlineLoadingState } from '@/components/LoadingState';
|
|
import { ErrorCard } from '@/components/ErrorCard';
|
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
|
import { __ } from '@/lib/i18n';
|
|
|
|
function Money({ value, currency, symbol }: { value?: number; currency?: string; symbol?: string }) {
|
|
return <>{formatMoney(value, { currency, symbol })}</>;
|
|
}
|
|
|
|
export default function Invoice() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
|
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
const q = useQuery({
|
|
queryKey: ['order', id],
|
|
enabled: !!id,
|
|
queryFn: () => api.get(`/orders/${id}`),
|
|
});
|
|
|
|
const order = q.data;
|
|
|
|
// Check if all items are virtual
|
|
const isVirtualOnly = React.useMemo(() => {
|
|
if (!order?.items || order.items.length === 0) return false;
|
|
return order.items.every((item: any) => item.virtual || item.downloadable);
|
|
}, [order?.items]);
|
|
|
|
// Generate QR code
|
|
useEffect(() => {
|
|
if (!qrRef.current || !order) return;
|
|
(async () => {
|
|
try {
|
|
const mod = await import('qrcode');
|
|
const QR = (mod as any).default || (mod as any);
|
|
const text = `ORDER:${order.number || id}`;
|
|
await QR.toCanvas(qrRef.current, text, { width: 96, margin: 1 });
|
|
} catch (_) {
|
|
// QR library not available
|
|
}
|
|
})();
|
|
}, [order, id]);
|
|
|
|
const handlePrint = () => {
|
|
window.print();
|
|
};
|
|
|
|
if (q.isLoading) {
|
|
return <InlineLoadingState message={__('Loading invoice...')} />;
|
|
}
|
|
|
|
if (q.isError) {
|
|
return (
|
|
<ErrorCard
|
|
title={__('Failed to load order')}
|
|
message={getPageLoadErrorMessage(q.error)}
|
|
onRetry={() => q.refetch()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!order) return null;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100">
|
|
{/* Actions bar - hidden on print */}
|
|
<div className="no-print bg-white border-b sticky top-0 z-10">
|
|
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
<Link to={`/orders/${id}`}>
|
|
<Button variant="ghost" size="sm">
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
{__('Back to Order')}
|
|
</Button>
|
|
</Link>
|
|
<Button onClick={handlePrint} size="sm">
|
|
<Printer className="w-4 h-4 mr-2" />
|
|
{__('Print Invoice')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invoice content */}
|
|
<div className="py-8 print:py-0 print:bg-white">
|
|
<div className="print-a4 bg-white shadow-lg print:shadow-none mx-auto max-w-3xl p-8 print:max-w-none print:p-0">
|
|
{/* Invoice Header */}
|
|
<div className="flex items-start justify-between mb-8 pb-6 border-b-2 border-gray-200">
|
|
<div>
|
|
<div className="text-3xl font-bold text-gray-900">{__('INVOICE')}</div>
|
|
<div className="text-sm text-gray-600 mt-1">#{order.number}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-xl font-semibold text-gray-900">{siteTitle}</div>
|
|
<div className="text-sm text-gray-500 mt-1">{window.location.origin}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invoice Meta */}
|
|
<div className="grid grid-cols-2 gap-8 mb-8">
|
|
<div>
|
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{__('Invoice Date')}</div>
|
|
<div className="text-sm text-gray-900">{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}</div>
|
|
{order.payment_method && (
|
|
<>
|
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 mt-4">{__('Payment Method')}</div>
|
|
<div className="text-sm text-gray-900">{order.payment_method}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Billing & Shipping */}
|
|
<div className="grid grid-cols-2 gap-8 mb-8">
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Bill To')}</div>
|
|
<div className="text-sm text-gray-900 font-medium">{order.billing?.name || '—'}</div>
|
|
{order.billing?.email && <div className="text-sm text-gray-600">{order.billing.email}</div>}
|
|
{order.billing?.phone && <div className="text-sm text-gray-600">{order.billing.phone}</div>}
|
|
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.billing?.address || '' }} />
|
|
</div>
|
|
{!isVirtualOnly && order.shipping?.name && (
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Ship To')}</div>
|
|
<div className="text-sm text-gray-900 font-medium">{order.shipping?.name || '—'}</div>
|
|
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.shipping?.address || '' }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Items Table */}
|
|
<table className="w-full mb-8" style={{ borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr className="bg-gray-900 text-white">
|
|
<th className="text-left py-3 px-4 text-sm font-semibold">{__('Product')}</th>
|
|
<th className="text-center py-3 px-4 text-sm font-semibold w-20">{__('Qty')}</th>
|
|
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Price')}</th>
|
|
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Total')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(order.items || []).map((it: any, idx: number) => (
|
|
<tr key={it.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
|
<td className="py-3 px-4 text-sm">
|
|
<div className="font-medium text-gray-900">{it.name}</div>
|
|
{it.sku && <div className="text-xs text-gray-500">SKU: {it.sku}</div>}
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-center text-gray-600">{it.qty}</td>
|
|
<td className="py-3 px-4 text-sm text-right text-gray-600">
|
|
<Money value={it.subtotal / it.qty} currency={order.currency} symbol={order.currency_symbol} />
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-right font-medium text-gray-900">
|
|
<Money value={it.total} currency={order.currency} symbol={order.currency_symbol} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* Totals */}
|
|
<div className="flex justify-end mb-8">
|
|
<div className="w-72">
|
|
<div className="flex justify-between py-2 text-sm">
|
|
<span className="text-gray-600">{__('Subtotal')}</span>
|
|
<span className="text-gray-900"><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span>
|
|
</div>
|
|
{(order.totals?.discount || 0) > 0 && (
|
|
<div className="flex justify-between py-2 text-sm">
|
|
<span className="text-gray-600">{__('Discount')}</span>
|
|
<span className="text-green-600">-<Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span>
|
|
</div>
|
|
)}
|
|
{(order.totals?.shipping || 0) > 0 && (
|
|
<div className="flex justify-between py-2 text-sm">
|
|
<span className="text-gray-600">{__('Shipping')}</span>
|
|
<span className="text-gray-900"><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span>
|
|
</div>
|
|
)}
|
|
{(order.totals?.tax || 0) > 0 && (
|
|
<div className="flex justify-between py-2 text-sm">
|
|
<span className="text-gray-600">{__('Tax')}</span>
|
|
<span className="text-gray-900"><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between py-3 mt-2 border-t-2 border-gray-900">
|
|
<span className="text-lg font-bold text-gray-900">{__('Total')}</span>
|
|
<span className="text-lg font-bold text-gray-900">
|
|
<Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="mt-auto pt-8 border-t border-gray-200 text-center text-xs text-gray-500">
|
|
<p>{__('Thank you for your business!')}</p>
|
|
<p className="mt-1">{siteTitle} • {window.location.origin}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div >
|
|
);
|
|
}
|