feat: Create dedicated Invoice and Label pages
Invoice Page (/orders/:id/invoice): - A4-ready layout (210mm x 297mm) - Store header, invoice number, QR code - Billing/shipping address sections - Styled items table with alternating rows - Totals summary with conditional display - Thank you footer - Back to Order and Print buttons Label Page (/orders/:id/label): - 4x6 inch thermal label layout - Ship To address with phone - Items list (physical products only) - Shipping method - QR code for scanning - Back to Order and Print buttons Order Detail: - Removed print-mode logic - Removed print-only layouts - Invoice/Label buttons now link to dedicated pages - Label button still hidden for virtual-only orders
This commit is contained in:
219
admin-spa/src/routes/Orders/Invoice.tsx
Normal file
219
admin-spa/src/routes/Orders/Invoice.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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">
|
||||
<div
|
||||
className="print-a4 bg-white shadow-lg print:shadow-none mx-auto"
|
||||
style={{
|
||||
width: '210mm',
|
||||
minHeight: '297mm',
|
||||
padding: '20mm',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user