浏览代码

feat(dashboard): Order modification (#3656)

Michael Bromley 6 月之前
父节点
当前提交
8a15d89153
共有 34 个文件被更改,包括 3223 次插入655 次删除
  1. 30 37
      packages/dashboard/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx
  2. 33 53
      packages/dashboard/src/app/routes/_authenticated/_orders/components/fulfillment-details.tsx
  3. 14 7
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-address.tsx
  4. 23 12
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-line-custom-fields-form.tsx
  5. 364 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-modification-preview-dialog.tsx
  6. 222 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-modification-summary.tsx
  7. 146 85
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table.tsx
  8. 268 31
      packages/dashboard/src/app/routes/_authenticated/_orders/components/payment-details.tsx
  9. 80 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/settle-refund-dialog.tsx
  10. 102 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/state-transition-control.tsx
  11. 144 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/use-transition-order-to-state.tsx
  12. 118 2
      packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts
  13. 144 52
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx
  14. 550 0
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx
  15. 0 17
      packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx
  16. 5 2
      packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-types.ts
  17. 4 3
      packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-utils.ts
  18. 0 1
      packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx
  19. 7 1
      packages/dashboard/src/lib/components/data-display/date-time.tsx
  20. 11 0
      packages/dashboard/src/lib/components/data-input/relation-input.tsx
  21. 9 2
      packages/dashboard/src/lib/components/data-input/relation-selector.tsx
  22. 34 0
      packages/dashboard/src/lib/components/data-table/data-table-utils.ts
  23. 2 2
      packages/dashboard/src/lib/components/data-table/data-table-view-options.tsx
  24. 5 2
      packages/dashboard/src/lib/components/data-table/data-table.tsx
  25. 307 0
      packages/dashboard/src/lib/components/data-table/use-generated-columns.tsx
  26. 15 286
      packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx
  27. 28 4
      packages/dashboard/src/lib/components/shared/product-variant-selector.tsx
  28. 3 3
      packages/dashboard/src/lib/framework/component-registry/dynamic-component.tsx
  29. 321 2
      packages/dashboard/src/lib/framework/document-introspection/get-document-structure.spec.ts
  30. 149 31
      packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts
  31. 21 6
      packages/dashboard/src/lib/framework/extension-api/types/layout.ts
  32. 1 4
      packages/dashboard/src/lib/framework/layout-engine/layout-extensions.ts
  33. 61 10
      packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx
  34. 2 0
      packages/dashboard/src/lib/framework/page/use-detail-page.ts

+ 30 - 37
packages/dashboard/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx

@@ -1,3 +1,4 @@
+import { SingleRelationInput } from '@/vdb/components/data-input/relation-input.js';
 import { ProductVariantSelector } from '@/vdb/components/shared/product-variant-selector.js';
 import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
 import { Button } from '@/vdb/components/ui/button.js';
@@ -14,8 +15,8 @@ import {
 } from '@tanstack/react-table';
 import { Trash2 } from 'lucide-react';
 import { useState } from 'react';
-import { UseFormReturn } from 'react-hook-form';
 import {
+    couponCodeSelectorPromotionListDocument,
     draftOrderEligibleShippingMethodsDocument,
     orderDetailDocument,
     orderLineFragment,
@@ -35,13 +36,20 @@ type ShippingMethodQuote = ResultOf<
 export interface OrderTableProps {
     order: OrderFragment;
     eligibleShippingMethods: ShippingMethodQuote[];
-    onAddItem: (event: { productVariantId: string }) => void;
+    onAddItem: (variant: {
+        productVariantId: string;
+        productVariantName: string;
+        sku: string;
+        productAsset: any;
+        price?: any;
+        priceWithTax?: any;
+    }) => void;
     onAdjustLine: (event: { lineId: string; quantity: number; customFields: Record<string, any> }) => void;
     onRemoveLine: (event: { lineId: string }) => void;
     onSetShippingMethod: (event: { shippingMethodId: string }) => void;
     onApplyCouponCode: (event: { couponCode: string }) => void;
     onRemoveCouponCode: (event: { couponCode: string }) => void;
-    orderLineForm: UseFormReturn<any>;
+    displayTotals?: boolean;
 }
 
 export function EditOrderTable({
@@ -53,14 +61,11 @@ export function EditOrderTable({
     onSetShippingMethod,
     onApplyCouponCode,
     onRemoveCouponCode,
-    orderLineForm,
+    displayTotals = true,
 }: Readonly<OrderTableProps>) {
     const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
-    const [couponCode, setCouponCode] = useState('');
-
     const currencyCode = order.currencyCode;
-
-    const columns: ColumnDef<OrderLineFragment>[] = [
+    const columns: ColumnDef<OrderLineFragment & { customFields?: Record<string, any> }>[] = [
         {
             header: 'Image',
             accessorKey: 'featuredAsset',
@@ -99,7 +104,7 @@ export function EditOrderTable({
                                 onAdjustLine({
                                     lineId: row.original.id,
                                     quantity: e.target.valueAsNumber,
-                                    customFields: row.original.customFields,
+                                    customFields: row.original.customFields ?? {},
                                 })
                             }
                         />
@@ -120,7 +125,7 @@ export function EditOrderTable({
                                         customFields: customFields,
                                     });
                                 }}
-                                form={orderLineForm}
+                                value={row.original.customFields}
                             />
                         )}
                     </div>
@@ -189,11 +194,7 @@ export function EditOrderTable({
                             <TableCell colSpan={columns.length} className="h-12">
                                 <div className="my-4 flex justify-center">
                                     <div className="max-w-lg">
-                                        <ProductVariantSelector
-                                            onProductVariantIdChange={variantId => {
-                                                onAddItem({ productVariantId: variantId });
-                                            }}
-                                        />
+                                        <ProductVariantSelector onProductVariantSelect={onAddItem} />
                                     </div>
                                 </div>
                             </TableCell>
@@ -210,27 +211,7 @@ export function EditOrderTable({
                         </TableRow>
                         <TableRow>
                             <TableCell colSpan={columns.length} className="h-12">
-                                <div className="flex flex-col gap-4">
-                                    <div className="flex gap-2">
-                                        <Input
-                                            type="text"
-                                            placeholder="Coupon code"
-                                            value={couponCode}
-                                            onChange={e => setCouponCode(e.target.value)}
-                                            onKeyDown={e => {
-                                                if (e.key === 'Enter') {
-                                                    onApplyCouponCode({ couponCode });
-                                                }
-                                            }}
-                                        />
-                                        <Button
-                                            type="button"
-                                            onClick={() => onApplyCouponCode({ couponCode })}
-                                            disabled={!couponCode}
-                                        >
-                                            <Trans>Apply</Trans>
-                                        </Button>
-                                    </div>
+                                <div className="flex gap-4">
                                     {order.couponCodes?.length > 0 && (
                                         <div className="flex flex-wrap gap-2">
                                             {order.couponCodes.map(code => (
@@ -254,10 +235,22 @@ export function EditOrderTable({
                                             ))}
                                         </div>
                                     )}
+                                    <SingleRelationInput
+                                        config={{
+                                            listQuery: couponCodeSelectorPromotionListDocument,
+                                            idKey: 'couponCode',
+                                            labelKey: 'couponCode',
+                                            placeholder: 'Search coupon codes...',
+                                            label: (item: any) => `${item.couponCode} (${item.name})`,
+                                        }}
+                                        value={''}
+                                        selectorLabel={<Trans>Add coupon code</Trans>}
+                                        onChange={code => onApplyCouponCode({ couponCode: code })}
+                                    />
                                 </div>
                             </TableCell>
                         </TableRow>
-                        <OrderTableTotals order={order} columnCount={columns.length} />
+                        {displayTotals && <OrderTableTotals order={order} columnCount={columns.length} />}
                     </TableBody>
                 </Table>
             </div>

+ 33 - 53
packages/dashboard/src/app/routes/_authenticated/_orders/components/fulfillment-details.tsx

@@ -1,12 +1,5 @@
 import { LabeledData } from '@/vdb/components/labeled-data.js';
-import { Button } from '@/vdb/components/ui/button.js';
 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/vdb/components/ui/collapsible.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
 import { api } from '@/vdb/graphql/api.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
@@ -19,6 +12,7 @@ import {
     orderDetailFragment,
     transitionFulfillmentToStateDocument,
 } from '../orders.graphql.js';
+import { getTypeForState, StateTransitionControl } from './state-transition-control.js';
 
 type Order = NonNullable<ResultOf<typeof orderDetailFragment>>;
 
@@ -78,6 +72,30 @@ export function FulfillmentDetails({ order, fulfillment, onSuccess }: Readonly<F
         });
     };
 
+    const getFulfillmentActions = () => {
+        const actions = [];
+
+        const suggested = nextSuggestedState();
+        if (suggested) {
+            actions.push({
+                label: `Transition to ${suggested}`,
+                onClick: () => handleStateTransition(suggested),
+                disabled: transitionFulfillmentMutation.isPending,
+            });
+        }
+
+        nextOtherStates().forEach(state => {
+            actions.push({
+                label: `Transition to ${state}`,
+                type: getTypeForState(state),
+                onClick: () => handleStateTransition(state),
+                disabled: transitionFulfillmentMutation.isPending,
+            });
+        });
+
+        return actions;
+    };
+
     return (
         <div className="space-y-1 p-3 border rounded-md">
             <div className="space-y-1">
@@ -101,7 +119,7 @@ export function FulfillmentDetails({ order, fulfillment, onSuccess }: Readonly<F
                             <ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
                         </CollapsibleTrigger>
                         <CollapsibleContent className="mt-2 space-y-1">
-                            {fulfillment.lines.map((line) => {
+                            {fulfillment.lines.map(line => {
                                 const orderLine = orderLinesMap.get(line.orderLineId);
                                 const productName = orderLine?.productVariant?.name ?? 'Unknown product';
                                 const sku = orderLine?.productVariant?.sku;
@@ -123,51 +141,13 @@ export function FulfillmentDetails({ order, fulfillment, onSuccess }: Readonly<F
                 </div>
             )}
 
-            {fulfillment.nextStates.length > 0 && (
-                <div className="mt-3 pt-3 border-t">
-                    <div className="flex">
-                        <Button
-                            variant="outline"
-                            size="sm"
-                            disabled={transitionFulfillmentMutation.isPending}
-                            className="rounded-r-none flex-1 justify-start shadow-none"
-                        >
-                            <Trans>State: {fulfillment.state}</Trans>
-                        </Button>
-                        <DropdownMenu>
-                            <DropdownMenuTrigger asChild>
-                                <Button
-                                    variant="outline"
-                                    size="sm"
-                                    disabled={transitionFulfillmentMutation.isPending}
-                                    className="rounded-l-none border-l-0 shadow-none"
-                                >
-                                    <ChevronDown className="h-4 w-4" />
-                                </Button>
-                            </DropdownMenuTrigger>
-                            <DropdownMenuContent align="end">
-                                {nextSuggestedState() && (
-                                    <DropdownMenuItem
-                                        onClick={() => handleStateTransition(nextSuggestedState()!)}
-                                        disabled={transitionFulfillmentMutation.isPending}
-                                    >
-                                        <Trans>Transition to {nextSuggestedState()}</Trans>
-                                    </DropdownMenuItem>
-                                )}
-                                {nextOtherStates().map(state => (
-                                    <DropdownMenuItem
-                                        key={state}
-                                        onClick={() => handleStateTransition(state)}
-                                        disabled={transitionFulfillmentMutation.isPending}
-                                    >
-                                        <Trans>Transition to {state}</Trans>
-                                    </DropdownMenuItem>
-                                ))}
-                            </DropdownMenuContent>
-                        </DropdownMenu>
-                    </div>
-                </div>
-            )}
+            <div className="mt-3 pt-3 border-t">
+                <StateTransitionControl
+                    currentState={fulfillment.state}
+                    actions={getFulfillmentActions()}
+                    isLoading={transitionFulfillmentMutation.isPending}
+                />
+            </div>
         </div>
     );
 }

+ 14 - 7
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-address.tsx

@@ -2,13 +2,13 @@ import { Separator } from '@/vdb/components/ui/separator.js';
 import { ResultOf } from 'gql.tada';
 import { Globe, Phone } from 'lucide-react';
 import { orderAddressFragment } from '../orders.graphql.js';
+import { Trans } from '@/vdb/lib/trans.js';
 
-type OrderAddress = ResultOf<typeof orderAddressFragment>;
+type OrderAddress = Omit<ResultOf<typeof orderAddressFragment>, 'country'> & {
+    country: string | { code: string; name: string } | null;
+};
 
 export function OrderAddress({ address }: Readonly<{ address?: OrderAddress }>) {
-    if (!address) {
-        return null;
-    }
 
     const {
         fullName,
@@ -23,6 +23,13 @@ export function OrderAddress({ address }: Readonly<{ address?: OrderAddress }>)
         phoneNumber,
     } = address;
 
+    const countryName = typeof country === 'string' ? country : country?.name;
+    const countryCodeString = country && typeof country !== 'string' ? country?.code : countryCode;
+
+    if (!address || Object.values(address).every(value => !value)) {
+        return <div className="text-sm text-muted-foreground"><Trans>No address</Trans></div>;
+    }
+
     return (
         <div className="space-y-1 text-sm">
             {fullName && <p className="font-medium">{fullName}</p>}
@@ -36,9 +43,9 @@ export function OrderAddress({ address }: Readonly<{ address?: OrderAddress }>)
                 {country && (
                     <div className="flex items-center gap-1.5 mt-1">
                         <Globe className="h-3 w-3 text-muted-foreground" />
-                        <span>{country}</span>
-                        {countryCode && (
-                            <span className="text-xs text-muted-foreground">({countryCode})</span>
+                        <span>{countryName}</span>
+                        {countryCodeString && (
+                            <span className="text-xs text-muted-foreground">({countryCodeString})</span>
                         )}
                     </div>
                 )}

+ 23 - 12
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-line-custom-fields-form.tsx

@@ -2,17 +2,24 @@ import { CustomFieldsForm } from '@/vdb/components/shared/custom-fields-form.js'
 import { Button } from '@/vdb/components/ui/button.js';
 import { Form } from '@/vdb/components/ui/form.js';
 import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { Trans } from '@/vdb/lib/trans.js';
 import { Settings2 } from 'lucide-react';
-import { UseFormReturn } from 'react-hook-form';
+import { useForm } from 'react-hook-form';
 
 interface OrderLineCustomFieldsFormProps {
     onUpdate: (customFieldValues: Record<string, any>) => void;
-    form: UseFormReturn<any>;
+    value: Record<string, any>;
 }
 
-export function OrderLineCustomFieldsForm({ onUpdate, form }: Readonly<OrderLineCustomFieldsFormProps>) {
+export function OrderLineCustomFieldsForm({ onUpdate, value }: Readonly<OrderLineCustomFieldsFormProps>) {
+    const form = useForm<Record<string, any>>({
+        defaultValues: {
+            customFields: value,
+        },
+    });
+
     const onSubmit = (values: any) => {
-        onUpdate(values.input?.customFields);
+        onUpdate(values.customFields);
     };
 
     return (
@@ -24,15 +31,19 @@ export function OrderLineCustomFieldsForm({ onUpdate, form }: Readonly<OrderLine
             </PopoverTrigger>
             <PopoverContent className="w-80">
                 <Form {...form}>
-                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-                        <h4 className="font-medium leading-none">Custom Fields</h4>
-                        <CustomFieldsForm
-                            entityType="OrderLine"
-                            control={form.control}
-                            formPathPrefix="input"
-                        />
+                    <form
+                        onSubmit={e => {
+                            e.stopPropagation();
+                            form.handleSubmit(onSubmit)(e);
+                        }}
+                        className="space-y-4"
+                    >
+                        <h4 className="font-medium leading-none">
+                            <Trans>Custom Fields</Trans>
+                        </h4>
+                        <CustomFieldsForm entityType="OrderLine" control={form.control} />
                         <Button type="submit" className="w-full" disabled={!form.formState.isValid}>
-                            Update
+                            <Trans>Update</Trans>
                         </Button>
                     </form>
                 </Form>

+ 364 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-modification-preview-dialog.tsx

@@ -0,0 +1,364 @@
+import { MoneyInput } from '@/vdb/components/data-input/money-input.js';
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { Alert, AlertDescription, AlertTitle } from '@/vdb/components/ui/alert.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Card, CardContent, CardHeader, CardTitle } from '@/vdb/components/ui/card.js';
+import {
+    Dialog,
+    DialogClose,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { FormControl, FormField } from '@/vdb/components/ui/form.js';
+import { Textarea } from '@/vdb/components/ui/textarea.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import { api } from '@/vdb/graphql/api.js';
+import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { useMutation } from '@tanstack/react-query';
+import { ResultOf, VariablesOf } from 'gql.tada';
+import { CheckIcon } from 'lucide-react';
+import { useEffect, useRef } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { modifyOrderDocument, orderDetailDocument } from '../orders.graphql.js';
+import { OrderTable } from './order-table.js';
+
+export type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+export type ModifyOrderInput = VariablesOf<typeof modifyOrderDocument>['input'];
+
+interface OrderModificationPreviewDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    orderSnapshot: OrderFragment;
+    modifyOrderInput: ModifyOrderInput;
+    /**
+     * The price difference between the order snapshot and the preview order.
+     * If the dialog is cancelled, this will be undefined.
+     */
+    onResolve: (priceDifference?: number) => void;
+}
+
+interface PaymentRefundForm {
+    payments: Record<string, number>;
+    note: string;
+}
+
+export function OrderModificationPreviewDialog({
+    open,
+    onOpenChange,
+    orderSnapshot,
+    modifyOrderInput,
+    onResolve,
+}: Readonly<OrderModificationPreviewDialogProps>) {
+    const { i18n } = useLingui();
+    const { formatCurrency } = useLocalFormat();
+    // Use a ref to track the last input sent to avoid duplicate calls
+    const lastInputRef = useRef<ModifyOrderInput | null>(null);
+    const previewMutation = useMutation({
+        mutationFn: api.mutate(addCustomFields(modifyOrderDocument)),
+    });
+
+    // Create form with dynamic fields for each payment
+    const refundForm = useForm<PaymentRefundForm>({
+        defaultValues: {
+            note: '',
+            payments:
+                orderSnapshot.payments?.reduce(
+                    (acc, payment) => {
+                        acc[payment.id] = 0;
+                        return acc;
+                    },
+                    {} as Record<string, number>,
+                ) || {},
+        },
+    });
+
+    const confirmMutation = useMutation({
+        mutationFn: (input: ModifyOrderInput) => api.mutate(addCustomFields(modifyOrderDocument))({ input }),
+    });
+
+    // Trigger preview when dialog opens or input changes (while open)
+    useEffect(() => {
+        if (open) {
+            // Only trigger if input actually changed
+            if (
+                !lastInputRef.current ||
+                JSON.stringify(lastInputRef.current) !== JSON.stringify(modifyOrderInput)
+            ) {
+                const input = { ...modifyOrderInput, dryRun: true };
+                previewMutation.mutate({ input });
+                lastInputRef.current = modifyOrderInput;
+            }
+        }
+    }, [open, modifyOrderInput]);
+
+    const previewOrder =
+        previewMutation.data?.modifyOrder?.__typename === 'Order' ? previewMutation.data.modifyOrder : null;
+    const error =
+        previewMutation.data && previewMutation.data.modifyOrder?.__typename !== 'Order'
+            ? previewMutation.data.modifyOrder?.message || i18n.t('Unknown error')
+            : previewMutation.error?.message || null;
+    const loading = previewMutation.isPending;
+
+    // Price difference
+    const priceDifference = previewOrder ? previewOrder.totalWithTax - orderSnapshot.totalWithTax : 0;
+    const formattedDiff = previewOrder
+        ? formatCurrency(Math.abs(priceDifference), previewOrder.currencyCode)
+        : '';
+
+    // Calculate total refund amount from form
+    const totalRefundAmount =
+        orderSnapshot.payments?.reduce((total, payment) => {
+            const refundAmount = refundForm.watch(`payments.${payment.id}`) || 0;
+            return total + refundAmount;
+        }, 0) || 0;
+
+    // Check if total refund matches the required amount
+    const isRefundComplete = priceDifference > 0 || totalRefundAmount >= Math.abs(priceDifference);
+    const remainingAmount = Math.abs(priceDifference) - totalRefundAmount;
+
+    // Confirm handler
+    const handleConfirm = async () => {
+        if (!previewOrder) return;
+        const input: ModifyOrderInput = { ...modifyOrderInput, dryRun: false };
+        if (priceDifference < 0) {
+            // Create refunds array from form data
+            const { note, payments } = refundForm.getValues();
+            const refunds = Object.entries(payments)
+                .filter(([_, amount]) => amount > 0)
+                .map(([paymentId, amount]) => ({
+                    paymentId,
+                    amount,
+                    reason: note,
+                }));
+
+            input.refunds = refunds;
+        }
+        await confirmMutation.mutateAsync(input);
+        onResolve(priceDifference);
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="min-w-[80vw] max-h-[90vh] p-8 overflow-hidden flex flex-col">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Preview order modifications</Trans>
+                    </DialogTitle>
+                    <DialogDescription>
+                        <Trans>Review the changes before applying them to the order.</Trans>
+                    </DialogDescription>
+                </DialogHeader>
+                <div className="space-y-4 flex-1 overflow-y-auto">
+                    {loading && (
+                        <div className="text-center py-8">
+                            <Trans>Loading preview…</Trans>
+                        </div>
+                    )}
+                    {error && <div className="text-destructive py-2">{error}</div>}
+                    {previewOrder && !loading && !error && (
+                        <>
+                            <OrderTable order={previewOrder} />
+                            {/* Refund/payment UI using Alert */}
+                            {priceDifference < 0 && (
+                                <>
+                                    <Alert variant="destructive">
+                                        <AlertTitle>
+                                            <Trans>Refund required</Trans>
+                                        </AlertTitle>
+                                        <AlertDescription>
+                                            <Trans>
+                                                A refund of {formattedDiff} is required. Select payment
+                                                amounts and enter a note to proceed.
+                                            </Trans>
+                                        </AlertDescription>
+                                    </Alert>
+                                    <FormProvider {...refundForm}>
+                                        <form className="space-y-4 mt-4">
+                                            {/* Payment Cards */}
+                                            <div className="space-y-3">
+                                                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+                                                    {orderSnapshot.payments?.map(payment => (
+                                                        <Card key={payment.id} className="">
+                                                            <CardHeader className="">
+                                                                <CardTitle className="flex gap-2 items-baseline">
+                                                                    <span className="">{payment.method}</span>
+                                                                    <span className="text-xs text-muted-foreground">
+                                                                        ID: {payment.transactionId}
+                                                                    </span>
+                                                                </CardTitle>
+                                                                <Button
+                                                                    type="button"
+                                                                    size="sm"
+                                                                    variant="outline"
+                                                                    className=""
+                                                                    onClick={() => {
+                                                                        const currentRefundAmount =
+                                                                            refundForm.getValues(
+                                                                                `payments.${payment.id}`,
+                                                                            ) || 0;
+                                                                        const availableAmount =
+                                                                            payment.amount;
+                                                                        const remainingNeeded =
+                                                                            Math.abs(priceDifference) -
+                                                                            totalRefundAmount;
+
+                                                                        // Calculate how much we can still refund from this payment method
+                                                                        const remainingFromThisPayment =
+                                                                            availableAmount -
+                                                                            currentRefundAmount;
+
+                                                                        // Take the minimum of what's needed and what's available
+                                                                        const amountToAdd = Math.min(
+                                                                            remainingFromThisPayment,
+                                                                            remainingNeeded,
+                                                                        );
+
+                                                                        if (amountToAdd > 0) {
+                                                                            refundForm.setValue(
+                                                                                `payments.${payment.id}`,
+                                                                                currentRefundAmount +
+                                                                                    amountToAdd,
+                                                                            );
+                                                                        }
+                                                                    }}
+                                                                >
+                                                                    <Trans>Refund from this method</Trans>
+                                                                </Button>
+                                                            </CardHeader>
+                                                            <CardContent className="pt-0">
+                                                                <div className="flex flex-col gap-3">
+                                                                    <div className="text-sm text-muted-foreground">
+                                                                        <Trans>Available:</Trans>{' '}
+                                                                        {formatCurrency(
+                                                                            payment.amount,
+                                                                            orderSnapshot.currencyCode,
+                                                                        )}
+                                                                    </div>
+                                                                    <div className="w-full">
+                                                                        <FormField
+                                                                            name={`payments.${payment.id}`}
+                                                                            control={refundForm.control}
+                                                                            render={({ field }) => (
+                                                                                <FormControl>
+                                                                                    <MoneyInput
+                                                                                        value={
+                                                                                            field.value || 0
+                                                                                        }
+                                                                                        onChange={
+                                                                                            field.onChange
+                                                                                        }
+                                                                                        currency={
+                                                                                            orderSnapshot.currencyCode
+                                                                                        }
+                                                                                    />
+                                                                                </FormControl>
+                                                                            )}
+                                                                        />
+                                                                    </div>
+                                                                </div>
+                                                            </CardContent>
+                                                        </Card>
+                                                    ))}
+                                                </div>
+                                            </div>
+
+                                            {/* Total Refund Summary */}
+                                            <div className="bg-muted/50 rounded-lg pb-3">
+                                                <div className="flex items-center justify-between p-3">
+                                                    <div className="flex items-center gap-2">
+                                                        <span className="text-sm font-medium">
+                                                            <Trans>Total refund:</Trans>
+                                                        </span>
+                                                        <span className="text-sm">
+                                                            {formatCurrency(
+                                                                totalRefundAmount,
+                                                                orderSnapshot.currencyCode,
+                                                            )}
+                                                        </span>
+                                                        {isRefundComplete && (
+                                                            <CheckIcon className="h-4 w-4 text-green-600" />
+                                                        )}
+                                                    </div>
+                                                    {!isRefundComplete && (
+                                                        <div className="text-sm text-muted-foreground">
+                                                            <Trans>Remaining:</Trans>{' '}
+                                                            {formatCurrency(
+                                                                remainingAmount,
+                                                                orderSnapshot.currencyCode,
+                                                            )}
+                                                        </div>
+                                                    )}
+                                                </div>
+                                                <div className="px-3">
+                                                    <FormFieldWrapper
+                                                        name="note"
+                                                        control={refundForm.control}
+                                                        rules={{ required: true }}
+                                                        render={({ field }) => (
+                                                            <Textarea
+                                                                {...field}
+                                                                className="bg-background"
+                                                                placeholder={i18n.t('Enter refund note')}
+                                                            />
+                                                        )}
+                                                    />
+                                                </div>
+                                            </div>
+                                        </form>
+                                    </FormProvider>
+                                </>
+                            )}
+                            <div className="w-full flex justify-end">
+                                <div className="max-w-l mb-2">
+                                    {priceDifference > 0 && (
+                                        <Alert variant="destructive">
+                                            <AlertTitle>
+                                                <Trans>Additional payment required</Trans>
+                                            </AlertTitle>
+                                            <AlertDescription>
+                                                <Trans>
+                                                    An additional payment of {formattedDiff} will be required.
+                                                </Trans>
+                                            </AlertDescription>
+                                        </Alert>
+                                    )}
+                                    {priceDifference === 0 && (
+                                        <Alert variant="default">
+                                            <AlertTitle>
+                                                <Trans>No payment or refund required</Trans>
+                                            </AlertTitle>
+                                            <AlertDescription>
+                                                <Trans>
+                                                    No payment or refund is required for these changes.
+                                                </Trans>
+                                            </AlertDescription>
+                                        </Alert>
+                                    )}
+                                </div>
+                            </div>
+                        </>
+                    )}
+                </div>
+                <DialogFooter>
+                    <DialogClose asChild>
+                        <Button type="button" variant="secondary" onClick={() => onResolve()}>
+                            <Trans>Cancel</Trans>
+                        </Button>
+                    </DialogClose>
+                    <Button
+                        type="button"
+                        variant="default"
+                        onClick={handleConfirm}
+                        disabled={loading || !!error || confirmMutation.isPending || !isRefundComplete}
+                    >
+                        <Trans>Confirm</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 222 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-modification-summary.tsx

@@ -0,0 +1,222 @@
+import { Trans } from '@/vdb/lib/trans.js';
+import { ResultOf, VariablesOf } from 'gql.tada';
+import { modifyOrderDocument, orderDetailDocument } from '../orders.graphql.js';
+
+type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+type ModifyOrderInput = VariablesOf<typeof modifyOrderDocument>['input'];
+
+type OrderLine = OrderFragment['lines'][number];
+
+interface OrderModificationSummaryProps {
+    originalOrder: OrderFragment;
+    modifyOrderInput: ModifyOrderInput;
+    addedVariants: Map<string, any>; // For displaying added line info
+    eligibleShippingMethods: Array<{
+        id: string;
+        name: string;
+    }>;
+}
+
+interface LineAdjustment {
+    orderLineId: string;
+    name: string;
+    oldQuantity: number;
+    newQuantity: number;
+    oldCustomFields: Record<string, any>;
+    newCustomFields: Record<string, any>;
+}
+
+export function OrderModificationSummary({
+    originalOrder,
+    modifyOrderInput,
+    addedVariants,
+    eligibleShippingMethods,
+}: Readonly<OrderModificationSummaryProps>) {
+    // Map by line id for quick lookup
+    const originalLineMap = new Map(originalOrder.lines.map(line => [line.id, line]));
+
+    // Adjusted lines: in both, but quantity changed
+    const adjustedLines = (modifyOrderInput.adjustOrderLines ?? [])
+        .map((adj: NonNullable<ModifyOrderInput['adjustOrderLines']>[number] & { customFields?: any }) => {
+            const orig = originalLineMap.get(adj.orderLineId);
+            if (
+                orig &&
+                (adj.quantity !== orig.quantity ||
+                    JSON.stringify(adj.customFields) !== JSON.stringify((orig as any).customFields))
+            ) {
+                return {
+                    orderLineId: adj.orderLineId,
+                    name: orig.productVariant.name,
+                    oldQuantity: orig.quantity,
+                    newQuantity: adj.quantity,
+                    oldCustomFields: (orig as any).customFields,
+                    newCustomFields: adj.customFields,
+                };
+            }
+            return null;
+        })
+        .filter(Boolean) as Array<LineAdjustment>;
+
+    // Added lines: from addItems
+    const addedLines = (modifyOrderInput.addItems ?? [])
+        .map(item => {
+            const variantInfo = addedVariants.get(item.productVariantId);
+            return variantInfo
+                ? {
+                      id: `added-${item.productVariantId}`,
+                      name: variantInfo.productVariantName,
+                      quantity: item.quantity,
+                  }
+                : null;
+        })
+        .filter(Boolean) as Array<{ id: string; name: string; quantity: number }>;
+
+    // Removed lines: quantity set to 0 in adjustOrderLines
+    const removedLines = (modifyOrderInput.adjustOrderLines ?? [])
+        .map(adj => {
+            const orig = originalLineMap.get(adj.orderLineId);
+            if (orig && adj.quantity === 0) {
+                return orig;
+            }
+            return null;
+        })
+        .filter(Boolean) as OrderLine[];
+
+    // Coupon code changes
+    const originalCoupons = new Set(originalOrder.couponCodes ?? []);
+    const modifiedCoupons = new Set(modifyOrderInput.couponCodes ?? []);
+    const addedCouponCodes = Array.from(modifiedCoupons).filter(code => !originalCoupons.has(code));
+    const removedCouponCodes = Array.from(originalCoupons).filter(code => !modifiedCoupons.has(code));
+
+    // Shipping method change detection
+    const originalShippingMethodId = originalOrder.shippingLines?.[0]?.shippingMethod?.id;
+    const modifiedShippingMethodId = modifyOrderInput.shippingMethodIds?.[0];
+    let shippingMethodChanged = false;
+    let newShippingMethodName = '';
+    if (modifiedShippingMethodId && modifiedShippingMethodId !== originalShippingMethodId) {
+        shippingMethodChanged = true;
+        newShippingMethodName =
+            eligibleShippingMethods.find(m => m.id === modifiedShippingMethodId)?.name ||
+            modifiedShippingMethodId;
+    }
+
+    return (
+        <div className="text-sm">
+            {/* Address changes */}
+            {modifyOrderInput.updateShippingAddress && (
+                <div className="mb-2">
+                    <span className="font-medium">
+                        <Trans>Shipping address changed</Trans>
+                    </span>
+                </div>
+            )}
+            {modifyOrderInput.updateBillingAddress && (
+                <div className="mb-2">
+                    <span className="font-medium">
+                        <Trans>Billing address changed</Trans>
+                    </span>
+                </div>
+            )}
+            {shippingMethodChanged && (
+                <div className="mb-2">
+                    <span className="font-medium">
+                        <Trans>Shipping method changed</Trans>: {newShippingMethodName}
+                    </span>
+                </div>
+            )}
+            {adjustedLines.length > 0 && (
+                <div className="mb-2">
+                    <div className="font-medium">
+                        <Trans>Adjusting {adjustedLines.length} lines</Trans>
+                    </div>
+                    <ul className="list-disc ml-4">
+                        {adjustedLines.map(line => (
+                            <li key={line.orderLineId} className="">
+                                <div className="flex items-center gap-2">
+                                    {line.name}:{' '}
+                                    {line.oldQuantity !== line.newQuantity && (
+                                        <div>
+                                            <span className="text-muted-foreground">
+                                                {line.oldQuantity} →{' '}
+                                            </span>
+
+                                            {line.newQuantity}
+                                        </div>
+                                    )}
+                                </div>
+                                {JSON.stringify(line.oldCustomFields) !==
+                                    JSON.stringify(line.newCustomFields) && (
+                                    <span className="text-muted-foreground">
+                                        <Trans>Custom fields changed</Trans>
+                                    </span>
+                                )}
+                            </li>
+                        ))}
+                    </ul>
+                </div>
+            )}
+            {addedLines.length > 0 && (
+                <div className="mb-2">
+                    <div className="font-medium">
+                        <Trans>Adding {addedLines.length} item(s)</Trans>
+                    </div>
+                    <ul className="list-disc ml-4">
+                        {addedLines.map(line => (
+                            <li key={line.id}>
+                                {line.name} x {line.quantity}
+                            </li>
+                        ))}
+                    </ul>
+                </div>
+            )}
+            {removedLines.length > 0 && (
+                <div className="mb-2">
+                    <div className="font-medium">
+                        <Trans>Removing {removedLines.length} item(s)</Trans>
+                    </div>
+                    <ul className="list-disc ml-4">
+                        {removedLines.map(line => (
+                            <li key={line.id}>{line.productVariant.name}</li>
+                        ))}
+                    </ul>
+                </div>
+            )}
+            {addedCouponCodes.length > 0 && (
+                <div className="mb-2">
+                    <div className="font-medium">
+                        <Trans>Added coupon codes</Trans>
+                    </div>
+                    <ul className="list-disc ml-4">
+                        {addedCouponCodes.map(code => (
+                            <li key={code}>{code}</li>
+                        ))}
+                    </ul>
+                </div>
+            )}
+            {removedCouponCodes.length > 0 && (
+                <div className="mb-2">
+                    <div className="font-medium">
+                        <Trans>Removed coupon codes</Trans>
+                    </div>
+                    <ul className="list-disc ml-4">
+                        {removedCouponCodes.map(code => (
+                            <li key={code}>{code}</li>
+                        ))}
+                    </ul>
+                </div>
+            )}
+            {adjustedLines.length === 0 &&
+                addedLines.length === 0 &&
+                removedLines.length === 0 &&
+                addedCouponCodes.length === 0 &&
+                removedCouponCodes.length === 0 &&
+                !modifyOrderInput.updateShippingAddress &&
+                !modifyOrderInput.updateBillingAddress &&
+                !shippingMethodChanged && (
+                    <div className="text-muted-foreground">
+                        <Trans>No modifications made</Trans>
+                    </div>
+                )}
+        </div>
+    );
+}

+ 146 - 85
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table.tsx

@@ -1,14 +1,19 @@
+import { Money } from '@/vdb/components/data-display/money.js';
+import { getColumnVisibility } from '@/vdb/components/data-table/data-table-utils.js';
+import { DataTable } from '@/vdb/components/data-table/data-table.js';
+import { useGeneratedColumns } from '@/vdb/components/data-table/use-generated-columns.js';
+import { DetailPageButton } from '@/vdb/components/shared/detail-page-button.js';
 import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/vdb/components/ui/dropdown-menu.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import { getFieldsFromDocumentNode } from '@/vdb/framework/document-introspection/get-document-structure.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
-import {
-    ColumnDef,
-    flexRender,
-    getCoreRowModel,
-    useReactTable,
-    VisibilityState,
-} from '@tanstack/react-table';
-import { useState } from 'react';
+import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { JsonEditor } from 'json-edit-react';
+import { EllipsisVertical } from 'lucide-react';
+import { useMemo } from 'react';
 import { orderDetailDocument, orderLineFragment } from '../orders.graphql.js';
 import { MoneyGrossNet } from './money-gross-net.js';
 import { OrderTableTotals } from './order-table-totals.js';
@@ -18,111 +23,167 @@ type OrderLineFragment = ResultOf<typeof orderLineFragment>;
 
 export interface OrderTableProps {
     order: OrderFragment;
+    pageId: string;
 }
 
-export function OrderTable({ order }: Readonly<OrderTableProps>) {
-    const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
-
-    const currencyCode = order.currencyCode;
-
-    const columns: ColumnDef<OrderLineFragment>[] = [
-        {
-            header: 'Image',
+// Factory function to create customizeColumns with inline components
+function createCustomizeColumns(currencyCode: string) {
+    return {
+        featuredAsset: {
+            header: () => <Trans>Image</Trans>,
             accessorKey: 'featuredAsset',
-            cell: ({ row }) => {
+            cell: ({ row }: { row: any }) => {
                 const asset = row.original.featuredAsset;
                 return <VendureImage asset={asset} preset="tiny" />;
             },
         },
-        {
-            header: 'Product',
-            accessorKey: 'productVariant.name',
-        },
-        {
-            header: 'SKU',
-            accessorKey: 'productVariant.sku',
+        productVariant: {
+            header: () => <Trans>Product</Trans>,
+            cell: ({ row }: { row: any }) => {
+                const productVariant = row.original.productVariant;
+                return (
+                    <DetailPageButton
+                        id={productVariant.id}
+                        label={productVariant.name}
+                        href={`/product-variants/${productVariant.id}`}
+                    />
+                );
+            },
         },
-        {
-            header: 'Unit price',
+        unitPriceWithTax: {
+            header: () => <Trans>Unit price</Trans>,
             accessorKey: 'unitPriceWithTax',
-            cell: ({ cell, row }) => {
+            cell: ({ row }: { row: any }) => {
                 const value = row.original.unitPriceWithTax;
                 const netValue = row.original.unitPrice;
                 return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
             },
         },
-        {
-            header: 'Quantity',
+        fulfillmentLines: {
+            cell: ({ row }: { row: any }) => (
+                <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="ghost" size="icon">
+                            <EllipsisVertical />
+                        </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent>
+                        <JsonEditor data={row.original.fulfillmentLines} viewOnly rootFontSize={12} />
+                    </DropdownMenuContent>
+                </DropdownMenu>
+            ),
+        },
+        quantity: {
+            header: () => <Trans>Quantity</Trans>,
             accessorKey: 'quantity',
         },
-        {
-            header: 'Total',
+        unitPrice: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.unitPrice} />
+            ),
+        },
+        proratedUnitPrice: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.proratedUnitPrice} />
+            ),
+        },
+        proratedUnitPriceWithTax: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.proratedUnitPriceWithTax} />
+            ),
+        },
+        linePrice: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.linePrice} />
+            ),
+        },
+        discountedLinePrice: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.discountedLinePrice} />
+            ),
+        },
+        lineTax: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.lineTax} />
+            ),
+        },
+        linePriceWithTax: {
+            header: () => <Trans>Total</Trans>,
             accessorKey: 'linePriceWithTax',
-            cell: ({ cell, row }) => {
+            cell: ({ row }: { row: any }) => {
                 const value = row.original.linePriceWithTax;
                 const netValue = row.original.linePrice;
                 return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
             },
         },
+        discountedLinePriceWithTax: {
+            cell: ({ row }: { row: any }) => (
+                <Money currencyCode={currencyCode} value={row.original.discountedLinePriceWithTax} />
+            ),
+        },
+    };
+}
+
+export function OrderTable({ order, pageId }: Readonly<OrderTableProps>) {
+    const { setTableSettings, settings } = useUserSettings();
+    const tableSettings = pageId ? settings.tableSettings?.[pageId] : undefined;
+
+    const defaultColumnVisibility = tableSettings?.columnVisibility ?? {
+        featuredAsset: true,
+        productVariant: true,
+        unitPriceWithTax: true,
+        quantity: true,
+        linePriceWithTax: true,
+    };
+    const columnOrder = tableSettings?.columnOrder ?? [
+        'featuredAsset',
+        'productVariant',
+        'unitPriceWithTax',
+        'quantity',
+        'linePriceWithTax',
     ];
+    const currencyCode = order.currencyCode;
 
-    const data = order.lines;
+    const fields = getFieldsFromDocumentNode(addCustomFields(orderDetailDocument), ['order', 'lines']);
 
-    const table = useReactTable({
-        data,
-        columns,
-        getCoreRowModel: getCoreRowModel(),
-        rowCount: data.length,
-        onColumnVisibilityChange: setColumnVisibility,
-        state: {
-            columnVisibility,
-        },
+    const customizeColumns = useMemo(() => createCustomizeColumns(currencyCode), [currencyCode]);
+
+    const { columns, customFieldColumnNames } = useGeneratedColumns({
+        fields,
+        rowActions: [],
+        customizeColumns: customizeColumns as any,
+        deleteMutation: undefined,
+        defaultColumnOrder: columnOrder,
+        additionalColumns: {},
+        includeSelectionColumn: false,
+        includeActionsColumn: false,
+        enableSorting: false,
     });
 
+    const columnVisibility = getColumnVisibility(fields, defaultColumnVisibility, customFieldColumnNames);
+    const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
+    const data = order.lines;
+
     return (
         <div className="w-full">
-            <div className="">
-                <Table>
-                    <TableHeader>
-                        {table.getHeaderGroups().map(headerGroup => (
-                            <TableRow key={headerGroup.id}>
-                                {headerGroup.headers.map(header => {
-                                    return (
-                                        <TableHead key={header.id}>
-                                            {header.isPlaceholder
-                                                ? null
-                                                : flexRender(
-                                                      header.column.columnDef.header,
-                                                      header.getContext(),
-                                                  )}
-                                        </TableHead>
-                                    );
-                                })}
-                            </TableRow>
-                        ))}
-                    </TableHeader>
-                    <TableBody>
-                        {table.getRowModel().rows?.length ? (
-                            table.getRowModel().rows.map(row => (
-                                <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
-                                    {row.getVisibleCells().map(cell => (
-                                        <TableCell key={cell.id}>
-                                            {flexRender(cell.column.columnDef.cell, cell.getContext())}
-                                        </TableCell>
-                                    ))}
-                                </TableRow>
-                            ))
-                        ) : (
-                            <TableRow>
-                                <TableCell colSpan={columns.length} className="h-24 text-center">
-                                    No results.
-                                </TableCell>
-                            </TableRow>
-                        )}
-                        <OrderTableTotals order={order} columnCount={columns.length} />
-                    </TableBody>
-                </Table>
-            </div>
+            <DataTable
+                columns={columns as any}
+                data={data as any}
+                totalItems={data.length}
+                disableViewOptions={false}
+                defaultColumnVisibility={columnVisibility}
+                onColumnVisibilityChange={(_, columnVisibility) => {
+                    setTableSettings(pageId, 'columnVisibility', columnVisibility);
+                }}
+                setTableOptions={options => ({
+                    ...options,
+                    manualPagination: false,
+                    manualSorting: false,
+                    manualFiltering: false,
+                })}
+            >
+                <OrderTableTotals order={order} columnCount={visibleColumnCount} />
+            </DataTable>
         </div>
     );
 }

+ 268 - 31
packages/dashboard/src/app/routes/_authenticated/_orders/components/payment-details.tsx

@@ -1,46 +1,283 @@
 import { LabeledData } from '@/vdb/components/labeled-data.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/vdb/components/ui/collapsible.js';
+import { api } from '@/vdb/graphql/api.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/vdb/components/ui/collapsible.js';
-import { ChevronDown } from 'lucide-react';
-import { Trans } from '@/vdb/lib/trans.js';
-import { paymentWithRefundsFragment } from '../orders.graphql.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { useMutation } from '@tanstack/react-query';
 import { JsonEditor } from 'json-edit-react';
+import { ChevronDown } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import {
+    cancelPaymentDocument,
+    paymentWithRefundsFragment,
+    settlePaymentDocument,
+    settleRefundDocument,
+    transitionPaymentToStateDocument,
+} from '../orders.graphql.js';
+import { SettleRefundDialog } from './settle-refund-dialog.js';
+import { getTypeForState, StateTransitionControl } from './state-transition-control.js';
 
 type PaymentDetailsProps = {
     payment: ResultOf<typeof paymentWithRefundsFragment>;
     currencyCode: string;
+    onSuccess?: () => void;
 };
 
-export function PaymentDetails({ payment, currencyCode }: Readonly<PaymentDetailsProps>) {
+export function PaymentDetails({ payment, currencyCode, onSuccess }: Readonly<PaymentDetailsProps>) {
     const { formatCurrency, formatDate } = useLocalFormat();
-    const t = (key: string) => key;
+    const { i18n } = useLingui();
+    const [settleRefundDialogOpen, setSettleRefundDialogOpen] = useState(false);
+    const [selectedRefundId, setSelectedRefundId] = useState<string | null>(null);
+
+    const settlePaymentMutation = useMutation({
+        mutationFn: api.mutate(settlePaymentDocument),
+        onSuccess: (result: ResultOf<typeof settlePaymentDocument>) => {
+            if (result.settlePayment.__typename === 'Payment') {
+                toast.success(i18n.t('Payment settled successfully'));
+                onSuccess?.();
+            } else {
+                toast.error(result.settlePayment.message ?? i18n.t('Failed to settle payment'));
+            }
+        },
+        onError: () => {
+            toast.error(i18n.t('Failed to settle payment'));
+        },
+    });
+
+    const transitionPaymentMutation = useMutation({
+        mutationFn: api.mutate(transitionPaymentToStateDocument),
+        onSuccess: (result: ResultOf<typeof transitionPaymentToStateDocument>) => {
+            if (result.transitionPaymentToState.__typename === 'Payment') {
+                toast.success(i18n.t('Payment state updated successfully'));
+                onSuccess?.();
+            } else {
+                toast.error(
+                    result.transitionPaymentToState.message ?? i18n.t('Failed to update payment state'),
+                );
+            }
+        },
+        onError: () => {
+            toast.error(i18n.t('Failed to update payment state'));
+        },
+    });
+
+    const cancelPaymentMutation = useMutation({
+        mutationFn: api.mutate(cancelPaymentDocument),
+        onSuccess: (result: ResultOf<typeof cancelPaymentDocument>) => {
+            if (result.cancelPayment.__typename === 'Payment') {
+                toast.success(i18n.t('Payment cancelled successfully'));
+                onSuccess?.();
+            } else {
+                toast.error(result.cancelPayment.message ?? i18n.t('Failed to cancel payment'));
+            }
+        },
+        onError: () => {
+            toast.error(i18n.t('Failed to cancel payment'));
+        },
+    });
+
+    const settleRefundMutation = useMutation({
+        mutationFn: api.mutate(settleRefundDocument),
+        onSuccess: (result: ResultOf<typeof settleRefundDocument>) => {
+            if (result.settleRefund.__typename === 'Refund') {
+                toast.success(i18n.t('Refund settled successfully'));
+                onSuccess?.();
+                setSettleRefundDialogOpen(false);
+            } else {
+                toast.error(result.settleRefund.message ?? i18n.t('Failed to settle refund'));
+            }
+        },
+        onError: () => {
+            toast.error(i18n.t('Failed to settle refund'));
+        },
+    });
+
+    const handlePaymentStateTransition = (state: string) => {
+        if (state === 'Cancelled') {
+            cancelPaymentMutation.mutate({ id: payment.id });
+        } else {
+            transitionPaymentMutation.mutate({ id: payment.id, state });
+        }
+    };
+
+    const handleSettlePayment = () => {
+        settlePaymentMutation.mutate({ id: payment.id });
+    };
+
+    const handleSettleRefund = (refundId: string) => {
+        setSelectedRefundId(refundId);
+        setSettleRefundDialogOpen(true);
+    };
+
+    const handleSettleRefundConfirm = (transactionId: string) => {
+        if (selectedRefundId) {
+            settleRefundMutation.mutate({
+                input: {
+                    id: selectedRefundId,
+                    transactionId,
+                },
+            });
+        }
+    };
+
+    const nextOtherStates = (): string[] => {
+        if (!payment.nextStates) {
+            return [];
+        }
+        return payment.nextStates.filter(s => s !== 'Settled' && s !== 'Error');
+    };
+
+    const getPaymentActions = () => {
+        const actions = [];
+
+        if (payment.nextStates?.includes('Settled')) {
+            actions.push({
+                label: 'Settle payment',
+                onClick: handleSettlePayment,
+                type: 'success',
+                disabled: settlePaymentMutation.isPending,
+            });
+        }
+
+        nextOtherStates().forEach(state => {
+            actions.push({
+                label: state === 'Cancelled' ? 'Cancel payment' : `Transition to ${state}`,
+                type: getTypeForState(state),
+                onClick: () => handlePaymentStateTransition(state),
+                disabled: transitionPaymentMutation.isPending || cancelPaymentMutation.isPending,
+            });
+        });
+
+        return actions;
+    };
 
     return (
-        <div className="space-y-1 p-3 border rounded-md">
-            <LabeledData label={<Trans>Payment method</Trans>} value={payment.method} />
-            <LabeledData label={<Trans>Amount</Trans>} value={formatCurrency(payment.amount, currencyCode)} />
-            <LabeledData label={<Trans>Created at</Trans>} value={formatDate(payment.createdAt)} />
-            {payment.transactionId && (
-                <LabeledData label={<Trans>Transaction ID</Trans>} value={payment.transactionId} />
-            )}
-            {/* We need to check if there is errorMessage field in the Payment type */}
-            {payment.errorMessage && (
-                <LabeledData
-                    label={<Trans>Error message</Trans>}
-                    value={payment.errorMessage}
-                    className="text-destructive"
-                />
-            )}
-            <Collapsible className="mt-2 border-t pt-2">
-                <CollapsibleTrigger className="flex items-center justify-between w-full text-sm hover:underline text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md p-1 -m-1">
-                    <Trans>Payment metadata</Trans>
-                    <ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
-                </CollapsibleTrigger>
-                <CollapsibleContent className="mt-2">
-                    <JsonEditor viewOnly rootFontSize={12} minWidth={100} rootName='' data={payment.metadata} collapse />
-                </CollapsibleContent>
-            </Collapsible>
-        </div>
+        <>
+            <div className="space-y-1 p-3 border rounded-md">
+                <div className="grid lg:grid-cols-2 gap-2">
+                    <LabeledData label={<Trans>Payment method</Trans>} value={payment.method} />
+                    <LabeledData
+                        label={<Trans>Amount</Trans>}
+                        value={formatCurrency(payment.amount, currencyCode)}
+                    />
+                    <LabeledData
+                        label={<Trans>Created at</Trans>}
+                        value={formatDate(payment.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
+                    />
+                    {payment.transactionId && (
+                        <LabeledData label={<Trans>Transaction ID</Trans>} value={payment.transactionId} />
+                    )}
+                    {/* We need to check if there is errorMessage field in the Payment type */}
+                    {payment.errorMessage && (
+                        <LabeledData
+                            label={<Trans>Error message</Trans>}
+                            value={payment.errorMessage}
+                            className="text-destructive"
+                        />
+                    )}
+                </div>
+                <Collapsible className="mt-2 border-t pt-2">
+                    <CollapsibleTrigger className="flex items-center justify-between w-full text-sm hover:underline text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md p-1 -m-1">
+                        <Trans>Payment metadata</Trans>
+                        <ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
+                    </CollapsibleTrigger>
+                    <CollapsibleContent className="mt-2">
+                        <JsonEditor
+                            viewOnly
+                            rootFontSize={12}
+                            minWidth={100}
+                            rootName=""
+                            data={payment.metadata}
+                            collapse
+                        />
+                    </CollapsibleContent>
+                </Collapsible>
+                {payment.refunds && payment.refunds.length > 0 && (
+                    <Collapsible className="mt-2 border-t pt-2">
+                        <CollapsibleTrigger className="flex items-center justify-between w-full text-sm hover:underline text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md p-1 -m-1">
+                            <Trans>Refunds ({payment.refunds.length})</Trans>
+                            <ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
+                        </CollapsibleTrigger>
+                        <CollapsibleContent className="mt-2 space-y-3">
+                            {payment.refunds.map(refund => (
+                                <div key={refund.id} className="p-3 border rounded-md bg-muted/50">
+                                    <div className="space-y-1">
+                                        <LabeledData label={<Trans>Refund ID</Trans>} value={refund.id} />
+                                        <LabeledData label={<Trans>State</Trans>} value={refund.state} />
+                                        <LabeledData
+                                            label={<Trans>Created at</Trans>}
+                                            value={formatDate(refund.createdAt, {
+                                                dateStyle: 'short',
+                                                timeStyle: 'short',
+                                            })}
+                                        />
+                                        <LabeledData
+                                            label={<Trans>Total</Trans>}
+                                            value={formatCurrency(refund.total, currencyCode)}
+                                        />
+                                        {refund.reason && (
+                                            <LabeledData
+                                                label={<Trans>Reason</Trans>}
+                                                value={refund.reason}
+                                            />
+                                        )}
+                                        {refund.transactionId && (
+                                            <LabeledData
+                                                label={<Trans>Transaction ID</Trans>}
+                                                value={refund.transactionId}
+                                            />
+                                        )}
+                                        {refund.metadata && Object.keys(refund.metadata).length > 0 && (
+                                            <div className="mt-2">
+                                                <LabeledData label={<Trans>Metadata</Trans>} value="" />
+                                                <JsonEditor
+                                                    viewOnly
+                                                    rootFontSize={11}
+                                                    minWidth={100}
+                                                    rootName=""
+                                                    data={refund.metadata}
+                                                    collapse
+                                                />
+                                            </div>
+                                        )}
+                                    </div>
+                                    {refund.state === 'Pending' && (
+                                        <div className="mt-3 pt-3 border-t">
+                                            <Button
+                                                size="sm"
+                                                onClick={() => handleSettleRefund(refund.id)}
+                                                disabled={settleRefundMutation.isPending}
+                                            >
+                                                <Trans>Settle refund</Trans>
+                                            </Button>
+                                        </div>
+                                    )}
+                                </div>
+                            ))}
+                        </CollapsibleContent>
+                    </Collapsible>
+                )}
+                <div className="mt-3 pt-3 border-t">
+                    <StateTransitionControl
+                        currentState={payment.state}
+                        actions={getPaymentActions()}
+                        isLoading={
+                            settlePaymentMutation.isPending ||
+                            transitionPaymentMutation.isPending ||
+                            cancelPaymentMutation.isPending
+                        }
+                    />
+                </div>
+            </div>
+            <SettleRefundDialog
+                open={settleRefundDialogOpen}
+                onOpenChange={setSettleRefundDialogOpen}
+                onSettle={handleSettleRefundConfirm}
+                isLoading={settleRefundMutation.isPending}
+            />
+        </>
     );
 }

+ 80 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/settle-refund-dialog.tsx

@@ -0,0 +1,80 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Label } from '@/vdb/components/ui/label.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { useState } from 'react';
+
+type SettleRefundDialogProps = {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    onSettle: (transactionId: string) => void;
+    isLoading?: boolean;
+};
+
+export function SettleRefundDialog({
+    open,
+    onOpenChange,
+    onSettle,
+    isLoading,
+}: Readonly<SettleRefundDialogProps>) {
+    const [transactionId, setTransactionId] = useState('');
+
+    const handleSettle = () => {
+        if (transactionId.trim()) {
+            onSettle(transactionId.trim());
+            setTransactionId('');
+        }
+    };
+
+    const handleOpenChange = (newOpen: boolean) => {
+        if (!newOpen) {
+            setTransactionId('');
+        }
+        onOpenChange(newOpen);
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={handleOpenChange}>
+            <DialogContent>
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Settle refund</Trans>
+                    </DialogTitle>
+                    <DialogDescription>
+                        <Trans>Enter the transaction ID for this refund settlement</Trans>
+                    </DialogDescription>
+                </DialogHeader>
+                <div className="space-y-4">
+                    <div className="space-y-2">
+                        <Label htmlFor="transaction-id">
+                            <Trans>Transaction ID</Trans>
+                        </Label>
+                        <Input
+                            id="transaction-id"
+                            value={transactionId}
+                            onChange={e => setTransactionId(e.target.value)}
+                            placeholder="Enter transaction ID..."
+                            disabled={isLoading}
+                        />
+                    </div>
+                </div>
+                <DialogFooter>
+                    <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isLoading}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button onClick={handleSettle} disabled={!transactionId.trim() || isLoading}>
+                        <Trans>Settle refund</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 102 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/state-transition-control.tsx

@@ -0,0 +1,102 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { cn } from '@/vdb/lib/utils.js';
+import { EllipsisVertical, CircleDashed, CircleCheck, CircleX } from 'lucide-react';
+
+type StateType = 'default' | 'destructive' | 'success';
+
+type StateTransitionAction = {
+    label: string;
+    onClick: () => void;
+    disabled?: boolean;
+    type?: StateType;
+};
+
+type StateTransitionControlProps = {
+    currentState: string;
+    actions: StateTransitionAction[];
+    isLoading?: boolean;
+};
+
+export function getTypeForState(state: string): StateType {
+    const stateLower = state.toLowerCase();
+    switch (stateLower) {
+        case 'cancelled':
+        case 'error':
+            return 'destructive';
+        case 'completed':
+        case 'settled':
+        case 'delivered':
+            return 'success';
+        default:
+            return 'default';
+    }
+}
+
+export function StateTransitionControl({
+    currentState,
+    actions,
+    isLoading,
+}: Readonly<StateTransitionControlProps>) {
+    const currentStateType = getTypeForState(currentState);
+    const iconForType = {
+        destructive: <CircleX className="h-4 w-4 text-destructive" />,
+        success: <CircleCheck className="h-4 w-4 text-success" />,
+        default: <CircleDashed className="h-4 w-4 text-muted-foreground" />,
+    };
+
+    return (
+        <div className="flex min-w-0">
+            <div
+                className={cn(
+                    'inline-flex flex-nowrap items-center justify-start gap-1 h-8 rounded-md px-3 text-xs font-medium border border-input bg-background min-w-0',
+                    actions.length > 0 && 'rounded-r-none',
+                )}
+                title={currentState}
+            >
+                <div className="flex-shrink-0">{iconForType[currentStateType]}</div>
+                <span className="truncate">
+                    {currentState}
+                </span>
+            </div>
+            {actions.length > 0 && (
+                <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                        <Button
+                            variant="outline"
+                            size="sm"
+                            disabled={isLoading}
+                            className={cn(
+                                'rounded-l-none border-l-0 shadow-none',
+                                'bg-background',
+                            )}
+                        >
+                            <EllipsisVertical className="h-4 w-4" />
+                        </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent align="end">
+                        {actions.map((action, index) => {
+                            return (
+                                <DropdownMenuItem
+                                    key={action.label + index}
+                                    onClick={action.onClick}
+                                    variant={action.type === 'destructive' ? 'destructive' : 'default'}
+                                    disabled={action.disabled || isLoading}
+                                >
+                                    {iconForType[action.type ?? 'default']}
+                                    <Trans>{action.label}</Trans>
+                                </DropdownMenuItem>
+                            );
+                        })}
+                    </DropdownMenuContent>
+                </DropdownMenu>
+            )}
+        </div>
+    );
+}

+ 144 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/use-transition-order-to-state.tsx

@@ -0,0 +1,144 @@
+import { api } from '@/vdb/graphql/api.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { orderHistoryDocument, transitionOrderToStateDocument } from '../orders.graphql.js';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/vdb/components/ui/dialog.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
+import { useState } from 'react';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
+import { ResultOf } from 'gql.tada';
+
+/**
+ * Returns the state the order was in before it entered 'Modifying'.
+ * @param orderId The order ID
+ */
+export function useTransitionOrderToState(orderId: string | undefined) {
+    const [selectStateOpen, setSelectStateOpen] = useState(false);
+    const [onSuccessFn, setOnSuccessFn] = useState<() => void>(() => {});
+    const { data, isLoading, error } = useQuery({
+        queryKey: ['orderPreModifyingState', orderId],
+        queryFn: async () => {
+            const result = await api.query(orderHistoryDocument, {
+                id: orderId!,
+                options: {
+                    filter: { type: { eq: 'ORDER_STATE_TRANSITION' } },
+                    sort: { createdAt: 'DESC' },
+                    take: 50, // fetch enough history entries
+                },
+            });
+            const items = result.order?.history?.items ?? [];
+            const modifyingEntry = items.find(i => i.data?.to === 'Modifying');
+            return modifyingEntry ? (modifyingEntry.data?.from as string | undefined) : undefined;
+        },
+        enabled: !!orderId,
+    });
+    const transitionOrderToStateMutation = useMutation({
+        mutationFn: api.mutate(transitionOrderToStateDocument),
+    });
+
+    const transitionToState = async (state: string) => {
+        if (orderId) {
+            const { transitionOrderToState } = await transitionOrderToStateMutation.mutateAsync({
+                id: orderId,
+                state,
+            });
+            if (transitionOrderToState?.__typename === 'OrderStateTransitionError') {
+                return transitionOrderToState.transitionError;
+            }
+        }
+        return undefined;
+    }
+
+    const transitionToPreModifyingState = async () => {
+        if (data && orderId) {
+            return transitionToState(data);
+        } else {
+            return 'Could not find the state the order was in before it entered Modifying';
+        }
+    };
+
+    const ManuallySelectNextState = (props: { availableStates: string[] }) => {
+        const manuallyTransition = useMutation({
+            mutationFn: api.mutate(transitionOrderToStateDocument),
+            onSuccess: (result: ResultOf<typeof transitionOrderToStateDocument>) => {
+                if (result.transitionOrderToState?.__typename === 'OrderStateTransitionError') {
+                    setTransitionError(result.transitionOrderToState.transitionError);
+                } else {
+                    setTransitionError(undefined);
+                    setSelectStateOpen(false);
+                    onSuccessFn?.();
+                }
+            },
+            onError: (error) => {
+                setTransitionError(error.message);
+            },
+        });
+        const [selectedState, setSelectedState] = useState<string | undefined>(undefined);
+        const [transitionError, setTransitionError] = useState<string | undefined>(undefined);
+        if (!orderId) {
+            return null;
+        }
+        const onTransitionClick = () => {
+            if (!selectedState) {
+                return;
+            }
+            manuallyTransition.mutateAsync({
+                id: orderId,
+                state: selectedState,
+            });
+        };
+        return (
+            <Dialog open={selectStateOpen} onOpenChange={setSelectStateOpen}>
+                <DialogContent>
+                    <DialogHeader>
+                        <DialogTitle>
+                            <Trans>Select next state</Trans>
+                        </DialogTitle>
+                    </DialogHeader>
+                    <DialogDescription>
+                        <Trans>Select the next state for the order</Trans>
+                    </DialogDescription>
+                    <Select value={selectedState} onValueChange={setSelectedState}>
+                        <SelectTrigger>
+                            <SelectValue placeholder="Select a state" />
+                        </SelectTrigger>
+                        <SelectContent>
+                            {props.availableStates.map(state => (
+                                <SelectItem key={state} value={state}>
+                                    {state}
+                                </SelectItem>
+                            ))}
+                        </SelectContent>
+                    </Select>
+                    {transitionError && (
+                        <Alert variant="destructive">
+                            <AlertDescription>
+                                <Trans>Error transitioning to state</Trans>: {transitionError}
+                            </AlertDescription>
+                        </Alert>
+                    )}
+                    <DialogFooter>
+                        <Button type="button" disabled={!selectedState} onClick={onTransitionClick}>
+                            <Trans>Transition to selected state</Trans>
+                        </Button>
+                    </DialogFooter>
+                </DialogContent>
+            </Dialog>
+        );
+    };
+    return {
+        isLoading,
+        error,
+        preModifyingState: data,
+        transitionToPreModifyingState,
+        transitionToState,
+        ManuallySelectNextState,
+        selectNextState: ({ onSuccess }: { onSuccess?: () => void }) => {
+            setSelectStateOpen(true);
+            if (onSuccess) {
+                setOnSuccessFn(() => onSuccess);
+            }
+        },
+    };
+}

+ 118 - 2
packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts

@@ -165,7 +165,6 @@ export const orderLineFragment = graphql(
             linePriceWithTax
             discountedLinePrice
             discountedLinePriceWithTax
-            customFields
         }
     `,
     [assetFragment],
@@ -278,6 +277,7 @@ export const orderDetailFragment = graphql(
                     id
                 }
             }
+            customFields
         }
     `,
     [
@@ -294,7 +294,6 @@ export const orderDetailDocument = graphql(
         query GetOrder($id: ID!) {
             order(id: $id) {
                 ...OrderDetail
-                customFields
             }
         }
     `,
@@ -506,6 +505,9 @@ export const transitionOrderToStateDocument = graphql(
                     id
                 }
                 ...ErrorResult
+                ... on OrderStateTransitionError {
+                    transitionError
+                }
             }
         }
     `,
@@ -606,3 +608,117 @@ export const transitionFulfillmentToStateDocument = graphql(
     `,
     [errorResultFragment],
 );
+
+export const couponCodeSelectorPromotionListDocument = graphql(`
+    query CouponCodeSelectorPromotionList($options: PromotionListOptions) {
+        promotions(options: $options) {
+            items {
+                id
+                name
+                couponCode
+            }
+            totalItems
+        }
+    }
+`);
+
+export const modifyOrderDocument = graphql(
+    `
+        mutation ModifyOrder($input: ModifyOrderInput!) {
+            modifyOrder(input: $input) {
+                __typename
+                ...OrderDetail
+                ...ErrorResult
+            }
+        }
+    `,
+    [orderDetailFragment, errorResultFragment],
+);
+
+export const settlePaymentDocument = graphql(
+    `
+        mutation SettlePayment($id: ID!) {
+            settlePayment(id: $id) {
+                __typename
+                ... on Payment {
+                    id
+                    state
+                    amount
+                    method
+                    metadata
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const transitionPaymentToStateDocument = graphql(
+    `
+        mutation TransitionPaymentToState($id: ID!, $state: String!) {
+            transitionPaymentToState(id: $id, state: $state) {
+                __typename
+                ... on Payment {
+                    id
+                    state
+                    amount
+                    method
+                    metadata
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const cancelPaymentDocument = graphql(
+    `
+        mutation CancelPayment($id: ID!) {
+            cancelPayment(id: $id) {
+                __typename
+                ... on Payment {
+                    id
+                    state
+                    amount
+                    method
+                    metadata
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const settleRefundDocument = graphql(
+    `
+        mutation SettleRefund($input: SettleRefundInput!) {
+            settleRefund(input: $input) {
+                __typename
+                ... on Refund {
+                    id
+                    state
+                    total
+                    items
+                    adjustment
+                    reason
+                    transactionId
+                    method
+                    metadata
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const setOrderCustomFieldsDocument = graphql(`
+    mutation SetOrderCustomFields($input: UpdateOrderInput!) {
+        setOrderCustomFields(input: $input) {
+            id
+        }
+    }
+`);

+ 144 - 52
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx

@@ -1,10 +1,10 @@
+import { CustomFieldsForm } from '@/vdb/components/shared/custom-fields-form.js';
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
 import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
-import { Badge } from '@/vdb/components/ui/badge.js';
 import { Button } from '@/vdb/components/ui/button.js';
+import { DropdownMenuItem } from '@/vdb/components/ui/dropdown-menu.js';
 import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
 import {
-    CustomFieldsPageBlock,
     Page,
     PageActionBar,
     PageActionBarRight,
@@ -13,23 +13,32 @@ import {
     PageTitle,
 } from '@/vdb/framework/layout-engine/page-layout.js';
 import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { api } from '@/vdb/graphql/api.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
+import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
-import { Link, createFileRoute, redirect } from '@tanstack/react-router';
-import { User } from 'lucide-react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
+import { Pencil, User } from 'lucide-react';
+import { useMemo } from 'react';
 import { toast } from 'sonner';
 import { AddManualPaymentDialog } from './components/add-manual-payment-dialog.js';
 import { FulfillOrderDialog } from './components/fulfill-order-dialog.js';
 import { FulfillmentDetails } from './components/fulfillment-details.js';
 import { OrderAddress } from './components/order-address.js';
 import { OrderHistoryContainer } from './components/order-history/order-history-container.js';
+import { orderHistoryQueryKey } from './components/order-history/use-order-history.js';
 import { OrderTable } from './components/order-table.js';
 import { OrderTaxSummary } from './components/order-tax-summary.js';
 import { PaymentDetails } from './components/payment-details.js';
-import { orderDetailDocument } from './orders.graphql.js';
+import { getTypeForState, StateTransitionControl } from './components/state-transition-control.js';
+import { useTransitionOrderToState } from './components/use-transition-order-to-state.js';
+import {
+    orderDetailDocument,
+    setOrderCustomFieldsDocument,
+    transitionOrderToStateDocument,
+} from './orders.graphql.js';
 import { canAddFulfillment, shouldShowAddManualPaymentButton } from './utils/order-utils.js';
-import { useQueryClient } from '@tanstack/react-query';
-import { orderHistoryQueryKey } from './components/order-history/use-order-history.js';
 
 const pageId = 'order-detail';
 
@@ -55,6 +64,12 @@ export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
             });
         }
 
+        if (result.order.state === 'Modifying') {
+            throw redirect({
+                to: `/orders/${params.id}/modify`,
+            });
+        }
+
         return {
             breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code],
         };
@@ -65,11 +80,13 @@ export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
 function OrderDetailPage() {
     const params = Route.useParams();
     const { i18n } = useLingui();
+    const navigate = useNavigate();
     const queryClient = useQueryClient();
-    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
+    const { form, submitHandler, entity, refreshEntity } = useDetailPage({
         pageId,
         queryDocument: orderDetailDocument,
-        setValuesForUpdate: entity => {
+        updateDocument: setOrderCustomFieldsDocument,
+        setValuesForUpdate: (entity: any) => {
             return {
                 id: entity.id,
                 customFields: entity.customFields,
@@ -86,19 +103,83 @@ function OrderDetailPage() {
             });
         },
     });
+    const { transitionToState } = useTransitionOrderToState(entity?.id);
+    const transitionOrderToStateMutation = useMutation({
+        mutationFn: api.mutate(transitionOrderToStateDocument),
+    });
+    const customFieldConfig = useCustomFieldConfig('Order');
+    const stateTransitionActions = useMemo(() => {
+        if (!entity) {
+            return [];
+        }
+        return entity.nextStates.map(state => ({
+            label: `Transition to ${state}`,
+            type: getTypeForState(state),
+            onClick: async () => {
+                const transitionError = await transitionToState(state);
+                if (transitionError) {
+                    toast(i18n.t('Failed to transition order to state'), {
+                        description: transitionError,
+                    });
+                } else {
+                    refreshOrderAndHistory();
+                }
+            },
+        }));
+    }, [entity, transitionToState, i18n]);
 
     if (!entity) {
         return null;
     }
 
+    const handleModifyClick = async () => {
+        try {
+            await transitionOrderToStateMutation.mutateAsync({
+                id: entity.id,
+                state: 'Modifying',
+            });
+            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
+            await queryClient.invalidateQueries({ queryKey });
+            await navigate({ to: `/orders/$id/modify`, params: { id: entity.id } });
+        } catch (error) {
+            toast(i18n.t('Failed to modify order'), {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        }
+    };
+
+    const nextStates = entity.nextStates;
     const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
     const showFulfillButton = canAddFulfillment(entity);
 
+    async function refreshOrderAndHistory() {
+        if (entity) {
+            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
+            await queryClient.invalidateQueries({ queryKey });
+            queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
+        }
+    }
+
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
             <PageTitle>{entity?.code ?? ''}</PageTitle>
             <PageActionBar>
-                <PageActionBarRight>
+                <PageActionBarRight
+                    dropdownMenuItems={[
+                        ...(nextStates.includes('Modifying')
+                            ? [
+                                  {
+                                      component: () => (
+                                          <DropdownMenuItem onClick={handleModifyClick}>
+                                              <Pencil className="w-4 h-4" />
+                                              <Trans>Modify</Trans>
+                                          </DropdownMenuItem>
+                                      ),
+                                  },
+                              ]
+                            : []),
+                    ]}
+                >
                     {showAddPaymentButton && (
                         <PermissionGuard requires={['UpdateOrder']}>
                             <AddManualPaymentDialog
@@ -114,35 +195,54 @@ function OrderDetailPage() {
                             <FulfillOrderDialog
                                 order={entity}
                                 onSuccess={() => {
-                                    refreshEntity();
-                                    queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
+                                    refreshOrderAndHistory();
                                 }}
                             />
                         </PermissionGuard>
                     )}
-                    <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
-                        <Button
-                            type="submit"
-                            disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
-                        >
-                            <Trans>Update</Trans>
-                        </Button>
-                    </PermissionGuard>
                 </PageActionBarRight>
             </PageActionBar>
             <PageLayout>
                 <PageBlock column="main" blockId="order-table">
-                    <OrderTable order={entity} />
+                    <OrderTable order={entity} pageId={pageId} />
                 </PageBlock>
                 <PageBlock column="main" blockId="tax-summary" title={<Trans>Tax summary</Trans>}>
                     <OrderTaxSummary order={entity} />
                 </PageBlock>
-                <CustomFieldsPageBlock column="main" entityType="Order" control={form.control} />
+                {customFieldConfig?.length ? (
+                    <PageBlock column="main" blockId="custom-fields">
+                        <CustomFieldsForm entityType="Order" control={form.control} />
+                        <div className="flex justify-end">
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid}
+                            >
+                                Save
+                            </Button>
+                        </div>
+                    </PageBlock>
+                ) : null}
+                <PageBlock column="main" blockId="payment-details" title={<Trans>Payment details</Trans>}>
+                    <div className="grid lg:grid-cols-2 gap-4">
+                        {entity?.payments?.map(payment => (
+                            <PaymentDetails
+                                key={payment.id}
+                                payment={payment}
+                                currencyCode={entity.currencyCode}
+                                onSuccess={() => refreshOrderAndHistory()}
+                            />
+                        ))}
+                    </div>
+                </PageBlock>
                 <PageBlock column="main" blockId="order-history" title={<Trans>Order history</Trans>}>
                     <OrderHistoryContainer orderId={entity.id} />
                 </PageBlock>
-                <PageBlock column="side" blockId="state" title={<Trans>State</Trans>}>
-                    <Badge variant="outline">{entity?.state}</Badge>
+                <PageBlock column="side" blockId="state">
+                    <StateTransitionControl
+                        currentState={entity?.state}
+                        actions={stateTransitionActions}
+                        isLoading={transitionOrderToStateMutation.isPending}
+                    />
                 </PageBlock>
                 <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
                     <Button variant="ghost" asChild>
@@ -170,40 +270,32 @@ function OrderDetailPage() {
                         )}
                     </div>
                 </PageBlock>
-                <PageBlock column="side" blockId="payment-details" title={<Trans>Payment details</Trans>}>
-                    {entity?.payments?.map(payment => (
-                        <PaymentDetails
-                            key={payment.id}
-                            payment={payment}
-                            currencyCode={entity.currencyCode}
-                        />
-                    ))}
-                </PageBlock>
-
                 <PageBlock
                     column="side"
                     blockId="fulfillment-details"
                     title={<Trans>Fulfillment details</Trans>}
                 >
-                    {entity?.fulfillments?.length && entity.fulfillments.length > 0  ? (
+                    {entity?.fulfillments?.length && entity.fulfillments.length > 0 ? (
                         <div className="space-y-2">
-                        {entity?.fulfillments?.map(fulfillment => (
-                        <FulfillmentDetails
-                            key={fulfillment.id}
-                            order={entity}
-                            fulfillment={fulfillment}
-                            onSuccess={() => {
-                                refreshEntity();
-                                queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
-                            }}
-                        />
-                        ))}
-                    </div>
-                ) : (
-                    <div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
-                        <Trans>No fulfillments</Trans>
-                    </div>
-                )}
+                            {entity?.fulfillments?.map(fulfillment => (
+                                <FulfillmentDetails
+                                    key={fulfillment.id}
+                                    order={entity}
+                                    fulfillment={fulfillment}
+                                    onSuccess={() => {
+                                        refreshEntity();
+                                        queryClient.refetchQueries({
+                                            queryKey: orderHistoryQueryKey(entity.id),
+                                        });
+                                    }}
+                                />
+                            ))}
+                        </div>
+                    ) : (
+                        <div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
+                            <Trans>No fulfillments</Trans>
+                        </div>
+                    )}
                 </PageBlock>
             </PageLayout>
         </Page>

+ 550 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx

@@ -0,0 +1,550 @@
+import { ErrorPage } from '@/vdb/components/shared/error-page.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import {
+    Page,
+    PageActionBar,
+    PageActionBarRight,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/vdb/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
+import { ResultOf, VariablesOf } from 'gql.tada';
+import { User } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { toast } from 'sonner';
+import { CustomerAddressSelector } from './components/customer-address-selector.js';
+import { EditOrderTable } from './components/edit-order-table.js';
+import { OrderAddress } from './components/order-address.js';
+import { OrderModificationPreviewDialog } from './components/order-modification-preview-dialog.js';
+import { OrderModificationSummary } from './components/order-modification-summary.js';
+import { useTransitionOrderToState } from './components/use-transition-order-to-state.js';
+import {
+    draftOrderEligibleShippingMethodsDocument,
+    modifyOrderDocument,
+    orderDetailDocument,
+} from './orders.graphql.js';
+import { AddressFragment, Order } from './utils/order-types.js';
+
+const pageId = 'order-modify';
+type ModifyOrderInput = VariablesOf<typeof modifyOrderDocument>['input'];
+
+export const Route = createFileRoute('/_authenticated/_orders/orders_/$id_/modify')({
+    component: ModifyOrderPage,
+    loader: async ({ context, params }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+
+        const result: ResultOf<typeof orderDetailDocument> = await context.queryClient.ensureQueryData(
+            getDetailQueryOptions(addCustomFields(orderDetailDocument), { id: params.id }),
+            { id: params.id },
+        );
+
+        if (!result.order) {
+            throw new Error(`Order with the ID ${params.id} was not found`);
+        }
+
+        if (result.order.state === 'Draft') {
+            throw redirect({
+                to: `/orders/draft/${params.id}`,
+            });
+        }
+        if (result.order.state !== 'Modifying') {
+            throw redirect({
+                to: `/orders/${params.id}`,
+            });
+        }
+
+        return {
+            breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code, { label: 'Modify' }],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+// --- AddedLine type for added items ---
+interface AddedLine {
+    id: string;
+    featuredAsset?: any;
+    productVariant: {
+        id: string;
+        name: string;
+        sku: string;
+    };
+    unitPrice: number;
+    unitPriceWithTax: number;
+    quantity: number;
+    linePrice: number;
+    linePriceWithTax: number;
+}
+
+// --- ProductVariantInfo type ---
+type ProductVariantInfo = {
+    productVariantId: string;
+    productVariantName: string;
+    sku: string;
+    productAsset: {
+        preview: string;
+    };
+    price?: number;
+    priceWithTax?: number;
+};
+
+function ModifyOrderPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate({ from: '/orders/$id/modify' });
+    const { i18n } = useLingui();
+    const queryClient = useQueryClient();
+    const { form, submitHandler, entity } = useDetailPage({
+        pageId,
+        queryDocument: orderDetailDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async () => {
+            toast(i18n.t('Successfully updated order'));
+            form.reset(form.getValues());
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update order'), {
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const { data: eligibleShippingMethods } = useQuery({
+        queryKey: ['eligibleShippingMethods', entity?.id],
+        queryFn: () => api.query(draftOrderEligibleShippingMethodsDocument, { orderId: entity?.id ?? '' }),
+        enabled: !!entity?.shippingAddress?.streetLine1,
+    });
+
+    const { transitionToPreModifyingState, ManuallySelectNextState, selectNextState, transitionToState } =
+        useTransitionOrderToState(entity?.id ?? '');
+
+    // --- Modification intent state ---
+
+    const [modifyOrderInput, setModifyOrderInput] = useState<ModifyOrderInput>({
+        orderId: '',
+        addItems: [],
+        adjustOrderLines: [],
+        surcharges: [],
+        note: '',
+        couponCodes: [],
+        options: {
+            recalculateShipping: true,
+        },
+        dryRun: true,
+    } satisfies ModifyOrderInput);
+
+    useEffect(() => {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            orderId: entity?.id ?? '',
+            couponCodes: entity?.couponCodes ?? [],
+        }));
+    }, [entity?.id]);
+
+    // --- Added variants info state ---
+    const [addedVariants, setAddedVariants] = useState<Map<string, ProductVariantInfo>>(new Map());
+
+    // --- Handlers update modifyOrderInput ---
+    function handleAddItem(variant: ProductVariantInfo) {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            addItems: [...(prev.addItems ?? []), { productVariantId: variant.productVariantId, quantity: 1 }],
+        }));
+        setAddedVariants(prev => {
+            const newMap = new Map(prev);
+            newMap.set(variant.productVariantId, variant);
+            return newMap;
+        });
+    }
+
+    function handleAdjustLine({
+        lineId,
+        quantity,
+        customFields,
+    }: {
+        lineId: string;
+        quantity: number;
+        customFields: Record<string, any>;
+    }) {
+        // Check if this is an added line
+        if (lineId.startsWith('added-')) {
+            const productVariantId = lineId.replace('added-', '');
+            setModifyOrderInput(prev => ({
+                ...prev,
+                addItems: (prev.addItems ?? []).map(item =>
+                    item.productVariantId === productVariantId ? { ...item, quantity } : item,
+                ),
+            }));
+        } else {
+            let normalizedCustomFields: any = customFields;
+            delete normalizedCustomFields.__entityId__;
+            if (Object.keys(normalizedCustomFields).length === 0) {
+                normalizedCustomFields = undefined;
+            }
+            setModifyOrderInput(prev => {
+                const existing = (prev.adjustOrderLines ?? []).find(l => l.orderLineId === lineId);
+                const adjustOrderLines = existing
+                    ? (prev.adjustOrderLines ?? []).map(l =>
+                          l.orderLineId === lineId
+                              ? { ...l, quantity, customFields: normalizedCustomFields }
+                              : l,
+                      )
+                    : [
+                          ...(prev.adjustOrderLines ?? []),
+                          { orderLineId: lineId, quantity, customFields: normalizedCustomFields },
+                      ];
+                return { ...prev, adjustOrderLines };
+            });
+        }
+    }
+
+    function handleRemoveLine({ lineId }: { lineId: string }) {
+        if (lineId.startsWith('added-')) {
+            const productVariantId = lineId.replace('added-', '');
+            setModifyOrderInput(prev => ({
+                ...prev,
+                addItems: (prev.addItems ?? []).filter(item => item.productVariantId !== productVariantId),
+            }));
+            setAddedVariants(prev => {
+                const newMap = new Map(prev);
+                newMap.delete(productVariantId);
+                return newMap;
+            });
+        } else {
+            setModifyOrderInput(prev => {
+                const existingAdjustment = (prev.adjustOrderLines ?? []).find(l => l.orderLineId === lineId);
+                const adjustOrderLines = existingAdjustment
+                    ? (prev.adjustOrderLines ?? []).map(l =>
+                          l.orderLineId === lineId ? { ...l, quantity: 0 } : l,
+                      )
+                    : [...(prev.adjustOrderLines ?? []), { orderLineId: lineId, quantity: 0 }];
+                return {
+                    ...prev,
+                    adjustOrderLines,
+                };
+            });
+        }
+    }
+
+    function handleSetShippingMethod({ shippingMethodId }: { shippingMethodId: string }) {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            shippingMethodIds: [shippingMethodId],
+        }));
+    }
+
+    function handleApplyCouponCode({ couponCode }: { couponCode: string }) {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            couponCodes: prev.couponCodes?.includes(couponCode)
+                ? prev.couponCodes
+                : [...(prev.couponCodes ?? []), couponCode],
+        }));
+    }
+
+    function handleRemoveCouponCode({ couponCode }: { couponCode: string }) {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            couponCodes: (prev.couponCodes ?? []).filter(code => code !== couponCode),
+        }));
+    }
+
+    // --- Address editing state ---
+    const [editingShippingAddress, setEditingShippingAddress] = useState(false);
+    const [editingBillingAddress, setEditingBillingAddress] = useState(false);
+
+    function orderAddressToModifyOrderInput(
+        address: AddressFragment,
+    ): ModifyOrderInput['updateShippingAddress'] {
+        return {
+            streetLine1: address.streetLine1,
+            streetLine2: address.streetLine2,
+            city: address.city,
+            countryCode: address.country.code,
+            fullName: address.fullName,
+            postalCode: address.postalCode,
+            province: address.province,
+            company: address.company,
+            phoneNumber: address.phoneNumber,
+        };
+    }
+
+    // --- Address selection handlers ---
+    function handleSelectShippingAddress(address: AddressFragment) {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            updateShippingAddress: orderAddressToModifyOrderInput(address),
+        }));
+        setEditingShippingAddress(false);
+    }
+
+    function handleSelectBillingAddress(address: AddressFragment) {
+        setModifyOrderInput(prev => ({
+            ...prev,
+            updateBillingAddress: orderAddressToModifyOrderInput(address),
+        }));
+        setEditingBillingAddress(false);
+    }
+
+    // --- Utility: compute pending order for display ---
+    function computePendingOrder(input: ModifyOrderInput): Order | null {
+        if (!entity) {
+            return null;
+        }
+        // Adjust lines
+        const lines = entity.lines.map(line => {
+            const adjust = input.adjustOrderLines?.find(l => l.orderLineId === line.id);
+            return adjust
+                ? { ...line, quantity: adjust.quantity, customFields: (adjust as any).customFields }
+                : line;
+        });
+        // Add new items (as AddedLine)
+        const addedLines = input.addItems
+            ?.map(item => {
+                const variantInfo = addedVariants.get(item.productVariantId);
+                return variantInfo
+                    ? ({
+                          id: `added-${item.productVariantId}`,
+                          featuredAsset: variantInfo.productAsset ?? null,
+                          productVariant: {
+                              id: variantInfo.productVariantId,
+                              name: variantInfo.productVariantName,
+                              sku: variantInfo.sku,
+                          },
+                          unitPrice: variantInfo.price ?? 0,
+                          unitPriceWithTax: variantInfo.priceWithTax ?? 0,
+                          quantity: item.quantity,
+                          linePrice: (variantInfo.price ?? 0) * item.quantity,
+                          linePriceWithTax: (variantInfo.priceWithTax ?? 0) * item.quantity,
+                      } as unknown as Order['lines'][number])
+                    : null;
+            })
+            .filter(x => x != null);
+        return {
+            ...entity,
+            lines: [...lines, ...(addedLines ?? [])],
+            couponCodes: input.couponCodes ?? [],
+            shippingLines: input.shippingMethodIds
+                ? input.shippingMethodIds
+                      .map(shippingMethodId => {
+                          const shippingMethod =
+                              eligibleShippingMethods?.eligibleShippingMethodsForDraftOrder.find(
+                                  method => method.id === shippingMethodId,
+                              );
+                          if (!shippingMethod) {
+                              return;
+                          }
+                          return {
+                              shippingMethod: {
+                                  ...shippingMethod,
+                                  fulfillmentHandlerCode: 'manual',
+                              },
+                              discountedPriceWithTax: shippingMethod?.priceWithTax ?? 0,
+                              id: shippingMethodId,
+                          };
+                      })
+                      .filter(x => x !== undefined)
+                : entity.shippingLines,
+        };
+    }
+
+    const [previewOpen, setPreviewOpen] = useState(false);
+
+    if (!entity) {
+        return null;
+    }
+
+    const pendingOrder = computePendingOrder(modifyOrderInput);
+    const hasModifications =
+        (modifyOrderInput.addItems?.length ?? 0) > 0 ||
+        (modifyOrderInput.adjustOrderLines?.length ?? 0) > 0 ||
+        (modifyOrderInput.couponCodes?.length ?? 0) > 0 ||
+        (modifyOrderInput.shippingMethodIds?.length ?? 0) > 0 ||
+        modifyOrderInput.updateShippingAddress ||
+        modifyOrderInput.updateBillingAddress;
+
+    if (!pendingOrder) {
+        return null;
+    }
+
+    // On successful state transition, invalidate the order detail query and navigate to the order detail page
+    const onSuccess = () => {
+        const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
+        queryClient.invalidateQueries({ queryKey });
+        navigate({ to: `/orders/$id`, params: { id: entity?.id } });
+    };
+
+    const handleCancelModificationClick = async () => {
+        const transitionError = await transitionToPreModifyingState();
+        if (!transitionError) {
+            onSuccess();
+        } else {
+            selectNextState({ onSuccess });
+        }
+    };
+
+    const handleModificationSubmit = async (priceDifference?: number) => {
+        const transitionError =
+            priceDifference && priceDifference > 0
+                ? await transitionToState('ArrangingAdditionalPayment')
+                : await transitionToPreModifyingState();
+        if (!transitionError) {
+            const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
+            await queryClient.invalidateQueries({ queryKey });
+            setPreviewOpen(false);
+            await navigate({ to: `/orders/$id`, params: { id: entity?.id } });
+        } else {
+            selectNextState({ onSuccess });
+        }
+    };
+
+    const shippingAddress = modifyOrderInput.updateShippingAddress ?? entity.shippingAddress;
+    const billingAddress = modifyOrderInput.updateBillingAddress ?? entity.billingAddress;
+
+    return (
+        <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
+            <PageTitle>
+                <Trans>Modify order</Trans>
+            </PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <Button type="button" variant="secondary" onClick={handleCancelModificationClick}>
+                        <Trans>Cancel modification</Trans>
+                    </Button>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="main" blockId="order-lines" title={<Trans>Order lines</Trans>}>
+                    <EditOrderTable
+                        order={pendingOrder}
+                        eligibleShippingMethods={
+                            eligibleShippingMethods?.eligibleShippingMethodsForDraftOrder ?? []
+                        }
+                        onAddItem={handleAddItem}
+                        onAdjustLine={handleAdjustLine}
+                        onRemoveLine={handleRemoveLine}
+                        onSetShippingMethod={handleSetShippingMethod}
+                        onApplyCouponCode={handleApplyCouponCode}
+                        onRemoveCouponCode={handleRemoveCouponCode}
+                        displayTotals={false}
+                    />
+                </PageBlock>
+                <PageBlock
+                    column="side"
+                    blockId="modification-summary"
+                    title={<Trans>Summary of modifications</Trans>}
+                >
+                    <OrderModificationSummary
+                        originalOrder={entity}
+                        modifyOrderInput={modifyOrderInput}
+                        addedVariants={addedVariants}
+                        eligibleShippingMethods={
+                            eligibleShippingMethods?.eligibleShippingMethodsForDraftOrder?.map(m => ({
+                                id: m.id,
+                                name: m.name,
+                            })) ?? []
+                        }
+                    />
+                    <div className="mt-4 flex justify-end">
+                        <Button
+                            type="button"
+                            onClick={() => setPreviewOpen(true)}
+                            disabled={!hasModifications}
+                        >
+                            <Trans>Preview changes</Trans>
+                        </Button>
+                    </div>
+                    <OrderModificationPreviewDialog
+                        open={previewOpen}
+                        onOpenChange={setPreviewOpen}
+                        orderSnapshot={entity}
+                        modifyOrderInput={modifyOrderInput}
+                        onResolve={handleModificationSubmit}
+                    />
+                </PageBlock>
+                <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
+                    {entity.customer ? (
+                        <Button variant="ghost" asChild>
+                            <Link to={`/customers/${entity?.customer?.id}`}>
+                                <User className="w-4 h-4" />
+                                {entity?.customer?.firstName} {entity?.customer?.lastName}
+                            </Link>
+                        </Button>
+                    ) : (
+                        <div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
+                            <Trans>No customer</Trans>
+                        </div>
+                    )}
+                </PageBlock>
+                <PageBlock column="side" blockId="addresses" title={<Trans>Addresses</Trans>}>
+                    <div className="mb-4">
+                        <div className="mb-1">
+                            <Trans>Shipping address</Trans>:
+                            <Button
+                                variant="ghost"
+                                size="sm"
+                                className="ml-2"
+                                onClick={() => setEditingShippingAddress(true)}
+                            >
+                                <Trans>Edit</Trans>
+                            </Button>
+                        </div>
+                        {editingShippingAddress ? (
+                            <CustomerAddressSelector
+                                customerId={entity.customer?.id}
+                                onSelect={handleSelectShippingAddress}
+                            />
+                        ) : null}
+                        {shippingAddress && !editingShippingAddress ? (
+                            <OrderAddress address={shippingAddress} />
+                        ) : (
+                            <div className="text-muted-foreground text-xs font-medium">
+                                <Trans>No shipping address</Trans>
+                            </div>
+                        )}
+                    </div>
+                    <div>
+                        <div className="mb-1">
+                            <Trans>Billing address</Trans>:
+                            <Button
+                                variant="ghost"
+                                size="sm"
+                                className="ml-2"
+                                onClick={() => setEditingBillingAddress(true)}
+                            >
+                                <Trans>Edit</Trans>
+                            </Button>
+                        </div>
+                        {editingBillingAddress ? (
+                            <CustomerAddressSelector
+                                customerId={entity.customer?.id}
+                                onSelect={handleSelectBillingAddress}
+                            />
+                        ) : null}
+                        {billingAddress && !editingBillingAddress ? (
+                            <OrderAddress address={billingAddress} />
+                        ) : (
+                            <div className="text-muted-foreground text-xs font-medium">
+                                <Trans>No billing address</Trans>
+                            </div>
+                        )}
+                    </div>
+                </PageBlock>
+            </PageLayout>
+            <ManuallySelectNextState availableStates={entity.nextStates} />
+        </Page>
+    );
+}

+ 0 - 17
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx

@@ -90,22 +90,6 @@ function DraftOrderPage() {
         params: { id: params.id },
     });
 
-    const { form: orderLineForm } = useGeneratedForm({
-        document: addCustomFields(adjustDraftOrderLineDocument),
-        varName: undefined,
-        entity: entity?.lines[0],
-        setValues: entity => {
-            return {
-                orderId: entity.id,
-                input: {
-                    quantity: entity.quantity,
-                    orderLineId: entity.id,
-                    customFields: entity.customFields,
-                },
-            };
-        },
-    });
-
     const { form: orderCustomFieldsForm } = useGeneratedForm({
         document: setDraftOrderCustomFieldsDocument,
         varName: undefined,
@@ -396,7 +380,6 @@ function DraftOrderPage() {
                                 couponCode: e.couponCode,
                             })
                         }
-                        orderLineForm={orderLineForm}
                     />
                 </PageBlock>
                 <PageBlock column="main" blockId="order-custom-fields" title={<Trans>Custom fields</Trans>}>

+ 5 - 2
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-types.ts

@@ -1,7 +1,10 @@
-import { ResultOf } from '@/vdb/graphql/graphql.js';
+import { FragmentOf, ResultOf } from '@/vdb/graphql/graphql.js';
 
-import { orderDetailDocument } from '../orders.graphql.js';
+import { addressFragment } from '../../_customers/customers.graphql.js';
+import { orderAddressFragment, orderDetailDocument } from '../orders.graphql.js';
 
 export type Order = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
 export type Payment = NonNullable<NonNullable<Order>['payments']>[number];
 export type Fulfillment = NonNullable<NonNullable<Order>['fulfillments']>[number];
+export type OrderAddressFragment = FragmentOf<typeof orderAddressFragment>;
+export type AddressFragment = FragmentOf<typeof addressFragment>;

+ 4 - 3
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-utils.ts

@@ -64,9 +64,10 @@ export function canAddFulfillment(order: Order): boolean {
 
     // Check if order is in a fulfillable state
     const isFulfillableState =
-        order.nextStates.includes('Shipped') ||
-        order.nextStates.includes('PartiallyShipped') ||
-        order.nextStates.includes('Delivered');
+        (order.nextStates.includes('Shipped') ||
+            order.nextStates.includes('PartiallyShipped') ||
+            order.nextStates.includes('Delivered')) &&
+        order.state !== 'ArrangingAdditionalPayment';
 
     return (
         !allItemsFulfilled &&

+ 0 - 1
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx

@@ -158,7 +158,6 @@ function ProductDetailPage() {
                             <AddProductVariantDialog
                                 productId={params.id}
                                 onSuccess={() => {
-                                    console.log('onSuccess');
                                     refreshRef.current?.();
                                 }}
                             />

+ 7 - 1
packages/dashboard/src/lib/components/data-display/date-time.tsx

@@ -3,11 +3,17 @@ import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 export function DateTime({ value }: Readonly<{ value: string | Date }>) {
     const { formatDate } = useLocalFormat();
     let renderedDate: string;
+    let renderedTime: string;
     try {
         renderedDate = formatDate(value);
+        renderedTime = formatDate(value, { timeStyle: 'long' });
     } catch (e) {
         renderedDate = value.toString();
+        renderedTime = '';
         console.error(e);
     }
-    return renderedDate;
+    return <div className="flex flex-col">
+        <div className="text-sm">{renderedDate}</div>
+        <div className="text-xs text-muted-foreground">{renderedTime}</div>
+    </div>;
 }

+ 11 - 0
packages/dashboard/src/lib/components/data-input/relation-input.tsx

@@ -10,6 +10,12 @@ export interface SingleRelationInputProps<T = any> {
     config: Parameters<typeof createRelationSelectorConfig<T>>[0];
     disabled?: boolean;
     className?: string;
+    /**
+     * @description
+     * Custom text for the selector label,
+     * defaults to `Select item` or `Select items`
+     */
+    selectorLabel?: React.ReactNode;
 }
 
 export function SingleRelationInput<T>({
@@ -18,6 +24,7 @@ export function SingleRelationInput<T>({
     config,
     disabled,
     className,
+    selectorLabel,
 }: Readonly<SingleRelationInputProps<T>>) {
     const singleConfig = createRelationSelectorConfig<T>({
         ...config,
@@ -28,6 +35,7 @@ export function SingleRelationInput<T>({
         <RelationSelector
             config={singleConfig}
             value={value}
+            selectorLabel={selectorLabel}
             onChange={newValue => onChange(newValue as string)}
             disabled={disabled}
             className={className}
@@ -44,6 +52,7 @@ export interface MultiRelationInputProps<T = any> {
     config: Parameters<typeof createRelationSelectorConfig<T>>[0];
     disabled?: boolean;
     className?: string;
+    selectorLabel?: React.ReactNode;
 }
 
 export function MultiRelationInput<T>({
@@ -52,6 +61,7 @@ export function MultiRelationInput<T>({
     config,
     disabled,
     className,
+    selectorLabel,
 }: Readonly<MultiRelationInputProps<T>>) {
     const multiConfig = createRelationSelectorConfig<T>({
         ...config,
@@ -65,6 +75,7 @@ export function MultiRelationInput<T>({
             onChange={newValue => onChange(newValue as string[])}
             disabled={disabled}
             className={className}
+            selectorLabel={selectorLabel}
         />
     );
 }

+ 9 - 2
packages/dashboard/src/lib/components/data-input/relation-selector.tsx

@@ -40,6 +40,12 @@ export interface RelationSelectorConfig<T = any> {
 
 export interface RelationSelectorProps<T = any> {
     config: RelationSelectorConfig<T>;
+    /**
+     * @description
+     * The label for the selector trigger. Default is
+     * "Select item" for single select and "Select items" for multi select.
+     */
+    selectorLabel?: React.ReactNode;
     value?: string | string[];
     onChange: (value: string | string[]) => void;
     disabled?: boolean;
@@ -186,6 +192,7 @@ export function RelationSelector<T>({
     onChange,
     disabled,
     className,
+    selectorLabel,
 }: Readonly<RelationSelectorProps<T>>) {
     const [open, setOpen] = useState(false);
     const [selectedItemsCache, setSelectedItemsCache] = useState<T[]>([]);
@@ -396,10 +403,10 @@ export function RelationSelector<T>({
                             {isMultiple
                                 ? selectedItems.length > 0
                                     ? `Add more (${selectedItems.length} selected)`
-                                    : 'Select items'
+                                    : selectorLabel ?? <Trans>Select items</Trans>
                                 : selectedItems.length > 0
                                   ? 'Change selection'
-                                  : 'Select item'}
+                                  : selectorLabel ?? <Trans>Select item</Trans>}
                         </Trans>
                     </Button>
                 </PopoverTrigger>

+ 34 - 0
packages/dashboard/src/lib/components/data-table/data-table-utils.ts

@@ -0,0 +1,34 @@
+import { FieldInfo } from '@/vdb/framework/document-introspection/get-document-structure.js';
+
+/**
+ * Returns the default column visibility configuration.
+ *
+ * @example
+ * ```ts
+ * const columnVisibility = getColumnVisibility(fields, {
+ *     id: false,
+ *     createdAt: false,
+ *     updatedAt: false,
+ * });
+ * ```
+ */
+export function getColumnVisibility(
+    fields: FieldInfo[],
+    defaultVisibility?: Record<string, boolean | undefined>,
+    customFieldColumnNames?: string[],
+): Record<string, boolean> {
+    const allDefaultsTrue = defaultVisibility && Object.values(defaultVisibility).every(v => v === true);
+    const allDefaultsFalse = defaultVisibility && Object.values(defaultVisibility).every(v => v === false);
+    return {
+        id: false,
+        createdAt: false,
+        updatedAt: false,
+        ...(allDefaultsTrue ? { ...Object.fromEntries(fields.map(f => [f.name, false])) } : {}),
+        ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
+        // Make custom fields hidden by default unless overridden
+        ...(customFieldColumnNames
+            ? { ...Object.fromEntries(customFieldColumnNames.map(f => [f, false])) }
+            : {}),
+        ...defaultVisibility,
+    };
+}

+ 2 - 2
packages/dashboard/src/lib/components/data-table/data-table-view-options.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { DndContext, closestCenter } from '@dnd-kit/core';
+import { closestCenter, DndContext } from '@dnd-kit/core';
 import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
 import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
 import { CSS } from '@dnd-kit/utilities';
@@ -83,7 +83,7 @@ export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps
                         <Trans>Columns</Trans>
                     </Button>
                 </DropdownMenuTrigger>
-                <DropdownMenuContent align="end" className="w-[150px]">
+                <DropdownMenuContent align="end">
                     <DndContext
                         collisionDetection={closestCenter}
                         onDragEnd={handleDragEnd}

+ 5 - 2
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -36,6 +36,7 @@ export interface FacetedFilter {
 }
 
 interface DataTableProps<TData> {
+    children?: React.ReactNode;
     columns: ColumnDef<TData, any>[];
     data: TData[];
     totalItems: number;
@@ -62,6 +63,7 @@ interface DataTableProps<TData> {
 }
 
 export function DataTable<TData>({
+    children,
     columns,
     data,
     totalItems,
@@ -178,7 +180,7 @@ export function DataTable<TData>({
                                 />
                             ))}
                         </Suspense>
-                        <AddFilterMenu columns={table.getAllColumns()} />
+                        {onFilterChange && <AddFilterMenu columns={table.getAllColumns()} />}
                     </div>
                     <div className="flex gap-1">
                         {columnFilters
@@ -266,11 +268,12 @@ export function DataTable<TData>({
                                 </TableCell>
                             </TableRow>
                         )}
+                        {children}
                     </TableBody>
                 </Table>
                 <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
             </div>
-            <DataTablePagination table={table} />
+            {onPageChange && totalItems != null && <DataTablePagination table={table} />}
         </>
     );
 }

+ 307 - 0
packages/dashboard/src/lib/components/data-table/use-generated-columns.tsx

@@ -0,0 +1,307 @@
+import { DisplayComponent } from '@/vdb/framework/component-registry/dynamic-component.js';
+import { FieldInfo, getTypeFieldInfo } from '@/vdb/framework/document-introspection/get-document-structure.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { useMutation } from '@tanstack/react-query';
+import { AccessorKeyColumnDef, createColumnHelper, Row } from '@tanstack/react-table';
+import { EllipsisIcon, TrashIcon } from 'lucide-react';
+import { useMemo } from 'react';
+import { toast } from 'sonner';
+import {
+    AdditionalColumns,
+    AllItemFieldKeys,
+    CustomizeColumnConfig,
+    FacetedFilterConfig,
+    PaginatedListItemFields,
+    RowAction,
+    usePaginatedList,
+} from '../shared/paginated-list-data-table.js';
+import {
+    AlertDialog,
+    AlertDialogAction,
+    AlertDialogCancel,
+    AlertDialogContent,
+    AlertDialogDescription,
+    AlertDialogFooter,
+    AlertDialogHeader,
+    AlertDialogTitle,
+    AlertDialogTrigger,
+} from '../ui/alert-dialog.js';
+import { Button } from '../ui/button.js';
+import { Checkbox } from '../ui/checkbox.js';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu.js';
+import { DataTableColumnHeader } from './data-table-column-header.js';
+
+/**
+ * @description
+ * This hook is used to generate the columns for a data table, combining the fields
+ * from the query with the additional columns and the custom fields.
+ *
+ * It also
+ * - adds the row actions and the delete mutation.
+ * - adds the row selection column.
+ * - adds the custom field columns.
+ */
+export function useGeneratedColumns<T extends TypedDocumentNode<any, any>>({
+    fields,
+    customizeColumns,
+    rowActions,
+    deleteMutation,
+    additionalColumns,
+    defaultColumnOrder,
+    facetedFilters,
+    includeSelectionColumn = true,
+    includeActionsColumn = true,
+    enableSorting = true,
+}: Readonly<{
+    fields: FieldInfo[];
+    customizeColumns?: CustomizeColumnConfig<T>;
+    rowActions?: RowAction<PaginatedListItemFields<T>>[];
+    deleteMutation?: TypedDocumentNode<any, any>;
+    additionalColumns?: AdditionalColumns<T>;
+    defaultColumnOrder?: Array<string | number | symbol>;
+    facetedFilters?: FacetedFilterConfig<T>;
+    includeSelectionColumn?: boolean;
+    includeActionsColumn?: boolean;
+    enableSorting?: boolean;
+}>) {
+    const columnHelper = createColumnHelper<PaginatedListItemFields<T>>();
+
+    const { columns, customFieldColumnNames } = useMemo(() => {
+        const columnConfigs: Array<{ fieldInfo: FieldInfo; isCustomField: boolean }> = [];
+        const customFieldColumnNames: string[] = [];
+
+        columnConfigs.push(
+            ...fields // Filter out custom fields
+                .filter(field => field.name !== 'customFields' && !field.type.endsWith('CustomFields'))
+                .map(field => ({ fieldInfo: field, isCustomField: false })),
+        );
+
+        const customFieldColumn = fields.find(field => field.name === 'customFields');
+        if (customFieldColumn && customFieldColumn.type !== 'JSON') {
+            const customFieldFields = getTypeFieldInfo(customFieldColumn.type);
+            columnConfigs.push(
+                ...customFieldFields.map(field => ({ fieldInfo: field, isCustomField: true })),
+            );
+            customFieldColumnNames.push(...customFieldFields.map(field => field.name));
+        }
+
+        const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
+            const customConfig = customizeColumns?.[fieldInfo.name as unknown as AllItemFieldKeys<T>] ?? {};
+            const { header, ...customConfigRest } = customConfig;
+            const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
+
+            return columnHelper.accessor(fieldInfo.name as any, {
+                id: fieldInfo.name,
+                meta: { fieldInfo, isCustomField },
+                enableColumnFilter,
+                enableSorting: fieldInfo.isScalar && enableSorting,
+                // Filtering is done on the server side, but we set this to 'equalsString' because
+                // otherwise the TanStack Table with apply an "auto" function which somehow
+                // prevents certain filters from working.
+                filterFn: 'equalsString',
+                cell: ({ cell, row }) => {
+                    const cellValue = cell.getValue();
+                    const value =
+                        cellValue ??
+                        (isCustomField ? row.original?.customFields?.[fieldInfo.name] : undefined);
+
+                    if (fieldInfo.list && Array.isArray(value)) {
+                        return value.join(', ');
+                    }
+                    if (
+                        (fieldInfo.type === 'DateTime' && typeof value === 'string') ||
+                        value instanceof Date
+                    ) {
+                        return <DisplayComponent id="vendure:dateTime" value={value} />;
+                    }
+                    if (fieldInfo.type === 'Boolean') {
+                        if (cell.column.id === 'enabled') {
+                            return <DisplayComponent id="vendure:booleanBadge" value={value} />;
+                        } else {
+                            return <DisplayComponent id="vendure:booleanCheckbox" value={value} />;
+                        }
+                    }
+                    if (fieldInfo.type === 'Asset') {
+                        return <DisplayComponent id="vendure:asset" value={value} />;
+                    }
+                    if (value !== null && typeof value === 'object') {
+                        return JSON.stringify(value);
+                    }
+                    return value;
+                },
+                header: headerContext => {
+                    return (
+                        <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />
+                    );
+                },
+                ...customConfigRest,
+            });
+        });
+
+        let finalColumns = [...queryBasedColumns];
+
+        for (const [id, column] of Object.entries(additionalColumns ?? {})) {
+            if (!id) {
+                throw new Error('Column id is required');
+            }
+            finalColumns.push(columnHelper.accessor(id as any, { ...column, id }));
+        }
+
+        if (defaultColumnOrder) {
+            // ensure the columns with ids matching the items in defaultColumnOrder
+            // appear as the first columns in sequence, and leave the remainder in the
+            // existing order
+            const orderedColumns = finalColumns
+                .filter(column => column.id && defaultColumnOrder.includes(column.id as any))
+                .sort(
+                    (a, b) =>
+                        defaultColumnOrder.indexOf(a.id as any) - defaultColumnOrder.indexOf(b.id as any),
+                );
+            const remainingColumns = finalColumns.filter(
+                column => !column.id || !defaultColumnOrder.includes(column.id as any),
+            );
+            finalColumns = [...orderedColumns, ...remainingColumns];
+        }
+
+        if (includeActionsColumn && (rowActions || deleteMutation)) {
+            const rowActionColumn = getRowActions(rowActions, deleteMutation);
+            if (rowActionColumn) {
+                finalColumns.push(rowActionColumn);
+            }
+        }
+
+        if (includeSelectionColumn) {
+            // Add the row selection column
+            finalColumns.unshift({
+                id: 'selection',
+                accessorKey: 'selection',
+                header: ({ table }) => (
+                    <Checkbox
+                        className="mx-1"
+                        checked={table.getIsAllRowsSelected()}
+                        onCheckedChange={checked =>
+                            table.toggleAllRowsSelected(checked === 'indeterminate' ? undefined : checked)
+                        }
+                    />
+                ),
+                enableColumnFilter: false,
+                cell: ({ row }) => {
+                    return (
+                        <Checkbox
+                            className="mx-1"
+                            checked={row.getIsSelected()}
+                            onCheckedChange={row.getToggleSelectedHandler()}
+                        />
+                    );
+                },
+            });
+        }
+
+        return { columns: finalColumns, customFieldColumnNames };
+    }, [fields, customizeColumns, rowActions, deleteMutation, additionalColumns, defaultColumnOrder]);
+
+    return { columns, customFieldColumnNames };
+}
+
+function getRowActions(
+    rowActions?: RowAction<any>[],
+    deleteMutation?: TypedDocumentNode<any, any>,
+): AccessorKeyColumnDef<any> | undefined {
+    return {
+        id: 'actions',
+        accessorKey: 'actions',
+        header: () => <Trans>Actions</Trans>,
+        enableColumnFilter: false,
+        cell: ({ row }) => {
+            return (
+                <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                        <Button variant="ghost" size="icon">
+                            <EllipsisIcon />
+                        </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent>
+                        {rowActions?.map((action, index) => (
+                            <DropdownMenuItem
+                                onClick={() => action.onClick?.(row)}
+                                key={`${action.label}-${index}`}
+                            >
+                                {action.label}
+                            </DropdownMenuItem>
+                        ))}
+                        {deleteMutation && (
+                            <DeleteMutationRowAction deleteMutation={deleteMutation} row={row} />
+                        )}
+                    </DropdownMenuContent>
+                </DropdownMenu>
+            );
+        },
+    };
+}
+
+function DeleteMutationRowAction({
+    deleteMutation,
+    row,
+}: Readonly<{
+    deleteMutation: TypedDocumentNode<any, any>;
+    row: Row<{ id: string }>;
+}>) {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { i18n } = useLingui();
+    const { mutate: deleteMutationFn } = useMutation({
+        mutationFn: api.mutate(deleteMutation),
+        onSuccess: (result: { [key: string]: { result: 'DELETED' | 'NOT_DELETED'; message: string } }) => {
+            const unwrappedResult = Object.values(result)[0];
+            if (unwrappedResult.result === 'DELETED') {
+                refetchPaginatedList();
+                toast.success(i18n.t('Deleted successfully'));
+            } else {
+                toast.error(i18n.t('Failed to delete'), {
+                    description: unwrappedResult.message,
+                });
+            }
+        },
+        onError: (err: Error) => {
+            toast.error(i18n.t('Failed to delete'), {
+                description: err.message,
+            });
+        },
+    });
+    return (
+        <AlertDialog>
+            <AlertDialogTrigger asChild>
+                <DropdownMenuItem onSelect={e => e.preventDefault()}>
+                    <div className="flex items-center gap-2 text-destructive">
+                        <TrashIcon className="w-4 h-4 text-destructive" />
+                        <Trans>Delete</Trans>
+                    </div>
+                </DropdownMenuItem>
+            </AlertDialogTrigger>
+            <AlertDialogContent>
+                <AlertDialogHeader>
+                    <AlertDialogTitle>
+                        <Trans>Confirm deletion</Trans>
+                    </AlertDialogTitle>
+                    <AlertDialogDescription>
+                        <Trans>
+                            Are you sure you want to delete this item? This action cannot be undone.
+                        </Trans>
+                    </AlertDialogDescription>
+                </AlertDialogHeader>
+                <AlertDialogFooter>
+                    <AlertDialogCancel>
+                        <Trans>Cancel</Trans>
+                    </AlertDialogCancel>
+                    <AlertDialogAction
+                        onClick={() => deleteMutationFn({ id: row.original.id })}
+                        className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+                    >
+                        <Trans>Delete</Trans>
+                    </AlertDialogAction>
+                </AlertDialogFooter>
+            </AlertDialogContent>
+        </AlertDialog>
+    );
+}

+ 15 - 286
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -1,51 +1,26 @@
-import { DataTableColumnHeader } from '@/vdb/components/data-table/data-table-column-header.js';
 import { DataTable, FacetedFilter } from '@/vdb/components/data-table/data-table.js';
 import {
-    FieldInfo,
-    getObjectPathToPaginatedList,
-    getTypeFieldInfo,
+    getObjectPathToPaginatedList
 } from '@/vdb/framework/document-introspection/get-document-structure.js';
 import { useListQueryFields } from '@/vdb/framework/document-introspection/hooks.js';
 import { api } from '@/vdb/graphql/api.js';
-import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useDebounce } from '@uidotdev/usehooks';
 
-import {
-    AlertDialog,
-    AlertDialogAction,
-    AlertDialogCancel,
-    AlertDialogContent,
-    AlertDialogDescription,
-    AlertDialogFooter,
-    AlertDialogHeader,
-    AlertDialogTitle,
-    AlertDialogTrigger,
-} from '@/vdb/components/ui/alert-dialog.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { DisplayComponent } from '@/vdb/framework/component-registry/dynamic-component.js';
 import { BulkAction } from '@/vdb/framework/extension-api/types/index.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
 import { useExtendedListQuery } from '@/vdb/hooks/use-extended-list-query.js';
-import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import {
     ColumnFiltersState,
     ColumnSort,
-    createColumnHelper,
     SortingState,
-    Table,
+    Table
 } from '@tanstack/react-table';
-import { AccessorKeyColumnDef, ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
-import { EllipsisIcon, TrashIcon } from 'lucide-react';
-import React, { useMemo } from 'react';
-import { toast } from 'sonner';
-import { Button } from '../ui/button.js';
-import { Checkbox } from '../ui/checkbox.js';
+import { ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
+import React from 'react';
+import { getColumnVisibility } from '../data-table/data-table-utils.js';
+import { useGeneratedColumns } from '../data-table/use-generated-columns.js';
 
 // Type that identifies a paginated list structure (has items array and totalItems)
 type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true : false;
@@ -345,139 +320,14 @@ export function PaginatedListDataTable<
         listData = listData?.[path];
     }
 
-    const columnHelper = createColumnHelper<PaginatedListItemFields<T>>();
-
-    const { columns, customFieldColumnNames } = useMemo(() => {
-        const columnConfigs: Array<{ fieldInfo: FieldInfo; isCustomField: boolean }> = [];
-        const customFieldColumnNames: string[] = [];
-
-        columnConfigs.push(
-            ...fields // Filter out custom fields
-                .filter(field => field.name !== 'customFields' && !field.type.endsWith('CustomFields'))
-                .map(field => ({ fieldInfo: field, isCustomField: false })),
-        );
-
-        const customFieldColumn = fields.find(field => field.name === 'customFields');
-        if (customFieldColumn && customFieldColumn.type !== 'JSON') {
-            const customFieldFields = getTypeFieldInfo(customFieldColumn.type);
-            columnConfigs.push(
-                ...customFieldFields.map(field => ({ fieldInfo: field, isCustomField: true })),
-            );
-            customFieldColumnNames.push(...customFieldFields.map(field => field.name));
-        }
-
-        const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
-            const customConfig = customizeColumns?.[fieldInfo.name as unknown as AllItemFieldKeys<T>] ?? {};
-            const { header, ...customConfigRest } = customConfig;
-            const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
-
-            return columnHelper.accessor(fieldInfo.name as any, {
-                id: fieldInfo.name,
-                meta: { fieldInfo, isCustomField },
-                enableColumnFilter,
-                enableSorting: fieldInfo.isScalar,
-                // Filtering is done on the server side, but we set this to 'equalsString' because
-                // otherwise the TanStack Table with apply an "auto" function which somehow
-                // prevents certain filters from working.
-                filterFn: 'equalsString',
-                cell: ({ cell, row }) => {
-                    const cellValue = cell.getValue();
-                    const value =
-                        cellValue ??
-                        (isCustomField ? row.original?.customFields?.[fieldInfo.name] : undefined);
-
-                    if (fieldInfo.list && Array.isArray(value)) {
-                        return value.join(', ');
-                    }
-                    if (
-                        (fieldInfo.type === 'DateTime' && typeof value === 'string') ||
-                        value instanceof Date
-                    ) {
-                        return <DisplayComponent id="vendure:dateTime" value={value} />;
-                    }
-                    if (fieldInfo.type === 'Boolean') {
-                        if (cell.column.id === 'enabled') {
-                            return <DisplayComponent id="vendure:booleanBadge" value={value} />;
-                        } else {
-                            return <DisplayComponent id="vendure:booleanCheckbox" value={value} />;
-                        }
-                    }
-                    if (fieldInfo.type === 'Asset') {
-                        return <DisplayComponent id="vendure:asset" value={value} />;
-                    }
-                    if (value !== null && typeof value === 'object') {
-                        return JSON.stringify(value);
-                    }
-                    return value;
-                },
-                header: headerContext => {
-                    return (
-                        <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />
-                    );
-                },
-                ...customConfigRest,
-            });
-        });
-
-        let finalColumns = [...queryBasedColumns];
-
-        for (const [id, column] of Object.entries(additionalColumns ?? {})) {
-            if (!id) {
-                throw new Error('Column id is required');
-            }
-            finalColumns.push(columnHelper.accessor(id as any, { ...column, id }));
-        }
-
-        if (defaultColumnOrder) {
-            // ensure the columns with ids matching the items in defaultColumnOrder
-            // appear as the first columns in sequence, and leave the remainder in the
-            // existing order
-            const orderedColumns = finalColumns
-                .filter(column => column.id && defaultColumnOrder.includes(column.id as any))
-                .sort(
-                    (a, b) =>
-                        defaultColumnOrder.indexOf(a.id as any) - defaultColumnOrder.indexOf(b.id as any),
-                );
-            const remainingColumns = finalColumns.filter(
-                column => !column.id || !defaultColumnOrder.includes(column.id as any),
-            );
-            finalColumns = [...orderedColumns, ...remainingColumns];
-        }
-
-        if (rowActions || deleteMutation) {
-            const rowActionColumn = getRowActions(rowActions, deleteMutation);
-            if (rowActionColumn) {
-                finalColumns.push(rowActionColumn);
-            }
-        }
-
-        // Add the row selection column
-        finalColumns.unshift({
-            id: 'selection',
-            accessorKey: 'selection',
-            header: ({ table }) => (
-                <Checkbox
-                    className="mx-1"
-                    checked={table.getIsAllRowsSelected()}
-                    onCheckedChange={checked =>
-                        table.toggleAllRowsSelected(checked === 'indeterminate' ? undefined : checked)
-                    }
-                />
-            ),
-            enableColumnFilter: false,
-            cell: ({ row }) => {
-                return (
-                    <Checkbox
-                        className="mx-1"
-                        checked={row.getIsSelected()}
-                        onCheckedChange={row.getToggleSelectedHandler()}
-                    />
-                );
-            },
-        });
-
-        return { columns: finalColumns, customFieldColumnNames };
-    }, [fields, customizeColumns, rowActions]);
+    const { columns, customFieldColumnNames } = useGeneratedColumns({
+        fields,
+        customizeColumns,
+        rowActions,
+        deleteMutation,
+        additionalColumns,
+        defaultColumnOrder,
+    });
 
     const columnVisibility = getColumnVisibility(fields, defaultVisibility, customFieldColumnNames);
     const transformedData =
@@ -509,124 +359,3 @@ export function PaginatedListDataTable<
     );
 }
 
-function getRowActions(
-    rowActions?: RowAction<any>[],
-    deleteMutation?: TypedDocumentNode<any, any>,
-): AccessorKeyColumnDef<any> | undefined {
-    return {
-        id: 'actions',
-        accessorKey: 'actions',
-        header: 'Actions',
-        enableColumnFilter: false,
-        cell: ({ row }) => {
-            return (
-                <DropdownMenu>
-                    <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon">
-                            <EllipsisIcon />
-                        </Button>
-                    </DropdownMenuTrigger>
-                    <DropdownMenuContent>
-                        {rowActions?.map((action, index) => (
-                            <DropdownMenuItem onClick={() => action.onClick?.(row)} key={index}>
-                                {action.label}
-                            </DropdownMenuItem>
-                        ))}
-                        {deleteMutation && (
-                            <DeleteMutationRowAction deleteMutation={deleteMutation} row={row} />
-                        )}
-                    </DropdownMenuContent>
-                </DropdownMenu>
-            );
-        },
-    };
-}
-
-function DeleteMutationRowAction({
-    deleteMutation,
-    row,
-}: {
-    deleteMutation: TypedDocumentNode<any, any>;
-    row: Row<{ id: string }>;
-}) {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { i18n } = useLingui();
-    const { mutate: deleteMutationFn } = useMutation({
-        mutationFn: api.mutate(deleteMutation),
-        onSuccess: (result: { [key: string]: { result: 'DELETED' | 'NOT_DELETED'; message: string } }) => {
-            const unwrappedResult = Object.values(result)[0];
-            if (unwrappedResult.result === 'DELETED') {
-                refetchPaginatedList();
-                toast.success(i18n.t('Deleted successfully'));
-            } else {
-                toast.error(i18n.t('Failed to delete'), {
-                    description: unwrappedResult.message,
-                });
-            }
-        },
-        onError: (err: Error) => {
-            toast.error(i18n.t('Failed to delete'), {
-                description: err.message,
-            });
-        },
-    });
-    return (
-        <AlertDialog>
-            <AlertDialogTrigger asChild>
-                <DropdownMenuItem onSelect={e => e.preventDefault()}>
-                    <div className="flex items-center gap-2 text-destructive">
-                        <TrashIcon className="w-4 h-4 text-destructive" />
-                        <Trans>Delete</Trans>
-                    </div>
-                </DropdownMenuItem>
-            </AlertDialogTrigger>
-            <AlertDialogContent>
-                <AlertDialogHeader>
-                    <AlertDialogTitle>
-                        <Trans>Confirm deletion</Trans>
-                    </AlertDialogTitle>
-                    <AlertDialogDescription>
-                        <Trans>
-                            Are you sure you want to delete this item? This action cannot be undone.
-                        </Trans>
-                    </AlertDialogDescription>
-                </AlertDialogHeader>
-                <AlertDialogFooter>
-                    <AlertDialogCancel>
-                        <Trans>Cancel</Trans>
-                    </AlertDialogCancel>
-                    <AlertDialogAction
-                        onClick={() => deleteMutationFn({ id: row.original.id })}
-                        className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
-                    >
-                        <Trans>Delete</Trans>
-                    </AlertDialogAction>
-                </AlertDialogFooter>
-            </AlertDialogContent>
-        </AlertDialog>
-    );
-}
-
-/**
- * Returns the default column visibility configuration.
- */
-function getColumnVisibility(
-    fields: FieldInfo[],
-    defaultVisibility?: Record<string, boolean | undefined>,
-    customFieldColumnNames?: string[],
-): Record<string, boolean> {
-    const allDefaultsTrue = defaultVisibility && Object.values(defaultVisibility).every(v => v === true);
-    const allDefaultsFalse = defaultVisibility && Object.values(defaultVisibility).every(v => v === false);
-    return {
-        id: false,
-        createdAt: false,
-        updatedAt: false,
-        ...(allDefaultsTrue ? { ...Object.fromEntries(fields.map(f => [f.name, false])) } : {}),
-        ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
-        // Make custom fields hidden by default unless overridden
-        ...(customFieldColumnNames
-            ? { ...Object.fromEntries(customFieldColumnNames.map(f => [f, false])) }
-            : {}),
-        ...defaultVisibility,
-    };
-}

+ 28 - 4
packages/dashboard/src/lib/components/shared/product-variant-selector.tsx

@@ -8,7 +8,7 @@ import {
 } from '@/vdb/components/ui/command.js';
 import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
 import { api } from '@/vdb/graphql/api.js';
-import { assetFragment } from '@/vdb/graphql/fragments.js';
+import { AssetFragment, assetFragment } from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
 import { useQuery } from '@tanstack/react-query';
 import { useDebounce } from '@uidotdev/usehooks';
@@ -28,6 +28,13 @@ const productVariantListDocument = graphql(
                     featuredAsset {
                         ...Asset
                     }
+                    price
+                    priceWithTax
+                    product {
+                        featuredAsset {
+                            ...Asset
+                        }
+                    }
                 }
                 totalItems
             }
@@ -37,10 +44,17 @@ const productVariantListDocument = graphql(
 );
 
 export interface ProductVariantSelectorProps {
-    onProductVariantIdChange: (productVariantId: string) => void;
+    onProductVariantSelect: (variant: {
+        productVariantId: string;
+        productVariantName: string;
+        sku: string;
+        productAsset: AssetFragment | null;
+        price?: number;
+        priceWithTax?: number;
+    }) => void;
 }
 
-export function ProductVariantSelector({ onProductVariantIdChange }: Readonly<ProductVariantSelectorProps>) {
+export function ProductVariantSelector({ onProductVariantSelect }: Readonly<ProductVariantSelectorProps>) {
     const [search, setSearch] = useState('');
     const [open, setOpen] = useState(false);
     const debouncedSearch = useDebounce(search, 500);
@@ -85,7 +99,17 @@ export function ProductVariantSelector({ onProductVariantIdChange }: Readonly<Pr
                                     key={variant.id}
                                     value={variant.id}
                                     onSelect={() => {
-                                        onProductVariantIdChange(variant.id);
+                                        onProductVariantSelect({
+                                            productVariantId: variant.id,
+                                            productVariantName: variant.name,
+                                            sku: variant.sku,
+                                            productAsset:
+                                                variant.featuredAsset ??
+                                                variant.product.featuredAsset ??
+                                                null,
+                                            price: variant.price,
+                                            priceWithTax: variant.priceWithTax,
+                                        });
                                         setOpen(false);
                                     }}
                                     className="flex items-center gap-2 p-2"

+ 3 - 3
packages/dashboard/src/lib/framework/component-registry/dynamic-component.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import { COMPONENT_REGISTRY, useComponentRegistry } from "./component-registry.js";
+import { useComponentRegistry } from "./component-registry.js";
 
 export type DisplayComponentProps<
     T extends keyof (typeof COMPONENT_REGISTRY)['dataDisplay'] | string,
@@ -33,7 +33,7 @@ export type InputComponentProps<
  */
 export function DisplayComponent<
     T extends keyof (typeof COMPONENT_REGISTRY)['dataDisplay'] | string,
->(props: DisplayComponentProps<T>): React.ReactNode {   
+>(props: Readonly<DisplayComponentProps<T>>): React.ReactNode {   
     const { getDisplayComponent } = useComponentRegistry();
     const Component = getDisplayComponent(props.id);
     if (!Component) {
@@ -45,7 +45,7 @@ export function DisplayComponent<
 
 export function InputComponent<
     T extends keyof (typeof COMPONENT_REGISTRY)['dataInput'] | string,
->(props: InputComponentProps<T>): React.ReactNode {
+>(props: Readonly<InputComponentProps<T>>): React.ReactNode {
     const { getInputComponent } = useComponentRegistry();
     const Component = getInputComponent(props.id);
     if (!Component) {

+ 321 - 2
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.spec.ts

@@ -1,7 +1,11 @@
 import { graphql } from 'gql.tada';
 import { describe, expect, it, vi } from 'vitest';
 
-import { getListQueryFields, getOperationVariablesFields } from './get-document-structure.js';
+import {
+    getFieldsFromDocumentNode,
+    getListQueryFields,
+    getOperationVariablesFields,
+} from './get-document-structure.js';
 
 vi.mock('virtual:admin-api-schema', () => {
     return {
@@ -11,6 +15,7 @@ vi.mock('virtual:admin-api-schema', () => {
                     products: ['ProductList', false, false, true],
                     product: ['Product', false, false, false],
                     collection: ['Collection', false, false, false],
+                    order: ['Order', false, false, false],
                 },
                 Mutation: {
                     updateProduct: ['Product', false, false, false],
@@ -148,7 +153,17 @@ vi.mock('virtual:admin-api-schema', () => {
                     quantity: ['Int', false, false, false],
                 },
             },
-            scalars: ['ID', 'String', 'Int', 'Boolean', 'Float', 'JSON', 'DateTime', 'Upload', 'Money'],
+            scalars: [
+                'ID',
+                'String',
+                'Int',
+                'Boolean',
+                'Float',
+                'JSON',
+                'DateTime',
+                'Upload',
+                'CurrencyCode',
+            ],
             enums: {},
         },
     };
@@ -372,6 +387,9 @@ describe('getOperationVariablesFields', () => {
             mutation AdjustDraftOrderLine($orderId: ID!, $input: AdjustDraftOrderLineInput!) {
                 adjustDraftOrderLine(orderId: $orderId, input: $input) {
                     id
+                    lines {
+                        id
+                    }
                 }
             }
         `);
@@ -418,3 +436,304 @@ describe('getOperationVariablesFields', () => {
         ]);
     });
 });
+
+describe('getFieldsFromDocumentNode', () => {
+    it('should extract fields from a simple path', () => {
+        const doc = graphql(`
+            query {
+                order(id: "1") {
+                    id
+                    lines {
+                        id
+                        quantity
+                    }
+                }
+            }
+        `);
+
+        const fields = getFieldsFromDocumentNode(doc, ['order', 'lines']);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'quantity',
+                nullable: false,
+                type: 'Int',
+            },
+        ]);
+    });
+
+    it('should extract fields from root level', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                    name
+                    description
+                }
+            }
+        `);
+
+        const fields = getFieldsFromDocumentNode(doc, ['product']);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'name',
+                nullable: false,
+                type: 'String',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'description',
+                nullable: false,
+                type: 'String',
+            },
+        ]);
+    });
+
+    it('should handle fragments in the target selection', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                    featuredAsset {
+                        ...AssetFields
+                    }
+                }
+            }
+
+            fragment AssetFields on Asset {
+                id
+                name
+                preview
+            }
+        `);
+
+        const fields = getFieldsFromDocumentNode(doc, ['product', 'featuredAsset']);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'name',
+                nullable: false,
+                type: 'String',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'preview',
+                nullable: false,
+                type: 'String',
+            },
+        ]);
+    });
+
+    it('should handle deep nested paths', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    variants {
+                        prices {
+                            currencyCode
+                            price
+                        }
+                    }
+                }
+            }
+        `);
+
+        const fields = getFieldsFromDocumentNode(doc, ['product', 'variants', 'prices']);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'currencyCode',
+                nullable: false,
+                type: 'CurrencyCode',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: false,
+                list: false,
+                name: 'price',
+                nullable: false,
+                type: 'Money',
+            },
+        ]);
+    });
+
+    it('should throw error for non-existent field', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                }
+            }
+        `);
+
+        expect(() => getFieldsFromDocumentNode(doc, ['product', 'nonExistentField'])).toThrow(
+            'Field "nonExistentField" not found at path product.nonExistentField',
+        );
+    });
+
+    it('should throw error for non-existent path', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                }
+            }
+        `);
+
+        expect(() => getFieldsFromDocumentNode(doc, ['nonExistentProduct'])).toThrow(
+            'Field "nonExistentProduct" not found at path nonExistentProduct',
+        );
+    });
+
+    it('should throw error when field has no selection set but path continues', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                }
+            }
+        `);
+
+        expect(() => getFieldsFromDocumentNode(doc, ['product', 'id', 'something'])).toThrow(
+            'Field "id" has no selection set but path continues',
+        );
+    });
+
+    it('should handle empty selection set', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                    name
+                }
+            }
+        `);
+
+        // Test with a path that leads to a field with no selection set
+        expect(() => getFieldsFromDocumentNode(doc, ['product', 'id', 'something'])).toThrow(
+            'Field "id" has no selection set but path continues',
+        );
+    });
+
+    it('should handle mixed field types and fragments', () => {
+        const doc = graphql(`
+            query {
+                product {
+                    id
+                    featuredAsset {
+                        ...AssetFields
+                        fileSize
+                    }
+                }
+            }
+
+            fragment AssetFields on Asset {
+                id
+                name
+            }
+        `);
+
+        const fields = getFieldsFromDocumentNode(doc, ['product', 'featuredAsset']);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'name',
+                nullable: false,
+                type: 'String',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'fileSize',
+                nullable: false,
+                type: 'Int',
+            },
+        ]);
+    });
+
+    it('should handle fields within fragment spreads', () => {
+        const doc = graphql(`
+            query {
+                order {
+                    ...OrderFields
+                }
+            }
+
+            fragment OrderFields on Order {
+                id
+                lines {
+                    id
+                    quantity
+                }
+            }
+        `);
+
+        const fields = getFieldsFromDocumentNode(doc, ['order', 'lines']);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'quantity',
+                nullable: false,
+                type: 'Int',
+            },
+        ]);
+    });
+});

+ 149 - 31
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts

@@ -1,5 +1,5 @@
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { VariablesOf } from 'gql.tada';
+import { ResultOf, VariablesOf } from 'gql.tada';
 import {
     DocumentNode,
     FieldNode,
@@ -32,33 +32,165 @@ export interface FieldInfo {
  */
 export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
     const fields: FieldInfo[] = [];
-    const fragments: Record<string, FragmentDefinitionNode> = {};
+    const fragments = collectFragments(documentNode);
+    const operationDefinition = findQueryOperation(documentNode);
 
-    // Collect all fragment definitions
+    for (const query of operationDefinition.selectionSet.selections) {
+        if (query.kind === 'Field') {
+            const queryField = query;
+            const fieldInfo = getQueryInfo(queryField.name.value);
+            if (fieldInfo.isPaginatedList) {
+                processPaginatedList(queryField, fieldInfo, fields, fragments);
+            } else if (queryField.selectionSet) {
+                // Check for nested paginated lists
+                findNestedPaginatedLists(queryField, fieldInfo.type, fields, fragments);
+            }
+        }
+    }
+
+    return fields;
+}
+
+// Utility type to get all valid paths into a type
+export type PathTo<T> = T extends object
+    ? {
+          [K in keyof T & (string | number)]: [K] | [K, ...PathTo<T[K]>];
+      }[keyof T & (string | number)]
+    : [];
+
+/**
+ * @description
+ * This function is used to get the FieldInfo for the fields in the path of a DocumentNode.
+ *
+ * For example, in the following query:
+ *
+ * ```graphql
+ * query {
+ *   product {
+ *     id
+ *     name
+ *     variants {
+ *       id
+ *       name
+ *     }
+ *   }
+ * }
+ * ```
+ *
+ * The path `['product', 'variants']` will return the FieldInfo for the `variants` field,
+ * namely the `id` and `name` fields.
+ */
+export function getFieldsFromDocumentNode<
+    T extends TypedDocumentNode<any, any>,
+    P extends PathTo<ResultOf<T>>,
+>(documentNode: T, path: P): FieldInfo[] {
+    const fragments = collectFragments(documentNode);
+    const operationDefinition = findQueryOperation(documentNode);
+
+    // Navigate to the target path
+    let currentSelections = operationDefinition.selectionSet.selections;
+    let currentType = 'Query';
+
+    for (let i = 0; i < path.length; i++) {
+        const pathSegment = path[i] as string;
+        const { fieldNode } = findFieldInSelections(
+            currentSelections,
+            pathSegment,
+            fragments,
+            path.slice(0, i + 1),
+        );
+
+        const fieldInfo = getObjectFieldInfo(currentType, pathSegment);
+        if (!fieldInfo) {
+            throw new Error(`Could not determine type for field "${pathSegment}"`);
+        }
+
+        // If this is the last path segment, collect the fields
+        if (i === path.length - 1) {
+            return collectFieldsFromNode(fieldNode, fieldInfo.type, fragments);
+        }
+
+        // Continue navigating deeper
+        if (!fieldNode.selectionSet) {
+            throw new Error(`Field "${pathSegment}" has no selection set but path continues`);
+        }
+        currentSelections = fieldNode.selectionSet.selections;
+        currentType = fieldInfo.type;
+    }
+
+    return [];
+}
+
+function collectFragments(documentNode: DocumentNode): Record<string, FragmentDefinitionNode> {
+    const fragments: Record<string, FragmentDefinitionNode> = {};
     documentNode.definitions.forEach(def => {
         if (def.kind === 'FragmentDefinition') {
             fragments[def.name.value] = def;
         }
     });
+    return fragments;
+}
 
+function findQueryOperation(documentNode: DocumentNode): OperationDefinitionNode {
     const operationDefinition = documentNode.definitions.find(
         (def): def is OperationDefinitionNode =>
             def.kind === 'OperationDefinition' && def.operation === 'query',
     );
+    if (!operationDefinition) {
+        throw new Error('Could not find query operation definition');
+    }
+    return operationDefinition;
+}
+
+function findMutationOperation(documentNode: DocumentNode): OperationDefinitionNode {
+    const operationDefinition = documentNode.definitions.find(
+        (def): def is OperationDefinitionNode =>
+            def.kind === 'OperationDefinition' && def.operation === 'mutation',
+    );
+    if (!operationDefinition) {
+        throw new Error('Could not find mutation operation definition');
+    }
+    return operationDefinition;
+}
 
-    for (const query of operationDefinition?.selectionSet.selections ?? []) {
-        if (query.kind === 'Field') {
-            const queryField = query;
-            const fieldInfo = getQueryInfo(queryField.name.value);
-            if (fieldInfo.isPaginatedList) {
-                processPaginatedList(queryField, fieldInfo, fields, fragments);
-            } else if (queryField.selectionSet) {
-                // Check for nested paginated lists
-                findNestedPaginatedLists(queryField, fieldInfo.type, fields, fragments);
+function findFieldInSelections(
+    selections: readonly any[],
+    pathSegment: string,
+    fragments: Record<string, FragmentDefinitionNode>,
+    currentPath: string[] = [],
+): { fieldNode: FieldNode; fragmentSelections: readonly any[] } {
+    for (const selection of selections) {
+        if (selection.kind === 'Field' && selection.name.value === pathSegment) {
+            return { fieldNode: selection, fragmentSelections: [] };
+        } else if (selection.kind === 'FragmentSpread') {
+            const fragment = fragments[selection.name.value];
+            if (fragment) {
+                const fragmentField = fragment.selectionSet.selections.find(
+                    s => s.kind === 'Field' && s.name.value === pathSegment,
+                ) as FieldNode;
+                if (fragmentField) {
+                    return { fieldNode: fragmentField, fragmentSelections: fragment.selectionSet.selections };
+                }
             }
         }
     }
+    const pathString = currentPath.join('.');
+    throw new Error(`Field "${pathSegment}" not found at path ${pathString}`);
+}
 
+function collectFieldsFromNode(
+    fieldNode: FieldNode,
+    typeName: string,
+    fragments: Record<string, FragmentDefinitionNode>,
+): FieldInfo[] {
+    const fields: FieldInfo[] = [];
+    if (fieldNode.selectionSet) {
+        for (const selection of fieldNode.selectionSet.selections) {
+            if (selection.kind === 'Field' || selection.kind === 'FragmentSpread') {
+                collectFields(typeName, selection, fields, fragments);
+            }
+        }
+    }
     return fields;
 }
 
@@ -204,11 +336,8 @@ function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
  * Helper function to get the first field selection from a query operation definition.
  */
 function getFirstQueryField(documentNode: DocumentNode): FieldNode {
-    const operationDefinition = documentNode.definitions.find(
-        (def): def is OperationDefinitionNode =>
-            def.kind === 'OperationDefinition' && def.operation === 'query',
-    );
-    const firstSelection = operationDefinition?.selectionSet.selections[0];
+    const operationDefinition = findQueryOperation(documentNode);
+    const firstSelection = operationDefinition.selectionSet.selections[0];
     if (firstSelection?.kind === 'Field') {
         return firstSelection;
     } else {
@@ -305,15 +434,7 @@ export function getObjectPathToPaginatedList(
     documentNode: DocumentNode,
     currentPath: string[] = [],
 ): string[] {
-    // get the query OperationDefinition
-    const operationDefinition = documentNode.definitions.find(
-        (def): def is OperationDefinitionNode =>
-            def.kind === 'OperationDefinition' && def.operation === 'query',
-    );
-    if (!operationDefinition) {
-        throw new Error('Could not find query operation definition');
-    }
-
+    const operationDefinition = findQueryOperation(documentNode);
     return findPaginatedListPath(operationDefinition.selectionSet, 'Query', currentPath);
 }
 
@@ -365,11 +486,8 @@ function findPaginatedListPath(
  * The mutation name is `createProduct`.
  */
 export function getMutationName(documentNode: DocumentNode): string {
-    const operationDefinition = documentNode.definitions.find(
-        (def): def is OperationDefinitionNode =>
-            def.kind === 'OperationDefinition' && def.operation === 'mutation',
-    );
-    const firstSelection = operationDefinition?.selectionSet.selections[0];
+    const operationDefinition = findMutationOperation(documentNode);
+    const firstSelection = operationDefinition.selectionSet.selections[0];
     if (firstSelection?.kind === 'Field') {
         return firstSelection.name.value;
     } else {

+ 21 - 6
packages/dashboard/src/lib/framework/extension-api/types/layout.ts

@@ -27,6 +27,27 @@ export interface DashboardActionBarItem {
      * A React component that will be rendered in the action bar.
      */
     component: React.FunctionComponent<{ context: PageContextValue }>;
+    /**
+     * @description
+     * The type of action bar item to display. Defaults to `button`.
+     * The 'dropdown' type is used to display the action bar item as a dropdown menu item.
+     *
+     * When using the dropdown type, use a suitable [dropdown item](https://ui.shadcn.com/docs/components/dropdown-menu)
+     * component, such as:
+     *
+     * ```tsx
+     * import { DropdownMenuItem } from '\@vendure/dashboard';
+     *
+     * // ...
+     *
+     * {
+     *   component: () => <DropdownMenuItem>My Item</DropdownMenuItem>
+     * }
+     * ```
+     *
+     * @default 'button'
+     */
+    type?: 'button' | 'dropdown';
     /**
      * @description
      * Any permissions that are required to display this action bar item.
@@ -34,12 +55,6 @@ export interface DashboardActionBarItem {
     requiresPermission?: string | string[];
 }
 
-export interface DashboardActionBarDropdownMenuItem {
-    locationId: string;
-    component: React.FunctionComponent<{ context: PageContextValue }>;
-    requiresPermission?: string | string[];
-}
-
 export type PageBlockPosition = { blockId: string; order: 'before' | 'after' | 'replace' };
 
 /**

+ 1 - 4
packages/dashboard/src/lib/framework/layout-engine/layout-extensions.ts

@@ -1,7 +1,4 @@
-import {
-    DashboardActionBarItem,
-    DashboardPageBlockDefinition,
-} from '../extension-api/extension-api-types.js';
+import { DashboardActionBarItem, DashboardPageBlockDefinition } from '../extension-api/types/layout.js';
 import { globalRegistry } from '../registry/global-registry.js';
 
 globalRegistry.register('dashboardActionBarItemRegistry', new Map<string, DashboardActionBarItem[]>());

+ 61 - 10
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -7,11 +7,14 @@ import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { cn } from '@/vdb/lib/utils.js';
 import { useMediaQuery } from '@uidotdev/usehooks';
-import React, { ComponentProps } from 'react';
+import { EllipsisVerticalIcon } from 'lucide-react';
+import React, { ComponentProps, useMemo } from 'react';
 import { Control, UseFormReturn } from 'react-hook-form';
 
 import { DashboardActionBarItem } from '../extension-api/types/layout.js';
 
+import { Button } from '@/vdb/components/ui/button.js';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/vdb/components/ui/dropdown-menu.js';
 import { PageBlockContext } from '@/vdb/framework/layout-engine/page-block-provider.js';
 import { PageContext, PageContextValue } from '@/vdb/framework/layout-engine/page-provider.js';
 import { getDashboardActionBarItems, getDashboardPageBlocks } from './layout-extensions.js';
@@ -295,6 +298,8 @@ export function PageActionBarLeft({ children }: Readonly<{ children: React.React
     return <div className="flex justify-start gap-2">{children}</div>;
 }
 
+type InlineDropdownItem = Omit<DashboardActionBarItem, 'type' | 'pageId'>;
+
 /**
  * @description
  * **Status: Developer Preview**
@@ -303,20 +308,42 @@ export function PageActionBarLeft({ children }: Readonly<{ children: React.React
  * @docsPage PageActionBar
  * @since 3.3.0
  */
-export function PageActionBarRight({ children }: Readonly<{ children: React.ReactNode }>) {
+export function PageActionBarRight({
+    children,
+    dropdownMenuItems,
+}: Readonly<{
+    children: React.ReactNode;
+    dropdownMenuItems?: InlineDropdownItem[];
+}>) {
     const page = usePage();
     const actionBarItems = page.pageId ? getDashboardActionBarItems(page.pageId) : [];
+    const actionBarButtonItems = actionBarItems.filter(item => item.type !== 'dropdown');
+    const actionBarDropdownItems = [
+        ...(dropdownMenuItems ?? []).map(item => ({
+            ...item,
+            pageId: page.pageId ?? '',
+            type: 'dropdown' as const,
+        })),
+        ...actionBarItems.filter(item => item.type === 'dropdown'),
+    ];
+
     return (
         <div className="flex justify-end gap-2">
-            {actionBarItems.map((item, index) => (
-                <PageActionBarItem key={index} item={item} page={page} />
+            {actionBarButtonItems.map((item, index) => (
+                <PageActionBarItem key={item.pageId + index} item={item} page={page} />
             ))}
             {children}
+            {actionBarDropdownItems.length > 0 && (
+                <PageActionBarDropdown items={actionBarDropdownItems} page={page} />
+            )}
         </div>
     );
 }
 
-function PageActionBarItem({ item, page }: { item: DashboardActionBarItem; page: PageContextValue }) {
+function PageActionBarItem({
+    item,
+    page,
+}: Readonly<{ item: DashboardActionBarItem; page: PageContextValue }>) {
     return (
         <PermissionGuard requires={item.requiresPermission ?? []}>
             <item.component context={page} />
@@ -324,6 +351,28 @@ function PageActionBarItem({ item, page }: { item: DashboardActionBarItem; page:
     );
 }
 
+function PageActionBarDropdown({
+    items,
+    page,
+}: Readonly<{ items: DashboardActionBarItem[]; page: PageContextValue }>) {
+    return (
+        <DropdownMenu>
+            <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon">
+                    <EllipsisVerticalIcon className="w-4 h-4" />
+                </Button>
+            </DropdownMenuTrigger>
+            <DropdownMenuContent>
+                {items.map((item, index) => (
+                    <PermissionGuard key={item.pageId + index} requires={item.requiresPermission ?? []}>
+                        <item.component context={page} />
+                    </PermissionGuard>
+                ))}
+            </DropdownMenuContent>
+        </DropdownMenu>
+    );
+}
+
 /**
  * @description
  * **Status: Developer Preview**
@@ -363,8 +412,9 @@ export function PageBlock({
     blockId,
     column,
 }: Readonly<PageBlockProps>) {
+    const contextValue = useMemo(() => ({ blockId, title, description, column }), [blockId, title, description, column]);
     return (
-        <PageBlockContext.Provider value={{ blockId, title, description, column }}>
+        <PageBlockContext.Provider value={contextValue}>
             <LocationWrapper>
                 <Card className={cn('w-full', className)}>
                     {title || description ? (
@@ -395,9 +445,10 @@ export function FullWidthPageBlock({
     children,
     className,
     blockId,
-}: Pick<PageBlockProps, 'children' | 'className' | 'blockId'>) {
+}: Readonly<Pick<PageBlockProps, 'children' | 'className' | 'blockId'>>) {
+    const contextValue = useMemo(() => ({ blockId, column: 'main' as const }), [blockId]);
     return (
-        <PageBlockContext.Provider value={{ blockId, column: 'main' }}>
+        <PageBlockContext.Provider value={contextValue}>
             <LocationWrapper>
                 <div className={cn('w-full', className)}>{children}</div>
             </LocationWrapper>
@@ -419,11 +470,11 @@ export function CustomFieldsPageBlock({
     column,
     entityType,
     control,
-}: {
+}: Readonly<{
     column: 'main' | 'side';
     entityType: string;
     control: Control<any, any>;
-}) {
+}>) {
     const customFieldConfig = useCustomFieldConfig(entityType);
     if (!customFieldConfig || customFieldConfig.length === 0) {
         return null;

+ 2 - 0
packages/dashboard/src/lib/framework/page/use-detail-page.ts

@@ -111,11 +111,13 @@ export interface DetailPageOptions<
 export function getDetailQueryOptions<T, V extends Variables = Variables>(
     document: TypedDocumentNode<T, V> | DocumentNode,
     variables: V,
+    options: Partial<Parameters<typeof queryOptions>[0]> = {},
 ): DefinedInitialDataOptions {
     const queryName = getQueryName(document);
     return queryOptions({
         queryKey: ['DetailPage', queryName, variables],
         queryFn: () => api.query(document, variables),
+        ...options,
     }) as DefinedInitialDataOptions;
 }