Browse Source

feat(dashboard): Tax rate detail view

Michael Bromley 10 months ago
parent
commit
d554da96a1

+ 49 - 0
packages/dashboard/src/components/data-input/affixed-input.tsx

@@ -0,0 +1,49 @@
+import { cn } from '@/lib/utils.js';
+import { Input } from '../ui/input.js';
+import { ReactNode, useRef, useEffect, useState } from 'react';
+
+interface AffixedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
+    prefix?: ReactNode;
+    suffix?: ReactNode;
+}
+
+export function AffixedInput({ prefix, suffix, className = '', ...props }: AffixedInputProps) {
+    const prefixRef = useRef<HTMLSpanElement>(null);
+    const suffixRef = useRef<HTMLSpanElement>(null);
+    const [prefixWidth, setPrefixWidth] = useState(0);
+    const [suffixWidth, setSuffixWidth] = useState(0);
+
+    useEffect(() => {
+        if (prefixRef.current) {
+            setPrefixWidth(Math.round(prefixRef.current.getBoundingClientRect().width));
+        }
+        if (suffixRef.current) {
+            setSuffixWidth(Math.round(suffixRef.current.getBoundingClientRect().width));
+        }
+    }, [prefix, suffix]);
+
+    const style = {
+        paddingLeft: prefix ? `calc(1rem + ${prefixWidth}px)` : undefined,
+        paddingRight: suffix ? `calc(1rem + ${suffixWidth}px)` : undefined,
+    };
+
+    return (
+        <div className="relative flex items-center">
+            {prefix && (
+                <span ref={prefixRef} className="absolute left-3 text-muted-foreground whitespace-nowrap">
+                    {prefix}
+                </span>
+            )}
+            <Input
+                {...props}
+                className={className}
+                style={style}
+            />
+            {suffix && (
+                <span ref={suffixRef} className="absolute right-3 text-muted-foreground whitespace-nowrap">
+                    {suffix}
+                </span>
+            )}
+        </div>
+    );
+}

+ 45 - 50
packages/dashboard/src/components/data-input/money-input.tsx

@@ -1,7 +1,7 @@
-import { Input } from '../ui/input.js';
 import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { useMemo, useState, useEffect } from 'react';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
+import { AffixedInput } from './affixed-input.js';
 
 // Original component
 function MoneyInputInternal({
@@ -55,57 +55,52 @@ function MoneyInputInternal({
     }, [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)) {
+        <AffixedInput
+            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.toFixed(2));
+                        setDisplayValue(newValue.toString());
                     }
-                }}
-                className={shouldPrefix ? 'pl-8' : 'pr-8'}
-                step="0.01"
-                min="0"
-            />
-            {!shouldPrefix && (
-                <span className="absolute right-3 text-muted-foreground">{currencySymbol}</span>
-            )}
-        </div>
+                }
+            }}
+            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));
+                }
+            }}
+            step="0.01"
+            min="0"
+            prefix={shouldPrefix ? currencySymbol : undefined}
+            suffix={!shouldPrefix ? currencySymbol : undefined}
+        />
     );
 }
 

+ 2 - 2
packages/dashboard/src/components/shared/tax-category-select.tsx → packages/dashboard/src/components/shared/tax-category-selector.tsx

@@ -24,12 +24,12 @@ const taxCategoriesDocument = graphql(`
     }
 `);
 
-export interface TaxCategorySelectProps {
+export interface TaxCategorySelectorProps {
     value: string;
     onChange: (value: string) => void;
 }
 
-export function TaxCategorySelect({ value, onChange }: TaxCategorySelectProps) {
+export function TaxCategorySelector({ value, onChange }: TaxCategorySelectorProps) {
     const { data, isLoading, isPending, status } = useQuery({
         queryKey: ['taxCategories'],
         staleTime: 1000 * 60 * 5,

+ 66 - 0
packages/dashboard/src/components/shared/zone-selector.tsx

@@ -0,0 +1,66 @@
+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 zonesDocument = graphql(`
+    query Zones($options: ZoneListOptions) {
+        zones(options: $options) {
+            items {
+                id
+                name
+            }
+        }
+    }
+`);
+
+export interface ZoneSelectorProps {
+    value: string;
+    onChange: (value: string) => void;
+}
+
+export function ZoneSelector({ value, onChange }: ZoneSelectorProps) {
+    const { data, isLoading, isPending } = useQuery({
+        queryKey: ['zones'],
+        staleTime: 1000 * 60 * 5,
+        queryFn: () =>
+            api.query(zonesDocument, {
+                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 zone</Trans>} />
+            </SelectTrigger>
+            <SelectContent>
+                {data && (
+                    <SelectGroup>
+                        {data?.zones.items.map(zone => (
+                            <SelectItem key={zone.id} value={zone.id}>
+                                {zone.name}
+                            </SelectItem>
+                        ))}
+                    </SelectGroup>
+                )}
+            </SelectContent>
+        </Select>
+    );
+}
+

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

@@ -3,7 +3,7 @@ import { ContentLanguageSelector } from '@/components/layout/content-language-se
 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 { TaxCategorySelector } from '@/components/shared/tax-category-selector.js';
 import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
 import { Button } from '@/components/ui/button.js';
 import {
@@ -215,7 +215,7 @@ export function ProductVariantDetailPage() {
                                                 <Trans>Tax category</Trans>
                                             </FormLabel>
                                             <FormControl>
-                                                <TaxCategorySelect {...field} />
+                                                <TaxCategorySelector {...field} />
                                             </FormControl>
                                         </FormItem>
                                     )}

+ 30 - 2
packages/dashboard/src/routes/_authenticated/_tax-rates/tax-rates.graphql.ts

@@ -25,8 +25,8 @@ export const taxRateItemFragment = graphql(`
 
 export const taxRateListQuery = graphql(
     `
-        query TaxRateList {
-            taxRates {
+        query TaxRateList($options: TaxRateListOptions) {
+            taxRates(options: $options) {
                 items {
                     ...TaxRateItem
                 }
@@ -36,3 +36,31 @@ export const taxRateListQuery = graphql(
     `,
     [taxRateItemFragment],
 );
+
+export const taxRateDetailQuery = graphql(
+    `
+        query TaxRateDetail($id: ID!) {
+            taxRate(id: $id) {
+                ...TaxRateItem
+                customFields
+            }
+        }
+    `,
+    [taxRateItemFragment],
+);
+
+export const createTaxRateDocument = graphql(`
+    mutation CreateTaxRate($input: CreateTaxRateInput!) {
+        createTaxRate(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateTaxRateDocument = graphql(`
+    mutation UpdateTaxRate($input: UpdateTaxRateInput!) {
+        updateTaxRate(input: $input) {
+            id
+        }
+    }
+`);

+ 198 - 0
packages/dashboard/src/routes/_authenticated/_tax-rates/tax-rates_.$id.tsx

@@ -0,0 +1,198 @@
+import { AffixedInput } from '@/components/data-input/affixed-input.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { TaxCategorySelector } from '@/components/shared/tax-category-selector.js';
+import { Button } from '@/components/ui/button.js';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.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 { createTaxRateDocument, taxRateDetailQuery, updateTaxRateDocument } from './tax-rates.graphql.js';
+import { ZoneSelector } from '@/components/shared/zone-selector.js';
+import { Switch } from '@/components/ui/switch.js';
+export const Route = createFileRoute('/_authenticated/_tax-rates/tax-rates_/$id')({
+    component: TaxRateDetailPage,
+    loader: async ({ context, params }) => {
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(taxRateDetailQuery), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.taxRate) {
+            throw new Error(`Tax rate with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/tax-rates', label: 'Tax rates' },
+                isNew ? <Trans>New tax rate</Trans> : result.taxRate.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function TaxRateDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: addCustomFields(taxRateDetailQuery),
+        entityField: 'taxRate',
+        createDocument: createTaxRateDocument,
+        updateDocument: updateTaxRateDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                name: entity.name,
+                value: entity.value,
+                enabled: entity.enabled,
+                categoryId: entity.category.id,
+                zoneId: entity.zone.id,
+                customerGroupId: entity.customerGroup?.id,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated tax rate'), {
+                position: 'top-right',
+            });
+            form.reset(form.getValues());
+            if (creatingNewEntity) {
+                await navigate({ to: `../${data?.id}`, from: Route.id });
+            }
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update tax rate'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>{creatingNewEntity ? <Trans>New tax rate</Trans> : (entity?.name ?? '')}</PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <div></div>
+                        <PermissionGuard requires={['UpdateTaxRate']}>
+                            <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>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+                        </PageBlock>
+                        <PageBlock column="main">
+                            <div className="md:grid md:grid-cols-2 gap-4">
+                                <FormField
+                                    control={form.control}
+                                    name="name"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Name</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <Input placeholder="" {...field} />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="value"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Rate</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <AffixedInput
+                                                    type="number"
+                                                    suffix="%"
+                                                    value={field.value}
+                                                    onChange={e => field.onChange(e.target.valueAsNumber)}
+                                                />
+                                            </FormControl>
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="categoryId"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Tax category</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <TaxCategorySelector
+                                                    value={field.value}
+                                                    onChange={field.onChange}
+                                                />
+                                            </FormControl>
+                                        </FormItem>
+                                    )}
+                                />
+                                <FormField
+                                    control={form.control}
+                                    name="zoneId"
+                                    render={({ field }) => (
+                                        <FormItem>
+                                            <FormLabel>
+                                                <Trans>Zone</Trans>
+                                            </FormLabel>
+                                            <FormControl>
+                                                <ZoneSelector value={field.value} onChange={field.onChange} />
+                                            </FormControl>
+                                        </FormItem>
+                                    )}
+                                />
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock column="main" entityType="TaxRate" control={form.control} />
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}