소스 검색

feat(dashboard): Draft order view

Michael Bromley 9 달 전
부모
커밋
1644ec6a3d

+ 104 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/customer-address-selector.tsx

@@ -0,0 +1,104 @@
+import { Button } from '@/components/ui/button.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { api } from '@/graphql/api.js';
+import { graphql, ResultOf } from '@/graphql/graphql.js';
+import { useQuery } from '@tanstack/react-query';
+import { Trans } from '@/lib/trans.js';
+import { useLingui } from '@/lib/trans.js';
+import { addressFragment } from '../../_customers/customers.graphql.js';
+import { Card } from '@/components/ui/card.js';
+import { cn } from '@/lib/utils.js';
+import { useState } from 'react';
+import { Plus } from 'lucide-react';
+
+const getCustomerAddressesDocument = graphql(
+    `
+        query GetCustomerAddresses($customerId: ID!) {
+            customer(id: $customerId) {
+                id
+                addresses {
+                    ...Address
+                }
+            }
+        }
+    `,
+    [addressFragment],
+);
+
+type CustomerAddressesQuery = ResultOf<typeof getCustomerAddressesDocument>;
+
+interface CustomerAddressSelectorProps {
+    customerId: string | undefined;
+    onSelect: (address: ResultOf<typeof addressFragment>) => void;
+}
+
+export function CustomerAddressSelector({ customerId, onSelect }: CustomerAddressSelectorProps) {
+    const { i18n } = useLingui();
+    const [open, setOpen] = useState(false);
+
+    const { data, isLoading } = useQuery<CustomerAddressesQuery>({
+        queryKey: ['customerAddresses', customerId],
+        queryFn: () => api.query(getCustomerAddressesDocument, { customerId: customerId ?? '' }),
+        enabled: !!customerId,
+    });
+
+    const addresses: ResultOf<typeof addressFragment>[] = data?.customer?.addresses || [];
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <div className="flex items-center gap-2">
+                    <Button variant="outline" size="sm" className="" disabled={!customerId}>
+                        <Plus className="h-4 w-4" />
+                        <Trans>Select address</Trans>
+                    </Button>
+                </div>
+            </PopoverTrigger>
+            <PopoverContent className="w-[400px] p-0" align="start">
+                <div className="p-4">
+                    <h4 className="mb-4">
+                        <Trans>Select an address</Trans>
+                    </h4>
+                    <div className="space-y-2">
+                        {isLoading ? (
+                            <div className="text-sm text-muted-foreground">
+                                <Trans>Loading addresses...</Trans>
+                            </div>
+                        ) : addresses.length === 0 ? (
+                            <div className="text-sm text-muted-foreground">
+                                <Trans>No addresses found</Trans>
+                            </div>
+                        ) : (
+                            addresses.map(address => (
+                                <Card
+                                    key={address.id}
+                                    className={cn(
+                                        'p-4 cursor-pointer hover:bg-accent transition-colors',
+                                    )}
+                                    onClick={() => {
+                                        onSelect(address);
+                                        setOpen(false);
+                                    }}
+                                >
+                                    <div className="flex flex-col gap-1 text-sm">
+                                        <div className="font-semibold">{address.fullName}</div>
+                                        {address.company && <div>{address.company}</div>}
+                                        <div>{address.streetLine1}</div>
+                                        {address.streetLine2 && <div>{address.streetLine2}</div>}
+                                        <div>
+                                            {address.city}
+                                            {address.province && `, ${address.province}`}
+                                        </div>
+                                        <div>{address.postalCode}</div>
+                                        <div>{address.country.name}</div>
+                                        {address.phoneNumber && <div>{address.phoneNumber}</div>}
+                                    </div>
+                                </Card>
+                            ))
+                        )}
+                    </div>
+                </div>
+            </PopoverContent>
+        </Popover>
+    );
+}

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

@@ -0,0 +1,212 @@
+import { ProductVariantSelector } from '@/components/shared/product-variant-selector.js';
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { Button } from '@/components/ui/button.js';
+import { Input } from '@/components/ui/input.js';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { Trans } from '@/lib/trans.js';
+import {
+    ColumnDef,
+    flexRender,
+    getCoreRowModel,
+    useReactTable,
+    VisibilityState,
+} from '@tanstack/react-table';
+import { Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { draftOrderEligibleShippingMethodsDocument, orderDetailDocument, orderLineFragment } from '../orders.graphql.js';
+import { MoneyGrossNet } from './money-gross-net.js';
+import { OrderTableTotals } from './order-table-totals.js';
+import { ShippingMethodSelector } from './shipping-method-selector.js';
+
+type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+type OrderLineFragment = ResultOf<typeof orderLineFragment>;
+
+type ShippingMethodQuote = ResultOf<typeof draftOrderEligibleShippingMethodsDocument>['eligibleShippingMethodsForDraftOrder'][number];
+
+export interface OrderTableProps {
+    order: OrderFragment;
+    eligibleShippingMethods: ShippingMethodQuote[];
+    onAddItem: (event: { productVariantId: string; }) => void;
+    onAdjustLine: (event: { lineId: string; quantity: number }) => void;
+    onRemoveLine: (event: { lineId: string }) => void;
+    onSetShippingMethod: (event: { shippingMethodId: string }) => void;
+    onApplyCouponCode: (event: { couponCode: string }) => void;
+    onRemoveCouponCode: (event: { couponCode: string }) => void;
+}
+
+export function EditOrderTable({ order, eligibleShippingMethods, onAddItem, onAdjustLine, onRemoveLine, onSetShippingMethod, onApplyCouponCode, onRemoveCouponCode }: OrderTableProps) {
+    const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
+    const [couponCode, setCouponCode] = useState('');
+
+    const currencyCode = order.currencyCode;
+
+    const columns: ColumnDef<OrderLineFragment>[] = [
+        {
+            header: 'Image',
+            accessorKey: 'featuredAsset',
+            cell: ({ row }) => {
+                const asset = row.original.featuredAsset;
+                return <VendureImage asset={asset} preset="tiny" />;
+            },
+        },
+        {
+            header: 'Product',
+            accessorKey: 'productVariant.name',
+        },
+        {
+            header: 'SKU',
+            accessorKey: 'productVariant.sku',
+        },
+        {
+            header: 'Unit price',
+            accessorKey: 'unitPriceWithTax',
+            cell: ({ row }) => {
+                const value = row.original.unitPriceWithTax
+                const netValue = row.original.unitPrice;
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />
+            },
+        },
+        {
+            header: 'Quantity',
+            accessorKey: 'quantity',
+            cell: ({ row }) => {
+                return <div className="flex gap-2">
+                    <Input type="number" value={row.original.quantity} onChange={e => onAdjustLine({ lineId: row.original.id, quantity: e.target.valueAsNumber })} />
+                    <Button variant="outline" size="icon" onClick={() => onRemoveLine({ lineId: row.original.id })}>
+                        <Trash2 />
+                    </Button>
+                </div>;
+            },
+        },
+        {
+            header: 'Total',
+            accessorKey: 'linePriceWithTax',
+            cell: ({ cell, row }) => {
+                const value = row.original.linePriceWithTax;
+                const netValue = row.original.linePrice;
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
+            },
+        },
+    ];
+
+    const data = order.lines;
+
+    const table = useReactTable({
+        data,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+        rowCount: data.length,
+        onColumnVisibilityChange: setColumnVisibility,
+        state: {
+            columnVisibility,
+        },
+    });
+
+    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>
+                            ))
+                        ) : null}
+                        <TableRow>
+                            <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 });
+                                        }} />
+                                    </div>
+                                </div>
+                            </TableCell>
+                        </TableRow>
+                        <TableRow>
+                            <TableCell colSpan={columns.length} className="h-12">
+                                <ShippingMethodSelector
+                                    eligibleShippingMethods={eligibleShippingMethods}
+                                    selectedShippingMethodId={order.shippingLines?.[0]?.shippingMethod?.id}
+                                    currencyCode={currencyCode}
+                                    onSelect={(shippingMethodId) => onSetShippingMethod({ shippingMethodId })}
+                                />
+                            </TableCell>
+                        </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
+                                            onClick={() => onApplyCouponCode({ couponCode })}
+                                            disabled={!couponCode}
+                                        >
+                                            <Trans>Apply</Trans>
+                                        </Button>
+                                    </div>
+                                    {order.couponCodes?.length > 0 && (
+                                        <div className="flex flex-wrap gap-2">
+                                            {order.couponCodes.map((code) => (
+                                                <div
+                                                    key={code}
+                                                    className="flex items-center gap-2 px-3 py-1 text-sm border rounded-md"
+                                                >
+                                                    <span>{code}</span>
+                                                    <Button
+                                                        variant="ghost"
+                                                        size="sm"
+                                                        className="h-6 w-6 p-0"
+                                                        onClick={() => onRemoveCouponCode({ couponCode: code })}
+                                                    >
+                                                        <Trash2 className="h-4 w-4" />
+                                                    </Button>
+                                                </div>
+                                            ))}
+                                        </div>
+                                    )}
+                                </div>
+                            </TableCell>
+                        </TableRow>
+                        <OrderTableTotals order={order} columnCount={columns.length} />
+                    </TableBody>
+                </Table>
+            </div>
+        </div>
+    );
+}

+ 18 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/money-gross-net.tsx

@@ -0,0 +1,18 @@
+import { Money } from "@/components/data-display/money.js";
+
+export interface MoneyGrossNetProps {
+    priceWithTax: number;
+    price: number;
+    currencyCode: string;
+}
+
+export function MoneyGrossNet({ priceWithTax, price, currencyCode }: MoneyGrossNetProps) {
+    return   <div className="flex flex-col gap-1">
+        <div>
+            <Money value={priceWithTax} currencyCode={currencyCode} />
+        </div>
+        <div className="text-xs text-muted-foreground">
+            <Money value={price} currencyCode={currencyCode} />
+        </div>
+    </div>;
+}

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

@@ -32,7 +32,8 @@ export function OrderAddress({ address }: { address?: OrderAddress }) {
             <div className="text-sm">
                 {streetLine1 && <p>{streetLine1}</p>}
                 {streetLine2 && <p>{streetLine2}</p>}
-                <p>{[city, province, postalCode].filter(Boolean).join(', ')}</p>
+                <p>{[city, province].filter(Boolean).join(', ')}</p>
+                {postalCode && <p>{postalCode}</p>}
                 {country && (
                     <div className="flex items-center gap-1.5 mt-1">
                         <Globe className="h-3 w-3 text-muted-foreground" />

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

@@ -0,0 +1,53 @@
+import { ResultOf } from "@/graphql/graphql.js";
+import { orderDetailDocument } from "../orders.graphql.js";
+import { TableRow, TableCell } from "@/components/ui/table.js";
+import { MoneyGrossNet } from "./money-gross-net.js";
+import { Trans } from "@/lib/trans.js";
+
+type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
+
+export interface OrderTableTotalsProps {
+    order: OrderFragment;
+    columnCount: number;
+}
+
+export function OrderTableTotals({ order, columnCount }: OrderTableTotalsProps) {
+    const currencyCode = order.currencyCode;
+
+    return (
+        <>
+            {order.discounts?.length > 0 ? order.discounts.map(discount => <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12">
+                    <Trans>Discount</Trans>: {discount.description}
+                </TableCell>
+                <TableCell colSpan={1} className="h-12">
+                    <MoneyGrossNet priceWithTax={discount.amountWithTax} price={discount.amount} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>) : null}
+            <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12">
+                    <Trans>Sub total</Trans>
+                </TableCell>
+                <TableCell colSpan={1} className="h-12">
+                    <MoneyGrossNet priceWithTax={order.subTotalWithTax} price={order.subTotal} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>
+            <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12">
+                    <Trans>Shipping</Trans>
+                </TableCell>
+                <TableCell colSpan={1} className="h-12">
+                    <MoneyGrossNet priceWithTax={order.shippingWithTax} price={order.shipping} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>
+            <TableRow>
+                <TableCell colSpan={columnCount - 1} className="h-12 font-bold">
+                    <Trans>Total</Trans>
+                </TableCell>
+                <TableCell colSpan={1} className="h-12 font-bold">
+                    <MoneyGrossNet priceWithTax={order.totalWithTax} price={order.total} currencyCode={currencyCode} />
+                </TableCell>
+            </TableRow>
+        </>
+    )
+}

+ 8 - 49
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table.tsx

@@ -1,3 +1,4 @@
+import { VendureImage } from '@/components/shared/vendure-image.js';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
 import { ResultOf } from '@/graphql/graphql.js';
 import {
@@ -9,9 +10,8 @@ import {
 } from '@tanstack/react-table';
 import { useState } from 'react';
 import { orderDetailDocument, orderLineFragment } from '../orders.graphql.js';
-import { VendureImage } from '@/components/shared/vendure-image.js';
-import { Money } from '@/components/data-display/money.js';
-import { Trans } from '@/lib/trans.js';
+import { MoneyGrossNet } from './money-gross-net.js';
+import { OrderTableTotals } from './order-table-totals.js';
 
 type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
 type OrderLineFragment = ResultOf<typeof orderLineFragment>;
@@ -46,18 +46,9 @@ export function OrderTable({ order }: OrderTableProps) {
             header: 'Unit price',
             accessorKey: 'unitPriceWithTax',
             cell: ({ cell, row }) => {
-                const value = cell.getValue();
+                const value = row.original.unitPriceWithTax;
                 const netValue = row.original.unitPrice;
-                return (
-                    <div className="flex flex-col gap-1">
-                        <div>
-                            <Money value={value} currencyCode={currencyCode} />
-                        </div>
-                        <div className="text-xs text-muted-foreground">
-                            <Money value={netValue} currencyCode={currencyCode} />
-                        </div>
-                    </div>
-                );
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
             },
         },
         {
@@ -68,18 +59,9 @@ export function OrderTable({ order }: OrderTableProps) {
             header: 'Total',
             accessorKey: 'linePriceWithTax',
             cell: ({ cell, row }) => {
-                const value = cell.getValue();
+                const value = row.original.linePriceWithTax;
                 const netValue = row.original.linePrice;
-                return (
-                    <div className="flex flex-col gap-1">
-                        <div>
-                            <Money value={value} currencyCode={currencyCode} />
-                        </div>
-                        <div className="text-xs text-muted-foreground">
-                            <Money value={netValue} currencyCode={currencyCode} />
-                        </div>
-                    </div>
-                );
+                return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
             },
         },
     ];
@@ -137,30 +119,7 @@ export function OrderTable({ order }: OrderTableProps) {
                                 </TableCell>
                             </TableRow>
                         )}
-                        <TableRow>
-                            <TableCell colSpan={columns.length - 1} className="h-12">
-                                <Trans>Sub total</Trans>
-                            </TableCell>
-                            <TableCell colSpan={1} className="h-12">
-                                <Money value={order.subTotalWithTax} currencyCode={currencyCode} />
-                            </TableCell>
-                        </TableRow>
-                        <TableRow>
-                            <TableCell colSpan={columns.length - 1} className="h-12">
-                                <Trans>Shipping</Trans>
-                            </TableCell>
-                            <TableCell colSpan={1} className="h-12">
-                                <Money value={order.shippingWithTax} currencyCode={currencyCode} />
-                            </TableCell>
-                        </TableRow>
-                        <TableRow>
-                            <TableCell colSpan={columns.length - 1} className="h-12 font-bold">
-                                <Trans>Total</Trans>
-                            </TableCell>
-                            <TableCell colSpan={1} className="h-12 font-bold">
-                                <Money value={order.totalWithTax} currencyCode={currencyCode} />
-                            </TableCell>
-                        </TableRow>
+                        <OrderTableTotals order={order} columnCount={columns.length} />
                     </TableBody>
                 </Table>
             </div>

+ 65 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/shipping-method-selector.tsx

@@ -0,0 +1,65 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { Money } from '@/components/data-display/money.js';
+import { Trans } from '@/lib/trans.js';
+import { draftOrderEligibleShippingMethodsDocument } from '../orders.graphql.js';
+import { ResultOf } from '@/graphql/graphql.js';
+
+type ShippingMethodQuote = ResultOf<typeof draftOrderEligibleShippingMethodsDocument>['eligibleShippingMethodsForDraftOrder'][number];
+
+interface ShippingMethodSelectorProps {
+    eligibleShippingMethods: ShippingMethodQuote[];
+    selectedShippingMethodId?: string;
+    currencyCode: string;
+    onSelect: (shippingMethodId: string) => void;
+}
+
+export function ShippingMethodSelector({ 
+    eligibleShippingMethods, 
+    selectedShippingMethodId,
+    currencyCode,
+    onSelect 
+}: ShippingMethodSelectorProps) {
+    return (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+            {eligibleShippingMethods?.length ? eligibleShippingMethods.map(method => (
+                <Card 
+                    key={method.id} 
+                    className={`cursor-pointer hover:bg-muted/50 transition-colors border-2 border-transparent ${
+                        selectedShippingMethodId === method.id 
+                            ? 'border-primary' 
+                            : ''
+                    }`}
+                    onClick={() => onSelect(method.id)}
+                >
+                    <CardHeader className="pb-2">
+                        <CardTitle className="">
+                            <Trans>{method.name}</Trans>
+                        </CardTitle>
+                    </CardHeader>
+                    <CardContent>
+                        <div className="space-y-2">
+                            {method.description && (
+                                <p className="text-sm text-muted-foreground">
+                                    <Trans>{method.description}</Trans>
+                                </p>
+                            )}
+                            <div className="flex items-center justify-between">
+                                <span className="text-sm font-medium">
+                                    <Trans>Price</Trans>
+                                </span>
+                                <Money 
+                                    value={method.priceWithTax} 
+                                    currencyCode={currencyCode} 
+                                />
+                            </div>
+                        </div>
+                    </CardContent>
+                </Card>
+            )) : (
+                <div className="col-span-full text-center text-muted-foreground">
+                    <Trans>No shipping methods available</Trans>
+                </div>
+            )}
+        </div>
+    );
+} 

+ 181 - 1
packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts

@@ -1,4 +1,4 @@
-import { assetFragment } from '@/graphql/fragments.js';
+import { assetFragment, errorResultFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 import { gql } from 'awesome-graphql-client';
 
@@ -214,6 +214,7 @@ export const orderDetailFragment = graphql(
             promotions {
                 id
                 couponCode
+                name
             }
             subTotal
             subTotalWithTax
@@ -323,3 +324,182 @@ export const orderHistoryDocument = graphql(`
         }
     }
 `);
+
+export const createDraftOrderDocument = graphql(`
+    mutation CreateDraftOrder {
+        createDraftOrder {
+            id
+        }
+    }
+`);
+
+export const deleteDraftOrderDocument = graphql(
+    `
+        mutation DeleteDraftOrder($orderId: ID!) {
+            deleteDraftOrder(orderId: $orderId) {
+                result
+                message
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const addItemToDraftOrderDocument = graphql(
+    `
+        mutation AddItemToDraftOrder($orderId: ID!, $input: AddItemToDraftOrderInput!) {
+            addItemToDraftOrder(orderId: $orderId, input: $input) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const adjustDraftOrderLineDocument = graphql(
+    `
+        mutation AdjustDraftOrderLine($orderId: ID!, $input: AdjustDraftOrderLineInput!) {
+            adjustDraftOrderLine(orderId: $orderId, input: $input) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const removeDraftOrderLineDocument = graphql(
+    `
+        mutation RemoveDraftOrderLine($orderId: ID!, $orderLineId: ID!) {
+            removeDraftOrderLine(orderId: $orderId, orderLineId: $orderLineId) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const setCustomerForDraftOrderDocument = graphql(
+    `
+        mutation SetCustomerForDraftOrder($orderId: ID!, $customerId: ID, $input: CreateCustomerInput) {
+            setCustomerForDraftOrder(orderId: $orderId, customerId: $customerId, input: $input) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const setShippingAddressForDraftOrderDocument = graphql(`
+    mutation SetDraftOrderShippingAddress($orderId: ID!, $input: CreateAddressInput!) {
+        setDraftOrderShippingAddress(orderId: $orderId, input: $input) {
+            id
+        }
+    }
+`);
+
+export const setBillingAddressForDraftOrderDocument = graphql(`
+    mutation SetDraftOrderBillingAddress($orderId: ID!, $input: CreateAddressInput!) {
+        setDraftOrderBillingAddress(orderId: $orderId, input: $input) {
+            id
+        }
+    }
+`);
+
+export const unsetShippingAddressForDraftOrderDocument = graphql(`
+    mutation UnsetDraftOrderShippingAddress($orderId: ID!) {
+        unsetDraftOrderShippingAddress(orderId: $orderId) {
+            id
+        }
+    }
+`);
+
+export const unsetBillingAddressForDraftOrderDocument = graphql(`
+    mutation UnsetDraftOrderBillingAddress($orderId: ID!) {
+        unsetDraftOrderBillingAddress(orderId: $orderId) {
+            id
+        }
+    }
+`);
+
+export const applyCouponCodeToDraftOrderDocument = graphql(
+    `
+        mutation ApplyCouponCodeToDraftOrder($orderId: ID!, $couponCode: String!) {
+            applyCouponCodeToDraftOrder(orderId: $orderId, couponCode: $couponCode) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const removeCouponCodeFromDraftOrderDocument = graphql(`
+    mutation RemoveCouponCodeFromDraftOrder($orderId: ID!, $couponCode: String!) {
+        removeCouponCodeFromDraftOrder(orderId: $orderId, couponCode: $couponCode) {
+            id
+        }
+    }
+`);
+
+export const draftOrderEligibleShippingMethodsDocument = graphql(`
+    query DraftOrderEligibleShippingMethods($orderId: ID!) {
+        eligibleShippingMethodsForDraftOrder(orderId: $orderId) {
+            id
+            name
+            code
+            description
+            price
+            priceWithTax
+            metadata
+        }
+    }
+`);
+
+export const setDraftOrderShippingMethodDocument = graphql(
+    `
+        mutation SetDraftOrderShippingMethod($orderId: ID!, $shippingMethodId: ID!) {
+            setDraftOrderShippingMethod(orderId: $orderId, shippingMethodId: $shippingMethodId) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);
+
+export const transitionOrderToStateDocument = graphql(
+    `
+        mutation TransitionOrderToState($id: ID!, $state: String!) {
+            transitionOrderToState(id: $id, state: $state) {
+                __typename
+                ... on Order {
+                    id
+                }
+                ...ErrorResult
+            }
+        }
+    `,
+    [errorResultFragment],
+);

+ 26 - 5
packages/dashboard/src/app/routes/_authenticated/_orders/orders.tsx

@@ -4,9 +4,14 @@ import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
-import { createFileRoute, Link } from '@tanstack/react-router';
-import { orderListDocument } from './orders.graphql.js';
+import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { createDraftOrderDocument, orderListDocument } from './orders.graphql.js';
 import { useServerConfig } from '@/hooks/use-server-config.js';
+import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { PlusIcon } from 'lucide-react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '@/graphql/api.js';
+import { ResultOf } from '@/graphql/graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders')({
     component: OrderListPage,
@@ -15,6 +20,13 @@ export const Route = createFileRoute('/_authenticated/_orders/orders')({
 
 function OrderListPage() {
     const serverConfig = useServerConfig();
+    const navigate = useNavigate();
+    const { mutate: createDraftOrder } = useMutation({
+        mutationFn: api.mutate(createDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof createDraftOrderDocument>) => {
+            navigate({ to: '/orders/draft/$id', params: { id: result.createDraftOrder.id } });
+        }
+    })
     return (
         <ListPage
             pageId="order-list"
@@ -35,8 +47,10 @@ function OrderListPage() {
             defaultSort={[{ id: 'orderPlacedAt', desc: true }]}
             transformVariables={variables => {
                 return {
-                    ...variables,
-                    filterOperator: 'OR',
+                    options: {
+                        ...variables.options,
+                        filterOperator: 'OR',
+                    }
                 };
             }}
             listQuery={orderListDocument}
@@ -115,6 +129,13 @@ function OrderListPage() {
                     }) ?? [],
                 },
             }}
-        />
+        >
+            <PageActionBarRight>
+                <Button onClick={() => createDraftOrder({})}>
+                    <PlusIcon className="mr-2 h-4 w-4" />
+                    <Trans>Draft order</Trans>
+                </Button>
+            </PageActionBarRight>
+        </ListPage>
     );
 }

+ 31 - 9
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.$id.tsx

@@ -2,6 +2,7 @@ import { ErrorPage } from '@/components/shared/error-page.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Badge } from '@/components/ui/badge.js';
 import { Button } from '@/components/ui/button.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import {
     CustomFieldsPageBlock,
     Page,
@@ -11,10 +12,10 @@ import {
     PageLayout,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
-import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
-import { useDetailPage } from '@/framework/page/use-detail-page.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { ResultOf } from '@/graphql/graphql.js';
 import { Trans, useLingui } from '@/lib/trans.js';
-import { Link, createFileRoute } from '@tanstack/react-router';
+import { Link, createFileRoute, redirect } from '@tanstack/react-router';
 import { User } from 'lucide-react';
 import { toast } from 'sonner';
 import { OrderAddress } from './components/order-address.js';
@@ -26,12 +27,33 @@ import { orderDetailDocument } from './orders.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
     component: OrderDetailPage,
-    loader: detailPageRouteLoader({
-        queryDocument: orderDetailDocument,
-        breadcrumb(_isNew, entity) {
-            return [{ path: '/orders', label: 'Orders' }, entity?.code];
-        },
-    }),
+    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}`,
+            });
+        }
+
+        return {
+            breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code],
+        };
+    },
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
 });
 

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

@@ -0,0 +1,329 @@
+import { CustomerSelector } from '@/components/shared/customer-selector.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { Page, PageActionBar, PageActionBarRight, PageBlock, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { api } from '@/graphql/api.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
+import { ResultOf } from 'gql.tada';
+import { User, Trash2 } from 'lucide-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 { addItemToDraftOrderDocument, adjustDraftOrderLineDocument, applyCouponCodeToDraftOrderDocument, draftOrderEligibleShippingMethodsDocument, orderDetailDocument, removeCouponCodeFromDraftOrderDocument, removeDraftOrderLineDocument, setBillingAddressForDraftOrderDocument, setCustomerForDraftOrderDocument, setDraftOrderShippingMethodDocument, setShippingAddressForDraftOrderDocument, transitionOrderToStateDocument, unsetBillingAddressForDraftOrderDocument, unsetShippingAddressForDraftOrderDocument } from './orders.graphql.js';
+import { Input } from '@/components/ui/input.js';
+import { useState } from 'react';
+
+export const Route = createFileRoute('/_authenticated/_orders/orders_/draft/$id')({
+    component: DraftOrderPage,
+    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/${params.id}`,
+            });
+        }
+
+        return {
+            breadcrumb: [{ path: '/orders', label: 'Orders' }, result.order.code],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function DraftOrderPage() {
+    const params = Route.useParams();
+    const { i18n } = useLingui();
+    const navigate = useNavigate();
+
+    const { entity, refreshEntity } = useDetailPage({
+        queryDocument: orderDetailDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+    });
+
+    const { data: eligibleShippingMethods } = useQuery({
+        queryKey: ['eligibleShippingMethods', entity?.id],
+        queryFn: () => api.query(draftOrderEligibleShippingMethodsDocument, { orderId: entity?.id ?? '' }),
+        enabled: !!entity?.shippingAddress?.streetLine1,
+    });
+
+    const { mutate: addItemToDraftOrder } = useMutation({
+        mutationFn: api.mutate(addItemToDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof addItemToDraftOrderDocument>) => {
+            const order = result.addItemToDraftOrder;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Item added to order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: adjustDraftOrderLine } = useMutation({
+        mutationFn: api.mutate(adjustDraftOrderLineDocument),
+        onSuccess: (result: ResultOf<typeof adjustDraftOrderLineDocument>) => {
+            const order = result.adjustDraftOrderLine;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Order line updated'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: removeDraftOrderLine } = useMutation({
+        mutationFn: api.mutate(removeDraftOrderLineDocument),
+        onSuccess: (result: ResultOf<typeof removeDraftOrderLineDocument>) => {
+            const order = result.removeDraftOrderLine;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Order line removed'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: setCustomerForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setCustomerForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof setCustomerForDraftOrderDocument>) => {
+            const order = result.setCustomerForDraftOrder;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Customer set for order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: setShippingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setShippingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof setShippingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Shipping address set for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: setBillingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setBillingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof setBillingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Billing address set for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: unsetShippingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(unsetShippingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof unsetShippingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Shipping address unset for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: unsetBillingAddressForDraftOrder } = useMutation({
+        mutationFn: api.mutate(unsetBillingAddressForDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof unsetBillingAddressForDraftOrderDocument>) => {
+            toast.success(i18n.t('Billing address unset for order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: setShippingMethodForDraftOrder } = useMutation({
+        mutationFn: api.mutate(setDraftOrderShippingMethodDocument),
+        onSuccess: (result: ResultOf<typeof setDraftOrderShippingMethodDocument>) => {
+            const order = result.setDraftOrderShippingMethod;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Shipping method set for order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    // coupon code
+    const { mutate: setCouponCodeForDraftOrder } = useMutation({
+        mutationFn: api.mutate(applyCouponCodeToDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof applyCouponCodeToDraftOrderDocument>) => {
+            const order = result.applyCouponCodeToDraftOrder;
+            switch (order.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Coupon code set for order'));
+                    refreshEntity();
+                    break;
+                default:
+                    toast.error(order.message);
+                    break;
+            }
+        },
+    });
+
+    const { mutate: removeCouponCodeForDraftOrder } = useMutation({
+        mutationFn: api.mutate(removeCouponCodeFromDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof removeCouponCodeFromDraftOrderDocument>) => {
+            const order = result.removeCouponCodeFromDraftOrder;
+            toast.success(i18n.t('Coupon code removed from order'));
+            refreshEntity();
+        },
+    });
+
+    const { mutate: completeDraftOrder } = useMutation({
+        mutationFn: api.mutate(transitionOrderToStateDocument),
+        onSuccess: async (result: ResultOf<typeof transitionOrderToStateDocument>) => {
+            const order = result.transitionOrderToState;
+            switch (order?.__typename) {
+                case 'Order':
+                    toast.success(i18n.t('Draft order completed'));
+                    refreshEntity();
+                    setTimeout(() => {
+                        navigate({ to: `/orders/$id`, params: { id: order.id } });
+                    }, 500);
+                    break;
+                default:
+                    toast.error(order ? order.message : 'Unknown error');
+                    break;
+            }
+        },
+    });
+
+    if (!entity) {
+        return null;
+    }
+
+    return (
+        <Page pageId="order-detail">
+            <PageTitle><Trans>Draft order</Trans>: {entity?.code ?? ''}</PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['UpdateOrder']}>
+                        <Button type="submit"
+                            disabled={!entity.customer || entity.lines.length === 0 || entity.shippingLines.length === 0 || entity.state !== 'Draft'}
+                            onClick={() => completeDraftOrder({ id: entity.id, state: 'ArrangingPayment' })}
+                        >
+                            <Trans>Complete draft</Trans>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="main" blockId="order-table">
+                    <EditOrderTable order={entity}
+                        eligibleShippingMethods={eligibleShippingMethods?.eligibleShippingMethodsForDraftOrder ?? []}
+                        onSetShippingMethod={(e) => setShippingMethodForDraftOrder({ orderId: entity.id, shippingMethodId: e.shippingMethodId })}
+                        onAddItem={(e) => addItemToDraftOrder({ orderId: entity.id, input: { productVariantId: e.productVariantId, quantity: 1 } })}
+                        onAdjustLine={(e) => adjustDraftOrderLine({ orderId: entity.id, input: { orderLineId: e.lineId, quantity: e.quantity } })}
+                        onRemoveLine={(e) => removeDraftOrderLine({ orderId: entity.id, orderLineId: e.lineId })}
+                        onApplyCouponCode={(e) => setCouponCodeForDraftOrder({ orderId: entity.id, couponCode: e.couponCode })}
+                        onRemoveCouponCode={(e) => removeCouponCodeForDraftOrder({ orderId: entity.id, couponCode: e.couponCode })}
+                    />
+                </PageBlock>
+                <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
+                    {entity?.customer?.id ? <Button variant="ghost" asChild className="mb-4">
+                        <Link to={`/customers/${entity?.customer?.id}`}>
+                            <User className="w-4 h-4" />
+                            {entity?.customer?.firstName} {entity?.customer?.lastName}
+                        </Link>
+                    </Button> : null}
+                    <CustomerSelector onSelect={customer => {
+                        setCustomerForDraftOrder({ orderId: entity.id, customerId: customer.id });
+                    }} />
+                </PageBlock>
+                <PageBlock column="side" blockId="shipping-address" title={<Trans>Shipping address</Trans>}>
+                    <div className="flex flex-col">
+                        <OrderAddress address={entity.shippingAddress ?? undefined} />
+                        {entity.shippingAddress?.streetLine1 ?
+                            <RemoveAddressButton onClick={() => unsetShippingAddressForDraftOrder({ orderId: entity.id })} /> : <CustomerAddressSelector customerId={entity.customer?.id} onSelect={address => {
+                                setShippingAddressForDraftOrder({
+                                    orderId: entity.id, input: {
+                                        fullName: address.fullName,
+                                        company: address.company,
+                                        streetLine1: address.streetLine1,
+                                        streetLine2: address.streetLine2,
+                                        city: address.city,
+                                        province: address.province,
+                                        postalCode: address.postalCode,
+                                        countryCode: address.country.code,
+                                        phoneNumber: address.phoneNumber,
+                                    }
+                                });
+                            }} />
+                        }
+                    </div>
+                </PageBlock>
+                <PageBlock column="side" blockId="billing-address" title={<Trans>Billing address</Trans>}>
+                    <div className="flex flex-col">
+                        <OrderAddress address={entity.billingAddress ?? undefined} />
+                        {entity.billingAddress?.streetLine1 ? <RemoveAddressButton onClick={() => unsetBillingAddressForDraftOrder({ orderId: entity.id })} /> : <CustomerAddressSelector customerId={entity.customer?.id} onSelect={address => {
+                            setBillingAddressForDraftOrder({
+                                orderId: entity.id, input: {
+                                    fullName: address.fullName,
+                                    company: address.company,
+                                    streetLine1: address.streetLine1,
+                                    streetLine2: address.streetLine2,
+                                    city: address.city,
+                                    province: address.province,
+                                    postalCode: address.postalCode,
+                                    countryCode: address.country.code,
+                                    phoneNumber: address.phoneNumber,
+                                }
+                            });
+                        }} />
+                        }
+                    </div>
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    );
+}
+
+function RemoveAddressButton(props: { onClick: () => void }) {
+    return (<div className="">
+        <Button variant="outline" className="mt-4" size="sm" onClick={props.onClick}>
+            <Trans>Remove</Trans>
+        </Button>
+    </div>)
+}

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_products/products.tsx

@@ -38,7 +38,7 @@ function ProductListPage() {
                     <Button asChild>
                         <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />
-                            New Product
+                            <Trans>New Product</Trans>
                         </Link>
                     </Button>
                 </PermissionGuard>

+ 13 - 14
packages/dashboard/src/lib/components/shared/customer-selector.tsx

@@ -5,6 +5,7 @@ import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
 import { Trans } from '@/lib/trans.js';
 import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
 import { Plus, Search } from 'lucide-react';
 import { useState } from 'react';
 
@@ -38,19 +39,20 @@ export interface CustomerSelectorProps {
 export function CustomerSelector(props: CustomerSelectorProps) {
     const [open, setOpen] = useState(false);
     const [searchTerm, setSearchTerm] = useState('');
+    const debouncedSearchTerm = useDebounce(searchTerm, 300);
 
     const { data, isLoading } = useQuery({
-        queryKey: ['customers', searchTerm],
+        queryKey: ['customers', debouncedSearchTerm],
         queryFn: () =>
             api.query(customersDocument, {
                 options: {
                     sort: { lastName: 'ASC' },
-                    filter: searchTerm ? {
-                        firstName: { contains: searchTerm },
-                        lastName: { contains: searchTerm },
-                        emailAddress: { contains: searchTerm },
+                    filter: debouncedSearchTerm ? {
+                        firstName: { contains: debouncedSearchTerm },
+                        lastName: { contains: debouncedSearchTerm },
+                        emailAddress: { contains: debouncedSearchTerm },
                     } : undefined,
-                    filterOperator: searchTerm ? 'OR' : undefined,
+                    filterOperator: debouncedSearchTerm ? 'OR' : undefined,
                 },
             }),
         staleTime: 1000 * 60, // 1 minute
@@ -70,14 +72,11 @@ export function CustomerSelector(props: CustomerSelectorProps) {
             </PopoverTrigger>
             <PopoverContent className="p-0 w-[350px]" align="start">
                 <Command shouldFilter={false}>
-                    <div className="flex items-center border-b px-3">
-                        <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
-                        <CommandInput 
-                            placeholder="Search customers..." 
-                            onValueChange={handleSearch}
-                            className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
-                        />
-                    </div>
+                    <CommandInput
+                        placeholder="Search customers..."
+                        onValueChange={handleSearch}
+                        className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
+                    />
                     <CommandList>
                         <CommandEmpty>
                             {isLoading ? (

+ 111 - 0
packages/dashboard/src/lib/components/shared/product-variant-selector.tsx

@@ -0,0 +1,111 @@
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command.js';
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger,
+} from "@/components/ui/popover.js";
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { cn } from '@/lib/utils.js';
+import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import { ChevronsUpDown, Plus } from 'lucide-react';
+import { useState } from 'react';
+import { Button } from '../ui/button.js';
+import { assetFragment } from '@/graphql/fragments.js';
+import { VendureImage } from './vendure-image.js';
+
+const productVariantListDocument = graphql(`
+    query ProductVariantList($options: ProductVariantListOptions) {
+        productVariants(options: $options) {
+            items {
+                id
+                name
+                sku
+                featuredAsset {
+                    ...Asset
+                }
+            }
+            totalItems
+        }
+    }
+`, [assetFragment]);
+
+export interface ProductVariantSelectorProps {
+    onProductVariantIdChange: (productVariantId: string) => void;
+}
+
+export function ProductVariantSelector({ onProductVariantIdChange }: ProductVariantSelectorProps) {
+    const [search, setSearch] = useState('');
+    const [open, setOpen] = useState(false);
+    const debouncedSearch = useDebounce(search, 500);
+
+    const { data, isLoading } = useQuery({
+        queryKey: ['productVariants', debouncedSearch],
+        staleTime: 1000 * 60 * 5,
+        enabled: debouncedSearch.length > 0,
+        queryFn: () =>
+            api.query(productVariantListDocument, {
+                options: {
+                    take: 10,
+                    filter: {
+                        name: { contains: debouncedSearch },
+                        sku: { contains: debouncedSearch },
+                    },
+                    filterOperator: 'OR',
+                },
+            }),
+    });
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <Button
+                    variant="outline"
+                    role="combobox"
+                    className="w-full"
+                >
+                    Add item to order
+                    <Plus className="opacity-50" />
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="p-0">
+                <Command shouldFilter={false}>
+                    <CommandInput
+                        placeholder="Add item to order..."
+                        className="h-9"
+                       onValueChange={(value) => setSearch(value)}
+                    />
+                    <CommandList>
+                        <CommandEmpty>No products found.</CommandEmpty>
+                        <CommandGroup>
+                            {data?.productVariants.items.map((variant) => (
+                                <CommandItem
+                                    key={variant.id}
+                                    value={variant.id}
+                                    onSelect={() => {
+                                        onProductVariantIdChange(variant.id);
+                                        setOpen(false);
+                                    }}
+                                    className="flex items-center gap-2 p-2"
+                                >
+                                    {variant.featuredAsset && (
+                                        <VendureImage
+                                            asset={variant.featuredAsset}
+                                            preset="tiny"
+                                            className="size-8 rounded-md object-cover"
+                                        />
+                                    )}
+                                    <div className="flex flex-col">
+                                        <span className="text-sm font-medium">{variant.name}</span>
+                                        <span className="text-xs text-muted-foreground">{variant.sku}</span>
+                                    </div>
+                                </CommandItem>
+                            ))}
+                        </CommandGroup>
+                    </CommandList>
+                </Command>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 8 - 0
packages/dashboard/src/lib/graphql/fragments.tsx

@@ -51,4 +51,12 @@ export const configurableOperationDefFragment = graphql(`
     }
 `);
 
+export const errorResultFragment = graphql(`
+    fragment ErrorResult on ErrorResult {
+        errorCode
+        message
+    }
+`);
+
+
 export type ConfigurableOperationDefFragment = ResultOf<typeof configurableOperationDefFragment>;