Browse Source

feat(dashboard): Implement variant detail

Michael Bromley 10 months ago
parent
commit
ea6168a100

+ 105 - 0
packages/dashboard/src/components/data-type-components/money.tsx

@@ -1,6 +1,111 @@
 import { useLocalFormat } from "@/hooks/use-local-format.js";
+import { Input } from "../ui/input.js";
+import { useUserSettings } from "@/hooks/use-user-settings.js";
+import { useMemo, useState, useEffect } from "react";
 
 export function Money({ value, currency }: { value: number, currency: string }) {
     const { formatCurrency } = useLocalFormat();
     return formatCurrency(value, currency);
 }
+
+export function MoneyInput({ value, currency, onChange }: { value: number, currency: string, onChange: (value: number) => void }) {
+    const { settings: { displayLanguage, displayLocale } } = useUserSettings();
+    const { toMajorUnits, toMinorUnits } = useLocalFormat();
+    const [displayValue, setDisplayValue] = useState(toMajorUnits(value).toFixed(2));
+
+    // Update display value when prop value changes
+    useEffect(() => {
+        setDisplayValue(toMajorUnits(value).toFixed(2));
+    }, [value, toMajorUnits]);
+
+    // Determine if the currency symbol should be a prefix based on locale
+    const shouldPrefix = useMemo(() => {
+        if (!currency) return false;
+        const locale = displayLocale || displayLanguage.replace(/_/g, '-');
+        const parts = new Intl.NumberFormat(locale, {
+            style: 'currency',
+            currency,
+            currencyDisplay: 'symbol',
+        }).formatToParts();
+        const NaNString = parts.find(p => p.type === 'nan')?.value ?? 'NaN';
+        const localised = new Intl.NumberFormat(locale, {
+            style: 'currency',
+            currency,
+            currencyDisplay: 'symbol',
+        }).format(undefined as any);
+        return localised.indexOf(NaNString) > 0;
+    }, [currency, displayLocale, displayLanguage]);
+
+    // Get the currency symbol
+    const currencySymbol = useMemo(() => {
+        if (!currency) return '';
+        const locale = displayLocale || displayLanguage.replace(/_/g, '-');
+        const parts = new Intl.NumberFormat(locale, {
+            style: 'currency',
+            currency,
+            currencyDisplay: 'symbol',
+        }).formatToParts();
+        return parts.find(p => p.type === 'currency')?.value ?? currency;
+    }, [currency, displayLocale, displayLanguage]);
+
+    return (
+        <div className="relative flex items-center">
+            {shouldPrefix && (
+                <span className="absolute left-3 text-muted-foreground">
+                    {currencySymbol}
+                </span>
+            )}
+            <Input
+                type="text"
+                value={displayValue}
+                onChange={e => {
+                    const inputValue = e.target.value;
+                    // Allow empty input
+                    if (inputValue === '') {
+                        setDisplayValue('');
+                        return;
+                    }
+                    // Only allow numbers and one decimal point
+                    if (!/^[0-9.]*$/.test(inputValue)) {
+                        return;
+                    }
+                    setDisplayValue(inputValue);
+                }}
+                onKeyDown={e => {
+                    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+                        e.preventDefault();
+                        const currentValue = parseFloat(displayValue) || 0;
+                        const step = e.key === 'ArrowUp' ? 0.01 : -0.01;
+                        const newValue = currentValue + step;
+                        if (newValue >= 0) {
+                            onChange(toMinorUnits(newValue));
+                            setDisplayValue(newValue.toString());
+                        }
+                    }
+                }}
+                onBlur={e => {
+                    const inputValue = e.target.value;
+                    if (inputValue === '') {
+                        onChange(0);
+                        setDisplayValue('0');
+                        return;
+                    }
+                    const newValue = parseFloat(inputValue);
+                    if (!isNaN(newValue)) {
+                        onChange(toMinorUnits(newValue));
+                        setDisplayValue(newValue.toFixed(2));
+                    }
+                }}
+                className={shouldPrefix ? "pl-8" : "pr-8"}
+                step="0.01"
+                min="0"
+            />
+            {!shouldPrefix && (
+                <span className="absolute right-3 text-muted-foreground">
+                    {currencySymbol}
+                </span>
+            )}
+        </div>
+    );
+}
+

+ 1 - 1
packages/dashboard/src/components/shared/custom-fields-form.tsx

@@ -21,7 +21,7 @@ type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
 
 interface CustomFieldsFormProps {
     entityType: string;
-    control: Control;
+    control: Control<any, any>;
 }
 
 export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps) {

+ 65 - 0
packages/dashboard/src/components/shared/tax-category-select.tsx

@@ -0,0 +1,65 @@
+import {
+    Select,
+    SelectContent,
+    SelectGroup,
+    SelectItem,
+    SelectTrigger,
+    SelectValue,
+} from '@/components/ui/select.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { Skeleton } from '../ui/skeleton.js';
+
+const taxCategoriesDocument = graphql(`
+    query TaxCategories($options: TaxCategoryListOptions) {
+        taxCategories(options: $options) {
+            items {
+                id
+                name
+                isDefault
+            }
+        }
+    }
+`);
+
+export interface TaxCategorySelectProps {
+    value: string;
+    onChange: (value: string) => void;
+}
+
+export function TaxCategorySelect({ value, onChange }: TaxCategorySelectProps) {
+    const { data, isLoading, isPending, status } = useQuery({
+        queryKey: ['taxCategories'],
+        staleTime: 1000 * 60 * 5,
+        queryFn: () =>
+            api.query(taxCategoriesDocument, {
+                options: {
+                    take: 100,
+                },
+            }),
+    });
+    if (isLoading || isPending) {
+        return <Skeleton className="h-10 w-full" />;
+    }
+
+    return (
+        <Select value={value} onValueChange={value => value && onChange(value)}>
+            <SelectTrigger>
+                <SelectValue placeholder={<Trans>Select a tax category</Trans>} />
+            </SelectTrigger>
+            <SelectContent>
+                {data && (
+                    <SelectGroup>
+                        {data?.taxCategories.items.map(taxCategory => (
+                            <SelectItem key={taxCategory.id} value={taxCategory.id}>
+                                {taxCategory.name}
+                            </SelectItem>
+                        ))}
+                    </SelectGroup>
+                )}
+            </SelectContent>
+        </Select>
+    );
+}

+ 19 - 8
packages/dashboard/src/components/shared/translatable-form-field.tsx

@@ -4,20 +4,31 @@ import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { ControllerProps } from 'react-hook-form';
 import { FieldValues } from 'react-hook-form';
 
-export type TranslatableFormFieldProps = {};
+export type TranslatableEntity = FieldValues & {
+    translations?: Array<{ languageCode: string }> | null;
+};
+
+export type TranslatableFormFieldProps<TFieldValues extends TranslatableEntity | TranslatableEntity[]> = Omit<
+    ControllerProps<TFieldValues>,
+    'name'
+> & {
+    name: TFieldValues extends TranslatableEntity
+        ? keyof Omit<NonNullable<TFieldValues['translations']>[number], 'languageCode'>
+        : TFieldValues extends TranslatableEntity[]
+          ? keyof Omit<NonNullable<TFieldValues[number]['translations']>[number], 'languageCode'>
+          : never;
+};
 
 export const TranslatableFormField = <
-    TFieldValues extends FieldValues & {
-        translations?: Array<{ languageCode: string }> | null;
-    } = FieldValues,
+    TFieldValues extends TranslatableEntity | TranslatableEntity[] = TranslatableEntity,
 >({
     name,
     ...props
-}: Omit<ControllerProps<TFieldValues>, 'name'> & {
-    name: keyof Omit<NonNullable<TFieldValues['translations']>[number], 'languageCode'>;
-}) => {
+}: TranslatableFormFieldProps<TFieldValues>) => {
     const { contentLanguage } = useUserSettings().settings;
-    const index = props.control?._formValues?.translations?.findIndex(
+    const formValues = props.control?._formValues;
+    const translations = Array.isArray(formValues) ? formValues?.[0].translations : formValues?.translations;
+    const index = translations?.findIndex(
         (translation: any) => translation?.languageCode === contentLanguage,
     );
     if (index === undefined || index === -1) {

+ 35 - 32
packages/dashboard/src/framework/layout-engine/page-layout.tsx

@@ -1,13 +1,16 @@
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.js";
+import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { cn } from '@/lib/utils.js';
 import React from 'react';
+import { Control } from 'react-hook-form';
 
 export type PageBlockProps = {
     children: React.ReactNode;
     /** Which column this block should appear in */
     column: 'main' | 'side';
-    title?: string;
-    description?: string;
+    title?: React.ReactNode | string;
+    description?: React.ReactNode | string;
     className?: string;
 };
 
@@ -23,53 +26,33 @@ function isPageBlock(child: unknown): child is React.ReactElement<PageBlockProps
 export function PageLayout({ children, className }: PageLayoutProps) {
     // Separate blocks into categories
     const childArray = React.Children.toArray(children);
-    const mainBlocks = childArray.filter(child => 
-        isPageBlock(child) && child.props.column === 'main'
-    );
-    const sideBlocks = childArray.filter(child => 
-        isPageBlock(child) && child.props.column === 'side'
-    );
+    const mainBlocks = childArray.filter(child => isPageBlock(child) && child.props.column === 'main');
+    const sideBlocks = childArray.filter(child => isPageBlock(child) && child.props.column === 'side');
 
     return (
         <div className={cn('w-full space-y-4', className)}>
             {/* Mobile: Natural DOM order */}
-            <div className="md:hidden space-y-4">
-                {children}
-            </div>
+            <div className="md:hidden space-y-4">{children}</div>
 
             {/* Desktop: Two-column layout */}
             <div className="hidden md:grid md:grid-cols-5 lg:grid-cols-4 md:gap-4">
-                <div className="md:col-span-3 space-y-4">
-                    {mainBlocks}
-                </div>
-                <div className="md:col-span-2 lg:col-span-1 space-y-4">
-                    {sideBlocks}
-                </div>
+                <div className="md:col-span-3 space-y-4">{mainBlocks}</div>
+                <div className="md:col-span-2 lg:col-span-1 space-y-4">{sideBlocks}</div>
             </div>
         </div>
     );
 }
 
 export function Page({ children }: { children: React.ReactNode }) {
-    return (
-        <div className="m-4">
-            {children}
-        </div>
-    );
+    return <div className="m-4">{children}</div>;
 }
 
 export function PageTitle({ children }: { children: React.ReactNode }) {
-    return (
-        <h1 className="text-2xl font-bold mb-4">{children}</h1>
-    );
+    return <h1 className="text-2xl font-bold mb-4">{children}</h1>;
 }
 
 export function PageActionBar({ children }: { children: React.ReactNode }) {
-    return (
-        <div className="flex justify-between">
-            {children}
-        </div>
-    );
+    return <div className="flex justify-between">{children}</div>;
 }
 
 export function PageBlock({ children, title, description }: PageBlockProps) {
@@ -84,4 +67,24 @@ export function PageBlock({ children, title, description }: PageBlockProps) {
             <CardContent className={!title ? 'pt-6' : ''}>{children}</CardContent>
         </Card>
     );
-} 
+}
+
+export function CustomFieldsPageBlock({
+    column,
+    entityType,
+    control,
+}: {
+    column: 'main' | 'side';
+    entityType: string;
+    control: Control<any, any>;
+}) {
+    const customFieldConfig = useCustomFieldConfig(entityType);
+    if (!customFieldConfig || customFieldConfig.length === 0) {
+        return null;
+    }
+    return (
+        <PageBlock column={column}>
+            <CustomFieldsForm entityType={entityType} control={control} />
+        </PageBlock>
+    );
+}

+ 8 - 0
packages/dashboard/src/hooks/use-local-format.ts

@@ -32,6 +32,13 @@ export function useLocalFormat() {
         [precisionFactor],
     );
 
+    const toMinorUnits = useCallback(
+        (value: number): number => {
+            return Math.round(value * precisionFactor);
+        },
+        [precisionFactor],
+    );
+
     const formatCurrency = useCallback(
         (value: number, currency: string) => {
             return i18n.number(toMajorUnits(value), {
@@ -99,5 +106,6 @@ export function useLocalFormat() {
         formatLanguageName,
         formatCurrencyName,
         toMajorUnits,
+        toMinorUnits,
     };
 }

+ 5 - 1
packages/dashboard/src/providers/channel-provider.tsx

@@ -21,6 +21,9 @@ const ChannelsQuery = graphql(
         query ChannelInformation {
             activeChannel {
                 ...ChannelInfo
+                defaultTaxZone {
+                    id
+                }
             }
             channels {
                 items {
@@ -35,11 +38,12 @@ const ChannelsQuery = graphql(
 
 
 // Define the type for a channel
+type ActiveChannel = ResultOf<typeof ChannelsQuery>['activeChannel'];
 type Channel = ResultOf<typeof channelFragment>;
 
 // Define the context interface
 export interface ChannelContext {
-    activeChannel: Channel | undefined;
+    activeChannel: ActiveChannel | undefined;
     channels: Channel[];
     selectedChannelId: string | undefined;
     selectedChannel: Channel | undefined;

+ 36 - 1
packages/dashboard/src/routeTree.gen.ts

@@ -19,6 +19,7 @@ import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/d
 import { Route as AuthenticatedProductsProductsImport } from './routes/_authenticated/_products/products';
 import { Route as AuthenticatedProductVariantsProductVariantsImport } from './routes/_authenticated/_product-variants/product-variants';
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
+import { Route as AuthenticatedProductVariantsProductVariantsIdImport } from './routes/_authenticated/_product-variants/product-variants_.$id';
 
 // Create/Update Routes
 
@@ -70,6 +71,13 @@ const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImpo
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 
+const AuthenticatedProductVariantsProductVariantsIdRoute =
+    AuthenticatedProductVariantsProductVariantsIdImport.update({
+        id: '/_product-variants/product-variants_/$id',
+        path: '/product-variants/$id',
+        getParentRoute: () => AuthenticatedRoute,
+    } as any);
+
 // Populate the FileRoutesByPath interface
 
 declare module '@tanstack/react-router' {
@@ -123,6 +131,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedProductsProductsImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_product-variants/product-variants_/$id': {
+            id: '/_authenticated/_product-variants/product-variants_/$id';
+            path: '/product-variants/$id';
+            fullPath: '/product-variants/$id';
+            preLoaderRoute: typeof AuthenticatedProductVariantsProductVariantsIdImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_products/products_/$id': {
             id: '/_authenticated/_products/products_/$id';
             path: '/products/$id';
@@ -140,6 +155,7 @@ interface AuthenticatedRouteChildren {
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
+    AuthenticatedProductVariantsProductVariantsIdRoute: typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
 }
 
@@ -148,6 +164,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
+    AuthenticatedProductVariantsProductVariantsIdRoute: AuthenticatedProductVariantsProductVariantsIdRoute,
     AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
 };
 
@@ -161,6 +178,7 @@ export interface FileRoutesByFullPath {
     '/': typeof AuthenticatedIndexRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
@@ -171,6 +189,7 @@ export interface FileRoutesByTo {
     '/': typeof AuthenticatedIndexRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
@@ -183,6 +202,7 @@ export interface FileRoutesById {
     '/_authenticated/': typeof AuthenticatedIndexRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
+    '/_authenticated/_product-variants/product-variants_/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
     '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
@@ -196,9 +216,18 @@ export interface FileRouteTypes {
         | '/'
         | '/product-variants'
         | '/products'
+        | '/product-variants/$id'
         | '/products/$id';
     fileRoutesByTo: FileRoutesByTo;
-    to: '/about' | '/login' | '/dashboard' | '/' | '/product-variants' | '/products' | '/products/$id';
+    to:
+        | '/about'
+        | '/login'
+        | '/dashboard'
+        | '/'
+        | '/product-variants'
+        | '/products'
+        | '/product-variants/$id'
+        | '/products/$id';
     id:
         | '__root__'
         | '/_authenticated'
@@ -208,6 +237,7 @@ export interface FileRouteTypes {
         | '/_authenticated/'
         | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
+        | '/_authenticated/_product-variants/product-variants_/$id'
         | '/_authenticated/_products/products_/$id';
     fileRoutesById: FileRoutesById;
 }
@@ -244,6 +274,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
         "/_authenticated/",
         "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
+        "/_authenticated/_product-variants/product-variants_/$id",
         "/_authenticated/_products/products_/$id"
       ]
     },
@@ -269,6 +300,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/_products/products.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_product-variants/product-variants_/$id": {
+      "filePath": "_authenticated/_product-variants/product-variants_.$id.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_products/products_/$id": {
       "filePath": "_authenticated/_products/products_.$id.tsx",
       "parent": "/_authenticated"

+ 88 - 0
packages/dashboard/src/routes/_authenticated/_product-variants/components/variant-price-detail.tsx

@@ -0,0 +1,88 @@
+import { useEffect, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useDataService } from '@/framework/data-service/use-data-service.js';
+import { Trans } from '@lingui/react/macro';
+import { graphql } from '@/graphql/graphql.js';
+import { api } from '@/graphql/api.js';
+import { useChannel } from '@/hooks/use-channel.js';
+import { Money } from '@/components/data-type-components/money.js';
+
+const taxRatesDocument = graphql(`
+    query TaxRates($options: TaxRateListOptions) {
+        taxRates(options: $options) {
+            items {
+                id
+                value
+                zone {
+                    id
+                }
+                category {
+                    id
+                }
+            }
+        }
+    }
+`);
+interface VariantPriceDetailProps {
+    priceIncludesTax: boolean;
+    price: number;
+    currencyCode: string;
+    taxCategoryId: string;
+}
+
+export function VariantPriceDetail({
+    priceIncludesTax,
+    price,
+    currencyCode,
+    taxCategoryId,
+}: VariantPriceDetailProps) {
+    const { activeChannel } = useChannel();
+    const [taxRate, setTaxRate] = useState(0);
+    const [grossPrice, setGrossPrice] = useState(0);
+
+    // Fetch tax rates
+    const { data: taxRatesData } = useQuery({
+        queryKey: ['taxRates'],
+        queryFn: () => api.query(taxRatesDocument, { options: { take: 999, skip: 0 } }),
+    });
+
+    useEffect(() => {
+        if (!taxRatesData?.taxRates.items || !activeChannel) {
+            return;
+        }
+
+        const defaultTaxZone = activeChannel.defaultTaxZone?.id;
+        if (!defaultTaxZone) {
+            setTaxRate(0);
+            return;
+        }
+
+        const applicableRate = taxRatesData.taxRates.items.find(
+            taxRate => taxRate.zone.id === defaultTaxZone && taxRate.category.id === taxCategoryId,
+        );
+
+        if (!applicableRate) {
+            setTaxRate(0);
+            return;
+        }
+
+        setTaxRate(applicableRate.value);
+    }, [taxRatesData, activeChannel, taxCategoryId]);
+
+    useEffect(() => {
+        setGrossPrice(Math.round(price * ((100 + taxRate) / 100)));
+    }, [price, taxRate]);
+
+    return (
+        <div className="space-y-1">
+            <div className="text-sm text-muted-foreground">
+                <Trans>Tax rate: {taxRate}%</Trans>
+            </div>
+            <div className="text-sm">
+                <Trans>
+                    Gross price: <Money value={grossPrice} currency={currencyCode} />
+                </Trans>
+            </div>
+        </div>
+    );
+}

+ 80 - 0
packages/dashboard/src/routes/_authenticated/_product-variants/product-variants.graphql.ts

@@ -30,3 +30,83 @@ export const productVariantListDocument = graphql(
     `,
     [assetFragment],
 );
+
+export const productVariantDetailDocument = graphql(
+    `
+        query ProductVariantDetail($id: ID!) {
+            productVariant(id: $id) {
+                id
+                createdAt
+                updatedAt
+                product {
+                    id
+                    name
+                }
+                enabled
+                featuredAsset {
+                    ...Asset
+                }
+                assets {
+                    ...Asset
+                }
+                facetValues {
+                    id
+                    name
+                    facet {
+                        id
+                        name
+                    }
+                }
+                translations {
+                    id
+                    languageCode
+                    name
+                }
+                name
+                sku
+                currencyCode
+                taxCategory {
+                    id
+                    name
+                    isDefault
+                }
+                price
+                priceWithTax
+                prices {
+                    currencyCode
+                    price
+                    customFields
+                }
+                trackInventory
+                outOfStockThreshold
+                stockLevels {
+                    id
+                    stockOnHand
+                    stockAllocated
+                    stockLocation {
+                        id
+                        name
+                    }
+                }
+                customFields
+            }
+        }
+    `,
+    [assetFragment],
+);
+
+export const createProductVariantDocument = graphql(`
+    mutation CreateProductVariant($input: [CreateProductVariantInput!]!) {
+        createProductVariants(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateProductVariantDocument = graphql(`
+    mutation UpdateProductVariant($input: UpdateProductVariantInput!) {
+        updateProductVariant(input: $input) {
+            id
+        }
+    }
+`);

+ 406 - 0
packages/dashboard/src/routes/_authenticated/_product-variants/product-variants_.$id.tsx

@@ -0,0 +1,406 @@
+import { MoneyInput } from '@/components/data-type-components/money.js';
+import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
+import { EntityAssets } from '@/components/shared/entity-assets.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { TaxCategorySelect } from '@/components/shared/tax-category-select.js';
+import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { Switch } from '@/components/ui/switch.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import {
+    CustomFieldsPageBlock,
+    Page,
+    PageActionBar,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { toast } from 'sonner';
+import { VariantPriceDetail } from './components/variant-price-detail.js';
+import {
+    createProductVariantDocument,
+    productVariantDetailDocument,
+    updateProductVariantDocument,
+} from './product-variants.graphql.js';
+import { Fragment } from 'react/jsx-runtime';
+import { AssignedFacetValues } from '@/components/shared/assigned-facet-values.js';
+
+export const Route = createFileRoute('/_authenticated/_product-variants/product-variants_/$id')({
+    component: ProductVariantDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(productVariantDetailDocument), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.productVariant) {
+            throw new Error(`Product with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/product-variants', label: 'Product variants' },
+                isNew ? <Trans>New product variant</Trans> : result.productVariant.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function ProductVariantDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
+        queryDocument: addCustomFields(productVariantDetailDocument),
+        entityField: 'productVariant',
+        createDocument: createProductVariantDocument,
+        updateDocument: updateProductVariantDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                enabled: entity.enabled,
+                sku: entity.sku,
+                featuredAssetId: entity.featuredAsset?.id,
+                assetIds: entity.assets.map(asset => asset.id),
+                facetValueIds: entity.facetValues.map(facetValue => facetValue.id),
+                taxCategoryId: entity.taxCategory.id,
+                price: entity.price,
+                prices: [],
+                trackInventory: entity.trackInventory,
+                outOfStockThreshold: entity.outOfStockThreshold,
+                stockLevels: entity.stockLevels.map(stockLevel => ({
+                    stockOnHand: stockLevel.stockOnHand,
+                    stockLocationId: stockLevel.stockLocation.id,
+                })),
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    customFields: translation.customFields,
+                })),
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: data => {
+            toast(i18n.t('Successfully updated product'), {
+                position: 'top-right',
+            });
+            form.reset(form.getValues());
+            if (creatingNewEntity) {
+                navigate({ to: `../${data?.[0]?.id}`, from: Route.id });
+            }
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update product'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const [price, taxCategoryId] = form.watch(['price', 'taxCategoryId']);
+
+    return (
+        <Page>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New product variant</Trans> : (entity?.name ?? '')}
+            </PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <ContentLanguageSelector />
+                        <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <Trans>Update</Trans>
+                            </Button>
+                        </PermissionGuard>
+                    </PageActionBar>
+                    <PageLayout>
+                        <PageBlock column="side">
+                            <FormField
+                                control={form.control}
+                                name="enabled"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Enabled</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <Switch checked={field.value} onCheckedChange={field.onChange} />
+                                        </FormControl>
+                                        <FormDescription>
+                                            <Trans>When enabled, a product is available in the shop</Trans>
+                                        </FormDescription>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="main">
+                            <div className="md:flex w-full gap-4">
+                                <div className="w-1/2">
+                                    <TranslatableFormField
+                                        control={form.control}
+                                        name="name"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Product name</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input placeholder="" {...field} />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                                <div className="w-1/2">
+                                    <FormField
+                                        control={form.control}
+                                        name="sku"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>SKU</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input placeholder="" {...field} />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock
+                            column="main"
+                            entityType="ProductVariant"
+                            control={form.control}
+                        />
+
+                        <PageBlock column="main" title={<Trans>Price and tax</Trans>}>
+                            <div className="grid grid-cols-2 gap-4 items-start">
+                                <FormField
+                                    control={form.control}
+                                    name="taxCategoryId"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Tax category</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <TaxCategorySelect {...field} />
+                                            </FormControl>
+                                        </FormItem>
+                                    )}
+                                />
+
+                                <div>
+                                    <FormField
+                                        control={form.control}
+                                        name="price"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Price</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <MoneyInput {...field} currency={entity?.currencyCode} />
+                                                </FormControl>
+                                            </FormItem>
+                                        )}
+                                    />
+                                    <VariantPriceDetail
+                                        priceIncludesTax={entity.priceIncludesTax}
+                                        price={price}
+                                        currencyCode={entity.currencyCode}
+                                        taxCategoryId={taxCategoryId}
+                                    />
+                                </div>
+                            </div>
+                        </PageBlock>
+                        <PageBlock column="main" title={<Trans>Stock</Trans>}>
+                            <div className="grid grid-cols-2 gap-4 items-start">
+                                {entity.stockLevels.map((stockLevel, index) => (
+                                    <Fragment key={stockLevel.id}>
+                                        <FormField
+                                            control={form.control}
+                                            name={`stockLevels.${index}.stockOnHand`}
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        <Trans>Stock level</Trans>
+                                                    </FormLabel>
+                                                    <FormControl>
+                                                        <Input type="number" {...field} />
+                                                    </FormControl>
+                                                </FormItem>
+                                            )}
+                                        />
+                                        <div>
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Allocated</Trans>
+                                                </FormLabel>
+                                                <div className="text-sm pt-1.5">{stockLevel.stockAllocated}</div>
+                                            </FormItem>
+                                        </div>
+                                    </Fragment>
+                                ))}
+
+                                <FormField
+                                    control={form.control}
+                                    name="trackInventory"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Track inventory</Trans>
+                                            </FormLabel>
+                                            <Select onValueChange={field.onChange} value={field.value}>
+                                                <FormControl>
+                                                    <SelectTrigger className="">
+                                                        <SelectValue placeholder="Track inventory" />
+                                                    </SelectTrigger>
+                                                </FormControl>
+                                                <SelectContent>
+                                                    <SelectItem value="INHERIT">
+                                                        <Trans>Inherit from global settings</Trans>
+                                                    </SelectItem>
+                                                    <SelectItem value="TRUE">
+                                                        <Trans>Track</Trans>
+                                                    </SelectItem>
+                                                    <SelectItem value="FALSE">
+                                                        <Trans>Do not track</Trans>
+                                                    </SelectItem>
+                                                </SelectContent>
+                                            </Select>
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="outOfStockThreshold"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Out-of-stock threshold</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input type="number" {...field} />
+                                            </FormControl>
+                                            <FormDescription>
+                                                <Trans>
+                                                    Sets the stock level at which this variant is considered
+                                                    to be out of stock. Using a negative value enables
+                                                    backorder support.
+                                                </Trans>
+                                            </FormDescription>
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="useGlobalOutOfStockThreshold"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Use global out-of-stock threshold</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Switch
+                                                    checked={field.value}
+                                                    onCheckedChange={field.onChange}
+                                                />
+                                            </FormControl>
+                                            <FormDescription>
+                                                <Trans>
+                                                    Sets the stock level at which this variant is considered
+                                                    to be out of stock. Using a negative value enables
+                                                    backorder support.
+                                                </Trans>
+                                            </FormDescription>
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+
+                        <PageBlock column="side">
+                            <FormField
+                                control={form.control}
+                                name="facetValueIds"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Facet values</Trans>
+                                        </FormLabel>
+                                        <FormControl>
+                                            <AssignedFacetValues
+                                                facetValues={entity?.facetValues ?? []}
+                                                {...field}
+                                            />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="side">
+                            <FormItem>
+                                <FormLabel>
+                                    <Trans>Assets</Trans>
+                                </FormLabel>
+                                <FormControl>
+                                    <EntityAssets
+                                        assets={entity?.assets}
+                                        featuredAsset={entity?.featuredAsset}
+                                        compact={true}
+                                        value={form.getValues()}
+                                        onChange={value => {
+                                            form.setValue('featuredAssetId', value.featuredAssetId, {
+                                                shouldDirty: true,
+                                                shouldValidate: true,
+                                            });
+                                            form.setValue('assetIds', value.assetIds, {
+                                                shouldDirty: true,
+                                                shouldValidate: true,
+                                            });
+                                        }}
+                                    />
+                                </FormControl>
+                                <FormDescription></FormDescription>
+                                <FormMessage />
+                            </FormItem>
+                        </PageBlock>
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}

+ 12 - 0
packages/dashboard/src/routes/_authenticated/_products/components/product-variants-table.tsx

@@ -4,6 +4,8 @@ import { useState } from "react";
 import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
 import { Money } from "@/components/data-type-components/money.js";
 import { useLocalFormat } from "@/hooks/use-local-format.js";
+import { Link } from "@tanstack/react-router";
+import { Button } from "@/components/ui/button.js";
 
 interface ProductVariantsTableProps {
     productId: string;
@@ -27,6 +29,16 @@ export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
             currencyCode: false,
         }}
         customizeColumns={{
+            name: {
+                header: 'Variant name',
+                cell: ({ row }) => {
+                    return (
+                        <Button asChild variant="ghost">
+                            <Link to={`../../product-variants/${row.original.id}`}>{row.original.name} </Link>
+                        </Button>
+                    );
+                },
+            },
             currencyCode: {
                 cell: ({ cell, row }) => {
                     const value = cell.getValue();

+ 0 - 1
packages/dashboard/src/routes/_authenticated/_products/products.graphql.ts

@@ -43,7 +43,6 @@ export const productDetailFragment = graphql(
                 name
                 slug
                 description
-                customFields
             }
 
             facetValues {

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

@@ -24,6 +24,7 @@ import {
     PageBlock,
     PageLayout,
     PageTitle,
+    CustomFieldsPageBlock,
 } from '@/framework/layout-engine/page-layout.js';
 import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@lingui/react/macro';
@@ -33,7 +34,6 @@ import { CreateProductVariantsDialog } from './components/create-product-variant
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
 
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
@@ -197,9 +197,7 @@ export function ProductDetailPage() {
                                 )}
                             />
                         </PageBlock>
-                        <PageBlock column="main">
-                            <CustomFieldsForm entityType="Product" control={form.control} />
-                        </PageBlock>
+                        <CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
                         {entity && entity.variantList.totalItems > 0 && (
                             <PageBlock column="main">
                                 <ProductVariantsTable productId={params.id} />