feat(dashboard) admin 3.0 return creation (#6713)

**What**
- request return flow

Co-authored-by: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com>
This commit is contained in:
Frane Polić
2024-03-22 17:57:18 +01:00
committed by GitHub
parent d24c819e7d
commit 2ae8eaa779
19 changed files with 1567 additions and 8 deletions

View File

@@ -19,7 +19,6 @@
"is": "is",
"select": "Select",
"selected": "Selected",
"details": "Details",
"enabled": "Enabled",
"disabled": "Disabled",
"expired": "Expired",
@@ -27,6 +26,7 @@
"revoked": "Revoked",
"admin": "Admin",
"store": "Store",
"details": "Details",
"items_one": "{{count}} item",
"items_other": "{{count}} items",
"countSelected": "{{count}} selected",
@@ -50,12 +50,13 @@
"settings": "Settings"
},
"actions": {
"save": "Save",
"create": "Create",
"delete": "Delete",
"remove": "Remove",
"revoke": "Revoke",
"cancel": "Cancel",
"save": "Save",
"back": "Back",
"continue": "Continue",
"confirm": "Confirm",
"edit": "Edit",
@@ -239,6 +240,7 @@
"cancelWarning": "You are about to cancel the order {{id}}. This action cannot be undone.",
"onDateFromSalesChannel": "{{date}} from {{salesChannel}}",
"summary": {
"requestReturn": "Request return",
"allocateItems": "Allocate items",
"editItems": "Edit items"
},
@@ -270,6 +272,28 @@
"newTotal": "New total",
"differenceDue": "Difference due"
},
"returns": {
"details": "Details",
"chooseItems": "Choose items",
"refundAmount": "Refund amount",
"locationDescription": "Choose which location you want to return the items to.",
"shippingDescription": "Choose which method you want to use for this return.",
"noInventoryLevel": "No inventory level",
"sendNotification": "Send notification",
"sendNotificationHint": "Notify customer of created return.",
"customRefund": "Custom refund",
"shippingPriceTooltip1": "Custom refund is enabled",
"noShippingOptions": "There are no shipping options for the region",
"shippingPriceTooltip2": "Shipping needs to be selected",
"customRefundHint": "If you want to refund something else instead of the total refund.",
"customShippingPrice": "Custom shipping",
"customShippingPriceHint": "Custom shipping cost.",
"noInventoryLevelDesc": "The selected location does not have an inventory level for the selected items. The return can be requested but cant be received until an inventory level is created for the selected location.",
"refundableAmountLabel": "Refundable amount",
"refundableAmountHeader": "Refundable Amount",
"returnableQuantityLabel": "Returnable quantity",
"returnableQuantityHeader": "Returnable Quantity"
},
"reservations": {
"allocatedLabel": "Allocated",
"notAllocatedLabel": "Not allocated"
@@ -721,6 +745,8 @@
"limit": "Limit",
"tags": "Tags",
"type": "Type",
"reason": "Reason",
"note": "Note",
"none": "none",
"all": "all",
"percentage": "Percentage",

View File

@@ -0,0 +1,6 @@
/**
* Helper function to cast a z.union([z.number(), z.string()]) to a number
*/
export const castNumber = (number: number | string) => {
return typeof number === "string" ? Number(number.replace(",", ".")) : number
}

View File

@@ -0,0 +1,75 @@
import { ClaimItem, LineItem, Order } from "@medusajs/medusa"
/**
* Return line items that are returnable from an order
* @param order
* @param isClaim
*/
export const getAllReturnableItems = (
order: Omit<Order, "beforeInserts">,
isClaim: boolean
) => {
let orderItems = order.items.reduce(
(map, obj) =>
map.set(obj.id, {
...obj,
}),
new Map<string, Omit<LineItem, "beforeInsert">>()
)
let claimedItems: ClaimItem[] = []
if (order.claims && order.claims.length) {
for (const claim of order.claims) {
if (claim.return_order?.status !== "canceled") {
claim.claim_items = claim.claim_items ?? []
claimedItems = [...claimedItems, ...claim.claim_items]
}
if (
claim.fulfillment_status === "not_fulfilled" &&
claim.payment_status === "na"
) {
continue
}
if (claim.additional_items && claim.additional_items.length) {
orderItems = claim.additional_items
.filter(
(it) =>
it.shipped_quantity ||
it.shipped_quantity === it.fulfilled_quantity
)
.reduce((map, obj) => map.set(obj.id, { ...obj }), orderItems)
}
}
}
if (!isClaim) {
if (order.swaps && order.swaps.length) {
for (const swap of order.swaps) {
if (swap.fulfillment_status === "not_fulfilled") {
continue
}
orderItems = swap.additional_items.reduce(
(map, obj) =>
map.set(obj.id, {
...obj,
}),
orderItems
)
}
}
}
for (const item of claimedItems) {
const i = orderItems.get(item.item_id)
if (i) {
i.quantity = i.quantity - item.quantity
i.quantity !== 0 ? orderItems.set(i.id, i) : orderItems.delete(i.id)
}
}
return [...orderItems.values()]
}

View File

@@ -2,8 +2,8 @@ import type {
AdminCollectionsRes,
AdminCustomerGroupsRes,
AdminCustomersRes,
AdminDraftOrdersRes,
AdminDiscountsRes,
AdminDraftOrdersRes,
AdminGiftCardsRes,
AdminOrdersRes,
AdminProductsRes,
@@ -124,6 +124,11 @@ export const v1Routes: RouteObject[] = [
path: "edit",
lazy: () => import("../../routes/orders/order-edit"),
},
{
path: "returns",
lazy: () =>
import("../../routes/orders/order-create-return"),
},
],
},
],

View File

@@ -0,0 +1,16 @@
import { z } from "zod"
export const CreateReturnSchema = z.object({
quantity: z.record(z.string(), z.number()),
reason: z.record(z.string(), z.string().optional()),
note: z.record(z.string(), z.string().optional()),
location: z.string(),
shipping: z.string(),
send_notification: z.boolean().optional(),
enable_custom_refund: z.boolean().optional(),
enable_custom_shipping_price: z.boolean().optional(),
custom_refund: z.union([z.string(), z.number()]).optional(),
custom_shipping_price: z.union([z.string(), z.number()]).optional(),
})

View File

@@ -0,0 +1 @@
export { OrderCreateReturnForm as CreateReturns } from "./order-create-return-form"

View File

@@ -0,0 +1,501 @@
import {
AdminGetVariantsVariantInventoryRes,
LevelWithAvailability,
LineItem,
Order,
} from "@medusajs/medusa"
import {
Alert,
CurrencyInput,
Heading,
Select,
Switch,
Text,
} from "@medusajs/ui"
import { useAdminShippingOptions, useAdminStockLocations } from "medusa-react"
import { useEffect, useMemo, useState } from "react"
import { Control, UseFormReturn, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as z from "zod"
import { Form } from "../../../../../../components/common/form"
import { ReturnItem } from "./return-item"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { MoneyAmountCell } from "../../../../../../components/table/table-cells/common/money-amount-cell"
import { castNumber } from "../../../../../../lib/cast-number"
import { getCurrencySymbol } from "../../../../../../lib/currencies"
import { medusa } from "../../../../../../lib/medusa"
import { getDbAmount } from "../../../../../../lib/money-amount-helpers"
import { CreateReturnSchema } from "../constants"
type OrderCreateReturnDetailsProps = {
form: UseFormReturn<z.infer<typeof CreateReturnSchema>>
items: LineItem[] // Items selected for return
order: Order
onRefundableAmountChange: (amount: number) => void
}
export function OrderCreateReturnDetails({
form,
items,
order,
onRefundableAmountChange,
}: OrderCreateReturnDetailsProps) {
const { t } = useTranslation()
const { currency_code } = order
const { setValue } = form
const [inventoryMap, setInventoryMap] = useState<
Record<string, LevelWithAvailability[]>
>({})
const {
customShippingPrice,
enableCustomRefund,
enableCustomShippingPrice,
quantity,
shipping,
selectedLocation,
} = useWatchFields(form.control)
const { shipping_options = [], isLoading: isShippingOptionsLoading } =
useAdminShippingOptions({
region_id: order.region_id,
is_return: true,
})
const noShippingOptions =
!isShippingOptionsLoading && !shipping_options.length
const { stock_locations = [] } = useAdminStockLocations({})
useEffect(() => {
const getInventoryMap = async () => {
const ret: Record<string, LevelWithAvailability[]> = {}
if (!items.length) {
return ret
}
;(
await Promise.all(
items.map(async (item) => {
if (!item.variant_id) {
return undefined
}
return await medusa.admin.variants.getInventory(item.variant_id)
})
)
)
.filter((it) => it?.variant)
.forEach((item) => {
const { variant } = item as AdminGetVariantsVariantInventoryRes
const levels = variant.inventory[0]?.location_levels
if (!levels) {
return
}
ret[variant.id] = levels
})
return ret
}
getInventoryMap().then((map) => {
setInventoryMap(map)
})
}, [items])
const showLevelsWarning = useMemo(() => {
if (!selectedLocation) {
return false
}
const allItemsHaveLocation = items
.map((item) => {
if (!item?.variant_id) {
return true
}
return inventoryMap[item.variant_id]?.find(
(l) => l.location_id === selectedLocation
)
})
.every(Boolean)
return !allItemsHaveLocation
}, [items, inventoryMap, selectedLocation])
const shippingPrice = useMemo(() => {
if (enableCustomShippingPrice && customShippingPrice) {
const amount =
customShippingPrice === "" ? 0 : castNumber(customShippingPrice)
return getDbAmount(amount, currency_code)
}
const method = shipping_options?.find((o) => shipping === o.id) as
| PricedShippingOption
| undefined
return method?.price_incl_tax || 0
}, [
shipping,
customShippingPrice,
enableCustomShippingPrice,
shipping_options,
currency_code,
])
const refundable = useMemo(() => {
const itemTotal = items.reduce((acc: number, curr: LineItem): number => {
const unitRefundable =
(curr.refundable || 0) / (curr.quantity - (curr.returned_quantity || 0))
return acc + unitRefundable * quantity[curr.id]
}, 0)
const amount = itemTotal - (shippingPrice || 0)
onRefundableAmountChange(amount)
return amount
}, [items, onRefundableAmountChange, quantity, shippingPrice])
useEffect(() => {
setValue("enable_custom_shipping_price", false, {
shouldDirty: true,
shouldTouch: true,
})
setValue("custom_shipping_price", 0, {
shouldDirty: true,
shouldTouch: true,
})
}, [enableCustomRefund, setValue])
return (
<div className="flex size-full flex-col items-center overflow-auto p-16">
<div className="flex w-full max-w-[720px] flex-col justify-center px-2 pb-2">
<div className="flex flex-col gap-y-1 pb-10">
<Heading className="text-2xl">{t("general.details")}</Heading>
</div>
<Heading className="mb-2 text-base">
{t("orders.returns.chooseItems")}
</Heading>
{items.map((item) => (
<ReturnItem
key={item.id}
item={item}
form={form}
currencyCode={order.currency_code}
/>
))}
<div className="flex flex-col gap-y-1 pb-4 pt-8">
<Heading className="text-base">{t("fields.shipping")}</Heading>
</div>
<div className="flex gap-x-4">
<div className="flex-1">
<Form.Field
control={form.control}
name="location"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label> {t("fields.location")}</Form.Label>
<Form.Hint>
{t("orders.returns.locationDescription")}
</Form.Hint>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger className="bg-ui-bg-base" ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{stock_locations.map((l) => (
<Select.Item key={l.id} value={l.id}>
{l.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex-1">
<Form.Field
control={form.control}
name="shipping"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label
tooltip={
noShippingOptions
? t("orders.returns.noShippingOptions")
: undefined
}
>
{t("fields.shipping")}
</Form.Label>
<Form.Hint>
{t("orders.returns.shippingDescription")}
</Form.Hint>
<Form.Control>
<Select
onValueChange={onChange}
{...field}
disabled={noShippingOptions}
>
<Select.Trigger className="bg-ui-bg-base" ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{shipping_options.map((o) => (
<Select.Item key={o.id} value={o.id}>
{o.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
{showLevelsWarning && (
<Alert variant="warning" dismissible className="mt-4 p-5">
<div className="text-ui-fg-subtle txt-small pb-2 font-medium leading-[20px]">
{t("orders.returns.noInventoryLevel")}
</div>
<Text className="text-ui-fg-subtle txt-small leading-normal">
{t("orders.returns.noInventoryLevelDesc")}
</Text>
</Alert>
)}
<div className="text-ui-fg-base my-10 flex w-full justify-between border-b border-t border-dashed py-8">
<Text weight="plus" className="txt-small flex-1">
{t("orders.returns.refundAmount")}
</Text>
<div className="txt-small block flex-1 text-right">
{form.watch("enable_custom_refund") ? (
<span className="text-right">-</span>
) : (
<MoneyAmountCell
align="right"
amount={refundable}
currencyCode={order.currency_code}
/>
)}
</div>
</div>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="send_notification"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label>
{t("orders.returns.sendNotification")}
</Form.Label>
<Form.Control>
<Form.Control>
<Switch
checked={!!value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("orders.returns.sendNotificationHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="mt-10 flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="enable_custom_refund"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label>{t("orders.returns.customRefund")}</Form.Label>
<Form.Control>
<Form.Control>
<Switch
checked={!!value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("orders.returns.customRefundHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{enableCustomRefund && (
<div className="w-[50%] pr-2">
<Form.Field
control={form.control}
name="custom_refund"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<CurrencyInput
min={0}
max={order.refundable_amount}
onValueChange={onChange}
code={order.currency_code}
symbol={getCurrencySymbol(order.currency_code)}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)}
</div>
<div className="mt-10 flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="enable_custom_shipping_price"
render={({ field }) => {
let tooltip = undefined
if (enableCustomRefund) {
tooltip = t("orders.returns.shippingPriceTooltip1")
} else if (!shipping) {
tooltip = t("orders.returns.shippingPriceTooltip2")
}
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label tooltip={tooltip}>
{t("orders.returns.customShippingPrice")}
</Form.Label>
<Form.Control>
<Form.Control>
<Switch
disabled={
form.watch("enable_custom_refund") ||
!form.watch("shipping")
}
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</Form.Control>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("orders.returns.customShippingPriceHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{enableCustomShippingPrice && (
<div className="w-[50%] pr-2">
<Form.Field
control={form.control}
name="custom_shipping_price"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<CurrencyInput
min={0}
onValueChange={onChange}
code={order.currency_code}
symbol={getCurrencySymbol(order.currency_code)}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)}
</div>
</div>
</div>
)
}
const useWatchFields = (
control: Control<z.infer<typeof CreateReturnSchema>>
) => {
const enableCustomShippingPrice = useWatch({
control: control,
name: "enable_custom_shipping_price",
})
const enableCustomRefund = useWatch({
control: control,
name: "enable_custom_refund",
})
const quantity = useWatch({
control: control,
name: "quantity",
})
const shipping = useWatch({
control: control,
name: "shipping",
})
const customShippingPrice = useWatch({
control: control,
name: "custom_shipping_price",
})
const selectedLocation = useWatch({
control: control,
name: "location",
})
return {
enableCustomShippingPrice,
enableCustomRefund,
quantity,
shipping,
customShippingPrice,
selectedLocation,
}
}

View File

@@ -0,0 +1,138 @@
import { useTranslation } from "react-i18next"
import { LineItem } from "@medusajs/medusa"
import { Input, Select, Text } from "@medusajs/ui"
import { useAdminReturnReasons } from "medusa-react"
import { UseFormReturn } from "react-hook-form"
import { Form } from "../../../../../../components/common/form"
import { Thumbnail } from "../../../../../../components/common/thumbnail"
import { MoneyAmountCell } from "../../../../../../components/table/table-cells/common/money-amount-cell"
type OrderEditItemProps = {
item: LineItem
currencyCode: string
form: UseFormReturn<any>
}
function ReturnItem({ item, currencyCode, form }: OrderEditItemProps) {
const { t } = useTranslation()
const { return_reasons = [] } = useAdminReturnReasons()
return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
<div className="flex gap-x-2 border-b p-3 text-sm">
<div className="flex flex-grow items-center gap-x-3">
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{item.title}
</Text>
{item.variant.sku && <span>({item.variant.sku})</span>}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.variant.title}
</Text>
</div>
</div>
<div className="text-ui-fg-subtle txt-small mr-2 flex flex-shrink-0">
<MoneyAmountCell currencyCode={currencyCode} amount={item.total} />
</div>
</div>
<div className="block p-3 text-sm">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="flex-1">
<Text weight="plus" className="txt-small mb-2">
{t("fields.quantity")}
</Text>
<Form.Field
control={form.control}
name={`quantity.${item.id}`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
className="bg-ui-bg-base txt-small w-full rounded-lg"
min={1}
max={item.quantity}
type="number"
{...field}
onChange={(e) => {
const val = e.target.value
field.onChange(val === "" ? null : Number(val))
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex-1">
<Text weight="plus" className="txt-small mb-2">
{t("fields.reason")}
</Text>
<Form.Field
control={form.control}
name={`reason.${item.id}`}
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger
className="bg-ui-bg-base txt-small"
ref={ref}
>
<Select.Value />
</Select.Trigger>
<Select.Content>
{return_reasons.map((i) => (
<Select.Item key={i.id} value={i.id}>
{i.label}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex-1">
<Text weight="plus" className="txt-small mb-2">
{t("fields.note")}
</Text>
<Form.Field
control={form.control}
name={`note.${item.id}`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input className="bg-ui-bg-base txt-small" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</div>
)
}
export { ReturnItem }

View File

@@ -0,0 +1,286 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { useForm } from "react-hook-form"
import {
AdminPostOrdersOrderReturnsReq,
LineItem,
Order,
} from "@medusajs/medusa"
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
import { useAdminRequestReturn, useAdminShippingOptions } from "medusa-react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { OrdersReturnItem } from "@medusajs/medusa/dist/types/orders"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { castNumber } from "../../../../../lib/cast-number"
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
import { getAllReturnableItems } from "../../../../../lib/rma"
import { CreateReturnSchema } from "./constants"
import { OrderCreateReturnDetails } from "./order-create-return-details"
import { CreateReturnItemTable } from "./order-create-return-item-table"
type OrderCreateReturnsFormProps = {
order: Order
}
enum Tab {
ITEMS = "items",
DETAILS = "details",
}
type StepStatus = {
[key in Tab]: ProgressStatus
}
export function OrderCreateReturnForm({ order }: OrderCreateReturnsFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [tab, setTab] = React.useState<Tab>(Tab.ITEMS)
const { mutateAsync: requestReturnOrder, isLoading } = useAdminRequestReturn(
order.id
)
const { shipping_options = [] } = useAdminShippingOptions({
region_id: order.region_id,
is_return: true,
})
const refundableAmount = useRef(0)
// List of line items that can be returned with updated quantities
const returnableItems = useMemo(() => getAllReturnableItems(order, false), [])
// Line items that are selected for return
const selected = returnableItems.filter((i) => selectedItems.includes(i.id))
const form = useForm<zod.infer<typeof CreateReturnSchema>>({
defaultValues: {
// Items not selected so we don't know defaults yet
quantity: {},
reason: {},
note: {},
location: "",
shipping: "",
send_notification: !order.no_notification,
enable_custom_refund: false,
enable_custom_shipping_price: false,
custom_refund: "",
custom_shipping_price: "",
},
})
const {
formState: { isDirty },
setValue,
} = form
const onSubmit = form.handleSubmit(async (data) => {
const items = selected.map((item) => {
const ret: OrdersReturnItem = {
item_id: item.id,
quantity: data.quantity[item.id],
}
if (data.reason[item.id]) {
ret["reason_id"] = data.reason[item.id]
}
if (data.note[item.id]) {
ret["note"] = data.note[item.id]
}
return ret
})
let refund = refundableAmount.current
if (data.enable_custom_refund && data.custom_refund) {
const customRefund =
data.custom_refund === "" ? 0 : castNumber(data.custom_refund)
refund = getDbAmount(customRefund, order.currency_code)
}
const payload: AdminPostOrdersOrderReturnsReq = {
items,
no_notification: !data.send_notification,
refund,
}
if (data.location) {
payload["location_id"] = data.location
}
if (data.shipping) {
const option = shipping_options.find((o) => o.id === data.shipping) as
| PricedShippingOption
| undefined
const taxRate =
option?.tax_rates?.reduce((acc, curr) => {
return acc + (curr.rate || 0) / 100
}, 0) || 0
let price = option?.price_incl_tax
? Math.round(option.price_incl_tax / (1 + taxRate))
: 0
if (data.enable_custom_shipping_price) {
const customShipping = data.custom_shipping_price
? castNumber(data.custom_shipping_price)
: 0
price = getDbAmount(customShipping, order.currency_code)
}
// TODO: do we send shipping if custom refund is set?
payload["return_shipping"] = {
option_id: data.shipping,
price,
}
}
await requestReturnOrder(payload)
handleSuccess(`/orders/${order.id}`)
})
const [status, setStatus] = React.useState<StepStatus>({
[Tab.ITEMS]: "not-started",
[Tab.DETAILS]: "not-started",
})
const onTabChange = React.useCallback(async (value: Tab) => {
setTab(value)
}, [])
const onNext = React.useCallback(async () => {
switch (tab) {
case Tab.ITEMS: {
selected.forEach((item) => {
setValue(`quantity.${item.id}`, item.quantity, {
shouldDirty: true,
shouldTouch: true,
})
setValue(`reason.${item.id}`, "", {
shouldDirty: true,
shouldTouch: true,
})
setValue(`note.${item.id}`, "", {
shouldDirty: true,
shouldTouch: true,
})
})
setTab(Tab.DETAILS)
break
}
case Tab.DETAILS:
await onSubmit()
break
}
}, [tab, selected, setValue, onSubmit])
const onSelectionChange = (ids: string[]) => {
setSelectedItems(ids)
if (ids.length) {
setStatus((prev) => ({ ...prev, [Tab.ITEMS]: "in-progress" }))
}
}
const onRefundableAmountChange = (amount: number) => {
refundableAmount.current = amount
}
useEffect(() => {
if (tab === Tab.DETAILS) {
setStatus({ [Tab.ITEMS]: "completed", [Tab.DETAILS]: "not-started" })
}
}, [tab])
useEffect(() => {
if (isDirty) {
setStatus({ [Tab.ITEMS]: "completed", [Tab.DETAILS]: "in-progress" })
}
}, [isDirty])
const canMoveToDetails = selectedItems.length
return (
<RouteFocusModal.Form form={form}>
<ProgressTabs
value={tab}
className="h-full"
onValueChange={(tab) => onTabChange(tab as Tab)}
>
<RouteFocusModal.Header className="flex w-full items-center justify-between">
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
<ProgressTabs.Trigger
value={Tab.ITEMS}
className="w-full max-w-[200px]"
status={status[Tab.ITEMS]}
disabled={tab === Tab.DETAILS}
>
<span className="w-full cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
{t("orders.returns.chooseItems")}
</span>
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
value={Tab.DETAILS}
className="w-full max-w-[200px]"
status={status[Tab.DETAILS]}
disabled={!canMoveToDetails}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t("orders.returns.details")}
</span>
</ProgressTabs.Trigger>
</ProgressTabs.List>
<div className="flex flex-1 items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
size="small"
className="whitespace-nowrap"
isLoading={isLoading}
onClick={onNext}
disabled={!canMoveToDetails}
type={tab === Tab.DETAILS ? "submit" : "button"}
>
{tab === Tab.DETAILS ? t("actions.save") : t("general.next")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-[calc(100%-56px)] w-full flex-col items-center overflow-y-auto">
<ProgressTabs.Content value={Tab.ITEMS} className="h-full w-full">
<CreateReturnItemTable
items={returnableItems as LineItem[]}
selectedItems={selectedItems}
onSelectionChange={onSelectionChange}
currencyCode={order.currency_code}
/>
</ProgressTabs.Content>
<ProgressTabs.Content value={Tab.DETAILS} className="h-full w-full">
<OrderCreateReturnDetails
form={form}
items={selected as LineItem[]}
order={order}
onRefundableAmountChange={onRefundableAmountChange}
/>
</ProgressTabs.Content>
</RouteFocusModal.Body>
</ProgressTabs>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,277 @@
import { LineItem } from "@medusajs/medusa"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { DataTable } from "../../../../../../components/table/data-table/index.ts"
import { useDataTable } from "../../../../../../hooks/use-data-table.tsx"
import {
DateComparisonOperator,
NumericalComparisonOperator,
} from "@medusajs/types"
import { getPresentationalAmount } from "../../../../../../lib/money-amount-helpers.ts"
import { useReturnItemTableColumns } from "./use-return-item-table-columns.tsx"
import { useReturnItemTableFilters } from "./use-return-item-table-filters.tsx"
import { useReturnItemTableQuery } from "./use-return-item-table-query.tsx"
const PAGE_SIZE = 50
const PREFIX = "rit"
type CreateReturnItemTableProps = {
onSelectionChange: (ids: string[]) => void
selectedItems: string[]
items: LineItem[]
currencyCode: string
}
export const CreateReturnItemTable = ({
onSelectionChange,
selectedItems,
items,
currencyCode,
}: CreateReturnItemTableProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
selectedItems.reduce((acc, id) => {
acc[id] = true
return acc
}, {} as RowSelectionState)
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
setRowSelection(newState)
onSelectionChange(Object.keys(newState))
}
const { searchParams, raw } = useReturnItemTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const queriedItems = useMemo(() => {
const {
order,
offset,
limit,
q,
created_at,
updated_at,
refundable_amount,
returnable_quantity,
} = searchParams
let results: LineItem[] = items
if (q) {
results = results.filter((i) => {
return (
i.variant.product.title.toLowerCase().includes(q.toLowerCase()) ||
i.variant.title.toLowerCase().includes(q.toLowerCase()) ||
i.variant.sku?.toLowerCase().includes(q.toLowerCase())
)
})
}
if (order) {
const direction = order[0] === "-" ? "desc" : "asc"
const field = order.replace("-", "")
results = sortItems(results, field, direction)
}
if (created_at) {
results = filterByDate(results, created_at, "created_at")
}
if (updated_at) {
results = filterByDate(results, updated_at, "updated_at")
}
if (returnable_quantity) {
results = filterByNumber(
results,
returnable_quantity,
"returnable_quantity",
currencyCode
)
}
if (refundable_amount) {
results = filterByNumber(
results,
refundable_amount,
"refundable_amount",
currencyCode
)
}
return results.slice(offset, offset + limit)
}, [items, currencyCode, searchParams])
const columns = useReturnItemTableColumns(currencyCode)
const filters = useReturnItemTableFilters()
const { table } = useDataTable({
data: queriedItems as LineItem[],
columns: columns,
count: items.length,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: (row) => {
return row.original.quantity - (row.original.returned_quantity || 0) > 0
},
rowSelection: {
state: rowSelection,
updater,
},
})
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={queriedItems.length}
filters={filters}
pagination
layout="fill"
search
orderBy={[
"product_title",
"variant_title",
"sku",
"returnable_quantity",
"refundable_amount",
]}
prefix={PREFIX}
queryObject={raw}
/>
</div>
)
}
const sortItems = (
items: LineItem[],
field: string,
direction: "asc" | "desc"
) => {
return items.sort((a, b) => {
let aValue: any
let bValue: any
if (field === "product_title") {
aValue = a.variant.product.title
bValue = b.variant.product.title
} else if (field === "variant_title") {
aValue = a.variant.title
bValue = b.variant.title
} else if (field === "sku") {
aValue = a.variant.sku
bValue = b.variant.sku
} else if (field === "returnable_quantity") {
aValue = a.quantity - (a.returned_quantity || 0)
bValue = b.quantity - (b.returned_quantity || 0)
} else if (field === "refundable_amount") {
aValue = a.refundable || 0
bValue = b.refundable || 0
}
if (aValue < bValue) {
return direction === "asc" ? -1 : 1
}
if (aValue > bValue) {
return direction === "asc" ? 1 : -1
}
return 0
})
}
const filterByDate = (
items: LineItem[],
date: DateComparisonOperator,
field: "created_at" | "updated_at"
) => {
const { gt, gte, lt, lte } = date
return items.filter((i) => {
const itemDate = new Date(i[field])
let isValid = true
if (gt) {
isValid = isValid && itemDate > new Date(gt)
}
if (gte) {
isValid = isValid && itemDate >= new Date(gte)
}
if (lt) {
isValid = isValid && itemDate < new Date(lt)
}
if (lte) {
isValid = isValid && itemDate <= new Date(lte)
}
return isValid
})
}
const defaultOperators = {
eq: undefined,
gt: undefined,
gte: undefined,
lt: undefined,
lte: undefined,
}
const filterByNumber = (
items: LineItem[],
value: NumericalComparisonOperator | number,
field: "returnable_quantity" | "refundable_amount",
currency_code: string
) => {
const { eq, gt, lt, gte, lte } =
typeof value === "object"
? { ...defaultOperators, ...value }
: { ...defaultOperators, eq: value }
return items.filter((i) => {
const returnableQuantity = i.quantity - (i.returned_quantity || 0)
const refundableAmount = getPresentationalAmount(
i.refundable || 0,
currency_code
)
const itemValue =
field === "returnable_quantity" ? returnableQuantity : refundableAmount
if (eq) {
return itemValue === eq
}
let isValid = true
if (gt) {
isValid = isValid && itemValue > gt
}
if (gte) {
isValid = isValid && itemValue >= gte
}
if (lt) {
isValid = isValid && itemValue < lt
}
if (lte) {
isValid = isValid && itemValue <= lte
}
return isValid
})
}

View File

@@ -0,0 +1,103 @@
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import {
ProductCell,
ProductHeader,
} from "../../../../../../components/table/table-cells/product/product-cell"
import { getStylizedAmount } from "../../../../../../lib/money-amount-helpers"
const columnHelper = createColumnHelper<any>()
export const useReturnItemTableColumns = (currencyCode: string) => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
const isSelectable = row.getCanSelect()
return (
<Checkbox
disabled={!isSelectable}
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.display({
id: "product",
header: () => <ProductHeader />,
cell: ({ row }) => (
<ProductCell product={row.original.variant.product} />
),
}),
columnHelper.accessor("variant.sku", {
header: t("fields.sku"),
cell: ({ getValue }) => {
return getValue() || "-"
},
}),
columnHelper.accessor("variant.title", {
header: t("fields.variant"),
}),
columnHelper.accessor("quantity", {
header: () => (
<div className="flex size-full items-center overflow-hidden text-right">
<span className="truncate">
{t("orders.returns.returnableQuantityHeader")}
</span>
</div>
),
cell: ({ getValue, row }) => {
const returnableQuantity =
getValue() - (row.original.returned_quantity || 0)
return returnableQuantity
},
}),
columnHelper.accessor("refundable", {
header: () => (
<div className="flex size-full items-center justify-end overflow-hidden text-right">
<span className="truncate">
{t("orders.returns.refundableAmountHeader")}
</span>
</div>
),
cell: ({ getValue }) => {
const amount = getValue() || 0
const stylized = getStylizedAmount(amount, currencyCode)
return (
<div className="flex size-full items-center justify-end overflow-hidden text-right">
<span className="truncate">{stylized}</span>
</div>
)
},
}),
],
[t, currencyCode]
)
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../../components/table/data-table"
export const useReturnItemTableFilters = () => {
const { t } = useTranslation()
const filters: Filter[] = [
{
key: "returnable_quantity",
label: t("orders.returns.returnableQuantityLabel"),
type: "number",
},
{
key: "refundable_amount",
label: t("orders.returns.refundableAmountLabel"),
type: "number",
},
{
key: "created_at",
label: t("fields.createdAt"),
type: "date",
},
{
key: "updated_at",
label: t("fields.updatedAt"),
type: "date",
},
]
return filters
}

View File

@@ -0,0 +1,61 @@
import {
DateComparisonOperator,
NumericalComparisonOperator,
} from "@medusajs/types"
import { useQueryParams } from "../../../../../../hooks/use-query-params"
export type ReturnItemTableQuery = {
q?: string
offset: number
order?: string
created_at?: DateComparisonOperator
updated_at?: DateComparisonOperator
returnable_quantity?: NumericalComparisonOperator | number
refundable_amount?: NumericalComparisonOperator | number
}
export const useReturnItemTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
[
"q",
"offset",
"order",
"created_at",
"updated_at",
"returnable_quantity",
"refundable_amount",
],
prefix
)
const {
offset,
created_at,
updated_at,
refundable_amount,
returnable_quantity,
...rest
} = raw
const searchParams = {
...rest,
limit: pageSize,
offset: offset ? Number(offset) : 0,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
refundable_amount: refundable_amount
? JSON.parse(refundable_amount)
: undefined,
returnable_quantity: returnable_quantity
? JSON.parse(returnable_quantity)
: undefined,
}
return { searchParams, raw }
}

View File

@@ -0,0 +1 @@
export { OrderCreateReturn as Component } from "./order-create-return"

View File

@@ -0,0 +1,26 @@
import { useAdminOrder } from "medusa-react"
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateReturns } from "./components/order-create-return-form"
export function OrderCreateReturn() {
const { id } = useParams()
const { order, isLoading, isError, error } = useAdminOrder(id!, {
expand:
"items,items.variant,items.variant.product,returnable_items,claims,claims.additional_items,claims.return_order,swaps,swaps.additional_items",
})
if (isError) {
throw error
}
const ready = !isLoading && order
return (
<RouteFocusModal>
{ready && <CreateReturns order={order} />}
</RouteFocusModal>
)
}

View File

@@ -1,4 +1,4 @@
import { Buildings, PencilSquare } from "@medusajs/icons"
import { Buildings, PencilSquare, ArrowUturnLeft } from "@medusajs/icons"
import { LineItem, Order } from "@medusajs/medusa"
import { ReservationItemDTO } from "@medusajs/types"
import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui"
@@ -46,6 +46,11 @@ const Header = ({ order }: { order: Order }) => {
to: "#", // TODO: Open modal to allocate items
icon: <Buildings />,
},
{
label: t("orders.summary.requestReturn"),
to: `/orders/${order.id}/returns`,
icon: <ArrowUturnLeft />,
},
],
},
]}

View File

@@ -1,10 +1,9 @@
import {
AdminGetVariantsVariantInventoryRes,
AdminPostOrdersOrderReturnsReq,
InventoryLevelDTO,
LevelWithAvailability,
Order,
LineItem as RawLineItem,
StockLocationDTO,
} from "@medusajs/medusa"
import LayeredModal, {
LayeredModalContext,
@@ -100,7 +99,7 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
}, [order.items])
const [inventoryMap, setInventoryMap] = useState<
Map<string, InventoryLevelDTO[]>
Map<string, LevelWithAvailability[]>
>(new Map())
React.useEffect(() => {
@@ -122,7 +121,7 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
.filter((it) => !!it)
.map((item) => {
const { variant } = item as AdminGetVariantsVariantInventoryRes
return [variant.id, variant.inventory[0].location_levels]
return [variant.id, variant.inventory[0]?.location_levels]
})
)
}