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:
1 parent
1fb679e785
commit
17567b9f0a
15 files changed
+1574
-4
No files matched your search
@@ -56,7 +56,10 @@ export const useClaims = (
|
|||||||
export const useCreateClaim = (
|
export const useCreateClaim = (
|
||||||
orderId: string,
|
orderId: string,
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
HttpTypes.AdminClaimResponse,
|
{
|
||||||
|
claim: HttpTypes.AdminClaimResponse
|
||||||
|
order: HttpTypes.AdminOrderResponse
|
||||||
|
},
|
||||||
Error,
|
Error,
|
||||||
HttpTypes.AdminCreateClaim
|
HttpTypes.AdminCreateClaim
|
||||||
>
|
>
|
||||||
@@ -75,6 +78,11 @@ export const useCreateClaim = (
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ordersQueryKeys.preview(orderId),
|
queryKey: ordersQueryKeys.preview(orderId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: claimsQueryKeys.lists(),
|
||||||
|
})
|
||||||
|
|
||||||
options?.onSuccess?.(data, variables, context)
|
options?.onSuccess?.(data, variables, context)
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
@@ -103,6 +111,7 @@ export const useCancelClaim = (
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: claimsQueryKeys.details(),
|
queryKey: claimsQueryKeys.details(),
|
||||||
})
|
})
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: claimsQueryKeys.lists(),
|
queryKey: claimsQueryKeys.lists(),
|
||||||
})
|
})
|
||||||
@@ -231,7 +240,7 @@ export const useAddClaimInboundItems = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUpdateClaimInboundItems = (
|
export const useUpdateClaimInboundItem = (
|
||||||
id: string,
|
id: string,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
options?: UseMutationOptions<
|
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": {
|
"reservations": {
|
||||||
"allocatedLabel": "Allocated",
|
"allocatedLabel": "Allocated",
|
||||||
"notAllocatedLabel": "Not allocated"
|
"notAllocatedLabel": "Not allocated"
|
||||||
|
|||||||
@@ -243,6 +243,11 @@ export const RouteMap: RouteObject[] = [
|
|||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/orders/order-create-return"),
|
import("../../routes/orders/order-create-return"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "claims",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/orders/order-create-claim"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "payments/:paymentId/refund",
|
path: "payments/:paymentId/refund",
|
||||||
lazy: () =>
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+275
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./add-claim-items-table"
|
||||||
+98
@@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
@@ -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
|
||||||
|
}
|
||||||
+61
@@ -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 }
|
||||||
|
}
|
||||||
+715
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+247
@@ -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 }
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./claim-create-form"
|
||||||
+23
@@ -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"
|
||||||
+7
-2
@@ -10,15 +10,15 @@ import {
|
|||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
ArrowDownRightMini,
|
ArrowDownRightMini,
|
||||||
ArrowLongRight,
|
|
||||||
ArrowUturnLeft,
|
ArrowUturnLeft,
|
||||||
|
ExclamationCircle,
|
||||||
|
ArrowLongRight,
|
||||||
} from "@medusajs/icons"
|
} from "@medusajs/icons"
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
Copy,
|
Copy,
|
||||||
Heading,
|
Heading,
|
||||||
IconButton,
|
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
Text,
|
Text,
|
||||||
} from "@medusajs/ui"
|
} from "@medusajs/ui"
|
||||||
@@ -150,6 +150,11 @@ const Header = ({ order }: { order: AdminOrder }) => {
|
|||||||
to: `/orders/${order.id}/returns`,
|
to: `/orders/${order.id}/returns`,
|
||||||
icon: <ArrowUturnLeft />,
|
icon: <ArrowUturnLeft />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("orders.claims.create"),
|
||||||
|
to: `/orders/${order.id}/claims`,
|
||||||
|
icon: <ExclamationCircle />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in new issue
Block a user