From 85ed02570575a40ac796e66f5cd734d65da1d4c8 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Thu, 8 Aug 2024 14:20:13 +0200 Subject: [PATCH] feat(dashboard,js-sdk,medusa): add ability to add outbound items to claim (#8502) what: - user can add/remove/update outbound items to a claim - adds API to query variants Note: There are several paths that are not implemented yet correctly, but have some code lying around. Those will be tackled in the followup PRs https://github.com/user-attachments/assets/cadb3f7a-982f-44c7-8d7e-9f4f26949f4f RESOLVES CC-330 RESOLVES CC-296 --- .../dashboard/src/hooks/api/index.ts | 3 +- .../src/hooks/api/product-variants.tsx | 24 + .../dashboard/src/i18n/translations/en.json | 3 + .../add-claim-outbound-items-table.tsx | 87 ++++ .../add-claim-outbound-items-table/index.ts | 1 + .../use-claim-outbound-item-table-columns.tsx | 68 +++ .../use-claim-outbound-item-table-filters.tsx | 22 + .../use-claim-outbound-item-table-query.tsx | 40 ++ .../claim-create-form/claim-create-form.tsx | 147 +++--- .../claim-create-form/claim-outbound-item.tsx | 121 +++++ .../claim-outbound-section.tsx | 446 ++++++++++++++++++ .../claim-create-form/item-placeholder.tsx | 11 + .../components/claim-create-form/schema.ts | 15 +- packages/core/js-sdk/src/admin/index.ts | 5 +- .../core/js-sdk/src/admin/product-variant.ts | 23 + .../api/admin/product-variants/middlewares.ts | 17 + .../admin/product-variants/query-config.ts | 39 ++ .../src/api/admin/product-variants/route.ts | 39 ++ .../api/admin/product-variants/validators.ts | 22 + packages/medusa/src/api/middlewares.ts | 2 + 20 files changed, 1052 insertions(+), 83 deletions(-) create mode 100644 packages/admin-next/dashboard/src/hooks/api/product-variants.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/add-claim-outbound-items-table.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-filters.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-outbound-item.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-outbound-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/item-placeholder.tsx create mode 100644 packages/core/js-sdk/src/admin/product-variant.ts create mode 100644 packages/medusa/src/api/admin/product-variants/middlewares.ts create mode 100644 packages/medusa/src/api/admin/product-variants/query-config.ts create mode 100644 packages/medusa/src/api/admin/product-variants/route.ts create mode 100644 packages/medusa/src/api/admin/product-variants/validators.ts diff --git a/packages/admin-next/dashboard/src/hooks/api/index.ts b/packages/admin-next/dashboard/src/hooks/api/index.ts index 4b15966954..1c90b4192b 100644 --- a/packages/admin-next/dashboard/src/hooks/api/index.ts +++ b/packages/admin-next/dashboard/src/hooks/api/index.ts @@ -11,10 +11,12 @@ export * from "./fulfillment-providers" export * from "./fulfillment-sets" export * from "./inventory" export * from "./invites" +export * from "./notification" export * from "./orders" export * from "./payments" export * from "./price-lists" export * from "./product-types" +export * from "./product-variants" export * from "./products" export * from "./promotions" export * from "./regions" @@ -29,4 +31,3 @@ export * from "./tax-rates" export * from "./tax-regions" export * from "./users" export * from "./workflow-executions" -export * from "./notification" diff --git a/packages/admin-next/dashboard/src/hooks/api/product-variants.tsx b/packages/admin-next/dashboard/src/hooks/api/product-variants.tsx new file mode 100644 index 0000000000..965260fd65 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/product-variants.tsx @@ -0,0 +1,24 @@ +import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query" +import { sdk } from "../../lib/client" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const +export const productVariantQueryKeys = queryKeysFactory( + PRODUCT_VARIANT_QUERY_KEY +) + +export const useVariants = ( + query?: Record, + options?: Omit< + UseQueryOptions, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.productVariant.list(query), + queryKey: productVariantQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 5ffa6c135a..3424a762c8 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -845,6 +845,7 @@ "returns": { "create": "Create Return", "inbound": "Inbound", + "outbound": "Outbound", "sendNotification": "Send notification", "sendNotificationHint": "Notify customer about return.", "returnTotal": "Return total", @@ -885,6 +886,8 @@ "claims": { "create": "Create Claim", "outbound": "Outbound", + "outboundShipping": "Outbound shipping", + "outboundShippingHint": "Choose which method you want to use.", "refundAmount": "Estimated difference", "activeChangeError": "There is an active order change on this order. Please finish or discard the previous change.", "actions": { diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/add-claim-outbound-items-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/add-claim-outbound-items-table.tsx new file mode 100644 index 0000000000..3b0dec3ead --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/add-claim-outbound-items-table.tsx @@ -0,0 +1,87 @@ +import { OnChangeFn, RowSelectionState } from "@tanstack/react-table" +import { useState } from "react" + +import { DataTable } from "../../../../../components/table/data-table" +import { useVariants } from "../../../../../hooks/api" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { useClaimOutboundItemTableColumns } from "./use-claim-outbound-item-table-columns" +import { useClaimOutboundItemTableFilters } from "./use-claim-outbound-item-table-filters" +import { useClaimOutboundItemTableQuery } from "./use-claim-outbound-item-table-query" + +const PAGE_SIZE = 50 +const PREFIX = "rit" + +type AddClaimOutboundItemsTableProps = { + onSelectionChange: (ids: string[]) => void + selectedItems: string[] + currencyCode: string +} + +export const AddClaimOutboundItemsTable = ({ + onSelectionChange, + selectedItems, + currencyCode, +}: AddClaimOutboundItemsTableProps) => { + 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 } = useClaimOutboundItemTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + + const { variants = [], count } = useVariants({ + ...searchParams, + fields: "*inventory_items.inventory.location_levels,+inventory_quantity", + }) + + const columns = useClaimOutboundItemTableColumns(currencyCode) + const filters = useClaimOutboundItemTableFilters() + + const { table } = useDataTable({ + data: variants, + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: (_row) => { + // TODO: Check inventory here. Check if other validations needs to be made + return true + }, + rowSelection: { + state: rowSelection, + updater, + }, + }) + + return ( +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/index.ts new file mode 100644 index 0000000000..d498dbc3b9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/index.ts @@ -0,0 +1 @@ +export * from "./add-claim-outbound-items-table" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-columns.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-columns.tsx new file mode 100644 index 0000000000..9e7e51f3a2 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-columns.tsx @@ -0,0 +1,68 @@ +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" + +const columnHelper = createColumnHelper() + +export const useClaimOutboundItemTableColumns = (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 }) => { + return + }, + }), + columnHelper.accessor("sku", { + header: t("fields.sku"), + cell: ({ getValue }) => { + return getValue() || "-" + }, + }), + columnHelper.accessor("title", { + header: t("fields.title"), + }), + ], + [t, currencyCode] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-filters.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-filters.tsx new file mode 100644 index 0000000000..b2bd28ccc0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-filters.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next" + +import { Filter } from "../../../../../components/table/data-table" + +export const useClaimOutboundItemTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { + 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-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-query.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-query.tsx new file mode 100644 index 0000000000..634ab89df1 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-outbound-items-table/use-claim-outbound-item-table-query.tsx @@ -0,0 +1,40 @@ +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 useClaimOutboundItemTableQuery = ({ + pageSize = 50, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams( + ["q", "offset", "order", "created_at", "updated_at"], + prefix + ) + + const { offset, created_at, updated_at, ...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, + } + + return { searchParams, raw } +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx index 78dccd8df9..0739a06129 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx @@ -34,7 +34,7 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" import { AddClaimItemsTable } from "../add-claim-items-table" import { ClaimInboundItem } from "./claim-inbound-item.tsx" -import { ClaimCreateSchema, ReturnCreateSchemaType } from "./schema" +import { ClaimCreateSchema, CreateClaimSchemaType } from "./schema" import { useAddClaimInboundItems, @@ -47,6 +47,8 @@ import { } from "../../../../../hooks/api/claims" import { sdk } from "../../../../../lib/client" import { currencies } from "../../../../../lib/data/currencies" +import { ClaimOutboundSection } from "./claim-outbound-section" +import { ItemPlaceholder } from "./item-placeholder" type ReturnCreateFormProps = { order: AdminOrder @@ -56,7 +58,6 @@ type ReturnCreateFormProps = { let itemsToAdd: string[] = [] let itemsToRemove: string[] = [] - let IS_CANCELING = false export const ClaimCreateForm = ({ @@ -92,11 +93,13 @@ export const ClaimCreateForm = ({ /** * MUTATIONS */ + // TODO: implement confirm claim request const { mutateAsync: confirmClaimRequest, isPending: isConfirming } = {} // useConfirmClaimRequest(claim.id, order.id) const { mutateAsync: cancelClaimRequest, isPending: isCanceling } = useCancelClaimRequest(claim.id, order.id) + // TODO: implement update claim request const { mutateAsync: updateClaimRequest, isPending: isUpdating } = {} // useUpdateClaim(claim.id, order.id) const { @@ -145,40 +148,50 @@ export const ClaimCreateForm = ({ [preview.items] ) + const inboundPreviewItems = previewItems.filter( + (item) => !!item.actions?.find((a) => a.action === "RETURN_ITEM") + ) + + const outboundPreviewItems = previewItems.filter( + (item) => !!item.actions?.find((a) => a.action === "ITEM_ADD") + ) + const itemsMap = useMemo( () => new Map(order?.items?.map((i) => [i.id, i])), [order.items] ) - const previewItemsMap = useMemo( - () => new Map(previewItems.map((i) => [i.id, i])), - [previewItems] - ) - /** * FORM */ - - const form = useForm({ + const form = useForm({ defaultValues: () => { const method = preview.shipping_methods.find( (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD") ) return Promise.resolve({ - inbound_items: previewItems.map((i) => { - const returnAction = i.actions?.find( + inbound_items: inboundPreviewItems.map((i) => { + const inboundAction = i.actions?.find( (a) => a.action === "RETURN_ITEM" ) return { item_id: i.id, + variant_id: i.variant_id, quantity: i.detail.return_requested_quantity, - note: returnAction?.internal_note, - reason_id: returnAction?.details?.reason_id as string | undefined, + note: inboundAction?.internal_note, + reason_id: inboundAction?.details?.reason_id as string | undefined, } }), + outbound_items: outboundPreviewItems.map((i) => ({ + item_id: i.id, + variant_id: i.variant_id, + quantity: i.detail.quantity, + })), inbound_option_id: method ? method.shipping_option_id : "", + // TODO: pick up shipping method for outbound when available + outbound_option_id: method ? method.shipping_option_id : "", location_id: "", send_notification: false, }) @@ -187,7 +200,7 @@ export const ClaimCreateForm = ({ }) const { - fields: items, + fields: inboundItems, append, remove, update, @@ -196,22 +209,27 @@ export const ClaimCreateForm = ({ control: form.control, }) + const previewItemsMap = useMemo( + () => new Map(previewItems.map((i) => [i.id, i])), + [previewItems, inboundItems] + ) + useEffect(() => { const existingItemsMap: Record = {} - previewItems.forEach((i) => { - const ind = items.findIndex((field) => field.item_id === i.id) + inboundPreviewItems.forEach((i) => { + const ind = inboundItems.findIndex((field) => field.item_id === i.id) existingItemsMap[i.id] = true if (ind > -1) { - if (items[ind].quantity !== i.detail.return_requested_quantity) { + if (inboundItems[ind].quantity !== i.detail.return_requested_quantity) { const returnItemAction = i.actions?.find( (a) => a.action === "RETURN_ITEM" ) update(ind, { - ...items[ind], + ...inboundItems[ind], quantity: i.detail.return_requested_quantity, note: returnItemAction?.internal_note, reason_id: returnItemAction?.details?.reason_id as string, @@ -222,7 +240,7 @@ export const ClaimCreateForm = ({ } }) - items.forEach((i, ind) => { + inboundItems.forEach((i, ind) => { if (!(i.item_id in existingItemsMap)) { remove(ind) } @@ -239,7 +257,7 @@ export const ClaimCreateForm = ({ } }, [preview.shipping_methods]) - const showPlaceholder = !items.length + const showInboundItemsPlaceholder = !inboundItems.length const locationId = form.watch("location_id") const shippingOptionId = form.watch("inbound_option_id") @@ -285,7 +303,7 @@ export const ClaimCreateForm = ({ } } - setIsOpen("items", false) + setIsOpen("inbound-items", false) } const onLocationChange = async (selectedLocationId?: string | null) => { @@ -321,7 +339,7 @@ export const ClaimCreateForm = ({ return false } - const allItemsHaveLocation = items + const allItemsHaveLocation = inboundItems .map((_i) => { const item = itemsMap.get(_i.item_id) if (!item?.variant_id || !item?.variant) { @@ -339,45 +357,30 @@ export const ClaimCreateForm = ({ .every(Boolean) return !allItemsHaveLocation - }, [items, inventoryMap, locationId]) + }, [inboundItems, inventoryMap, locationId]) useEffect(() => { const getInventoryMap = async () => { const ret: Record = {} - if (!items.length) { + if (!inboundItems.length) { return ret } - ;( - await Promise.all( - items.map(async (_i) => { - const item = itemsMap.get(_i.item_id)! + const variantIds = inboundItems + .map((item) => item?.variant_id) + .filter(Boolean) - if (!item.variant_id || !item.variant?.product) { - return undefined - } - - return await sdk.admin.product.retrieveVariant( - item.variant.product.id, - item.variant_id, - { fields: "*inventory,*inventory.location_levels" } - ) - }) + const variants = ( + await sdk.admin.productVariant.list( + { id: variantIds }, + { fields: "*inventory,*inventory.location_levels" } ) - ) - .filter((it) => !!it?.variant) - .forEach((item) => { + ).variants - const { variant } = item - const levels = variant.inventory[0]?.location_levels - - if (!levels) { - return - } - - ret[variant.id] = levels - }) + variants.forEach((variant) => { + ret[variant.id] = variant.inventory[0]?.location_levels || [] + }) return ret } @@ -385,7 +388,7 @@ export const ClaimCreateForm = ({ getInventoryMap().then((map) => { setInventoryMap(map) }) - }, [items]) + }, [inboundItems]) useEffect(() => { /** @@ -429,7 +432,8 @@ export const ClaimCreateForm = ({ {t("orders.claims.create")} - - {showPlaceholder && ( -
- )} - - {items.map( + {showInboundItemsPlaceholder && } + {inboundItems.map( (item, index) => - previewItemsMap.get(item.item_id) && ( + previewItemsMap.get(item.item_id) && + itemsMap.get(item.item_id)! && ( ) )} - - {!showPlaceholder && ( + {!showInboundItemsPlaceholder && (
{/*LOCATION*/}
@@ -628,7 +622,6 @@ export const ClaimCreateForm = ({
)} - {showLevelsWarning && (
@@ -640,6 +633,13 @@ export const ClaimCreateForm = ({ )} + + {/*TOTALS SECTION*/}
@@ -664,7 +664,9 @@ export const ClaimCreateForm = ({ onClick={() => setIsShippingPriceEdit(true)} variant="transparent" className="text-ui-fg-muted" - disabled={showPlaceholder || !shippingOptionId} + disabled={ + showInboundItemsPlaceholder || !shippingOptionId + } > @@ -712,7 +714,7 @@ export const ClaimCreateForm = ({ value && setCustomShippingAmount(parseInt(value)) } value={customShippingAmount} - disabled={showPlaceholder} + disabled={showInboundItemsPlaceholder} /> ) : ( getStylizedAmount(shippingTotal, order.currency_code) @@ -732,7 +734,6 @@ export const ClaimCreateForm = ({
- {/*SEND NOTIFICATION*/}
void + // TODO: create a payload type for outbound updates + onUpdate: (payload: HttpTypes.AdminUpdateReturnItems) => void + + form: UseFormReturn +} + +function ClaimOutboundItem({ + previewItem, + currencyCode, + form, + onRemove, + onUpdate, + index, +}: ClaimOutboundItemProps) { + const { t } = useTranslation() + + return ( +
+
+
+ + +
+
+ + {previewItem.title}{" "} + + + {previewItem.variant_sku && ( + ({previewItem.variant_sku}) + )} +
+ + {previewItem.product_title} + +
+
+ +
+
+ { + return ( + + + { + const val = e.target.value + const payload = val === "" ? null : Number(val) + + field.onChange(payload) + + if (payload) { + onUpdate({ quantity: payload }) + } + }} + /> + + + + ) + }} + /> + + {t("fields.qty")} + +
+ +
+ +
+ + , + }, + ].filter(Boolean), + }, + ]} + /> +
+
+
+ ) +} + +export { ClaimOutboundItem } diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-outbound-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-outbound-section.tsx new file mode 100644 index 0000000000..da1da35c3c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-outbound-section.tsx @@ -0,0 +1,446 @@ +import { + AdminClaim, + AdminOrder, + AdminOrderPreview, + InventoryLevelDTO, +} from "@medusajs/types" +import { Alert, Button, Heading, Text, toast } from "@medusajs/ui" +import { useEffect, useMemo, useState } from "react" +import { useFieldArray, UseFormReturn } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { Form } from "../../../../../components/common/form" +import { Combobox } from "../../../../../components/inputs/combobox" +import { + RouteFocusModal, + StackedFocusModal, + useStackedModal, +} from "../../../../../components/modals" +import { + useAddClaimOutboundItems, + useAddClaimOutboundShipping, + useDeleteClaimOutboundShipping, + useRemoveClaimOutboundItem, + useUpdateClaimOutboundItems, +} from "../../../../../hooks/api/claims" +import { useShippingOptions } from "../../../../../hooks/api/shipping-options" +import { useStockLocations } from "../../../../../hooks/api/stock-locations" +import { sdk } from "../../../../../lib/client" +import { AddClaimOutboundItemsTable } from "../add-claim-outbound-items-table" +import { ClaimOutboundItem } from "./claim-outbound-item" +import { ItemPlaceholder } from "./item-placeholder" +import { CreateClaimSchemaType } from "./schema" + +type ClaimOutboundSectionProps = { + order: AdminOrder + claim: AdminClaim + preview: AdminOrderPreview + form: UseFormReturn +} + +let itemsToAdd: string[] = [] +let itemsToRemove: string[] = [] + +export const ClaimOutboundSection = ({ + order, + preview, + claim, + form, +}: ClaimOutboundSectionProps) => { + const { t } = useTranslation() + + const { setIsOpen } = useStackedModal() + 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 + */ + }) + + const { mutateAsync: addOutboundShipping } = useAddClaimOutboundShipping( + claim.id, + order.id + ) + + const { mutateAsync: deleteOutboundShipping } = + useDeleteClaimOutboundShipping(claim.id, order.id) + + const { mutateAsync: addOutboundItem } = useAddClaimOutboundItems( + claim.id, + order.id + ) + + const { mutateAsync: updateOutboundItem } = useUpdateClaimOutboundItems( + claim.id, + order.id + ) + + const { mutateAsync: removeOutboundItem } = useRemoveClaimOutboundItem( + claim.id, + order.id + ) + + /** + * Only consider items that belong to this claim and is an outbound item + */ + const previewOutboundItems = useMemo( + () => + preview?.items?.filter( + (i) => + !!i.actions?.find( + (a) => a.claim_id === claim.id && a.action === "ITEM_ADD" + ) + ), + [preview.items] + ) + + const variantItemMap = useMemo( + () => new Map(order?.items?.map((i) => [i.variant_id, i])), + [order.items] + ) + + const { + fields: outboundItems, + append, + remove, + update, + } = useFieldArray({ + name: "outbound_items", + control: form.control, + }) + + const variantOutboundMap = useMemo( + () => new Map(previewOutboundItems.map((i) => [i.variant_id, i])), + [previewOutboundItems, outboundItems] + ) + + useEffect(() => { + const existingItemsMap: Record = {} + + previewOutboundItems.forEach((i) => { + const ind = outboundItems.findIndex((field) => field.item_id === i.id) + + existingItemsMap[i.id] = true + + if (ind > -1) { + if (outboundItems[ind].quantity !== i.detail.quantity) { + update(ind, { + ...outboundItems[ind], + quantity: i.detail.quantity, + }) + } + } else { + append({ + item_id: i.id, + quantity: i.detail.quantity, + variant_id: i.variant_id, + }) + } + }) + + outboundItems.forEach((i, ind) => { + if (!(i.item_id in existingItemsMap)) { + remove(ind) + } + }) + }, [previewOutboundItems]) + + useEffect(() => { + // TODO: Pick the shipping methods from actions where return_id is null for outbound + const method = preview.shipping_methods.find( + (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD") + ) + + if (method) { + form.setValue("outbound_option_id", method.shipping_option_id) + } + }, [preview.shipping_methods]) + + const locationId = form.watch("location_id") + const showOutboundItemsPlaceholder = !outboundItems.length + + const onItemsSelected = async () => { + itemsToAdd.length && + (await addOutboundItem( + { + items: itemsToAdd.map((variantId) => ({ + variant_id: variantId, + quantity: 1, + })), + }, + { + onError: (error) => { + toast.error(error.message) + }, + } + )) + + for (const itemToRemove of itemsToRemove) { + const actionId = previewOutboundItems + .find((i) => i.variant_id === itemToRemove) + ?.actions?.find((a) => a.action === "ITEM_ADD")?.id + + if (actionId) { + await removeOutboundItem(actionId, { + onError: (error) => { + toast.error(error.message) + }, + }) + } + } + + setIsOpen("outbound-items", false) + } + + // TODO: implement outbound shipping + const { mutateAsync: updateClaimRequest, isPending: isUpdating } = {} // useUpdateClaim(claim.id, order.id) + const onLocationChange = async (selectedLocationId?: string | null) => { + await updateClaimRequest({ location_id: selectedLocationId }) + } + + const onShippingOptionChange = async (selectedOptionId: string) => { + const promises = preview.shipping_methods + .map((s) => s.actions?.find((a) => a.action === "SHIPPING_ADD")?.id) + .filter(Boolean) + .map(deleteOutboundShipping) + + await Promise.all(promises) + + await addOutboundShipping( + { shipping_option_id: selectedOptionId }, + { + onError: (error) => { + toast.error(error.message) + }, + } + ) + } + + const showLevelsWarning = useMemo(() => { + if (!locationId) { + return false + } + + const allItemsHaveLocation = outboundItems + .map((i) => { + const item = variantItemMap.get(i.variant_id) + if (!item?.variant_id || !item?.variant) { + return true + } + + if (!item.variant.manage_inventory) { + return true + } + + return inventoryMap[item.variant_id]?.find( + (l) => l.location_id === locationId + ) + }) + .every(Boolean) + + return !allItemsHaveLocation + }, [outboundItems, inventoryMap, locationId]) + + useEffect(() => { + // TODO: Ensure inventory validation occurs correctly + const getInventoryMap = async () => { + const ret: Record = {} + + if (!outboundItems.length) { + return ret + } + + const variantIds = outboundItems + .map((item) => item?.variant_id) + .filter(Boolean) + const variants = ( + await sdk.admin.productVariant.list( + { id: variantIds }, + { fields: "*inventory,*inventory.location_levels" } + ) + ).variants + + variants.forEach((variant) => { + ret[variant.id] = variant.inventory[0]?.location_levels || [] + }) + + return ret + } + + getInventoryMap().then((map) => { + setInventoryMap(map) + }) + }, [outboundItems]) + + return ( +
+
+ {t("orders.returns.outbound")} + + + + + {t("actions.addItems")} + + + + + + i.variant_id)} + currencyCode={order.currency_code} + onSelectionChange={(finalSelection) => { + const alreadySelected = outboundItems.map((i) => i.variant_id) + + itemsToAdd = finalSelection.filter( + (selection) => !alreadySelected.includes(selection) + ) + itemsToRemove = alreadySelected.filter( + (selection) => !finalSelection.includes(selection) + ) + }} + /> + + +
+
+ + + + +
+
+
+
+ +
+ + {showOutboundItemsPlaceholder && } + + {outboundItems.map( + (item, index) => + variantOutboundMap.get(item.variant_id) && ( + { + const actionId = previewOutboundItems + .find((i) => i.id === item.item_id) + ?.actions?.find((a) => a.action === "ITEM_ADD")?.id + + if (actionId) { + removeOutboundItem(actionId, { + onError: (error) => { + toast.error(error.message) + }, + }) + } + }} + onUpdate={(payload) => { + const actionId = previewOutboundItems + .find((i) => i.id === item.item_id) + ?.actions?.find((a) => a.action === "ITEM_ADD")?.id + + if (actionId) { + updateOutboundItem( + { ...payload, actionId }, + { + onError: (error) => { + toast.error(error.message) + }, + } + ) + } + }} + index={index} + /> + ) + )} + {!showOutboundItemsPlaceholder && ( +
+ {/*OUTBOUND SHIPPING*/} +
+
+ {t("orders.claims.outboundShipping")} + + {t("orders.claims.outboundShippingHint")} + +
+ + { + return ( + + + { + onChange(val) + val && onShippingOptionChange(val) + }} + {...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")} + +
+ )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/item-placeholder.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/item-placeholder.tsx new file mode 100644 index 0000000000..7c07737e10 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/item-placeholder.tsx @@ -0,0 +1,11 @@ +export const ItemPlaceholder = () => { + return ( +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts index 36b97cecc6..283872871d 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts @@ -9,16 +9,15 @@ export const ClaimCreateSchema = z.object({ note: z.string().nullish(), }) ), - // TODO: Bring back when introducing outbound items - // outbound_items: z.array( - // z.object({ - // item_id: z.string(), // TODO: variant id? - // quantity: z.number(), - // }) - // ), + outbound_items: z.array( + z.object({ + item_id: z.string(), // TODO: variant id? + quantity: z.number(), + }) + ), location_id: z.string().optional(), inbound_option_id: z.string().nullish(), send_notification: z.boolean().optional(), }) -export type ReturnCreateSchemaType = z.infer +export type CreateClaimSchemaType = z.infer diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts index 9fb4ba7684..a3afd9557f 100644 --- a/packages/core/js-sdk/src/admin/index.ts +++ b/packages/core/js-sdk/src/admin/index.ts @@ -1,4 +1,5 @@ import { Client } from "../client" +import { Claim } from "./claim" import { Currency } from "./currency" import { Customer } from "./customer" import { Fulfillment } from "./fulfillment" @@ -16,6 +17,7 @@ import { ProductCategory } from "./product-category" import { ProductCollection } from "./product-collection" import { ProductTag } from "./product-tag" import { ProductType } from "./product-type" +import { ProductVariant } from "./product-variant" import { Region } from "./region" import { Return } from "./return" import { ReturnReason } from "./return-reason" @@ -28,7 +30,6 @@ import { TaxRate } from "./tax-rate" import { TaxRegion } from "./tax-region" import { Upload } from "./upload" import { User } from "./user" -import { Claim } from "./claim" export class Admin { public invite: Invite @@ -61,6 +62,7 @@ export class Admin { public user: User public currency: Currency public payment: Payment + public productVariant: ProductVariant constructor(client: Client) { this.invite = new Invite(client) @@ -93,5 +95,6 @@ export class Admin { this.user = new User(client) this.currency = new Currency(client) this.payment = new Payment(client) + this.productVariant = new ProductVariant(client) } } diff --git a/packages/core/js-sdk/src/admin/product-variant.ts b/packages/core/js-sdk/src/admin/product-variant.ts new file mode 100644 index 0000000000..3ba28169f1 --- /dev/null +++ b/packages/core/js-sdk/src/admin/product-variant.ts @@ -0,0 +1,23 @@ +import { HttpTypes } from "@medusajs/types" +import { Client } from "../client" +import { ClientHeaders } from "../types" + +export class ProductVariant { + private client: Client + constructor(client: Client) { + this.client = client + } + + async list( + queryParams?: HttpTypes.AdminProductVariantParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/product-variants`, + { + headers, + query: queryParams, + } + ) + } +} diff --git a/packages/medusa/src/api/admin/product-variants/middlewares.ts b/packages/medusa/src/api/admin/product-variants/middlewares.ts new file mode 100644 index 0000000000..9c280f59f1 --- /dev/null +++ b/packages/medusa/src/api/admin/product-variants/middlewares.ts @@ -0,0 +1,17 @@ +import { MiddlewareRoute } from "@medusajs/framework" +import { validateAndTransformQuery } from "../../utils/validate-query" +import * as QueryConfig from "./query-config" +import { AdminGetProductVariantsParams } from "./validators" + +export const adminProductVariantRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/product-variants", + middlewares: [ + validateAndTransformQuery( + AdminGetProductVariantsParams, + QueryConfig.listProductVariantQueryConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api/admin/product-variants/query-config.ts b/packages/medusa/src/api/admin/product-variants/query-config.ts new file mode 100644 index 0000000000..408268b814 --- /dev/null +++ b/packages/medusa/src/api/admin/product-variants/query-config.ts @@ -0,0 +1,39 @@ +export const defaultAdminProductVariantFields = [ + "id", + "title", + "sku", + "barcode", + "ean", + "upc", + "allow_backorder", + "manage_inventory", + "hs_code", + "origin_country", + "mid_code", + "material", + "weight", + "length", + "height", + "width", + "metadata", + "variant_rank", + "product_id", + "created_at", + "updated_at", + "*product", + "*prices", + "*options", + "prices.price_rules.value", + "prices.price_rules.attribute", +] + +export const retrieveProductVariantQueryConfig = { + defaults: defaultAdminProductVariantFields, + isList: false, +} + +export const listProductVariantQueryConfig = { + ...retrieveProductVariantQueryConfig, + defaultLimit: 50, + isList: true, +} diff --git a/packages/medusa/src/api/admin/product-variants/route.ts b/packages/medusa/src/api/admin/product-variants/route.ts new file mode 100644 index 0000000000..1b08cf6319 --- /dev/null +++ b/packages/medusa/src/api/admin/product-variants/route.ts @@ -0,0 +1,39 @@ +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework" +import { HttpTypes } from "@medusajs/types" +import { wrapVariantsWithInventoryQuantity } from "../../utils/middlewares" +import { refetchEntities } from "../../utils/refetch-entity" +import { remapKeysForVariant, remapVariantResponse } from "../products/helpers" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) => + field.includes("inventory_quantity") + ) + + if (withInventoryQuantity) { + req.remoteQueryConfig.fields = req.remoteQueryConfig.fields.filter( + (field) => !field.includes("inventory_quantity") + ) + } + + const { rows: variants, metadata } = await refetchEntities( + "variant", + { ...req.filterableFields }, + req.scope, + remapKeysForVariant(req.remoteQueryConfig.fields ?? []), + req.remoteQueryConfig.pagination + ) + + if (withInventoryQuantity) { + await wrapVariantsWithInventoryQuantity(req, variants || []) + } + + res.json({ + variants: variants.map(remapVariantResponse), + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} diff --git a/packages/medusa/src/api/admin/product-variants/validators.ts b/packages/medusa/src/api/admin/product-variants/validators.ts new file mode 100644 index 0000000000..8e4633eca0 --- /dev/null +++ b/packages/medusa/src/api/admin/product-variants/validators.ts @@ -0,0 +1,22 @@ +import { z } from "zod" +import { createFindParams, createOperatorMap } from "../../utils/validators" + +export type AdminGetProductVariantsParamsType = z.infer< + typeof AdminGetProductVariantsParams +> +export const AdminGetProductVariantsParams = createFindParams({ + offset: 0, + limit: 50, +}).merge( + z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + manage_inventory: z.boolean().optional(), + allow_backorder: z.boolean().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), + $and: z.lazy(() => AdminGetProductVariantsParams.array()).optional(), + $or: z.lazy(() => AdminGetProductVariantsParams.array()).optional(), + }) +) diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index bcf74e5196..12bda16e96 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -21,6 +21,7 @@ import { adminPricePreferencesRoutesMiddlewares } from "./admin/price-preference import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares" import { adminProductTagRoutesMiddlewares } from "./admin/product-tags/middlewares" import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" +import { adminProductVariantRoutesMiddlewares } from "./admin/product-variants/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { adminRefundReasonsRoutesMiddlewares } from "./admin/refund-reasons/middlewares" @@ -110,4 +111,5 @@ export default defineMiddlewares([ ...adminClaimRoutesMiddlewares, ...adminRefundReasonsRoutesMiddlewares, ...adminExchangeRoutesMiddlewares, + ...adminProductVariantRoutesMiddlewares, ])