Files
WooNooW/admin-spa/src/routes/Orders/Invoice.tsx
Dwindi Ramadhana 2b94f26cae fix: Invoice print layout - hide app shell, fix padding
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
2026-01-06 21:06:28 +07:00

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 >
);
}