feat(dashboard): claims first implementation (#8468)

* wip: setup UI

* wip: rendering modal, adding claim items, create checks

* fix: make form work after merge

* fix: continuation of claim edit

---------

Co-authored-by: fPolic <mainacc.polic@gmail.com>
This commit is contained in:
Riqwan Thamir
2024-08-07 13:40:31 +02:00
committed by GitHub
parent 1fb679e785
commit 17567b9f0a
15 changed files with 1574 additions and 4 deletions

View File

@@ -56,7 +56,10 @@ export const useClaims = (
export const useCreateClaim = (
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminClaimResponse,
{
claim: HttpTypes.AdminClaimResponse
order: HttpTypes.AdminOrderResponse
},
Error,
HttpTypes.AdminCreateClaim
>
@@ -75,6 +78,11 @@ export const useCreateClaim = (
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: claimsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
@@ -103,6 +111,7 @@ export const useCancelClaim = (
queryClient.invalidateQueries({
queryKey: claimsQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: claimsQueryKeys.lists(),
})
@@ -231,7 +240,7 @@ export const useAddClaimInboundItems = (
})
}
export const useUpdateClaimInboundItems = (
export const useUpdateClaimInboundItem = (
id: string,
orderId: string,
options?: UseMutationOptions<

View File

@@ -882,6 +882,12 @@
}
}
},
"claims": {
"create": "Create Claim",
"outbound": "Outbound",
"refundAmount": "Estimated difference",
"activeChangeError": "There is an active order change on this order. Please finish or discard the previous change."
},
"reservations": {
"allocatedLabel": "Allocated",
"notAllocatedLabel": "Not allocated"

View File

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

View File

@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { toast } from "@medusajs/ui"
import { RouteFocusModal } from "../../../components/modals"
import { ClaimCreateForm } from "./components/claim-create-form"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { useClaims, useCreateClaim } from "../../../hooks/api/claims"
import { DEFAULT_FIELDS } from "../order-detail/constants"
let IS_REQUEST_RUNNING = false
export const ClaimCreate = () => {
const { id } = useParams()
const navigate = useNavigate()
const { t } = useTranslation()
const { order } = useOrder(id!, {
fields: DEFAULT_FIELDS,
})
const { order: preview } = useOrderPreview(id!)
const [activeClaimId, setActiveClaimId] = useState()
const { mutateAsync: createClaim } = useCreateClaim(order.id)
// TODO: GET /claims/:id is not implemented
// const { claim } = useClaim(activeClaimId, undefined, {
// enabled: !!activeClaimId,
// })
// TEMP HACK: until the endpoint above is implemented
const { claims } = useClaims(undefined, {
enabled: !!activeClaimId,
limit: 999,
})
const claim = useMemo(() => {
if (claims) {
return claims.find((c) => c.id === activeClaimId)
}
}, [claims, activeClaimId])
useEffect(() => {
async function run() {
if (IS_REQUEST_RUNNING || !preview) {
return
}
if (preview.order_change) {
if (preview.order_change.change_type === "claim") {
setActiveClaimId(preview.order_change.claim_id)
} else {
navigate(`/orders/${preview.id}`, { replace: true })
toast.error(t("orders.claims.activeChangeError"))
}
return
}
IS_REQUEST_RUNNING = true
try {
const { claim } = await createClaim({
order_id: preview.id,
type: "replace",
})
setActiveClaimId(claim.id)
} catch (e) {
navigate(`/orders/${preview.id}`, { replace: true })
toast.error(e.message)
} finally {
IS_REQUEST_RUNNING = false
}
}
run()
}, [preview])
return (
<RouteFocusModal>
{claim && preview && order && (
<ClaimCreateForm order={order} claim={claim} preview={preview} />
)}
</RouteFocusModal>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,715 @@
import React, { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Alert,
Button,
CurrencyInput,
Heading,
IconButton,
Switch,
Text,
toast,
} from "@medusajs/ui"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { AdminClaim, AdminOrder, InventoryLevelDTO } from "@medusajs/types"
import { PencilSquare } from "@medusajs/icons"
import {
RouteFocusModal,
StackedFocusModal,
useRouteModal,
useStackedModal,
} from "../../../../../components/modals"
import { ClaimCreateSchema, ReturnCreateSchemaType } from "./schema"
import { AddClaimItemsTable } from "../add-claim-items-table"
import { Form } from "../../../../../components/common/form"
import { ClaimInboundItem } from "./claim-inbound-item.tsx"
import { Combobox } from "../../../../../components/inputs/combobox"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useShippingOptions } from "../../../../../hooks/api/shipping-options"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { currencies } from "../../../../../lib/data/currencies"
import { sdk } from "../../../../../lib/client"
import {
useAddClaimInboundItems,
useAddClaimInboundShipping,
useDeleteClaimInboundShipping,
useRemoveClaimInboundItem,
useUpdateClaimInboundItem,
useUpdateClaimInboundShipping,
} from "../../../../../hooks/api/claims"
type ReturnCreateFormProps = {
order: AdminOrder
claim: AdminClaim
preview: AdminOrder
}
let selectedItems: string[] = []
let IS_CANCELING = false
export const ClaimCreateForm = ({
order,
preview,
claim,
}: ReturnCreateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
/**
* STATE
*/
const { setIsOpen } = useStackedModal()
const [isShippingPriceEdit, setIsShippingPriceEdit] = useState(false)
const [customShippingAmount, setCustomShippingAmount] = useState(0)
const [inventoryMap, setInventoryMap] = useState<
Record<string, InventoryLevelDTO[]>
>({})
/**
* HOOKS
*/
const { stock_locations = [] } = useStockLocations({ limit: 999 })
const { shipping_options = [] } = useShippingOptions({
limit: 999,
fields: "*prices,+service_zone.fulfillment_set.location.id",
/**
* TODO: this should accept filter for location_id
*/
})
/**
* MUTATIONS
*/
const { mutateAsync: confirmClaimRequest, isPending: isConfirming } = {} // useConfirmClaimRequest(claim.id, order.id)
const { mutateAsync: cancelClaimRequest, isPending: isCanceling } = {} // useCancelClaimRequest(claim.id, order.id)
const { mutateAsync: updateClaimRequest, isPending: isUpdating } = {} // useUpdateClaim(claim.id, order.id)
const {
mutateAsync: addInboundShipping,
isPending: isAddingInboundShipping,
} = useAddClaimInboundShipping(claim.id, order.id)
const {
mutateAsync: updateInboundShipping,
isPending: isUpdatingInboundShipping,
} = useUpdateClaimInboundShipping(claim.id, order.id)
const {
mutateAsync: deleteInboundShipping,
isPending: isDeletingInboundShipping,
} = useDeleteClaimInboundShipping(claim.id, order.id)
const { mutateAsync: addInboundItem, isPending: isAddingInboundItem } =
useAddClaimInboundItems(claim.id, order.id)
const { mutateAsync: updateInboundItem, isPending: isUpdatingInboundItem } =
useUpdateClaimInboundItem(claim.id, order.id)
const { mutateAsync: removeInboundItem, isPending: isRemovingInboundItem } =
useRemoveClaimInboundItem(claim.id, order.id)
const isRequestLoading =
isConfirming ||
isCanceling ||
isAddingInboundShipping ||
isUpdatingInboundShipping ||
isDeletingInboundShipping ||
isAddingInboundItem ||
isRemovingInboundItem ||
isUpdatingInboundItem ||
isUpdating
/**
* Only consider items that belong to this claim.
*/
const previewItems = useMemo(
() =>
preview.items.filter(
(i) => !!i.actions?.find((a) => a.claim_id === claim.id)
),
[preview.items]
)
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>({
defaultValues: () => {
const method = preview.shipping_methods.find(
(s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD")
)
return Promise.resolve({
inbound_items: previewItems.map((i) => ({
item_id: i.id,
quantity: i.detail.return_requested_quantity,
note: i.actions?.find((a) => a.action === "RETURN_ITEM")
?.internal_note,
reason_id: i.actions?.find((a) => a.action === "RETURN_ITEM")?.details
?.reason_id,
})),
inbound_option_id: method ? method.shipping_option_id : "",
location_id: "",
send_notification: false,
})
},
resolver: zodResolver(ClaimCreateSchema),
})
const {
fields: items,
append,
remove,
update,
} = useFieldArray({
name: "inbound_items",
control: form.control,
})
useEffect(() => {
const existingItemsMap = {}
previewItems.forEach((i) => {
const ind = items.findIndex((field) => field.item_id === i.id)
existingItemsMap[i.id] = true
if (ind > -1) {
if (items[ind].quantity !== i.detail.return_requested_quantity) {
const returnItemAction = i.actions?.find(
(a) => a.action === "RETURN_ITEM"
)
update(ind, {
...items[ind],
quantity: i.detail.return_requested_quantity,
note: returnItemAction?.internal_note,
reason_id: returnItemAction?.details?.reason_id,
})
}
} else {
append({ item_id: i.id, quantity: i.detail.return_requested_quantity })
}
})
items.forEach((i, ind) => {
if (!(i.item_id in existingItemsMap)) {
remove(ind)
}
})
}, [previewItems])
useEffect(() => {
const method = preview.shipping_methods.find(
(s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD")
)
if (method) {
form.setValue("option_id", method.shipping_option_id)
}
}, [preview.shipping_methods])
const showPlaceholder = !items.length
const locationId = form.watch("location_id")
const shippingOptionId = form.watch("option_id")
const handleSubmit = form.handleSubmit(async (data) => {
try {
await confirmClaimRequest({ no_notification: !data.send_notification })
handleSuccess()
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
const onItemsSelected = () => {
addInboundItem({
items: selectedItems.map((id) => ({
id,
quantity: 1,
})),
})
setIsOpen("items", false)
}
const onLocationChange = async (selectedLocationId: string) => {
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(deleteInboundShipping)
await Promise.all(promises)
await addInboundShipping({ shipping_option_id: selectedOptionId })
}
useEffect(() => {
if (isShippingPriceEdit) {
document.getElementById("js-shipping-input").focus()
}
}, [isShippingPriceEdit])
const showLevelsWarning = useMemo(() => {
if (!locationId) {
return false
}
const allItemsHaveLocation = items
.map((_i) => {
const item = itemsMap.get(_i.item_id)
if (!item?.variant_id) {
return true
}
if (!item.variant.manage_inventory) {
return true
}
return inventoryMap[item.variant_id]?.find(
(l) => l.location_id === locationId
)
})
.every(Boolean)
return !allItemsHaveLocation
}, [items, inventoryMap, locationId])
useEffect(() => {
const getInventoryMap = async () => {
const ret: Record<string, InventoryLevelDTO[]> = {}
if (!items.length) {
return ret
}
;(
await Promise.all(
items.map(async (_i) => {
const item = itemsMap.get(_i.item_id)
if (!item.variant_id) {
return undefined
}
return await sdk.admin.product.retrieveVariant(
item.variant.product.id,
item.variant_id,
{ fields: "*inventory,*inventory.location_levels" }
)
})
)
)
.filter((it) => it?.variant)
.forEach((item) => {
const { variant } = item
const levels = variant.inventory[0]?.location_levels
if (!levels) {
return
}
ret[variant.id] = levels
})
return ret
}
getInventoryMap().then((map) => {
setInventoryMap(map)
})
}, [items])
useEffect(() => {
/**
* Unmount hook
*/
return () => {
if (IS_CANCELING) {
cancelClaimRequest()
// TODO: add this on ESC press
IS_CANCELING = false
}
}
}, [])
const returnTotal = preview.return_requested_total
const shippingTotal = useMemo(() => {
const method = preview.shipping_methods.find(
(sm) => !!sm.actions?.find((a) => a.action === "SHIPPING_ADD")
)
return method?.total || 0
}, [preview.shipping_methods])
const refundAmount = returnTotal - shippingTotal
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex size-full justify-center overflow-y-auto">
<div className="mt-16 w-[720px] max-w-[100%] px-4 md:p-0">
<Heading level="h1">{t("orders.claims.create")}</Heading>
<div className="mt-8 flex items-center justify-between">
<Heading level="h2">{t("orders.returns.inbound")}</Heading>
<StackedFocusModal id="items">
<StackedFocusModal.Trigger asChild>
<a className="focus-visible:shadow-borders-focus transition-fg txt-compact-small-plus cursor-pointer text-blue-500 outline-none hover:text-blue-400">
{t("actions.addItems")}
</a>
</StackedFocusModal.Trigger>
<StackedFocusModal.Content>
<StackedFocusModal.Header />
<AddClaimItemsTable
items={order.items!}
selectedItems={items.map((i) => i.item_id)}
currencyCode={order.currency_code}
onSelectionChange={(s) => (selectedItems = s)}
/>
<StackedFocusModal.Footer>
<div className="flex w-full items-center justify-end gap-x-4">
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button
type="button"
variant="secondary"
size="small"
>
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
role="button"
onClick={() => onItemsSelected()}
>
{t("actions.save")}
</Button>
</div>
</div>
</StackedFocusModal.Footer>
</StackedFocusModal.Content>
</StackedFocusModal>
</div>
{showPlaceholder && (
<div
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
className="bg-ui-bg-field mt-4 block h-[56px] w-full rounded-lg border border-dashed"
/>
)}
{items.map((item, index) => (
<ClaimInboundItem
key={item.id}
item={itemsMap.get(item.item_id)!}
previewItem={previewItemsMap.get(item.item_id)!}
currencyCode={order.currency_code}
form={form}
onRemove={() => {
const actionId = previewItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
removeInboundItem(actionId)
}
}}
onUpdate={(payload) => {
const actionId = previewItems
.find((i) => i.id === item.item_id)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
updateInboundItem({ ...payload, actionId })
}
}}
index={index}
/>
))}
{!showPlaceholder && (
<div className="mt-8 flex flex-col gap-y-4">
{/*LOCATION*/}
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Form.Label>{t("orders.returns.location")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.locationHint")}
</Form.Hint>
</div>
<Form.Field
control={form.control}
name="location_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
onLocationChange(v)
}}
{...field}
options={(stock_locations ?? []).map(
(stockLocation) => ({
label: stockLocation.name,
value: stockLocation.id,
})
)}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
{/*INBOUND SHIPPING*/}
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Form.Label>
{t("orders.returns.inboundShipping")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.inboundShippingHint")}
</Form.Hint>
</div>
{/*TODO: WHAT IF THE RETURN OPTION HAS COMPUTED PRICE*/}
<Form.Field
control={form.control}
name="option_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
onShippingOptionChange(v)
}}
{...field}
options={(shipping_options ?? [])
.filter(
(so) =>
(locationId
? so.service_zone.fulfillment_set!
.location.id === locationId
: true) &&
!!so.rules.find(
(r) =>
r.attribute === "is_return" &&
r.value === "true"
)
)
.map((so) => ({
label: so.name,
value: so.id,
}))}
disabled={!locationId}
/>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
</div>
)}
{showLevelsWarning && (
<Alert variant="warning" dismissible className="mt-4 p-5">
<div className="text-ui-fg-subtle txt-small pb-2 font-medium leading-[20px]">
{t("orders.returns.noInventoryLevel")}
</div>
<Text className="text-ui-fg-subtle txt-small leading-normal">
{t("orders.returns.noInventoryLevelDesc")}
</Text>
</Alert>
)}
{/*TOTALS SECTION*/}
<div className="mt-8 border-y border-dotted py-4">
<div className="mb-2 flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.returns.returnTotal")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(
returnTotal ? -1 * returnTotal : returnTotal,
order.currency_code
)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.returns.inboundShipping")}
</span>
<span className="txt-small text-ui-fg-subtle flex items-center">
{!isShippingPriceEdit && (
<IconButton
onClick={() => setIsShippingPriceEdit(true)}
variant="transparent"
className="text-ui-fg-muted"
disabled={showPlaceholder || !shippingOptionId}
>
<PencilSquare />
</IconButton>
)}
{isShippingPriceEdit ? (
<CurrencyInput
id="js-shipping-input"
onBlur={() => {
let actionId
preview.shipping_methods.forEach((s) => {
if (s.actions) {
for (let a of s.actions) {
if (a.action === "SHIPPING_ADD") {
actionId = a.id
}
}
}
})
if (actionId) {
updateInboundShipping({
actionId,
custom_price:
typeof customShippingAmount === "string"
? null
: customShippingAmount,
})
}
setIsShippingPriceEdit(false)
}}
symbol={
currencies[order.currency_code.toUpperCase()]
.symbol_native
}
code={order.currency_code}
onValueChange={(value) =>
setCustomShippingAmount(value ? parseInt(value) : "")
}
value={customShippingAmount}
disabled={showPlaceholder}
/>
) : (
getStylizedAmount(shippingTotal, order.currency_code)
)}
</span>
</div>
<div className="mt-4 flex items-center justify-between border-t border-dotted pt-4">
<span className="txt-small font-medium">
{t("orders.claims.refundAmount")}
</span>
<span className="txt-small font-medium">
{getStylizedAmount(
refundAmount ? -1 * refundAmount : refundAmount,
order.currency_code
)}
</span>
</div>
</div>
{/*SEND NOTIFICATION*/}
<div className="bg-ui-bg-field mt-8 rounded-lg border py-2 pl-2 pr-4">
<Form.Field
control={form.control}
name="send_notification"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center">
<Form.Control className="mr-4 self-start">
<Switch
className="mt-[2px]"
checked={!!value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
<div className="block">
<Form.Label>
{t("orders.returns.sendNotification")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.sendNotificationHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex w-full items-center justify-end gap-x-4">
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button
type="button"
onClick={() => (IS_CANCELING = true)}
variant="secondary"
size="small"
>
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
isLoading={isRequestLoading}
>
{t("actions.save")}
</Button>
</div>
</div>
</RouteFocusModal.Footer>
</form>
</RouteFocusModal.Form>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { z } from "zod"
export const ClaimCreateSchema = z.object({
inbound_items: z.array(
z.object({
item_id: z.string(),
quantity: z.number(),
reason_id: z.string().optional().nullable(),
note: z.string().optional().nullable(),
})
),
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(),
send_notification: z.boolean().optional(),
})
export type ReturnCreateSchemaType = z.infer<typeof ClaimCreateSchema>

View File

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

View File

@@ -10,15 +10,15 @@ import {
} from "@medusajs/types"
import {
ArrowDownRightMini,
ArrowLongRight,
ArrowUturnLeft,
ExclamationCircle,
ArrowLongRight,
} from "@medusajs/icons"
import {
Button,
Container,
Copy,
Heading,
IconButton,
StatusBadge,
Text,
} from "@medusajs/ui"
@@ -150,6 +150,11 @@ const Header = ({ order }: { order: AdminOrder }) => {
to: `/orders/${order.id}/returns`,
icon: <ArrowUturnLeft />,
},
{
label: t("orders.claims.create"),
to: `/orders/${order.id}/claims`,
icon: <ExclamationCircle />,
},
],
},
]}