From 1cef11a1d2439e1a62a91214c324d06d881a4cd4 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Tue, 6 Jan 2026 20:57:57 +0700 Subject: [PATCH] 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 --- admin-spa/src/App.tsx | 4 + admin-spa/src/routes/Orders/Detail.tsx | 197 ++------------------- admin-spa/src/routes/Orders/Invoice.tsx | 219 ++++++++++++++++++++++++ admin-spa/src/routes/Orders/Label.tsx | 136 +++++++++++++++ 4 files changed, 369 insertions(+), 187 deletions(-) create mode 100644 admin-spa/src/routes/Orders/Invoice.tsx create mode 100644 admin-spa/src/routes/Orders/Label.tsx diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 9fee446..d7f844d 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -13,6 +13,8 @@ import OrdersIndex from '@/routes/Orders'; import OrderNew from '@/routes/Orders/New'; import OrderEdit from '@/routes/Orders/Edit'; import OrderDetail from '@/routes/Orders/Detail'; +import OrderInvoice from '@/routes/Orders/Invoice'; +import OrderLabel from '@/routes/Orders/Label'; import ProductsIndex from '@/routes/Products'; import ProductNew from '@/routes/Products/New'; import ProductEdit from '@/routes/Products/Edit'; @@ -551,6 +553,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> {/* Coupons (under Marketing) */} } /> diff --git a/admin-spa/src/routes/Orders/Detail.tsx b/admin-spa/src/routes/Orders/Detail.tsx index 276474f..c733590 100644 --- a/admin-spa/src/routes/Orders/Detail.tsx +++ b/admin-spa/src/routes/Orders/Detail.tsx @@ -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(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 ( -
+
{/* Desktop extra actions - hidden on mobile, shown on desktop */}
- - + {!isVirtualOnly && ( - + )}
@@ -472,145 +434,6 @@ export default function OrderShow() {
)} - - {/* Print-only layouts */} - {order && ( -
- {mode === 'invoice' && ( -
- {/* Invoice Header */} -
-
-
{__('INVOICE')}
-
#{order.number}
-
-
-
{siteTitle}
-
{window.location.origin}
-
-
- - {/* Invoice Meta */} -
-
-
{__('Invoice Date')}
-
{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}
- {order.payment_method && ( - <> -
{__('Payment Method')}
-
{order.payment_method}
- - )} -
-
- -
-
- - {/* Billing & Shipping */} -
-
-
{__('Bill To')}
-
{order.billing?.name || '—'}
- {order.billing?.email &&
{order.billing.email}
} - {order.billing?.phone &&
{order.billing.phone}
} -
-
- {!isVirtualOnly && order.shipping?.name && ( -
-
{__('Ship To')}
-
{order.shipping?.name || '—'}
-
-
- )} -
- - {/* Items Table */} - - - - - - - - - - - {(order.items || []).map((it: any, idx: number) => ( - - - - - - - ))} - -
{__('Product')}{__('Qty')}{__('Price')}{__('Total')}
-
{it.name}
- {it.sku &&
SKU: {it.sku}
} -
{it.qty}
- - {/* Totals */} -
-
-
- {__('Subtotal')} - -
- {(order.totals?.discount || 0) > 0 && ( -
- {__('Discount')} - - -
- )} - {(order.totals?.shipping || 0) > 0 && ( -
- {__('Shipping')} - -
- )} - {(order.totals?.tax || 0) > 0 && ( -
- {__('Tax')} - -
- )} -
- {__('Total')} - -
-
-
- - {/* Footer */} -
-

{__('Thank you for your business!')}

-

{siteTitle} • {window.location.origin}

-
-
- )} - {mode === 'label' && ( -
-
-
-
#{order.number}
- -
-
-
{__('Ship To')}
-
-
-
{__('Items')}
-
    - {(order.items || []).map((it: any) => ( -
  • {it.name} ×{it.qty}
  • - ))} -
-
-
- )} -
- )}
); } \ No newline at end of file diff --git a/admin-spa/src/routes/Orders/Invoice.tsx b/admin-spa/src/routes/Orders/Invoice.tsx new file mode 100644 index 0000000..a89c2ef --- /dev/null +++ b/admin-spa/src/routes/Orders/Invoice.tsx @@ -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(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 ; + } + + if (q.isError) { + return ( + q.refetch()} + /> + ); + } + + if (!order) return null; + + return ( +
+ {/* Actions bar - hidden on print */} +
+
+ + + + +
+
+ + {/* Invoice content */} +
+
+ {/* Invoice Header */} +
+
+
{__('INVOICE')}
+
#{order.number}
+
+
+
{siteTitle}
+
{window.location.origin}
+
+
+ + {/* Invoice Meta */} +
+
+
{__('Invoice Date')}
+
{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}
+ {order.payment_method && ( + <> +
{__('Payment Method')}
+
{order.payment_method}
+ + )} +
+
+ +
+
+ + {/* Billing & Shipping */} +
+
+
{__('Bill To')}
+
{order.billing?.name || '—'}
+ {order.billing?.email &&
{order.billing.email}
} + {order.billing?.phone &&
{order.billing.phone}
} +
+
+ {!isVirtualOnly && order.shipping?.name && ( +
+
{__('Ship To')}
+
{order.shipping?.name || '—'}
+
+
+ )} +
+ + {/* Items Table */} + + + + + + + + + + + {(order.items || []).map((it: any, idx: number) => ( + + + + + + + ))} + +
{__('Product')}{__('Qty')}{__('Price')}{__('Total')}
+
{it.name}
+ {it.sku &&
SKU: {it.sku}
} +
{it.qty} + + + +
+ + {/* Totals */} +
+
+
+ {__('Subtotal')} + +
+ {(order.totals?.discount || 0) > 0 && ( +
+ {__('Discount')} + - +
+ )} + {(order.totals?.shipping || 0) > 0 && ( +
+ {__('Shipping')} + +
+ )} + {(order.totals?.tax || 0) > 0 && ( +
+ {__('Tax')} + +
+ )} +
+ {__('Total')} + + + +
+
+
+ + {/* Footer */} +
+

{__('Thank you for your business!')}

+

{siteTitle} • {window.location.origin}

+
+
+
+
+ ); +} diff --git a/admin-spa/src/routes/Orders/Label.tsx b/admin-spa/src/routes/Orders/Label.tsx new file mode 100644 index 0000000..b2ba0a2 --- /dev/null +++ b/admin-spa/src/routes/Orders/Label.tsx @@ -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(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 ; + } + + if (q.isError) { + return ( + q.refetch()} + /> + ); + } + + if (!order) return null; + + return ( +
+ {/* Actions bar - hidden on print */} +
+
+ + + + +
+
+ + {/* Label content - 4x6 inch thermal label size */} +
+
+ {/* Order Number & QR */} +
+
+
#{order.number}
+
+ {new Date((order.date_ts || 0) * 1000).toLocaleDateString()} +
+
+ +
+ + {/* Ship To */} +
+
{__('SHIP TO')}
+
{order.shipping?.name || order.billing?.name || '—'}
+ {(order.shipping?.phone || order.billing?.phone) && ( +
{order.shipping?.phone || order.billing?.phone}
+ )} +
+
+ + {/* Items */} +
+
{__('ITEMS')}
+
    + {(order.items || []).filter((it: any) => !it.virtual && !it.downloadable).map((it: any) => ( +
  • + {it.name} + ×{it.qty} +
  • + ))} +
+
+ + {/* Shipping Method */} + {order.shipping_method && ( +
+
{__('SHIPPING METHOD')}
+
{order.shipping_method}
+
+ )} +
+
+
+ ); +}