diff --git a/packages/admin-next/dashboard/src/components/common/link-button/link-button.tsx b/packages/admin-next/dashboard/src/components/common/link-button/link-button.tsx index 16ad64597d..cd5ed5bf2d 100644 --- a/packages/admin-next/dashboard/src/components/common/link-button/link-button.tsx +++ b/packages/admin-next/dashboard/src/components/common/link-button/link-button.tsx @@ -14,7 +14,7 @@ export const LinkButton = ({ return ( , + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: async () => sdk.admin.order.retrievePreview(id), + queryKey: ordersQueryKeys.preview(id), + ...options, + }) + + return { ...data, ...rest } +} + export const useOrders = ( query?: Record, options?: Omit< @@ -49,10 +71,14 @@ export const useOrders = ( export const useCreateOrderFulfillment = ( orderId: string, - options?: UseMutationOptions + options?: UseMutationOptions< + HttpTypes.AdminOrderResponse, + Error, + HttpTypes.AdminCreateOrderFulfillment + > ) => { return useMutation({ - mutationFn: (payload: any) => + mutationFn: (payload: HttpTypes.AdminCreateOrderFulfillment) => sdk.admin.order.createFulfillment(orderId, payload), onSuccess: (data: any, variables: any, context: any) => { queryClient.invalidateQueries({ diff --git a/packages/admin-next/dashboard/src/hooks/api/return-reasons.tsx b/packages/admin-next/dashboard/src/hooks/api/return-reasons.tsx new file mode 100644 index 0000000000..df47080ff6 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/return-reasons.tsx @@ -0,0 +1,29 @@ +import { HttpTypes } from "@medusajs/types" +import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query" + +import { sdk } from "../../lib/client" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const RETURN_REASONS_QUERY_KEY = "return_reasons" as const +export const returnReasonQueryKeys = queryKeysFactory(RETURN_REASONS_QUERY_KEY) + +export const useReturnReasons = ( + query?: HttpTypes.AdminReturnReasonListParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminReturnReasonsResponse, + Error, + HttpTypes.AdminReturnReasonsResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.returnReason.list(query), + queryKey: returnReasonQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} diff --git a/packages/admin-next/dashboard/src/hooks/api/returns.tsx b/packages/admin-next/dashboard/src/hooks/api/returns.tsx new file mode 100644 index 0000000000..7c2359070b --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/returns.tsx @@ -0,0 +1,284 @@ +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions, +} from "@tanstack/react-query" +import { HttpTypes } from "@medusajs/types" + +import { sdk } from "../../lib/client" +import { queryClient } from "../../lib/query-client" +import { ordersQueryKeys } from "./orders" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const RETURNS_QUERY_KEY = "returns" as const +export const returnsQueryKeys = queryKeysFactory(RETURNS_QUERY_KEY) + +export const useReturn = ( + id: string, + query?: Record, + options?: Omit< + UseQueryOptions, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: async () => sdk.admin.return.retrieve(id, query), + queryKey: returnsQueryKeys.detail(id, query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useReturns = ( + query?: HttpTypes.AdminReturnFilters, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminReturnFilters, + Error, + HttpTypes.AdminReturnsResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: async () => sdk.admin.return.list(query), + queryKey: returnsQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useInitiateReturn = ( + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminInitiateReturnRequest + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminInitiateReturnRequest) => + sdk.admin.return.initiateRequest(payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.lists(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useAddReturnItem = ( + id: string, + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminAddReturnItems + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminAddReturnItems) => + sdk.admin.return.addReturnItem(id, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateReturnItem = ( + id: string, + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminUpdateReturnItems & { actionId: string } + > +) => { + return useMutation({ + mutationFn: ({ + actionId, + ...payload + }: HttpTypes.AdminUpdateReturnItems & { actionId: string }) => { + return sdk.admin.return.updateReturnItem(id, actionId, payload) + }, + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useRemoveReturnItem = ( + id: string, + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminAddReturnItems + > +) => { + return useMutation({ + mutationFn: (actionId: string) => + sdk.admin.return.removeReturnItem(id, actionId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useAddReturnShipping = ( + id: string, + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminAddReturnShipping + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminAddReturnShipping) => + sdk.admin.return.addReturnShipping(id, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateReturnShipping = ( + id: string, + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminAddReturnShipping + > +) => { + return useMutation({ + mutationFn: ({ + actionId, + ...payload + }: HttpTypes.AdminAddReturnShipping & { actionId: string }) => + sdk.admin.return.updateReturnShipping(id, actionId, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteReturnShipping = ( + id: string, + orderId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (actionId: string) => + sdk.admin.return.deleteReturnShipping(id, actionId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useConfirmReturnRequest = ( + id: string, + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminReturnResponse, + Error, + HttpTypes.AdminConfirmReturnRequest + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminConfirmReturnRequest) => + sdk.admin.return.confirmRequest(id, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.lists(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + + queryClient.invalidateQueries({ + queryKey: returnsQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: returnsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useCancelReturnRequest = ( + id: string, + orderId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => sdk.admin.return.cancelRequest(id), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.lists(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + + queryClient.invalidateQueries({ + queryKey: returnsQueryKeys.details(), + }) + queryClient.invalidateQueries({ + queryKey: returnsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index fbff88c9a4..455e1f30e8 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -107,9 +107,12 @@ "close": "Close", "showMore": "Show more", "continue": "Continue", + "addReason": "Add Reason", + "addNote": "Add Note", "reset": "Reset", "confirm": "Confirm", "edit": "Edit", + "addItems": "Add items", "download": "Download", "clearAll": "Clear all", "apply": "Apply", @@ -772,6 +775,7 @@ "requiresAction": "Requires action" } }, + "edits": { "title": "Edit order", "currentItems": "Current items", @@ -783,26 +787,25 @@ "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", + "create": "Create Return", + "inbound": "Inbound", "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.", + "sendNotificationHint": "Notify customer about return.", + "returnTotal": "Return total", + "refundAmount": "Refund amount", + "reason": "Reason", + "reasonHint": "Choose why the customer want to return items.", + "note": "Note", + "noInventoryLevel": "No inventory level", "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", + "noteHint": "You can type freely if you want to specify something.", + "location": "Location", + "locationHint": "Choose which location you want to return the items to.", + "inboundShipping": "Inbound shipping", + "inboundShippingHint": "Choose which method you want to use.", "returnableQuantityLabel": "Returnable quantity", - "returnableQuantityHeader": "Returnable Quantity" + "refundableAmountLabel": "Refundable amount", + "returnRequestedInfo": "{{requestedItemsCount}}x item return requested" }, "reservations": { "allocatedLabel": "Allocated", @@ -922,7 +925,9 @@ "items_other": "{{count}} items" }, "return": { - "created": "Return created" + "created": "Return created", + "items_one": "{{count}} item returned", + "items_other": "{{count}} items returned" }, "note": { "comment": "Comment", @@ -2256,6 +2261,7 @@ "inStock": "In stock", "location": "Location", "quantity": "Quantity", + "qty": "Qty", "variant": "Variant", "id": "ID", "parent": "Parent", diff --git a/packages/admin-next/dashboard/src/lib/rma.ts b/packages/admin-next/dashboard/src/lib/rma.ts index 399532c67c..9108fd73fa 100644 --- a/packages/admin-next/dashboard/src/lib/rma.ts +++ b/packages/admin-next/dashboard/src/lib/rma.ts @@ -1,75 +1,15 @@ -import { ClaimItem, LineItem, Order } from "@medusajs/medusa" +import { AdminOrderLineItem } from "@medusajs/types" -/** - * 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>() +export function getReturnableQuantity(item: AdminOrderLineItem): number { + const { + // TODO: this should be `fulfilled_quantity`? now there is check on the BD that we can't return more quantity than we shipped but some items don't require shipping + shipped_quantity, + return_received_quantity, + return_dismissed_quantity, // TODO: check this + return_requested_quantity, + } = item.detail + + return ( + shipped_quantity - (return_received_quantity + return_requested_quantity) ) - - 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/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index f579d9f02b..b732de4118 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -227,6 +227,11 @@ export const RouteMap: RouteObject[] = [ lazy: () => import("../../routes/orders/order-create-shipment"), }, + { + path: "returns", + lazy: () => + import("../../routes/orders/order-create-return"), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/add-return-items-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/add-return-items-table.tsx new file mode 100644 index 0000000000..705b93f54a --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/add-return-items-table.tsx @@ -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 { useReturnItemTableColumns } from "./use-return-item-table-columns" +import { useReturnItemTableFilters } from "./use-return-item-table-filters" +import { useReturnItemTableQuery } from "./use-return-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 AddReturnItemsTable = ({ + onSelectionChange, + selectedItems, + items, + currencyCode, +}: AddReturnItemsTableProps) => { + 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: 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 = useReturnItemTableColumns(currencyCode) + const filters = useReturnItemTableFilters() + + 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 ( +
+ +
+ ) +} + +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 + }) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/index.ts new file mode 100644 index 0000000000..2d6fc95d41 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/index.ts @@ -0,0 +1 @@ +export * from "./add-return-items-table" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/use-return-item-table-columns.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/use-return-item-table-columns.tsx new file mode 100644 index 0000000000..29366917a0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/use-return-item-table-columns.tsx @@ -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() + +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("fields.quantity")} +
+ ), + cell: ({ getValue, row }) => { + return getReturnableQuantity(row.original) + }, + }), + columnHelper.accessor("refundable_total", { + header: () => ( +
+ {t("fields.price")} +
+ ), + 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/add-return-items-table/use-return-item-table-filters.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/use-return-item-table-filters.tsx new file mode 100644 index 0000000000..94b07c777a --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/use-return-item-table-filters.tsx @@ -0,0 +1,32 @@ +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/add-return-items-table/use-return-item-table-query.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-table/use-return-item-table-query.tsx new file mode 100644 index 0000000000..422dfb53d9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/add-return-items-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/components/return-create-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/index.ts new file mode 100644 index 0000000000..da04d4fb07 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/index.ts @@ -0,0 +1 @@ +export * from "./return-create-form" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/return-create-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/return-create-form.tsx new file mode 100644 index 0000000000..1b92a6b473 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/return-create-form.tsx @@ -0,0 +1,709 @@ +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 { AdminOrder, InventoryLevelDTO, ReturnDTO } from "@medusajs/types" +import { PencilSquare } from "@medusajs/icons" + +import { + RouteFocusModal, + StackedFocusModal, + useRouteModal, + useStackedModal, +} from "../../../../../components/modals" + +import { ReturnCreateSchema, ReturnCreateSchemaType } from "./schema" +import { AddReturnItemsTable } from "../add-return-items-table" +import { Form } from "../../../../../components/common/form" +import { ReturnItem } from "./return-item" +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 { + useAddReturnItem, + useAddReturnShipping, + useCancelReturnRequest, + useConfirmReturnRequest, + useDeleteReturnShipping, + useRemoveReturnItem, + useUpdateReturnItem, + useUpdateReturnShipping, +} from "../../../../../hooks/api/returns" +import { currencies } from "../../../../../lib/data/currencies" +import { sdk } from "../../../../../lib/client" + +type ReturnCreateFormProps = { + order: AdminOrder + activeReturn: ReturnDTO // TODO: AdminReturn + preview: AdminOrder // TODO +} + +let selectedItems: string[] = [] + +let IS_CANCELING = false + +export const ReturnCreateForm = ({ + order, + preview, + activeReturn, +}: 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 + >({}) + + /** + * 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: confirmReturnRequest, isPending: isConfirming } = + useConfirmReturnRequest(activeReturn.id, order.id) + + const { mutateAsync: cancelReturnRequest, isPending: isCanceling } = + useCancelReturnRequest(activeReturn.id, order.id) + + const { mutateAsync: addReturnShipping, isPending: isAddingReturnShipping } = + useAddReturnShipping(activeReturn.id, order.id) + + const { + mutateAsync: updateReturnShipping, + isPending: isUpdatingReturnShipping, + } = useUpdateReturnShipping(activeReturn.id, order.id) + + const { + mutateAsync: deleteReturnShipping, + isPending: isDeletingReturnShipping, + } = useDeleteReturnShipping(activeReturn.id, order.id) + + const { mutateAsync: addReturnItem, isPending: isAddingReturnItem } = + useAddReturnItem(activeReturn.id, order.id) + + const { mutateAsync: removeReturnItem, isPending: isRemovingReturnItem } = + useRemoveReturnItem(activeReturn.id, order.id) + + const { mutateAsync: updateReturnItem, isPending: isUpdatingReturnItem } = + useUpdateReturnItem(activeReturn.id, order.id) + + const isRequestLoading = + isConfirming || + isCanceling || + isAddingReturnShipping || + isUpdatingReturnShipping || + isDeletingReturnShipping || + isAddingReturnItem || + isRemovingReturnItem || + isUpdatingReturnItem + + /** + * FORM + */ + + const form = useForm({ + /** + * TODO: reason selection once Return reason settings are added + */ + defaultValues: () => { + const method = preview.shipping_methods.find( + (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD") + ) + + return Promise.resolve({ + items: preview.items + .filter((i) => !!i.detail.return_requested_quantity) + .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, + })), + option_id: method ? method.shipping_option_id : "", + location_id: "", + send_notification: false, + }) + }, + resolver: zodResolver(ReturnCreateSchema), + }) + + const itemsMap = useMemo( + () => new Map(order.items.map((i) => [i.id, i])), + [order.items] + ) + + const previewItemsMap = useMemo( + () => new Map(preview.items.map((i) => [i.id, i])), + [preview.items] + ) + + const { + fields: items, + append, + remove, + update, + } = useFieldArray({ + name: "items", + control: form.control, + }) + + useEffect(() => { + const existingItemsMap = {} + + preview.items.forEach((i) => { + const ind = items.findIndex((field) => field.item_id === i.id) + + /** + * THESE ITEMS ARE REMOVED FROM RETURN REQUEST + */ + if (!i.detail.return_requested_quantity) { + return + } + + 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) + } + }) + }, [preview.items]) + + 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 confirmReturnRequest({ no_notification: !data.send_notification }) + + handleSuccess() + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + }) + + const onItemsSelected = () => { + addReturnItem({ + items: selectedItems.map((id) => ({ + id, + quantity: 1, + })), + }) + + setIsOpen("items", false) + } + + const onShippingOptionChange = async (selectedOptionId: string) => { + const promises = preview.shipping_methods + .map((s) => s.actions?.find((a) => a.action === "SHIPPING_ADD")?.id) + .filter(Boolean) + .map(deleteReturnShipping) + + await Promise.all(promises) + + await addReturnShipping({ 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 = {} + + 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) { + cancelReturnRequest() + // 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 ( + +
+ + + +
+ {t("orders.returns.create")} +
+ {t("orders.returns.inbound")} + + + + {t("actions.addItems")} + + + + + + i.item_id)} + currencyCode={order.currency_code} + onSelectionChange={(s) => (selectedItems = s)} + /> + +
+
+ + + + +
+
+
+
+
+
+ {showPlaceholder && ( +
+ )} + {items.map((item, index) => ( + { + const actionId = preview.items + .find((i) => i.id === item.item_id) + ?.actions?.find((a) => a.action === "RETURN_ITEM")?.id + + if (actionId) { + removeReturnItem(actionId) + } + }} + onUpdate={(payload) => { + const actionId = preview.items + .find((i) => i.id === item.item_id) + ?.actions?.find((a) => a.action === "RETURN_ITEM")?.id + + if (actionId) { + updateReturnItem({ ...payload, actionId }) + } + }} + index={index} + /> + ))} + {!showPlaceholder && ( +
+ {/*LOCATION*/} +
+
+ {t("orders.returns.location")} + + {t("orders.returns.locationHint")} + +
+ + { + return ( + + + { + onChange(v) + }} + {...field} + options={(stock_locations ?? []).map( + (stockLocation) => ({ + label: stockLocation.name, + value: stockLocation.id, + }) + )} + /> + + + ) + }} + /> +
+ + {/*INBOUND SHIPPING*/} +
+
+ + {t("orders.returns.inboundShipping")} + + + {t("orders.returns.inboundShippingHint")} + +
+ + {/*TODO: WHAT IF THE RETURN OPTION HAS COMPUTED PRICE*/} + { + return ( + + + { + 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} + /> + + + ) + }} + /> +
+
+ )} + + {showLevelsWarning && ( + +
+ {t("orders.returns.noInventoryLevel")} +
+ + {t("orders.returns.noInventoryLevelDesc")} + +
+ )} + + {/*TOTALS SECTION*/} +
+
+ + {t("orders.returns.returnTotal")} + + + {getStylizedAmount( + returnTotal ? -1 * returnTotal : returnTotal, + order.currency_code + )} + +
+ +
+ + {t("orders.returns.inboundShipping")} + + + {!isShippingPriceEdit && ( + setIsShippingPriceEdit(true)} + variant="transparent" + className="text-ui-fg-muted" + disabled={showPlaceholder || !shippingOptionId} + > + + + )} + {isShippingPriceEdit ? ( + { + 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) { + updateReturnShipping({ + 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) + )} + +
+ +
+ + {t("orders.returns.refundAmount")} + + + {getStylizedAmount( + refundAmount ? -1 * refundAmount : refundAmount, + order.currency_code + )} + +
+
+ + {/*SEND NOTIFICATION*/} +
+ { + return ( + +
+ + + +
+ + {t("orders.returns.sendNotification")} + + + {t("orders.returns.sendNotificationHint")} + +
+
+ +
+ ) + }} + /> +
+
+ + +
+
+ + + + +
+
+
+ + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/return-item.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/return-item.tsx new file mode 100644 index 0000000000..b7a5d61421 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/return-item.tsx @@ -0,0 +1,246 @@ +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 +} + +function ReturnItem({ + item, + previewItem, + currencyCode, + form, + onRemove, + onUpdate, + index, +}: OrderEditItemProps) { + const { t } = useTranslation() + + const { return_reasons = [] } = useReturnReasons({ fields: "+label" }) + + const formItem = form.watch(`items.${index}`) + + const showReturnReason = typeof formItem.reason_id === "string" + const showNote = typeof formItem.note === "string" + + return ( +
+
+
+ +
+
+ + {item.title}{" "} + + {item.variant.sku && ({item.variant.sku})} +
+ + {item.variant.product.title} + +
+
+ +
+
+ { + return ( + + + { + const val = e.target.value + const payload = val === "" ? null : Number(val) + + field.onChange(payload) + + if (payload) { + // todo: move on blur + onUpdate({ quantity: payload }) + } + }} + /> + + + + ) + }} + /> + + {t("fields.qty")} + +
+ +
+ +
+ + + form.setValue(`items.${index}.reason_id`, ""), + icon: , + }, + !showNote && { + label: t("actions.addNote"), + onClick: () => form.setValue(`items.${index}.note`, ""), + icon: , + }, + { + label: t("actions.remove"), + onClick: onRemove, + icon: , + }, + ].filter(Boolean), + }, + ]} + /> +
+
+ <> + {/*REASON*/} + {showReturnReason && ( +
+
+ {t("orders.returns.reason")} + + {t("orders.returns.reasonHint")} + +
+ +
+
+ { + return ( + + + { + onUpdate({ reason_id: v }) + onChange(v) + }} + {...field} + options={return_reasons.map((reason) => ({ + label: reason.label, + value: reason.id, + }))} + /> + + + + ) + }} + /> +
+ { + onUpdate({ reason_id: null }) // TODO BE: we should be able to set to unset reason here + form.setValue(`items.${index}.reason_id`, "") + }} + > + + +
+
+ )} + + {/*NOTE*/} + {showNote && ( +
+
+ {t("orders.returns.note")} + + {t("orders.returns.noteHint")} + +
+ +
+
+ { + return ( + + + + onUpdate({ internal_note: field.value }) + } + className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover" + /> + + + + ) + }} + /> +
+ { + form.setValue(`items.${index}.note`, { + shouldDirty: true, + shouldTouch: true, + }) + onUpdate({ internal_note: null }) + }} + > + + +
+
+ )} + +
+ ) +} + +export { ReturnItem } diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/schema.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/schema.ts new file mode 100644 index 0000000000..709495ec7d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/components/return-create-form/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +export const ReturnCreateSchema = z.object({ + items: z.array( + z.object({ + item_id: z.string(), + quantity: z.number(), + reason_id: z.string().optional().nullable(), + note: z.string().optional().nullable(), + }) + ), + location_id: z.string().optional(), + option_id: z.string(), + send_notification: z.boolean().optional(), + // TODO: implement this + receive_now: z.boolean().optional(), +}) + +export type ReturnCreateSchemaType = z.infer 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..a0b058862b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/index.ts @@ -0,0 +1 @@ +export { ReturnCreate as Component } from "./return-create" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-return/return-create.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-return/return-create.tsx new file mode 100644 index 0000000000..c92e304324 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-return/return-create.tsx @@ -0,0 +1,66 @@ +import { useParams } from "react-router-dom" +import { useEffect, useState } from "react" + +import { RouteFocusModal } from "../../../components/modals" +import { ReturnCreateForm } from "./components/return-create-form" + +import { useOrder, useOrderPreview } from "../../../hooks/api/orders" +import { useInitiateReturn, useReturn } from "../../../hooks/api/returns" +import { DEFAULT_FIELDS } from "../order-detail/constants" + +let IS_REQUEST_RUNNING = false + +export const ReturnCreate = () => { + const { id } = useParams() + + const { order } = useOrder(id!, { + fields: DEFAULT_FIELDS, + }) + + const { order: preview } = useOrderPreview(id!) + + const [activeReturnId, setActiveReturnId] = useState() + + const { mutateAsync: initiateReturn } = useInitiateReturn(order.id) + + const { return: activeReturn } = useReturn(activeReturnId, undefined, { + enabled: !!activeReturnId, + }) + + useEffect(() => { + async function run() { + if (IS_REQUEST_RUNNING || !order || !preview) { + return + } + + /** + * Active return already exists + */ + if (preview.order_change?.change_type === "return") { + setActiveReturnId(preview.order_change.return.id) + return + } + + IS_REQUEST_RUNNING = true + + const orderReturn = await initiateReturn({ order_id: order.id }) + setActiveReturnId(orderReturn.id) + + IS_REQUEST_RUNNING = false + } + + run() + }, [order, preview]) + + return ( + + {activeReturn && preview && order && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx index 1cbdc46029..2959237204 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-activity-section/order-timeline.tsx @@ -5,11 +5,12 @@ import { PropsWithChildren, ReactNode, useMemo, useState } from "react" import { Link } from "react-router-dom" import { XMarkMini } from "@medusajs/icons" -import { AdminFulfillment, AdminOrder } from "@medusajs/types" +import { AdminFulfillment, AdminOrder, AdminReturn } from "@medusajs/types" import { useTranslation } from "react-i18next" import { useDate } from "../../../../../hooks/use-date" import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" +import { useReturns } from "../../../../../hooks/api/returns" type OrderTimelineProps = { order: AdminOrder @@ -80,6 +81,8 @@ type Activity = { const useActivityItems = (order: AdminOrder) => { const { t } = useTranslation() + const { returns = [] } = useReturns({ order_id: order.id, fields: "*items" }) + const notes = [] const isLoading = false // const { notes, isLoading, isError, error } = useNotes( @@ -165,16 +168,13 @@ const useActivityItems = (order: AdminOrder) => { } } - /** - * TODO: revisit when API is fixed to fetch returns of an order - */ - - // for (const ret of order.returns) { - // items.push({ - // title: t("orders.activity.events.return.created"), - // timestamp: ret.created_at, - // }) - // } + for (const ret of returns) { + items.push({ + title: t("orders.activity.events.return.created"), + timestamp: ret.created_at, + children: , + }) + } // for (const note of notes || []) { // items.push({ @@ -391,3 +391,21 @@ const FulfillmentCreatedBody = ({
) } + +const ReturnCreatedBody = ({ orderReturn }: { orderReturn: AdminReturn }) => { + const { t } = useTranslation() + + const numberOfItems = orderReturn.items.reduce((acc, item) => { + return acc + item.quantity + }, 0) + + return ( +
+ + {t("orders.activity.events.return.items", { + count: numberOfItems, + })} + +
+ ) +} 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 41140d9d87..9fdcb0c277 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,8 +1,13 @@ +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" +import { useMemo } from "react" + import { AdminOrder, OrderLineItemDTO, ReservationItemDTO, } from "@medusajs/types" +import { ArrowDownRightMini, ArrowUturnLeft } from "@medusajs/icons" import { Button, Container, @@ -11,9 +16,6 @@ import { StatusBadge, Text, } from "@medusajs/ui" -import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" -import { useMemo } from "react" import { ActionMenu } from "../../../../../components/common/action-menu" import { Thumbnail } from "../../../../../components/common/thumbnail" @@ -22,6 +24,8 @@ import { getStylizedAmount, } from "../../../../../lib/money-amount-helpers" import { useReservationItems } from "../../../../../hooks/api/reservations" +import { useReturns } from "../../../../../hooks/api/returns" +import { useDate } from "../../../../../hooks/use-date" type OrderSummarySectionProps = { order: AdminOrder @@ -67,6 +71,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
+ @@ -104,11 +109,11 @@ const Header = ({ order }: { order: AdminOrder }) => { // to: "#", // TODO: Open modal to allocate items // icon: , // }, - // { - // label: t("orders.summary.requestReturn"), - // to: `/orders/${order.id}/returns`, - // icon: , - // }, + { + label: t("orders.returns.create"), + to: `/orders/${order.id}/returns`, + icon: , + }, ], }, ]} @@ -270,6 +275,41 @@ const CostBreakdown = ({ order }: { order: AdminOrder }) => { ) } +const ReturnBreakdown = ({ order }: { order: AdminOrder }) => { + const { t } = useTranslation() + const { getRelativeDate } = useDate() + + const { returns = [] } = useReturns({ + order_id: order.id, + status: "requested", + fields: "*items", + }) + + if (!returns.length) { + return null + } + + return returns.map((activeReturn) => ( +
+
+ + + {t("orders.returns.returnRequestedInfo", { + requestedItemsCount: activeReturn.items.length, + })} + +
+ + + {getRelativeDate(activeReturn.created_at)} + +
+ )) +} + const Total = ({ order }: { order: AdminOrder }) => { const { t } = useTranslation() diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts index 7d2c681563..59a9eed81c 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts @@ -11,12 +11,16 @@ const DEFAULT_PROPERTIES = [ "subtotal", "discounts_total", "shipping_total", + "shipping_tax_total", "tax_total", + "refundable_total", ] const DEFAULT_RELATIONS = [ "*customer", "*items", // -> we get LineItem here with added `quantity` and `detail` which is actually an OrderItem (which is a parent object to LineItem in the DB) + "*items.variant", + "*items.variant.product", "*items.variant.options", "+items.variant.manage_inventory", "*shipping_address", diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts index 6730c91728..8f01ea58b5 100644 --- a/packages/core/js-sdk/src/admin/index.ts +++ b/packages/core/js-sdk/src/admin/index.ts @@ -25,6 +25,7 @@ import { TaxRate } from "./tax-rate" import { TaxRegion } from "./tax-region" import { Upload } from "./upload" import { User } from "./user" +import { ReturnReason } from "./return-reason" export class Admin { public invite: Invite @@ -37,6 +38,7 @@ export class Admin { public productType: ProductType public upload: Upload public region: Region + public returnReason: ReturnReason public stockLocation: StockLocation public salesChannel: SalesChannel public fulfillmentSet: FulfillmentSet @@ -47,12 +49,12 @@ export class Admin { public inventoryItem: InventoryItem public notification: Notification public order: Order + public return: Return public taxRate: TaxRate public taxRegion: TaxRegion public store: Store public productTag: ProductTag public user: User - public return: Return constructor(client: Client) { this.invite = new Invite(client) @@ -65,6 +67,7 @@ export class Admin { this.productType = new ProductType(client) this.upload = new Upload(client) this.region = new Region(client) + this.returnReason = new ReturnReason(client) this.stockLocation = new StockLocation(client) this.salesChannel = new SalesChannel(client) this.fulfillmentSet = new FulfillmentSet(client) @@ -75,11 +78,11 @@ export class Admin { this.inventoryItem = new InventoryItem(client) this.notification = new Notification(client) this.order = new Order(client) + this.return = new Return(client) this.taxRate = new TaxRate(client) this.taxRegion = new TaxRegion(client) this.store = new Store(client) this.productTag = new ProductTag(client) this.user = new User(client) - this.return = new Return(client) } } diff --git a/packages/core/js-sdk/src/admin/order.ts b/packages/core/js-sdk/src/admin/order.ts index 55a253c384..bd6b106a67 100644 --- a/packages/core/js-sdk/src/admin/order.ts +++ b/packages/core/js-sdk/src/admin/order.ts @@ -1,5 +1,4 @@ import { - AdminCreateOrderShipment, FindParams, HttpTypes, PaginatedResponse, @@ -24,6 +23,15 @@ export class Order { ) } + async retrievePreview(id: string, headers?: ClientHeaders) { + return await this.client.fetch<{ order: HttpTypes.AdminOrder }>( + `/admin/orders/${id}/preview`, + { + headers, + } + ) + } + async list( queryParams?: FindParams & HttpTypes.AdminOrderFilters, headers?: ClientHeaders diff --git a/packages/core/js-sdk/src/admin/return-reason.ts b/packages/core/js-sdk/src/admin/return-reason.ts new file mode 100644 index 0000000000..dde719cbc4 --- /dev/null +++ b/packages/core/js-sdk/src/admin/return-reason.ts @@ -0,0 +1,24 @@ +import { HttpTypes } from "@medusajs/types" + +import { Client } from "../client" +import { ClientHeaders } from "../types" + +export class ReturnReason { + private client: Client + constructor(client: Client) { + this.client = client + } + + async list( + queryParams?: HttpTypes.AdminReturnReasonListParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + "/admin/return-reasons", + { + headers, + query: queryParams, + } + ) + } +} diff --git a/packages/core/js-sdk/src/admin/return.ts b/packages/core/js-sdk/src/admin/return.ts index b72491b8c4..da29462e48 100644 --- a/packages/core/js-sdk/src/admin/return.ts +++ b/packages/core/js-sdk/src/admin/return.ts @@ -1,4 +1,5 @@ -import { HttpTypes } from "@medusajs/types" +import { FindParams, HttpTypes, SelectParams } from "@medusajs/types" + import { Client } from "../client" import { ClientHeaders } from "../types" @@ -8,6 +9,26 @@ export class Return { this.client = client } + async list(query?: HttpTypes.AdminReturnFilters, headers?: ClientHeaders) { + return await this.client.fetch( + `/admin/returns`, + { + query, + headers, + } + ) + } + + async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) { + return await this.client.fetch( + `/admin/returns/${id}`, + { + query, + headers, + } + ) + } + async initiateRequest( body: HttpTypes.AdminInitiateReturnRequest, query?: HttpTypes.SelectParams, @@ -24,6 +45,21 @@ export class Return { ) } + async cancelRequest( + id: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/returns/${id}/request`, + { + method: "DELETE", + headers, + query, + } + ) + } + async addReturnItem( id: string, body: HttpTypes.AdminAddReturnItems, @@ -41,6 +77,40 @@ export class Return { ) } + async updateReturnItem( + id: string, + actionId: string, + body: HttpTypes.AdminUpdateReturnItems, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/returns/${id}/request-items/${actionId}`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + async removeReturnItem( + id: string, + actionId: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/returns/${id}/request-items/${actionId}`, + { + method: "DELETE", + headers, + query, + } + ) + } + async addReturnShipping( id: string, body: HttpTypes.AdminAddReturnShipping, @@ -58,6 +128,40 @@ export class Return { ) } + async updateReturnShipping( + id: string, + actionId: string, + body: HttpTypes.AdminAddReturnShipping, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/returns/${id}/shipping-method/${actionId}`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + async deleteReturnShipping( + id: string, + actionId: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/returns/${id}/shipping-method/${actionId}`, + { + method: "DELETE", + headers, + query, + } + ) + } + async confirmRequest( id: string, body: HttpTypes.AdminConfirmReturnRequest, diff --git a/packages/core/types/src/http/index.ts b/packages/core/types/src/http/index.ts index efcfa0e731..9beeaadadd 100644 --- a/packages/core/types/src/http/index.ts +++ b/packages/core/types/src/http/index.ts @@ -23,6 +23,8 @@ export * from "./product-tag" export * from "./product-type" export * from "./promotion" export * from "./region" +export * from "./return" +export * from "./return-reason" export * from "./reservation" export * from "./return" export * from "./sales-channel" diff --git a/packages/core/types/src/http/order/admin.ts b/packages/core/types/src/http/order/admin.ts index a7fd6ccfbe..055bbc6a4f 100644 --- a/packages/core/types/src/http/order/admin.ts +++ b/packages/core/types/src/http/order/admin.ts @@ -12,6 +12,14 @@ export interface AdminOrderFilters extends BaseOrderFilters {} export interface AdminOrderAddress extends BaseOrderAddress {} export interface AdminOrderShippingMethod extends BaseOrderShippingMethod {} +export interface AdminOrderResponse { + order: AdminOrder +} + +export interface AdminOrdersResponse { + orders: AdminOrder[] +} + export interface AdminCreateOrderFulfillment { items: { id: string; quantity: number }[] location_id?: string diff --git a/packages/core/types/src/http/return-reason/admin.ts b/packages/core/types/src/http/return-reason/admin.ts new file mode 100644 index 0000000000..cc57eaca90 --- /dev/null +++ b/packages/core/types/src/http/return-reason/admin.ts @@ -0,0 +1,28 @@ +import { BaseReturnReason } from "./common" +import { FindParams } from "../common" +import { BaseFilterable, OperatorMap } from "../../dal" + +export interface AdminReturnReason extends BaseReturnReason {} + +export interface AdminCreateReturnReason { + // TODO: + value: string + label: string + description?: string +} + +export interface AdminReturnReasonsResponse { + return_reasons: AdminReturnReason[] +} + +export interface AdminReturnReasonListParams + extends FindParams, + BaseFilterable { + id?: string[] | string | OperatorMap + value?: string | OperatorMap + label?: string | OperatorMap + description?: string | OperatorMap + parent_return_reason_id?: string | OperatorMap + created_at?: OperatorMap + updated_at?: OperatorMap +} diff --git a/packages/core/types/src/http/return-reason/common.ts b/packages/core/types/src/http/return-reason/common.ts new file mode 100644 index 0000000000..07f8544a36 --- /dev/null +++ b/packages/core/types/src/http/return-reason/common.ts @@ -0,0 +1,9 @@ +export interface BaseReturnReason { + id: string + value: string + label: string + description?: string | null + metadata?: Record | null + created_at: string + updated_at: string +} diff --git a/packages/core/types/src/http/return-reason/index.ts b/packages/core/types/src/http/return-reason/index.ts new file mode 100644 index 0000000000..26b8eb9dad --- /dev/null +++ b/packages/core/types/src/http/return-reason/index.ts @@ -0,0 +1 @@ +export * from "./admin" diff --git a/packages/core/types/src/http/return/admin.ts b/packages/core/types/src/http/return/admin.ts index 95c2c0b192..0709eed187 100644 --- a/packages/core/types/src/http/return/admin.ts +++ b/packages/core/types/src/http/return/admin.ts @@ -1,4 +1,7 @@ -export interface BaseReturnItem { +import { OperatorMap } from "../../dal" +import { FindParams } from "../common" + +export interface AdminBaseReturnItem { id: string quantity: number received_quantity: number @@ -9,7 +12,7 @@ export interface BaseReturnItem { metadata?: Record } -export interface AdminReturnResponse { +export interface AdminReturn { id: string order_id: string status?: string @@ -19,7 +22,15 @@ export interface AdminReturnResponse { display_id: number no_notification?: boolean refund_amount?: number - items: BaseReturnItem[] + items: AdminBaseReturnItem[] +} + +export interface AdminReturnResponse { + return: AdminReturn +} + +export interface AdminReturnsResponse { + returns: AdminReturn[] } export interface AdminInitiateReturnRequest { @@ -42,6 +53,13 @@ export interface AdminAddReturnItem { export interface AdminAddReturnItems { items: AdminAddReturnItem[] } + +export interface AdminUpdateReturnItems { + quantity?: number + internal_note?: string + reason_id?: string +} + export interface AdminAddReturnShipping { shipping_option_id: string custom_price?: number @@ -49,6 +67,25 @@ export interface AdminAddReturnShipping { internal_note?: string metadata?: Record } + +export interface AdminUpdateReturnShipping { + custom_price?: number + internal_note?: string + metadata?: Record +} + export interface AdminConfirmReturnRequest { no_notification?: boolean } + +export interface AdminReturnFilters extends FindParams { + id?: string[] | string | OperatorMap + order_id?: string[] | string | OperatorMap + status?: + | string[] + | string + | Record + | OperatorMap> + created_at?: OperatorMap + updated_at?: OperatorMap +} diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index 22cabb4b11..d673a798a2 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -450,6 +450,7 @@ export interface UpdateReturnDTO { internal_note?: string | null note?: string | null reason_id?: string | null + return_id?: string | null metadata?: Record | null }[] } diff --git a/packages/medusa/src/api/admin/returns/middlewares.ts b/packages/medusa/src/api/admin/returns/middlewares.ts index b7cdd7f92c..371741f8fc 100644 --- a/packages/medusa/src/api/admin/returns/middlewares.ts +++ b/packages/medusa/src/api/admin/returns/middlewares.ts @@ -7,6 +7,7 @@ import { AdminGetOrdersParams, AdminPostReceiveReturnItemsReqSchema, AdminPostReceiveReturnsReqSchema, + AdminPostCancelReturnReqSchema, AdminPostReturnsConfirmRequestReqSchema, AdminPostReturnsReqSchema, AdminPostReturnsRequestItemsActionReqSchema, @@ -122,6 +123,17 @@ export const adminReturnRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/returns/:id/cancel", + middlewares: [ + validateAndTransformBody(AdminPostCancelReturnReqSchema), + validateAndTransformQuery( + AdminGetOrdersOrderParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, { method: ["DELETE"], matcher: "/admin/returns/:id/request", diff --git a/packages/medusa/src/api/admin/returns/validators.ts b/packages/medusa/src/api/admin/returns/validators.ts index 1348020c20..d3e46283b1 100644 --- a/packages/medusa/src/api/admin/returns/validators.ts +++ b/packages/medusa/src/api/admin/returns/validators.ts @@ -94,10 +94,10 @@ export type AdminPostReceiveReturnItemsReqSchemaType = z.infer< > export const AdminPostCancelReturnReqSchema = z.object({ - return_id: z.string(), no_notification: z.boolean().optional(), internal_note: z.string().nullish(), }) + export type AdminPostCancelReturnReqSchemaType = z.infer< typeof AdminPostCancelReturnReqSchema > diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index c96ae6c4e1..738ca8d15d 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -67,6 +67,7 @@ export const config: MiddlewaresConfig = { ...storeOrderRoutesMiddlewares, ...authRoutesMiddlewares, ...adminWorkflowsExecutionsMiddlewares, + ...adminReturnRoutesMiddlewares, ...storeRegionRoutesMiddlewares, ...adminRegionRoutesMiddlewares, ...adminReturnRoutesMiddlewares, @@ -97,7 +98,6 @@ export const config: MiddlewaresConfig = { ...adminOrderRoutesMiddlewares, ...adminReservationRoutesMiddlewares, ...adminProductCategoryRoutesMiddlewares, - ...adminReservationRoutesMiddlewares, ...adminShippingProfilesMiddlewares, ...adminFulfillmentsRoutesMiddlewares, ...adminFulfillmentProvidersRoutesMiddlewares, diff --git a/packages/modules/order/src/migrations/Migration20240604100512.ts b/packages/modules/order/src/migrations/Migration20240604100512.ts index abcca83fd6..9da1242d22 100644 --- a/packages/modules/order/src/migrations/Migration20240604100512.ts +++ b/packages/modules/order/src/migrations/Migration20240604100512.ts @@ -129,15 +129,17 @@ export class Migration20240604100512 extends Migration { exchange_id ) WHERE exchange_id IS NOT NULL AND deleted_at IS NULL; - - - CREATE TYPE return_status_enum AS ENUM ( - 'requested', - 'received', - 'partially_received', - 'canceled' - ); + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'return_status_enum') THEN + CREATE TYPE return_status_enum AS ENUM ( + 'requested', + 'received', + 'partially_received', + 'canceled'); + END IF; + END$$; CREATE TABLE IF NOT EXISTS "return" ( "id" TEXT NOT NULL, @@ -265,12 +267,15 @@ export class Migration20240604100512 extends Migration { CREATE INDEX IF NOT EXISTS "IDX_order_exchange_item_item_id" ON "order_exchange_item" ("item_id") WHERE deleted_at IS NULL; - - - CREATE TYPE order_claim_type_enum AS ENUM ( - 'refund', - 'replace' - ); + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'order_claim_type_enum') THEN + CREATE TYPE order_claim_type_enum AS ENUM ( + 'refund', + 'replace' + ); + END IF; + END$$; CREATE TABLE IF NOT EXISTS "order_claim" ( "id" TEXT NOT NULL, @@ -302,14 +307,17 @@ export class Migration20240604100512 extends Migration { CREATE INDEX IF NOT EXISTS "IDX_order_claim_return_id" ON "order_claim" ("return_id") WHERE return_id IS NOT NULL AND deleted_at IS NULL; - - - CREATE TYPE claim_reason_enum AS ENUM ( - 'missing_item', - 'wrong_item', - 'production_failure', - 'other' - ); + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'claim_reason_enum') THEN + CREATE TYPE claim_reason_enum AS ENUM ( + 'missing_item', + 'wrong_item', + 'production_failure', + 'other' + ); + END IF; + END$$; CREATE TABLE IF NOT EXISTS "order_claim_item" ( "id" TEXT NOT NULL, @@ -337,7 +345,6 @@ export class Migration20240604100512 extends Migration { WHERE deleted_at IS NULL; - CREATE TABLE IF NOT EXISTS "order_claim_item_image" ( "id" TEXT NOT NULL, "claim_item_id" TEXT NOT NULL,