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:
@@ -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"
|
||||
+18
-2
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
+42
-46
@@ -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>
|
||||
|
||||
+27
-13
@@ -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>
|
||||
|
||||
+33
-11
@@ -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) => {
|
||||
|
||||
+169
-81
@@ -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 }) => {
|
||||
|
||||
+12
@@ -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(),
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./order-receive-return-form.tsx"
|
||||
+379
@@ -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>
|
||||
)
|
||||
}
|
||||
+159
@@ -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"
|
||||
+96
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user