Преглед изворни кода

feat(dashboard): Implement adding new variants & product options

Michael Bromley пре 7 месеци
родитељ
комит
7646a4ade2

+ 369 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/add-product-variant-dialog.tsx

@@ -0,0 +1,369 @@
+import { MoneyInput } from '@/components/data-input/money-input.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/components/ui/dialog.js';
+import { Form } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { api } from '@/graphql/api.js';
+import { graphql, ResultOf, VariablesOf } from '@/graphql/graphql.js';
+import { useChannel } from '@/hooks/use-channel.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { Plus } from 'lucide-react';
+import { useCallback, useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import * as z from 'zod';
+import { CreateProductOptionsDialog } from './create-product-options-dialog.js';
+import { ProductOptionSelect } from './product-option-select.js';
+
+const getProductOptionGroupsDocument = graphql(`
+    query GetProductOptionGroups($productId: ID!) {
+        product(id: $productId) {
+            id
+            name
+            optionGroups {
+                id
+                code
+                name
+                options {
+                    id
+                    code
+                    name
+                }
+            }
+            variants {
+                id
+                name
+                sku
+                options {
+                    id
+                    code
+                    name
+                    groupId
+                }
+            }
+        }
+    }
+`);
+
+const createProductVariantDocument = graphql(`
+    mutation CreateProductVariant($input: CreateProductVariantInput!) {
+        createProductVariants(input: [$input]) {
+            id
+        }
+    }
+`);
+
+const createProductOptionDocument = graphql(`
+    mutation CreateProductOption($input: CreateProductOptionInput!) {
+        createProductOption(input: $input) {
+            id
+            code
+            name
+            groupId
+        }
+    }
+`);
+
+const createProductOptionGroupDocument = graphql(`
+    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
+        createProductOptionGroup(input: $input) {
+            id
+        }
+    }
+`);
+
+const addOptionGroupToProductDocument = graphql(`
+    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
+        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            id
+            optionGroups {
+                id
+                code
+                name
+                options {
+                    id
+                    code
+                    name
+                }
+            }
+        }
+    }
+`);
+
+const formSchema = z.object({
+    name: z.string().min(1, 'Name is required'),
+    sku: z.string().min(1, 'SKU is required'),
+    price: z.string().min(1, 'Price is required'),
+    stockOnHand: z.string().min(1, 'Stock level is required'),
+    options: z.record(z.string(), z.string()),
+});
+
+type FormValues = z.infer<typeof formSchema>;
+
+export function AddProductVariantDialog({
+    productId,
+    onSuccess,
+}: {
+    productId: string;
+    onSuccess?: () => void;
+}) {
+    const [open, setOpen] = useState(false);
+    const { activeChannel } = useChannel();
+    const { i18n } = useLingui();
+    const [duplicateVariantError, setDuplicateVariantError] = useState<string | null>(null);
+    const [nameTouched, setNameTouched] = useState(false);
+
+    const { data: productData, refetch } = useQuery({
+        queryKey: ['productOptionGroups', productId],
+        queryFn: () => api.query(getProductOptionGroupsDocument, { productId }),
+    });
+
+    const form = useForm<FormValues>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            name: '',
+            sku: '',
+            price: '0',
+            stockOnHand: '0',
+            options: {},
+        },
+    });
+
+    const checkForDuplicateVariant = useCallback(
+        (values: FormValues) => {
+            if (!productData?.product) return;
+
+            const newOptionIds = Object.values(values.options).sort();
+            if (newOptionIds.length !== productData.product.optionGroups.length) {
+                setDuplicateVariantError(null);
+                return;
+            }
+
+            const existingVariant = productData.product.variants.find(variant => {
+                const variantOptionIds = variant.options.map(opt => opt.id).sort();
+                return JSON.stringify(variantOptionIds) === JSON.stringify(newOptionIds);
+            });
+
+            if (existingVariant) {
+                setDuplicateVariantError(
+                    `A variant with these options already exists: ${existingVariant.name} (${existingVariant.sku})`,
+                );
+            } else {
+                setDuplicateVariantError(null);
+            }
+        },
+        [productData?.product],
+    );
+
+    const generateNameFromOptions = useCallback(
+        (values: FormValues) => {
+            if (!productData?.product?.name || nameTouched) return;
+
+            const selectedOptions = Object.entries(values.options)
+                .map(([groupId, optionId]) => {
+                    const group = productData.product?.optionGroups.find(g => g.id === groupId);
+                    const option = group?.options.find(o => o.id === optionId);
+                    return option?.name;
+                })
+                .filter(Boolean);
+
+            if (selectedOptions.length === productData.product.optionGroups.length) {
+                const newName = `${productData.product.name} ${selectedOptions.join(' ')}`;
+                form.setValue('name', newName, { shouldDirty: true });
+            }
+        },
+        [productData?.product, nameTouched, form],
+    );
+
+    // Watch for changes in options to check for duplicates and update name
+    const options = form.watch('options');
+    useEffect(() => {
+        checkForDuplicateVariant(form.getValues());
+        generateNameFromOptions(form.getValues());
+    }, [JSON.stringify(options), checkForDuplicateVariant, generateNameFromOptions, form]);
+
+    // Also check when the dialog opens and product data is loaded
+    useEffect(() => {
+        if (open && productData?.product) {
+            checkForDuplicateVariant(form.getValues());
+        }
+    }, [open, productData?.product, checkForDuplicateVariant, form]);
+
+    const createProductVariantMutation = useMutation({
+        mutationFn: api.mutate(createProductVariantDocument),
+        onSuccess: (result: ResultOf<typeof createProductVariantDocument>) => {
+            toast.success(i18n.t('Successfully created product variant'));
+            setOpen(false);
+            onSuccess?.();
+        },
+        onError: error => {
+            toast.error(i18n.t('Failed to create product variant'), {
+                description: error instanceof Error ? error.message : i18n.t('Unknown error'),
+            });
+        },
+    });
+
+    const createProductOptionMutation = useMutation({
+        mutationFn: api.mutate(createProductOptionDocument),
+        onSuccess: (
+            result: ResultOf<typeof createProductOptionDocument>,
+            variables: VariablesOf<typeof createProductOptionDocument>,
+        ) => {
+            if (result?.createProductOption) {
+                // Update the form with the new option
+                const currentOptions = form.getValues('options');
+                form.setValue('options', {
+                    ...currentOptions,
+                    [variables.input.productOptionGroupId]: result.createProductOption.id,
+                });
+                // Refetch product data to get the new option
+                refetch();
+            }
+        },
+    });
+
+    const onSubmit = useCallback(
+        (values: FormValues) => {
+            if (!productData?.product) return;
+            if (duplicateVariantError) return;
+
+            createProductVariantMutation.mutate({
+                input: {
+                    productId,
+                    sku: values.sku,
+                    price: Number(values.price),
+                    stockOnHand: Number(values.stockOnHand),
+                    optionIds: Object.values(values.options),
+                    translations: [
+                        {
+                            languageCode: 'en',
+                            name: values.name,
+                        },
+                    ],
+                },
+            });
+        },
+        [createProductVariantMutation, productData?.product, duplicateVariantError, productId],
+    );
+
+    // If there are no option groups, show the create options dialog instead
+    if (productData?.product?.optionGroups.length === 0) {
+        return (
+            <CreateProductOptionsDialog
+                productId={productId}
+                onSuccess={() => {
+                    refetch();
+                    onSuccess?.();
+                }}
+            />
+        );
+    }
+
+    return (
+        <Dialog open={open} onOpenChange={setOpen}>
+            <DialogTrigger asChild>
+                <Button variant="outline">
+                    <Plus className="mr-2 h-4 w-4" />
+                    <Trans>Add variant</Trans>
+                </Button>
+            </DialogTrigger>
+            <DialogContent>
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Add product variant</Trans>
+                    </DialogTitle>
+                </DialogHeader>
+                <Form {...form}>
+                    <form
+                        onSubmit={e => {
+                            e.stopPropagation();
+                            form.handleSubmit(onSubmit)(e);
+                        }}
+                        className="space-y-4"
+                    >
+                        <div className="grid grid-cols-2 gap-4">
+                            {productData?.product?.optionGroups.map(group => (
+                                <ProductOptionSelect
+                                    key={group.id}
+                                    group={group}
+                                    value={form.watch(`options.${group.id}`)}
+                                    onChange={value => {
+                                        form.setValue(`options.${group.id}`, value, {
+                                            shouldDirty: true,
+                                            shouldValidate: true,
+                                        });
+                                    }}
+                                    onCreateOption={name => {
+                                        createProductOptionMutation.mutate({
+                                            input: {
+                                                productOptionGroupId: group.id,
+                                                code: name.toLowerCase().replace(/\s+/g, '-'),
+                                                translations: [
+                                                    {
+                                                        languageCode: 'en',
+                                                        name,
+                                                    },
+                                                ],
+                                            },
+                                        });
+                                    }}
+                                />
+                            ))}
+                        </div>
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="name"
+                            label={<Trans>Name</Trans>}
+                            render={({ field }) => <Input {...field} onFocus={() => setNameTouched(true)} />}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="sku"
+                            label={<Trans>SKU</Trans>}
+                            render={({ field }) => <Input {...field} />}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="price"
+                            label={<Trans>Price</Trans>}
+                            render={({ field }) => (
+                                <MoneyInput
+                                    value={Number(field.value) || 0}
+                                    onChange={value => field.onChange(value.toString())}
+                                    currency={activeChannel?.defaultCurrencyCode ?? 'USD'}
+                                />
+                            )}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="stockOnHand"
+                            label={<Trans>Stock level</Trans>}
+                            render={({ field }) => <Input type="number" {...field} />}
+                        />
+                        <DialogFooter className="flex flex-col items-end gap-2">
+                            {duplicateVariantError && (
+                                <p className="text-sm text-destructive">{duplicateVariantError}</p>
+                            )}
+                            <Button
+                                type="submit"
+                                disabled={createProductVariantMutation.isPending || !!duplicateVariantError}
+                            >
+                                <Trans>Create variant</Trans>
+                            </Button>
+                        </DialogFooter>
+                    </form>
+                </Form>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 435 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-options-dialog.tsx

@@ -0,0 +1,435 @@
+import { Button } from '@/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/components/ui/dialog.js';
+import { Form } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { Plus, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import * as z from 'zod';
+
+const getProductDocument = graphql(`
+    query GetProduct($productId: ID!) {
+        product(id: $productId) {
+            id
+            name
+            variants {
+                id
+                name
+                sku
+                options {
+                    id
+                    code
+                    name
+                    groupId
+                }
+            }
+            optionGroups {
+                id
+                code
+                name
+                options {
+                    id
+                    code
+                    name
+                }
+            }
+        }
+    }
+`);
+
+const createProductOptionGroupDocument = graphql(`
+    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
+        createProductOptionGroup(input: $input) {
+            id
+            code
+            name
+            options {
+                id
+                code
+                name
+            }
+        }
+    }
+`);
+
+const addOptionGroupToProductDocument = graphql(`
+    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
+        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            id
+            optionGroups {
+                id
+                code
+                name
+                options {
+                    id
+                    code
+                    name
+                }
+            }
+        }
+    }
+`);
+
+const updateProductVariantDocument = graphql(`
+    mutation UpdateProductVariant($input: UpdateProductVariantInput!) {
+        updateProductVariant(input: $input) {
+            id
+            name
+            options {
+                id
+                code
+                name
+            }
+        }
+    }
+`);
+
+const formSchema = z.object({
+    optionGroups: z.array(z.object({
+        name: z.string().min(1, 'Option group name is required'),
+        options: z.array(z.string().min(1, 'Option name is required')).min(1, 'At least one option is required'),
+    })).min(1, 'At least one option group is required'),
+    existingVariantOptionIds: z.array(z.string()).min(1, 'Must select an option for the existing variant'),
+});
+
+type FormValues = z.infer<typeof formSchema>;
+
+export function CreateProductOptionsDialog({
+    productId,
+    onSuccess,
+}: {
+    productId: string;
+    onSuccess?: () => void;
+}) {
+    const [open, setOpen] = useState(false);
+    const { i18n } = useLingui();
+
+    const { data: productData } = useQuery({
+        queryKey: ['product', productId],
+        queryFn: () => api.query(getProductDocument, { productId }),
+    });
+
+    const form = useForm<FormValues>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            optionGroups: [{ name: '', options: [''] }],
+            existingVariantOptionIds: [],
+        },
+    });
+
+    const createProductOptionGroupMutation = useMutation({
+        mutationFn: api.mutate(createProductOptionGroupDocument),
+    });
+
+    const addOptionGroupToProductMutation = useMutation({
+        mutationFn: api.mutate(addOptionGroupToProductDocument),
+    });
+
+    const updateProductVariantMutation = useMutation({
+        mutationFn: api.mutate(updateProductVariantDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Successfully created product options'));
+            setOpen(false);
+            onSuccess?.();
+        },
+        onError: (error) => {
+            toast.error(i18n.t('Failed to create product options'), {
+                description: error instanceof Error ? error.message : i18n.t('Unknown error'),
+            });
+        }
+    });
+
+    const onSubmit = async (values: FormValues) => {
+        if (!productData?.product) return;
+
+        try {
+            // Create all option groups and their options
+            const createdOptionGroups = await Promise.all(
+                values.optionGroups.map(async (group) => {
+                    const result = await createProductOptionGroupMutation.mutateAsync({
+                        input: {
+                            code: group.name.toLowerCase().replace(/\s+/g, '-'),
+                            translations: [
+                                {
+                                    languageCode: "en",
+                                    name: group.name,
+                                },
+                            ],
+                            options: group.options.map(option => ({
+                                code: option.toLowerCase().replace(/\s+/g, '-'),
+                                translations: [
+                                    {
+                                        languageCode: "en",
+                                        name: option,
+                                    },
+                                ],
+                            })),
+                        },
+                    });
+
+                    // Add the option group to the product
+                    await addOptionGroupToProductMutation.mutateAsync({
+                        productId,
+                        optionGroupId: result.createProductOptionGroup.id,
+                    });
+
+                    return result.createProductOptionGroup;
+                })
+            );
+
+            // Combine existing and newly created option groups
+            const allOptionGroups = [
+                ...(productData.product.optionGroups || []),
+                ...createdOptionGroups,
+            ];
+
+            // Map the selected option names to their IDs
+            const selectedOptionIds = values.existingVariantOptionIds.map((optionName, index) => {
+                const group = allOptionGroups[index];
+                const option = group.options.find(opt => opt.name === optionName);
+                if (!option) {
+                    throw new Error(`Option "${optionName}" not found in group "${group.name}"`);
+                }
+                return option.id;
+            });
+
+            // Update the existing variant with the selected options
+            if (productData.product.variants[0]) {
+                // Create a new name by appending the selected option names
+                const selectedOptionNames = values.existingVariantOptionIds
+                    .map((optionName, index) => {
+                        const group = allOptionGroups[index];
+                        const option = group.options.find(opt => opt.name === optionName);
+                        return option?.name;
+                    })
+                    .filter(Boolean)
+                    .join(' ');
+
+                const newVariantName = `${productData.product.name} ${selectedOptionNames}`;
+
+                await updateProductVariantMutation.mutateAsync({
+                    input: {
+                        id: productData.product.variants[0].id,
+                        optionIds: selectedOptionIds,
+                        translations: [
+                            {
+                                languageCode: "en",
+                                name: newVariantName,
+                            },
+                        ],
+                    },
+                });
+            }
+        } catch (error) {
+            toast.error(i18n.t('Failed to create product options'), {
+                description: error instanceof Error ? error.message : i18n.t('Unknown error'),
+            });
+        }
+    };
+
+    const addOptionGroup = () => {
+        const currentGroups = form.getValues('optionGroups');
+        form.setValue('optionGroups', [...currentGroups, { name: '', options: [''] }]);
+    };
+
+    const removeOptionGroup = (index: number) => {
+        const currentGroups = form.getValues('optionGroups');
+        form.setValue('optionGroups', currentGroups.filter((_, i) => i !== index));
+    };
+
+    const addOption = (groupIndex: number) => {
+        const currentGroups = form.getValues('optionGroups');
+        const updatedGroups = [...currentGroups];
+        updatedGroups[groupIndex].options.push('');
+        form.setValue('optionGroups', updatedGroups);
+    };
+
+    const removeOption = (groupIndex: number, optionIndex: number) => {
+        const currentGroups = form.getValues('optionGroups');
+        const updatedGroups = [...currentGroups];
+        updatedGroups[groupIndex].options = updatedGroups[groupIndex].options.filter((_, i) => i !== optionIndex);
+        form.setValue('optionGroups', updatedGroups);
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={setOpen}>
+            <DialogTrigger asChild>
+                <Button variant="outline">
+                    <Plus className="mr-2 h-4 w-4" />
+                    <Trans>Create options</Trans>
+                </Button>
+            </DialogTrigger>
+            <DialogContent className="max-w-2xl">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Create product options</Trans>
+                    </DialogTitle>
+                </DialogHeader>
+                <Form {...form}>
+                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+                        <div className="space-y-4">
+                            {form.watch('optionGroups').map((group, groupIndex) => (
+                                <div key={groupIndex} className="space-y-4 p-4 border rounded-lg">
+                                    <div className="flex justify-between items-center">
+                                        <FormFieldWrapper
+                                            control={form.control}
+                                            name={`optionGroups.${groupIndex}.name`}
+                                            label={<Trans>Option group name</Trans>}
+                                            render={({ field }) => (
+                                                <Input {...field} placeholder={i18n.t('e.g. Size')} />
+                                            )}
+                                        />
+                                        {groupIndex > 0 && (
+                                            <Button
+                                                type="button"
+                                                variant="ghost"
+                                                onClick={() => removeOptionGroup(groupIndex)}
+                                            >
+                                                <Trans>Remove group</Trans>
+                                            </Button>
+                                        )}
+                                    </div>
+                                    <div className="space-y-2">
+                                        {group.options.map((_, optionIndex) => (
+                                            <div key={optionIndex} className="flex gap-2 items-end">
+                                                <FormFieldWrapper
+                                                    control={form.control}
+                                                    name={`optionGroups.${groupIndex}.options.${optionIndex}`}
+                                                    label={<Trans>Option name</Trans>}
+                                                    render={({ field }) => (
+                                                        <Input {...field} placeholder={i18n.t('e.g. Small')} />
+                                                    )}
+                                                />
+                                                {optionIndex > 0 && (
+                                                    <Button
+                                                        type="button"
+                                                        variant="ghost"
+                                                        size="icon"
+                                                        onClick={() => removeOption(groupIndex, optionIndex)}
+                                                    >
+                                                        <Trash2 className="h-4 w-4" />
+                                                    </Button>
+                                                )}
+                                            </div>
+                                        ))}
+                                        <Button
+                                            type="button"
+                                            variant="outline"
+                                            onClick={() => addOption(groupIndex)}
+                                        >
+                                            <Plus className="mr-2 h-4 w-4" />
+                                            <Trans>Add option</Trans>
+                                        </Button>
+                                    </div>
+                                </div>
+                            ))}
+                            <Button
+                                type="button"
+                                variant="outline"
+                                size="sm"
+                                onClick={addOptionGroup}
+                            >
+                                <Plus className="mr-2 h-4 w-4" />
+                                <Trans>Add another option group</Trans>
+                            </Button>
+                        </div>
+
+                        {productData?.product?.variants[0] && (
+                            <div className="space-y-4 p-4 border rounded-lg">
+                                <h3 className="font-medium">
+                                    <Trans>Assign options to existing variant</Trans>
+                                </h3>
+                                <p className="text-sm text-muted-foreground">
+                                    <Trans>Select which options should apply to the existing variant "{productData.product.variants[0].name}"</Trans>
+                                </p>
+                                {/* Show existing option groups first */}
+                                {productData.product.optionGroups?.map((group, groupIndex) => (
+                                    <FormFieldWrapper
+                                        key={group.id}
+                                        control={form.control}
+                                        name={`existingVariantOptionIds.${groupIndex}`}
+                                        label={group.name}
+                                        render={({ field }) => (
+                                            <select
+                                                className="w-full p-2 border rounded-md"
+                                                value={field.value}
+                                                onChange={(e) => {
+                                                    const newValues = [...form.getValues('existingVariantOptionIds')];
+                                                    newValues[groupIndex] = e.target.value;
+                                                    form.setValue('existingVariantOptionIds', newValues);
+                                                }}
+                                            >
+                                                <option value="">Select an option</option>
+                                                {group.options.map((option) => (
+                                                    <option key={option.id} value={option.name}>
+                                                        {option.name}
+                                                    </option>
+                                                ))}
+                                            </select>
+                                        )}
+                                    />
+                                ))}
+                                {/* Then show new option groups */}
+                                {form.watch('optionGroups').map((group, groupIndex) => (
+                                    <FormFieldWrapper
+                                        key={`new-${groupIndex}`}
+                                        control={form.control}
+                                        name={`existingVariantOptionIds.${(productData?.product?.optionGroups?.length || 0) + groupIndex}`}
+                                        label={group.name || <Trans>Option group {groupIndex + 1}</Trans>}
+                                        render={({ field }) => (
+                                            <select
+                                                className="w-full p-2 border rounded-md"
+                                                value={field.value}
+                                                onChange={(e) => {
+                                                    const newValues = [...form.getValues('existingVariantOptionIds')];
+                                                    newValues[(productData?.product?.optionGroups?.length || 0) + groupIndex] = e.target.value;
+                                                    form.setValue('existingVariantOptionIds', newValues);
+                                                }}
+                                            >
+                                                <option value="">Select an option</option>
+                                                {group.options.map((option, optionIndex) => (
+                                                    <option key={optionIndex} value={option}>
+                                                        {option}
+                                                    </option>
+                                                ))}
+                                            </select>
+                                        )}
+                                    />
+                                ))}
+                            </div>
+                        )}
+
+                        <DialogFooter>
+                            <Button
+                                type="submit"
+                                disabled={
+                                    createProductOptionGroupMutation.isPending ||
+                                    addOptionGroupToProductMutation.isPending ||
+                                    updateProductVariantMutation.isPending ||
+                                    (productData?.product?.variants[0] && 
+                                        form.watch('existingVariantOptionIds').some(value => !value))
+                                }
+                            >
+                                <Trans>Create options</Trans>
+                            </Button>
+                        </DialogFooter>
+                    </form>
+                </Form>
+            </DialogContent>
+        </Dialog>
+    );
+} 

+ 117 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/product-option-select.tsx

@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { Plus, Check, ChevronsUpDown } from 'lucide-react';
+import { Button } from '@/components/ui/button.js';
+import { FormFieldWrapper } from '@/components/shared/form-field-wrapper.js';
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+} from '@/components/ui/command.js';
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger,
+} from '@/components/ui/popover.js';
+import { cn } from '@/lib/utils.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+interface ProductOption {
+    id: string;
+    code: string;
+    name: string;
+}
+
+interface ProductOptionGroup {
+    id: string;
+    code: string;
+    name: string;
+    options: ProductOption[];
+}
+
+interface ProductOptionSelectProps {
+    group: ProductOptionGroup;
+    value: string;
+    onChange: (value: string) => void;
+    onCreateOption: (name: string) => void;
+}
+
+export function ProductOptionSelect({ group, value, onChange, onCreateOption }: ProductOptionSelectProps) {
+    const [open, setOpen] = useState(false);
+    const [newOptionInput, setNewOptionInput] = useState('');
+    const { i18n } = useLingui();
+
+    return (
+        <FormFieldWrapper
+            control={undefined}
+            name={`options.${group.id}`}
+            label={group.name}
+            render={() => (
+                <Popover
+                    open={open}
+                    onOpenChange={setOpen}
+                >
+                    <PopoverTrigger asChild>
+                        <Button
+                            variant="outline"
+                            role="combobox"
+                            className="w-full justify-between"
+                        >
+                            {value
+                                ? group.options.find(
+                                    (option) => option.id === value,
+                                )?.name
+                                : <Trans>Select {group.name}</Trans>}
+                            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                        </Button>
+                    </PopoverTrigger>
+                    <PopoverContent className="w-full p-0">
+                        <Command>
+                            <CommandInput 
+                                placeholder={i18n.t('Search {name}...').replace('{name}', group.name)} 
+                                onValueChange={setNewOptionInput}
+                            />
+                            <CommandEmpty className='py-2'>
+                                <div className="p-2">
+                                    <Button
+                                        variant="outline"
+                                        className="w-full justify-start"
+                                        onClick={() => {
+                                            if (newOptionInput) {
+                                                onCreateOption(newOptionInput);
+                                            }
+                                        }}
+                                    >
+                                        <Plus className="mr-2 h-4 w-4" />
+                                        <Trans>Add "{newOptionInput}"</Trans>
+                                    </Button>
+                                </div>
+                            </CommandEmpty>
+                            <CommandGroup>
+                                {group.options.map((option) => (
+                                    <CommandItem
+                                        key={option.id}
+                                        value={option.name}
+                                        onSelect={() => {
+                                            onChange(option.id);
+                                            setOpen(false);
+                                        }}
+                                    >
+                                        <Check
+                                            className={cn(
+                                                "mr-2 h-4 w-4",
+                                                value === option.id ? "opacity-100" : "opacity-0"
+                                            )}
+                                        />
+                                        {option.name}
+                                    </CommandItem>
+                                ))}
+                            </CommandGroup>
+                        </Command>
+                    </PopoverContent>
+                </Popover>
+            )}
+        />
+    );
+} 

+ 4 - 2
packages/dashboard/src/app/routes/_authenticated/_products/components/product-variants-table.tsx

@@ -1,4 +1,4 @@
-import { PaginatedListDataTable } from "@/components/shared/paginated-list-data-table.js";
+import { PaginatedListDataTable, PaginatedListRefresherRegisterFn } from "@/components/shared/paginated-list-data-table.js";
 import { productVariantListDocument } from "../products.graphql.js";
 import { useState } from "react";
 import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
@@ -9,9 +9,10 @@ import { Button } from "@/components/ui/button.js";
 
 interface ProductVariantsTableProps {
     productId: string;
+    registerRefresher?: PaginatedListRefresherRegisterFn;
 }
 
-export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
+export function ProductVariantsTable({ productId, registerRefresher }: ProductVariantsTableProps) {
     const { formatCurrencyName } = useLocalFormat();
     const [page, setPage] = useState(1);
     const [pageSize, setPageSize] = useState(10);
@@ -19,6 +20,7 @@ export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
     const [filters, setFilters] = useState<ColumnFiltersState>([]);
 
     return <PaginatedListDataTable
+        registerRefresher={registerRefresher}
         listQuery={productVariantListDocument}
         transformVariables={variables => ({
             ...variables,

+ 16 - 2
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx

@@ -27,7 +27,9 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
 import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
+import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
+import { useRef } from 'react';
 
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
@@ -48,6 +50,7 @@ function ProductDetailPage() {
     const navigate = useNavigate();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
+    const refreshRef = useRef<() => void>(() => {});
 
     const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
         queryDocument: productDetailDocument,
@@ -85,7 +88,7 @@ function ProductDetailPage() {
             });
         },
     });
-
+    
     return (
         <Page pageId="product-detail" entity={entity} form={form} submitHandler={submitHandler}>
             <PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
@@ -139,7 +142,18 @@ function ProductDetailPage() {
                 <CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
                 {entity && entity.variantList.totalItems > 0 && (
                     <PageBlock column="main" blockId="product-variants-table">
-                        <ProductVariantsTable productId={params.id} />
+                        <ProductVariantsTable productId={params.id} registerRefresher={refresher => {
+                            refreshRef.current = refresher;
+                        }} />
+                         <div className="mt-4">
+                            <AddProductVariantDialog
+                                productId={params.id}
+                                onSuccess={() => {
+                                    console.log('onSuccess');
+                                    refreshRef.current?.();
+                                }}
+                            />
+                        </div>
                     </PageBlock>
                 )}
                 {entity && entity.variantList.totalItems === 0 && (

+ 10 - 0
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -174,6 +174,8 @@ export interface RowAction<T> {
     onClick?: (row: Row<T>) => void;
 }
 
+export type PaginatedListRefresherRegisterFn = (refreshFn: () => void) => void;
+
 export interface PaginatedListDataTableProps<
     T extends TypedDocumentNode<U, V>,
     U extends any,
@@ -202,6 +204,12 @@ export interface PaginatedListDataTableProps<
     disableViewOptions?: boolean;
     transformData?: (data: PaginatedListItemFields<T>[]) => PaginatedListItemFields<T>[];
     setTableOptions?: (table: TableOptions<any>) => TableOptions<any>;
+    /**
+     * Register a function that allows you to assign a refresh function for
+     * this list. The function can be assigned to a ref and then called when
+     * the list needs to be refreshed.
+     */
+    registerRefresher?: PaginatedListRefresherRegisterFn;
 }
 
 export const PaginatedListDataTableKey = 'PaginatedListDataTable';
@@ -234,6 +242,7 @@ export function PaginatedListDataTable<
     disableViewOptions,
     setTableOptions,
     transformData,
+    registerRefresher,
 }: PaginatedListDataTableProps<T, U, V, AC>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const debouncedSearchTerm = useDebounce(searchTerm, 500);
@@ -274,6 +283,7 @@ export function PaginatedListDataTable<
     function refetchPaginatedList() {
         queryClient.invalidateQueries({ queryKey });
     }
+    registerRefresher?.(refetchPaginatedList);
 
     const { data } = useQuery({
         queryFn: () => {

+ 1 - 1
packages/dashboard/src/lib/components/ui/button.tsx

@@ -43,7 +43,7 @@ export interface ButtonProps
 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
     ({ className, variant, size, asChild = false, ...props }, ref) => {
         const Comp = asChild ? Slot : 'button';
-        return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
+        return <Comp className={cn(buttonVariants({ variant, size, className }))} type={props.type ?? 'button'} ref={ref} {...props} />;
     },
 );
 Button.displayName = 'Button';