feat(dashboard,js-sdk,medusa): add ability to add outbound items to claim (#8502)

what:

- user can add/remove/update outbound items to a claim
- adds API to query variants

Note: There are several paths that are not implemented yet correctly, but have some code lying around. Those will be tackled in the followup PRs

https://github.com/user-attachments/assets/cadb3f7a-982f-44c7-8d7e-9f4f26949f4f

RESOLVES CC-330
RESOLVES CC-296
This commit is contained in:
Riqwan Thamir
2024-08-08 14:20:13 +02:00
committed by GitHub
parent 9cd66bc842
commit 85ed025705
20 changed files with 1052 additions and 83 deletions

View File

@@ -11,10 +11,12 @@ export * from "./fulfillment-providers"
export * from "./fulfillment-sets"
export * from "./inventory"
export * from "./invites"
export * from "./notification"
export * from "./orders"
export * from "./payments"
export * from "./price-lists"
export * from "./product-types"
export * from "./product-variants"
export * from "./products"
export * from "./promotions"
export * from "./regions"
@@ -29,4 +31,3 @@ export * from "./tax-rates"
export * from "./tax-regions"
export * from "./users"
export * from "./workflow-executions"
export * from "./notification"

View File

@@ -0,0 +1,24 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const
export const productVariantQueryKeys = queryKeysFactory(
PRODUCT_VARIANT_QUERY_KEY
)
export const useVariants = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.productVariant.list(query),
queryKey: productVariantQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -845,6 +845,7 @@
"returns": {
"create": "Create Return",
"inbound": "Inbound",
"outbound": "Outbound",
"sendNotification": "Send notification",
"sendNotificationHint": "Notify customer about return.",
"returnTotal": "Return total",
@@ -885,6 +886,8 @@
"claims": {
"create": "Create Claim",
"outbound": "Outbound",
"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": {

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 { useClaimOutboundItemTableColumns } from "./use-claim-outbound-item-table-columns"
import { useClaimOutboundItemTableFilters } from "./use-claim-outbound-item-table-filters"
import { useClaimOutboundItemTableQuery } from "./use-claim-outbound-item-table-query"
const PAGE_SIZE = 50
const PREFIX = "rit"
type AddClaimOutboundItemsTableProps = {
onSelectionChange: (ids: string[]) => void
selectedItems: string[]
currencyCode: string
}
export const AddClaimOutboundItemsTable = ({
onSelectionChange,
selectedItems,
currencyCode,
}: AddClaimOutboundItemsTableProps) => {
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 } = useClaimOutboundItemTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { variants = [], count } = useVariants({
...searchParams,
fields: "*inventory_items.inventory.location_levels,+inventory_quantity",
})
const columns = useClaimOutboundItemTableColumns(currencyCode)
const filters = useClaimOutboundItemTableFilters()
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-claim-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 useClaimOutboundItemTableColumns = (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 useClaimOutboundItemTableFilters = () => {
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,40 @@
import {
DateComparisonOperator,
NumericalComparisonOperator,
} from "@medusajs/types"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export type ReturnItemTableQuery = {
q?: string
offset: number
order?: string
created_at?: DateComparisonOperator
updated_at?: DateComparisonOperator
returnable_quantity?: NumericalComparisonOperator | number
refundable_amount?: NumericalComparisonOperator | number
}
export const useClaimOutboundItemTableQuery = ({
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

@@ -34,7 +34,7 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { AddClaimItemsTable } from "../add-claim-items-table"
import { ClaimInboundItem } from "./claim-inbound-item.tsx"
import { ClaimCreateSchema, ReturnCreateSchemaType } from "./schema"
import { ClaimCreateSchema, CreateClaimSchemaType } from "./schema"
import {
useAddClaimInboundItems,
@@ -47,6 +47,8 @@ import {
} from "../../../../../hooks/api/claims"
import { sdk } from "../../../../../lib/client"
import { currencies } from "../../../../../lib/data/currencies"
import { ClaimOutboundSection } from "./claim-outbound-section"
import { ItemPlaceholder } from "./item-placeholder"
type ReturnCreateFormProps = {
order: AdminOrder
@@ -56,7 +58,6 @@ type ReturnCreateFormProps = {
let itemsToAdd: string[] = []
let itemsToRemove: string[] = []
let IS_CANCELING = false
export const ClaimCreateForm = ({
@@ -92,11 +93,13 @@ export const ClaimCreateForm = ({
/**
* MUTATIONS
*/
// TODO: implement confirm claim request
const { mutateAsync: confirmClaimRequest, isPending: isConfirming } = {} // useConfirmClaimRequest(claim.id, order.id)
const { mutateAsync: cancelClaimRequest, isPending: isCanceling } =
useCancelClaimRequest(claim.id, order.id)
// TODO: implement update claim request
const { mutateAsync: updateClaimRequest, isPending: isUpdating } = {} // useUpdateClaim(claim.id, order.id)
const {
@@ -145,40 +148,50 @@ export const ClaimCreateForm = ({
[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")
)
const itemsMap = useMemo(
() => new Map(order?.items?.map((i) => [i.id, i])),
[order.items]
)
const previewItemsMap = useMemo(
() => new Map(previewItems.map((i) => [i.id, i])),
[previewItems]
)
/**
* FORM
*/
const form = useForm<ReturnCreateSchemaType>({
const form = useForm<CreateClaimSchemaType>({
defaultValues: () => {
const method = preview.shipping_methods.find(
(s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD")
)
return Promise.resolve({
inbound_items: previewItems.map((i) => {
const returnAction = i.actions?.find(
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: returnAction?.internal_note,
reason_id: returnAction?.details?.reason_id as string | undefined,
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: method ? method.shipping_option_id : "",
// TODO: pick up shipping method for outbound when available
outbound_option_id: method ? method.shipping_option_id : "",
location_id: "",
send_notification: false,
})
@@ -187,7 +200,7 @@ export const ClaimCreateForm = ({
})
const {
fields: items,
fields: inboundItems,
append,
remove,
update,
@@ -196,22 +209,27 @@ export const ClaimCreateForm = ({
control: form.control,
})
const previewItemsMap = useMemo(
() => new Map(previewItems.map((i) => [i.id, i])),
[previewItems, inboundItems]
)
useEffect(() => {
const existingItemsMap: Record<string, boolean> = {}
previewItems.forEach((i) => {
const ind = items.findIndex((field) => field.item_id === i.id)
inboundPreviewItems.forEach((i) => {
const ind = inboundItems.findIndex((field) => field.item_id === i.id)
existingItemsMap[i.id] = true
if (ind > -1) {
if (items[ind].quantity !== i.detail.return_requested_quantity) {
if (inboundItems[ind].quantity !== i.detail.return_requested_quantity) {
const returnItemAction = i.actions?.find(
(a) => a.action === "RETURN_ITEM"
)
update(ind, {
...items[ind],
...inboundItems[ind],
quantity: i.detail.return_requested_quantity,
note: returnItemAction?.internal_note,
reason_id: returnItemAction?.details?.reason_id as string,
@@ -222,7 +240,7 @@ export const ClaimCreateForm = ({
}
})
items.forEach((i, ind) => {
inboundItems.forEach((i, ind) => {
if (!(i.item_id in existingItemsMap)) {
remove(ind)
}
@@ -239,7 +257,7 @@ export const ClaimCreateForm = ({
}
}, [preview.shipping_methods])
const showPlaceholder = !items.length
const showInboundItemsPlaceholder = !inboundItems.length
const locationId = form.watch("location_id")
const shippingOptionId = form.watch("inbound_option_id")
@@ -285,7 +303,7 @@ export const ClaimCreateForm = ({
}
}
setIsOpen("items", false)
setIsOpen("inbound-items", false)
}
const onLocationChange = async (selectedLocationId?: string | null) => {
@@ -321,7 +339,7 @@ export const ClaimCreateForm = ({
return false
}
const allItemsHaveLocation = items
const allItemsHaveLocation = inboundItems
.map((_i) => {
const item = itemsMap.get(_i.item_id)
if (!item?.variant_id || !item?.variant) {
@@ -339,45 +357,30 @@ export const ClaimCreateForm = ({
.every(Boolean)
return !allItemsHaveLocation
}, [items, inventoryMap, locationId])
}, [inboundItems, inventoryMap, locationId])
useEffect(() => {
const getInventoryMap = async () => {
const ret: Record<string, InventoryLevelDTO[]> = {}
if (!items.length) {
if (!inboundItems.length) {
return ret
}
;(
await Promise.all(
items.map(async (_i) => {
const item = itemsMap.get(_i.item_id)!
const variantIds = inboundItems
.map((item) => item?.variant_id)
.filter(Boolean)
if (!item.variant_id || !item.variant?.product) {
return undefined
}
return await sdk.admin.product.retrieveVariant(
item.variant.product.id,
item.variant_id,
{ fields: "*inventory,*inventory.location_levels" }
)
})
const variants = (
await sdk.admin.productVariant.list(
{ id: variantIds },
{ fields: "*inventory,*inventory.location_levels" }
)
)
.filter((it) => !!it?.variant)
.forEach((item) => {
).variants
const { variant } = item
const levels = variant.inventory[0]?.location_levels
if (!levels) {
return
}
ret[variant.id] = levels
})
variants.forEach((variant) => {
ret[variant.id] = variant.inventory[0]?.location_levels || []
})
return ret
}
@@ -385,7 +388,7 @@ export const ClaimCreateForm = ({
getInventoryMap().then((map) => {
setInventoryMap(map)
})
}, [items])
}, [inboundItems])
useEffect(() => {
/**
@@ -429,7 +432,8 @@ export const ClaimCreateForm = ({
<Heading level="h1">{t("orders.claims.create")}</Heading>
<div className="mt-8 flex items-center justify-between">
<Heading level="h2">{t("orders.returns.inbound")}</Heading>
<StackedFocusModal id="items">
<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")}
@@ -440,10 +444,10 @@ export const ClaimCreateForm = ({
<AddClaimItemsTable
items={order.items!}
selectedItems={items.map((i) => i.item_id)}
selectedItems={inboundItems.map((i) => i.item_id)}
currencyCode={order.currency_code}
onSelectionChange={(finalSelection) => {
const alreadySelected = items.map((i) => i.item_id)
const alreadySelected = inboundItems.map((i) => i.item_id)
itemsToAdd = finalSelection.filter(
(selection) => !alreadySelected.includes(selection)
@@ -482,20 +486,11 @@ export const ClaimCreateForm = ({
</StackedFocusModal.Content>
</StackedFocusModal>
</div>
{showPlaceholder && (
<div
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
className="bg-ui-bg-field mt-4 block h-[56px] w-full rounded-lg border border-dashed"
/>
)}
{items.map(
{showInboundItemsPlaceholder && <ItemPlaceholder />}
{inboundItems.map(
(item, index) =>
previewItemsMap.get(item.item_id) && (
previewItemsMap.get(item.item_id) &&
itemsMap.get(item.item_id)! && (
<ClaimInboundItem
key={item.id}
item={itemsMap.get(item.item_id)!}
@@ -535,8 +530,7 @@ export const ClaimCreateForm = ({
/>
)
)}
{!showPlaceholder && (
{!showInboundItemsPlaceholder && (
<div className="mt-8 flex flex-col gap-y-4">
{/*LOCATION*/}
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
@@ -628,7 +622,6 @@ export const ClaimCreateForm = ({
</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]">
@@ -640,6 +633,13 @@ export const ClaimCreateForm = ({
</Alert>
)}
<ClaimOutboundSection
form={form}
preview={preview}
order={order}
claim={claim}
/>
{/*TOTALS SECTION*/}
<div className="mt-8 border-y border-dotted py-4">
<div className="mb-2 flex items-center justify-between">
@@ -664,7 +664,9 @@ export const ClaimCreateForm = ({
onClick={() => setIsShippingPriceEdit(true)}
variant="transparent"
className="text-ui-fg-muted"
disabled={showPlaceholder || !shippingOptionId}
disabled={
showInboundItemsPlaceholder || !shippingOptionId
}
>
<PencilSquare />
</IconButton>
@@ -712,7 +714,7 @@ export const ClaimCreateForm = ({
value && setCustomShippingAmount(parseInt(value))
}
value={customShippingAmount}
disabled={showPlaceholder}
disabled={showInboundItemsPlaceholder}
/>
) : (
getStylizedAmount(shippingTotal, order.currency_code)
@@ -732,7 +734,6 @@ export const ClaimCreateForm = ({
</span>
</div>
</div>
{/*SEND NOTIFICATION*/}
<div className="bg-ui-bg-field mt-8 rounded-lg border py-2 pl-2 pr-4">
<Form.Field

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 { CreateClaimSchemaType } from "./schema"
type ClaimOutboundItemProps = {
previewItem: AdminOrderLineItem
currencyCode: string
index: number
onRemove: () => void
// TODO: create a payload type for outbound updates
onUpdate: (payload: HttpTypes.AdminUpdateReturnItems) => void
form: UseFormReturn<CreateClaimSchemaType>
}
function ClaimOutboundItem({
previewItem,
currencyCode,
form,
onRemove,
onUpdate,
index,
}: ClaimOutboundItemProps) {
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 { ClaimOutboundItem }

View File

@@ -0,0 +1,446 @@
import {
AdminClaim,
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 {
useAddClaimOutboundItems,
useAddClaimOutboundShipping,
useDeleteClaimOutboundShipping,
useRemoveClaimOutboundItem,
useUpdateClaimOutboundItems,
} from "../../../../../hooks/api/claims"
import { useShippingOptions } from "../../../../../hooks/api/shipping-options"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { sdk } from "../../../../../lib/client"
import { AddClaimOutboundItemsTable } from "../add-claim-outbound-items-table"
import { ClaimOutboundItem } from "./claim-outbound-item"
import { ItemPlaceholder } from "./item-placeholder"
import { CreateClaimSchemaType } from "./schema"
type ClaimOutboundSectionProps = {
order: AdminOrder
claim: AdminClaim
preview: AdminOrderPreview
form: UseFormReturn<CreateClaimSchemaType>
}
let itemsToAdd: string[] = []
let itemsToRemove: string[] = []
export const ClaimOutboundSection = ({
order,
preview,
claim,
form,
}: ClaimOutboundSectionProps) => {
const { t } = useTranslation()
const { setIsOpen } = useStackedModal()
const [inventoryMap, setInventoryMap] = useState<
Record<string, InventoryLevelDTO[]>
>({})
/**
* HOOKS
*/
const { stock_locations = [] } = useStockLocations({ limit: 999 })
const { shipping_options = [] } = useShippingOptions({
limit: 999,
fields: "*prices,+service_zone.fulfillment_set.location.id",
/**
* TODO: this should accept filter for location_id
*/
})
const { mutateAsync: addOutboundShipping } = useAddClaimOutboundShipping(
claim.id,
order.id
)
const { mutateAsync: deleteOutboundShipping } =
useDeleteClaimOutboundShipping(claim.id, order.id)
const { mutateAsync: addOutboundItem } = useAddClaimOutboundItems(
claim.id,
order.id
)
const { mutateAsync: updateOutboundItem } = useUpdateClaimOutboundItems(
claim.id,
order.id
)
const { mutateAsync: removeOutboundItem } = useRemoveClaimOutboundItem(
claim.id,
order.id
)
/**
* Only consider items that belong to this claim and is an outbound item
*/
const previewOutboundItems = useMemo(
() =>
preview?.items?.filter(
(i) =>
!!i.actions?.find(
(a) => a.claim_id === claim.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])
useEffect(() => {
// TODO: Pick the shipping methods from actions where return_id is null for outbound
const method = preview.shipping_methods.find(
(s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD")
)
if (method) {
form.setValue("outbound_option_id", method.shipping_option_id)
}
}, [preview.shipping_methods])
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 actionId = previewOutboundItems
.find((i) => i.variant_id === itemToRemove)
?.actions?.find((a) => a.action === "ITEM_ADD")?.id
if (actionId) {
await removeOutboundItem(actionId, {
onError: (error) => {
toast.error(error.message)
},
})
}
}
setIsOpen("outbound-items", false)
}
// TODO: implement outbound shipping
const { mutateAsync: updateClaimRequest, isPending: isUpdating } = {} // useUpdateClaim(claim.id, order.id)
const onLocationChange = async (selectedLocationId?: string | null) => {
await updateClaimRequest({ location_id: selectedLocationId })
}
const onShippingOptionChange = async (selectedOptionId: string) => {
const promises = preview.shipping_methods
.map((s) => s.actions?.find((a) => a.action === "SHIPPING_ADD")?.id)
.filter(Boolean)
.map(deleteOutboundShipping)
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(() => {
// TODO: Ensure inventory validation occurs correctly
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 />
<AddClaimOutboundItemsTable
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) && (
<ClaimOutboundItem
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.claims.outboundShipping")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.claims.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 ?? [])
.filter(
(so) =>
(locationId
? so.service_zone.fulfillment_set!.location
.id === locationId
: true) &&
!!so.rules.find(
(r) =>
r.attribute === "is_return" &&
r.value === "true"
)
)
.map((so) => ({
label: so.name,
value: so.id,
}))}
disabled={!locationId}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</div>
)}
{showLevelsWarning && (
<Alert variant="warning" dismissible className="mt-4 p-5">
<div className="text-ui-fg-subtle txt-small pb-2 font-medium leading-[20px]">
{t("orders.returns.noInventoryLevel")}
</div>
<Text className="text-ui-fg-subtle txt-small leading-normal">
{t("orders.returns.noInventoryLevelDesc")}
</Text>
</Alert>
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
export const ItemPlaceholder = () => {
return (
<div
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
className="bg-ui-bg-field mt-4 block h-[56px] w-full rounded-lg border border-dashed"
/>
)
}

View File

@@ -9,16 +9,15 @@ export const ClaimCreateSchema = z.object({
note: z.string().nullish(),
})
),
// TODO: Bring back when introducing outbound items
// outbound_items: z.array(
// z.object({
// item_id: z.string(), // TODO: variant id?
// quantity: z.number(),
// })
// ),
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 ReturnCreateSchemaType = z.infer<typeof ClaimCreateSchema>
export type CreateClaimSchemaType = z.infer<typeof ClaimCreateSchema>

View File

@@ -1,4 +1,5 @@
import { Client } from "../client"
import { Claim } from "./claim"
import { Currency } from "./currency"
import { Customer } from "./customer"
import { Fulfillment } from "./fulfillment"
@@ -16,6 +17,7 @@ import { ProductCategory } from "./product-category"
import { ProductCollection } from "./product-collection"
import { ProductTag } from "./product-tag"
import { ProductType } from "./product-type"
import { ProductVariant } from "./product-variant"
import { Region } from "./region"
import { Return } from "./return"
import { ReturnReason } from "./return-reason"
@@ -28,7 +30,6 @@ import { TaxRate } from "./tax-rate"
import { TaxRegion } from "./tax-region"
import { Upload } from "./upload"
import { User } from "./user"
import { Claim } from "./claim"
export class Admin {
public invite: Invite
@@ -61,6 +62,7 @@ export class Admin {
public user: User
public currency: Currency
public payment: Payment
public productVariant: ProductVariant
constructor(client: Client) {
this.invite = new Invite(client)
@@ -93,5 +95,6 @@ export class Admin {
this.user = new User(client)
this.currency = new Currency(client)
this.payment = new Payment(client)
this.productVariant = new ProductVariant(client)
}
}

View File

@@ -0,0 +1,23 @@
import { HttpTypes } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class ProductVariant {
private client: Client
constructor(client: Client) {
this.client = client
}
async list(
queryParams?: HttpTypes.AdminProductVariantParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminProductVariantListResponse>(
`/admin/product-variants`,
{
headers,
query: queryParams,
}
)
}
}

View File

@@ -0,0 +1,17 @@
import { MiddlewareRoute } from "@medusajs/framework"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as QueryConfig from "./query-config"
import { AdminGetProductVariantsParams } from "./validators"
export const adminProductVariantRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/product-variants",
middlewares: [
validateAndTransformQuery(
AdminGetProductVariantsParams,
QueryConfig.listProductVariantQueryConfig
),
],
},
]

View File

@@ -0,0 +1,39 @@
export const defaultAdminProductVariantFields = [
"id",
"title",
"sku",
"barcode",
"ean",
"upc",
"allow_backorder",
"manage_inventory",
"hs_code",
"origin_country",
"mid_code",
"material",
"weight",
"length",
"height",
"width",
"metadata",
"variant_rank",
"product_id",
"created_at",
"updated_at",
"*product",
"*prices",
"*options",
"prices.price_rules.value",
"prices.price_rules.attribute",
]
export const retrieveProductVariantQueryConfig = {
defaults: defaultAdminProductVariantFields,
isList: false,
}
export const listProductVariantQueryConfig = {
...retrieveProductVariantQueryConfig,
defaultLimit: 50,
isList: true,
}

View File

@@ -0,0 +1,39 @@
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework"
import { HttpTypes } from "@medusajs/types"
import { wrapVariantsWithInventoryQuantity } from "../../utils/middlewares"
import { refetchEntities } from "../../utils/refetch-entity"
import { remapKeysForVariant, remapVariantResponse } from "../products/helpers"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse<HttpTypes.AdminProductVariantListResponse>
) => {
const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) =>
field.includes("inventory_quantity")
)
if (withInventoryQuantity) {
req.remoteQueryConfig.fields = req.remoteQueryConfig.fields.filter(
(field) => !field.includes("inventory_quantity")
)
}
const { rows: variants, metadata } = await refetchEntities(
"variant",
{ ...req.filterableFields },
req.scope,
remapKeysForVariant(req.remoteQueryConfig.fields ?? []),
req.remoteQueryConfig.pagination
)
if (withInventoryQuantity) {
await wrapVariantsWithInventoryQuantity(req, variants || [])
}
res.json({
variants: variants.map(remapVariantResponse),
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod"
import { createFindParams, createOperatorMap } from "../../utils/validators"
export type AdminGetProductVariantsParamsType = z.infer<
typeof AdminGetProductVariantsParams
>
export const AdminGetProductVariantsParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
manage_inventory: z.boolean().optional(),
allow_backorder: z.boolean().optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
$and: z.lazy(() => AdminGetProductVariantsParams.array()).optional(),
$or: z.lazy(() => AdminGetProductVariantsParams.array()).optional(),
})
)

View File

@@ -21,6 +21,7 @@ import { adminPricePreferencesRoutesMiddlewares } from "./admin/price-preference
import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares"
import { adminProductTagRoutesMiddlewares } from "./admin/product-tags/middlewares"
import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares"
import { adminProductVariantRoutesMiddlewares } from "./admin/product-variants/middlewares"
import { adminProductRoutesMiddlewares } from "./admin/products/middlewares"
import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares"
import { adminRefundReasonsRoutesMiddlewares } from "./admin/refund-reasons/middlewares"
@@ -110,4 +111,5 @@ export default defineMiddlewares([
...adminClaimRoutesMiddlewares,
...adminRefundReasonsRoutesMiddlewares,
...adminExchangeRoutesMiddlewares,
...adminProductVariantRoutesMiddlewares,
])