feat(dashboard): Customer groups domain (#6098)
**What** - Adds list, detail, edit, create, add customers views to the CG domain. **Note** - Missing metadata in forms (coming in separate PR for entire admin) - Missing table filters (separate PR) CLOSES CORE-1648
This commit is contained in:
committed by
GitHub
parent
19bbae61f8
commit
80feb972cb
@@ -35,6 +35,7 @@
|
||||
"areYouSure": "Are you sure?",
|
||||
"noRecordsFound": "No records found",
|
||||
"typeToConfirm": "Please type {val} to confirm:",
|
||||
"noResultsTitle": "No results",
|
||||
"noResultsMessage": "Try changing the filters or search query",
|
||||
"noRecordsTitle": "No records",
|
||||
"noRecordsMessage": "There are no records to show",
|
||||
@@ -80,7 +81,15 @@
|
||||
"firstSeen": "First seen"
|
||||
},
|
||||
"customerGroups": {
|
||||
"domain": "Customer Groups"
|
||||
"domain": "Customer Groups",
|
||||
"createGroup": "Create group",
|
||||
"createCustomerGroup": "Create Customer Group",
|
||||
"createCustomerGroupHint": "Create a new customer group to segment your customers.",
|
||||
"customerAlreadyAdded": "The customer has already been added to the group.",
|
||||
"editCustomerGroup": "Edit Customer Group",
|
||||
"removeCustomersWarning_one": "You are about to remove {{count}} customer from the customer group. This action cannot be undone.",
|
||||
"removeCustomersWarning_other": "You are about to remove {{count}} customers from the customer group. This action cannot be undone.",
|
||||
"deleteCustomerGroupWarning": "You are about to delete the customer group {{name}}. This action cannot be undone."
|
||||
},
|
||||
"orders": {
|
||||
"domain": "Orders"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type NoResultsProps = {
|
||||
title: string
|
||||
title?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const NoResults = ({ title, message }: NoResultsProps) => {
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
<MagnifyingGlass />
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{title}
|
||||
{title ?? t("general.noResultsTitle")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{message ?? t("general.noResultsMessage")}
|
||||
@@ -51,7 +51,9 @@ export const NoRecords = ({ title, message, action }: NoRecordsProps) => {
|
||||
</div>
|
||||
{action && (
|
||||
<Link to={action.to}>
|
||||
<Button variant="secondary">{action.label}</Button>
|
||||
<Button variant="secondary" size="small">
|
||||
{action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./table-row-actions"
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EllipsisHorizontal } from "@medusajs/icons"
|
||||
import { DropdownMenu, IconButton } from "@medusajs/ui"
|
||||
import { ReactNode } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type TableRowAction = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
} & (
|
||||
| {
|
||||
to: string
|
||||
onClick?: never
|
||||
}
|
||||
| {
|
||||
onClick: () => void
|
||||
to?: never
|
||||
}
|
||||
)
|
||||
|
||||
type TableRowActionGroup = {
|
||||
actions: TableRowAction[]
|
||||
}
|
||||
|
||||
type TableRowActionsProps = {
|
||||
groups: TableRowActionGroup[]
|
||||
}
|
||||
|
||||
export const TableRowActions = ({ groups }: TableRowActionsProps) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<IconButton size="small" variant="transparent">
|
||||
<EllipsisHorizontal />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{groups.map((group, index) => {
|
||||
if (!group.actions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLast = index === groups.length - 1
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{group.actions.map((action, index) => {
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={action.to} key={index}>
|
||||
<DropdownMenu.Item
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{!isLast && <DropdownMenu.Separator />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AdminCustomerGroupsRes,
|
||||
AdminCustomersRes,
|
||||
AdminProductsRes,
|
||||
AdminRegionsRes,
|
||||
@@ -183,12 +184,43 @@ const router = createBrowserRouter([
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/customer-groups/list"),
|
||||
path: "",
|
||||
lazy: () =>
|
||||
import("../../routes/customer-groups/customer-group-list"),
|
||||
children: [
|
||||
{
|
||||
path: "create",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/customer-groups/customer-group-create"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/customer-groups/details"),
|
||||
lazy: () =>
|
||||
import("../../routes/customer-groups/customer-group-detail"),
|
||||
handle: {
|
||||
crumb: (data: AdminCustomerGroupsRes) =>
|
||||
data.customer_group.name,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "add-customers",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/customer-groups/customer-group-add-customers"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/customer-groups/customer-group-edit"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Customer } from "@medusajs/medusa"
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FocusModal,
|
||||
Hint,
|
||||
Table,
|
||||
Tooltip,
|
||||
clx,
|
||||
} from "@medusajs/ui"
|
||||
import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
adminCustomerKeys,
|
||||
useAdminAddCustomersToCustomerGroup,
|
||||
useAdminCustomers,
|
||||
} from "medusa-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import * as zod from "zod"
|
||||
import {
|
||||
NoRecords,
|
||||
NoResults,
|
||||
} from "../../../../../components/common/empty-table-content"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Query } from "../../../../../components/filtering/query"
|
||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
import { queryClient } from "../../../../../lib/medusa"
|
||||
|
||||
type AddCustomersFormProps = {
|
||||
customerGroupId: string
|
||||
subscribe: (state: boolean) => void
|
||||
}
|
||||
|
||||
const AddCustomersSchema = zod.object({
|
||||
customer_ids: zod.array(zod.string()).min(1),
|
||||
})
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export const AddCustomersForm = ({
|
||||
customerGroupId,
|
||||
subscribe,
|
||||
}: AddCustomersFormProps) => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const form = useForm<zod.infer<typeof AddCustomersSchema>>({
|
||||
defaultValues: {
|
||||
customer_ids: [],
|
||||
},
|
||||
resolver: zodResolver(AddCustomersSchema),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
subscribe(isDirty)
|
||||
}, [isDirty])
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize]
|
||||
)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
"customer_ids",
|
||||
Object.keys(rowSelection).filter((k) => rowSelection[k])
|
||||
)
|
||||
}, [rowSelection])
|
||||
|
||||
const params = useQueryParams(["q"])
|
||||
const { customers, count, isLoading, isError, error } = useAdminCustomers({
|
||||
expand: "groups",
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageIndex * PAGE_SIZE,
|
||||
...params,
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
data: customers ?? [],
|
||||
columns,
|
||||
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
meta: {
|
||||
customerGroupId,
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading: isMutating } =
|
||||
useAdminAddCustomersToCustomerGroup(customerGroupId)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
customer_ids: data.customer_ids.map((id) => ({ id })),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(adminCustomerKeys.lists())
|
||||
navigate(`/customer-groups/${customerGroupId}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const noRecords =
|
||||
!isLoading &&
|
||||
!customers?.length &&
|
||||
!Object.values(params).filter(Boolean).length
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{form.formState.errors.customer_ids && (
|
||||
<Hint variant="error">
|
||||
{form.formState.errors.customer_ids.message}
|
||||
</Hint>
|
||||
)}
|
||||
<FocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("general.cancel")}
|
||||
</Button>
|
||||
</FocusModal.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isMutating}
|
||||
>
|
||||
{t("general.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</FocusModal.Header>
|
||||
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
|
||||
{noRecords ? (
|
||||
<div className="w-full flex-1 flex items-center justify-center">
|
||||
<NoRecords />
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y w-full flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between w-full px-6 py-4">
|
||||
<div></div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Query />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 overflow-y-auto">
|
||||
{(customers?.length || 0) > 0 ? (
|
||||
<Table>
|
||||
<Table.Header className="border-t-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
},
|
||||
{
|
||||
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
|
||||
row.original.groups
|
||||
?.map((cg) => cg.id)
|
||||
.includes(customerGroupId),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center min-h-full">
|
||||
<NoResults />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LocalizedTablePagination
|
||||
canNextPage={table.getCanNextPage()}
|
||||
canPreviousPage={table.getCanPreviousPage()}
|
||||
nextPage={table.nextPage}
|
||||
previousPage={table.previousPage}
|
||||
count={count ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FocusModal.Body>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Customer>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const { customerGroupId } = table.options.meta as {
|
||||
customerGroupId: string
|
||||
}
|
||||
|
||||
const isAdded = row.original.groups
|
||||
?.map((gc) => gc.id)
|
||||
.includes(customerGroupId)
|
||||
|
||||
const isSelected = row.getIsSelected() || isAdded
|
||||
|
||||
const Component = (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isAdded}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isAdded) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("customerGroups.customerAlreadyAdded")}
|
||||
side="right"
|
||||
>
|
||||
{Component}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return Component
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: t("fields.email"),
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "name",
|
||||
header: t("fields.name"),
|
||||
cell: ({ row }) => {
|
||||
const name = [row.original.first_name, row.original.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return name || "-"
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return columns
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./add-customers-form"
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FocusModal } from "@medusajs/ui"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||
import { AddCustomersForm } from "./components/add-customers-form"
|
||||
|
||||
export const CustomerGroupAddCustomers = () => {
|
||||
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<FocusModal open={open} onOpenChange={onOpenChange}>
|
||||
<FocusModal.Content>
|
||||
<AddCustomersForm customerGroupId={id!} subscribe={subscribe} />
|
||||
</FocusModal.Content>
|
||||
</FocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomerGroupAddCustomers as Component } from "./customer-group-add-customers"
|
||||
@@ -0,0 +1,105 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button, FocusModal, Heading, Input, Text } from "@medusajs/ui"
|
||||
import { useAdminCreateCustomerGroup } from "medusa-react"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import * as zod from "zod"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
|
||||
type CreateCustomerGroupFormProps = {
|
||||
subscribe: (state: boolean) => void
|
||||
}
|
||||
|
||||
const CreateCustomerGroupSchema = zod.object({
|
||||
name: zod.string().min(1),
|
||||
})
|
||||
|
||||
export const CreateCustomerGroupForm = ({
|
||||
subscribe,
|
||||
}: CreateCustomerGroupFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const form = useForm<zod.infer<typeof CreateCustomerGroupSchema>>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
resolver: zodResolver(CreateCustomerGroupSchema),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
subscribe(isDirty)
|
||||
}, [isDirty])
|
||||
|
||||
const { mutateAsync, isLoading } = useAdminCreateCustomerGroup()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
name: data.name,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ customer_group }) => {
|
||||
navigate(`/customer-groups/${customer_group.id}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FocusModal.Header>
|
||||
<div className="flex items-center gap-x-2 justify-end">
|
||||
<FocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("general.cancel")}
|
||||
</Button>
|
||||
</FocusModal.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t("general.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</FocusModal.Header>
|
||||
<FocusModal.Body className="flex flex-col items-center pt-[72px]">
|
||||
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
|
||||
<div>
|
||||
<Heading>{t("customerGroups.createCustomerGroup")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("customerGroups.createCustomerGroupHint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.name")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FocusModal.Body>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create-customer-group-form"
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FocusModal } from "@medusajs/ui"
|
||||
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||
import { CreateCustomerGroupForm } from "./components/create-customer-group-form"
|
||||
|
||||
export const CustomerGroupCreate = () => {
|
||||
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||
|
||||
return (
|
||||
<FocusModal open={open} onOpenChange={onOpenChange}>
|
||||
<FocusModal.Content>
|
||||
<CreateCustomerGroupForm subscribe={subscribe} />
|
||||
</FocusModal.Content>
|
||||
</FocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomerGroupCreate as Component } from "./customer-group-create"
|
||||
@@ -0,0 +1,387 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { Customer, CustomerGroup } from "@medusajs/medusa"
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CommandBar,
|
||||
Container,
|
||||
Heading,
|
||||
StatusBadge,
|
||||
Table,
|
||||
clx,
|
||||
usePrompt,
|
||||
} from "@medusajs/ui"
|
||||
import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
useAdminCustomerGroupCustomers,
|
||||
useAdminRemoveCustomersFromCustomerGroup,
|
||||
} from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import {
|
||||
NoRecords,
|
||||
NoResults,
|
||||
} from "../../../../../components/common/empty-table-content"
|
||||
import { TableRowActions } from "../../../../../components/common/table-row-actions"
|
||||
import { Query } from "../../../../../components/filtering/query"
|
||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
type CustomerGroupCustomerSectionProps = {
|
||||
group: CustomerGroup
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export const CustomerGroupCustomerSection = ({
|
||||
group,
|
||||
}: CustomerGroupCustomerSectionProps) => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize]
|
||||
)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const params = useQueryParams(["q"])
|
||||
const { customers, count, isLoading, isError, error } =
|
||||
useAdminCustomerGroupCustomers(
|
||||
group.id,
|
||||
{
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageIndex * PAGE_SIZE,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
data: customers ?? [],
|
||||
columns,
|
||||
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
meta: {
|
||||
customerGroupId: group.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync } = useAdminRemoveCustomersFromCustomerGroup(group.id)
|
||||
const prompt = usePrompt()
|
||||
|
||||
const handleRemoveCustomers = async () => {
|
||||
const selected = Object.keys(rowSelection).filter((k) => rowSelection[k])
|
||||
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("customerGroups.removeCustomersWarning", {
|
||||
count: selected.length,
|
||||
}),
|
||||
confirmText: t("general.continue"),
|
||||
cancelText: t("general.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
customer_ids: selected.map((s) => ({ id: s })),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRowSelection({})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const noRecords =
|
||||
!isLoading &&
|
||||
!customers?.length &&
|
||||
!Object.values(params).filter(Boolean).length
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="p-0 divide-y">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<Heading level="h2">{t("customers.domain")}</Heading>
|
||||
<Link to={`/customer-groups/${group.id}/add-customers`}>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("general.add")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
{noRecords ? (
|
||||
<NoRecords />
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div></div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Query />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{(customers?.length || 0) > 0 ? (
|
||||
<Table>
|
||||
<Table.Header className="border-t-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap [&_td:first-of-type]:w-[1%] [&_td:first-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
navigate(`/customers/${row.original.id}`)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="border-b">
|
||||
<NoResults />
|
||||
</div>
|
||||
)}
|
||||
<LocalizedTablePagination
|
||||
canNextPage={table.getCanNextPage()}
|
||||
canPreviousPage={table.getCanPreviousPage()}
|
||||
nextPage={table.nextPage}
|
||||
previousPage={table.previousPage}
|
||||
count={count ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
<CommandBar open={!!Object.keys(rowSelection).length}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: Object.keys(rowSelection).length,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
<CommandBar.Command
|
||||
action={handleRemoveCustomers}
|
||||
shortcut="r"
|
||||
label={t("general.remove")}
|
||||
/>
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomerActions = ({
|
||||
customer,
|
||||
customerGroupId,
|
||||
}: {
|
||||
customer: Customer
|
||||
customerGroupId: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { mutateAsync } =
|
||||
useAdminRemoveCustomersFromCustomerGroup(customerGroupId)
|
||||
|
||||
const prompt = usePrompt()
|
||||
|
||||
const handleRemove = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("customerGroups.removeCustomersWarning", {
|
||||
count: 1,
|
||||
}),
|
||||
confirmText: t("general.continue"),
|
||||
cancelText: t("general.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
customer_ids: [{ id: customer.id }],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRowActions
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("general.edit"),
|
||||
to: `/customers/${customer.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("general.remove"),
|
||||
onClick: handleRemove,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Customer>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: t("fields.email"),
|
||||
cell: ({ getValue }) => <span>{getValue()}</span>,
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "name",
|
||||
header: t("fields.name"),
|
||||
cell: ({ row }) => {
|
||||
const name = [row.original.first_name, row.original.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return name || "-"
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("has_account", {
|
||||
header: t("fields.account"),
|
||||
cell: ({ getValue }) => {
|
||||
const hasAccount = getValue()
|
||||
|
||||
return (
|
||||
<StatusBadge color={hasAccount ? "green" : "blue"}>
|
||||
{hasAccount ? t("customers.registered") : t("customers.guest")}
|
||||
</StatusBadge>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row, table }) => {
|
||||
const { customerGroupId } = table.options.meta as {
|
||||
customerGroupId: string
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomerActions
|
||||
customer={row.original}
|
||||
customerGroupId={customerGroupId}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./customer-group-customer-section"
|
||||
@@ -0,0 +1,56 @@
|
||||
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import type { CustomerGroup } from "@medusajs/medusa"
|
||||
import { Container, DropdownMenu, Heading, IconButton } from "@medusajs/ui"
|
||||
import { useAdminDeleteCustomerGroup } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
|
||||
type CustomerGroupGeneralSectionProps = {
|
||||
group: CustomerGroup
|
||||
}
|
||||
|
||||
export const CustomerGroupGeneralSection = ({
|
||||
group,
|
||||
}: CustomerGroupGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { mutateAsync } = useAdminDeleteCustomerGroup(group.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess: () => {
|
||||
navigate("/customer-groups", { replace: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="px-6 py-4 flex items-center justify-between">
|
||||
<Heading>{group.name}</Heading>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<IconButton size="small" variant="transparent">
|
||||
<EllipsisHorizontal />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<Link to={`/customer-groups/${group.id}/edit`}>
|
||||
<DropdownMenu.Item className="flex items-center gap-x-2">
|
||||
<PencilSquare className="text-ui-fg-subtle" />
|
||||
<span>{t("general.edit")}</span>
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center gap-x-2"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash className="text-ui-fg-subtle" />
|
||||
<span>{t("general.delete")}</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./customer-group-general-section"
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useAdminCustomerGroup } from "medusa-react"
|
||||
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { CustomerGroupCustomerSection } from "./components/customer-group-customer-section"
|
||||
import { CustomerGroupGeneralSection } from "./components/customer-group-general-section"
|
||||
import { customerGroupLoader } from "./loader"
|
||||
|
||||
export const CustomerGroupDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
ReturnType<typeof customerGroupLoader>
|
||||
>
|
||||
|
||||
const { id } = useParams()
|
||||
const { customer_group, isLoading, isError, error } = useAdminCustomerGroup(
|
||||
id!,
|
||||
undefined,
|
||||
{ initialData }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError || !customer_group) {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw json("An unknown error occurred", 500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<CustomerGroupGeneralSection group={customer_group} />
|
||||
<CustomerGroupCustomerSection group={customer_group} />
|
||||
<JsonViewSection data={customer_group} />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CustomerGroupDetail as Component } from "./customer-group-detail"
|
||||
export { customerGroupLoader as loader } from "./loader"
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AdminCustomerGroupsRes } from "@medusajs/medusa"
|
||||
import { Response } from "@medusajs/medusa-js"
|
||||
import { adminProductKeys } from "medusa-react"
|
||||
import { LoaderFunctionArgs } from "react-router-dom"
|
||||
|
||||
import { medusa, queryClient } from "../../../lib/medusa"
|
||||
|
||||
const customerGroupDetailQuery = (id: string) => ({
|
||||
queryKey: adminProductKeys.detail(id),
|
||||
queryFn: async () => medusa.admin.customerGroups.retrieve(id),
|
||||
})
|
||||
|
||||
export const customerGroupLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params.id
|
||||
const query = customerGroupDetailQuery(id!)
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<Response<AdminCustomerGroupsRes>>(
|
||||
query.queryKey
|
||||
) ?? (await queryClient.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CustomerGroup } from "@medusajs/medusa"
|
||||
import { Button, Drawer, Input } from "@medusajs/ui"
|
||||
import { useAdminUpdateCustomerGroup } from "medusa-react"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as z from "zod"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
|
||||
type EditCustomerGroupFormProps = {
|
||||
group: CustomerGroup
|
||||
onSuccessfulSubmit: () => void
|
||||
subscribe: (state: boolean) => void
|
||||
}
|
||||
|
||||
const EditCustomerGroupSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
})
|
||||
|
||||
export const EditCustomerGroupForm = ({
|
||||
group,
|
||||
onSuccessfulSubmit,
|
||||
subscribe,
|
||||
}: EditCustomerGroupFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const form = useForm<z.infer<typeof EditCustomerGroupSchema>>({
|
||||
defaultValues: {
|
||||
name: group.name || "",
|
||||
},
|
||||
resolver: zodResolver(EditCustomerGroupSchema),
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
useEffect(() => {
|
||||
subscribe(isDirty)
|
||||
}, [isDirty])
|
||||
|
||||
const { mutateAsync, isLoading } = useAdminUpdateCustomerGroup(group.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: () => {
|
||||
onSuccessfulSubmit()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col overflow-hidden flex-1"
|
||||
>
|
||||
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto flex-1 max-w-full">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.name")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} size="small" />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Drawer.Body>
|
||||
<Drawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Drawer.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("general.cancel")}
|
||||
</Button>
|
||||
</Drawer.Close>
|
||||
<Button size="small" type="submit" isLoading={isLoading}>
|
||||
{t("general.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer.Footer>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-customer-group-form"
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Drawer, Heading } from "@medusajs/ui"
|
||||
import { useAdminCustomerGroup } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
|
||||
import { EditCustomerGroupForm } from "./components/edit-customer-group-form"
|
||||
|
||||
export const CustomerGroupEdit = () => {
|
||||
const [open, onOpenChange, subscribe] = useRouteModalState()
|
||||
|
||||
const { id } = useParams()
|
||||
const { customer_group, isLoading, isError, error } = useAdminCustomerGroup(
|
||||
id!
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSuccessfulSubmit = () => {
|
||||
onOpenChange(false, true)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Heading>{t("customerGroups.editCustomerGroup")}</Heading>
|
||||
</Drawer.Header>
|
||||
{!isLoading && customer_group && (
|
||||
<EditCustomerGroupForm
|
||||
subscribe={subscribe}
|
||||
onSuccessfulSubmit={handleSuccessfulSubmit}
|
||||
group={customer_group}
|
||||
/>
|
||||
)}
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomerGroupEdit as Component } from "./customer-group-edit"
|
||||
@@ -0,0 +1,251 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { CustomerGroup } from "@medusajs/medusa"
|
||||
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
|
||||
import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
useAdminCustomerGroups,
|
||||
useAdminDeleteCustomerGroup,
|
||||
} from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import {
|
||||
NoRecords,
|
||||
NoResults,
|
||||
} from "../../../../../components/common/empty-table-content"
|
||||
import { TableRowActions } from "../../../../../components/common/table-row-actions"
|
||||
import { OrderBy } from "../../../../../components/filtering/order-by"
|
||||
import { Query } from "../../../../../components/filtering/query"
|
||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export const CustomerGroupListTable = () => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize]
|
||||
)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const params = useQueryParams(["q", "order"])
|
||||
const { customer_groups, count, isLoading, isError, error } =
|
||||
useAdminCustomerGroups({
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageIndex * PAGE_SIZE,
|
||||
...params,
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
data: customer_groups ?? [],
|
||||
columns,
|
||||
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
})
|
||||
|
||||
const noRecords =
|
||||
!isLoading &&
|
||||
!customer_groups?.length &&
|
||||
!Object.values(params).filter(Boolean).length
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="p-0 divide-y">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("customerGroups.domain")}</Heading>
|
||||
<Link to="/customer-groups/create">
|
||||
<Button size="small" variant="secondary">
|
||||
{t("general.create")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
{noRecords ? (
|
||||
<NoRecords
|
||||
action={{
|
||||
label: t("customerGroups.createGroup"),
|
||||
to: "/customer-groups/create",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
<div className="flex items-center px-6 py-2 justify-between">
|
||||
<div className="flex items-center gap-x-2"></div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Query />
|
||||
<OrderBy keys={["name", "created_at", "updated_at"]} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{(customer_groups?.length || 0) > 0 ? (
|
||||
<Table>
|
||||
<Table.Header className="border-t-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
navigate(`/customer-groups/${row.original.id}`)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="border-b">
|
||||
<NoResults />
|
||||
</div>
|
||||
)}
|
||||
<LocalizedTablePagination
|
||||
canNextPage={table.getCanNextPage()}
|
||||
canPreviousPage={table.getCanPreviousPage()}
|
||||
nextPage={table.nextPage}
|
||||
previousPage={table.previousPage}
|
||||
count={count ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomerGroupActions = ({ group }: { group: CustomerGroup }) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { mutateAsync } = useAdminDeleteCustomerGroup(group.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("customerGroups.deleteCustomerGroupWarning", {
|
||||
name: group.name,
|
||||
}),
|
||||
confirmText: t("general.delete"),
|
||||
cancelText: t("general.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRowActions
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("general.edit"),
|
||||
to: `/customer-groups/${group.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("general.delete"),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<CustomerGroup>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: t("fields.name"),
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <CustomerGroupActions group={row.original} />,
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./customer-group-list-table"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { CustomerGroupListTable } from "./components/customer-group-list-table"
|
||||
|
||||
export const CustomerGroupsList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<CustomerGroupListTable />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomerGroupsList as Component } from "./customer-group-list"
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
export const CustomerGroupDetails = () => {
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<Heading>Customers</Heading>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { CustomerGroupDetails as Component } from "./details";
|
||||
@@ -1 +0,0 @@
|
||||
export { CustomerGroupsList as Component } from "./list";
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
export const CustomerGroupsList = () => {
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<Heading>Customer Groups</Heading>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -182,30 +182,21 @@ const useColumns = () => {
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("email", {
|
||||
header: t("fields.email"),
|
||||
cell: ({ getValue }) => <span>{getValue()}</span>,
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "name",
|
||||
header: t("fields.name"),
|
||||
cell: ({ row }) => {
|
||||
const firstName = row.original.first_name
|
||||
const lastName = row.original.last_name
|
||||
const name = [row.original.first_name, row.original.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
let value = "-"
|
||||
|
||||
if (firstName && lastName) {
|
||||
value = `${firstName} ${lastName}`
|
||||
} else if (firstName) {
|
||||
value = firstName
|
||||
} else if (lastName) {
|
||||
value = lastName
|
||||
}
|
||||
|
||||
return <span>{value}</span>
|
||||
return name || "-"
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("email", {
|
||||
header: t("fields.email"),
|
||||
cell: ({ getValue }) => <span>{getValue()}</span>,
|
||||
}),
|
||||
columnHelper.accessor("has_account", {
|
||||
header: t("fields.account"),
|
||||
cell: ({ getValue }) => {
|
||||
|
||||
Reference in New Issue
Block a user