Browse Source

feat(dashboard): Customer group detail

Michael Bromley 10 months ago
parent
commit
36c0f8d252

+ 107 - 0
packages/dashboard/src/components/shared/customer-selector.tsx

@@ -0,0 +1,107 @@
+import { Button } from '@/components/ui/button.js';
+import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { Plus, Search } from 'lucide-react';
+import { useState } from 'react';
+
+const customersDocument = graphql(`
+    query GetCustomers($options: CustomerListOptions) {
+        customers(options: $options) {
+            items {
+                id
+                firstName
+                lastName
+                emailAddress
+            }
+            totalItems
+        }
+    }
+`);
+
+export interface Customer {
+    id: string;
+    firstName: string;
+    lastName: string;
+    emailAddress: string;
+}
+
+export interface CustomerSelectorProps {
+    onSelect: (value: Customer) => void;
+    label?: string | React.ReactNode;
+    readOnly?: boolean;
+}
+
+export function CustomerSelector(props: CustomerSelectorProps) {
+    const [open, setOpen] = useState(false);
+    const [searchTerm, setSearchTerm] = useState('');
+
+    const { data, isLoading } = useQuery({
+        queryKey: ['customers', searchTerm],
+        queryFn: () =>
+            api.query(customersDocument, {
+                options: {
+                    sort: { lastName: 'ASC' },
+                    filter: searchTerm ? {
+                        firstName: { contains: searchTerm },
+                        lastName: { contains: searchTerm },
+                        emailAddress: { contains: searchTerm },
+                    } : undefined,
+                    filterOperator: searchTerm ? 'OR' : undefined,
+                },
+            }),
+        staleTime: 1000 * 60, // 1 minute
+    });
+
+    const handleSearch = (value: string) => {
+        setSearchTerm(value);
+    };
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <Button variant="outline" size="sm" type="button" disabled={props.readOnly} className="gap-2">
+                    <Plus className="h-4 w-4" />
+                    {props.label ?? <Trans>Select customer</Trans>}
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="p-0 w-[350px]" align="start">
+                <Command shouldFilter={false}>
+                    <div className="flex items-center border-b px-3">
+                        <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+                        <CommandInput 
+                            placeholder="Search customers..." 
+                            onValueChange={handleSearch}
+                            className="h-10 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
+                        />
+                    </div>
+                    <CommandList>
+                        <CommandEmpty>
+                            {isLoading ? (
+                                <Trans>Loading...</Trans>
+                            ) : (
+                                <Trans>No customers found</Trans>
+                            )}
+                        </CommandEmpty>
+                        {data?.customers.items.map(customer => (
+                            <CommandItem
+                                key={customer.id}
+                                onSelect={() => {
+                                    props.onSelect(customer);
+                                    setOpen(false);
+                                }}
+                                className="flex flex-col items-start"
+                            >
+                                <div className="font-medium">{customer.firstName} {customer.lastName}</div>
+                                <div className="text-sm text-muted-foreground">{customer.emailAddress}</div>
+                            </CommandItem>
+                        ))}
+                    </CommandList>
+                </Command>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 3 - 1
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -184,6 +184,8 @@ export interface PaginatedListDataTableProps<
     facetedFilters?: FacetedFilterConfig<T>;
 }
 
+export const PaginatedListDataTableKey = 'PaginatedListDataTable';
+
 export function PaginatedListDataTable<
     T extends TypedDocumentNode<U, V>,
     U extends Record<string, any> = any,
@@ -235,7 +237,7 @@ export function PaginatedListDataTable<
           }
         : undefined;
 
-    const defaultQueryKey = ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter, debouncedSearchTerm];
+    const defaultQueryKey = [PaginatedListDataTableKey, listQuery, page, itemsPerPage, sorting, filter, debouncedSearchTerm];
     const queryKey = transformQueryKey ? transformQueryKey(defaultQueryKey) : defaultQueryKey;
 
     function refetchPaginatedList() {

+ 91 - 58
packages/dashboard/src/routes/_authenticated/_customer-groups/components/customer-group-members-table.tsx

@@ -1,10 +1,17 @@
-import { PaginatedListDataTable } from '@/components/shared/paginated-list-data-table.js';
+import { CustomerSelector } from '@/components/shared/customer-selector.js';
+import { PaginatedListDataTable, PaginatedListDataTableKey } from '@/components/shared/paginated-list-data-table.js';
 import { Button } from '@/components/ui/button.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
 import { Link } from '@tanstack/react-router';
 import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
 import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { addCustomerToGroupDocument } from '../../_customers/customers.graphql.js';
+import { toast } from 'sonner';
+import { useLingui } from '@lingui/react';
 
 export const customerGroupMemberListDocument = graphql(`
     query CustomerGroupMemberList($id: ID!, $options: CustomerListOptions) {
@@ -26,71 +33,97 @@ export const customerGroupMemberListDocument = graphql(`
 
 export interface CustomerGroupMembersTableProps {
     customerGroupId: string;
+    canAddCustomers?: boolean;
 }
 
-export function CustomerGroupMembersTable({ customerGroupId }: CustomerGroupMembersTableProps) {
+export function CustomerGroupMembersTable({ customerGroupId, canAddCustomers = true }: CustomerGroupMembersTableProps) {
     const [sorting, setSorting] = useState<SortingState>([]);
     const [page, setPage] = useState(1);
     const [pageSize, setPageSize] = useState(10);
     const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    const { i18n } = useLingui();
+    const queryClient = useQueryClient();
+
+    const { mutate: addCustomerToGroup } = useMutation({
+        mutationFn: api.mutate(addCustomerToGroupDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Customer added to group'));
+            queryClient.invalidateQueries({ 
+                queryKey: [PaginatedListDataTableKey, customerGroupMemberListDocument] 
+            });
+        },
+        onError: () => {
+            toast.error(i18n.t('Failed to add customer to group'));
+        },
+    });
 
     return (
-        <PaginatedListDataTable
-            listQuery={addCustomFields(customerGroupMemberListDocument)}
-            transformVariables={variables => ({
-                ...variables,
-                id: customerGroupId
-            })}
-            page={page}
-            itemsPerPage={pageSize}
-            sorting={sorting}
-            columnFilters={filters}
-            onPageChange={(_, page, perPage) => {
-                setPage(page);
-                setPageSize(perPage);
-            }}
-            onSortChange={(_, sorting) => {
-                setSorting(sorting);
-            }}
-            onFilterChange={(_, filters) => {
-                setFilters(filters);
-            }}
-            onSearchTermChange={searchTerm => {
-                return {
-                    lastName: {
-                        contains: searchTerm,
-                    },
-                    emailAddress: {
-                        contains: searchTerm,
-                    },
-                };
-            }}
-            additionalColumns={{
-                name: {
-                    header: 'Name',
-                    cell: ({ row }) => {
-                        const value = `${row.original.firstName} ${row.original.lastName}`;
-                        return (
-                            <Button asChild variant="ghost">
-                                <Link
-                                    to="/customers/$id"
-                                    params={{ id: row.original.id }}
-                                >
-                                    {value}
-                                </Link>
-                            </Button>
-                        );
+        <div>
+            <PaginatedListDataTable
+                listQuery={addCustomFields(customerGroupMemberListDocument)}
+                transformVariables={variables => ({
+                    ...variables,
+                    id: customerGroupId,
+                })}
+                page={page}
+                itemsPerPage={pageSize}
+                sorting={sorting}
+                columnFilters={filters}
+                onPageChange={(_, page, perPage) => {
+                    setPage(page);
+                    setPageSize(perPage);
+                }}
+                onSortChange={(_, sorting) => {
+                    setSorting(sorting);
+                }}
+                onFilterChange={(_, filters) => {
+                    setFilters(filters);
+                }}
+                onSearchTermChange={searchTerm => {
+                    return {
+                        lastName: {
+                            contains: searchTerm,
+                        },
+                        emailAddress: {
+                            contains: searchTerm,
+                        },
+                    };
+                }}
+                additionalColumns={{
+                    name: {
+                        header: 'Name',
+                        cell: ({ row }) => {
+                            const value = `${row.original.firstName} ${row.original.lastName}`;
+                            return (
+                                <Button asChild variant="ghost">
+                                    <Link to="/customers/$id" params={{ id: row.original.id }}>
+                                        {value}
+                                    </Link>
+                                </Button>
+                            );
+                        },
                     },
-                },
-            }}
-            defaultColumnOrder={['name', 'emailAddress']}
-            defaultVisibility={{
-                id: false,
-                createdAt: false,
-                updatedAt: false,
-                firstName: false,
-                lastName: false,
-            }}
-        />
+                }}
+                defaultColumnOrder={['name', 'emailAddress']}
+                defaultVisibility={{
+                    id: false,
+                    createdAt: false,
+                    updatedAt: false,
+                    firstName: false,
+                    lastName: false,
+                }}
+            />
+            {canAddCustomers && (
+                <CustomerSelector
+                    onSelect={customer => {
+                        addCustomerToGroup({
+                        customerId: customer.id,
+                        groupId: customerGroupId,
+                    });
+                }}
+                    label={<Trans>Add customer</Trans>}
+                />
+            )}
+        </div>
     );
 }

+ 44 - 0
packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups.graphql.ts

@@ -15,3 +15,47 @@ export const customerGroupListDocument = graphql(`
         }
     }
 `);
+
+export const addCustomersToGroupDocument = graphql(`
+    mutation AddCustomersToGroup($customerGroupId: ID!, $customerIds: [ID!]!) {
+        addCustomersToGroup(customerGroupId: $customerGroupId, customerIds: $customerIds) {
+            id
+        }
+    }
+`);
+
+export const removeCustomersFromGroupDocument = graphql(`
+    mutation RemoveCustomersFromGroup($customerGroupId: ID!, $customerIds: [ID!]!) {
+        removeCustomersFromGroup(customerGroupId: $customerGroupId, customerIds: $customerIds) {
+            id
+        }
+    }
+`);
+
+export const customerGroupDocument = graphql(`
+    query CustomerGroup($id: ID!) {
+        customerGroup(id: $id) {
+            id
+            createdAt
+            updatedAt
+            name
+            customFields
+        }
+    }
+`);
+
+export const createCustomerGroupDocument = graphql(`
+    mutation CreateCustomerGroup($input: CreateCustomerGroupInput!) {
+        createCustomerGroup(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateCustomerGroupDocument = graphql(`
+    mutation UpdateCustomerGroup($input: UpdateCustomerGroupInput!) {
+        updateCustomerGroup(input: $input) {
+            id
+        }
+    }
+`);

+ 1 - 1
packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups.tsx

@@ -51,7 +51,7 @@ function CustomerGroupListPage() {
             }}
         >
             <PageActionBar>
-                <PermissionGuard requires={['CreateCustomer']}>
+                <PermissionGuard requires={['CreateCustomerGroup']}>
                     <Button asChild>
                         <Link to="./new">
                             <PlusIcon className="mr-2 h-4 w-4" />

+ 146 - 0
packages/dashboard/src/routes/_authenticated/_customer-groups/customer-groups_.$id.tsx

@@ -0,0 +1,146 @@
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.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 { CustomerGroupMembersTable } from './components/customer-group-members-table.js';
+import {
+    createCustomerGroupDocument,
+    customerGroupDocument,
+    updateCustomerGroupDocument,
+} from './customer-groups.graphql.js';
+import { CustomerSelector } from '@/components/shared/customer-selector.js';
+import { api } from '@/graphql/api.js';
+import { addCustomerToGroupDocument } from '../_customers/customers.graphql.js';
+import { useMutation } from '@tanstack/react-query';
+
+export const Route = createFileRoute('/_authenticated/_customer-groups/customer-groups_/$id')({
+    component: CustomerGroupDetailPage,
+    loader: async ({ context, params }) => {
+        console.log('params', params);
+        const isNew = params.id === NEW_ENTITY_PATH;
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(addCustomFields(customerGroupDocument), { id: params.id }),
+                  { id: params.id },
+              );
+        if (!isNew && !result.customerGroup) {
+            throw new Error(`Customer group with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/customer-groups', label: 'Customer groups' },
+                isNew ? <Trans>New customer group</Trans> : result.customerGroup.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+export function CustomerGroupDetailPage() {
+    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(customerGroupDocument),
+        entityField: 'customerGroup',
+        createDocument: createCustomerGroupDocument,
+        updateDocument: updateCustomerGroupDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                name: entity.name,
+                customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(i18n.t('Successfully updated customer group'), {
+                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 customer group'), {
+                position: 'top-right',
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    return (
+        <Page>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New customer group</Trans> : (entity?.name ?? '')}
+            </PageTitle>
+            <Form {...form}>
+                <form onSubmit={submitHandler} className="space-y-8">
+                    <PageActionBar>
+                        <div></div>
+                        <PermissionGuard requires={['UpdateCustomerGroup']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <Trans>Update</Trans>
+                            </Button>
+                        </PermissionGuard>
+                    </PageActionBar>
+                    <PageLayout>
+                        <PageBlock column="main">
+                            <div className="md:flex w-full gap-4">
+                                <div className="w-1/2">
+                                    <FormField
+                                        control={form.control}
+                                        name="name"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    <Trans>Name</Trans>
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input placeholder="" {...field} />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                            </div>
+                        </PageBlock>
+                        <CustomFieldsPageBlock
+                            column="main"
+                            entityType="CustomerGroup"
+                            control={form.control}
+                        />
+                        {!creatingNewEntity && (
+                            <PageBlock column="main" title={<Trans>Customers</Trans>}>
+                                <CustomerGroupMembersTable customerGroupId={entity?.id} />
+                            </PageBlock>
+                        )}
+                    </PageLayout>
+                </form>
+            </Form>
+        </Page>
+    );
+}