feat(dashboard, js-sdk, types): receive return e2e (#8305)

**What**
- receive return flow
- order summary section adjustments
- fix received item in summary
- activity event

---

CLOSES TRI-79 CC-256
This commit is contained in:
Frane Polić
2024-08-01 18:29:11 +00:00
committed by GitHub
parent 7ae1d80380
commit 2280d31396
21 changed files with 1536 additions and 167 deletions
@@ -0,0 +1,96 @@
import { DropdownMenu, clx } from "@medusajs/ui"
import { PropsWithChildren, ReactNode } from "react"
import { Link } from "react-router-dom"
type Action = {
icon: ReactNode
label: string
disabled?: boolean
} & (
| {
to: string
onClick?: never
}
| {
onClick: () => void
to?: never
}
)
type ActionGroup = {
actions: Action[]
}
type ActionMenuProps = {
groups: ActionGroup[]
}
export const ButtonMenu = ({
groups,
children,
}: PropsWithChildren<ActionMenuProps>) => {
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => {
if (!group.actions.length) {
return null
}
const isLast = index === groups.length - 1
return (
<DropdownMenu.Group key={index}>
{group.actions.map((action, index) => {
if (action.onClick) {
return (
<DropdownMenu.Item
disabled={action.disabled}
key={index}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
className={clx(
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
{
"[&_svg]:text-ui-fg-disabled": action.disabled,
}
)}
>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
)
}
return (
<div key={index}>
<DropdownMenu.Item
className={clx(
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
{
"[&_svg]:text-ui-fg-disabled": action.disabled,
}
)}
asChild
disabled={action.disabled}
>
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
{action.icon}
<span>{action.label}</span>
</Link>
</DropdownMenu.Item>
</div>
)
})}
{!isLast && <DropdownMenu.Separator />}
</DropdownMenu.Group>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export * from "./action-menu"
@@ -8,12 +8,14 @@ import { Form } from "../../common/form"
type RouteModalFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
form: UseFormReturn<TFieldValues>
blockSearch?: boolean
onClose?: (isSubmitSuccessful: boolean) => void
}>
export const RouteModalForm = <TFieldValues extends FieldValues = any>({
form,
blockSearch = false,
children,
onClose,
}: RouteModalFormProps<TFieldValues>) => {
const { t } = useTranslation()
@@ -25,6 +27,7 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
const { isSubmitSuccessful } = nextLocation.state || {}
if (isSubmitSuccessful) {
onClose?.(true)
return false
}
@@ -32,10 +35,22 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
const isSearchChanged = currentLocation.search !== nextLocation.search
if (blockSearch) {
return isDirty && (isPathChanged || isSearchChanged)
const ret = isDirty && (isPathChanged || isSearchChanged)
if (!ret) {
onClose?.(isSubmitSuccessful)
}
return ret
}
return isDirty && isPathChanged
const ret = isDirty && isPathChanged
if (!ret) {
onClose?.(isSubmitSuccessful)
}
return ret
})
const handleCancel = () => {
@@ -44,6 +59,7 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
const handleContinue = () => {
blocker?.proceed?.()
onClose?.(false)
}
return (
@@ -53,6 +53,10 @@ export const useReturns = (
return { ...data, ...rest }
}
/**
* REQUEST RETURN
*/
export const useInitiateReturn = (
orderId: string,
options?: UseMutationOptions<
@@ -81,6 +85,74 @@ export const useInitiateReturn = (
})
}
export const useConfirmReturnRequest = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminConfirmReturnRequest
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminConfirmReturnRequest) =>
sdk.admin.return.confirmRequest(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelReturnRequest = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminReturnResponse, Error>
) => {
return useMutation({
mutationFn: () => sdk.admin.return.cancelRequest(id),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
refetchType: "all",
})
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddReturnItem = (
id: string,
orderId: string,
@@ -235,18 +307,185 @@ export const useDeleteReturnShipping = (
})
}
export const useConfirmReturnRequest = (
/**
* RECEIVE RETURN
*/
export const useInitiateReceiveReturn = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminConfirmReturnRequest
HttpTypes.AdminInitiateReceiveReturn
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminConfirmReturnRequest) =>
sdk.admin.return.confirmRequest(id, payload),
mutationFn: (payload: HttpTypes.AdminInitiateReceiveReturn) =>
sdk.admin.return.initiateReceive(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddReceiveItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminReceiveItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminReceiveItems) =>
sdk.admin.return.receiveItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateReceiveItem = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminUpdateReceiveItems & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateReceiveItems & { actionId: string }) => {
return sdk.admin.return.updateReceiveItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRemoveReceiveItems = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminReturnResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) => {
return sdk.admin.return.removeReceiveItem(id, actionId)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddDismissItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminDismissItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminDismissItems) =>
sdk.admin.return.dismissItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateDismissItem = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminUpdateDismissItems & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateReceiveItems & { actionId: string }) => {
return sdk.admin.return.updateDismissItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRemoveDismissItem = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminReturnResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) => {
return sdk.admin.return.removeDismissItem(id, actionId)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useConfirmReturnReceive = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminConfirmReceiveReturn
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminConfirmReceiveReturn) =>
sdk.admin.return.confirmReceive(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
@@ -271,13 +510,13 @@ export const useConfirmReturnRequest = (
})
}
export const useCancelReturnRequest = (
export const useCancelReceiveReturn = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminReturnResponse, Error>
) => {
return useMutation({
mutationFn: () => sdk.admin.return.cancelRequest(id),
mutationFn: () => sdk.admin.return.cancelReceive(id),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
@@ -288,6 +527,7 @@ export const useCancelReturnRequest = (
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
refetchType: "all", // For some reason RQ will refetch this but will return stale record from the cache
})
queryClient.invalidateQueries({
@@ -849,6 +849,7 @@
"sendNotificationHint": "Notify customer about return.",
"returnTotal": "Return total",
"refundAmount": "Refund amount",
"outstandingAmount": "Outstanding amount",
"reason": "Reason",
"reasonHint": "Choose why the customer want to return items.",
"note": "Note",
@@ -861,7 +862,25 @@
"inboundShippingHint": "Choose which method you want to use.",
"returnableQuantityLabel": "Returnable quantity",
"refundableAmountLabel": "Refundable amount",
"returnRequestedInfo": "{{requestedItemsCount}}x item return requested"
"returnRequestedInfo": "{{requestedItemsCount}}x item return requested",
"returnReceivedInfo": "{{requestedItemsCount}}x item return received",
"activeChangeError": "There is an active order change in progress on this order. Please finish or discard the change first.",
"receive": {
"action": "Receive items",
"receive": "Return {{label}}",
"restockAll": "Restock all items",
"itemsLabel": "Items received",
"title": "Receive items for #{{returnId}}",
"sendNotificationHint": "Notify customer about received return.",
"inventoryWarning": "Please note that we will automatically adjust the inventory levels based on your input above.",
"writeOffInputLabel": "How many of the items are damaged?",
"toast": {
"success": "Return received successfully.",
"errorLargeValue": "Quantity is greater than requested item quantity.",
"errorNegativeValue": "Quantity cannot be a negative value.",
"errorLargeDamagedValue": "Damaged items quantity + non damaged received quantity exceeds total item quantity on the return. Please decrease quantity of non damaged items."
}
}
},
"reservations": {
"allocatedLabel": "Allocated",
@@ -981,7 +1000,8 @@
"items_other": "{{count}} items"
},
"return": {
"created": "Return created",
"created": "Return #{{returnId}} created",
"received": "Return #{{returnId}} received",
"items_one": "{{count}} item returned",
"items_other": "{{count}} items returned"
},
+5 -3
View File
@@ -2,14 +2,16 @@ import { AdminOrderLineItem } from "@medusajs/types"
export function getReturnableQuantity(item: AdminOrderLineItem): number {
const {
// TODO: this should be `fulfilled_quantity`? now there is check on the BD that we can't return more quantity than we shipped but some items don't require shipping
shipped_quantity,
return_received_quantity,
return_dismissed_quantity, // TODO: check this
return_dismissed_quantity,
return_requested_quantity,
} = item.detail
return (
shipped_quantity - (return_received_quantity + return_requested_quantity)
shipped_quantity -
(return_received_quantity +
return_requested_quantity +
return_dismissed_quantity)
)
}
@@ -223,6 +223,11 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/orders/order-create-fulfillment"),
},
{
path: "returns/:return_id/receive",
lazy: () =>
import("../../routes/orders/order-receive-return"),
},
{
path: "allocate-items",
lazy: () =>
@@ -52,8 +52,6 @@ type ReturnCreateFormProps = {
let selectedItems: string[] = []
let IS_CANCELING = false
export const ReturnCreateForm = ({
order,
preview,
@@ -62,6 +60,27 @@ export const ReturnCreateForm = ({
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const itemsMap = useMemo(
() => new Map(order.items.map((i) => [i.id, i])),
[order.items]
)
/**
* Only consider items that belong to this return.
*/
const previewItems = useMemo(
() =>
preview.items.filter(
(i) => !!i.actions?.find((a) => a.return_id === activeReturn.id)
),
[preview.items]
)
const previewItemsMap = useMemo(
() => new Map(previewItems.map((i) => [i.id, i])),
[previewItems]
)
/**
* STATE
*/
@@ -142,16 +161,14 @@ export const ReturnCreateForm = ({
)
return Promise.resolve({
items: preview.items
.filter((i) => !!i.detail.return_requested_quantity)
.map((i) => ({
item_id: i.id,
quantity: i.detail.return_requested_quantity,
note: i.actions?.find((a) => a.action === "RETURN_ITEM")
?.internal_note,
reason_id: i.actions?.find((a) => a.action === "RETURN_ITEM")
?.details?.reason_id,
})),
items: previewItems.map((i) => ({
item_id: i.id,
quantity: i.detail.return_requested_quantity,
note: i.actions?.find((a) => a.action === "RETURN_ITEM")
?.internal_note,
reason_id: i.actions?.find((a) => a.action === "RETURN_ITEM")?.details
?.reason_id,
})),
option_id: method ? method.shipping_option_id : "",
location_id: "",
send_notification: false,
@@ -160,16 +177,6 @@ export const ReturnCreateForm = ({
resolver: zodResolver(ReturnCreateSchema),
})
const itemsMap = useMemo(
() => new Map(order.items.map((i) => [i.id, i])),
[order.items]
)
const previewItemsMap = useMemo(
() => new Map(preview.items.map((i) => [i.id, i])),
[preview.items]
)
const {
fields: items,
append,
@@ -183,7 +190,7 @@ export const ReturnCreateForm = ({
useEffect(() => {
const existingItemsMap = {}
preview.items.forEach((i) => {
previewItems.forEach((i) => {
const ind = items.findIndex((field) => field.item_id === i.id)
/**
@@ -218,7 +225,7 @@ export const ReturnCreateForm = ({
remove(ind)
}
})
}, [preview.items])
}, [previewItems])
useEffect(() => {
const method = preview.shipping_methods.find(
@@ -348,19 +355,6 @@ export const ReturnCreateForm = ({
})
}, [items])
useEffect(() => {
/**
* Unmount hook
*/
return () => {
if (IS_CANCELING) {
cancelReturnRequest()
// TODO: add this on ESC press
IS_CANCELING = false
}
}
}, [])
const returnTotal = preview.return_requested_total
const shippingTotal = useMemo(() => {
@@ -374,7 +368,14 @@ export const ReturnCreateForm = ({
const refundAmount = returnTotal - shippingTotal
return (
<RouteFocusModal.Form form={form}>
<RouteFocusModal.Form
form={form}
onClose={(isSubmitSuccessful) => {
if (!isSubmitSuccessful) {
cancelReturnRequest()
}
}}
>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header />
@@ -443,7 +444,7 @@ export const ReturnCreateForm = ({
currencyCode={order.currency_code}
form={form}
onRemove={() => {
const actionId = preview.items
const actionId = previewItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
@@ -452,7 +453,7 @@ export const ReturnCreateForm = ({
}
}}
onUpdate={(payload) => {
const actionId = preview.items
const actionId = previewItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
@@ -691,12 +692,7 @@ export const ReturnCreateForm = ({
<div className="flex w-full items-center justify-end gap-x-4">
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button
type="button"
onClick={() => (IS_CANCELING = true)}
variant="secondary"
size="small"
>
<Button type="button" variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
@@ -1,5 +1,8 @@
import { useParams } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "@medusajs/ui"
import { RouteFocusModal } from "../../../components/modals"
import { ReturnCreateForm } from "./components/return-create-form"
@@ -13,11 +16,14 @@ let IS_REQUEST_RUNNING = false
export const ReturnCreate = () => {
const { id } = useParams()
const navigate = useNavigate()
const { t } = useTranslation()
const { order } = useOrder(id!, {
fields: DEFAULT_FIELDS,
})
const { order: preview } = useOrderPreview(id!)
const { order: preview } = useOrderPreview(id!, undefined, {})
const [activeReturnId, setActiveReturnId] = useState()
@@ -29,28 +35,36 @@ export const ReturnCreate = () => {
useEffect(() => {
async function run() {
if (IS_REQUEST_RUNNING || !order || !preview) {
if (IS_REQUEST_RUNNING || !preview) {
return
}
/**
* Active return already exists
*/
if (preview.order_change?.change_type === "return") {
setActiveReturnId(preview.order_change.return.id)
if (preview.order_change) {
if (preview.order_change.change_type === "return_request") {
setActiveReturnId(preview.order_change.return_id)
} else {
navigate(`/orders/${order.id}`, { replace: true })
toast.error(t("orders.returns.activeChangeError"))
}
return
}
IS_REQUEST_RUNNING = true
const orderReturn = await initiateReturn({ order_id: order.id })
setActiveReturnId(orderReturn.id)
IS_REQUEST_RUNNING = false
try {
const orderReturn = await initiateReturn({ order_id: order.id })
setActiveReturnId(orderReturn.id)
} catch (e) {
navigate(`/orders/${order.id}`, { replace: true })
toast.error(e.message)
} finally {
IS_REQUEST_RUNNING = false
}
}
run()
}, [order, preview])
}, [preview])
return (
<RouteFocusModal>
@@ -81,7 +81,10 @@ type Activity = {
const useActivityItems = (order: AdminOrder) => {
const { t } = useTranslation()
const { returns = [] } = useReturns({ order_id: order.id, fields: "*items" })
const { returns = [] } = useReturns({
order_id: order.id,
fields: "+received_at,*items",
})
const notes = []
const isLoading = false
@@ -169,11 +172,24 @@ const useActivityItems = (order: AdminOrder) => {
}
for (const ret of returns) {
// Always display created action
items.push({
title: t("orders.activity.events.return.created"),
title: t("orders.activity.events.return.created", {
returnId: ret.id.slice(-7),
}),
timestamp: ret.created_at,
children: <ReturnCreatedBody orderReturn={ret} />,
children: <ReturnBody orderReturn={ret} />,
})
if (ret.status === "received" || ret.status === "partially_received") {
items.push({
title: t("orders.activity.events.return.received", {
returnId: ret.id.slice(-7),
}),
timestamp: ret.received_at,
children: <ReturnBody orderReturn={ret} />,
})
}
}
// for (const note of notes || []) {
@@ -242,13 +258,19 @@ const OrderActivityItem = ({
<Text size="small" leading="compact" weight="plus">
{title}
</Text>
<Tooltip
content={getFullDate({ date: timestamp, includeTime: true })}
>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{getRelativeDate(timestamp)}
</Text>
</Tooltip>
{timestamp && (
<Tooltip
content={getFullDate({ date: timestamp, includeTime: true })}
>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{getRelativeDate(timestamp)}
</Text>
</Tooltip>
)}
</div>
<div>{children}</div>
</div>
@@ -392,7 +414,7 @@ const FulfillmentCreatedBody = ({
)
}
const ReturnCreatedBody = ({ orderReturn }: { orderReturn: AdminReturn }) => {
const ReturnBody = ({ orderReturn }: { orderReturn: AdminReturn }) => {
const { t } = useTranslation()
const numberOfItems = orderReturn.items.reduce((acc, item) => {
@@ -4,15 +4,21 @@ import { useMemo } from "react"
import {
AdminOrder,
AdminReturn,
OrderLineItemDTO,
ReservationItemDTO,
} from "@medusajs/types"
import { ArrowDownRightMini, ArrowUturnLeft } from "@medusajs/icons"
import {
ArrowDownRightMini,
ArrowLongRight,
ArrowUturnLeft,
} from "@medusajs/icons"
import {
Button,
Container,
Copy,
Heading,
IconButton,
StatusBadge,
Text,
} from "@medusajs/ui"
@@ -26,6 +32,7 @@ import {
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date"
import { ButtonMenu } from "../../../../../components/common/button-menu/button-menu.tsx"
type OrderSummarySectionProps = {
order: AdminOrder
@@ -39,6 +46,14 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
line_item_id: order.items.map((i) => i.id),
})
const { returns = [] } = useReturns({
status: "requested",
order_id: order.id,
fields: "+received_at",
})
const showReturns = !!returns.length
/**
* Show Allocation button only if there are unfulfilled items that don't have reservations
*/
@@ -71,20 +86,41 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
<Container className="divide-y divide-dashed p-0">
<Header order={order} />
<ItemBreakdown order={order} />
<ReturnBreakdown order={order} />
<CostBreakdown order={order} />
<Total order={order} />
{showAllocateButton && (
<div className="bg-ui-bg-subtle flex items-center justify-end rounded-b-xl px-4 py-4">
<Button
onClick={() => navigate(`./allocate-items`)}
variant="secondary"
>
{t("orders.allocateItems.action")}
</Button>
</div>
)}
{showAllocateButton ||
(showReturns && (
<div className="bg-ui-bg-subtle flex items-center justify-end rounded-b-xl px-4 py-4">
{showReturns && (
<ButtonMenu
groups={[
{
actions: returns.map((r) => ({
label: t("orders.returns.receive.receive", {
label: `#${r.id.slice(-7)}`,
}),
icon: <ArrowLongRight />,
to: `/orders/${order.id}/returns/${r.id}/receive`,
})),
},
]}
>
<Button variant="secondary" size="small">
{t("orders.returns.receive.action")}
</Button>
</ButtonMenu>
)}
{showAllocateButton && (
<Button
onClick={() => navigate(`./allocate-items`)}
variant="secondary"
>
{t("orders.allocateItems.action")}
</Button>
)}
</div>
))}
</Container>
)
}
@@ -126,73 +162,80 @@ const Item = ({
item,
currencyCode,
reservation,
returns,
}: {
item: OrderLineItemDTO
currencyCode: string
reservation?: ReservationItemDTO | null
returns: AdminReturn[]
}) => {
const { t } = useTranslation()
const isInventoryManaged = item.variant.manage_inventory
return (
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
<Copy content={item.variant_sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">
{item.variant?.options.map((o) => o.value).join(" · ")}
</Text>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-4">
<Text size="small">
{getLocaleAmount(item.unit_price, currencyCode)}
</Text>
</div>
<div className="flex items-center gap-x-2">
<div className="w-fit min-w-[27px]">
<>
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
<Copy content={item.variant_sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">
<span className="tabular-nums">{item.quantity}</span>x
{item.variant?.options.map((o) => o.value).join(" · ")}
</Text>
</div>
<div className="overflow-visible">
{isInventoryManaged && (
<StatusBadge
color={reservation ? "green" : "orange"}
className="text-nowrap"
>
{reservation
? t("orders.reservations.allocatedLabel")
: t("orders.reservations.notAllocatedLabel")}
</StatusBadge>
)}
</div>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-4">
<Text size="small">
{getLocaleAmount(item.unit_price, currencyCode)}
</Text>
</div>
<div className="flex items-center gap-x-2">
<div className="w-fit min-w-[27px]">
<Text size="small">
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="overflow-visible">
{isInventoryManaged && (
<StatusBadge
color={reservation ? "green" : "orange"}
className="text-nowrap"
>
{reservation
? t("orders.reservations.allocatedLabel")
: t("orders.reservations.notAllocatedLabel")}
</StatusBadge>
)}
</div>
</div>
<div className="flex items-center justify-end">
<Text size="small" className="pt-[1px]">
{getLocaleAmount(item.subtotal || 0, currencyCode)}
</Text>
</div>
</div>
<div className="flex items-center justify-end">
<Text size="small" className="pt-[1px]">
{getLocaleAmount(item.subtotal || 0, currencyCode)}
</Text>
</div>
</div>
</div>
{returns.map((r) => (
<ReturnBreakdown key={r.id} orderReturn={r} itemId={item.id} />
))}
</>
)
}
@@ -201,6 +244,33 @@ const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
line_item_id: order.items.map((i) => i.id),
})
const { returns } = useReturns({
order_id: order.id,
fields: "*items",
})
const itemsReturnsMap = useMemo(() => {
if (!returns) {
return {}
}
const ret = {}
order.items?.forEach((i) => {
returns.forEach((r) => {
if (r.items.some((ri) => ri.item_id === i.id)) {
if (ret[i.id]) {
ret[i.id].push(r)
} else {
ret[i.id] = [r]
}
}
})
})
return ret
}, [returns])
return (
<div>
{order.items.map((item) => {
@@ -214,6 +284,7 @@ const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
item={item}
currencyCode={order.currency_code}
reservation={reservation}
returns={itemsReturnsMap[item.id] || []}
/>
)
})}
@@ -275,39 +346,56 @@ const CostBreakdown = ({ order }: { order: AdminOrder }) => {
)
}
const ReturnBreakdown = ({ order }: { order: AdminOrder }) => {
const ReturnBreakdown = ({
orderReturn,
itemId,
}: {
orderReturn: AdminReturn
itemId: string
}) => {
const { t } = useTranslation()
const { getRelativeDate } = useDate()
const { returns = [] } = useReturns({
order_id: order.id,
status: "requested",
fields: "*items",
})
if (!returns.length) {
if (
!["requested", "received", "partially_received"].includes(
orderReturn.status
)
) {
return null
}
return returns.map((activeReturn) => (
const isRequested = orderReturn.status === "requested"
return (
<div
key={activeReturn.id}
key={orderReturn.id}
className="text-ui-fg-subtle bg-ui-bg-subtle flex flex-row justify-between gap-y-2 px-6 py-4"
>
<div className="flex items-center gap-2">
<ArrowDownRightMini className="text-ui-fg-muted" />
<Text size="small" className="text-ui-fg-subtle">
{t("orders.returns.returnRequestedInfo", {
requestedItemsCount: activeReturn.items.length,
})}
{t(
`orders.returns.${
isRequested ? "returnRequestedInfo" : "returnReceivedInfo"
}`,
{
requestedItemsCount: orderReturn.items.find(
(ri) => ri.item_id === itemId
)[isRequested ? "quantity" : "received_quantity"],
}
)}
</Text>
</div>
<Text size="small" leading="compact" className="text-ui-fg-muted">
{getRelativeDate(activeReturn.created_at)}
</Text>
{isRequested && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
{getRelativeDate(
isRequested ? orderReturn.created_at : orderReturn.received_at
)}
</Text>
)}
</div>
))
)
}
const Total = ({ order }: { order: AdminOrder }) => {
@@ -0,0 +1,12 @@
import { z } from "zod"
export const ReceiveReturnSchema = z.object({
items: z.array(
z.object({
quantity: z.number().nullable(),
written_off_quantity: z.number().nullable(),
item_id: z.string(),
})
),
send_notification: z.boolean().optional(),
})
@@ -0,0 +1 @@
export * from "./order-receive-return-form.tsx"
@@ -0,0 +1,379 @@
import { useTranslation } from "react-i18next"
import { AdminOrder, AdminReturn } from "@medusajs/types"
import { Alert, Button, Input, Switch, Text, toast } from "@medusajs/ui"
import React, { useEffect, useMemo } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrrowRight } from "@medusajs/icons"
import * as zod from "zod"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { useStockLocation } from "../../../../../hooks/api"
import { ReceiveReturnSchema } from "./constants"
import { Form } from "../../../../../components/common/form"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import {
useAddReceiveItems,
useCancelReceiveReturn,
useConfirmReturnReceive,
useRemoveReceiveItems,
useUpdateReceiveItem,
} from "../../../../../hooks/api/returns"
import WrittenOffQuantity from "./written-off-quantity"
type OrderAllocateItemsFormProps = {
order: AdminOrder
preview: AdminOrder
orderReturn: AdminReturn
}
export function OrderReceiveReturnForm({
order,
preview,
orderReturn,
}: OrderAllocateItemsFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
/**
* Items on the preview order that are part of the return we are receiving currently.
*/
const previewItems = useMemo(() => {
const idsMap = {}
orderReturn.items.forEach((i) => (idsMap[i.item_id] = true))
return preview.items.filter((i) => idsMap[i.id])
}, [preview.items, orderReturn])
const { mutateAsync: confirmReturnReceive } = useConfirmReturnReceive(
orderReturn.id,
order.id
)
const { mutateAsync: cancelReceiveReturn } = useCancelReceiveReturn(
orderReturn.id,
order.id
)
const { mutateAsync: addReceiveItems } = useAddReceiveItems(
orderReturn.id,
order.id
)
const { mutateAsync: updateReceiveItem } = useUpdateReceiveItem(
orderReturn.id,
order.id
)
const { mutateAsync: removeReceiveItem } = useRemoveReceiveItems(
orderReturn.id,
order.id
)
const { stock_location } = useStockLocation(
orderReturn.location_id,
undefined,
{
enabled: !!orderReturn.location_id,
}
)
const itemsMap = useMemo(() => {
const ret = {}
order.items.forEach((i) => (ret[i.id] = i))
return ret
}, [order.items])
const form = useForm<zod.infer<typeof ReceiveReturnSchema>>({
defaultValues: {
items: previewItems
?.sort((i1, i2) => i1.id.localeCompare(i2.id))
.map((i) => ({
item_id: i.id,
quantity: i.detail.return_received_quantity,
written_off_quantity: i.detail.written_off_quantity,
})),
send_notification: false,
},
resolver: zodResolver(ReceiveReturnSchema),
})
useEffect(() => {
previewItems
?.sort((i1, i2) => i1.id.localeCompare(i2.id))
.forEach((item, index) => {
form.setValue(
`items.${index}.quantity`,
item.detail.return_received_quantity,
{ shouldTouch: true, shouldDirty: true }
)
form.setValue(
`items.${index}.written_off_quantity`,
item.detail.written_off_quantity,
{ shouldTouch: true, shouldDirty: true }
)
})
}, [previewItems])
/**
* HANDLERS
*/
const handleSubmit = form.handleSubmit(async (data) => {
try {
await confirmReturnReceive({ no_notification: !data.send_notification })
handleSuccess(`/orders/${order.id}`)
toast.success(t("general.success"), {
description: t("orders.returns.receive.toast.success"),
dismissLabel: t("actions.close"),
})
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
const handleQuantityChange = async (
itemId: string,
value: number | null,
index: number
) => {
const item = previewItems?.find((i) => i.id === itemId)
const action = item?.actions?.find(
(a) => a.action === "RECEIVE_RETURN_ITEM"
)
if (typeof value === "number" && value < 0) {
form.setValue(
`items.${index}.quantity`,
item.detail.return_received_quantity,
{ shouldTouch: true, shouldDirty: true }
)
toast.error(t("orders.returns.receive.toast.errorNegativeValue"))
return
}
if (typeof value === "number" && value > item.quantity) {
// reset value in the form and notify the user to be aware that we didn't chang anything
form.setValue(
`items.${index}.quantity`,
item.detail.return_received_quantity,
{ shouldTouch: true, shouldDirty: true }
)
toast.error(t("orders.returns.receive.toast.errorLargeValue"))
return
}
try {
if (action) {
if (value === null || value === 0) {
await removeReceiveItem(action.id)
return
}
await updateReceiveItem({ actionId: action.id, quantity: value })
} else {
if (typeof value === "number" && value > 0 && value <= item.quantity) {
await addReceiveItems({ items: [{ id: item.id, quantity: value }] })
}
}
} catch (e) {
toast.error(e.message)
}
}
const onFormClose = async (isSubmitSuccessful: boolean) => {
try {
if (!isSubmitSuccessful) {
await cancelReceiveReturn()
}
} catch (e) {
toast.error(e.message)
}
}
return (
<RouteDrawer.Form form={form} onClose={onFormClose}>
<form
onSubmit={handleSubmit}
className="flex size-full flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex size-full flex-col overflow-auto">
<div className="flex justify-between">
<div>
{stock_location && (
<div className="flex items-center gap-2">
<ArrrowRight className="text-ui-fg-subtle" />{" "}
<span className="text-ui-fg-base txt-small font-medium">
{stock_location.name}
</span>
</div>
)}
</div>
<span className="text-ui-fg-muted txt-small text-right">
{t("orders.returns.receive.itemsLabel")}
</span>
</div>
{previewItems.map((item, ind) => {
const originalItem = itemsMap[item.id]
return (
<div
key={item.id}
className="bg-ui-bg-subtle shadow-elevation-card-rest mt-2 rounded-xl"
>
<div className="flex flex-col items-center gap-x-2 gap-y-2 p-3 text-sm md:flex-row">
<div className="flex flex-1 items-center gap-x-3">
<Text size="small" className="text-ui-fg-subtle">
{item.quantity}x
</Text>
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{item.title}{" "}
</Text>
{originalItem.variant.sku && (
<span>({originalItem.variant.sku})</span>
)}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{originalItem.variant.product.title}
</Text>
</div>
</div>
<div className="flex flex-1 flex-row items-center gap-2">
<WrittenOffQuantity
form={form}
item={item}
index={ind}
returnId={orderReturn.id}
orderId={order.id}
/>
<Form.Field
control={form.control}
name={`items.${ind}.quantity`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="w-full">
<Form.Control>
<Input
min={0}
max={item.quantity}
type="number"
value={value}
className="bg-ui-bg-field-component text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
onChange={(e) => {
const value =
e.target.value === ""
? null
: parseFloat(e.target.value)
onChange(value)
}}
{...field}
onBlur={() => {
field.onBlur()
handleQuantityChange(item.id, value, ind)
}}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</div>
</div>
)
})}
{/*TOTALS*/}
<div className="my-6 border-b border-t border-dashed py-4">
<div className="mb-2 flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("fields.total")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(preview.total, order.currency_code)}
</span>
</div>
<div className="mt-4 flex items-center justify-between border-t border-dotted pt-4">
<span className="txt-small font-medium">
{t("orders.returns.outstandingAmount")}
</span>
<span className="txt-small font-medium">
{getStylizedAmount(
preview.summary.difference_sum || 0,
order.currency_code
)}
</span>
</div>
</div>
<Alert className="rounded-xl" variant="warning">
{t("orders.returns.receive.inventoryWarning")}
</Alert>
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl p-3">
<Form.Field
control={form.control}
name="send_notification"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center gap-3">
<Form.Control>
<Switch
className="mt-1 self-start"
checked={!!value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
<div className="flex flex-col">
<Form.Label>
{t("orders.returns.sendNotification")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.receive.sendNotificationHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer className="overflow-hidden">
<div className="flex items-center gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -0,0 +1,159 @@
import React, { useState } from "react"
import { HeartBroken } from "@medusajs/icons"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { AdminOrderLineItem } from "@medusajs/types"
import { Button, Input, Popover, toast } from "@medusajs/ui"
import { ReceiveReturnSchema } from "./constants"
import { Form } from "../../../../../components/common/form"
import {
useAddDismissItems,
useRemoveDismissItem,
useUpdateDismissItem,
} from "../../../../../hooks/api/returns"
type WriteOffQuantityProps = {
returnId: string
orderId: string
index: number
item: AdminOrderLineItem
form: UseFormReturn<typeof ReceiveReturnSchema>
}
function WrittenOffQuantity({
form,
item,
index,
returnId,
orderId,
}: WriteOffQuantityProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const { mutateAsync: addDismissedItems } = useAddDismissItems(
returnId,
orderId
)
const { mutateAsync: updateDismissedItems } = useUpdateDismissItem(
returnId,
orderId
)
const { mutateAsync: removeDismissedItems } = useRemoveDismissItem(
returnId,
orderId
)
const onDismissedQuantityChanged = async (value: number | null) => {
// TODO: if out of bounds prevent sending and notify user
const action = item.actions?.find(
(a) => a.action === "RECEIVE_DAMAGED_RETURN_ITEM"
)
if (typeof value === "number" && value < 0) {
form.setValue(
`items.${index}.written_off_quantity`,
item.detail.written_off_quantity,
{ shouldTouch: true, shouldDirty: true }
)
toast.error(t("orders.returns.receive.toast.errorNegativeValue"))
return
}
if (
typeof value === "number" &&
value > item.quantity - item.detail.return_received_quantity
) {
form.setValue(
`items.${index}.written_off_quantity`,
item.detail.written_off_quantity,
{ shouldTouch: true, shouldDirty: true }
)
toast.error(t("orders.returns.receive.toast.errorLargeDamagedValue"))
return
}
try {
if (value) {
if (!action) {
await addDismissedItems({
items: [{ id: item.id, quantity: value }],
})
} else {
await updateDismissedItems({ actionId: action.id, quantity: value })
}
} else {
if (action) {
// remove damaged item if value is unset and it was added before
await removeDismissedItems(action.id)
}
}
} catch (e) {
toast.error(e.message)
}
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger asChild>
<Button className="flex gap-2 px-2" variant="secondary" type="button">
<div>
<HeartBroken />
</div>
{!!item.detail.written_off_quantity && (
<span>{item.detail.written_off_quantity}</span>
)}
</Button>
</Popover.Trigger>
<Popover.Content align="center">
<div className="flex flex-col p-2">
<span className="txt-small text-ui-fg-subtle mb-2 font-medium">
{t("orders.returns.receive.writeOffInputLabel")}
</span>
<Form.Field
control={form.control}
name={`items.${index}.written_off_quantity`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="w-full">
<Form.Control>
<Input
min={0}
max={item.quantity}
type="number"
value={value}
className="bg-ui-bg-field-component text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
onChange={(e) => {
const value =
e.target.value === ""
? null
: parseFloat(e.target.value)
onChange(value)
}}
{...field}
onBlur={() => {
field.onBlur()
onDismissedQuantityChanged(value)
}}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</Popover.Content>
</Popover>
)
}
export default WrittenOffQuantity
@@ -0,0 +1 @@
export { OrderReceiveReturn as Component } from "./order-receive-return"
@@ -0,0 +1,96 @@
import { useNavigate, useParams } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { Heading, toast } from "@medusajs/ui"
import { useEffect } from "react"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { RouteDrawer } from "../../../components/modals"
import { OrderReceiveReturnForm } from "./components/order-receive-return-form"
import {
useAddReceiveItems,
useInitiateReceiveReturn,
useReturn,
} from "../../../hooks/api/returns"
let IS_REQUEST_RUNNING = false
export function OrderReceiveReturn() {
const { id, return_id } = useParams()
const { t } = useTranslation()
const navigate = useNavigate()
/**
* HOOKS
*/
const { order } = useOrder(id!, { fields: "+currency_code,*items" })
const { order: preview } = useOrderPreview(id!)
const { return: orderReturn } = useReturn(return_id, {
fields: "*items.item,*items.item.variant,*items.item.variant.product",
}) // TODO: fix API needs to return 404 if return not exists and not an empty object
/**
* MUTATIONS
*/
const { mutateAsync: initiateReceiveReturn } = useInitiateReceiveReturn(
return_id,
id
)
const { mutateAsync: addReceiveItems } = useAddReceiveItems(return_id, id)
useEffect(() => {
;(async function () {
if (IS_REQUEST_RUNNING || !preview) {
return
}
if (preview.order_change) {
if (preview.order_change.change_type !== "return_receive") {
navigate(`/orders/${order.id}`, { replace: true })
toast.error(t("orders.returns.activeChangeError"))
}
return
}
IS_REQUEST_RUNNING = true
try {
const { return: _return } = await initiateReceiveReturn({})
await addReceiveItems({
items: _return.items.map((i) => ({
id: i.item_id,
quantity: i.quantity,
})),
})
} catch (e) {
toast.error(e.message)
} finally {
IS_REQUEST_RUNNING = false
}
})()
}, [preview])
const ready = order && orderReturn && preview
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>
{t("orders.returns.receive.title", {
returnId: return_id?.slice(-7),
})}
</Heading>
</RouteDrawer.Header>
{ready && (
<OrderReceiveReturnForm
order={order}
orderReturn={orderReturn}
preview={preview}
/>
)}
</RouteDrawer>
)
}