feat(dashboard, order, medusa, types, js-sdk): Request return e2e flow (#7848)

This commit is contained in:
Frane Polić
2024-07-24 19:19:00 +02:00
committed by GitHub
parent 0a482e972f
commit f7d1cd259e
36 changed files with 2251 additions and 146 deletions

View File

@@ -14,7 +14,7 @@ export const LinkButton = ({
return (
<Link
className={clx(
" transition-fg txt-compact-small-plus rounded-[4px] outline-none",
"transition-fg txt-compact-small-plus rounded-[4px] outline-none",
"focus-visible:shadow-borders-focus",
{
"text-ui-fg-interactive hover:text-ui-fg-interactive-hover":

View File

@@ -9,10 +9,16 @@ import {
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { sdk } from "../../lib/client"
import { AdminCreateOrderShipment, HttpTypes } from "@medusajs/types"
import { HttpTypes } from "@medusajs/types"
const ORDERS_QUERY_KEY = "orders" as const
export const ordersQueryKeys = queryKeysFactory(ORDERS_QUERY_KEY)
const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY)
_orderKeys.preview = function (id: string) {
return [this.detail(id), "preview"]
}
export const ordersQueryKeys = _orderKeys
export const useOrder = (
id: string,
@@ -31,6 +37,22 @@ export const useOrder = (
return { ...data, ...rest }
}
export const useOrderPreview = (
id: string,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.order.retrievePreview(id),
queryKey: ordersQueryKeys.preview(id),
...options,
})
return { ...data, ...rest }
}
export const useOrders = (
query?: Record<string, any>,
options?: Omit<
@@ -49,10 +71,14 @@ export const useOrders = (
export const useCreateOrderFulfillment = (
orderId: string,
options?: UseMutationOptions<any, Error, any>
options?: UseMutationOptions<
HttpTypes.AdminOrderResponse,
Error,
HttpTypes.AdminCreateOrderFulfillment
>
) => {
return useMutation({
mutationFn: (payload: any) =>
mutationFn: (payload: HttpTypes.AdminCreateOrderFulfillment) =>
sdk.admin.order.createFulfillment(orderId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({

View File

@@ -0,0 +1,29 @@
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const RETURN_REASONS_QUERY_KEY = "return_reasons" as const
export const returnReasonQueryKeys = queryKeysFactory(RETURN_REASONS_QUERY_KEY)
export const useReturnReasons = (
query?: HttpTypes.AdminReturnReasonListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminReturnReasonsResponse,
Error,
HttpTypes.AdminReturnReasonsResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.returnReason.list(query),
queryKey: returnReasonQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -0,0 +1,284 @@
import {
QueryKey,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { HttpTypes } from "@medusajs/types"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { ordersQueryKeys } from "./orders"
import { queryKeysFactory } from "../../lib/query-key-factory"
const RETURNS_QUERY_KEY = "returns" as const
export const returnsQueryKeys = queryKeysFactory(RETURNS_QUERY_KEY)
export const useReturn = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.return.retrieve(id, query),
queryKey: returnsQueryKeys.detail(id, query),
...options,
})
return { ...data, ...rest }
}
export const useReturns = (
query?: HttpTypes.AdminReturnFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminReturnFilters,
Error,
HttpTypes.AdminReturnsResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.return.list(query),
queryKey: returnsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useInitiateReturn = (
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminInitiateReturnRequest
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminInitiateReturnRequest) =>
sdk.admin.return.initiateRequest(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 useAddReturnItem = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminAddReturnItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddReturnItems) =>
sdk.admin.return.addReturnItem(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateReturnItem = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminUpdateReturnItems & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateReturnItems & { actionId: string }) => {
return sdk.admin.return.updateReturnItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRemoveReturnItem = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminAddReturnItems
>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.return.removeReturnItem(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddReturnShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminAddReturnShipping
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddReturnShipping) =>
sdk.admin.return.addReturnShipping(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateReturnShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminReturnResponse,
Error,
HttpTypes.AdminAddReturnShipping
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminAddReturnShipping & { actionId: string }) =>
sdk.admin.return.updateReturnShipping(id, actionId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteReturnShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminReturnResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.return.deleteReturnShipping(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
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),
})
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -107,9 +107,12 @@
"close": "Close",
"showMore": "Show more",
"continue": "Continue",
"addReason": "Add Reason",
"addNote": "Add Note",
"reset": "Reset",
"confirm": "Confirm",
"edit": "Edit",
"addItems": "Add items",
"download": "Download",
"clearAll": "Clear all",
"apply": "Apply",
@@ -772,6 +775,7 @@
"requiresAction": "Requires action"
}
},
"edits": {
"title": "Edit order",
"currentItems": "Current items",
@@ -783,26 +787,25 @@
"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",
"create": "Create Return",
"inbound": "Inbound",
"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.",
"sendNotificationHint": "Notify customer about return.",
"returnTotal": "Return total",
"refundAmount": "Refund amount",
"reason": "Reason",
"reasonHint": "Choose why the customer want to return items.",
"note": "Note",
"noInventoryLevel": "No inventory level",
"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",
"noteHint": "You can type freely if you want to specify something.",
"location": "Location",
"locationHint": "Choose which location you want to return the items to.",
"inboundShipping": "Inbound shipping",
"inboundShippingHint": "Choose which method you want to use.",
"returnableQuantityLabel": "Returnable quantity",
"returnableQuantityHeader": "Returnable Quantity"
"refundableAmountLabel": "Refundable amount",
"returnRequestedInfo": "{{requestedItemsCount}}x item return requested"
},
"reservations": {
"allocatedLabel": "Allocated",
@@ -922,7 +925,9 @@
"items_other": "{{count}} items"
},
"return": {
"created": "Return created"
"created": "Return created",
"items_one": "{{count}} item returned",
"items_other": "{{count}} items returned"
},
"note": {
"comment": "Comment",
@@ -2256,6 +2261,7 @@
"inStock": "In stock",
"location": "Location",
"quantity": "Quantity",
"qty": "Qty",
"variant": "Variant",
"id": "ID",
"parent": "Parent",

View File

@@ -1,75 +1,15 @@
import { ClaimItem, LineItem, Order } from "@medusajs/medusa"
import { AdminOrderLineItem } from "@medusajs/types"
/**
* 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">>()
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_requested_quantity,
} = item.detail
return (
shipped_quantity - (return_received_quantity + return_requested_quantity)
)
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

@@ -227,6 +227,11 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/orders/order-create-shipment"),
},
{
path: "returns",
lazy: () =>
import("../../routes/orders/order-create-return"),
},
],
},
],

View File

@@ -0,0 +1,275 @@
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import {
DateComparisonOperator,
NumericalComparisonOperator,
} from "@medusajs/types"
import { AdminOrderLineItem } from "@medusajs/types"
import { useReturnItemTableColumns } from "./use-return-item-table-columns"
import { useReturnItemTableFilters } from "./use-return-item-table-filters"
import { useReturnItemTableQuery } from "./use-return-item-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { DataTable } from "../../../../../components/table/data-table"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { getReturnableQuantity } from "../../../../../lib/rma"
const PAGE_SIZE = 50
const PREFIX = "rit"
type AddReturnItemsTableProps = {
onSelectionChange: (ids: string[]) => void
selectedItems: string[]
items: AdminOrderLineItem[]
currencyCode: string
}
export const AddReturnItemsTable = ({
onSelectionChange,
selectedItems,
items,
currencyCode,
}: AddReturnItemsTableProps) => {
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: AdminOrderLineItem[] = 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 AdminOrderLineItem[],
columns: columns,
count: queriedItems.length,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: (row) => {
return getReturnableQuantity(row.original) > 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: AdminOrderLineItem[],
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: AdminOrderLineItem[],
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: AdminOrderLineItem[],
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 = getStylizedAmount(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 @@
export * from "./add-return-items-table"

View File

@@ -0,0 +1,98 @@
import { useMemo } from "react"
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useTranslation } from "react-i18next"
import {
ProductCell,
ProductHeader,
} from "../../../../../components/table/table-cells/product/product-cell"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { getReturnableQuantity } from "../../../../../lib/rma"
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("fields.quantity")}</span>
</div>
),
cell: ({ getValue, row }) => {
return getReturnableQuantity(row.original)
},
}),
columnHelper.accessor("refundable_total", {
header: () => (
<div className="flex size-full items-center justify-end overflow-hidden text-right">
<span className="truncate">{t("fields.price")}</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,32 @@
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 * from "./return-create-form"

View File

@@ -0,0 +1,709 @@
import React, { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Alert,
Button,
CurrencyInput,
Heading,
IconButton,
Switch,
Text,
toast,
} from "@medusajs/ui"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { AdminOrder, InventoryLevelDTO, ReturnDTO } from "@medusajs/types"
import { PencilSquare } from "@medusajs/icons"
import {
RouteFocusModal,
StackedFocusModal,
useRouteModal,
useStackedModal,
} from "../../../../../components/modals"
import { ReturnCreateSchema, ReturnCreateSchemaType } from "./schema"
import { AddReturnItemsTable } from "../add-return-items-table"
import { Form } from "../../../../../components/common/form"
import { ReturnItem } from "./return-item"
import { Combobox } from "../../../../../components/inputs/combobox"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useShippingOptions } from "../../../../../hooks/api/shipping-options"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import {
useAddReturnItem,
useAddReturnShipping,
useCancelReturnRequest,
useConfirmReturnRequest,
useDeleteReturnShipping,
useRemoveReturnItem,
useUpdateReturnItem,
useUpdateReturnShipping,
} from "../../../../../hooks/api/returns"
import { currencies } from "../../../../../lib/data/currencies"
import { sdk } from "../../../../../lib/client"
type ReturnCreateFormProps = {
order: AdminOrder
activeReturn: ReturnDTO // TODO: AdminReturn
preview: AdminOrder // TODO
}
let selectedItems: string[] = []
let IS_CANCELING = false
export const ReturnCreateForm = ({
order,
preview,
activeReturn,
}: ReturnCreateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
/**
* STATE
*/
const { setIsOpen } = useStackedModal()
const [isShippingPriceEdit, setIsShippingPriceEdit] = useState(false)
const [customShippingAmount, setCustomShippingAmount] = useState(0)
const [inventoryMap, setInventoryMap] = useState<
Record<string, InventoryLevelDTO[]>
>({})
/**
* HOOKS
*/
const { stock_locations = [] } = useStockLocations({ limit: 999 })
const { shipping_options = [] } = useShippingOptions({
limit: 999,
fields: "*prices,+service_zone.fulfillment_set.location.id",
/**
* TODO: this should accept filter for location_id
*/
})
/**
* MUTATIONS
*/
const { mutateAsync: confirmReturnRequest, isPending: isConfirming } =
useConfirmReturnRequest(activeReturn.id, order.id)
const { mutateAsync: cancelReturnRequest, isPending: isCanceling } =
useCancelReturnRequest(activeReturn.id, order.id)
const { mutateAsync: addReturnShipping, isPending: isAddingReturnShipping } =
useAddReturnShipping(activeReturn.id, order.id)
const {
mutateAsync: updateReturnShipping,
isPending: isUpdatingReturnShipping,
} = useUpdateReturnShipping(activeReturn.id, order.id)
const {
mutateAsync: deleteReturnShipping,
isPending: isDeletingReturnShipping,
} = useDeleteReturnShipping(activeReturn.id, order.id)
const { mutateAsync: addReturnItem, isPending: isAddingReturnItem } =
useAddReturnItem(activeReturn.id, order.id)
const { mutateAsync: removeReturnItem, isPending: isRemovingReturnItem } =
useRemoveReturnItem(activeReturn.id, order.id)
const { mutateAsync: updateReturnItem, isPending: isUpdatingReturnItem } =
useUpdateReturnItem(activeReturn.id, order.id)
const isRequestLoading =
isConfirming ||
isCanceling ||
isAddingReturnShipping ||
isUpdatingReturnShipping ||
isDeletingReturnShipping ||
isAddingReturnItem ||
isRemovingReturnItem ||
isUpdatingReturnItem
/**
* FORM
*/
const form = useForm<ReturnCreateSchemaType>({
/**
* TODO: reason selection once Return reason settings are added
*/
defaultValues: () => {
const method = preview.shipping_methods.find(
(s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD")
)
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,
})),
option_id: method ? method.shipping_option_id : "",
location_id: "",
send_notification: false,
})
},
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,
remove,
update,
} = useFieldArray({
name: "items",
control: form.control,
})
useEffect(() => {
const existingItemsMap = {}
preview.items.forEach((i) => {
const ind = items.findIndex((field) => field.item_id === i.id)
/**
* THESE ITEMS ARE REMOVED FROM RETURN REQUEST
*/
if (!i.detail.return_requested_quantity) {
return
}
existingItemsMap[i.id] = true
if (ind > -1) {
if (items[ind].quantity !== i.detail.return_requested_quantity) {
const returnItemAction = i.actions?.find(
(a) => a.action === "RETURN_ITEM"
)
update(ind, {
...items[ind],
quantity: i.detail.return_requested_quantity,
note: returnItemAction?.internal_note,
reason_id: returnItemAction?.details?.reason_id,
})
}
} else {
append({ item_id: i.id, quantity: i.detail.return_requested_quantity })
}
})
items.forEach((i, ind) => {
if (!(i.item_id in existingItemsMap)) {
remove(ind)
}
})
}, [preview.items])
useEffect(() => {
const method = preview.shipping_methods.find(
(s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD")
)
if (method) {
form.setValue("option_id", method.shipping_option_id)
}
}, [preview.shipping_methods])
const showPlaceholder = !items.length
const locationId = form.watch("location_id")
const shippingOptionId = form.watch("option_id")
const handleSubmit = form.handleSubmit(async (data) => {
try {
await confirmReturnRequest({ no_notification: !data.send_notification })
handleSuccess()
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
const onItemsSelected = () => {
addReturnItem({
items: selectedItems.map((id) => ({
id,
quantity: 1,
})),
})
setIsOpen("items", false)
}
const onShippingOptionChange = async (selectedOptionId: string) => {
const promises = preview.shipping_methods
.map((s) => s.actions?.find((a) => a.action === "SHIPPING_ADD")?.id)
.filter(Boolean)
.map(deleteReturnShipping)
await Promise.all(promises)
await addReturnShipping({ shipping_option_id: selectedOptionId })
}
useEffect(() => {
if (isShippingPriceEdit) {
document.getElementById("js-shipping-input").focus()
}
}, [isShippingPriceEdit])
const showLevelsWarning = useMemo(() => {
if (!locationId) {
return false
}
const allItemsHaveLocation = items
.map((_i) => {
const item = itemsMap.get(_i.item_id)
if (!item?.variant_id) {
return true
}
if (!item.variant.manage_inventory) {
return true
}
return inventoryMap[item.variant_id]?.find(
(l) => l.location_id === locationId
)
})
.every(Boolean)
return !allItemsHaveLocation
}, [items, inventoryMap, locationId])
useEffect(() => {
const getInventoryMap = async () => {
const ret: Record<string, InventoryLevelDTO[]> = {}
if (!items.length) {
return ret
}
;(
await Promise.all(
items.map(async (_i) => {
const item = itemsMap.get(_i.item_id)
if (!item.variant_id) {
return undefined
}
return await sdk.admin.product.retrieveVariant(
item.variant.product.id,
item.variant_id,
{ fields: "*inventory,*inventory.location_levels" }
)
})
)
)
.filter((it) => it?.variant)
.forEach((item) => {
const { variant } = item
const levels = variant.inventory[0]?.location_levels
if (!levels) {
return
}
ret[variant.id] = levels
})
return ret
}
getInventoryMap().then((map) => {
setInventoryMap(map)
})
}, [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(() => {
const method = preview.shipping_methods.find(
(sm) => !!sm.actions?.find((a) => a.action === "SHIPPING_ADD")
)
return method?.total || 0
}, [preview.shipping_methods])
const refundAmount = returnTotal - shippingTotal
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex size-full justify-center overflow-y-auto">
<div className="mt-16 w-[720px] max-w-[100%] px-4 md:p-0">
<Heading level="h1">{t("orders.returns.create")}</Heading>
<div className="mt-8 flex items-center justify-between">
<Heading level="h2">{t("orders.returns.inbound")}</Heading>
<StackedFocusModal id="items">
<StackedFocusModal.Trigger asChild>
<a className="focus-visible:shadow-borders-focus transition-fg txt-compact-small-plus cursor-pointer text-blue-500 outline-none hover:text-blue-400">
{t("actions.addItems")}
</a>
</StackedFocusModal.Trigger>
<StackedFocusModal.Content>
<StackedFocusModal.Header />
<AddReturnItemsTable
items={order.items!}
selectedItems={items.map((i) => i.item_id)}
currencyCode={order.currency_code}
onSelectionChange={(s) => (selectedItems = s)}
/>
<StackedFocusModal.Footer>
<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"
variant="secondary"
size="small"
>
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
role="button"
onClick={() => onItemsSelected()}
>
{t("actions.save")}
</Button>
</div>
</div>
</StackedFocusModal.Footer>
</StackedFocusModal.Content>
</StackedFocusModal>
</div>
{showPlaceholder && (
<div
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
className="bg-ui-bg-field mt-4 block h-[56px] w-full rounded-lg border border-dashed"
/>
)}
{items.map((item, index) => (
<ReturnItem
key={item.id}
item={itemsMap.get(item.item_id)!}
previewItem={previewItemsMap.get(item.item_id)!}
currencyCode={order.currency_code}
form={form}
onRemove={() => {
const actionId = preview.items
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
removeReturnItem(actionId)
}
}}
onUpdate={(payload) => {
const actionId = preview.items
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
updateReturnItem({ ...payload, actionId })
}
}}
index={index}
/>
))}
{!showPlaceholder && (
<div className="mt-8 flex flex-col gap-y-4">
{/*LOCATION*/}
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Form.Label>{t("orders.returns.location")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.locationHint")}
</Form.Hint>
</div>
<Form.Field
control={form.control}
name="location_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
}}
{...field}
options={(stock_locations ?? []).map(
(stockLocation) => ({
label: stockLocation.name,
value: stockLocation.id,
})
)}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
{/*INBOUND SHIPPING*/}
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Form.Label>
{t("orders.returns.inboundShipping")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.inboundShippingHint")}
</Form.Hint>
</div>
{/*TODO: WHAT IF THE RETURN OPTION HAS COMPUTED PRICE*/}
<Form.Field
control={form.control}
name="option_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
onShippingOptionChange(v)
}}
{...field}
options={(shipping_options ?? [])
.filter(
(so) =>
(locationId
? so.service_zone.fulfillment_set!
.location.id === locationId
: true) &&
!!so.rules.find(
(r) =>
r.attribute === "is_return" &&
r.value === "true"
)
)
.map((so) => ({
label: so.name,
value: so.id,
}))}
disabled={!locationId}
/>
</Form.Control>
</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>
)}
{/*TOTALS SECTION*/}
<div className="mt-8 border-y border-dotted py-4">
<div className="mb-2 flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.returns.returnTotal")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(
returnTotal ? -1 * returnTotal : returnTotal,
order.currency_code
)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.returns.inboundShipping")}
</span>
<span className="txt-small text-ui-fg-subtle flex items-center">
{!isShippingPriceEdit && (
<IconButton
onClick={() => setIsShippingPriceEdit(true)}
variant="transparent"
className="text-ui-fg-muted"
disabled={showPlaceholder || !shippingOptionId}
>
<PencilSquare />
</IconButton>
)}
{isShippingPriceEdit ? (
<CurrencyInput
id="js-shipping-input"
onBlur={() => {
let actionId
preview.shipping_methods.forEach((s) => {
if (s.actions) {
for (let a of s.actions) {
if (a.action === "SHIPPING_ADD") {
actionId = a.id
}
}
}
})
if (actionId) {
updateReturnShipping({
actionId,
custom_price:
typeof customShippingAmount === "string"
? null
: customShippingAmount,
})
}
setIsShippingPriceEdit(false)
}}
symbol={
currencies[order.currency_code.toUpperCase()]
.symbol_native
}
code={order.currency_code}
onValueChange={(value) =>
setCustomShippingAmount(value ? parseInt(value) : "")
}
value={customShippingAmount}
disabled={showPlaceholder}
/>
) : (
getStylizedAmount(shippingTotal, 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.refundAmount")}
</span>
<span className="txt-small font-medium">
{getStylizedAmount(
refundAmount ? -1 * refundAmount : refundAmount,
order.currency_code
)}
</span>
</div>
</div>
{/*SEND NOTIFICATION*/}
<div className="bg-ui-bg-field mt-8 rounded-lg border py-2 pl-2 pr-4">
<Form.Field
control={form.control}
name="send_notification"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center">
<Form.Control className="mr-4 self-start">
<Switch
className="mt-[2px]"
checked={!!value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
<div className="block">
<Form.Label>
{t("orders.returns.sendNotification")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.sendNotificationHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<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"
>
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
isLoading={isRequestLoading}
>
{t("actions.save")}
</Button>
</div>
</div>
</RouteFocusModal.Footer>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,246 @@
import { useTranslation } from "react-i18next"
import React from "react"
import { IconButton, Input, Text } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { HttpTypes, AdminOrderLineItem } from "@medusajs/types"
import { ChatBubble, DocumentText, XCircle, XMark } from "@medusajs/icons"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { Form } from "../../../../../components/common/form"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Combobox } from "../../../../../components/inputs/combobox"
import { useReturnReasons } from "../../../../../hooks/api/return-reasons"
type OrderEditItemProps = {
item: AdminOrderLineItem
previewItem: AdminOrderLineItem
currencyCode: string
index: number
onRemove: () => void
onUpdate: (payload: HttpTypes.AdminUpdateReturnItems) => void
form: UseFormReturn<any>
}
function ReturnItem({
item,
previewItem,
currencyCode,
form,
onRemove,
onUpdate,
index,
}: OrderEditItemProps) {
const { t } = useTranslation()
const { return_reasons = [] } = useReturnReasons({ fields: "+label" })
const formItem = form.watch(`items.${index}`)
const showReturnReason = typeof formItem.reason_id === "string"
const showNote = typeof formItem.note === "string"
return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
<div className="flex flex-col items-center gap-x-2 gap-y-2 border-b p-3 text-sm md:flex-row">
<div className="flex flex-1 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.product.title}
</Text>
</div>
</div>
<div className="flex flex-1 justify-between">
<div className="flex flex-grow items-center gap-2">
<Form.Field
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
className="bg-ui-bg-base txt-small w-[67px] rounded-lg"
min={1}
max={item.quantity}
type="number"
{...field}
onChange={(e) => {
const val = e.target.value
const payload = val === "" ? null : Number(val)
field.onChange(payload)
if (payload) {
// todo: move on blur
onUpdate({ quantity: payload })
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Text className="txt-small text-ui-fg-subtle">
{t("fields.qty")}
</Text>
</div>
<div className="text-ui-fg-subtle txt-small mr-2 flex flex-shrink-0">
<MoneyAmountCell
currencyCode={currencyCode}
amount={previewItem.return_requested_total}
/>
</div>
<ActionMenu
groups={[
{
actions: [
!showReturnReason && {
label: t("actions.addReason"),
onClick: () =>
form.setValue(`items.${index}.reason_id`, ""),
icon: <ChatBubble />,
},
!showNote && {
label: t("actions.addNote"),
onClick: () => form.setValue(`items.${index}.note`, ""),
icon: <DocumentText />,
},
{
label: t("actions.remove"),
onClick: onRemove,
icon: <XCircle />,
},
].filter(Boolean),
},
]}
/>
</div>
</div>
<>
{/*REASON*/}
{showReturnReason && (
<div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">
<div>
<Form.Label>{t("orders.returns.reason")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.reasonHint")}
</Form.Hint>
</div>
<div className="flex items-center gap-1">
<div className="flex-grow">
<Form.Field
control={form.control}
name={`items.${index}.reason_id`}
render={({ field: { ref, value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
value={value}
onChange={(v) => {
onUpdate({ reason_id: v })
onChange(v)
}}
{...field}
options={return_reasons.map((reason) => ({
label: reason.label,
value: reason.id,
}))}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<IconButton
type="button"
className="flex-shrink"
variant="transparent"
onClick={() => {
onUpdate({ reason_id: null }) // TODO BE: we should be able to set to unset reason here
form.setValue(`items.${index}.reason_id`, "")
}}
>
<XMark className="text-ui-fg-muted" />
</IconButton>
</div>
</div>
)}
{/*NOTE*/}
{showNote && (
<div className="grid grid-cols-1 gap-2 p-3 md:grid-cols-2">
<div>
<Form.Label>{t("orders.returns.note")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.noteHint")}
</Form.Hint>
</div>
<div className="flex items-center gap-1">
<div className="flex-grow">
<Form.Field
control={form.control}
name={`items.${index}.note`}
render={({ field: { ref, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
onChange={onChange}
{...field}
onBlur={() =>
onUpdate({ internal_note: field.value })
}
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<IconButton
type="button"
className="flex-shrink"
variant="transparent"
onClick={() => {
form.setValue(`items.${index}.note`, {
shouldDirty: true,
shouldTouch: true,
})
onUpdate({ internal_note: null })
}}
>
<XMark className="text-ui-fg-muted" />
</IconButton>
</div>
</div>
)}
</>
</div>
)
}
export { ReturnItem }

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
export const ReturnCreateSchema = z.object({
items: z.array(
z.object({
item_id: z.string(),
quantity: z.number(),
reason_id: z.string().optional().nullable(),
note: z.string().optional().nullable(),
})
),
location_id: z.string().optional(),
option_id: z.string(),
send_notification: z.boolean().optional(),
// TODO: implement this
receive_now: z.boolean().optional(),
})
export type ReturnCreateSchemaType = z.infer<typeof ReturnCreateSchema>

View File

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

View File

@@ -0,0 +1,66 @@
import { useParams } from "react-router-dom"
import { useEffect, useState } from "react"
import { RouteFocusModal } from "../../../components/modals"
import { ReturnCreateForm } from "./components/return-create-form"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { useInitiateReturn, useReturn } from "../../../hooks/api/returns"
import { DEFAULT_FIELDS } from "../order-detail/constants"
let IS_REQUEST_RUNNING = false
export const ReturnCreate = () => {
const { id } = useParams()
const { order } = useOrder(id!, {
fields: DEFAULT_FIELDS,
})
const { order: preview } = useOrderPreview(id!)
const [activeReturnId, setActiveReturnId] = useState()
const { mutateAsync: initiateReturn } = useInitiateReturn(order.id)
const { return: activeReturn } = useReturn(activeReturnId, undefined, {
enabled: !!activeReturnId,
})
useEffect(() => {
async function run() {
if (IS_REQUEST_RUNNING || !order || !preview) {
return
}
/**
* Active return already exists
*/
if (preview.order_change?.change_type === "return") {
setActiveReturnId(preview.order_change.return.id)
return
}
IS_REQUEST_RUNNING = true
const orderReturn = await initiateReturn({ order_id: order.id })
setActiveReturnId(orderReturn.id)
IS_REQUEST_RUNNING = false
}
run()
}, [order, preview])
return (
<RouteFocusModal>
{activeReturn && preview && order && (
<ReturnCreateForm
order={order}
activeReturn={activeReturn}
preview={preview}
/>
)}
</RouteFocusModal>
)
}

View File

@@ -5,11 +5,12 @@ import { PropsWithChildren, ReactNode, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { XMarkMini } from "@medusajs/icons"
import { AdminFulfillment, AdminOrder } from "@medusajs/types"
import { AdminFulfillment, AdminOrder, AdminReturn } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { useDate } from "../../../../../hooks/use-date"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { useReturns } from "../../../../../hooks/api/returns"
type OrderTimelineProps = {
order: AdminOrder
@@ -80,6 +81,8 @@ type Activity = {
const useActivityItems = (order: AdminOrder) => {
const { t } = useTranslation()
const { returns = [] } = useReturns({ order_id: order.id, fields: "*items" })
const notes = []
const isLoading = false
// const { notes, isLoading, isError, error } = useNotes(
@@ -165,16 +168,13 @@ const useActivityItems = (order: AdminOrder) => {
}
}
/**
* TODO: revisit when API is fixed to fetch returns of an order
*/
// for (const ret of order.returns) {
// items.push({
// title: t("orders.activity.events.return.created"),
// timestamp: ret.created_at,
// })
// }
for (const ret of returns) {
items.push({
title: t("orders.activity.events.return.created"),
timestamp: ret.created_at,
children: <ReturnCreatedBody orderReturn={ret} />,
})
}
// for (const note of notes || []) {
// items.push({
@@ -391,3 +391,21 @@ const FulfillmentCreatedBody = ({
</div>
)
}
const ReturnCreatedBody = ({ orderReturn }: { orderReturn: AdminReturn }) => {
const { t } = useTranslation()
const numberOfItems = orderReturn.items.reduce((acc, item) => {
return acc + item.quantity
}, 0)
return (
<div>
<Text size="small" className="text-ui-fg-subtle">
{t("orders.activity.events.return.items", {
count: numberOfItems,
})}
</Text>
</div>
)
}

View File

@@ -1,8 +1,13 @@
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useMemo } from "react"
import {
AdminOrder,
OrderLineItemDTO,
ReservationItemDTO,
} from "@medusajs/types"
import { ArrowDownRightMini, ArrowUturnLeft } from "@medusajs/icons"
import {
Button,
Container,
@@ -11,9 +16,6 @@ import {
StatusBadge,
Text,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useMemo } from "react"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Thumbnail } from "../../../../../components/common/thumbnail"
@@ -22,6 +24,8 @@ import {
getStylizedAmount,
} from "../../../../../lib/money-amount-helpers"
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date"
type OrderSummarySectionProps = {
order: AdminOrder
@@ -67,6 +71,7 @@ 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} />
@@ -104,11 +109,11 @@ const Header = ({ order }: { order: AdminOrder }) => {
// to: "#", // TODO: Open modal to allocate items
// icon: <Buildings />,
// },
// {
// label: t("orders.summary.requestReturn"),
// to: `/orders/${order.id}/returns`,
// icon: <ArrowUturnLeft />,
// },
{
label: t("orders.returns.create"),
to: `/orders/${order.id}/returns`,
icon: <ArrowUturnLeft />,
},
],
},
]}
@@ -270,6 +275,41 @@ const CostBreakdown = ({ order }: { order: AdminOrder }) => {
)
}
const ReturnBreakdown = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()
const { getRelativeDate } = useDate()
const { returns = [] } = useReturns({
order_id: order.id,
status: "requested",
fields: "*items",
})
if (!returns.length) {
return null
}
return returns.map((activeReturn) => (
<div
key={activeReturn.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,
})}
</Text>
</div>
<Text size="small" leading="compact" className="text-ui-fg-muted">
{getRelativeDate(activeReturn.created_at)}
</Text>
</div>
))
}
const Total = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()

View File

@@ -11,12 +11,16 @@ const DEFAULT_PROPERTIES = [
"subtotal",
"discounts_total",
"shipping_total",
"shipping_tax_total",
"tax_total",
"refundable_total",
]
const DEFAULT_RELATIONS = [
"*customer",
"*items", // -> we get LineItem here with added `quantity` and `detail` which is actually an OrderItem (which is a parent object to LineItem in the DB)
"*items.variant",
"*items.variant.product",
"*items.variant.options",
"+items.variant.manage_inventory",
"*shipping_address",

View File

@@ -25,6 +25,7 @@ import { TaxRate } from "./tax-rate"
import { TaxRegion } from "./tax-region"
import { Upload } from "./upload"
import { User } from "./user"
import { ReturnReason } from "./return-reason"
export class Admin {
public invite: Invite
@@ -37,6 +38,7 @@ export class Admin {
public productType: ProductType
public upload: Upload
public region: Region
public returnReason: ReturnReason
public stockLocation: StockLocation
public salesChannel: SalesChannel
public fulfillmentSet: FulfillmentSet
@@ -47,12 +49,12 @@ export class Admin {
public inventoryItem: InventoryItem
public notification: Notification
public order: Order
public return: Return
public taxRate: TaxRate
public taxRegion: TaxRegion
public store: Store
public productTag: ProductTag
public user: User
public return: Return
constructor(client: Client) {
this.invite = new Invite(client)
@@ -65,6 +67,7 @@ export class Admin {
this.productType = new ProductType(client)
this.upload = new Upload(client)
this.region = new Region(client)
this.returnReason = new ReturnReason(client)
this.stockLocation = new StockLocation(client)
this.salesChannel = new SalesChannel(client)
this.fulfillmentSet = new FulfillmentSet(client)
@@ -75,11 +78,11 @@ export class Admin {
this.inventoryItem = new InventoryItem(client)
this.notification = new Notification(client)
this.order = new Order(client)
this.return = new Return(client)
this.taxRate = new TaxRate(client)
this.taxRegion = new TaxRegion(client)
this.store = new Store(client)
this.productTag = new ProductTag(client)
this.user = new User(client)
this.return = new Return(client)
}
}

View File

@@ -1,5 +1,4 @@
import {
AdminCreateOrderShipment,
FindParams,
HttpTypes,
PaginatedResponse,
@@ -24,6 +23,15 @@ export class Order {
)
}
async retrievePreview(id: string, headers?: ClientHeaders) {
return await this.client.fetch<{ order: HttpTypes.AdminOrder }>(
`/admin/orders/${id}/preview`,
{
headers,
}
)
}
async list(
queryParams?: FindParams & HttpTypes.AdminOrderFilters,
headers?: ClientHeaders

View File

@@ -0,0 +1,24 @@
import { HttpTypes } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class ReturnReason {
private client: Client
constructor(client: Client) {
this.client = client
}
async list(
queryParams?: HttpTypes.AdminReturnReasonListParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnReasonsResponse>(
"/admin/return-reasons",
{
headers,
query: queryParams,
}
)
}
}

View File

@@ -1,4 +1,5 @@
import { HttpTypes } from "@medusajs/types"
import { FindParams, HttpTypes, SelectParams } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
@@ -8,6 +9,26 @@ export class Return {
this.client = client
}
async list(query?: HttpTypes.AdminReturnFilters, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminReturnsResponse>(
`/admin/returns`,
{
query,
headers,
}
)
}
async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/returns/${id}`,
{
query,
headers,
}
)
}
async initiateRequest(
body: HttpTypes.AdminInitiateReturnRequest,
query?: HttpTypes.SelectParams,
@@ -24,6 +45,21 @@ export class Return {
)
}
async cancelRequest(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/returns/${id}/request`,
{
method: "DELETE",
headers,
query,
}
)
}
async addReturnItem(
id: string,
body: HttpTypes.AdminAddReturnItems,
@@ -41,6 +77,40 @@ export class Return {
)
}
async updateReturnItem(
id: string,
actionId: string,
body: HttpTypes.AdminUpdateReturnItems,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/returns/${id}/request-items/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async removeReturnItem(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/returns/${id}/request-items/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addReturnShipping(
id: string,
body: HttpTypes.AdminAddReturnShipping,
@@ -58,6 +128,40 @@ export class Return {
)
}
async updateReturnShipping(
id: string,
actionId: string,
body: HttpTypes.AdminAddReturnShipping,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/returns/${id}/shipping-method/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async deleteReturnShipping(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/returns/${id}/shipping-method/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async confirmRequest(
id: string,
body: HttpTypes.AdminConfirmReturnRequest,

View File

@@ -23,6 +23,8 @@ export * from "./product-tag"
export * from "./product-type"
export * from "./promotion"
export * from "./region"
export * from "./return"
export * from "./return-reason"
export * from "./reservation"
export * from "./return"
export * from "./sales-channel"

View File

@@ -12,6 +12,14 @@ export interface AdminOrderFilters extends BaseOrderFilters {}
export interface AdminOrderAddress extends BaseOrderAddress {}
export interface AdminOrderShippingMethod extends BaseOrderShippingMethod {}
export interface AdminOrderResponse {
order: AdminOrder
}
export interface AdminOrdersResponse {
orders: AdminOrder[]
}
export interface AdminCreateOrderFulfillment {
items: { id: string; quantity: number }[]
location_id?: string

View File

@@ -0,0 +1,28 @@
import { BaseReturnReason } from "./common"
import { FindParams } from "../common"
import { BaseFilterable, OperatorMap } from "../../dal"
export interface AdminReturnReason extends BaseReturnReason {}
export interface AdminCreateReturnReason {
// TODO:
value: string
label: string
description?: string
}
export interface AdminReturnReasonsResponse {
return_reasons: AdminReturnReason[]
}
export interface AdminReturnReasonListParams
extends FindParams,
BaseFilterable<AdminReturnReasonListParams> {
id?: string[] | string | OperatorMap<string | string[]>
value?: string | OperatorMap<string>
label?: string | OperatorMap<string>
description?: string | OperatorMap<string>
parent_return_reason_id?: string | OperatorMap<string | string[]>
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
}

View File

@@ -0,0 +1,9 @@
export interface BaseReturnReason {
id: string
value: string
label: string
description?: string | null
metadata?: Record<string, any> | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1 @@
export * from "./admin"

View File

@@ -1,4 +1,7 @@
export interface BaseReturnItem {
import { OperatorMap } from "../../dal"
import { FindParams } from "../common"
export interface AdminBaseReturnItem {
id: string
quantity: number
received_quantity: number
@@ -9,7 +12,7 @@ export interface BaseReturnItem {
metadata?: Record<string, unknown>
}
export interface AdminReturnResponse {
export interface AdminReturn {
id: string
order_id: string
status?: string
@@ -19,7 +22,15 @@ export interface AdminReturnResponse {
display_id: number
no_notification?: boolean
refund_amount?: number
items: BaseReturnItem[]
items: AdminBaseReturnItem[]
}
export interface AdminReturnResponse {
return: AdminReturn
}
export interface AdminReturnsResponse {
returns: AdminReturn[]
}
export interface AdminInitiateReturnRequest {
@@ -42,6 +53,13 @@ export interface AdminAddReturnItem {
export interface AdminAddReturnItems {
items: AdminAddReturnItem[]
}
export interface AdminUpdateReturnItems {
quantity?: number
internal_note?: string
reason_id?: string
}
export interface AdminAddReturnShipping {
shipping_option_id: string
custom_price?: number
@@ -49,6 +67,25 @@ export interface AdminAddReturnShipping {
internal_note?: string
metadata?: Record<string, unknown>
}
export interface AdminUpdateReturnShipping {
custom_price?: number
internal_note?: string
metadata?: Record<string, unknown>
}
export interface AdminConfirmReturnRequest {
no_notification?: boolean
}
export interface AdminReturnFilters extends FindParams {
id?: string[] | string | OperatorMap<string | string[]>
order_id?: string[] | string | OperatorMap<string | string[]>
status?:
| string[]
| string
| Record<string, unknown>
| OperatorMap<Record<string, unknown>>
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
}

View File

@@ -450,6 +450,7 @@ export interface UpdateReturnDTO {
internal_note?: string | null
note?: string | null
reason_id?: string | null
return_id?: string | null
metadata?: Record<string, unknown> | null
}[]
}

View File

@@ -7,6 +7,7 @@ import {
AdminGetOrdersParams,
AdminPostReceiveReturnItemsReqSchema,
AdminPostReceiveReturnsReqSchema,
AdminPostCancelReturnReqSchema,
AdminPostReturnsConfirmRequestReqSchema,
AdminPostReturnsReqSchema,
AdminPostReturnsRequestItemsActionReqSchema,
@@ -122,6 +123,17 @@ export const adminReturnRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/returns/:id/cancel",
middlewares: [
validateAndTransformBody(AdminPostCancelReturnReqSchema),
validateAndTransformQuery(
AdminGetOrdersOrderParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["DELETE"],
matcher: "/admin/returns/:id/request",

View File

@@ -94,10 +94,10 @@ export type AdminPostReceiveReturnItemsReqSchemaType = z.infer<
>
export const AdminPostCancelReturnReqSchema = z.object({
return_id: z.string(),
no_notification: z.boolean().optional(),
internal_note: z.string().nullish(),
})
export type AdminPostCancelReturnReqSchemaType = z.infer<
typeof AdminPostCancelReturnReqSchema
>

View File

@@ -67,6 +67,7 @@ export const config: MiddlewaresConfig = {
...storeOrderRoutesMiddlewares,
...authRoutesMiddlewares,
...adminWorkflowsExecutionsMiddlewares,
...adminReturnRoutesMiddlewares,
...storeRegionRoutesMiddlewares,
...adminRegionRoutesMiddlewares,
...adminReturnRoutesMiddlewares,
@@ -97,7 +98,6 @@ export const config: MiddlewaresConfig = {
...adminOrderRoutesMiddlewares,
...adminReservationRoutesMiddlewares,
...adminProductCategoryRoutesMiddlewares,
...adminReservationRoutesMiddlewares,
...adminShippingProfilesMiddlewares,
...adminFulfillmentsRoutesMiddlewares,
...adminFulfillmentProvidersRoutesMiddlewares,

View File

@@ -129,15 +129,17 @@ export class Migration20240604100512 extends Migration {
exchange_id
)
WHERE exchange_id IS NOT NULL AND deleted_at IS NULL;
CREATE TYPE return_status_enum AS ENUM (
'requested',
'received',
'partially_received',
'canceled'
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'return_status_enum') THEN
CREATE TYPE return_status_enum AS ENUM (
'requested',
'received',
'partially_received',
'canceled');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS "return" (
"id" TEXT NOT NULL,
@@ -265,12 +267,15 @@ export class Migration20240604100512 extends Migration {
CREATE INDEX IF NOT EXISTS "IDX_order_exchange_item_item_id" ON "order_exchange_item" ("item_id")
WHERE deleted_at IS NULL;
CREATE TYPE order_claim_type_enum AS ENUM (
'refund',
'replace'
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'order_claim_type_enum') THEN
CREATE TYPE order_claim_type_enum AS ENUM (
'refund',
'replace'
);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS "order_claim" (
"id" TEXT NOT NULL,
@@ -302,14 +307,17 @@ export class Migration20240604100512 extends Migration {
CREATE INDEX IF NOT EXISTS "IDX_order_claim_return_id" ON "order_claim" ("return_id")
WHERE return_id IS NOT NULL AND deleted_at IS NULL;
CREATE TYPE claim_reason_enum AS ENUM (
'missing_item',
'wrong_item',
'production_failure',
'other'
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'claim_reason_enum') THEN
CREATE TYPE claim_reason_enum AS ENUM (
'missing_item',
'wrong_item',
'production_failure',
'other'
);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS "order_claim_item" (
"id" TEXT NOT NULL,
@@ -337,7 +345,6 @@ export class Migration20240604100512 extends Migration {
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS "order_claim_item_image" (
"id" TEXT NOT NULL,
"claim_item_id" TEXT NOT NULL,