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:
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, OrdersApi } from '@/lib/api';
|
||||
import { formatRelativeOrDate } from '@/lib/dates';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
import { ArrowLeft, Printer, ExternalLink, Loader2, Ticket, FileText, Pencil, RefreshCw } from 'lucide-react';
|
||||
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -48,28 +48,7 @@ export default function OrderShow() {
|
||||
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const mode = params.get('mode'); // undefined | 'label' | 'invoice'
|
||||
const isPrintMode = mode === 'label' || mode === 'invoice';
|
||||
|
||||
function triggerPrint(nextMode: 'label' | 'invoice') {
|
||||
params.set('mode', nextMode);
|
||||
setParams(params, { replace: true });
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
params.delete('mode');
|
||||
setParams(params, { replace: true });
|
||||
}, 50);
|
||||
}
|
||||
function printLabel() {
|
||||
triggerPrint('label');
|
||||
}
|
||||
function printInvoice() {
|
||||
triggerPrint('invoice');
|
||||
}
|
||||
|
||||
const [showRetryDialog, setShowRetryDialog] = useState(false);
|
||||
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const q = useQuery({
|
||||
queryKey: ['order', id],
|
||||
enabled: !!id,
|
||||
@@ -154,7 +133,7 @@ export default function OrderShow() {
|
||||
|
||||
// Set contextual header with Back button and Edit action
|
||||
useEffect(() => {
|
||||
if (!order || isPrintMode) {
|
||||
if (!order) {
|
||||
clearPageHeader();
|
||||
return;
|
||||
}
|
||||
@@ -178,37 +157,20 @@ export default function OrderShow() {
|
||||
);
|
||||
|
||||
return () => clearPageHeader();
|
||||
}, [order, isPrintMode, id, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrintMode || !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: 128, margin: 1 });
|
||||
} catch (_) {
|
||||
// optional dependency not installed; silently ignore
|
||||
}
|
||||
})();
|
||||
}, [mode, order, id, isPrintMode]);
|
||||
}, [order, id, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${mode === 'label' ? 'woonoow-label-mode' : ''}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Desktop extra actions - hidden on mobile, shown on desktop */}
|
||||
<div className="hidden md:flex flex-wrap items-center gap-2">
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print order')}>
|
||||
<Printer className="w-4 h-4" /> {__('Print')}
|
||||
</button>
|
||||
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print invoice')}>
|
||||
<Link to={`/orders/${id}/invoice`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
|
||||
<FileText className="w-4 h-4" /> {__('Invoice')}
|
||||
</button>
|
||||
</Link>
|
||||
{!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')}>
|
||||
<Link to={`/orders/${id}/label`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
|
||||
<Ticket className="w-4 h-4" /> {__('Label')}
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,145 +434,6 @@ export default function OrderShow() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Print-only layouts */}
|
||||
{order && (
|
||||
<div className="print-only">
|
||||
{mode === 'invoice' && (
|
||||
<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-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>
|
||||
)}
|
||||
{mode === 'label' && (
|
||||
<div className="p-4 print-4x6">
|
||||
<div className="border rounded p-4 h-full">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="text-base font-semibold">#{order.number}</div>
|
||||
<canvas ref={qrRef} className="w-24 h-24 border" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="text-xs opacity-60 mb-1">{__('Ship To')}</div>
|
||||
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }} />
|
||||
</div>
|
||||
<div className="text-xs opacity-60 mb-1">{__('Items')}</div>
|
||||
<ul className="text-sm list-disc pl-4">
|
||||
{(order.items || []).map((it: any) => (
|
||||
<li key={it.id}>{it.name} ×{it.qty}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
136
admin-spa/src/routes/Orders/Label.tsx
Normal file
136
admin-spa/src/routes/Orders/Label.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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 { 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';
|
||||
|
||||
export default function Label() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['order', id],
|
||||
enabled: !!id,
|
||||
queryFn: () => api.get(`/orders/${id}`),
|
||||
});
|
||||
|
||||
const order = q.data;
|
||||
|
||||
// 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: 128, margin: 1 });
|
||||
} catch (_) {
|
||||
// QR library not available
|
||||
}
|
||||
})();
|
||||
}, [order, id]);
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (q.isLoading) {
|
||||
return <InlineLoadingState message={__('Loading label...')} />;
|
||||
}
|
||||
|
||||
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-2xl 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 Label')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label content - 4x6 inch thermal label size */}
|
||||
<div className="py-8 print:py-0 flex justify-center">
|
||||
<div
|
||||
className="print-4x6 bg-white shadow-lg print:shadow-none"
|
||||
style={{
|
||||
width: '4in',
|
||||
height: '6in',
|
||||
padding: '0.5in',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Order Number & QR */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">#{order.number}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
|
||||
</div>
|
||||
|
||||
{/* Ship To */}
|
||||
<div className="mb-4 p-3 border-2 border-gray-900 rounded">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('SHIP TO')}</div>
|
||||
<div className="text-lg font-bold text-gray-900">{order.shipping?.name || order.billing?.name || '—'}</div>
|
||||
{(order.shipping?.phone || order.billing?.phone) && (
|
||||
<div className="text-sm text-gray-700 mt-1">{order.shipping?.phone || order.billing?.phone}</div>
|
||||
)}
|
||||
<div
|
||||
className="text-sm text-gray-700 mt-2 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('ITEMS')}</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{(order.items || []).filter((it: any) => !it.virtual && !it.downloadable).map((it: any) => (
|
||||
<li key={it.id} className="flex justify-between">
|
||||
<span className="truncate">{it.name}</span>
|
||||
<span className="font-medium ml-2">×{it.qty}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Shipping Method */}
|
||||
{order.shipping_method && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">{__('SHIPPING METHOD')}</div>
|
||||
<div className="text-sm font-medium text-gray-900">{order.shipping_method}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user