Parcourir la source

feat(dashboard): Order detail missing features (#3636)

Michael Bromley il y a 6 mois
Parent
commit
183b8f3c1c

+ 191 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/add-manual-payment-dialog.tsx

@@ -0,0 +1,191 @@
+import {
+    RelationSelector,
+    createRelationSelectorConfig,
+} from '@/vdb/components/data-input/relation-selector.js';
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { Form } from '@/vdb/components/ui/form.js';
+import { Input } from '@/vdb/components/ui/input.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 { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import {
+    addManualPaymentToOrderDocument,
+    paymentMethodsDocument
+} from '../orders.graphql.js';
+import { Order } from '../utils/order-types.js';
+import { calculateOutstandingPaymentAmount } from '../utils/order-utils.js';
+
+interface AddManualPaymentDialogProps {
+    order: Order;
+    onSuccess?: () => void;
+}
+
+interface FormData {
+    method: string;
+    transactionId: string;
+}
+
+export function AddManualPaymentDialog({ order, onSuccess }: Readonly<AddManualPaymentDialogProps>) {
+    const { i18n } = useLingui();
+    const { formatCurrency } = useLocalFormat();
+    const [isSubmitting, setIsSubmitting] = useState(false);
+    const [open, setOpen] = useState(false);
+
+    const addManualPaymentMutation = useMutation({
+        mutationFn: api.mutate(addManualPaymentToOrderDocument),
+        onSuccess: (result: any) => {
+            const { addManualPaymentToOrder } = result;
+            if (addManualPaymentToOrder.__typename === 'Order') {
+                toast(i18n.t('Successfully added payment to order'));
+                onSuccess?.();
+            } else {
+                toast(i18n.t('Failed to add payment'), {
+                    description: addManualPaymentToOrder.message,
+                });
+            }
+        },
+        onError: error => {
+            toast(i18n.t('Failed to add payment'), {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        },
+    });
+
+    const form = useForm<FormData>({
+        defaultValues: {
+            method: '',
+            transactionId: '',
+        },
+    });
+    const method = form.watch('method');
+
+    const handleSubmit = async (data: FormData) => {
+        setIsSubmitting(true);
+        try {
+            addManualPaymentMutation.mutate({
+                input: {
+                    orderId: order.id,
+                    method: data.method,
+                    transactionId: data.transactionId,
+                    metadata: {},
+                },
+            });
+            setOpen(false);
+            form.reset();
+        } catch (error) {
+            toast(i18n.t('Failed to add payment'), {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        } finally {
+            setIsSubmitting(false);
+        }
+    };
+
+    const handleCancel = () => {
+        form.reset();
+        setOpen(false);
+    };
+
+    const outstandingAmount = calculateOutstandingPaymentAmount(order);
+    const currencyCode = order.currencyCode;
+
+    // Create relation selector config for payment methods
+    const paymentMethodSelectorConfig = createRelationSelectorConfig({
+        listQuery: paymentMethodsDocument,
+        idKey: 'code',
+        labelKey: 'name',
+        placeholder: i18n.t('Search payment methods...'),
+        multiple: false,
+        label: (method: any) => `${method.name} (${method.code})`,
+    });
+
+    return (
+        <>
+            <Button
+                onClick={e => {
+                    e.stopPropagation();
+                    setOpen(true);
+                }}
+                className="mr-2"
+            >
+                <Trans>Add payment to order ({formatCurrency(outstandingAmount, currencyCode)})</Trans>
+            </Button>
+            <Dialog open={open}>
+                <DialogContent className="sm:max-w-[500px]">
+                    <DialogHeader>
+                        <DialogTitle>
+                            <Trans>Add payment to order</Trans>
+                        </DialogTitle>
+                        <DialogDescription>
+                            <Trans>
+                                Add a manual payment of {formatCurrency(outstandingAmount, currencyCode)}
+                            </Trans>
+                        </DialogDescription>
+                    </DialogHeader>
+                    <Form {...form}>
+                        <form onSubmit={e => {
+                            e.stopPropagation();
+                            form.handleSubmit(handleSubmit)(e);
+                        }} className="space-y-4">
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="method"
+                                label={<Trans>Payment method</Trans>}
+                                rules={{ required: i18n.t('Payment method is required') }}
+                                render={({ field }) => (
+                                    <RelationSelector
+                                        config={paymentMethodSelectorConfig}
+                                        value={field.value}
+                                        onChange={field.onChange}
+                                        disabled={isSubmitting}
+                                    />
+                                )}
+                            />
+                            <FormFieldWrapper
+                                control={form.control}
+                                name="transactionId"
+                                label={<Trans>Transaction ID</Trans>}
+                                rules={{ required: i18n.t('Transaction ID is required') }}
+                                render={({ field }) => (
+                                    <Input {...field} placeholder={i18n.t('Enter transaction ID')} />
+                                )}
+                            />
+                            <DialogFooter>
+                                <Button type="button" variant="outline" onClick={handleCancel}>
+                                    <Trans>Cancel</Trans>
+                                </Button>
+                                <Button
+                                    type="submit"
+                                    disabled={
+                                        !form.formState.isValid || isSubmitting || !method
+                                    }
+                                >
+                                    {isSubmitting ? (
+                                        <Trans>Adding...</Trans>
+                                    ) : (
+                                        <Trans>
+                                            Add payment ({formatCurrency(outstandingAmount, currencyCode)})
+                                        </Trans>
+                                    )}
+                                </Button>
+                            </DialogFooter>
+                        </form>
+                    </Form>
+                </DialogContent>
+            </Dialog>
+        </>
+    );
+}

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

@@ -54,7 +54,7 @@ export function EditOrderTable({
     onApplyCouponCode,
     onRemoveCouponCode,
     orderLineForm,
-}: OrderTableProps) {
+}: Readonly<OrderTableProps>) {
     const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
     const [couponCode, setCouponCode] = useState('');
 
@@ -130,7 +130,7 @@ export function EditOrderTable({
         {
             header: 'Total',
             accessorKey: 'linePriceWithTax',
-            cell: ({ cell, row }) => {
+            cell: ({ row }) => {
                 const value = row.original.linePriceWithTax;
                 const netValue = row.original.linePrice;
                 return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;

+ 320 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx

@@ -0,0 +1,320 @@
+import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { Form } from '@/vdb/components/ui/form.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Label } from '@/vdb/components/ui/label.js';
+import { api } from '@/vdb/graphql/api.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import { fulfillmentHandlersDocument, fulfillOrderDocument } from '../orders.graphql.js';
+import { Order } from '../utils/order-types.js';
+
+interface FulfillOrderDialogProps {
+    order: Order;
+    onSuccess?: () => void;
+}
+
+interface FormData {
+    handler: ConfigurableOperationInputType;
+}
+
+interface FulfillmentQuantity {
+    fulfillCount: number;
+    max: number;
+}
+
+export function FulfillOrderDialog({ order, onSuccess }: Readonly<FulfillOrderDialogProps>) {
+    const { i18n } = useLingui();
+    const [isSubmitting, setIsSubmitting] = useState(false);
+    const [open, setOpen] = useState(false);
+    const [fulfillmentQuantities, setFulfillmentQuantities] = useState<{
+        [lineId: string]: FulfillmentQuantity;
+    }>({});
+
+    // Get fulfillment handlers
+    const { data: fulfillmentHandlersData } = useQuery({
+        queryKey: ['fulfillmentHandlers'],
+        queryFn: () => api.query(fulfillmentHandlersDocument),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    // Get global settings for inventory tracking
+    const { data: globalSettingsData } = useQuery({
+        queryKey: ['globalSettings'],
+        queryFn: () =>
+            api.query(
+                graphql(`
+                    query GetGlobalSettings {
+                        globalSettings {
+                            trackInventory
+                        }
+                    }
+                `),
+            ),
+        staleTime: 1000 * 60 * 60 * 5,
+    });
+
+    const fulfillOrderMutation = useMutation({
+        mutationFn: api.mutate(fulfillOrderDocument),
+        onSuccess: (result: any) => {
+            const { addFulfillmentToOrder } = result;
+            if (addFulfillmentToOrder.__typename === 'Fulfillment') {
+                toast(i18n.t('Successfully fulfilled order'));
+                onSuccess?.();
+            } else {
+                toast(i18n.t('Failed to fulfill order'), {
+                    description: addFulfillmentToOrder.message,
+                });
+            }
+        },
+        onError: error => {
+            toast(i18n.t('Failed to fulfill order'), {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        },
+    });
+
+    const form = useForm<FormData>({
+        defaultValues: {
+            handler: {
+                code: '',
+                arguments: [],
+            },
+        },
+    });
+
+    // Initialize fulfillment quantities when dialog opens
+    const initializeFulfillmentQuantities = () => {
+        if (!globalSettingsData?.globalSettings) return;
+
+        const quantities: { [lineId: string]: FulfillmentQuantity } = {};
+        order.lines.forEach(line => {
+            const fulfillCount = getFulfillableCount(line, globalSettingsData.globalSettings.trackInventory);
+            quantities[line.id] = { fulfillCount, max: fulfillCount };
+        });
+        setFulfillmentQuantities(quantities);
+
+        // Set default fulfillment handler
+        const defaultHandler =
+            fulfillmentHandlersData?.fulfillmentHandlers.find(
+                h => h.code === order.shippingLines[0]?.shippingMethod?.fulfillmentHandlerCode,
+            ) ?? fulfillmentHandlersData?.fulfillmentHandlers[0];
+
+        if (defaultHandler) {
+            form.setValue('handler', {
+                code: defaultHandler.code,
+                arguments: defaultHandler.args.map(arg => ({
+                    name: arg.name,
+                    value: arg.defaultValue ?? '',
+                })),
+            });
+        }
+    };
+
+    const getFulfillableCount = (line: Order['lines'][number], globalTrackInventory: boolean): number => {
+        const { trackInventory, stockOnHand } = line.productVariant;
+        const effectiveTrackInventory =
+            trackInventory === 'INHERIT' ? globalTrackInventory : trackInventory === 'TRUE';
+
+        const unfulfilledCount = getUnfulfilledCount(line);
+        return effectiveTrackInventory ? Math.min(unfulfilledCount, stockOnHand) : unfulfilledCount;
+    };
+
+    const getUnfulfilledCount = (line: Order['lines'][number]): number => {
+        const fulfilled =
+            order.fulfillments
+                ?.filter(f => f.state !== 'Cancelled')
+                .map(f => f.lines)
+                .flat()
+                .filter(row => row.orderLineId === line.id)
+                .reduce((sum, row) => sum + row.quantity, 0) ?? 0;
+        return line.quantity - fulfilled;
+    };
+
+    const updateFulfillmentQuantity = (lineId: string, fulfillCount: number) => {
+        setFulfillmentQuantities(prev => ({
+            ...prev,
+            [lineId]: { ...prev[lineId], fulfillCount },
+        }));
+    };
+
+    const canSubmit = (): boolean => {
+        const totalCount = Object.values(fulfillmentQuantities).reduce(
+            (total, { fulfillCount }) => total + fulfillCount,
+            0,
+        );
+        const fulfillmentQuantityIsValid = Object.values(fulfillmentQuantities).every(
+            ({ fulfillCount, max }) => fulfillCount <= max && fulfillCount >= 0,
+        );
+        const formIsValid = form.formState.isValid;
+        return formIsValid && totalCount > 0 && fulfillmentQuantityIsValid;
+    };
+
+    const handleSubmit = async (data: FormData) => {
+        setIsSubmitting(true);
+        try {
+            const lines = Object.entries(fulfillmentQuantities)
+                .filter(([, { fulfillCount }]) => fulfillCount > 0)
+                .map(([orderLineId, { fulfillCount }]) => ({
+                    orderLineId,
+                    quantity: fulfillCount,
+                }));
+
+            fulfillOrderMutation.mutate({
+                input: {
+                    lines,
+                    handler: data.handler,
+                },
+            });
+            setOpen(false);
+            form.reset();
+            setFulfillmentQuantities({});
+        } catch (error) {
+            toast(i18n.t('Failed to fulfill order'), {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        } finally {
+            setIsSubmitting(false);
+        }
+    };
+
+    const handleCancel = () => {
+        form.reset();
+        setFulfillmentQuantities({});
+        setOpen(false);
+    };
+
+    const handleOpen = () => {
+        setOpen(true);
+        // Initialize quantities after a short delay to ensure data is loaded
+        setTimeout(initializeFulfillmentQuantities, 100);
+    };
+
+    const fulfillmentHandlers = fulfillmentHandlersData?.fulfillmentHandlers;
+    const selectedHandler = fulfillmentHandlers?.find(h => h.code === form.watch('handler.code'));
+
+    return (
+        <>
+            <Button
+                onClick={e => {
+                    e.stopPropagation();
+                    handleOpen();
+                }}
+                className="mr-2"
+            >
+                <Trans>Fulfill order</Trans>
+            </Button>
+            <Dialog open={open}>
+                <DialogContent className="sm:max-w-[600px]">
+                    <DialogHeader>
+                        <DialogTitle>
+                            <Trans>Fulfill order</Trans>
+                        </DialogTitle>
+                        <DialogDescription>
+                            <Trans>Select quantities to fulfill and configure the fulfillment handler</Trans>
+                        </DialogDescription>
+                    </DialogHeader>
+                    <Form {...form}>
+                        <form
+                            onSubmit={e => {
+                                e.stopPropagation();
+                                form.handleSubmit(handleSubmit)(e);
+                            }}
+                            className="space-y-4"
+                        >
+                            <div className="space-y-4">
+                                <div className="font-medium">
+                                    <Trans>Order lines</Trans>
+                                </div>
+                                {order.lines.map(line => {
+                                    const quantity = fulfillmentQuantities[line.id];
+                                    if (!quantity || quantity.max <= 0) return null;
+
+                                    return (
+                                        <div
+                                            key={line.id}
+                                            className="flex items-center justify-between p-3 border rounded-md"
+                                        >
+                                            <div className="flex-1">
+                                                <div className="font-medium">{line.productVariant.name}</div>
+                                                <div className="text-sm text-muted-foreground">
+                                                    SKU: {line.productVariant.sku}
+                                                </div>
+                                                <div className="text-sm text-muted-foreground">
+                                                    <Trans>
+                                                        {quantity.max} of {line.quantity} available to fulfill
+                                                    </Trans>
+                                                </div>
+                                            </div>
+                                            <div className="flex items-center space-x-2">
+                                                <Label htmlFor={`quantity-${line.id}`}>
+                                                    <Trans>Quantity</Trans>
+                                                </Label>
+                                                <Input
+                                                    id={`quantity-${line.id}`}
+                                                    type="number"
+                                                    min="0"
+                                                    max={quantity.max}
+                                                    value={quantity.fulfillCount}
+                                                    onChange={e => {
+                                                        const value = parseInt(e.target.value) || 0;
+                                                        updateFulfillmentQuantity(line.id, value);
+                                                    }}
+                                                    className="w-20"
+                                                />
+                                            </div>
+                                        </div>
+                                    );
+                                })}
+                            </div>
+
+                            {selectedHandler && (
+                                <FormFieldWrapper
+                                    control={form.control}
+                                    name="handler"
+                                    label={<Trans>Fulfillment handler</Trans>}
+                                    render={({ field }) => (
+                                        <ConfigurableOperationInput
+                                            operationDefinition={selectedHandler}
+                                            value={field.value}
+                                            onChange={field.onChange}
+                                            readonly={false}
+                                            removable={false}
+                                        />
+                                    )}
+                                />
+                            )}
+
+                            <DialogFooter>
+                                <Button type="button" variant="outline" onClick={handleCancel}>
+                                    <Trans>Cancel</Trans>
+                                </Button>
+                                <Button type="submit" disabled={!canSubmit() || isSubmitting}>
+                                    {isSubmitting ? (
+                                        <Trans>Fulfilling...</Trans>
+                                    ) : (
+                                        <Trans>Fulfill order</Trans>
+                                    )}
+                                </Button>
+                            </DialogFooter>
+                        </form>
+                    </Form>
+                </DialogContent>
+            </Dialog>
+        </>
+    );
+}

+ 173 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/fulfillment-details.tsx

@@ -0,0 +1,173 @@
+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';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { useMutation } from '@tanstack/react-query';
+import { ChevronDown } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+    fulfillmentFragment,
+    orderDetailFragment,
+    transitionFulfillmentToStateDocument,
+} from '../orders.graphql.js';
+
+type Order = NonNullable<ResultOf<typeof orderDetailFragment>>;
+
+type FulfillmentDetailsProps = {
+    order: Order;
+    fulfillment: ResultOf<typeof fulfillmentFragment>;
+    onSuccess?: () => void;
+};
+
+export function FulfillmentDetails({ order, fulfillment, onSuccess }: Readonly<FulfillmentDetailsProps>) {
+    const { formatDate } = useLocalFormat();
+    const { i18n } = useLingui();
+
+    // Create a map of order lines by ID for quick lookup
+    const orderLinesMap = new Map(order.lines.map(line => [line.id, line]));
+
+    const transitionFulfillmentMutation = useMutation({
+        mutationFn: api.mutate(transitionFulfillmentToStateDocument),
+        onSuccess: (result: ResultOf<typeof transitionFulfillmentToStateDocument>) => {
+            const fulfillment = result.transitionFulfillmentToState;
+            if (fulfillment.__typename === 'Fulfillment') {
+                toast.success(i18n.t('Fulfillment state updated successfully'));
+                onSuccess?.();
+            } else {
+                toast.error(fulfillment.message ?? i18n.t('Failed to update fulfillment state'));
+            }
+        },
+        onError: error => {
+            toast.error(i18n.t('Failed to update fulfillment state'));
+        },
+    });
+
+    const nextSuggestedState = (): string | undefined => {
+        const { nextStates } = fulfillment;
+        const namedStateOrDefault = (targetState: string) =>
+            nextStates.includes(targetState) ? targetState : nextStates[0];
+
+        switch (fulfillment.state) {
+            case 'Pending':
+                return namedStateOrDefault('Shipped');
+            case 'Shipped':
+                return namedStateOrDefault('Delivered');
+            default:
+                return nextStates.find(s => s !== 'Cancelled');
+        }
+    };
+
+    const nextOtherStates = (): string[] => {
+        const suggested = nextSuggestedState();
+        return fulfillment.nextStates.filter(s => s !== suggested);
+    };
+
+    const handleStateTransition = (state: string) => {
+        transitionFulfillmentMutation.mutate({
+            id: fulfillment.id,
+            state,
+        });
+    };
+
+    return (
+        <div className="space-y-1 p-3 border rounded-md">
+            <div className="space-y-1">
+                <LabeledData label={<Trans>Fulfillment ID</Trans>} value={fulfillment.id.slice(-8)} />
+                <LabeledData label={<Trans>Method</Trans>} value={fulfillment.method} />
+                <LabeledData label={<Trans>State</Trans>} value={fulfillment.state} />
+                {fulfillment.trackingCode && (
+                    <LabeledData label={<Trans>Tracking code</Trans>} value={fulfillment.trackingCode} />
+                )}
+                <LabeledData label={<Trans>Created</Trans>} value={formatDate(fulfillment.createdAt)} />
+            </div>
+
+            {fulfillment.lines.length > 0 && (
+                <div className="mt-3 pt-3 border-t">
+                    <Collapsible>
+                        <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>
+                                Fulfilled items (
+                                {fulfillment.lines.reduce((acc, line) => acc + line.quantity, 0)})
+                            </Trans>
+                            <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) => {
+                                const orderLine = orderLinesMap.get(line.orderLineId);
+                                const productName = orderLine?.productVariant?.name ?? 'Unknown product';
+                                const sku = orderLine?.productVariant?.sku;
+
+                                return (
+                                    <div key={line.orderLineId} className="text-sm text-muted-foreground">
+                                        <div className="font-medium text-foreground text-xs">
+                                            {productName}
+                                        </div>
+                                        <div className="flex items-center gap-2 text-xs">
+                                            <span>Qty: {line.quantity}</span>
+                                            {sku && <span>SKU: {sku}</span>}
+                                        </div>
+                                    </div>
+                                );
+                            })}
+                        </CollapsibleContent>
+                    </Collapsible>
+                </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>
+    );
+}

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

@@ -24,11 +24,11 @@ export function OrderAddress({ address }: Readonly<{ address?: OrderAddress }>)
     } = address;
 
     return (
-        <div className="space-y-2">
+        <div className="space-y-1 text-sm">
             {fullName && <p className="font-medium">{fullName}</p>}
             {company && <p className="text-sm text-muted-foreground">{company}</p>}
 
-            <div className="text-sm">
+            <div>
                 {streetLine1 && <p>{streetLine1}</p>}
                 {streetLine2 && <p>{streetLine2}</p>}
                 <p>{[city, province].filter(Boolean).join(', ')}</p>

+ 6 - 2
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/use-order-history.ts

@@ -1,7 +1,7 @@
 import { api } from '@/vdb/graphql/api.js';
 import { graphql, ResultOf } from '@/vdb/graphql/graphql.js';
 import { useLingui } from '@/vdb/lib/trans.js';
-import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
+import { QueryKey, useInfiniteQuery, useMutation } from '@tanstack/react-query';
 import { useState } from 'react';
 import { toast } from 'sonner';
 
@@ -55,6 +55,10 @@ export interface UseOrderHistoryResult {
     hasNextPage: boolean;
 }
 
+export function orderHistoryQueryKey(orderId: string): QueryKey {
+    return ['OrderHistory', orderId];
+}
+
 export function useOrderHistory({
     orderId,
     pageSize = 10,
@@ -83,7 +87,7 @@ export function useOrderHistory({
                     take: pageSize,
                 },
             }),
-        queryKey: ['OrderHistory', orderId],
+        queryKey: orderHistoryQueryKey(orderId),
         initialPageParam: 0,
         getNextPageParam: (lastPage, _pages, lastPageParam) => {
             const totalItems = lastPage.order?.history?.totalItems ?? 0;

+ 2 - 5
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx

@@ -1,13 +1,10 @@
 import { TableCell, TableRow } from '@/vdb/components/ui/table.js';
-import { ResultOf } from '@/vdb/graphql/graphql.js';
 import { Trans } from '@/vdb/lib/trans.js';
-import { orderDetailDocument } from '../orders.graphql.js';
+import { Order } from '../utils/order-types.js';
 import { MoneyGrossNet } from './money-gross-net.js';
 
-type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
-
 export interface OrderTableTotalsProps {
-    order: OrderFragment;
+    order: Order;
     columnCount: number;
 }
 

+ 2 - 3
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-tax-summary.tsx

@@ -1,10 +1,9 @@
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { Trans } from '@/vdb/lib/trans.js';
-import { ResultOf } from 'gql.tada';
-import { orderDetailFragment } from '../orders.graphql.js';
+import { Order } from '../utils/order-types.js';
 
-export function OrderTaxSummary({ order }: Readonly<{ order: ResultOf<typeof orderDetailFragment> }>) {
+export function OrderTaxSummary({ order }: Readonly<{ order: Order }>) {
     const { formatCurrency } = useLocalFormat();
     return (
         <div>

+ 14 - 29
packages/dashboard/src/app/routes/_authenticated/_orders/components/payment-details.tsx

@@ -1,7 +1,11 @@
+import { LabeledData } from '@/vdb/components/labeled-data.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 { JsonEditor } from 'json-edit-react';
 
 type PaymentDetailsProps = {
     payment: ResultOf<typeof paymentWithRefundsFragment>;
@@ -13,17 +17,13 @@ export function PaymentDetails({ payment, currencyCode }: Readonly<PaymentDetail
     const t = (key: string) => key;
 
     return (
-        <div className="space-y-2">
+        <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
@@ -32,30 +32,15 @@ export function PaymentDetails({ payment, currencyCode }: Readonly<PaymentDetail
                     className="text-destructive"
                 />
             )}
-
-            <LabeledData
-                label={<Trans>Payment metadata</Trans>}
-                value={
-                    <pre className="max-h-96 overflow-auto rounded-md bg-muted p-4 text-sm">
-                        {JSON.stringify(payment.metadata, null, 2)}
-                    </pre>
-                }
-            />
-        </div>
-    );
-}
-
-type LabeledDataProps = {
-    label: string | React.ReactNode;
-    value: React.ReactNode;
-    className?: string;
-};
-
-function LabeledData({ label, value, className }: LabeledDataProps) {
-    return (
-        <div className="">
-            <span className="font-medium text-muted-foreground text-sm">{label}</span>
-            <div className={`col-span-2 ${className}`}>{value}</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>
         </div>
     );
 }

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

@@ -1,4 +1,8 @@
-import { assetFragment, errorResultFragment } from '@/vdb/graphql/fragments.js';
+import {
+    assetFragment,
+    configurableOperationDefFragment,
+    errorResultFragment,
+} from '@/vdb/graphql/fragments.js';
 import { graphql } from '@/vdb/graphql/graphql.js';
 
 export const orderListDocument = graphql(`
@@ -20,7 +24,6 @@ export const orderListDocument = graphql(`
                 total
                 totalWithTax
                 currencyCode
-
                 shippingLines {
                     shippingMethod {
                         name
@@ -508,3 +511,98 @@ export const transitionOrderToStateDocument = graphql(
     `,
     [errorResultFragment],
 );
+
+export const paymentMethodsDocument = graphql(`
+    query GetPaymentMethods($options: PaymentMethodListOptions!) {
+        paymentMethods(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+                code
+                description
+                enabled
+            }
+            totalItems
+        }
+    }
+`);
+
+export const addManualPaymentToOrderDocument = graphql(
+    `
+        mutation AddManualPaymentToOrder($input: ManualPaymentInput!) {
+            addManualPaymentToOrder(input: $input) {
+                __typename
+                ... on Order {
+                    id
+                    state
+                    payments {
+                        id
+                        amount
+                        method
+                        state
+                    }
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const fulfillmentHandlersDocument = graphql(
+    `
+        query GetFulfillmentHandlers {
+            fulfillmentHandlers {
+                ...ConfigurableOperationDef
+            }
+        }
+    `,
+    [configurableOperationDefFragment],
+);
+
+export const fulfillOrderDocument = graphql(
+    `
+        mutation FulfillOrder($input: FulfillOrderInput!) {
+            addFulfillmentToOrder(input: $input) {
+                __typename
+                ... on Fulfillment {
+                    id
+                    state
+                    method
+                    trackingCode
+                    lines {
+                        orderLineId
+                        quantity
+                    }
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const transitionFulfillmentToStateDocument = graphql(
+    `
+        mutation TransitionFulfillmentToState($id: ID!, $state: String!) {
+            transitionFulfillmentToState(id: $id, state: $state) {
+                __typename
+                ... on Fulfillment {
+                    id
+                    state
+                    nextStates
+                    method
+                    trackingCode
+                    lines {
+                        orderLineId
+                        quantity
+                    }
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);

+ 58 - 2
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx

@@ -18,12 +18,18 @@ import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { Link, createFileRoute, redirect } from '@tanstack/react-router';
 import { User } from 'lucide-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 { 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 { 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';
 
@@ -59,8 +65,8 @@ export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
 function OrderDetailPage() {
     const params = Route.useParams();
     const { i18n } = useLingui();
-
-    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+    const queryClient = useQueryClient();
+    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
         pageId,
         queryDocument: orderDetailDocument,
         setValuesForUpdate: entity => {
@@ -85,11 +91,35 @@ function OrderDetailPage() {
         return null;
     }
 
+    const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
+    const showFulfillButton = canAddFulfillment(entity);
+
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
             <PageTitle>{entity?.code ?? ''}</PageTitle>
             <PageActionBar>
                 <PageActionBarRight>
+                    {showAddPaymentButton && (
+                        <PermissionGuard requires={['UpdateOrder']}>
+                            <AddManualPaymentDialog
+                                order={entity}
+                                onSuccess={() => {
+                                    refreshEntity();
+                                }}
+                            />
+                        </PermissionGuard>
+                    )}
+                    {showFulfillButton && (
+                        <PermissionGuard requires={['UpdateOrder']}>
+                            <FulfillOrderDialog
+                                order={entity}
+                                onSuccess={() => {
+                                    refreshEntity();
+                                    queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
+                                }}
+                            />
+                        </PermissionGuard>
+                    )}
                     <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
                         <Button
                             type="submit"
@@ -149,6 +179,32 @@ function OrderDetailPage() {
                         />
                     ))}
                 </PageBlock>
+
+                <PageBlock
+                    column="side"
+                    blockId="fulfillment-details"
+                    title={<Trans>Fulfillment details</Trans>}
+                >
+                    {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>
+                )}
+                </PageBlock>
             </PageLayout>
         </Page>
     );

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-types.ts

@@ -0,0 +1,7 @@
+import { ResultOf } from '@/vdb/graphql/graphql.js';
+
+import { 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];

+ 77 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/utils/order-utils.ts

@@ -0,0 +1,77 @@
+import { Fulfillment, Order, Payment } from './order-types.js';
+
+/**
+ * Calculates the outstanding payment amount for an order
+ */
+export function calculateOutstandingPaymentAmount(order: Order): number {
+    if (!order) return 0;
+
+    const paymentIsValid = (p: Payment): boolean =>
+        p.state !== 'Cancelled' && p.state !== 'Declined' && p.state !== 'Error';
+
+    let amountCovered = 0;
+    for (const payment of order.payments?.filter(paymentIsValid) ?? []) {
+        const refunds = payment.refunds.filter(r => r.state !== 'Failed') ?? [];
+        const refundsTotal = refunds.reduce((sum, refund) => sum + (refund.total || 0), 0);
+        amountCovered += payment.amount - refundsTotal;
+    }
+    return order.totalWithTax - amountCovered;
+}
+
+/**
+ * Checks if an order has unsettled modifications
+ */
+export function hasUnsettledModifications(order: Order): boolean {
+    if (!order) return false;
+    return order.modifications.some(m => !m.isSettled);
+}
+
+/**
+ * Determines if the add manual payment button should be displayed
+ */
+export function shouldShowAddManualPaymentButton(order: Order): boolean {
+    if (!order) return false;
+
+    return (
+        order.type !== 'Aggregate' &&
+        (order.state === 'ArrangingPayment' || order.state === 'ArrangingAdditionalPayment') &&
+        (hasUnsettledModifications(order) || calculateOutstandingPaymentAmount(order) > 0)
+    );
+}
+
+/**
+ * Determines if we can add a fulfillment to an order
+ */
+export function canAddFulfillment(order: Order): boolean {
+    if (!order) return false;
+
+    // Get all fulfillment lines from non-cancelled fulfillments
+    const allFulfillmentLines: Fulfillment['lines'] = (order.fulfillments ?? [])
+        .filter(fulfillment => fulfillment.state !== 'Cancelled')
+        .reduce((all, fulfillment) => [...all, ...fulfillment.lines], [] as Fulfillment['lines']);
+
+    // Check if all items are already fulfilled
+    let allItemsFulfilled = true;
+    for (const line of order.lines) {
+        const totalFulfilledCount = allFulfillmentLines
+            .filter(row => row.orderLineId === line.id)
+            .reduce((sum, row) => sum + row.quantity, 0);
+        if (totalFulfilledCount < line.quantity) {
+            allItemsFulfilled = false;
+            break;
+        }
+    }
+
+    // Check if order is in a fulfillable state
+    const isFulfillableState =
+        order.nextStates.includes('Shipped') ||
+        order.nextStates.includes('PartiallyShipped') ||
+        order.nextStates.includes('Delivered');
+
+    return (
+        !allItemsFulfilled &&
+        !hasUnsettledModifications(order) &&
+        calculateOutstandingPaymentAmount(order) === 0 &&
+        isFulfillableState
+    );
+}

+ 21 - 0
packages/dashboard/src/lib/components/labeled-data.tsx

@@ -0,0 +1,21 @@
+type LabeledDataProps = {
+    label: string | React.ReactNode;
+    value: React.ReactNode;
+    className?: string;
+};
+
+/**
+ * @description
+ * Used to display a value with a label, like
+ *
+ * Order Code
+ * QWERTY
+ */
+export function LabeledData({ label, value, className }: Readonly<LabeledDataProps>) {
+    return (
+        <div className="">
+            <span className="font-medium text-muted-foreground text-xs">{label}</span>
+            <div className={`col-span-2 text-sm ${className}`}>{value}</div>
+        </div>
+    );
+}