feat(dashboard,core-flows,js-sdk,types,medusa): Add exchange UI + fixes (#8606)

what:

- adds exchange UI
- fixes bugs on core-flows and endpoints
- random set of tiny fixes


https://github.com/user-attachments/assets/b163b9c1-4475-4936-ae98-20795760cc55
This commit is contained in:
Riqwan Thamir
2024-08-15 19:54:51 +02:00
committed by GitHub
parent ba34c53151
commit 82c147b91e
36 changed files with 3666 additions and 67 deletions

View File

@@ -11,6 +11,7 @@ import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { ordersQueryKeys } from "./orders"
import { returnsQueryKeys } from "./returns"
const CLAIMS_QUERY_KEY = "claims" as const
export const claimsQueryKeys = queryKeysFactory(CLAIMS_QUERY_KEY)
@@ -499,6 +500,10 @@ export const useClaimConfirmRequest = (
mutationFn: (payload: HttpTypes.AdminRequestClaim) =>
sdk.admin.claim.request(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.all,
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
@@ -509,6 +514,11 @@ export const useClaimConfirmRequest = (
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: claimsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,

View File

@@ -0,0 +1,559 @@
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { ordersQueryKeys } from "./orders"
import { returnsQueryKeys } from "./returns"
const EXCHANGES_QUERY_KEY = "exchanges" as const
export const exchangesQueryKeys = queryKeysFactory(EXCHANGES_QUERY_KEY)
export const useExchange = (
id: string,
query?: HttpTypes.AdminExchangeListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminExchangeResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.exchange.retrieve(id, query),
queryKey: exchangesQueryKeys.detail(id, query),
...options,
})
return { ...data, ...rest }
}
export const useExchanges = (
query?: HttpTypes.AdminExchangeListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminExchangeListParams,
Error,
HttpTypes.AdminExchangeListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.exchange.list(query),
queryKey: exchangesQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useCreateExchange = (
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminCreateExchange
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminCreateExchange) =>
sdk.admin.exchange.create(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: exchangesQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelExchange = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeResponse, Error>
) => {
return useMutation({
mutationFn: () => sdk.admin.exchange.cancel(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: exchangesQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: exchangesQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteExchange = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeDeleteResponse, Error>
) => {
return useMutation({
mutationFn: () => sdk.admin.exchange.delete(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: exchangesQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: exchangesQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddExchangeItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminAddExchangeItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddExchangeItems) =>
sdk.admin.exchange.addItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateExchangeItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminUpdateExchangeItem & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateExchangeItem & { actionId: string }) => {
return sdk.admin.exchange.updateItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRemoveExchangeItem = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminReturnResponse, Error, string>
) => {
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 useAddExchangeInboundItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminAddExchangeInboundItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddExchangeInboundItems) =>
sdk.admin.exchange.addInboundItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateExchangeInboundItem = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminUpdateExchangeInboundItem & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateExchangeInboundItem & { actionId: string }) => {
return sdk.admin.exchange.updateInboundItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRemoveExchangeInboundItem = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.exchange.removeInboundItem(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.all,
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddExchangeInboundShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminExchangeAddInboundShipping
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminExchangeAddInboundShipping) =>
sdk.admin.exchange.addInboundShipping(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateExchangeInboundShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminExchangeUpdateInboundShipping
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminExchangeUpdateInboundShipping & { actionId: string }) =>
sdk.admin.exchange.updateInboundShipping(id, actionId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteExchangeInboundShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.exchange.deleteInboundShipping(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddExchangeOutboundItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminAddExchangeOutboundItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddExchangeOutboundItems) =>
sdk.admin.exchange.addOutboundItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateExchangeOutboundItems = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminUpdateExchangeOutboundItem & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateExchangeOutboundItem & { actionId: string }) => {
return sdk.admin.exchange.updateOutboundItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRemoveExchangeOutboundItem = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.exchange.removeOutboundItem(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddExchangeOutboundShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminExchangeAddOutboundShipping
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminExchangeAddOutboundShipping) =>
sdk.admin.exchange.addOutboundShipping(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateExchangeOutboundShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminExchangeUpdateOutboundShipping
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminExchangeUpdateOutboundShipping & { actionId: string }) =>
sdk.admin.exchange.updateOutboundShipping(id, actionId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteExchangeOutboundShipping = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeResponse, Error, string>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.exchange.deleteOutboundShipping(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useExchangeConfirmRequest = (
id: string,
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminExchangeResponse,
Error,
HttpTypes.AdminRequestExchange
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminRequestExchange) =>
sdk.admin.exchange.request(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: returnsQueryKeys.all,
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: exchangesQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelExchangeRequest = (
id: string,
orderId: string,
options?: UseMutationOptions<HttpTypes.AdminExchangeResponse, Error>
) => {
return useMutation({
mutationFn: () => sdk.admin.exchange.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: exchangesQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: exchangesQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -852,7 +852,7 @@
"returnTotal": "Return total",
"inboundTotal": "Inbound total",
"refundAmount": "Refund amount",
"outstandingAmount": "Difference amount",
"outstandingAmount": "Outstanding amount",
"reason": "Reason",
"reasonHint": "Choose why the customer want to return items.",
"note": "Note",
@@ -904,6 +904,25 @@
"onlyReturnShippingOptions": "This list will consist of only return shipping options."
}
},
"exchanges": {
"create": "Create Exchange",
"manage": "Manage Exchange",
"outbound": "Outbound",
"outboundItemAdded": "{{itemsCount}}x added through exchange",
"outboundTotal": "Outbound total",
"outboundShipping": "Outbound shipping",
"outboundShippingHint": "Choose which method you want to use.",
"refundAmount": "Estimated difference",
"activeChangeError": "There is an active order change on this order. Please finish or discard the previous change.",
"actions": {
"cancelExchange": {
"successToast": "Exchange was successfully canceled."
}
},
"tooltips": {
"onlyReturnShippingOptions": "This list will consist of only return shipping options."
}
},
"reservations": {
"allocatedLabel": "Allocated",
"notAllocatedLabel": "Not allocated"

View File

@@ -248,6 +248,11 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/orders/order-create-claim"),
},
{
path: "exchanges",
lazy: () =>
import("../../routes/orders/order-create-exchange"),
},
{
path: "payments/:paymentId/refund",
lazy: () =>

View File

@@ -0,0 +1,186 @@
import { AdminOrderLineItem, DateComparisonOperator } from "@medusajs/types"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { getReturnableQuantity } from "../../../../../lib/rma"
import { useExchangeItemTableColumns } from "./use-exchange-item-table-columns"
import { useExchangeItemTableFilters } from "./use-exchange-item-table-filters"
import { useExchangeItemTableQuery } from "./use-exchange-item-table-query"
const PAGE_SIZE = 50
const PREFIX = "rit"
type AddExchangeInboundItemsTableProps = {
onSelectionChange: (ids: string[]) => void
selectedItems: string[]
items: AdminOrderLineItem[]
currencyCode: string
}
export const AddExchangeInboundItemsTable = ({
onSelectionChange,
selectedItems,
items,
currencyCode,
}: AddExchangeInboundItemsTableProps) => {
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 } = useExchangeItemTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const queriedItems = useMemo(() => {
const { order, offset, limit, q, created_at, updated_at } = 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")
}
return results.slice(offset, offset + limit)
}, [items, currencyCode, searchParams])
const columns = useExchangeItemTableColumns(currencyCode)
const filters = useExchangeItemTableFilters()
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"]}
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
}
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,
}

View File

@@ -0,0 +1 @@
export * from "./add-exchange-inbound-items-table"

View File

@@ -0,0 +1,98 @@
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"
import { getReturnableQuantity } from "../../../../../lib/rma"
const columnHelper = createColumnHelper<any>()
export const useExchangeItemTableColumns = (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,22 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useExchangeItemTableFilters = () => {
const { t } = useTranslation()
const filters: Filter[] = [
{
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,26 @@
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useExchangeItemTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
["q", "offset", "order", "created_at", "updated_at"],
prefix
)
const { offset, created_at, updated_at, ...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,
}
return { searchParams, raw }
}

View File

@@ -0,0 +1,87 @@
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useState } from "react"
import { DataTable } from "../../../../../components/table/data-table"
import { useVariants } from "../../../../../hooks/api"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useExchangeOutboundItemTableColumns } from "./use-exchange-outbound-item-table-columns"
import { useExchangeOutboundItemTableFilters } from "./use-exchange-outbound-item-table-filters"
import { useExchangeOutboundItemTableQuery } from "./use-exchange-outbound-item-table-query"
const PAGE_SIZE = 50
const PREFIX = "rit"
type AddExchangeOutboundItemsTableProps = {
onSelectionChange: (ids: string[]) => void
selectedItems: string[]
currencyCode: string
}
export const AddExchangeOutboundItemsTable = ({
onSelectionChange,
selectedItems,
currencyCode,
}: AddExchangeOutboundItemsTableProps) => {
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 } = useExchangeOutboundItemTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { variants = [], count } = useVariants({
...searchParams,
fields: "*inventory_items.inventory.location_levels,+inventory_quantity",
})
const columns = useExchangeOutboundItemTableColumns(currencyCode)
const filters = useExchangeOutboundItemTableFilters()
const { table } = useDataTable({
data: variants,
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: (_row) => {
// TODO: Check inventory here. Check if other validations needs to be made
return true
},
rowSelection: {
state: rowSelection,
updater,
},
})
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
filters={filters}
pagination
layout="fill"
search
orderBy={["product_id", "title", "sku"]}
prefix={PREFIX}
queryObject={raw}
/>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./add-exchange-outbound-items-table"

View File

@@ -0,0 +1,68 @@
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"
const columnHelper = createColumnHelper<any>()
export const useExchangeOutboundItemTableColumns = (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 }) => {
return <ProductCell product={row.original.product} />
},
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ getValue }) => {
return getValue() || "-"
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
}),
],
[t, currencyCode]
)
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useExchangeOutboundItemTableFilters = () => {
const { t } = useTranslation()
const filters: Filter[] = [
{
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,26 @@
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useExchangeOutboundItemTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
["q", "offset", "order", "created_at", "updated_at"],
prefix
)
const { offset, created_at, updated_at, ...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,
}
return { searchParams, raw }
}

View File

@@ -0,0 +1,423 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilSquare } from "@medusajs/icons"
import { AdminExchange, AdminOrder, AdminOrderPreview } from "@medusajs/types"
import {
Button,
CurrencyInput,
Heading,
IconButton,
Switch,
toast,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { Form } from "../../../../../components/common/form"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { CreateExchangeSchemaType, ExchangeCreateSchema } from "./schema"
import { AdminReturn } from "@medusajs/types"
import {
useCancelExchangeRequest,
useExchangeConfirmRequest,
useUpdateExchangeInboundShipping,
} from "../../../../../hooks/api/exchanges"
import { currencies } from "../../../../../lib/data/currencies"
import { ExchangeInboundSection } from "./exchange-inbound-section.tsx"
import { ExchangeOutboundSection } from "./exchange-outbound-section"
type ReturnCreateFormProps = {
order: AdminOrder
exchange: AdminExchange
preview: AdminOrderPreview
orderReturn?: AdminReturn
}
let IS_CANCELING = false
export const ExchangeCreateForm = ({
order,
preview,
exchange,
orderReturn,
}: ReturnCreateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
/**
* STATE
*/
const [isShippingPriceEdit, setIsShippingPriceEdit] = useState(false)
const [customShippingAmount, setCustomShippingAmount] = useState(0)
/**
* MUTATIONS
*/
const { mutateAsync: confirmExchangeRequest, isPending: isConfirming } =
useExchangeConfirmRequest(exchange.id, order.id)
const { mutateAsync: cancelExchangeRequest, isPending: isCanceling } =
useCancelExchangeRequest(exchange.id, order.id)
const {
mutateAsync: updateInboundShipping,
isPending: isUpdatingInboundShipping,
} = useUpdateExchangeInboundShipping(exchange.id, order.id)
const isRequestLoading =
isConfirming || isCanceling || isUpdatingInboundShipping
/**
* Only consider items that belong to this exchange.
*/
const previewItems = useMemo(
() =>
preview?.items?.filter(
(i) => !!i.actions?.find((a) => a.exchange_id === exchange.id)
),
[preview.items]
)
const inboundPreviewItems = previewItems.filter(
(item) => !!item.actions?.find((a) => a.action === "RETURN_ITEM")
)
const outboundPreviewItems = previewItems.filter(
(item) => !!item.actions?.find((a) => a.action === "ITEM_ADD")
)
/**
* FORM
*/
const form = useForm<CreateExchangeSchemaType>({
defaultValues: () => {
const inboundShippingMethod = preview.shipping_methods.find((s) => {
const action = s.actions?.find((a) => a.action === "SHIPPING_ADD")
return !!action?.return?.id
})
const outboundShippingMethod = preview.shipping_methods.find((s) => {
const action = s.actions?.find((a) => a.action === "SHIPPING_ADD")
return action && !!!action?.return?.id
})
return Promise.resolve({
inbound_items: inboundPreviewItems.map((i) => {
const inboundAction = i.actions?.find(
(a) => a.action === "RETURN_ITEM"
)
return {
item_id: i.id,
variant_id: i.variant_id,
quantity: i.detail.return_requested_quantity,
note: inboundAction?.internal_note,
reason_id: inboundAction?.details?.reason_id as string | undefined,
}
}),
outbound_items: outboundPreviewItems.map((i) => ({
item_id: i.id,
variant_id: i.variant_id,
quantity: i.detail.quantity,
})),
inbound_option_id: inboundShippingMethod
? inboundShippingMethod.shipping_option_id
: "",
outbound_option_id: outboundShippingMethod
? outboundShippingMethod.shipping_option_id
: "",
location_id: orderReturn?.location_id,
send_notification: false,
})
},
resolver: zodResolver(ExchangeCreateSchema),
})
const outboundShipping = preview.shipping_methods.find((s) => {
const action = s.actions?.find((a) => a.action === "SHIPPING_ADD")
return action && !!!action?.return?.id
})
const shippingOptionId = form.watch("inbound_option_id")
const handleSubmit = form.handleSubmit(async (data) => {
try {
await confirmExchangeRequest({ no_notification: !data.send_notification })
handleSuccess()
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
})
}
})
useEffect(() => {
if (isShippingPriceEdit) {
document.getElementById("js-shipping-input")?.focus()
}
}, [isShippingPriceEdit])
useEffect(() => {
/**
* Unmount hook
*/
return () => {
if (IS_CANCELING) {
cancelExchangeRequest(undefined, {
onSuccess: () => {
toast.success(
t("orders.exchanges.actions.cancelExchange.successToast")
)
},
onError: (error) => {
toast.error(error.message)
},
})
IS_CANCELING = false
}
}
}, [])
const shippingTotal = useMemo(() => {
const method = preview.shipping_methods.find(
(sm) => !!sm.actions?.find((a) => a.action === "SHIPPING_ADD")
)
return (method?.total as number) || 0
}, [preview.shipping_methods])
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.exchanges.create")}</Heading>
<ExchangeInboundSection
form={form}
preview={preview}
order={order}
exchange={exchange}
orderReturn={orderReturn}
/>
<ExchangeOutboundSection
form={form}
preview={preview}
order={order}
exchange={exchange}
/>
{/*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.inboundTotal")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(
inboundPreviewItems.reduce((acc, item) => {
const action = item.actions?.find(
(act) => act.action === "RETURN_ITEM"
)
acc = acc + (action?.amount || 0)
return acc
}, 0) * -1,
order.currency_code
)}
</span>
</div>
<div className="mb-2 flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.exchanges.outboundTotal")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(
outboundPreviewItems.reduce((acc, item) => {
const action = item.actions?.find(
(act) => act.action === "ITEM_ADD"
)
acc = acc + (action?.amount || 0)
return acc
}, 0),
order.currency_code
)}
</span>
</div>
<div className="mb-2 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={
!inboundPreviewItems?.length || !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) {
updateInboundShipping(
{
actionId,
custom_price:
typeof customShippingAmount === "string"
? null
: customShippingAmount,
},
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
setIsShippingPriceEdit(false)
}}
symbol={
currencies[order.currency_code.toUpperCase()]
.symbol_native
}
code={order.currency_code}
onValueChange={(value) =>
value && setCustomShippingAmount(parseInt(value))
}
value={customShippingAmount}
disabled={!inboundPreviewItems?.length}
/>
) : (
getStylizedAmount(shippingTotal, order.currency_code)
)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.exchanges.outboundShipping")}
</span>
<span className="txt-small text-ui-fg-subtle flex items-center">
{getStylizedAmount(
outboundShipping?.amount ?? 0,
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.exchanges.refundAmount")}
</span>
<span className="txt-small font-medium">
{getStylizedAmount(
preview.summary.pending_difference,
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,244 @@
import { ChatBubble, DocumentText, XCircle, XMark } from "@medusajs/icons"
import { AdminOrderLineItem, HttpTypes } from "@medusajs/types"
import { IconButton, Input, Text } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Form } from "../../../../../components/common/form"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { Combobox } from "../../../../../components/inputs/combobox"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { useReturnReasons } from "../../../../../hooks/api/return-reasons"
type ExchangeInboundItemProps = {
item: AdminOrderLineItem
previewItem: AdminOrderLineItem
currencyCode: string
index: number
onRemove: () => void
onUpdate: (payload: HttpTypes.AdminUpdateReturnItems) => void
form: UseFormReturn<any>
}
function ExchangeInboundItem({
item,
previewItem,
currencyCode,
form,
onRemove,
onUpdate,
index,
}: ExchangeInboundItemProps) {
const { t } = useTranslation()
const { return_reasons = [] } = useReturnReasons({ fields: "+label" })
const formItem = form.watch(`inbound_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={`inbound_items.${index}.quantity`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
{...field}
className="bg-ui-bg-base txt-small w-[67px] rounded-lg"
min={1}
max={item.quantity}
type="number"
onBlur={(e) => {
const val = e.target.value
const payload = val === "" ? null : Number(val)
field.onChange(payload)
if (payload) {
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(`inbound_items.${index}.reason_id`, ""),
icon: <ChatBubble />,
},
!showNote && {
label: t("actions.addNote"),
onClick: () =>
form.setValue(`inbound_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={`inbound_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={() => {
form.setValue(`inbound_items.${index}.reason_id`, null)
onUpdate({ reason_id: null })
}}
>
<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={`inbound_items.${index}.note`}
render={({ field: { ref, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
{...field}
onBlur={() => {
field.onChange(field.value)
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(`inbound_items.${index}.note`, null)
onUpdate({ internal_note: null })
}}
>
<XMark className="text-ui-fg-muted" />
</IconButton>
</div>
</div>
)}
</>
</div>
)
}
export { ExchangeInboundItem }

View File

@@ -0,0 +1,527 @@
import {
AdminExchange,
AdminOrder,
AdminOrderPreview,
AdminReturn,
InventoryLevelDTO,
} from "@medusajs/types"
import { Alert, Button, Heading, Text, toast } from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { useFieldArray, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import {
RouteFocusModal,
StackedFocusModal,
useStackedModal,
} from "../../../../../components/modals"
import { useShippingOptions, useStockLocations } from "../../../../../hooks/api"
import {
useAddExchangeInboundItems,
useAddExchangeInboundShipping,
useDeleteExchangeInboundShipping,
useRemoveExchangeInboundItem,
useUpdateExchangeInboundItem,
} from "../../../../../hooks/api/exchanges"
import { useUpdateReturn } from "../../../../../hooks/api/returns"
import { sdk } from "../../../../../lib/client"
import { ItemPlaceholder } from "../../../order-create-claim/components/claim-create-form/item-placeholder"
import { AddExchangeInboundItemsTable } from "../add-exchange-inbound-items-table"
import { ExchangeInboundItem } from "./exchange-inbound-item"
import { CreateExchangeSchemaType } from "./schema"
type ExchangeInboundSectionProps = {
order: AdminOrder
orderReturn?: AdminReturn
exchange: AdminExchange
preview: AdminOrderPreview
form: UseFormReturn<CreateExchangeSchemaType>
}
let itemsToAdd: string[] = []
let itemsToRemove: string[] = []
export const ExchangeInboundSection = ({
order,
preview,
exchange,
form,
orderReturn,
}: ExchangeInboundSectionProps) => {
const { t } = useTranslation()
/**
* STATE
*/
const { setIsOpen } = useStackedModal()
const [inventoryMap, setInventoryMap] = useState<
Record<string, InventoryLevelDTO[]>
>({})
/**
* MUTATIONS
*/
const { mutateAsync: updateReturn } = useUpdateReturn(
preview?.order_change?.return_id!,
order.id
)
const { mutateAsync: addInboundShipping } = useAddExchangeInboundShipping(
exchange.id,
order.id
)
const { mutateAsync: deleteInboundShipping } =
useDeleteExchangeInboundShipping(exchange.id, order.id)
const { mutateAsync: addInboundItem } = useAddExchangeInboundItems(
exchange.id,
order.id
)
const { mutateAsync: updateInboundItem } = useUpdateExchangeInboundItem(
exchange.id,
order.id
)
const { mutateAsync: removeInboundItem } = useRemoveExchangeInboundItem(
exchange.id,
order.id
)
/**
* Only consider items that belong to this exchange.
*/
const previewInboundItems = useMemo(
() =>
preview?.items?.filter(
(i) => !!i.actions?.find((a) => a.exchange_id === exchange.id)
),
[preview.items]
)
const inboundPreviewItems = previewInboundItems.filter(
(item) => !!item.actions?.find((a) => a.action === "RETURN_ITEM")
)
const itemsMap = useMemo(
() => new Map(order?.items?.map((i) => [i.id, i])),
[order.items]
)
const locationId = form.watch("location_id")
/**
* HOOKS
*/
const { stock_locations = [] } = useStockLocations({ limit: 999 })
const { shipping_options = [] } = useShippingOptions(
{
limit: 999,
fields: "*prices,+service_zone.fulfillment_set.location.id",
stock_location_id: locationId,
},
{
enabled: !!locationId,
}
)
const inboundShippingOptions = shipping_options.filter(
(shippingOption) =>
!!shippingOption.rules.find(
(r) => r.attribute === "is_return" && r.value === "true"
)
)
const {
fields: inboundItems,
append,
remove,
update,
} = useFieldArray({
name: "inbound_items",
control: form.control,
})
const inboundItemsMap = useMemo(
() => new Map(previewInboundItems.map((i) => [i.id, i])),
[previewInboundItems, inboundItems]
)
useEffect(() => {
const existingItemsMap: Record<string, boolean> = {}
inboundPreviewItems.forEach((i) => {
const ind = inboundItems.findIndex((field) => field.item_id === i.id)
existingItemsMap[i.id] = true
if (ind > -1) {
if (inboundItems[ind].quantity !== i.detail.return_requested_quantity) {
const returnItemAction = i.actions?.find(
(a) => a.action === "RETURN_ITEM"
)
update(ind, {
...inboundItems[ind],
quantity: i.detail.return_requested_quantity,
note: returnItemAction?.internal_note,
reason_id: returnItemAction?.details?.reason_id as string,
})
}
} else {
append({ item_id: i.id, quantity: i.detail.return_requested_quantity })
}
})
inboundItems.forEach((i, ind) => {
if (!(i.item_id in existingItemsMap)) {
remove(ind)
}
})
}, [previewInboundItems])
useEffect(() => {
const inboundShippingMethod = preview.shipping_methods.find((s) => {
const action = s.actions?.find((a) => a.action === "SHIPPING_ADD")
return !!action?.return?.id
})
if (inboundShippingMethod) {
form.setValue(
"inbound_option_id",
inboundShippingMethod.shipping_option_id
)
}
}, [preview.shipping_methods])
useEffect(() => {
form.setValue("location_id", orderReturn?.location_id)
}, [orderReturn])
const showInboundItemsPlaceholder = !inboundItems.length
const onItemsSelected = async () => {
itemsToAdd.length &&
(await addInboundItem(
{
items: itemsToAdd.map((id) => ({
id,
quantity: 1,
})),
},
{
onError: (error) => {
toast.error(error.message)
},
}
))
for (const itemToRemove of itemsToRemove) {
const actionId = previewInboundItems
.find((i) => i.id === itemToRemove)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
await removeInboundItem(actionId, {
onError: (error) => {
toast.error(error.message)
},
})
}
}
setIsOpen("inbound-items", false)
}
const onLocationChange = async (selectedLocationId?: string | null) => {
await updateReturn({ location_id: selectedLocationId })
}
const onShippingOptionChange = async (selectedOptionId: string) => {
const inboundShippingMethods = preview.shipping_methods.filter((s) => {
const action = s.actions?.find((a) => a.action === "SHIPPING_ADD")
return action && !!action?.return?.id
})
const promises = inboundShippingMethods
.filter(Boolean)
.map((inboundShippingMethod) => {
const action = inboundShippingMethod.actions?.find(
(a) => a.action === "SHIPPING_ADD"
)
if (action) {
deleteInboundShipping(action.id)
}
})
await Promise.all(promises)
await addInboundShipping(
{ shipping_option_id: selectedOptionId },
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
const showLevelsWarning = useMemo(() => {
if (!locationId) {
return false
}
const allItemsHaveLocation = inboundItems
.map((_i) => {
const item = itemsMap.get(_i.item_id)
if (!item?.variant_id || !item?.variant) {
return true
}
if (!item.variant.manage_inventory) {
return true
}
return inventoryMap[item.variant_id]?.find(
(l) => l.location_id === locationId
)
})
.every(Boolean)
return !allItemsHaveLocation
}, [inboundItems, inventoryMap, locationId])
useEffect(() => {
const getInventoryMap = async () => {
const ret: Record<string, InventoryLevelDTO[]> = {}
if (!inboundItems.length) {
return ret
}
const variantIds = inboundItems
.map((item) => item?.variant_id)
.filter(Boolean)
const variants = (
await sdk.admin.productVariant.list(
{ id: variantIds },
{ fields: "*inventory,*inventory.location_levels" }
)
).variants
variants.forEach((variant) => {
ret[variant.id] = variant.inventory?.[0]?.location_levels || []
})
return ret
}
getInventoryMap().then((map) => {
setInventoryMap(map)
})
}, [inboundItems])
return (
<div>
<div className="mt-8 flex items-center justify-between">
<Heading level="h2">{t("orders.returns.inbound")}</Heading>
<StackedFocusModal id="inbound-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 />
<AddExchangeInboundItemsTable
items={order.items!}
selectedItems={inboundItems.map((i) => i.item_id)}
currencyCode={order.currency_code}
onSelectionChange={(finalSelection) => {
const alreadySelected = inboundItems.map((i) => i.item_id)
itemsToAdd = finalSelection.filter(
(selection) => !alreadySelected.includes(selection)
)
itemsToRemove = alreadySelected.filter(
(selection) => !finalSelection.includes(selection)
)
}}
/>
<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={async () => await onItemsSelected()}
>
{t("actions.save")}
</Button>
</div>
</div>
</StackedFocusModal.Footer>
</StackedFocusModal.Content>
</StackedFocusModal>
</div>
{showInboundItemsPlaceholder && <ItemPlaceholder />}
{inboundItems.map(
(item, index) =>
inboundItemsMap.get(item.item_id) &&
itemsMap.get(item.item_id)! && (
<ExchangeInboundItem
key={item.id}
item={itemsMap.get(item.item_id)!}
previewItem={inboundItemsMap.get(item.item_id)!}
currencyCode={order.currency_code}
form={form}
onRemove={() => {
const actionId = previewInboundItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
removeInboundItem(actionId, {
onError: (error) => {
toast.error(error.message)
},
})
}
}}
onUpdate={(payload) => {
const actionId = previewInboundItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
updateInboundItem(
{ ...payload, actionId },
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
}}
index={index}
/>
)
)}
{!showInboundItemsPlaceholder && (
<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
{...field}
value={value ?? undefined}
onChange={(v) => {
onChange(v)
onLocationChange(v)
}}
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
tooltip={t(
"orders.exchanges.tooltips.onlyReturnShippingOptions"
)}
>
{t("orders.returns.inboundShipping")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.inboundShippingHint")}
</Form.Hint>
</div>
<Form.Field
control={form.control}
name="inbound_option_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value ?? undefined}
onChange={(val) => {
onChange(val)
val && onShippingOptionChange(val)
}}
{...field}
options={inboundShippingOptions.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>
)}
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { XCircle } from "@medusajs/icons"
import { AdminOrderLineItem, HttpTypes } from "@medusajs/types"
import { Input, Text } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Form } from "../../../../../components/common/form"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { CreateExchangeSchemaType } from "./schema"
type ExchangeOutboundItemProps = {
previewItem: AdminOrderLineItem
currencyCode: string
index: number
onRemove: () => void
// TODO: create a payload type for outbound updates
onUpdate: (payload: HttpTypes.AdminUpdateReturnItems) => void
form: UseFormReturn<CreateExchangeSchemaType>
}
function ExchangeOutboundItem({
previewItem,
currencyCode,
form,
onRemove,
onUpdate,
index,
}: ExchangeOutboundItemProps) {
const { t } = useTranslation()
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={previewItem.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{previewItem.title}{" "}
</Text>
{previewItem.variant_sku && (
<span>({previewItem.variant_sku})</span>
)}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{previewItem.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={`outbound_items.${index}.quantity`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
{...field}
className="bg-ui-bg-base txt-small w-[67px] rounded-lg"
min={1}
// TODO: add max available inventory quantity if present
// max={previewItem.quantity}
type="number"
onBlur={(e) => {
const val = e.target.value
const payload = val === "" ? null : Number(val)
field.onChange(payload)
if (payload) {
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.total}
/>
</div>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.remove"),
onClick: onRemove,
icon: <XCircle />,
},
].filter(Boolean),
},
]}
/>
</div>
</div>
</div>
)
}
export { ExchangeOutboundItem }

View File

@@ -0,0 +1,423 @@
import {
AdminExchange,
AdminOrder,
AdminOrderPreview,
InventoryLevelDTO,
} from "@medusajs/types"
import { Alert, Button, Heading, Text, toast } from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { useFieldArray, UseFormReturn } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import {
RouteFocusModal,
StackedFocusModal,
useStackedModal,
} from "../../../../../components/modals"
import {
useAddExchangeOutboundItems,
useAddExchangeOutboundShipping,
useDeleteExchangeOutboundShipping,
useRemoveExchangeOutboundItem,
useUpdateExchangeOutboundItems,
} from "../../../../../hooks/api/exchanges"
import { useShippingOptions } from "../../../../../hooks/api/shipping-options"
import { sdk } from "../../../../../lib/client"
import { ItemPlaceholder } from "../../../order-create-claim/components/claim-create-form/item-placeholder"
import { AddExchangeOutboundItemsTable } from "../add-exchange-outbound-items-table"
import { ExchangeOutboundItem } from "./exchange-outbound-item"
import { CreateExchangeSchemaType } from "./schema"
type ExchangeOutboundSectionProps = {
order: AdminOrder
exchange: AdminExchange
preview: AdminOrderPreview
form: UseFormReturn<CreateExchangeSchemaType>
}
let itemsToAdd: string[] = []
let itemsToRemove: string[] = []
export const ExchangeOutboundSection = ({
order,
preview,
exchange,
form,
}: ExchangeOutboundSectionProps) => {
const { t } = useTranslation()
const { setIsOpen } = useStackedModal()
const [inventoryMap, setInventoryMap] = useState<
Record<string, InventoryLevelDTO[]>
>({})
/**
* HOOKS
*/
const { shipping_options = [] } = useShippingOptions({
limit: 999,
fields: "*prices,+service_zone.fulfillment_set.location.id",
})
const { mutateAsync: addOutboundShipping } = useAddExchangeOutboundShipping(
exchange.id,
order.id
)
const { mutateAsync: deleteOutboundShipping } =
useDeleteExchangeOutboundShipping(exchange.id, order.id)
const { mutateAsync: addOutboundItem } = useAddExchangeOutboundItems(
exchange.id,
order.id
)
const { mutateAsync: updateOutboundItem } = useUpdateExchangeOutboundItems(
exchange.id,
order.id
)
const { mutateAsync: removeOutboundItem } = useRemoveExchangeOutboundItem(
exchange.id,
order.id
)
/**
* Only consider items that belong to this exchange and is an outbound item
*/
const previewOutboundItems = useMemo(
() =>
preview?.items?.filter(
(i) =>
!!i.actions?.find(
(a) => a.exchange_id === exchange.id && a.action === "ITEM_ADD"
)
),
[preview.items]
)
const variantItemMap = useMemo(
() => new Map(order?.items?.map((i) => [i.variant_id, i])),
[order.items]
)
const {
fields: outboundItems,
append,
remove,
update,
} = useFieldArray({
name: "outbound_items",
control: form.control,
})
const variantOutboundMap = useMemo(
() => new Map(previewOutboundItems.map((i) => [i.variant_id, i])),
[previewOutboundItems, outboundItems]
)
useEffect(() => {
const existingItemsMap: Record<string, boolean> = {}
previewOutboundItems.forEach((i) => {
const ind = outboundItems.findIndex((field) => field.item_id === i.id)
existingItemsMap[i.id] = true
if (ind > -1) {
if (outboundItems[ind].quantity !== i.detail.quantity) {
update(ind, {
...outboundItems[ind],
quantity: i.detail.quantity,
})
}
} else {
append({
item_id: i.id,
quantity: i.detail.quantity,
variant_id: i.variant_id,
})
}
})
outboundItems.forEach((i, ind) => {
if (!(i.item_id in existingItemsMap)) {
remove(ind)
}
})
}, [previewOutboundItems])
const locationId = form.watch("location_id")
const showOutboundItemsPlaceholder = !outboundItems.length
const onItemsSelected = async () => {
itemsToAdd.length &&
(await addOutboundItem(
{
items: itemsToAdd.map((variantId) => ({
variant_id: variantId,
quantity: 1,
})),
},
{
onError: (error) => {
toast.error(error.message)
},
}
))
for (const itemToRemove of itemsToRemove) {
const action = previewOutboundItems
.find((i) => i.variant_id === itemToRemove)
?.actions?.find((a) => a.action === "ITEM_ADD")
if (action?.id) {
await removeOutboundItem(action?.id, {
onError: (error) => {
toast.error(error.message)
},
})
}
}
setIsOpen("outbound-items", false)
}
const onShippingOptionChange = async (selectedOptionId: string) => {
const outboundShippingMethods = preview.shipping_methods.filter((s) => {
const action = s.actions?.find((a) => a.action === "SHIPPING_ADD")
return action && !!!action?.return?.id
})
const promises = outboundShippingMethods
.filter(Boolean)
.map((outboundShippingMethod) => {
const action = outboundShippingMethod.actions?.find(
(a) => a.action === "SHIPPING_ADD"
)
if (action) {
deleteOutboundShipping(action.id)
}
})
await Promise.all(promises)
await addOutboundShipping(
{ shipping_option_id: selectedOptionId },
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
const showLevelsWarning = useMemo(() => {
if (!locationId) {
return false
}
const allItemsHaveLocation = outboundItems
.map((i) => {
const item = variantItemMap.get(i.variant_id)
if (!item?.variant_id || !item?.variant) {
return true
}
if (!item.variant.manage_inventory) {
return true
}
return inventoryMap[item.variant_id]?.find(
(l) => l.location_id === locationId
)
})
.every(Boolean)
return !allItemsHaveLocation
}, [outboundItems, inventoryMap, locationId])
useEffect(() => {
const getInventoryMap = async () => {
const ret: Record<string, InventoryLevelDTO[]> = {}
if (!outboundItems.length) {
return ret
}
const variantIds = outboundItems
.map((item) => item?.variant_id)
.filter(Boolean)
const variants = (
await sdk.admin.productVariant.list(
{ id: variantIds },
{ fields: "*inventory,*inventory.location_levels" }
)
).variants
variants.forEach((variant) => {
ret[variant.id] = variant.inventory?.[0]?.location_levels || []
})
return ret
}
getInventoryMap().then((map) => {
setInventoryMap(map)
})
}, [outboundItems])
return (
<div>
<div className="mt-8 flex items-center justify-between">
<Heading level="h2">{t("orders.returns.outbound")}</Heading>
<StackedFocusModal id="outbound-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 />
<AddExchangeOutboundItemsTable
selectedItems={outboundItems.map((i) => i.variant_id)}
currencyCode={order.currency_code}
onSelectionChange={(finalSelection) => {
const alreadySelected = outboundItems.map((i) => i.variant_id)
itemsToAdd = finalSelection.filter(
(selection) => !alreadySelected.includes(selection)
)
itemsToRemove = alreadySelected.filter(
(selection) => !finalSelection.includes(selection)
)
}}
/>
<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={async () => await onItemsSelected()}
>
{t("actions.save")}
</Button>
</div>
</div>
</StackedFocusModal.Footer>
</StackedFocusModal.Content>
</StackedFocusModal>
</div>
{showOutboundItemsPlaceholder && <ItemPlaceholder />}
{outboundItems.map(
(item, index) =>
variantOutboundMap.get(item.variant_id) && (
<ExchangeOutboundItem
key={item.id}
previewItem={variantOutboundMap.get(item.variant_id)!}
currencyCode={order.currency_code}
form={form}
onRemove={() => {
const actionId = previewOutboundItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "ITEM_ADD")?.id
if (actionId) {
removeOutboundItem(actionId, {
onError: (error) => {
toast.error(error.message)
},
})
}
}}
onUpdate={(payload) => {
const actionId = previewOutboundItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "ITEM_ADD")?.id
if (actionId) {
updateOutboundItem(
{ ...payload, actionId },
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
}}
index={index}
/>
)
)}
{!showOutboundItemsPlaceholder && (
<div className="mt-8 flex flex-col gap-y-4">
{/*OUTBOUND SHIPPING*/}
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Form.Label>{t("orders.exchanges.outboundShipping")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.exchanges.outboundShippingHint")}
</Form.Hint>
</div>
<Form.Field
control={form.control}
name="outbound_option_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value ?? undefined}
onChange={(val) => {
onChange(val)
val && onShippingOptionChange(val)
}}
{...field}
options={shipping_options.map((so) => ({
label: so.name,
value: so.id,
}))}
disabled={!shipping_options.length}
/>
</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>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./exchange-create-form"

View File

@@ -0,0 +1,23 @@
import { z } from "zod"
export const ExchangeCreateSchema = z.object({
inbound_items: z.array(
z.object({
item_id: z.string(),
quantity: z.number(),
reason_id: z.string().nullish(),
note: z.string().nullish(),
})
),
outbound_items: z.array(
z.object({
item_id: z.string(), // TODO: variant id?
quantity: z.number(),
})
),
location_id: z.string().optional(),
inbound_option_id: z.string().nullish(),
send_notification: z.boolean().optional(),
})
export type CreateExchangeSchemaType = z.infer<typeof ExchangeCreateSchema>

View File

@@ -0,0 +1,84 @@
import { toast } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/modals"
import { useCreateExchange, useExchange } from "../../../hooks/api/exchanges"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { useReturn } from "../../../hooks/api/returns"
import { DEFAULT_FIELDS } from "../order-detail/constants"
import { ExchangeCreateForm } from "./components/exchange-create-form"
let IS_REQUEST_RUNNING = false
export const ExchangeCreate = () => {
const { id } = useParams()
const navigate = useNavigate()
const { t } = useTranslation()
const { order } = useOrder(id!, {
fields: DEFAULT_FIELDS,
})
const { order: preview } = useOrderPreview(id!)
const [activeExchangeId, setActiveExchangeId] = useState<string>()
const { mutateAsync: createExchange } = useCreateExchange(order.id)
const { exchange } = useExchange(activeExchangeId!, undefined, {
enabled: !!activeExchangeId,
})
const { return: orderReturn } = useReturn(exchange?.return_id!, undefined, {
enabled: !!exchange?.return_id,
})
useEffect(() => {
async function run() {
if (IS_REQUEST_RUNNING || !preview) {
return
}
if (preview.order_change) {
if (preview.order_change.change_type === "exchange") {
setActiveExchangeId(preview.order_change.exchange_id)
} else {
navigate(`/orders/${preview.id}`, { replace: true })
toast.error(t("orders.exchanges.activeChangeError"))
}
return
}
IS_REQUEST_RUNNING = true
try {
const { exchange: createdExchange } = await createExchange({
order_id: preview.id,
})
setActiveExchangeId(createdExchange.id)
} catch (e) {
toast.error(e.message)
navigate(`/orders/${preview.id}`, { replace: true })
} finally {
IS_REQUEST_RUNNING = false
}
}
run()
}, [preview])
return (
<RouteFocusModal>
{exchange && preview && order && (
<ExchangeCreateForm
order={order}
exchange={exchange}
preview={preview}
orderReturn={orderReturn}
/>
)}
</RouteFocusModal>
)
}

View File

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

View File

@@ -42,10 +42,13 @@ export function OrderCreateFulfillmentForm({
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: {
quantity: fulfillableItems.reduce((acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
}, {} as Record<string, number>),
quantity: fulfillableItems.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
},
{} as Record<string, number>
),
send_notification: !order.no_notification,
},
resolver: zodResolver(CreateFulfillmentSchema),
@@ -108,6 +111,16 @@ export function OrderCreateFulfillmentForm({
}
}, [fulfillableItems.length])
useEffect(() => {
const itemsToFulfill = order?.items?.filter(
(item) => getFulfillableQuantity(item) > 0
)
if (itemsToFulfill?.length) {
setFulfillableItems(itemsToFulfill)
}
}, [order.items])
return (
<RouteFocusModal.Form form={form}>
<form

View File

@@ -5,16 +5,18 @@ import { useNavigate } from "react-router-dom"
import {
ArrowDownRightMini,
ArrowLongRight,
ArrowPath,
ArrowUturnLeft,
DocumentText,
ExclamationCircle,
} from "@medusajs/icons"
import {
AdminClaim,
AdminExchange,
AdminOrder,
AdminOrderLineItem,
AdminOrderPreview,
AdminReturn,
OrderLineItemDTO,
ReservationItemDTO,
} from "@medusajs/types"
import {
@@ -32,6 +34,7 @@ import { ActionMenu } from "../../../../../components/common/action-menu"
import { ButtonMenu } from "../../../../../components/common/button-menu/button-menu.tsx"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useClaims } from "../../../../../hooks/api/claims.tsx"
import { useExchanges } from "../../../../../hooks/api/exchanges.tsx"
import { useOrderPreview } from "../../../../../hooks/api/orders.tsx"
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReturns } from "../../../../../hooks/api/returns"
@@ -165,13 +168,35 @@ const Header = ({
label: t("orders.returns.create"),
to: `/orders/${order.id}/returns`,
icon: <ArrowUturnLeft />,
disabled:
!!orderPreview?.order_change?.exchange_id ||
!!orderPreview?.order_change?.claim_id,
},
{
label: orderPreview?.order_change?.id
? t("orders.claims.manage")
: t("orders.claims.create"),
label:
orderPreview?.order_change?.id &&
orderPreview?.order_change?.exchange_id
? t("orders.exchanges.manage")
: t("orders.exchanges.create"),
to: `/orders/${order.id}/exchanges`,
icon: <ArrowPath />,
disabled:
(!!orderPreview?.order_change?.return_id &&
!!!orderPreview?.order_change?.exchange_id) ||
!!orderPreview?.order_change?.claim_id,
},
{
label:
orderPreview?.order_change?.id &&
orderPreview?.order_change?.claim_id
? t("orders.claims.manage")
: t("orders.claims.create"),
to: `/orders/${order.id}/claims`,
icon: <ExclamationCircle />,
disabled:
(!!orderPreview?.order_change?.return_id &&
!!!orderPreview?.order_change?.claim_id) ||
!!orderPreview?.order_change?.exchange_id,
},
],
},
@@ -187,15 +212,17 @@ const Item = ({
reservation,
returns,
claims,
exchanges,
}: {
item: OrderLineItemDTO
item: AdminOrderLineItem
currencyCode: string
reservation?: ReservationItemDTO | null
returns: AdminReturn[]
claims: AdminClaim[]
exchanges: AdminExchange[]
}) => {
const { t } = useTranslation()
const isInventoryManaged = item.variant.manage_inventory
const isInventoryManaged = item.variant?.manage_inventory
return (
<>
@@ -214,6 +241,7 @@ const Item = ({
>
{item.title}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
@@ -221,22 +249,25 @@ const Item = ({
</div>
)}
<Text size="small">
{item.variant?.options.map((o) => o.value).join(" · ")}
{item.variant?.options?.map((o) => o.value).join(" · ")}
</Text>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-4">
<Text size="small">
{getLocaleAmount(item.unit_price, currencyCode)}
</Text>
</div>
<div className="flex items-center gap-x-2">
<div className="w-fit min-w-[27px]">
<Text size="small">
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="overflow-visible">
{isInventoryManaged && (
<StatusBadge
@@ -250,6 +281,7 @@ const Item = ({
)}
</div>
</div>
<div className="flex items-center justify-end">
<Text size="small" className="pt-[1px]">
{getLocaleAmount(item.subtotal || 0, currencyCode)}
@@ -257,6 +289,7 @@ const Item = ({
</div>
</div>
</div>
{returns.map((r) => (
<ReturnBreakdown key={r.id} orderReturn={r} itemId={item.id} />
))}
@@ -264,6 +297,14 @@ const Item = ({
{claims.map((claim) => (
<ClaimBreakdown key={claim.id} claim={claim} itemId={item.id} />
))}
{exchanges.map((exchange) => (
<ExchangeBreakdown
key={exchange.id}
exchange={exchange}
itemId={item.id}
/>
))}
</>
)
}
@@ -273,41 +314,24 @@ const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
line_item_id: order.items.map((i) => i.id),
})
const { claims } = useClaims({
const { claims = [] } = useClaims({
order_id: order.id,
fields: "*additional_items",
})
const { returns } = useReturns({
const { exchanges = [] } = useExchanges({
order_id: order.id,
fields: "*additional_items",
})
const { returns = [] } = useReturns({
order_id: order.id,
fields: "*items,*items.reason",
})
const itemsReturnsMap = useMemo(() => {
if (!returns) {
return {}
}
const ret = {}
order.items?.forEach((i) => {
returns.forEach((r) => {
if (r.items.some((ri) => ri.item_id === i.id)) {
if (ret[i.id]) {
ret[i.id].push(r)
} else {
ret[i.id] = [r]
}
}
})
})
return ret
}, [returns])
return (
<div>
{order.items.map((item) => {
{order.items?.map((item) => {
const reservation = reservations
? reservations.find((r) => r.line_item_id === item.id)
: null
@@ -318,8 +342,9 @@ const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
item={item}
currencyCode={order.currency_code}
reservation={reservation}
returns={itemsReturnsMap[item.id] || []}
claims={claims || []}
returns={returns}
exchanges={exchanges}
claims={claims}
/>
)
})}
@@ -490,6 +515,45 @@ const ClaimBreakdown = ({
)
}
const ExchangeBreakdown = ({
exchange,
itemId,
}: {
exchange: AdminExchange
itemId: string
}) => {
const { t } = useTranslation()
const { getRelativeDate } = useDate()
const items = exchange.additional_items.filter(
(item) => item?.item?.id === itemId
)
return (
!!items.length && (
<div
key={exchange.id}
className="txt-compact-small-plus text-ui-fg-subtle bg-ui-bg-subtle border-dotted border-t-2 border-b-2 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>
{t(`orders.exchanges.outboundItemAdded`, {
itemsCount: items.reduce(
(acc, item) => (acc = acc + item.quantity),
0
),
})}
</Text>
</div>
<Text size="small" leading="compact" className="text-ui-fg-muted">
{getRelativeDate(exchange.created_at)}
</Text>
</div>
)
)
}
const Total = ({ order }: { order: AdminOrder }) => {
const { t } = useTranslation()
@@ -524,9 +588,13 @@ const Total = ({ order }: { order: AdminOrder }) => {
>
{t("orders.returns.outstandingAmount")}
</Text>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
<Text
className="text-ui-fg-subtle text-bold"
size="small"
leading="compact"
>
{getStylizedAmount(
order.summary.difference_sum || 0,
order.summary.pending_difference || 0,
order.currency_code
)}
</Text>

View File

@@ -6,7 +6,12 @@ import {
OrderExchangeDTO,
OrderPreviewDTO,
} from "@medusajs/types"
import { ChangeActionType, Modules, OrderChangeStatus } from "@medusajs/utils"
import {
ChangeActionType,
Modules,
OrderChangeStatus,
ReturnStatus,
} from "@medusajs/utils"
import {
WorkflowResponse,
createStep,
@@ -19,7 +24,7 @@ import { createRemoteLinkStep, useRemoteQueryStep } from "../../../common"
import { reserveInventoryStep } from "../../../definition/cart/steps/reserve-inventory"
import { prepareConfirmInventoryInput } from "../../../definition/cart/utils/prepare-confirm-inventory-input"
import { createReturnFulfillmentWorkflow } from "../../../fulfillment/workflows/create-return-fulfillment"
import { previewOrderChangeStep } from "../../steps"
import { previewOrderChangeStep, updateReturnsStep } from "../../steps"
import { confirmOrderChanges } from "../../steps/confirm-order-changes"
import { createOrderExchangeItemsFromActionsStep } from "../../steps/exchange/create-exchange-items-from-actions"
import { createReturnItemsFromActionsStep } from "../../steps/return/create-return-items-from-actions"
@@ -57,7 +62,6 @@ function prepareFulfillmentData({
items,
shippingOption,
deliveryAddress,
isReturn,
}: {
order: OrderDTO
items: any[]
@@ -74,17 +78,15 @@ function prepareFulfillmentData({
}
}
deliveryAddress?: Record<string, any>
isReturn?: boolean
}) {
const orderItemsMap = new Map<string, Required<OrderDTO>["items"][0]>(
order.items!.map((i) => [i.id, i])
)
const fulfillmentItems = items.map((i) => {
const orderItem = orderItemsMap.get(i.item_id) ?? i.item
const orderItem = orderItemsMap.get(i.id) ?? i.item
return {
line_item_id: i.item_id,
quantity: !isReturn ? i.quantity : undefined,
return_quantity: isReturn ? i.quantity : undefined,
quantity: i.quantity,
title: orderItem.variant_title ?? orderItem.title,
sku: orderItem.variant_sku || "",
barcode: orderItem.variant_barcode || "",
@@ -191,11 +193,8 @@ export const confirmExchangeRequestWorkflow = createWorkflow(
"id",
"version",
"canceled_at",
"items.id",
"items.title",
"items.variant_title",
"items.variant_sku",
"items.variant_barcode",
"items.*",
"items.item.id",
"shipping_address.*",
],
variables: { id: orderExchange.order_id },
@@ -248,6 +247,18 @@ export const confirmExchangeRequestWorkflow = createWorkflow(
}
)
when({ returnId }, ({ returnId }) => {
return !!returnId
}).then(() => {
updateReturnsStep([
{
id: returnId,
status: ReturnStatus.REQUESTED,
requested_at: new Date(),
},
])
})
const exchangeId = transform(
{ createExchangeItems },
({ createExchangeItems }) => {
@@ -320,9 +331,12 @@ export const confirmExchangeRequestWorkflow = createWorkflow(
reserveInventoryStep(formatedInventoryItems)
})
when({ returnShippingMethod }, ({ returnShippingMethod }) => {
return !!returnShippingMethod
}).then(() => {
when(
{ returnShippingMethod, returnId },
({ returnShippingMethod, returnId }) => {
return !!returnShippingMethod && !!returnId
}
).then(() => {
const returnShippingOption = useRemoteQueryStep({
entry_point: "shipping_options",
fields: [
@@ -343,7 +357,6 @@ export const confirmExchangeRequestWorkflow = createWorkflow(
order,
items: order.items!,
shippingOption: returnShippingOption,
isReturn: true,
},
prepareFulfillmentData
)

View File

@@ -19,6 +19,7 @@ import { useRemoteQueryStep } from "../../../common"
import { updateOrderExchangesStep } from "../../steps/exchange/update-order-exchanges"
import { previewOrderChangeStep } from "../../steps/preview-order-change"
import { createReturnsStep } from "../../steps/return/create-returns"
import { updateOrderChangesStep } from "../../steps/update-order-changes"
import {
throwIfIsCancelled,
throwIfItemsDoesNotExistsInOrder,
@@ -124,6 +125,17 @@ export const orderExchangeRequestItemReturnWorkflow = createWorkflow(
status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED],
})
when({ createdReturn }, ({ createdReturn }) => {
return !!createdReturn?.length
}).then(() => {
updateOrderChangesStep([
{
id: orderChange.id,
return_id: createdReturn?.[0]?.id,
},
])
})
exchangeRequestItemReturnValidationStep({
order,
items: input.items,

View File

@@ -0,0 +1,371 @@
import { HttpTypes } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class Exchange {
private client: Client
constructor(client: Client) {
this.client = client
}
async list(
query?: HttpTypes.AdminExchangeListParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeListResponse>(
`/admin/exchanges`,
{
query,
headers,
}
)
}
async retrieve(
id: string,
query?: HttpTypes.AdminExchangeParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}`,
{
query,
headers,
}
)
}
async create(
body: HttpTypes.AdminCreateExchange,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges`,
{
method: "POST",
headers,
body,
query,
}
)
}
async cancel(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/cancel`,
{
method: "POST",
headers,
query,
}
)
}
async delete(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeDeleteResponse>(
`/admin/exchanges/${id}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addItems(
id: string,
body: HttpTypes.AdminAddExchangeItems,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/exchange-items`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateItem(
id: string,
actionId: string,
body: HttpTypes.AdminUpdateExchangeItem,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/exchange-items/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async removeItem(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminReturnResponse>(
`/admin/exchanges/${id}/exchange-items/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addInboundItems(
id: string,
body: HttpTypes.AdminAddExchangeInboundItems,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/inbound/items`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateInboundItem(
id: string,
actionId: string,
body: HttpTypes.AdminUpdateExchangeInboundItem,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/inbound/items/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async removeInboundItem(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/inbound/items/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addInboundShipping(
id: string,
body: HttpTypes.AdminExchangeAddInboundShipping,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/inbound/shipping-method`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateInboundShipping(
id: string,
actionId: string,
body: HttpTypes.AdminExchangeUpdateInboundShipping,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/inbound/shipping-method/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async deleteInboundShipping(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/inbound/shipping-method/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addOutboundItems(
id: string,
body: HttpTypes.AdminAddExchangeOutboundItems,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/outbound/items`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateOutboundItem(
id: string,
actionId: string,
body: HttpTypes.AdminUpdateExchangeOutboundItem,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/outbound/items/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async removeOutboundItem(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/outbound/items/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addOutboundShipping(
id: string,
body: HttpTypes.AdminExchangeAddOutboundShipping,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/outbound/shipping-method`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateOutboundShipping(
id: string,
actionId: string,
body: HttpTypes.AdminExchangeUpdateOutboundShipping,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/outbound/shipping-method/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async deleteOutboundShipping(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/outbound/shipping-method/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
async request(
id: string,
body: HttpTypes.AdminRequestExchange,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/request`,
{
method: "POST",
headers,
body,
query,
}
)
}
async cancelRequest(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminExchangeResponse>(
`/admin/exchanges/${id}/request`,
{
method: "DELETE",
headers,
query,
}
)
}
}

View File

@@ -2,6 +2,7 @@ import { Client } from "../client"
import { Claim } from "./claim"
import { Currency } from "./currency"
import { Customer } from "./customer"
import { Exchange } from "./exchange"
import { Fulfillment } from "./fulfillment"
import { FulfillmentProvider } from "./fulfillment-provider"
import { FulfillmentSet } from "./fulfillment-set"
@@ -56,6 +57,7 @@ export class Admin {
public order: Order
public return: Return
public claim: Claim
public exchange: Exchange
public taxRate: TaxRate
public taxRegion: TaxRegion
public store: Store
@@ -99,5 +101,6 @@ export class Admin {
this.payment = new Payment(client)
this.productVariant = new ProductVariant(client)
this.refundReason = new RefundReason(client)
this.exchange = new Exchange(client)
}
}

View File

@@ -1,2 +1,4 @@
export * from "./entities"
export * from "./responses"
export * from "./payloads"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,74 @@
enum ExchangeReason {
MISSING_ITEM = "missing_item",
WRONG_ITEM = "wrong_item",
PRODUCTION_FAILURE = "production_failure",
OTHER = "other",
}
interface AdminExchangeAddItems {
items: {
id: string
quantity: number
reason?: ExchangeReason
description?: string
internal_note?: string
}[]
}
interface AdminExchangeUpdateItem {
quantity?: number
reason_id?: string | null
description?: string
internal_note?: string | null
}
interface AdminExchangeAddShippingMethod {
shipping_option_id: string
custom_price?: number
description?: string
internal_note?: string
metadata?: Record<string, unknown> | null
}
interface AdminExchangeUpdateShippingMethod {
custom_price?: number | null
internal_note?: string
metadata?: Record<string, unknown> | null
}
export interface AdminCreateExchange {
type: "refund" | "replace"
order_id: string
description?: string
internal_note?: string
metadata?: Record<string, unknown> | null
}
export interface AdminAddExchangeItems extends AdminExchangeAddItems {}
export interface AdminUpdateExchangeItem extends AdminExchangeUpdateItem {}
export interface AdminAddExchangeInboundItems extends AdminExchangeAddItems {}
export interface AdminUpdateExchangeInboundItem
extends AdminExchangeUpdateItem {}
export interface AdminAddExchangeOutboundItems extends AdminExchangeAddItems {}
export interface AdminUpdateExchangeOutboundItem
extends AdminExchangeUpdateItem {}
export interface AdminExchangeAddInboundShipping
extends AdminExchangeAddShippingMethod {}
export interface AdminExchangeUpdateInboundShipping
extends AdminExchangeUpdateShippingMethod {}
export interface AdminExchangeAddOutboundShipping
extends AdminExchangeAddShippingMethod {}
export interface AdminExchangeUpdateOutboundShipping
extends AdminExchangeUpdateShippingMethod {}
export interface AdminRequestExchange {
no_notification?: boolean
}
export interface AdminCancelExchange {
no_notification?: boolean
}

View File

@@ -0,0 +1,17 @@
import { BaseFilterable, OperatorMap } from "../../../dal"
import { SelectParams } from "../../common"
import { BaseExchangeListParams } from "../common"
export interface AdminExchangeListParams
extends BaseExchangeListParams,
BaseFilterable<AdminExchangeListParams> {
deleted_at?: OperatorMap<string>
}
export interface AdminExchangeParams extends SelectParams {
id?: string | string[]
status?: string | string[]
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
deleted_at?: OperatorMap<string>
}

View File

@@ -1,5 +1,5 @@
import { OrderDTO, OrderPreviewDTO } from "../../../order"
import { PaginatedResponse } from "../../common"
import { DeleteResponse, PaginatedResponse } from "../../common"
import { AdminReturn } from "../../return"
import { AdminExchange } from "./entities"
@@ -8,7 +8,7 @@ export interface AdminExchangeResponse {
}
export type AdminExchangeListResponse = PaginatedResponse<{
exchanges: AdminExchange
exchanges: AdminExchange[]
}>
export interface AdminExchangeOrderResponse {
@@ -30,3 +30,6 @@ export interface AdminExchangeReturnResponse {
order_preview: OrderPreviewDTO
return: AdminReturn
}
export interface AdminExchangeDeleteResponse
extends DeleteResponse<"exchange"> {}

View File

@@ -1,5 +1,7 @@
import { BaseOrder } from "../order/common";
import { AdminReturnItem, AdminReturn } from "../return";
import { OperatorMap } from "../../dal"
import { FindParams } from "../common"
import { BaseOrder } from "../order/common"
import { AdminReturn, AdminReturnItem } from "../return"
export interface BaseExchangeItem {
id: string
@@ -12,7 +14,8 @@ export interface BaseExchangeItem {
updated_at: string | null
}
export interface BaseExchange extends Omit<BaseOrder, "status" | "version" | "items"> {
export interface BaseExchange
extends Omit<BaseOrder, "status" | "version" | "items"> {
order_id: string
return_items: AdminReturnItem[]
additional_items: BaseExchangeItem[]
@@ -20,4 +23,14 @@ export interface BaseExchange extends Omit<BaseOrder, "status" | "version" | "it
difference_due?: number
return?: AdminReturn
return_id?: string
}
}
export interface BaseExchangeListParams extends FindParams {
q?: string
id?: string | string[]
order_id?: string | string[]
status?: string | string[]
created_at?: OperatorMap<string>
updated_at?: OperatorMap<string>
deleted_at?: OperatorMap<string>
}

View File

@@ -2,6 +2,7 @@ import {
removeItemReturnActionWorkflow,
updateRequestItemReturnWorkflow,
} from "@medusajs/core-flows"
import { HttpTypes } from "@medusajs/types"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
@@ -10,9 +11,9 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { refetchEntity } from "../../../../../../utils/refetch-entity"
import { defaultAdminDetailsReturnFields } from "../../../../../returns/query-config"
import { AdminPostExchangesRequestItemsReturnActionReqSchemaType } from "../../../../validators"
import { HttpTypes } from "@medusajs/types"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminPostExchangesRequestItemsReturnActionReqSchemaType>,
@@ -69,11 +70,15 @@ export const DELETE = async (
const { id, action_id } = req.params
const exchange = await refetchEntity("order_exchange", id, req.scope, [
"return_id",
])
const { result: orderPreview } = await removeItemReturnActionWorkflow(
req.scope
).run({
input: {
return_id: id,
return_id: exchange.return_id,
action_id,
},
})
@@ -81,7 +86,7 @@ export const DELETE = async (
const queryObject = remoteQueryObjectFromString({
entryPoint: "return",
variables: {
id,
id: exchange.return_id,
filters: {
...req.filterableFields,
},

View File

@@ -0,0 +1,28 @@
import { AdminExchangeResponse } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { refetchEntity } from "../../../utils/refetch-entity"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse<AdminExchangeResponse>
) => {
const exchange = await refetchEntity(
"order_exchange",
req.params.id,
req.scope,
req.remoteQueryConfig.fields
)
if (!exchange) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Exchange with id: ${req.params.id} was not found`
)
}
res.status(200).json({ exchange })
}