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:
@@ -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 can’t 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",
|
||||
|
||||
6
packages/admin-next/dashboard/src/lib/cast-number.ts
Normal file
6
packages/admin-next/dashboard/src/lib/cast-number.ts
Normal 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
|
||||
}
|
||||
75
packages/admin-next/dashboard/src/lib/rma.ts
Normal file
75
packages/admin-next/dashboard/src/lib/rma.ts
Normal 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()]
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderCreateReturnForm as CreateReturns } from "./order-create-return-form"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./order-create-return-details"
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./order-create-return-item-table"
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderCreateReturn as Component } from "./order-create-return"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user