feat(adshboard,types,medusa): enable adding notes/return reason to inbound claims (#8488)

* wip: setup UI

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

* fix: make form work after merge

* fix: continuation of claim edit

* chore: ability to add and remove items to claim inbound

* chore: minor fixes

* chore: add toast messages on actions

* feat(adshboard,types,medusa): enable adding notes/return reason to inbound items

* chore: fix types in a bunch of places

* chore: add conditional for actions

* Update packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>

---------

Co-authored-by: fPolic <mainacc.polic@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2024-08-07 19:32:07 +02:00
committed by GitHub
parent c017be2a54
commit d50161fa32
13 changed files with 404 additions and 99 deletions

View File

@@ -6,10 +6,10 @@ import {
UseQueryOptions,
} from "@tanstack/react-query"
import { HttpTypes } from "@medusajs/types"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { sdk } from "../../lib/client"
import { HttpTypes } from "@medusajs/types"
const ORDERS_QUERY_KEY = "orders" as const
const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY)
@@ -40,7 +40,12 @@ export const useOrder = (
export const useOrderPreview = (
id: string,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
UseQueryOptions<
HttpTypes.AdminOrderPreviewResponse,
Error,
HttpTypes.AdminOrderPreviewResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {

View File

@@ -886,7 +886,12 @@
"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."
"activeChangeError": "There is an active order change on this order. Please finish or discard the previous change.",
"actions": {
"cancelClaim": {
"successToast": "Claim was successfully canceled."
}
}
},
"reservations": {
"allocatedLabel": "Allocated",

View File

@@ -1,6 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilSquare } from "@medusajs/icons"
import { AdminClaim, AdminOrder, InventoryLevelDTO } from "@medusajs/types"
import {
AdminClaim,
AdminOrder,
AdminOrderPreview,
InventoryLevelDTO,
} from "@medusajs/types"
import {
Alert,
Button,
@@ -46,7 +51,7 @@ import { currencies } from "../../../../../lib/data/currencies"
type ReturnCreateFormProps = {
order: AdminOrder
claim: AdminClaim
preview: AdminOrder
preview: AdminOrderPreview
}
let itemsToAdd: string[] = []
@@ -134,14 +139,14 @@ export const ClaimCreateForm = ({
*/
const previewItems = useMemo(
() =>
preview.items.filter(
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])),
() => new Map(order?.items?.map((i) => [i.id, i])),
[order.items]
)
@@ -161,14 +166,18 @@ export const ClaimCreateForm = ({
)
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_items: previewItems.map((i) => {
const returnAction = i.actions?.find(
(a) => a.action === "RETURN_ITEM"
)
return {
item_id: i.id,
quantity: i.detail.return_requested_quantity,
note: returnAction?.internal_note,
reason_id: returnAction?.details?.reason_id as string | undefined,
}
}),
inbound_option_id: method ? method.shipping_option_id : "",
location_id: "",
send_notification: false,
@@ -188,7 +197,7 @@ export const ClaimCreateForm = ({
})
useEffect(() => {
const existingItemsMap = {}
const existingItemsMap: Record<string, boolean> = {}
previewItems.forEach((i) => {
const ind = items.findIndex((field) => field.item_id === i.id)
@@ -205,7 +214,7 @@ export const ClaimCreateForm = ({
...items[ind],
quantity: i.detail.return_requested_quantity,
note: returnItemAction?.internal_note,
reason_id: returnItemAction?.details?.reason_id,
reason_id: returnItemAction?.details?.reason_id as string,
})
}
} else {
@@ -226,13 +235,13 @@ export const ClaimCreateForm = ({
)
if (method) {
form.setValue("option_id", method.shipping_option_id)
form.setValue("inbound_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 shippingOptionId = form.watch("inbound_option_id")
const handleSubmit = form.handleSubmit(async (data) => {
try {
@@ -242,19 +251,25 @@ export const ClaimCreateForm = ({
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
const onItemsSelected = async () => {
itemsToAdd.length &&
(await addInboundItem({
items: itemsToAdd.map((id) => ({
id,
quantity: 1,
})),
}))
(await addInboundItem(
{
items: itemsToAdd.map((id) => ({
id,
quantity: 1,
})),
},
{
onError: (error) => {
toast.error(error.message)
},
}
))
for (const itemToRemove of itemsToRemove) {
const actionId = previewItems
@@ -262,14 +277,18 @@ export const ClaimCreateForm = ({
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
await removeInboundItem(actionId)
await removeInboundItem(actionId, {
onError: (error) => {
toast.error(error.message)
},
})
}
}
setIsOpen("items", false)
}
const onLocationChange = async (selectedLocationId: string) => {
const onLocationChange = async (selectedLocationId?: string | null) => {
await updateClaimRequest({ location_id: selectedLocationId })
}
@@ -281,12 +300,19 @@ export const ClaimCreateForm = ({
await Promise.all(promises)
await addInboundShipping({ shipping_option_id: selectedOptionId })
await addInboundShipping(
{ shipping_option_id: selectedOptionId },
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
useEffect(() => {
if (isShippingPriceEdit) {
document.getElementById("js-shipping-input").focus()
document.getElementById("js-shipping-input")?.focus()
}
}, [isShippingPriceEdit])
@@ -298,7 +324,7 @@ export const ClaimCreateForm = ({
const allItemsHaveLocation = items
.map((_i) => {
const item = itemsMap.get(_i.item_id)
if (!item?.variant_id) {
if (!item?.variant_id || !item?.variant) {
return true
}
@@ -326,11 +352,12 @@ export const ClaimCreateForm = ({
;(
await Promise.all(
items.map(async (_i) => {
const item = itemsMap.get(_i.item_id)
const item = itemsMap.get(_i.item_id)!
if (!item.variant_id) {
if (!item.variant_id || !item.variant?.product) {
return undefined
}
return await sdk.admin.product.retrieveVariant(
item.variant.product.id,
item.variant_id,
@@ -339,8 +366,9 @@ export const ClaimCreateForm = ({
})
)
)
.filter((it) => it?.variant)
.filter((it) => !!it?.variant)
.forEach((item) => {
const { variant } = item
const levels = variant.inventory[0]?.location_levels
@@ -365,23 +393,30 @@ export const ClaimCreateForm = ({
*/
return () => {
if (IS_CANCELING) {
cancelClaimRequest()
cancelClaimRequest(undefined, {
onSuccess: () => {
toast.success(t("orders.claims.actions.cancelClaim.successToast"))
},
onError: (error) => {
toast.error(error.message)
},
})
// 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
return (method?.total as number) || 0
}, [preview.shipping_methods])
const returnTotal = preview.return_requested_total
const refundAmount = returnTotal - shippingTotal
return (
@@ -473,7 +508,11 @@ export const ClaimCreateForm = ({
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
removeInboundItem(actionId)
removeInboundItem(actionId, {
onError: (error) => {
toast.error(error.message)
},
})
}
}}
onUpdate={(payload) => {
@@ -482,7 +521,14 @@ export const ClaimCreateForm = ({
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id
if (actionId) {
updateInboundItem({ ...payload, actionId })
updateInboundItem(
{ ...payload, actionId },
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
}}
index={index}
@@ -543,16 +589,16 @@ export const ClaimCreateForm = ({
{/*TODO: WHAT IF THE RETURN OPTION HAS COMPUTED PRICE*/}
<Form.Field
control={form.control}
name="option_id"
name="inbound_option_id"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
value={value}
onChange={(v) => {
onChange(v)
onShippingOptionChange(v)
value={value ?? undefined}
onChange={(val) => {
onChange(val)
val && onShippingOptionChange(val)
}}
{...field}
options={(shipping_options ?? [])
@@ -640,13 +686,20 @@ export const ClaimCreateForm = ({
})
if (actionId) {
updateInboundShipping({
actionId,
custom_price:
typeof customShippingAmount === "string"
? null
: customShippingAmount,
})
updateInboundShipping(
{
actionId,
custom_price:
typeof customShippingAmount === "string"
? null
: customShippingAmount,
},
{
onError: (error) => {
toast.error(error.message)
},
}
)
}
setIsShippingPriceEdit(false)
}}
@@ -656,7 +709,7 @@ export const ClaimCreateForm = ({
}
code={order.currency_code}
onValueChange={(value) =>
setCustomShippingAmount(value ? parseInt(value) : "")
value && setCustomShippingAmount(parseInt(value))
}
value={customShippingAmount}
disabled={showPlaceholder}

View File

@@ -1,16 +1,14 @@
import { useTranslation } from "react-i18next"
import React from "react"
import { ChatBubble, DocumentText, XCircle, XMark } from "@medusajs/icons"
import { AdminOrderLineItem, HttpTypes } from "@medusajs/types"
import { IconButton, Input, Text } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { HttpTypes, AdminOrderLineItem } from "@medusajs/types"
import { ChatBubble, DocumentText, XCircle, XMark } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
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 { Form } from "../../../../../components/common/form"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { Combobox } from "../../../../../components/inputs/combobox"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { useReturnReasons } from "../../../../../hooks/api/return-reasons"
type OrderEditItemProps = {
@@ -35,11 +33,9 @@ function ClaimInboundItem({
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"
@@ -48,15 +44,17 @@ function ClaimInboundItem({
<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>}
{item.variant?.sku && <span>({item.variant.sku})</span>}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.variant.product.title}
{item.variant?.product?.title}
</Text>
</div>
</div>
@@ -71,19 +69,18 @@ function ClaimInboundItem({
<Form.Item>
<Form.Control>
<Input
{...field}
className="bg-ui-bg-base txt-small w-[67px] rounded-lg"
min={1}
max={item.quantity}
type="number"
{...field}
onChange={(e) => {
onBlur={(e) => {
const val = e.target.value
const payload = val === "" ? null : Number(val)
field.onChange(payload)
if (payload) {
// todo: move on blur
onUpdate({ quantity: payload })
}
}}
@@ -178,8 +175,9 @@ function ClaimInboundItem({
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`, "")
form.setValue(`inbound_items.${index}.reason_id`, null)
onUpdate({ reason_id: null })
}}
>
<XMark className="text-ui-fg-muted" />
@@ -202,17 +200,17 @@ function ClaimInboundItem({
<div className="flex-grow">
<Form.Field
control={form.control}
name={`items.${index}.note`}
render={({ field: { ref, onChange, ...field } }) => {
name={`inbound_items.${index}.note`}
render={({ field: { ref, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
onChange={onChange}
{...field}
onBlur={() =>
onBlur={() => {
field.onChange(field.value)
onUpdate({ internal_note: field.value })
}
}}
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
/>
</Form.Control>
@@ -222,15 +220,14 @@ function ClaimInboundItem({
}}
/>
</div>
<IconButton
type="button"
className="flex-shrink"
variant="transparent"
onClick={() => {
form.setValue(`items.${index}.note`, {
shouldDirty: true,
shouldTouch: true,
})
form.setValue(`inbound_items.${index}.note`, null)
onUpdate({ internal_note: null })
}}
>

View File

@@ -5,18 +5,19 @@ export const ClaimCreateSchema = z.object({
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(),
reason_id: z.string().nullish(),
note: z.string().nullish(),
})
),
// TODO: Bring back when introducing outbound items
// outbound_items: z.array(
// z.object({
// item_id: z.string(), // TODO: variant id?
// quantity: z.number(),
// })
// ),
location_id: z.string().optional(),
inbound_option_id: z.string(),
inbound_option_id: z.string().nullish(),
send_notification: z.boolean().optional(),
})

View File

@@ -24,7 +24,7 @@ export class Order {
}
async retrievePreview(id: string, headers?: ClientHeaders) {
return await this.client.fetch<{ order: HttpTypes.AdminOrder }>(
return await this.client.fetch<HttpTypes.AdminOrderPreviewResponse>(
`/admin/orders/${id}/preview`,
{
headers,

View File

@@ -17,9 +17,9 @@ interface AdminClaimAddItems {
interface AdminClaimUpdateItem {
quantity?: number
reason_id?: ClaimReason
reason_id?: string | null
description?: string
internal_note?: string
internal_note?: string | null
}
interface AdminClaimAddShippingMethod {
@@ -31,7 +31,7 @@ interface AdminClaimAddShippingMethod {
}
interface AdminClaimUpdateShippingMethod {
custom_price?: number
custom_price?: number | null
internal_note?: string
metadata?: Record<string, unknown> | null
}

View File

@@ -1,5 +1,6 @@
import { GeoZoneType } from "../../../fulfillment"
import { AdminShippingOption } from "../../shipping-option"
import { AdminStockLocation } from "../../stock-locations"
export interface AdminGeoZone {
id: string
@@ -17,6 +18,7 @@ export interface AdminServiceZone {
id: string
name: string
fulfillment_set_id: string
fulfillment_set: AdminFulfillmentSet
geo_zones: AdminGeoZone[]
shipping_options: AdminShippingOption[]
created_at: string
@@ -28,6 +30,7 @@ export interface AdminFulfillmentSet {
id: string
name: string
type: string
location: AdminStockLocation
service_zones: AdminServiceZone[]
created_at: string
updated_at: string

View File

@@ -2,6 +2,8 @@ import { AdminPaymentCollection } from "../payment/admin"
import {
BaseOrder,
BaseOrderAddress,
BaseOrderChange,
BaseOrderChangeAction,
BaseOrderFilters,
BaseOrderLineItem,
BaseOrderShippingMethod,
@@ -45,3 +47,19 @@ export interface AdminCreateOrderShipment {
export interface AdminCancelOrderFulfillment {
no_notification?: boolean
}
// Order Preview
export interface AdminOrderPreview
extends Omit<AdminOrder, "items" | "shipping_methods"> {
return_requested_total: number
order_change: BaseOrderChange
items: (BaseOrderLineItem & { actions?: BaseOrderChangeAction[] })[]
shipping_methods: (BaseOrderShippingMethod & {
actions?: BaseOrderChangeAction[]
})[]
}
export interface AdminOrderPreviewResponse {
order: AdminOrderPreview
}

View File

@@ -1,5 +1,6 @@
import { BaseFilterable, OperatorMap } from "../../dal"
import { BigNumberValue } from "../../totals"
import { BaseClaim } from "../claim/common"
import { BasePaymentCollection } from "../payment/common"
import { BaseProduct, BaseProductVariant } from "../product/common"
@@ -116,7 +117,7 @@ export interface BaseOrderLineItem {
title: string
subtitle: string | null
thumbnail: string | null
variant?: BaseProductVariant
variant?: BaseProductVariant | null
variant_id: string | null
product?: BaseProduct
product_id: string | null
@@ -305,3 +306,224 @@ export interface BaseOrderFilters extends BaseFilterable<BaseOrderFilters> {
id?: string[] | string | OperatorMap<string | string[]>
status?: string[] | string | OperatorMap<string | string[]>
}
export interface BaseOrderChange {
/**
* The ID of the order change
*/
id: string
/**
* The version of the order change
*/
version: number
/**
* The type of the order change
*/
change_type?: "return" | "exchange" | "claim" | "edit"
/**
* The ID of the associated order
*/
order_id: string
/**
* The ID of the associated return order
*/
return_id: string
/**
* The ID of the associated exchange order
*/
exchange_id: string
/**
* The ID of the associated claim order
*/
claim_id: string
/**
* The associated order
*
* @expandable
*/
order: BaseOrder
/**
* The associated return order
*
* @expandable
*/
return_order: any
/**
* The associated exchange order
*
* @expandable
*/
exchange: any
/**
* The associated claim order
*
* @expandable
*/
claim: BaseClaim
/**
* The actions of the order change
*
* @expandable
*/
actions: BaseOrderChangeAction[]
/**
* The status of the order change
*/
status: string
/**
* The requested by of the order change
*/
requested_by: string | null
/**
* When the order change was requested
*/
requested_at: Date | string | null
/**
* The confirmed by of the order change
*/
confirmed_by: string | null
/**
* When the order change was confirmed
*/
confirmed_at: Date | string | null
/**
* The declined by of the order change
*/
declined_by: string | null
/**
* The declined reason of the order change
*/
declined_reason: string | null
/**
* The metadata of the order change
*/
metadata: Record<string, unknown> | null
/**
* When the order change was declined
*/
declined_at: Date | string | null
/**
* The canceled by of the order change
*/
canceled_by: string | null
/**
* When the order change was canceled
*/
canceled_at: Date | string | null
/**
* When the order change was created
*/
created_at: Date | string
/**
* When the order change was updated
*/
updated_at: Date | string
}
/**
* The order change action details.
*/
export interface BaseOrderChangeAction {
/**
* The ID of the order change action
*/
id: string
/**
* The ID of the associated order change
*/
order_change_id: string | null
/**
* The associated order change
*
* @expandable
*/
order_change: BaseOrderChange | null
/**
* The ID of the associated order
*/
order_id: string | null
/**
* The ID of the associated return.
*/
return_id: string | null
/**
* The ID of the associated claim.
*/
claim_id: string | null
/**
* The ID of the associated exchange.
*/
exchange_id: string | null
/**
* The associated order
*
* @expandable
*/
order: BaseOrder | null
/**
* The reference of the order change action
*/
reference: string
/**
* The ID of the reference
*/
reference_id: string
/**
* The action of the order change action
*/
action: string
/**
* The details of the order change action
*/
details: Record<string, unknown> | null
/**
* The internal note of the order change action
*/
internal_note: string | null
/**
* When the order change action was created
*/
created_at: Date | string
/**
* When the order change action was updated
*/
updated_at: Date | string
}

View File

@@ -56,8 +56,8 @@ export interface AdminAddReturnItems {
export interface AdminUpdateReturnItems {
quantity?: number
internal_note?: string
reason_id?: string
internal_note?: string | null
reason_id?: string | null
}
export interface AdminAddReturnShipping {

View File

@@ -86,7 +86,7 @@ export interface UpdateClaimItemWorkflowInput {
action_id: string
data: {
quantity?: BigNumberInput
reason_id?: ClaimReason
reason_id?: string | null
internal_note?: string | null
}
}

View File

@@ -44,6 +44,7 @@ export const AdminPostOrderClaimsReqSchema = z.object({
order_id: z.string(),
description: z.string().optional(),
internal_note: z.string().optional(),
reason_id: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
})
export type AdminPostOrderClaimsReqSchemaType = z.infer<
@@ -173,7 +174,7 @@ export type AdminPostClaimItemsReqSchemaType = z.infer<
export const AdminPostClaimsRequestItemsActionReqSchema = z.object({
quantity: z.number().optional(),
internal_note: z.string().nullish().optional(),
reason: z.nativeEnum(ClaimReason).nullish().optional(),
reason_id: z.string().nullish(),
metadata: z.record(z.unknown()).nullish().optional(),
})
@@ -183,7 +184,7 @@ export type AdminPostClaimsRequestItemsActionReqSchemaType = z.infer<
export const AdminPostClaimsItemsActionReqSchema = z.object({
quantity: z.number().optional(),
reason: z.nativeEnum(ClaimReason).nullish().optional(),
reason_id: z.string().nullish(),
internal_note: z.string().nullish().optional(),
})