feat: Implement A4 invoice layout and hide Label for virtual orders

Invoice:
- Enhanced A4-ready layout with proper structure
- Store header with invoice number
- Billing/shipping address sections
- Styled items table with alternating rows
- Totals summary with conditional display
- Thank you footer

Label:
- Label button now hidden for virtual-only orders
- Uses existing isVirtualOnly detection

Print CSS:
- Added @page A4 size directive
- Print-color-adjust for background colors
- 20mm padding for proper margins

Documentation:
- Updated subscription module plan (comprehensive)
- Updated affiliate module plan (comprehensive)
- Created shipping label standardization plan
This commit is contained in:
Dwindi Ramadhana
2026-01-05 19:16:13 +07:00
parent 2efc6a7605
commit 40aee67c46
5 changed files with 733 additions and 57 deletions

View File

@@ -205,9 +205,11 @@ export default function OrderShow() {
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print invoice')}>
<FileText className="w-4 h-4" /> {__('Invoice')}
</button>
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
<Ticket className="w-4 h-4" /> {__('Label')}
</button>
{!isVirtualOnly && (
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
<Ticket className="w-4 h-4" /> {__('Label')}
</button>
)}
</div>
</div>
@@ -475,55 +477,116 @@ export default function OrderShow() {
{order && (
<div className="print-only">
{mode === 'invoice' && (
<div className="max-w-[800px] mx-auto p-6 text-sm">
<div className="flex items-start justify-between mb-6">
<div className="print-a4 bg-white" style={{ minHeight: '297mm', width: '210mm', margin: '0 auto', 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-xl font-semibold">Invoice</div>
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts || 0) * 1000).toLocaleString()}</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="font-medium">{siteTitle}</div>
<div className="opacity-60 text-xs">{window.location.origin}</div>
<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>
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Invoice Meta */}
<div className="grid grid-cols-2 gap-8 mb-8">
<div>
<div className="text-xs opacity-60 mb-1">{__('Bill To')}</div>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.billing?.address || order.billing?.name || '' }} />
<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="text-right">
<canvas ref={qrRef} className="inline-block w-24 h-24 border" />
<div className="flex justify-end">
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
</div>
</div>
<table className="w-full border-collapse mb-6">
{/* 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>
<th className="text-left border-b py-2 pr-2">Product</th>
<th className="text-right border-b py-2 px-2">Qty</th>
<th className="text-right border-b py-2 px-2">Subtotal</th>
<th className="text-right border-b py-2 pl-2">Total</th>
<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) => (
<tr key={it.id}>
<td className="py-1 pr-2">{it.name}</td>
<td className="py-1 px-2 text-right">×{it.qty}</td>
<td className="py-1 px-2 text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></td>
<td className="py-1 pl-2 text-right"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
{(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>
<div className="flex justify-end">
<div className="min-w-[260px]">
<div className="flex justify-between"><span>Subtotal</span><span><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Discount</span><span><Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Shipping</span><span><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Tax</span><span><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between font-semibold border-t mt-2 pt-2"><span>Total</span><span><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></span></div>
{/* 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>
)}
{mode === 'label' && (