import { zodResolver } from "@hookform/resolvers/zod" import { InformationCircleSolid, PencilSquare } from "@medusajs/icons" import { AdminClaim, AdminInventoryLevel, AdminOrder, AdminOrderPreview, } from "@medusajs/types" import { Alert, Button, CurrencyInput, Heading, IconButton, Switch, Text, toast, Tooltip, usePrompt, } from "@medusajs/ui" import { useEffect, useMemo, useState } from "react" import { useFieldArray, useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { RouteFocusModal, StackedFocusModal, useRouteModal, useStackedModal, } from "../../../../../components/modals" import { Form } from "../../../../../components/common/form" import { Combobox } from "../../../../../components/inputs/combobox" import { useShippingOptions } from "../../../../../hooks/api/shipping-options" import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" import { AddClaimItemsTable } from "../add-claim-items-table" import { ClaimInboundItem } from "./claim-inbound-item.tsx" import { ClaimCreateSchema, CreateClaimSchemaType } from "./schema" import { AdminReturn, HttpTypes } from "@medusajs/types" import { KeyboundForm } from "../../../../../components/utilities/keybound-form/keybound-form.tsx" import { useAddClaimInboundItems, useAddClaimInboundShipping, useCancelClaimRequest, useClaimConfirmRequest, useDeleteClaimInboundShipping, useRemoveClaimInboundItem, useUpdateClaimInboundItem, useUpdateClaimInboundShipping, useUpdateClaimOutboundShipping, } from "../../../../../hooks/api/claims" import { useUpdateOrderChange } from "../../../../../hooks/api/orders" import { useUpdateReturn } from "../../../../../hooks/api/returns" import { sdk } from "../../../../../lib/client" import { currencies } from "../../../../../lib/data/currencies" import { ReturnShippingPlaceholder } from "../../../common/placeholders" import { ClaimOutboundSection } from "./claim-outbound-section" import { ItemPlaceholder } from "./item-placeholder" import { ExtendedVariant } from "../../../../product-variants/product-variant-detail/constants.ts" type ReturnCreateFormProps = { order: AdminOrder claim: AdminClaim preview: AdminOrderPreview orderReturn?: AdminReturn } let itemsToAdd: string[] = [] let itemsToRemove: string[] = [] let IS_CANCELING = false export const ClaimCreateForm = ({ order, preview, claim, orderReturn, }: ReturnCreateFormProps) => { const { t } = useTranslation() const { handleSuccess } = useRouteModal() /** * STATE */ const { setIsOpen } = useStackedModal() const [isShippingInboundPriceEdit, setIsShippingInboundPriceEdit] = useState(false) const [isShippingOutboundPriceEdit, setIsShippingOutboundPriceEdit] = useState(false) const [customInboundShippingAmount, setCustomInboundShippingAmount] = useState<{ value: string; float: number | null }>({ value: "0", float: 0, }) const [customOutboundShippingAmount, setCustomOutboundShippingAmount] = useState<{ value: string; float: number | null }>({ value: "0", float: 0, }) const [inventoryMap, setInventoryMap] = useState< Record >({}) /** * MUTATIONS */ // TODO: implement confirm claim request const { mutateAsync: confirmClaimRequest, isPending: isConfirming } = useClaimConfirmRequest(claim.id, order.id) const { mutateAsync: cancelClaimRequest, isPending: isCanceling } = useCancelClaimRequest(claim.id, order.id) // TODO: implement update claim request const { mutateAsync: updateReturn, isPending: isUpdating } = useUpdateReturn( preview?.order_change?.return_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: updateOutboundShipping, isPending: isUpdatingOutboundShipping, } = useUpdateClaimOutboundShipping(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 { mutateAsync: updateOrderChange } = useUpdateOrderChange( preview?.order_change?.id!, { onError: (error) => { toast.error(error.message) }, } ) const isRequestLoading = isConfirming || isCanceling || isAddingInboundShipping || isUpdatingInboundShipping || isDeletingInboundShipping || isUpdatingOutboundShipping || 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 inboundPreviewItems = previewItems.filter( (item) => !!item.actions?.find((a) => a.action === "RETURN_ITEM") ) const outboundPreviewItems = previewItems.filter( (item) => !!item.actions?.find((a) => a.action === "ITEM_ADD") ) const itemsMap = useMemo( () => new Map(order?.items?.map((i) => [i.id, i])), [order.items] ) const hasPromotions = useMemo(() => { return ( (order as any).promotions && Array.isArray((order as any).promotions) && (order as any).promotions.length > 0 ) }, [order]) /** * FORM */ const form = useForm({ defaultValues: () => { const inboundShippingMethod = preview.shipping_methods.find((s) => { return !!s.actions?.find( (a) => a.action === "SHIPPING_ADD" && !!a.return_id ) }) const outboundShippingMethod = preview.shipping_methods.find((s) => { return !!s.actions?.find( (a) => a.action === "SHIPPING_ADD" && !a.return_id ) }) return Promise.resolve({ inbound_items: inboundPreviewItems.map((i) => { const inboundAction = i.actions?.find( (a) => a.action === "RETURN_ITEM" ) return { item_id: i.id, variant_id: i.variant_id, quantity: i.detail.return_requested_quantity, note: inboundAction?.internal_note, reason_id: inboundAction?.details?.reason_id as string | undefined, } }), outbound_items: outboundPreviewItems.map((i) => ({ item_id: i.id, variant_id: i.variant_id, quantity: i.detail.quantity, })), inbound_option_id: inboundShippingMethod ? inboundShippingMethod.shipping_option_id : "", outbound_option_id: outboundShippingMethod ? outboundShippingMethod.shipping_option_id : "", location_id: orderReturn?.location_id, send_notification: false, carry_over_promotions: preview?.order_change?.carry_over_promotions ?? false, }) }, resolver: zodResolver(ClaimCreateSchema), }) const locationId = form.watch("location_id") /** * HOOKS */ const { stock_locations = [] } = useStockLocations({ limit: 999 }) const { shipping_options = [] } = useShippingOptions( { limit: 999, fields: "*prices,+service_zone.fulfillment_set.location.id", stock_location_id: locationId, }, { enabled: !!locationId, } ) const inboundShippingOptions = shipping_options.filter( (shippingOption) => !!shippingOption.rules.find( (r) => r.attribute === "is_return" && r.value === "true" ) ) const inboundShipping = preview.shipping_methods.find((s) => { return !!s.actions?.find( (a) => a.action === "SHIPPING_ADD" && !!a.return_id ) }) const outboundShipping = preview.shipping_methods.find((s) => { return !!s.actions?.find((a) => a.action === "SHIPPING_ADD" && !a.return_id) }) useEffect(() => { if (inboundShipping) { setCustomInboundShippingAmount({ value: inboundShipping.total.toFixed( currencies[order.currency_code.toUpperCase()].decimal_digits ), float: inboundShipping.total, }) } }, [inboundShipping]) useEffect(() => { if (outboundShipping) { setCustomOutboundShippingAmount({ value: outboundShipping.total.toFixed( currencies[order.currency_code.toUpperCase()].decimal_digits ), float: outboundShipping.total, }) } }, [outboundShipping]) const { fields: inboundItems, append, remove, update, } = useFieldArray({ name: "inbound_items", control: form.control, }) const previewItemsMap = useMemo( () => new Map(previewItems.map((i) => [i.id, i])), [previewItems, inboundItems] ) useEffect(() => { const existingItemsMap: Record = {} inboundPreviewItems.forEach((i) => { const ind = inboundItems.findIndex((field) => field.item_id === i.id) existingItemsMap[i.id] = true if (ind > -1) { if (inboundItems[ind].quantity !== i.detail.return_requested_quantity) { const returnItemAction = i.actions?.find( (a) => a.action === "RETURN_ITEM" ) update(ind, { ...inboundItems[ind], quantity: i.detail.return_requested_quantity, note: returnItemAction?.internal_note, reason_id: returnItemAction?.details?.reason_id as string, }) } } else { append( { item_id: i.id, quantity: i.detail.return_requested_quantity }, { shouldFocus: false } ) } }) inboundItems.forEach((i, ind) => { if (!(i.item_id in existingItemsMap)) { remove(ind) } }) }, [previewItems]) useEffect(() => { const inboundShipping = preview.shipping_methods.find( (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD" && !!a.return_id) ) if (inboundShipping) { form.setValue("inbound_option_id", inboundShipping.shipping_option_id) } else { form.setValue("inbound_option_id", null) } const outboundShipping = preview.shipping_methods.find( (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD" && !a.return_id) ) if (outboundShipping) { form.setValue("outbound_option_id", outboundShipping.shipping_option_id) } else { form.setValue("outbound_option_id", null) } }, [preview.shipping_methods]) useEffect(() => { form.setValue("location_id", orderReturn?.location_id) }, [orderReturn]) const showInboundItemsPlaceholder = !inboundPreviewItems.length const showOutboundItemsPlaceholder = !outboundPreviewItems.length const inboundShippingOptionId = form.watch("inbound_option_id") const outboundShippingOptionId = form.watch("outbound_option_id") const prompt = usePrompt() const handleSubmit = form.handleSubmit(async (data) => { const res = await prompt({ title: t("general.areYouSure"), description: t("orders.claims.confirmText"), confirmText: t("actions.continue"), cancelText: t("actions.cancel"), variant: "confirmation", }) if (!res) { return } await confirmClaimRequest( { no_notification: !data.send_notification }, { onSuccess: () => { toast.success(t("orders.claims.toast.confirmedSuccessfully")) handleSuccess() }, onError: (error) => { toast.error(error.message) }, } ) }) const onItemsSelected = async () => { itemsToAdd.length && (await addInboundItem( { items: itemsToAdd.map((id) => ({ id, quantity: 1, })), }, { onError: (error) => { toast.error(error.message) }, } )) for (const itemToRemove of itemsToRemove) { const actionId = previewItems .find((i) => i.id === itemToRemove) ?.actions?.find((a) => a.action === "RETURN_ITEM")?.id if (actionId) { await removeInboundItem(actionId, { onError: (error) => { toast.error(error.message) }, }) } } setIsOpen("inbound-items", false) } const onLocationChange = async (selectedLocationId?: string | null) => { await updateReturn({ location_id: selectedLocationId }) } const onShippingOptionChange = async ( selectedOptionId: string | undefined ) => { const inboundShippingMethods = preview.shipping_methods.filter((s) => { const action = s.actions?.find( (a) => a.action === "SHIPPING_ADD" && !!a.return_id ) return action && !!action?.return_id }) const promises = inboundShippingMethods .filter(Boolean) .map((inboundShippingMethod) => { const action = inboundShippingMethod.actions?.find( (a) => a.action === "SHIPPING_ADD" && !!a.return_id ) if (action) { return deleteInboundShipping(action.id) } }) await Promise.all(promises) if (selectedOptionId) { await addInboundShipping( { shipping_option_id: selectedOptionId }, { onError: (error) => { toast.error(error.message) }, } ) } } useEffect(() => { if (isShippingInboundPriceEdit) { document.getElementById("js-shipping-inbound-input")?.focus() } }, [isShippingInboundPriceEdit]) useEffect(() => { if (isShippingOutboundPriceEdit) { document.getElementById("js-shipping-outbound-input")?.focus() } }, [isShippingOutboundPriceEdit]) const showLevelsWarning = useMemo(() => { if (!locationId) { return false } const allItemsHaveLocation = inboundItems .map((_i) => { const item = itemsMap.get(_i.item_id) if (!item?.variant_id || !item?.variant) { return true } if (!item.variant?.manage_inventory) { return true } return inventoryMap[item.variant_id]?.find( (l) => l.location_id === locationId ) }) .every(Boolean) return !allItemsHaveLocation }, [inboundItems, inventoryMap, locationId]) useEffect(() => { const getInventoryMap = async () => { const ret: Record = {} if (!inboundItems.length) { return ret } const variantIds = inboundItems .map((item) => item?.variant_id) .filter(Boolean) as string[] const variants = ( await sdk.admin.productVariant.list({ id: variantIds, fields: "*inventory.location_levels", }) ).variants variants.forEach((variant) => { // TODO: fix this for inventory kits ret[variant.id] = (variant as ExtendedVariant).inventory?.[0]?.location_levels || [] }) return ret } getInventoryMap().then((map) => { setInventoryMap(map) }) }, [inboundItems]) useEffect(() => { /** * Unmount hook */ return () => { if (IS_CANCELING) { 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 } } }, []) /** * For estimated difference show pending difference and subtract the total of inbound items (assume all items will be returned correctly) * We don't include inbound total in the pending difference because it will be considered returned when the receive flow is completed */ const estimatedDifference = preview.summary.pending_difference - inboundPreviewItems.reduce((acc, item) => { return acc + item.total }, 0) const inboundShippingTotal = useMemo(() => { const method = preview.shipping_methods.find( (sm) => !!sm.actions?.find((a) => a.action === "SHIPPING_ADD" && !!a.return_id) ) return (method?.total as number) || 0 }, [preview.shipping_methods]) const outboundShippingTotal = useMemo(() => { const method = preview.shipping_methods.find( (sm) => !!sm.actions?.find((a) => a.action === "SHIPPING_ADD" && !a.return_id) ) return (method?.total as number) || 0 }, [preview.shipping_methods]) return (
{t("orders.claims.create")}
{t("orders.returns.inbound")} {t("actions.addItems")} i.item_id)} currencyCode={order.currency_code} onSelectionChange={(finalSelection) => { const alreadySelected = inboundItems.map((i) => i.item_id) itemsToAdd = finalSelection.filter( (selection) => !alreadySelected.includes(selection) ) itemsToRemove = alreadySelected.filter( (selection) => !finalSelection.includes(selection) ) }} />
{showInboundItemsPlaceholder && } {inboundItems.map( (item, index) => previewItemsMap.get(item.item_id) && itemsMap.get(item.item_id)! && ( { const actionId = previewItems .find((i) => i.id === item.item_id) ?.actions?.find((a) => a.action === "RETURN_ITEM")?.id if (actionId) { removeInboundItem(actionId, { onError: (error) => { toast.error(error.message) }, }) } }} onUpdate={(payload: HttpTypes.AdminUpdateReturnItems) => { const action = previewItems .find((i) => i.id === item.item_id) ?.actions?.find((a) => a.action === "RETURN_ITEM") if (action) { updateInboundItem( { ...payload, actionId: action.id }, { onError: (error) => { if ( action.details?.quantity && payload.quantity ) { form.setValue( `inbound_items.${index}.quantity`, action.details?.quantity as number ) } toast.error(error.message) }, } ) } }} index={index} /> ) )} {!showInboundItemsPlaceholder && (
{/* LOCATION*/}
{t("orders.returns.location")} {t("orders.returns.locationHint")}
{ return ( { onChange(v) onLocationChange(v) }} options={(stock_locations ?? []).map( (stockLocation) => ({ label: stockLocation.name, value: stockLocation.id, }) )} /> ) }} />
{/* INBOUND SHIPPING*/}
{t("orders.returns.inboundShipping")} ({t("fields.optional")}) {t("orders.returns.inboundShippingHint")}
{ return ( { onChange(val) onShippingOptionChange(val) }} {...field} options={inboundShippingOptions.map((so) => ({ label: so.name, value: so.id, }))} disabled={!locationId} noResultsPlaceholder={ } /> ) }} />
)} {showLevelsWarning && (
{t("orders.returns.noInventoryLevel")}
{t("orders.returns.noInventoryLevelDesc")}
)} {/* TOTALS SECTION*/}
{t("orders.returns.inboundTotal")} {getStylizedAmount( inboundPreviewItems.reduce((acc, item) => { const action = item.actions?.find( (act) => act.action === "RETURN_ITEM" ) // `RETURN_ITEM` action has amount acc = acc + Number((action as any)?.amount || 0) return acc }, 0) * -1, order.currency_code )}
{t("orders.claims.outboundTotal")} {getStylizedAmount( outboundPreviewItems.reduce((acc, item) => { const action = item.actions?.find( (act) => act.action === "ITEM_ADD" ) // `ITEM_ADD` action has amount acc = acc + Number((action as any)?.amount || 0) return acc }, 0), order.currency_code )}
{t("orders.returns.inboundShipping")} {!isShippingInboundPriceEdit && ( setIsShippingInboundPriceEdit(true)} variant="transparent" className="text-ui-fg-muted" disabled={ showInboundItemsPlaceholder || !inboundShippingOptionId } > )} {isShippingInboundPriceEdit ? ( { let actionId preview.shipping_methods.forEach((s) => { if (s.actions) { for (const a of s.actions) { if ( a.action === "SHIPPING_ADD" && !!a.return_id ) { actionId = a.id } } } }) const customPrice = customInboundShippingAmount.float if (actionId) { updateInboundShipping( { actionId, custom_amount: customPrice, }, { onError: (error) => { toast.error(error.message) }, } ) } setIsShippingInboundPriceEdit(false) }} symbol={ currencies[order.currency_code.toUpperCase()] .symbol_native } code={order.currency_code} onValueChange={(_value, _name, values) => { setCustomInboundShippingAmount({ value: values?.value ?? "", float: values?.float ?? null, }) }} value={customInboundShippingAmount.value} disabled={showInboundItemsPlaceholder} /> ) : ( getStylizedAmount(inboundShippingTotal, order.currency_code) )}
{t("orders.claims.outboundShipping")} {!isShippingOutboundPriceEdit && ( setIsShippingOutboundPriceEdit(true)} variant="transparent" className="text-ui-fg-muted" disabled={ showOutboundItemsPlaceholder || !outboundShippingOptionId } > )} {isShippingOutboundPriceEdit ? ( { let actionId preview.shipping_methods.forEach((s) => { if (s.actions) { for (const a of s.actions) { if (a.action === "SHIPPING_ADD" && !a.return_id) { actionId = a.id } } } }) const customPrice = customOutboundShippingAmount.float if (actionId) { updateOutboundShipping( { actionId, custom_amount: customPrice, }, { onError: (error) => { toast.error(error.message) }, } ) } setIsShippingOutboundPriceEdit(false) }} symbol={ currencies[order.currency_code.toUpperCase()] .symbol_native } code={order.currency_code} onValueChange={(_value, _name, values) => { setCustomOutboundShippingAmount({ value: values?.value ?? "", float: values?.float ?? null, }) }} value={customOutboundShippingAmount.value} disabled={showOutboundItemsPlaceholder} /> ) : ( getStylizedAmount( outboundShippingTotal, order.currency_code ) )}
{t("orders.claims.refundAmount")} {getStylizedAmount(estimatedDifference, order.currency_code)}
{/* CARRY OVER PROMOTION */} {hasPromotions && (
{ return (
{ onChange(checked) if (preview?.order_change?.id) { await updateOrderChange({ carry_over_promotions: checked, }) } }} {...field} />
{t("orders.claims.carryOverPromotion")} {t("orders.claims.carryOverPromotionHint")}
) }} />
)} {/* SEND NOTIFICATION*/}
{ return (
{t("orders.returns.sendNotification")} {t("orders.returns.sendNotificationHint")}
) }} />
) }