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:
@@ -13,6 +13,8 @@ import OrdersIndex from '@/routes/Orders';
|
|||||||
import OrderNew from '@/routes/Orders/New';
|
import OrderNew from '@/routes/Orders/New';
|
||||||
import OrderEdit from '@/routes/Orders/Edit';
|
import OrderEdit from '@/routes/Orders/Edit';
|
||||||
import OrderDetail from '@/routes/Orders/Detail';
|
import OrderDetail from '@/routes/Orders/Detail';
|
||||||
|
import OrderInvoice from '@/routes/Orders/Invoice';
|
||||||
|
import OrderLabel from '@/routes/Orders/Label';
|
||||||
import ProductsIndex from '@/routes/Products';
|
import ProductsIndex from '@/routes/Products';
|
||||||
import ProductNew from '@/routes/Products/New';
|
import ProductNew from '@/routes/Products/New';
|
||||||
import ProductEdit from '@/routes/Products/Edit';
|
import ProductEdit from '@/routes/Products/Edit';
|
||||||
@@ -551,6 +553,8 @@ function AppRoutes() {
|
|||||||
<Route path="/orders/new" element={<OrderNew />} />
|
<Route path="/orders/new" element={<OrderNew />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
||||||
|
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
|
||||||
|
<Route path="/orders/:id/label" element={<OrderLabel />} />
|
||||||
|
|
||||||
{/* Coupons (under Marketing) */}
|
{/* Coupons (under Marketing) */}
|
||||||
<Route path="/coupons" element={<CouponsIndex />} />
|
<Route path="/coupons" element={<CouponsIndex />} />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api, OrdersApi } from '@/lib/api';
|
import { api, OrdersApi } from '@/lib/api';
|
||||||
import { formatRelativeOrDate } from '@/lib/dates';
|
import { formatRelativeOrDate } from '@/lib/dates';
|
||||||
import { formatMoney } from '@/lib/currency';
|
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 { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -48,28 +48,7 @@ export default function OrderShow() {
|
|||||||
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
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 [showRetryDialog, setShowRetryDialog] = useState(false);
|
||||||
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ['order', id],
|
queryKey: ['order', id],
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -154,7 +133,7 @@ export default function OrderShow() {
|
|||||||
|
|
||||||
// Set contextual header with Back button and Edit action
|
// Set contextual header with Back button and Edit action
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!order || isPrintMode) {
|
if (!order) {
|
||||||
clearPageHeader();
|
clearPageHeader();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,37 +157,20 @@ export default function OrderShow() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => clearPageHeader();
|
return () => clearPageHeader();
|
||||||
}, [order, isPrintMode, id, setPageHeader, clearPageHeader, nav]);
|
}, [order, 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]);
|
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Desktop extra actions - hidden on mobile, shown on desktop */}
|
||||||
<div className="hidden md:flex flex-wrap items-center gap-2">
|
<div className="hidden md:flex flex-wrap items-center gap-2">
|
||||||
<div className="ml-auto 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')}>
|
<Link to={`/orders/${id}/invoice`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
|
||||||
<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')}>
|
|
||||||
<FileText className="w-4 h-4" /> {__('Invoice')}
|
<FileText className="w-4 h-4" /> {__('Invoice')}
|
||||||
</button>
|
</Link>
|
||||||
{!isVirtualOnly && (
|
{!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')}
|
<Ticket className="w-4 h-4" /> {__('Label')}
|
||||||
</button>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,145 +434,6 @@ export default function OrderShow() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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