diff --git a/docs/order-fulfillment.md b/docs/order-fulfillment.md new file mode 100644 index 0000000..58d42ba --- /dev/null +++ b/docs/order-fulfillment.md @@ -0,0 +1,52 @@ +# Order states and fulfillment + +Each order has four independent states: + +| State | Meaning | Who changes it | +| --- | --- | --- | +| Order | Open, completed, cancelled | Administrator workflow actions | +| Payment | Not recorded, pending, paid, partially refunded, refunded, failed, cancelled | Recorded payment transactions and refund totals | +| Fulfillment | Unfulfilled, preparing, partially fulfilled, shipped, delivered, legacy fulfilled | Administrator shipment actions | +| Returns | None, requested, partially returned, returned | Reserved for the returns workflow | + +Fulfillment actions do not charge or refund money. Stock is deducted during payment settlement. Creating shipments and marking delivery do not deduct it again. Cancelling a paid, unshipped order restores its sold quantities once and leaves payment status unchanged. Once any quantity has shipped, cancellation is blocked; physical returns need their own quantity and restocking workflow. + +## Shipment workflow + +Open an order in Payload and use **Fulfillment & tracking**: + +1. Start preparing a paid order. +2. Create a shipment, choose quantities from the remaining items, and enter its carrier. Tracking number and HTTPS tracking link are optional. +3. Dispatch the shipment. Orders with quantities still outstanding become partially fulfilled. +4. Edit tracking when details change and mark each shipment delivered when confirmed. +5. Complete the order after all shipments are delivered. + +Customer account and guest order pages display the separate states, shipment quantities, tracking links, and delivery dates. Existing order ownership and guest access-token checks still apply. Only administrators can mutate fulfillment or retry its emails. Activity records identify the staff member and time for each action. + +Shipping labels, carrier API synchronization, shipping-rate calculations, issuing refunds, and accepting returns are separate future workflows. Delivery updates in this version are recorded by staff. + +## Concurrency and retries + +`POST /api/orders/:id/workflow` takes an action, the last loaded `version`, and a UUID `requestID`. Shipment actions also take item row IDs and quantities or an existing shipment ID. The server validates quantities, payment eligibility, transitions, and tracking links. + +The version claim, shipment changes, activity event, and email-outbox entry commit in one database transaction. A stale version returns HTTP 409. Replaying the same request ID and payload returns the current order without creating another shipment. Reusing that ID for another payload returns HTTP 409. Purchased item IDs and quantities cannot be edited after creation. + +The legacy `status` field remains as a compatibility projection for the ecommerce plugin. Reports and custom views use the separate state fields. + +## Customer notifications + +Shipment, tracking-update, and delivery emails use the configured SMTP or Resend adapter. Staff can turn off the notification when dispatching a shipment or editing tracking. Email delivery happens after the order transaction commits. + +The internal `shipment-notifications` collection records pending, sending, sent, failed, or skipped deliveries. The order panel lists delivery status and offers retries for pending or failed entries. Concurrent retries claim one send attempt. A sent notification is not sent again. + +If the application stops after SMTP accepts a message but before its sent marker is saved, the entry remains `sending`. Check the provider's delivery logs before reconciling that entry. Exactly-once delivery cannot be guaranteed across a database and SMTP; ambiguous entries are not automatically resent. + +## Existing orders and release checks + +Run the committed migrations before deploying application code that reads the new columns. Historical completed orders become `fulfilled`, with no invented shipment or delivery date. Historical orders without payment evidence remain `not_recorded` and cannot be dispatched. Refund status alone does not imply a physical return. + +`pnpm test:migrations` requires a **fresh, empty, disposable local database** through `TEST_DATABASE_URL`, ending in `_test`. It builds the old schema, inserts synthetic historical cases, applies the new migrations, verifies states and preserved totals/items, and rehearses rollback/reapply. It refuses an existing orders table. CI runs it before integration and browser checks. + +The integration suite covers partial dispatch, tracking, delivery, completion, cancellation/restocking, payment independence, invalid quantities, access control, stale writes, request replay, and email failures/retries. Browser checks exercise staff controls and customer tracking in the production build. + +Rolling these migrations back removes shipment and notification history. Use a database backup when preserving that history is required. 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/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/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} + + )} +
+ ))} +
+ ) +} 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/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/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)}