feat(dashboard, js-sdk): customer page transfer order + cancel request in timeline (#10250)

**What**
- request order transfer from admin customers details page
- cancel transfer request from order timeline

---

CLOSES CMRC-730
This commit is contained in:
Frane Polić
2024-11-26 12:42:47 +01:00
committed by GitHub
parent 344a6c9ea0
commit 1bf60c7a7d
15 changed files with 127 additions and 380 deletions

View File

@@ -1 +0,0 @@
export * from "./transfer-ownership-form"

View File

@@ -1,264 +0,0 @@
import { Select, Text, clx } from "@medusajs/ui"
import { useInfiniteQuery } from "@tanstack/react-query"
import { format } from "date-fns"
import { debounce } from "lodash"
import { PropsWithChildren, useCallback, useEffect, useState } from "react"
import { Control, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { useCustomer } from "../../../hooks/api/customers"
import { sdk } from "../../../lib/client"
import { getStylizedAmount } from "../../../lib/money-amount-helpers"
import {
getOrderFulfillmentStatus,
getOrderPaymentStatus,
} from "../../../lib/order-helpers"
import { TransferOwnershipSchema } from "../../../lib/schemas"
import { Form } from "../../common/form"
import { Skeleton } from "../../common/skeleton"
import { Combobox } from "../../inputs/combobox"
import { HttpTypes } from "@medusajs/types"
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
type TransferOwnerShipFormProps = {
/**
* The Order or DraftOrder to transfer ownership of.
*/
order: HttpTypes.AdminOrder
/**
* React Hook Form control object.
*/
control: Control<TransferOwnerShipFieldValues>
}
const isOrder = (
order: HttpTypes.AdminOrder
): order is HttpTypes.AdminOrder => {
return "customer" in order
}
export const TransferOwnerShipForm = ({
order,
control,
}: TransferOwnerShipFormProps) => {
const { t } = useTranslation()
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const isOrderType = isOrder(order)
const currentOwnerId = useWatch({
control,
name: "current_owner_id",
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce((query) => setDebouncedQuery(query), 300),
[]
)
useEffect(() => {
debouncedUpdate(query)
return () => debouncedUpdate.cancel()
}, [query, debouncedUpdate])
const {
customer: owner,
isLoading: isLoadingOwner,
isError: isOwnerError,
error: ownerError,
} = useCustomer(currentOwnerId)
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["customers", debouncedQuery],
queryFn: async ({ pageParam = 0 }) => {
const res = await sdk.admin.customer.list({
q: debouncedQuery,
limit: 10,
offset: pageParam,
has_account: true, // Only show customers with confirmed accounts
})
return res
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const moreCustomersExist =
lastPage.count > lastPage.offset + lastPage.limit
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
},
})
const createLabel = (customer?: HttpTypes.AdminCustomer) => {
if (!customer) {
return ""
}
const { first_name, last_name, email } = customer
const name = [first_name, last_name].filter(Boolean).join(" ")
if (name) {
return `${name} (${email})`
}
return email
}
const ownerReady = !isLoadingOwner && owner
const options =
data?.pages
.map((p) =>
p.customers.map((c) => ({
label: createLabel(c),
value: c.id,
}))
)
.flat() || []
if (isOwnerError) {
throw ownerError
}
return (
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-2">
<Text size="small" leading="compact" weight="plus">
{isOrderType
? t("transferOwnership.details.order")
: t("transferOwnership.details.draft")}
</Text>
{isOrderType ? (
<OrderDetailsTable order={order} />
) : (
<DraftOrderDetailsTable draft={order} />
)}
</div>
<div className="flex flex-col gap-y-2">
<div>
<Text size="small" leading="compact" weight="plus">
{t("transferOwnership.currentOwner.label")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("transferOwnership.currentOwner.hint")}
</Text>
</div>
{ownerReady ? (
<Select defaultValue={owner.id} disabled>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item value={owner.id}>{createLabel(owner)}</Select.Item>
</Select.Content>
</Select>
) : (
<Skeleton className="h-8 w-full rounded-md" />
)}
</div>
<Form.Field
control={control}
name="new_owner_id"
render={({ field }) => {
return (
<Form.Item>
<div className="flex flex-col">
<Form.Label>{t("transferOwnership.newOwner.label")}</Form.Label>
<Form.Hint>{t("transferOwnership.newOwner.hint")}</Form.Hint>
</div>
<Form.Control>
<Combobox
{...field}
searchValue={query}
onSearchValueChange={setQuery}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
options={options}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)
}
const OrderDetailsTable = ({ order }: { order: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
const { label: fulfillmentLabel } = getOrderFulfillmentStatus(
t,
order.fulfillment_status
)
const { label: paymentLabel } = getOrderPaymentStatus(t, order.payment_status)
return (
<Table>
<Row label={t("fields.order")} value={`#${order.display_id}`} />
<DateRow date={order.created_at} />
<Row label={t("fields.fulfillment")} value={fulfillmentLabel} />
<Row label={t("fields.payment")} value={paymentLabel} />
<TotalRow total={order.total || 0} currencyCode={order.currency_code} />
</Table>
)
}
// TODO: Create type for Draft Order when we have it
const DraftOrderDetailsTable = ({ draft }: { draft: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
return (
<Table>
<Row label={t("fields.draft")} value={`#${draft.display_id}`} />
<DateRow date={draft.created_at} />
<Row
label={t("fields.status")}
value={t(`draftOrders.status.${draft.status}`)}
/>
{/* TODO: This will likely change. We don't use carts for draft orders any longer. */}
{/* <TotalRow
total={draft.cart.total || 0}
currencyCode={draft.cart.region.currency_code}
/> */}
</Table>
)
}
const DateRow = ({ date }: { date: string | Date }) => {
const { t } = useTranslation()
const formattedDate = format(new Date(date), "dd MMM yyyy")
return <Row label={t("fields.date")} value={formattedDate} />
}
const TotalRow = ({
total,
currencyCode,
}: {
total: number
currencyCode: string
}) => {
return <Row label="Total" value={getStylizedAmount(total, currencyCode)} />
}
const Row = ({ label, value }: { label: string; value: string }) => {
return (
<div className="txt-compact-small grid grid-cols-2 divide-x">
<div className="text-ui-fg-muted px-2 py-1.5">{label}</div>
<div className="text-ui-fg-subtle px-2 py-1.5">{value}</div>
</div>
)
}
const Table = ({ children }: PropsWithChildren) => {
return <div className={clx("divide-y rounded-lg border")}>{children}</div>
}

View File

@@ -286,6 +286,31 @@ export const useRequestTransferOrder = (
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelOrderTransfer = (
orderId: string,
options?: UseMutationOptions<any, FetchError, void>
) => {
return useMutation({
mutationFn: () => sdk.admin.order.cancelTransfer(orderId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,

View File

@@ -515,6 +515,9 @@
},
"import": {
"type": "string"
},
"cannotUndo": {
"type": "string"
}
},
"required": [
@@ -558,7 +561,8 @@
"logout",
"hide",
"export",
"import"
"import",
"cannotUndo"
],
"additionalProperties": false
},
@@ -5327,11 +5331,15 @@
},
"confirmed": {
"type": "string"
},
"declined": {
"type": "string"
}
},
"required": [
"requested",
"confirmed"
"confirmed",
"declined"
],
"additionalProperties": false
}

View File

@@ -136,7 +136,8 @@
"logout": "Logout",
"hide": "Hide",
"export": "Export",
"import": "Import"
"import": "Import",
"cannotUndo": "This action cannot be undone"
},
"operators": {
"in": "In"
@@ -1293,7 +1294,8 @@
},
"transfer": {
"requested": "Order transfer #{{transferId}} requested",
"confirmed": "Order transfer #{{transferId}} confirmed"
"confirmed": "Order transfer #{{transferId}} confirmed",
"declined": "Order transfer #{{transferId}} declined"
}
}
},

View File

@@ -603,6 +603,11 @@ export const RouteMap: RouteObject[] = [
"../../routes/customers/customers-add-customer-group"
),
},
{
path: ":order_id/transfer",
lazy: () =>
import("../../routes/orders/order-request-transfer"),
},
{
path: "metadata/edit",
lazy: () =>

View File

@@ -120,11 +120,10 @@ const useColumns = () => {
return useMemo(
() => [
...base,
// TODO: REENABLE WHEN TRANSFER OWNERSHIP IS IMPLEMENTED
// columnHelper.display({
// id: "actions",
// cell: ({ row }) => <CustomerOrderActions order={row.original} />,
// }),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CustomerOrderActions order={row.original} />,
}),
],
[base]
)

View File

@@ -1,72 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { TransferOwnershipSchema } from "../../../../../lib/schemas"
type TransferCustomerOrderOwnershipFormProps = {
order: HttpTypes.AdminOrder
}
export const TransferCustomerOrderOwnershipForm = ({
order,
}: TransferCustomerOrderOwnershipFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof TransferOwnershipSchema>>({
defaultValues: {
current_owner_id: order.customer_id ?? undefined,
new_owner_id: "",
},
resolver: zodResolver(TransferOwnershipSchema),
})
const { mutateAsync, isLoading } = {
mutateAsync: async (args: any) => {},
isLoading: false,
}
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(
{
customer_id: values.new_owner_id,
}
// {
// onSuccess: () => {
// handleSuccess()
// },
// }
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
onSubmit={handleSubmit}
className="flex size-full flex-col overflow-hidden"
>
<RouteDrawer.Body className="size-full flex-1 overflow-auto">
<TransferOwnerShipForm order={order} control={form.control} />
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" isLoading={isLoading} size="small">
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}

View File

@@ -1,29 +0,0 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals"
import { useOrder } from "../../../hooks/api/orders"
import { TransferCustomerOrderOwnershipForm } from "./components/transfer-customer-order-ownership-form"
export const CustomerTransferOwnership = () => {
const { t } = useTranslation()
const { order_id } = useParams()
const { order, isLoading, isError, error } = useOrder(order_id!)
const ready = !isLoading && order
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("transferOwnership.header")}</Heading>
</RouteDrawer.Header>
{ready && <TransferCustomerOrderOwnershipForm order={order} />}
</RouteDrawer>
)
}

View File

@@ -1 +0,0 @@
export { CustomerTransferOwnership as Component } from "./customer-transfer-ownership"

View File

@@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"
import { AdminOrderLineItem } from "@medusajs/types"
import {
useCancelOrderTransfer,
useCustomer,
useOrderChanges,
useOrderLineItems,
@@ -407,6 +408,14 @@ const useActivityItems = (order: AdminOrder): Activity[] => {
timestamp: transfer.confirmed_at,
})
}
if (transfer.declined_at) {
items.push({
title: t(`orders.activity.events.transfer.declined`, {
transferId: transfer.id.slice(-7),
}),
timestamp: transfer.declined_at,
})
}
}
// for (const note of notes || []) {
@@ -896,11 +905,33 @@ const TransferOrderRequestBody = ({
}: {
transfer: AdminOrderChange
}) => {
const prompt = usePrompt()
const { t } = useTranslation()
const action = transfer.actions[0]
const { customer } = useCustomer(action.reference_id)
const isCompleted = !!transfer.confirmed_at
const { mutateAsync: cancelTransfer } = useCancelOrderTransfer(
transfer.order_id
)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("actions.cannotUndo"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await cancelTransfer()
}
/**
* TODO: change original_email to customer info when action details is changed
*/
@@ -917,6 +948,16 @@ const TransferOrderRequestBody = ({
? `${customer?.first_name} ${customer?.last_name}`
: customer?.email}
</Text>
{!isCompleted && (
<Button
onClick={handleDelete}
className="text-ui-fg-subtle h-auto px-0 leading-none hover:bg-transparent"
variant="transparent"
size="small"
>
{t("actions.cancel")}
</Button>
)}
</div>
)
}

View File

@@ -72,7 +72,9 @@ export function CreateOrderTransferForm({
>
<RouteDrawer.Body className="flex-1 overflow-auto">
<div className="flex flex-col gap-y-8">
<TransferHeader />
<div className="flex justify-center">
<TransferHeader />
</div>
<Form.Field
control={form.control}
name="current_customer_details"

View File

@@ -10,17 +10,25 @@ import { CreateOrderTransferForm } from "./components/create-order-transfer-form
export const OrderRequestTransfer = () => {
const { t } = useTranslation()
const params = useParams()
const { order } = useOrder(params.id!, {
// Form is rendered bot on the order details page and customer page so we need to pick the correct param from URL
const orderId = (params.order_id || params.id) as string
const { order, isPending, isError } = useOrder(orderId, {
fields: DEFAULT_FIELDS,
})
if (!isPending && isError) {
throw new Error("Order not found")
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("orders.transfer.title")}</Heading>
</RouteDrawer.Header>
<CreateOrderTransferForm order={order} />
{order && <CreateOrderTransferForm order={order} />}
</RouteDrawer>
)
}

View File

@@ -211,6 +211,31 @@ export class Order {
)
}
/**
* This method cancels an order transfer request. It sends a request to the
* [Cancel Order Transfer Request](https://docs.medusajs.com/api/admin#orders_postordersidcanceltransferrequest)
* API route.
*
* @param id - The order's ID.
* @param headers - Headers to pass in the request.
* @returns The order's details.
*
* @example
* sdk.admin.order.cancelTransfer("order_123")
* .then(({ order }) => {
* console.log(order)
* })
*/
async cancelTransfer(id: string, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminOrderResponse>(
`/admin/orders/${id}/transfer/cancel`,
{
method: "POST",
headers,
}
)
}
/**
* This method creates a fulfillment for an order. It sends a request to the
* [Create Fulfillment](https://docs.medusajs.com/api/admin#orders_postordersidfulfillments)