diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index af1b57a3c0..87de04868a 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -19,7 +19,6 @@ "is": "is", "select": "Select", "selected": "Selected", - "details": "Details", "enabled": "Enabled", "disabled": "Disabled", "expired": "Expired", @@ -27,6 +26,7 @@ "revoked": "Revoked", "admin": "Admin", "store": "Store", + "details": "Details", "items_one": "{{count}} item", "items_other": "{{count}} items", "countSelected": "{{count}} selected", @@ -50,12 +50,13 @@ "settings": "Settings" }, "actions": { + "save": "Save", "create": "Create", "delete": "Delete", "remove": "Remove", "revoke": "Revoke", "cancel": "Cancel", - "save": "Save", + "back": "Back", "continue": "Continue", "confirm": "Confirm", "edit": "Edit", @@ -239,6 +240,7 @@ "cancelWarning": "You are about to cancel the order {{id}}. This action cannot be undone.", "onDateFromSalesChannel": "{{date}} from {{salesChannel}}", "summary": { + "requestReturn": "Request return", "allocateItems": "Allocate items", "editItems": "Edit items" }, @@ -270,6 +272,28 @@ "newTotal": "New total", "differenceDue": "Difference due" }, + "returns": { + "details": "Details", + "chooseItems": "Choose items", + "refundAmount": "Refund amount", + "locationDescription": "Choose which location you want to return the items to.", + "shippingDescription": "Choose which method you want to use for this return.", + "noInventoryLevel": "No inventory level", + "sendNotification": "Send notification", + "sendNotificationHint": "Notify customer of created return.", + "customRefund": "Custom refund", + "shippingPriceTooltip1": "Custom refund is enabled", + "noShippingOptions": "There are no shipping options for the region", + "shippingPriceTooltip2": "Shipping needs to be selected", + "customRefundHint": "If you want to refund something else instead of the total refund.", + "customShippingPrice": "Custom shipping", + "customShippingPriceHint": "Custom shipping cost.", + "noInventoryLevelDesc": "The selected location does not have an inventory level for the selected items. The return can be requested but can’t be received until an inventory level is created for the selected location.", + "refundableAmountLabel": "Refundable amount", + "refundableAmountHeader": "Refundable Amount", + "returnableQuantityLabel": "Returnable quantity", + "returnableQuantityHeader": "Returnable Quantity" + }, "reservations": { "allocatedLabel": "Allocated", "notAllocatedLabel": "Not allocated" @@ -721,6 +745,8 @@ "limit": "Limit", "tags": "Tags", "type": "Type", + "reason": "Reason", + "note": "Note", "none": "none", "all": "all", "percentage": "Percentage", diff --git a/packages/admin-next/dashboard/src/lib/cast-number.ts b/packages/admin-next/dashboard/src/lib/cast-number.ts new file mode 100644 index 0000000000..e9ef9bf1c0 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/cast-number.ts @@ -0,0 +1,6 @@ +/** + * Helper function to cast a z.union([z.number(), z.string()]) to a number + */ +export const castNumber = (number: number | string) => { + return typeof number === "string" ? Number(number.replace(",", ".")) : number +} diff --git a/packages/admin-next/dashboard/src/lib/rma.ts b/packages/admin-next/dashboard/src/lib/rma.ts new file mode 100644 index 0000000000..399532c67c --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/rma.ts @@ -0,0 +1,75 @@ +import { ClaimItem, LineItem, Order } from "@medusajs/medusa" + +/** + * Return line items that are returnable from an order + * @param order + * @param isClaim + */ +export const getAllReturnableItems = ( + order: Omit, + isClaim: boolean +) => { + let orderItems = order.items.reduce( + (map, obj) => + map.set(obj.id, { + ...obj, + }), + new Map>() + ) + + let claimedItems: ClaimItem[] = [] + + if (order.claims && order.claims.length) { + for (const claim of order.claims) { + if (claim.return_order?.status !== "canceled") { + claim.claim_items = claim.claim_items ?? [] + claimedItems = [...claimedItems, ...claim.claim_items] + } + + if ( + claim.fulfillment_status === "not_fulfilled" && + claim.payment_status === "na" + ) { + continue + } + + if (claim.additional_items && claim.additional_items.length) { + orderItems = claim.additional_items + .filter( + (it) => + it.shipped_quantity || + it.shipped_quantity === it.fulfilled_quantity + ) + .reduce((map, obj) => map.set(obj.id, { ...obj }), orderItems) + } + } + } + + if (!isClaim) { + if (order.swaps && order.swaps.length) { + for (const swap of order.swaps) { + if (swap.fulfillment_status === "not_fulfilled") { + continue + } + + orderItems = swap.additional_items.reduce( + (map, obj) => + map.set(obj.id, { + ...obj, + }), + orderItems + ) + } + } + } + + for (const item of claimedItems) { + const i = orderItems.get(item.item_id) + if (i) { + i.quantity = i.quantity - item.quantity + i.quantity !== 0 ? orderItems.set(i.id, i) : orderItems.delete(i.id) + } + } + + return [...orderItems.values()] +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx index 5cd03c2614..62ba66137d 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx @@ -2,8 +2,8 @@ import type { AdminCollectionsRes, AdminCustomerGroupsRes, AdminCustomersRes, - AdminDraftOrdersRes, AdminDiscountsRes, + AdminDraftOrdersRes, AdminGiftCardsRes, AdminOrdersRes, AdminProductsRes, @@ -124,6 +124,11 @@ export const v1Routes: RouteObject[] = [ path: "edit", lazy: () => import("../../routes/orders/order-edit"), }, + { + path: "returns", + lazy: () => + import("../../routes/orders/order-create-return"), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/constants.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/constants.ts new file mode 100644 index 0000000000..03ec8d5328 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/constants.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +export const CreateReturnSchema = z.object({ + quantity: z.record(z.string(), z.number()), + reason: z.record(z.string(), z.string().optional()), + note: z.record(z.string(), z.string().optional()), + location: z.string(), + shipping: z.string(), + send_notification: z.boolean().optional(), + + enable_custom_refund: z.boolean().optional(), + enable_custom_shipping_price: z.boolean().optional(), + + custom_refund: z.union([z.string(), z.number()]).optional(), + custom_shipping_price: z.union([z.string(), z.number()]).optional(), +}) diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/index.ts new file mode 100644 index 0000000000..4c62883abf --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/index.ts @@ -0,0 +1 @@ +export { OrderCreateReturnForm as CreateReturns } from "./order-create-return-form" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/index.ts new file mode 100644 index 0000000000..58782f5466 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/index.ts @@ -0,0 +1 @@ +export * from "./order-create-return-details" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/order-create-return-details.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/order-create-return-details.tsx new file mode 100644 index 0000000000..b4bf9878f3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/order-create-return-details.tsx @@ -0,0 +1,501 @@ +import { + AdminGetVariantsVariantInventoryRes, + LevelWithAvailability, + LineItem, + Order, +} from "@medusajs/medusa" +import { + Alert, + CurrencyInput, + Heading, + Select, + Switch, + Text, +} from "@medusajs/ui" +import { useAdminShippingOptions, useAdminStockLocations } from "medusa-react" +import { useEffect, useMemo, useState } from "react" +import { Control, UseFormReturn, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as z from "zod" + +import { Form } from "../../../../../../components/common/form" +import { ReturnItem } from "./return-item" + +import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing" +import { MoneyAmountCell } from "../../../../../../components/table/table-cells/common/money-amount-cell" +import { castNumber } from "../../../../../../lib/cast-number" +import { getCurrencySymbol } from "../../../../../../lib/currencies" +import { medusa } from "../../../../../../lib/medusa" +import { getDbAmount } from "../../../../../../lib/money-amount-helpers" +import { CreateReturnSchema } from "../constants" + +type OrderCreateReturnDetailsProps = { + form: UseFormReturn> + items: LineItem[] // Items selected for return + order: Order + onRefundableAmountChange: (amount: number) => void +} + +export function OrderCreateReturnDetails({ + form, + items, + order, + onRefundableAmountChange, +}: OrderCreateReturnDetailsProps) { + const { t } = useTranslation() + + const { currency_code } = order + const { setValue } = form + + const [inventoryMap, setInventoryMap] = useState< + Record + >({}) + + const { + customShippingPrice, + enableCustomRefund, + enableCustomShippingPrice, + quantity, + shipping, + selectedLocation, + } = useWatchFields(form.control) + + const { shipping_options = [], isLoading: isShippingOptionsLoading } = + useAdminShippingOptions({ + region_id: order.region_id, + is_return: true, + }) + + const noShippingOptions = + !isShippingOptionsLoading && !shipping_options.length + + const { stock_locations = [] } = useAdminStockLocations({}) + + useEffect(() => { + const getInventoryMap = async () => { + const ret: Record = {} + + if (!items.length) { + return ret + } + + ;( + await Promise.all( + items.map(async (item) => { + if (!item.variant_id) { + return undefined + } + return await medusa.admin.variants.getInventory(item.variant_id) + }) + ) + ) + .filter((it) => it?.variant) + .forEach((item) => { + const { variant } = item as AdminGetVariantsVariantInventoryRes + const levels = variant.inventory[0]?.location_levels + + if (!levels) { + return + } + + ret[variant.id] = levels + }) + + return ret + } + + getInventoryMap().then((map) => { + setInventoryMap(map) + }) + }, [items]) + + const showLevelsWarning = useMemo(() => { + if (!selectedLocation) { + return false + } + + const allItemsHaveLocation = items + .map((item) => { + if (!item?.variant_id) { + return true + } + return inventoryMap[item.variant_id]?.find( + (l) => l.location_id === selectedLocation + ) + }) + .every(Boolean) + + return !allItemsHaveLocation + }, [items, inventoryMap, selectedLocation]) + + const shippingPrice = useMemo(() => { + if (enableCustomShippingPrice && customShippingPrice) { + const amount = + customShippingPrice === "" ? 0 : castNumber(customShippingPrice) + + return getDbAmount(amount, currency_code) + } + + const method = shipping_options?.find((o) => shipping === o.id) as + | PricedShippingOption + | undefined + + return method?.price_incl_tax || 0 + }, [ + shipping, + customShippingPrice, + enableCustomShippingPrice, + shipping_options, + currency_code, + ]) + + const refundable = useMemo(() => { + const itemTotal = items.reduce((acc: number, curr: LineItem): number => { + const unitRefundable = + (curr.refundable || 0) / (curr.quantity - (curr.returned_quantity || 0)) + + return acc + unitRefundable * quantity[curr.id] + }, 0) + + const amount = itemTotal - (shippingPrice || 0) + onRefundableAmountChange(amount) + + return amount + }, [items, onRefundableAmountChange, quantity, shippingPrice]) + + useEffect(() => { + setValue("enable_custom_shipping_price", false, { + shouldDirty: true, + shouldTouch: true, + }) + setValue("custom_shipping_price", 0, { + shouldDirty: true, + shouldTouch: true, + }) + }, [enableCustomRefund, setValue]) + + return ( +
+
+
+ {t("general.details")} +
+ + {t("orders.returns.chooseItems")} + + {items.map((item) => ( + + ))} + +
+ {t("fields.shipping")} +
+ +
+
+ { + return ( + + {t("fields.location")} + + {t("orders.returns.locationDescription")} + + + + + + + ) + }} + /> +
+
+ { + return ( + + + {t("fields.shipping")} + + + {t("orders.returns.shippingDescription")} + + + + + + + ) + }} + /> +
+
+ {showLevelsWarning && ( + +
+ {t("orders.returns.noInventoryLevel")} +
+ + {t("orders.returns.noInventoryLevelDesc")} + +
+ )} + +
+ + {t("orders.returns.refundAmount")} + +
+ {form.watch("enable_custom_refund") ? ( + - + ) : ( + + )} +
+
+ +
+ { + return ( + +
+ + {t("orders.returns.sendNotification")} + + + + + + +
+ + {t("orders.returns.sendNotificationHint")} + + +
+ ) + }} + /> +
+ +
+ { + return ( + +
+ {t("orders.returns.customRefund")} + + + + + +
+ + {t("orders.returns.customRefundHint")} + + +
+ ) + }} + /> + + {enableCustomRefund && ( +
+ { + return ( + + + + + + + ) + }} + /> +
+ )} +
+ +
+ { + let tooltip = undefined + + if (enableCustomRefund) { + tooltip = t("orders.returns.shippingPriceTooltip1") + } else if (!shipping) { + tooltip = t("orders.returns.shippingPriceTooltip2") + } + + return ( + +
+ + {t("orders.returns.customShippingPrice")} + + + + + + +
+ + {t("orders.returns.customShippingPriceHint")} + + +
+ ) + }} + /> + + {enableCustomShippingPrice && ( +
+ { + return ( + + + + + + + ) + }} + /> +
+ )} +
+
+
+ ) +} + +const useWatchFields = ( + control: Control> +) => { + const enableCustomShippingPrice = useWatch({ + control: control, + name: "enable_custom_shipping_price", + }) + + const enableCustomRefund = useWatch({ + control: control, + name: "enable_custom_refund", + }) + + const quantity = useWatch({ + control: control, + name: "quantity", + }) + + const shipping = useWatch({ + control: control, + name: "shipping", + }) + + const customShippingPrice = useWatch({ + control: control, + name: "custom_shipping_price", + }) + + const selectedLocation = useWatch({ + control: control, + name: "location", + }) + + return { + enableCustomShippingPrice, + enableCustomRefund, + quantity, + shipping, + customShippingPrice, + selectedLocation, + } +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/return-item.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/return-item.tsx new file mode 100644 index 0000000000..4b6794b792 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-details/return-item.tsx @@ -0,0 +1,138 @@ +import { useTranslation } from "react-i18next" + +import { LineItem } from "@medusajs/medusa" +import { Input, Select, Text } from "@medusajs/ui" +import { useAdminReturnReasons } from "medusa-react" +import { UseFormReturn } from "react-hook-form" + +import { Form } from "../../../../../../components/common/form" +import { Thumbnail } from "../../../../../../components/common/thumbnail" +import { MoneyAmountCell } from "../../../../../../components/table/table-cells/common/money-amount-cell" + +type OrderEditItemProps = { + item: LineItem + currencyCode: string + + form: UseFormReturn +} + +function ReturnItem({ item, currencyCode, form }: OrderEditItemProps) { + const { t } = useTranslation() + + const { return_reasons = [] } = useAdminReturnReasons() + + return ( +
+
+
+ +
+
+ + {item.title} + + {item.variant.sku && ({item.variant.sku})} +
+ + {item.variant.title} + +
+
+ +
+ +
+
+ +
+
+
+ + {t("fields.quantity")} + + { + return ( + + + { + const val = e.target.value + field.onChange(val === "" ? null : Number(val)) + }} + /> + + + + ) + }} + /> +
+ +
+ + {t("fields.reason")} + + { + return ( + + + + + + + ) + }} + /> +
+ +
+ + {t("fields.note")} + + { + return ( + + + + + + + ) + }} + /> +
+
+
+
+ ) +} + +export { ReturnItem } diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-form.tsx new file mode 100644 index 0000000000..0466a05cbc --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-form.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { useForm } from "react-hook-form" + +import { + AdminPostOrdersOrderReturnsReq, + LineItem, + Order, +} from "@medusajs/medusa" +import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui" +import { useAdminRequestReturn, useAdminShippingOptions } from "medusa-react" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { OrdersReturnItem } from "@medusajs/medusa/dist/types/orders" +import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing" +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { castNumber } from "../../../../../lib/cast-number" +import { getDbAmount } from "../../../../../lib/money-amount-helpers" +import { getAllReturnableItems } from "../../../../../lib/rma" +import { CreateReturnSchema } from "./constants" +import { OrderCreateReturnDetails } from "./order-create-return-details" +import { CreateReturnItemTable } from "./order-create-return-item-table" + +type OrderCreateReturnsFormProps = { + order: Order +} + +enum Tab { + ITEMS = "items", + DETAILS = "details", +} + +type StepStatus = { + [key in Tab]: ProgressStatus +} + +export function OrderCreateReturnForm({ order }: OrderCreateReturnsFormProps) { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const [selectedItems, setSelectedItems] = useState([]) + const [tab, setTab] = React.useState(Tab.ITEMS) + + const { mutateAsync: requestReturnOrder, isLoading } = useAdminRequestReturn( + order.id + ) + + const { shipping_options = [] } = useAdminShippingOptions({ + region_id: order.region_id, + is_return: true, + }) + + const refundableAmount = useRef(0) + + // List of line items that can be returned with updated quantities + const returnableItems = useMemo(() => getAllReturnableItems(order, false), []) + // Line items that are selected for return + const selected = returnableItems.filter((i) => selectedItems.includes(i.id)) + + const form = useForm>({ + defaultValues: { + // Items not selected so we don't know defaults yet + quantity: {}, + reason: {}, + note: {}, + + location: "", + shipping: "", + send_notification: !order.no_notification, + + enable_custom_refund: false, + enable_custom_shipping_price: false, + + custom_refund: "", + custom_shipping_price: "", + }, + }) + + const { + formState: { isDirty }, + setValue, + } = form + + const onSubmit = form.handleSubmit(async (data) => { + const items = selected.map((item) => { + const ret: OrdersReturnItem = { + item_id: item.id, + quantity: data.quantity[item.id], + } + + if (data.reason[item.id]) { + ret["reason_id"] = data.reason[item.id] + } + + if (data.note[item.id]) { + ret["note"] = data.note[item.id] + } + + return ret + }) + + let refund = refundableAmount.current + + if (data.enable_custom_refund && data.custom_refund) { + const customRefund = + data.custom_refund === "" ? 0 : castNumber(data.custom_refund) + refund = getDbAmount(customRefund, order.currency_code) + } + + const payload: AdminPostOrdersOrderReturnsReq = { + items, + no_notification: !data.send_notification, + refund, + } + + if (data.location) { + payload["location_id"] = data.location + } + + if (data.shipping) { + const option = shipping_options.find((o) => o.id === data.shipping) as + | PricedShippingOption + | undefined + + const taxRate = + option?.tax_rates?.reduce((acc, curr) => { + return acc + (curr.rate || 0) / 100 + }, 0) || 0 + + let price = option?.price_incl_tax + ? Math.round(option.price_incl_tax / (1 + taxRate)) + : 0 + + if (data.enable_custom_shipping_price) { + const customShipping = data.custom_shipping_price + ? castNumber(data.custom_shipping_price) + : 0 + price = getDbAmount(customShipping, order.currency_code) + } + + // TODO: do we send shipping if custom refund is set? + payload["return_shipping"] = { + option_id: data.shipping, + price, + } + } + + await requestReturnOrder(payload) + + handleSuccess(`/orders/${order.id}`) + }) + + const [status, setStatus] = React.useState({ + [Tab.ITEMS]: "not-started", + [Tab.DETAILS]: "not-started", + }) + + const onTabChange = React.useCallback(async (value: Tab) => { + setTab(value) + }, []) + + const onNext = React.useCallback(async () => { + switch (tab) { + case Tab.ITEMS: { + selected.forEach((item) => { + setValue(`quantity.${item.id}`, item.quantity, { + shouldDirty: true, + shouldTouch: true, + }) + setValue(`reason.${item.id}`, "", { + shouldDirty: true, + shouldTouch: true, + }) + setValue(`note.${item.id}`, "", { + shouldDirty: true, + shouldTouch: true, + }) + }) + setTab(Tab.DETAILS) + break + } + case Tab.DETAILS: + await onSubmit() + break + } + }, [tab, selected, setValue, onSubmit]) + + const onSelectionChange = (ids: string[]) => { + setSelectedItems(ids) + + if (ids.length) { + setStatus((prev) => ({ ...prev, [Tab.ITEMS]: "in-progress" })) + } + } + + const onRefundableAmountChange = (amount: number) => { + refundableAmount.current = amount + } + + useEffect(() => { + if (tab === Tab.DETAILS) { + setStatus({ [Tab.ITEMS]: "completed", [Tab.DETAILS]: "not-started" }) + } + }, [tab]) + + useEffect(() => { + if (isDirty) { + setStatus({ [Tab.ITEMS]: "completed", [Tab.DETAILS]: "in-progress" }) + } + }, [isDirty]) + + const canMoveToDetails = selectedItems.length + + return ( + + onTabChange(tab as Tab)} + > + + + + + {t("orders.returns.chooseItems")} + + + + + {t("orders.returns.details")} + + + +
+ + + + +
+
+ + + + + + + + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/index.ts new file mode 100644 index 0000000000..838dd4ccb3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/index.ts @@ -0,0 +1 @@ +export * from "./order-create-return-item-table" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/order-create-return-item-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/order-create-return-item-table.tsx new file mode 100644 index 0000000000..69ebb06043 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/order-create-return-item-table.tsx @@ -0,0 +1,277 @@ +import { LineItem } from "@medusajs/medusa" +import { OnChangeFn, RowSelectionState } from "@tanstack/react-table" +import { useMemo, useState } from "react" + +import { DataTable } from "../../../../../../components/table/data-table/index.ts" +import { useDataTable } from "../../../../../../hooks/use-data-table.tsx" + +import { + DateComparisonOperator, + NumericalComparisonOperator, +} from "@medusajs/types" +import { getPresentationalAmount } from "../../../../../../lib/money-amount-helpers.ts" +import { useReturnItemTableColumns } from "./use-return-item-table-columns.tsx" +import { useReturnItemTableFilters } from "./use-return-item-table-filters.tsx" +import { useReturnItemTableQuery } from "./use-return-item-table-query.tsx" + +const PAGE_SIZE = 50 +const PREFIX = "rit" + +type CreateReturnItemTableProps = { + onSelectionChange: (ids: string[]) => void + selectedItems: string[] + items: LineItem[] + currencyCode: string +} + +export const CreateReturnItemTable = ({ + onSelectionChange, + selectedItems, + items, + currencyCode, +}: CreateReturnItemTableProps) => { + const [rowSelection, setRowSelection] = useState( + selectedItems.reduce((acc, id) => { + acc[id] = true + return acc + }, {} as RowSelectionState) + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + setRowSelection(newState) + onSelectionChange(Object.keys(newState)) + } + + const { searchParams, raw } = useReturnItemTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + + const queriedItems = useMemo(() => { + const { + order, + offset, + limit, + q, + created_at, + updated_at, + refundable_amount, + returnable_quantity, + } = searchParams + + let results: LineItem[] = 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 = useReturnItemTableColumns(currencyCode) + const filters = useReturnItemTableFilters() + + const { table } = useDataTable({ + data: queriedItems as LineItem[], + columns: columns, + count: items.length, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: (row) => { + return row.original.quantity - (row.original.returned_quantity || 0) > 0 + }, + rowSelection: { + state: rowSelection, + updater, + }, + }) + + return ( +
+ +
+ ) +} + +const sortItems = ( + items: LineItem[], + 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: LineItem[], + 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: LineItem[], + 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 = getPresentationalAmount( + 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 + }) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-columns.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-columns.tsx new file mode 100644 index 0000000000..56b886e7b0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-columns.tsx @@ -0,0 +1,103 @@ +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { + ProductCell, + ProductHeader, +} from "../../../../../../components/table/table-cells/product/product-cell" +import { getStylizedAmount } from "../../../../../../lib/money-amount-helpers" + +const columnHelper = createColumnHelper() + +export const useReturnItemTableColumns = (currencyCode: string) => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isSelectable = row.getCanSelect() + + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.display({ + id: "product", + header: () => , + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor("variant.sku", { + header: t("fields.sku"), + cell: ({ getValue }) => { + return getValue() || "-" + }, + }), + columnHelper.accessor("variant.title", { + header: t("fields.variant"), + }), + columnHelper.accessor("quantity", { + header: () => ( +
+ + {t("orders.returns.returnableQuantityHeader")} + +
+ ), + cell: ({ getValue, row }) => { + const returnableQuantity = + getValue() - (row.original.returned_quantity || 0) + + return returnableQuantity + }, + }), + columnHelper.accessor("refundable", { + header: () => ( +
+ + {t("orders.returns.refundableAmountHeader")} + +
+ ), + cell: ({ getValue }) => { + const amount = getValue() || 0 + + const stylized = getStylizedAmount(amount, currencyCode) + + return ( +
+ {stylized} +
+ ) + }, + }), + ], + [t, currencyCode] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-filters.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-filters.tsx new file mode 100644 index 0000000000..d112cd1479 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-filters.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "react-i18next" +import { Filter } from "../../../../../../components/table/data-table" + +export const useReturnItemTableFilters = () => { + 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 +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-query.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-query.tsx new file mode 100644 index 0000000000..92b94a0fc5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/order-create-return-form/order-create-return-item-table/use-return-item-table-query.tsx @@ -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 useReturnItemTableQuery = ({ + 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 } +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/index.ts new file mode 100644 index 0000000000..bcc863f1a1 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/index.ts @@ -0,0 +1 @@ +export { OrderCreateReturn as Component } from "./order-create-return" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/order-create-return.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/order-create-return.tsx new file mode 100644 index 0000000000..80615d1508 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/order-create-return.tsx @@ -0,0 +1,26 @@ +import { useAdminOrder } from "medusa-react" +import { useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/route-modal" +import { CreateReturns } from "./components/order-create-return-form" + +export function OrderCreateReturn() { + const { id } = useParams() + + const { order, isLoading, isError, error } = useAdminOrder(id!, { + expand: + "items,items.variant,items.variant.product,returnable_items,claims,claims.additional_items,claims.return_order,swaps,swaps.additional_items", + }) + + if (isError) { + throw error + } + + const ready = !isLoading && order + + return ( + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx index 8a3d09b182..e4653e43d5 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx @@ -1,4 +1,4 @@ -import { Buildings, PencilSquare } from "@medusajs/icons" +import { Buildings, PencilSquare, ArrowUturnLeft } from "@medusajs/icons" import { LineItem, Order } from "@medusajs/medusa" import { ReservationItemDTO } from "@medusajs/types" import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui" @@ -46,6 +46,11 @@ const Header = ({ order }: { order: Order }) => { to: "#", // TODO: Open modal to allocate items icon: , }, + { + label: t("orders.summary.requestReturn"), + to: `/orders/${order.id}/returns`, + icon: , + }, ], }, ]} diff --git a/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx b/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx index 56c168ef09..a880eb90f7 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/returns/index.tsx @@ -1,10 +1,9 @@ import { AdminGetVariantsVariantInventoryRes, AdminPostOrdersOrderReturnsReq, - InventoryLevelDTO, + LevelWithAvailability, Order, LineItem as RawLineItem, - StockLocationDTO, } from "@medusajs/medusa" import LayeredModal, { LayeredModalContext, @@ -100,7 +99,7 @@ const ReturnMenu: React.FC = ({ order, onDismiss }) => { }, [order.items]) const [inventoryMap, setInventoryMap] = useState< - Map + Map >(new Map()) React.useEffect(() => { @@ -122,7 +121,7 @@ const ReturnMenu: React.FC = ({ order, onDismiss }) => { .filter((it) => !!it) .map((item) => { const { variant } = item as AdminGetVariantsVariantInventoryRes - return [variant.id, variant.inventory[0].location_levels] + return [variant.id, variant.inventory[0]?.location_levels] }) ) }