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:
@@ -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<
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./add-claim-items-table"
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./claim-create-form"
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { ClaimCreate as Component } from "./claim-create"
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user