From ee0d8c1bbae93c801908d58535d529221e9f2c6d Mon Sep 17 00:00:00 2001 From: codejagaban Date: Wed, 4 Nov 2026 18:15:46 +0100 Subject: [PATCH 1/6] feat: Add admin fulfillment and tracking controls --- src/app/(payload)/admin/importMap.js | 2 + src/components/admin/OrderWorkflow/index.scss | 181 ++++++ src/components/admin/OrderWorkflow/index.tsx | 588 ++++++++++++++++++ src/orders/itemTitle.ts | 9 + src/plugins/index.ts | 5 + 5 files changed, 785 insertions(+) create mode 100644 src/components/admin/OrderWorkflow/index.scss create mode 100644 src/components/admin/OrderWorkflow/index.tsx create mode 100644 src/orders/itemTitle.ts diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 0e245fa..d303342 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -25,6 +25,7 @@ import { ProductPublishing as ProductPublishing_2e09d0ed302e9b72a91fa96d8b38a01d import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ProductInventoryHint as ProductInventoryHint_545506debe207e4feb643fee5c5d7763 } from '@/components/admin/ProductInventoryHint' import { ProductPublishButton as ProductPublishButton_98537d810be5839e22cd240d7c917c4f } from '@/components/admin/ProductPublishButton' +import { OrderWorkflow as OrderWorkflow_8924a1612676ea062ffdb24967bd4567 } from '@/components/admin/OrderWorkflow' import { AdminNav as AdminNav_2623bc737c612ce9420322f2210a7f19 } from '@/components/admin/AdminNav' import { BeforeLogin as BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' import { AdminStyles as AdminStyles_340cf3538fa47d0416e0abd461790ca4 } from '@/components/admin/AdminStyles' @@ -61,6 +62,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@/components/admin/ProductInventoryHint#ProductInventoryHint": ProductInventoryHint_545506debe207e4feb643fee5c5d7763, "@/components/admin/ProductPublishButton#ProductPublishButton": ProductPublishButton_98537d810be5839e22cd240d7c917c4f, + "@/components/admin/OrderWorkflow#OrderWorkflow": OrderWorkflow_8924a1612676ea062ffdb24967bd4567, "@/components/admin/AdminNav#AdminNav": AdminNav_2623bc737c612ce9420322f2210a7f19, "@/components/BeforeLogin#BeforeLogin": BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e, "@/components/admin/AdminStyles#AdminStyles": AdminStyles_340cf3538fa47d0416e0abd461790ca4, diff --git a/src/components/admin/OrderWorkflow/index.scss b/src/components/admin/OrderWorkflow/index.scss new file mode 100644 index 0000000..eb93bd1 --- /dev/null +++ b/src/components/admin/OrderWorkflow/index.scss @@ -0,0 +1,181 @@ +.order-workflow { + margin-bottom: 2rem; + padding: 1.5rem; + background: var(--admin-panel, var(--theme-elevation-0)); + border-radius: var(--admin-radius-panel, 12px); + color: var(--theme-elevation-900); + font-size: 1rem; + line-height: 1.5; + min-width: 0; + &__header, + &__shipment > header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + } + h2, + h3 { + display: flex; + gap: 0.6rem; + align-items: center; + margin: 0; + font-size: 1.25rem; + } + h3 { + font-size: 1.1rem; + } + &__states { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + margin: 1.25rem 0; + } + dt, + &__muted, + &__empty, + &__notification { + color: var(--theme-elevation-650); + } + dd { + margin: 0.25rem 0 0; + font-weight: 600; + } + &__actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.6rem 1rem; + .btn { + margin: 0; + } + } + &__editor { + margin: 1.25rem 0; + padding: 1.25rem; + border: 0; + border-radius: 8px; + background: var(--admin-panel-muted, var(--theme-elevation-50)); + min-width: 0; + legend { + font-weight: 600; + padding: 0 0.35rem; + } + } + &__inputs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin: 1rem 0; + label { + display: flex; + flex-direction: column; + gap: 0.35rem; + } + } + &__wide { + grid-column: 1 / -1; + } + input:not([type='checkbox']) { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 0.6rem 0.75rem; + border: 1px solid var(--theme-elevation-300); + border-radius: 6px; + background: var(--theme-elevation-0); + color: var(--theme-elevation-900); + font: inherit; + } + input:focus-visible, + summary:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: 3px; + } + input[aria-invalid='true'] { + border-color: var(--theme-error-500); + } + &__quantity { + display: grid; + grid-template-columns: minmax(0, 1fr) 5rem; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0; + small { + display: block; + color: var(--theme-elevation-650); + } + > .order-workflow__error { + grid-column: 1 / -1; + } + } + &__check { + display: flex; + gap: 0.6rem; + align-items: center; + margin: 1rem 0; + input { + width: 1.1rem; + height: 1.1rem; + } + } + &__notice { + background: var(--admin-panel-muted, var(--theme-elevation-100)); + border-radius: 8px; + padding: 0.85rem 1rem; + } + &__error { + color: var(--theme-error-600, #b42318); + } + &__shipment { + margin-top: 1.25rem; + padding: 1.25rem; + background: var(--admin-panel-muted, var(--theme-elevation-50)); + border-radius: 8px; + overflow-wrap: anywhere; + p { + margin: 0.6rem 0; + } + ul { + padding-left: 1.25rem; + } + } + &__notification { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + margin-top: 0.7rem; + font-size: 0.925rem; + .btn { + margin: 0; + } + } + &__history { + margin-top: 1.5rem; + summary { + cursor: pointer; + font-weight: 600; + } + ol { + padding-left: 1.25rem; + } + li { + padding: 0.5rem 0; + span { + display: block; + color: var(--theme-elevation-650); + } + } + } + @media (max-width: 700px) { + padding: 1rem; + &__states { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + &__inputs { + grid-template-columns: minmax(0, 1fr); + } + } +} diff --git a/src/components/admin/OrderWorkflow/index.tsx b/src/components/admin/OrderWorkflow/index.tsx new file mode 100644 index 0000000..37a1305 --- /dev/null +++ b/src/components/admin/OrderWorkflow/index.tsx @@ -0,0 +1,588 @@ +'use client' + +import { Button, useDocumentInfo, useForm as usePayloadForm, useFormModified } from '@payloadcms/ui' +import { Package, Truck } from '@phosphor-icons/react' +import React, { useCallback, useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import type { Order, ShipmentNotification } from '@/payload-types' +import { remainingItems, safeTrackingURL, type WorkflowCommand } from '@/orders/fulfillment' +import { stateLabel } from '@/orders/states' +import { orderItemTitle as productName } from '@/orders/itemTitle' +import './index.scss' + +type Fields = { + carrier: string + trackingNumber: string + trackingURL: string + notifyCustomer: boolean + quantities: Record +} +type Editor = { mode: 'ship' | 'tracking'; shipmentID?: string } +const actionLabel: Record = { + prepare: 'Preparation started', + ship: 'Shipment dispatched', + tracking: 'Tracking updated', + deliver: 'Shipment marked delivered', + complete: 'Order completed', + cancel: 'Order cancelled', +} +const date = (value: string) => + new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) + +export function OrderWorkflow() { + const { id } = useDocumentInfo() + return id ? ( + + ) : ( +

Save this order before managing fulfillment.

+ ) +} + +function SavedOrderWorkflow({ id }: { id: number | string }) { + const { setData } = useDocumentInfo() + const payloadForm = usePayloadForm() + const modified = useFormModified() + const [order, setOrder] = useState(null) + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const [editor, setEditor] = useState(null) + const [confirmCancel, setConfirmCancel] = useState(false) + const { + register, + reset, + handleSubmit, + setError: setFieldError, + formState: { errors }, + } = useForm({ + defaultValues: { + carrier: '', + trackingNumber: '', + trackingURL: '', + notifyCustomer: true, + quantities: {}, + }, + }) + + const load = useCallback( + async (signal?: AbortSignal) => { + const [response, emailResponse] = await Promise.all([ + fetch(`/api/orders/${id}?depth=1`, { credentials: 'include', signal }), + fetch( + `/api/shipment-notifications?where[order][equals]=${id}&limit=100&sort=-createdAt&depth=0`, + { credentials: 'include', signal }, + ), + ]) + if (!response.ok || !emailResponse.ok) + throw new Error('Could not load fulfillment details. Try refreshing.') + return { + order: (await response.json()) as Order, + notifications: (await emailResponse.json()).docs as ShipmentNotification[], + } + }, + [id], + ) + + useEffect(() => { + const controller = new AbortController() + void load(controller.signal) + .then((fresh) => { + setOrder(fresh.order) + setNotifications(fresh.notifications) + }) + .catch((e: Error) => { + if (!controller.signal.aborted) setError(e.message) + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + return () => controller.abort() + }, [load]) + + const refresh = async () => { + const fresh = await load() + setOrder(fresh.order) + setNotifications(fresh.notifications) + setData(fresh.order) + await payloadForm.reset(fresh.order) + payloadForm.setModified(false) + } + const run = async (action: WorkflowCommand['action'], extras: Record = {}) => { + if (!order || busy || modified) return + setBusy(true) + setError('') + setMessage('') + try { + const response = await fetch(`/api/orders/${id}/workflow`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action, + version: order.workflowVersion, + requestID: crypto.randomUUID(), + ...extras, + }), + }) + const result = await response.json() + if (!response.ok) throw new Error(result.message || 'Could not update this order.') + setEditor(null) + setConfirmCancel(false) + await refresh() + setMessage( + result.notificationStatus === 'failed' + ? 'Shipment saved. The email failed; retry it below.' + : result.notificationStatus === 'sending' + ? 'Shipment saved. Check the email provider before retrying its notification.' + : 'Order updated.', + ) + } catch (e) { + setError(e instanceof Error ? e.message : 'Connection failed. Refresh before trying again.') + } finally { + setBusy(false) + } + } + const openEditor = (next: Editor) => { + const shipment = order?.shipments?.find((row) => row.id === next.shipmentID) + reset({ + carrier: shipment?.carrier ?? '', + trackingNumber: shipment?.trackingNumber ?? '', + trackingURL: shipment?.trackingURL ?? '', + notifyCustomer: true, + quantities: Object.fromEntries( + remainingItems(order!).map((item, index) => [ + String(index), + String(Math.max(0, item.remaining)), + ]), + ), + }) + setEditor(next) + setError('') + setMessage('') + } + const submitShipment = handleSubmit(async (values) => { + if (!order || !editor) return + const items = remainingItems(order) + .map((item, index) => ({ + orderItemID: item.id, + quantity: Number(values.quantities[index] ?? 0), + })) + .filter((row) => row.quantity > 0) + if (editor.mode === 'ship' && !items.length) { + setFieldError('root', { message: 'Choose at least one item to ship.' }) + return + } + await run(editor.mode, { + ...values, + quantities: undefined, + items, + shipmentID: editor.shipmentID, + }) + }) + const retry = async (notificationID: number) => { + setBusy(true) + setError('') + try { + const response = await fetch(`/api/orders/${id}/notifications/${notificationID}/retry`, { + method: 'POST', + credentials: 'include', + }) + const result = await response.json() + if (!response.ok || result.status === 'failed') + throw new Error( + result.message || + 'Email could not be sent. Check the email configuration before retrying.', + ) + const fresh = await load() + setOrder(fresh.order) + setNotifications(fresh.notifications) + setMessage('Notification updated.') + } catch (e) { + setError(e instanceof Error ? e.message : 'Email retry failed.') + } finally { + setBusy(false) + } + } + const disabled = loading || busy || modified + const paid = order && ['paid', 'partially_refunded'].includes(order.paymentStatus) + const open = order?.orderStatus === 'open' + return ( +
+
+

+ Fulfillment & tracking +

+ +
+ {loading &&

Loading fulfillment details…

} + {error && ( +

+ {error} +

+ )} + {message &&

{message}

} + {modified && ( +

+ Save your other order changes before using fulfillment actions. +

+ )} + {order && ( + <> +
+ {( + [ + ['Order', order.orderStatus], + ['Payment', order.paymentStatus], + ['Fulfillment', order.fulfillmentStatus], + ['Returns', order.returnStatus], + ] as const + ).map(([label, value]) => ( +
+
{label}
+
{stateLabel(value)}
+
+ ))} +
+
+ {open && paid && order.fulfillmentStatus === 'unfulfilled' && ( + + )} + {open && + paid && + ['unfulfilled', 'preparing', 'partially_fulfilled'].includes( + order.fulfillmentStatus, + ) && ( + + )} + {open && ['delivered', 'fulfilled'].includes(order.fulfillmentStatus) && ( + + )} + {open && ['unfulfilled', 'preparing'].includes(order.fulfillmentStatus) && ( + + )} +
+ {open && !paid && ( +

+ Recorded payment is required before shipping. Demo orders with no payment record + cannot be dispatched. +

+ )} + {order.orderStatus === 'cancelled' && order.paymentStatus === 'paid' && ( +

+ Cancelled; payment is still paid. Cancelling does not issue a refund. +

+ )} + {confirmCancel && ( +
+

+ Cancel this unshipped order and restore its paid inventory? This does not refund the + customer. +

+
+ + +
+
+ )} + {editor && ( +
+ {editor.mode === 'ship' ? 'New shipment' : 'Edit tracking'} + {editor.mode === 'ship' && ( +
+ {remainingItems(order).map( + (item, index) => + item.remaining > 0 && ( + + ), + )} +
+ )} +
+ + + +
+ + {errors.root && ( +

+ {errors.root.message} +

+ )} +
+ + +
+
+ )} + {!order.shipments?.length && ( +

+ {order.fulfillmentStatus === 'fulfilled' + ? 'Fulfilled before shipment tracking was introduced. No delivery date was recorded.' + : 'No shipments yet. Record the items and tracking details when they leave the store.'} +

+ )} + {order.shipments?.map((shipment, index) => ( +
+
+

+ Shipment {index + 1} +

+ {shipment.deliveredAt ? 'Delivered' : 'Shipped'} +
+

+ {shipment.carrier} + {shipment.trackingNumber && <> · {shipment.trackingNumber}} + {shipment.trackingURL && ( + <> + {' '} + ·{' '} + + Track shipment + + + )} +

+

+ Shipped {date(shipment.shippedAt)} + {shipment.deliveredAt && <> · Delivered {date(shipment.deliveredAt)}} +

+
    + {shipment.items.map((row) => { + const item = order.items?.find((item) => item.id === row.orderItemID) + return ( +
  • + {item ? productName(item) : 'Ordered item'} × {row.quantity} +
  • + ) + })} +
+
+ + {!shipment.deliveredAt && ( + + )} +
+ {notifications + .filter((entry) => entry.shipmentID === shipment.id) + .map((entry) => ( +
+ + {stateLabel(entry.kind)} email:{' '} + {entry.status === 'skipped' + ? 'not requested' + : entry.status === 'sending' + ? 'sending or awaiting confirmation' + : entry.status} + {entry.sentAt && ` · ${date(entry.sentAt)}`} + + {['failed', 'pending'].includes(entry.status) && ( + + )} +
+ ))} +
+ ))} + {!!order.workflowEvents?.length && ( +
+ Activity ({order.workflowEvents.length}) +
    + {[...order.workflowEvents].reverse().map((event) => ( +
  1. + {actionLabel[event.action] || stateLabel(event.action)} + + {date(event.occurredAt)} ·{' '} + {event.actor && typeof event.actor === 'object' + ? event.actor.name || event.actor.email + : `Staff #${event.actor}`} + +
  2. + ))} +
+
+ )} + + )} +
+ ) +} diff --git a/src/orders/itemTitle.ts b/src/orders/itemTitle.ts new file mode 100644 index 0000000..2288281 --- /dev/null +++ b/src/orders/itemTitle.ts @@ -0,0 +1,9 @@ +import type { Order } from '@/payload-types' + +export const orderItemTitle = (item: NonNullable[number]) => { + const product = typeof item.product === 'object' ? item.product : null + const variant = typeof item.variant === 'object' ? item.variant : null + const title = product?.title || 'Ordered item' + if (!variant?.title) return title + return variant.title.startsWith(title) ? variant.title : `${title} · ${variant.title}` +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 74cf1a7..bdc175f 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -177,6 +177,11 @@ export const plugins: Plugin[] = [ ], }, fields: [ + { + name: 'orderWorkflow', + type: 'ui', + admin: { components: { Field: '@/components/admin/OrderWorkflow#OrderWorkflow' } }, + }, ...defaultCollection.fields.map((field) => field.type === 'select' && field.name === 'status' ? { ...field, admin: { ...field.admin, hidden: true, readOnly: true } } From c96f4bf7f96e363a5ab7c696ca248448344a81b8 Mon Sep 17 00:00:00 2001 From: codejagaban Date: Wed, 4 Nov 2026 18:15:46 +0100 Subject: [PATCH 2/6] feat: Show order progress and customer shipment tracking --- src/app/(app)/(account)/orders/[id]/page.tsx | 10 ++- src/components/OrderItem/index.tsx | 4 +- src/components/OrderStatus/index.tsx | 10 ++- src/components/OrderTracking/index.tsx | 94 ++++++++++++++++++++ 4 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 src/components/OrderTracking/index.tsx diff --git a/src/app/(app)/(account)/orders/[id]/page.tsx b/src/app/(app)/(account)/orders/[id]/page.tsx index 2a57619..8d3403a 100644 --- a/src/app/(app)/(account)/orders/[id]/page.tsx +++ b/src/app/(app)/(account)/orders/[id]/page.tsx @@ -14,6 +14,7 @@ import configPromise from '@payload-config' import { getPayload } from 'payload' import { OrderStatus } from '@/components/OrderStatus' import { AddressItem } from '@/components/addresses/AddressItem' +import { OrderTracking } from '@/components/OrderTracking' export const dynamic = 'force-dynamic' @@ -85,6 +86,7 @@ export default async function Order({ params, searchParams }: PageProps) { fulfillmentStatus: true, returnStatus: true, workflowVersion: true, + shipments: true, createdAt: true, updatedAt: true, shippingAddress: true, @@ -159,7 +161,9 @@ export default async function Order({ params, searchParams }: PageProps) {

Status

- + )} @@ -196,7 +200,9 @@ export default async function Order({ params, searchParams }: PageProps) { )} - {order.shippingAddress && ( + + + {order.shippingAddress?.addressLine1 && (

Shipping address

diff --git a/src/components/OrderItem/index.tsx b/src/components/OrderItem/index.tsx index 9fb337a..31b07f4 100644 --- a/src/components/OrderItem/index.tsx +++ b/src/components/OrderItem/index.tsx @@ -21,7 +21,9 @@ export const OrderItem: React.FC = ({ order }) => { {formatDateTime({ date: order.createdAt, format: 'MMMM dd, yyyy' })}

- {order.status && } +

diff --git a/src/components/OrderStatus/index.tsx b/src/components/OrderStatus/index.tsx index d29df92..c7635da 100644 --- a/src/components/OrderStatus/index.tsx +++ b/src/components/OrderStatus/index.tsx @@ -1,8 +1,8 @@ -import { OrderStatus as StatusOptions } from '@/payload-types' +import { stateLabel } from '@/orders/states' import { cn } from '@/utilities/cn' type Props = { - status: StatusOptions + status: string className?: string } @@ -13,12 +13,14 @@ export const OrderStatus: React.FC = ({ status, className }) => { 'w-fit rounded-full px-3 py-1 text-xs font-medium capitalize', { 'bg-secondary text-secondary-foreground': status === 'processing', - 'bg-primary text-primary-foreground': status === 'completed', + 'bg-primary text-primary-foreground': ['completed', 'delivered', 'fulfilled'].includes( + status, + ), }, className, )} > - {status} + {stateLabel(status)} ) } diff --git a/src/components/OrderTracking/index.tsx b/src/components/OrderTracking/index.tsx new file mode 100644 index 0000000..06cd86f --- /dev/null +++ b/src/components/OrderTracking/index.tsx @@ -0,0 +1,94 @@ +import type { Order } from '@/payload-types' +import { stateLabel } from '@/orders/states' +import { orderItemTitle } from '@/orders/itemTitle' + +export function OrderTracking({ order }: { order: Order }) { + return ( +

+

Order progress

+
+ {( + [ + ['Order', order.orderStatus], + ['Payment', order.paymentStatus], + ['Delivery', order.fulfillmentStatus], + ['Returns', order.returnStatus], + ] as const + ).map(([label, value]) => ( +
+
{label}
+
{stateLabel(value)}
+
+ ))} +
+ {order.orderStatus === 'cancelled' && order.paymentStatus === 'paid' && ( +

+ This order has been cancelled. A refund has not yet been recorded. +

+ )} + {!order.shipments?.length && order.orderStatus !== 'cancelled' && ( +

+ {order.fulfillmentStatus === 'fulfilled' + ? 'This order was fulfilled before shipment tracking was available.' + : 'Tracking details will appear here once your order is dispatched.'} +

+ )} + {order.shipments?.map((shipment, index) => ( +
+

+ Shipment {index + 1}: {shipment.deliveredAt ? 'Delivered' : 'Shipped'} +

+

+ {shipment.carrier} + {shipment.trackingNumber && ` · ${shipment.trackingNumber}`} +

+

+ Shipped{' '} + + {shipment.deliveredAt && ( + <> + {' '} + · Delivered{' '} + + + )} +

+
    + {shipment.items.map((row) => { + const item = order.items?.find((item) => item.id === row.orderItemID) + return ( +
  • + {item ? orderItemTitle(item) : 'Ordered item'} × {row.quantity} +
  • + ) + })} +
+ {shipment.trackingURL && ( + + Track shipment {index + 1} + + )} +
+ ))} +
+ ) +} From 78998292a7b72d127c821f66e1c9ff24ef27447d Mon Sep 17 00:00:00 2001 From: codejagaban Date: Wed, 4 Nov 2026 18:15:47 +0100 Subject: [PATCH 3/6] fix: Report payment and fulfillment states independently --- src/components/admin/AdminNav/index.tsx | 24 +++++++------- src/components/admin/AttentionQueue/index.tsx | 16 ++++++++-- src/components/admin/Dashboard/metrics.ts | 22 ++++++++----- src/components/admin/Dashboard/queries.ts | 31 +++++++++---------- src/components/admin/RecentOrders/index.scss | 11 +++++++ src/components/admin/RecentOrders/index.tsx | 19 +++--------- tests/unit/dashboard-metrics.unit.spec.ts | 26 ++++++++++++++++ tests/unit/recent-orders.unit.spec.ts | 2 +- 8 files changed, 94 insertions(+), 57 deletions(-) diff --git a/src/components/admin/AdminNav/index.tsx b/src/components/admin/AdminNav/index.tsx index 461e01c..150f58b 100644 --- a/src/components/admin/AdminNav/index.tsx +++ b/src/components/admin/AdminNav/index.tsx @@ -12,12 +12,7 @@ import './index.scss' type IconProps = { className?: string } const Icon = ({ children, className }: React.PropsWithChildren) => ( - - {initialsFor(user?.name, user?.email)} - + {initialsFor(user?.name, user?.email)} {displayName} Admin diff --git a/src/components/admin/AttentionQueue/index.tsx b/src/components/admin/AttentionQueue/index.tsx index 5090af7..fd095b8 100644 --- a/src/components/admin/AttentionQueue/index.tsx +++ b/src/components/admin/AttentionQueue/index.tsx @@ -35,7 +35,13 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { count: counts.processingOrders, detail: 'Paid orders waiting to be handled', href: filteredHref(adminPath, 'orders', [ - { field: 'status', operator: 'equals', value: 'processing' }, + { field: 'orderStatus', operator: 'equals', value: 'open' }, + { field: 'paymentStatus', operator: 'in', value: 'paid,partially_refunded' }, + { + field: 'fulfillmentStatus', + operator: 'in', + value: 'unfulfilled,preparing,partially_fulfilled', + }, ]), Icon: IconFulfilment, label: 'Orders to process', @@ -94,7 +100,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { label: 'Variants out of stock', tone: 'warning', }, - ].filter((item) => item.count > 0).slice(0, 3) + ] + .filter((item) => item.count > 0) + .slice(0, 3) const total = items.reduce((sum, item) => sum + item.count, 0) return ( @@ -120,7 +128,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { href={item.href} key={item.label} > - + diff --git a/src/components/admin/Dashboard/metrics.ts b/src/components/admin/Dashboard/metrics.ts index e619d77..2c8dc2b 100644 --- a/src/components/admin/Dashboard/metrics.ts +++ b/src/components/admin/Dashboard/metrics.ts @@ -1,4 +1,4 @@ -import type { OrderStatus } from '@/payload-types' +import type { Order, OrderStatus } from '@/payload-types' import { dayKeysFrom, zonedDateKey } from '@/utilities/storeTime' type SalesOrder = { @@ -10,6 +10,8 @@ type SalesOrder = { shippingRefundTotal?: number | null shippingTotal?: number | null status?: OrderStatus + orderStatus?: Order['orderStatus'] + paymentStatus?: Order['paymentStatus'] subtotal?: number | null taxRefundTotal?: number | null taxTotal?: number | null @@ -34,10 +36,8 @@ type SalesSummary = { /** * Summarize the value the store actually kept during one reporting window. * - * An order marked refunded still belongs in gross sales because a sale happened, - * but the same amount is removed again as a refund. Cancelled orders were never - * sales. The current ecommerce model only supports whole-order refunds, so this - * intentionally does not pretend that partial-refund data exists. + * Cancellation and refund are independent: cancelling an order does not make + * received money disappear. Legacy imports retain their historical fallback. */ export function summarizeOrderSales({ currency, @@ -71,7 +71,13 @@ export function summarizeOrderSales({ if (order.currency && order.currency !== currency) continue const status = order.status ?? 'processing' - if (status === 'cancelled') continue + if (order.paymentStatus) { + if ( + !['paid', 'partially_refunded', 'refunded'].includes(order.paymentStatus) && + !(order.paymentStatus === 'not_recorded' && order.orderStatus !== 'cancelled') + ) + continue + } else if (status === 'cancelled') continue const amount = typeof order.amount === 'number' ? order.amount : 0 const subtotal = typeof order.subtotal === 'number' ? order.subtotal : amount @@ -81,7 +87,7 @@ export function summarizeOrderSales({ const productRefundTotal = typeof order.productRefundTotal === 'number' ? order.productRefundTotal - : status === 'refunded' + : (order.paymentStatus ?? status) === 'refunded' ? amount : 0 const shippingRefundTotal = @@ -97,7 +103,7 @@ export function summarizeOrderSales({ productRefunds += productRefundTotal const orderNetSales = Math.max(0, subtotal - discountTotal - productRefundTotal) - if (status === 'refunded' && orderNetSales === 0) continue + if ((order.paymentStatus ?? status) === 'refunded' && orderNetSales === 0) continue netOrders += 1 const index = dayIndex.get(zonedDateKey(placed, timeZone)) diff --git a/src/components/admin/Dashboard/queries.ts b/src/components/admin/Dashboard/queries.ts index 405de53..85231ba 100644 --- a/src/components/admin/Dashboard/queries.ts +++ b/src/components/admin/Dashboard/queries.ts @@ -101,7 +101,7 @@ export const recentOrdersFrom = (result: unknown): RecentOrder[] => createdAt: new Date(row.created_at).toISOString(), currency: row.currency || 'USD', customer: row.customer || 'Guest customer', - fulfillmentStatus: row.fulfillment_status || 'processing', + fulfillmentStatus: row.fulfillment_status || 'unfulfilled', id: row.id, paymentStatus: row.payment_status || 'not_recorded', total: amount(row.amount), @@ -150,7 +150,7 @@ export async function getDashboardOrderData({ WITH report_orders AS ( SELECT "created_at", - "status", + "payment_status", COALESCE("subtotal", "amount", 0) AS "subtotal", COALESCE("discount_total", 0) AS "discount_total", COALESCE("shipping_total", 0) AS "shipping_total", @@ -164,7 +164,8 @@ export async function getDashboardOrderData({ OR ("created_at" >= ${priorStart} AND "created_at" < ${priorEnd}) ) AND ("currency" = ${currency} OR "currency" IS NULL) - AND "status" IS DISTINCT FROM 'cancelled' + AND ("payment_status" IN ('paid', 'partially_refunded', 'refunded') + OR ("payment_status" = 'not_recorded' AND "order_status" <> 'cancelled')) ) SELECT CASE @@ -187,7 +188,7 @@ export async function getDashboardOrderData({ ) AS "total_sales", COUNT(*) FILTER ( WHERE NOT ( - "status" = 'refunded' + "payment_status" = 'refunded' AND GREATEST(0, "subtotal" - "discount_total" - "product_refund_total") = 0 ) ) AS "net_orders" @@ -198,15 +199,17 @@ export async function getDashboardOrderData({ const [statusResult, attentionResult, popularResult, recentResult] = await Promise.all([ payload.db.drizzle.execute(sql` - SELECT COALESCE("status"::text, 'processing') AS "status", COUNT(*) AS "count" + SELECT CASE WHEN "order_status" = 'open' THEN 'processing' ELSE "order_status"::text END AS "status", COUNT(*) AS "count" FROM "orders" WHERE "created_at" >= ${start} AND "created_at" < ${end} AND ("currency" = ${currency} OR "currency" IS NULL) - GROUP BY "status" + GROUP BY "order_status" `), payload.db.drizzle.execute(sql` SELECT - (SELECT COUNT(*) FROM "orders" WHERE "status" = 'processing') AS "processing_orders", + (SELECT COUNT(*) FROM "orders" WHERE "order_status" = 'open' + AND "payment_status" IN ('paid', 'partially_refunded') + AND "fulfillment_status" IN ('unfulfilled', 'preparing', 'partially_fulfilled')) AS "processing_orders", ( SELECT COUNT(*) FROM "transactions" WHERE "status" = 'failed' AND "created_at" >= NOW() - INTERVAL '24 hours' @@ -255,7 +258,8 @@ export async function getDashboardOrderData({ WHERE "orders"."created_at" >= ${start} AND "orders"."created_at" < ${end} AND ("orders"."currency" = ${currency} OR "orders"."currency" IS NULL) - AND ("orders"."status" IN ('processing', 'completed') OR "orders"."status" IS NULL) + AND "orders"."order_status" <> 'cancelled' + AND "orders"."payment_status" IN ('paid', 'partially_refunded', 'not_recorded') AND "items"."product_id" IS NOT NULL GROUP BY "items"."product_id" ORDER BY "units" DESC @@ -272,18 +276,11 @@ export async function getDashboardOrderData({ ) AS "customer", COALESCE("orders"."amount", 0) AS "amount", COALESCE("orders"."currency"::text, ${currency}) AS "currency", - COALESCE("orders"."status"::text, 'processing') AS "fulfillment_status", - "latest_transaction"."status"::text AS "payment_status", + "orders"."fulfillment_status"::text AS "fulfillment_status", + "orders"."payment_status"::text AS "payment_status", "orders"."created_at" FROM "orders" LEFT JOIN "users" ON "users"."id" = "orders"."customer_id" - LEFT JOIN LATERAL ( - SELECT "transactions"."status" - FROM "transactions" - WHERE "transactions"."order_id" = "orders"."id" - ORDER BY "transactions"."updated_at" DESC, "transactions"."id" DESC - LIMIT 1 - ) AS "latest_transaction" ON TRUE ORDER BY "orders"."created_at" DESC, "orders"."id" DESC LIMIT 5 `), diff --git a/src/components/admin/RecentOrders/index.scss b/src/components/admin/RecentOrders/index.scss index 5683b8d..e40848c 100644 --- a/src/components/admin/RecentOrders/index.scss +++ b/src/components/admin/RecentOrders/index.scss @@ -182,6 +182,17 @@ } } +.recent-orders__status { + &--paid, &--fulfilled, &--delivered { color: #166534; background: #dcf0df; } + &--unfulfilled, &--preparing, &--partially_fulfilled { color: #78530b; background: #f9edcb; } + &--shipped { color: #285c74; background: #e2eef3; } +} +:root[data-theme='dark'] .recent-orders__status { + &--paid, &--fulfilled, &--delivered { color: #8bd5a1; background: #23362b; } + &--unfulfilled, &--preparing, &--partially_fulfilled { color: #e6c67f; background: #383021; } + &--shipped { color: #a0ccd9; background: #24343a; } +} + @media (prefers-color-scheme: dark) { :root:where(:not([data-theme='light'])) .recent-orders__status { &--succeeded, diff --git a/src/components/admin/RecentOrders/index.tsx b/src/components/admin/RecentOrders/index.tsx index a888ae4..693004e 100644 --- a/src/components/admin/RecentOrders/index.tsx +++ b/src/components/admin/RecentOrders/index.tsx @@ -3,6 +3,7 @@ import React from 'react' import type { RecentOrder } from '../Dashboard/queries' import { IconOrders } from '../icons' +import { stateLabel } from '@/orders/states' import './index.scss' @@ -16,17 +17,7 @@ const money = (minor: number, currency: string) => new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(minor / 100) const words = (value: string) => - value - .replaceAll('_', ' ') - .replace(/\b\w/g, (letter) => letter.toUpperCase()) - -const fulfillmentLabel = (status: string) => { - if (status === 'processing') return 'Unfulfilled' - if (status === 'completed') return 'Fulfilled' - if (status === 'refunded') return 'Refunded' - if (status === 'cancelled') return 'Cancelled' - return words(status) -} + value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()) export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) => (
@@ -58,9 +49,7 @@ export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) = > #{order.id} {order.customer} - - {money(order.total, order.currency)} - + {money(order.total, order.currency)} @@ -69,7 +58,7 @@ export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) = - {fulfillmentLabel(order.fulfillmentStatus)} + {stateLabel(order.fulfillmentStatus)}