From ba45a316a7638cc003599900d0088e5a5aeb51c8 Mon Sep 17 00:00:00 2001 From: Rares Stefan Date: Fri, 21 Apr 2023 18:42:52 +0200 Subject: [PATCH] feat(admin-ui): Implement allocations on draft orders (#3753) --- .changeset/green-pugs-live.md | 5 + .../reservation-indicator.tsx | 132 +++++++++ .../allocations/allocate-items-modal.tsx | 9 +- .../details/detail-cards/draft-summary.tsx | 272 ++++++++++++++++++ .../orders/details/detail-cards/summary.tsx | 2 +- .../orders/details/order-line/index.tsx | 132 +-------- .../domain/orders/draft-orders/details.tsx | 106 +------ 7 files changed, 420 insertions(+), 238 deletions(-) create mode 100644 .changeset/green-pugs-live.md create mode 100644 packages/admin-ui/ui/src/domain/orders/components/reservation-indicator/reservation-indicator.tsx create mode 100644 packages/admin-ui/ui/src/domain/orders/details/detail-cards/draft-summary.tsx diff --git a/.changeset/green-pugs-live.md b/.changeset/green-pugs-live.md new file mode 100644 index 0000000000..2d1fbd7a53 --- /dev/null +++ b/.changeset/green-pugs-live.md @@ -0,0 +1,5 @@ +--- +"@medusajs/admin-ui": patch +--- + +feat(admin): Implement allocations on draft orders diff --git a/packages/admin-ui/ui/src/domain/orders/components/reservation-indicator/reservation-indicator.tsx b/packages/admin-ui/ui/src/domain/orders/components/reservation-indicator/reservation-indicator.tsx new file mode 100644 index 0000000000..41611ba39a --- /dev/null +++ b/packages/admin-ui/ui/src/domain/orders/components/reservation-indicator/reservation-indicator.tsx @@ -0,0 +1,132 @@ +import React from "react" +import { LineItem } from "@medusajs/medusa" +import { sum } from "lodash" +import { ReservationItemDTO } from "@medusajs/types" +import { useAdminStockLocations, useAdminVariantsInventory } from "medusa-react" +import Tooltip from "../../../../components/atoms/tooltip" +import CircleQuarterSolid from "../../../../components/fundamentals/icons/circle-quarter-solid" +import WarningCircleIcon from "../../../../components/fundamentals/icons/warning-circle" +import CheckCircleFillIcon from "../../../../components/fundamentals/icons/check-circle-fill-icon" +import EditAllocationDrawer from "../../details/allocations/edit-allocation-modal" +import Button from "../../../../components/fundamentals/button" + +const ReservationIndicator = ({ + reservations, + lineItem, +}: { + reservations?: ReservationItemDTO[] + lineItem: LineItem +}) => { + const { variant, isLoading, isFetching } = useAdminVariantsInventory( + lineItem.variant_id!, + { + enabled: !!lineItem?.variant_id, + } + ) + + const { stock_locations } = useAdminStockLocations({ + id: reservations?.map((r) => r.location_id) || [], + }) + + const [reservation, setReservation] = + React.useState(null) + + const locationMap = new Map(stock_locations?.map((l) => [l.id, l.name]) || []) + + const reservationsSum = sum(reservations?.map((r) => r.quantity) || []) + + const allocatableSum = lineItem.quantity - (lineItem?.fulfilled_quantity || 0) + + const awaitingAllocation = allocatableSum - reservationsSum + + if ( + isLoading || + isFetching || + !lineItem.variant_id || + !variant?.inventory.length + ) { + return
+ } + + return ( +
+ + {reservationsSum || awaitingAllocation ? ( +
+ {!!awaitingAllocation && ( + + {awaitingAllocation} items await allocation + + )} + {reservations?.map((reservation) => ( + setReservation(reservation)} + /> + ))} +
+ ) : ( + + This item has been fulfilled. + + )} +
+ } + side="bottom" + > + {awaitingAllocation ? ( + reservationsSum ? ( + + ) : ( + + ) + ) : ( + + )} + + + {reservation && ( + setReservation(null)} + reservation={reservation} + item={lineItem} + /> + )} +
+ ) +} + +const EditAllocationButton = ({ + reservation, + locationName, + onClick, +}: { + reservation: ReservationItemDTO + totalReservedQuantity: number + locationName?: string + lineItem: LineItem + onClick: () => void +}) => { + return ( +
+ {`${reservation.quantity} item: ${locationName}`} + +
+ ) +} + +export default ReservationIndicator diff --git a/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx b/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx index 2af4ac1d9e..d49156f8ea 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx @@ -1,5 +1,5 @@ import { Controller, useForm, useWatch } from "react-hook-form" -import { LineItem, Order, ReservationItemDTO } from "@medusajs/medusa" +import { LineItem } from "@medusajs/medusa" import { NestedForm, nestedForm } from "../../../../utils/nested-form" import React, { useEffect, useMemo } from "react" import { @@ -20,6 +20,7 @@ import { getErrorMessage } from "../../../../utils/error-messages" import { getFulfillableQuantity } from "../create-fulfillment/item-table" import { sum } from "lodash" import useNotification from "../../../../hooks/use-notification" +import { ReservationItemDTO } from "@medusajs/types" type AllocationModalFormData = { location?: { label: string; value: string } @@ -27,13 +28,13 @@ type AllocationModalFormData = { } type AllocateItemsModalProps = { - order: Order + items: LineItem[] reservationItemsMap: Record close: () => void } const AllocateItemsModal: React.FC = ({ - order, + items, close, reservationItemsMap, }) => { @@ -175,7 +176,7 @@ const AllocateItemsModal: React.FC = ({

Select the number of items that you wish to allocate.

- {order.items?.map((item, i) => { + {items?.map((item, i) => { return ( = ({ order }) => { + const { client } = useMedusa() + const { isFeatureEnabled } = useFeatureFlag() + const inventoryEnabled = useMemo(() => { + return isFeatureEnabled("inventoryService") + }, [isFeatureEnabled]) + + const { cart } = order + const { region } = cart + + const [variantInventoryMap, setVariantInventoryMap] = React.useState< + Map + >(new Map()) + + React.useEffect(() => { + if (!inventoryEnabled) { + return + } + + const fetchInventory = async () => { + const inventory = await Promise.all( + cart.items.map(async (item) => { + if (!item.variant_id) { + return + } + return await client.admin.variants.getInventory(item.variant_id) + }) + ) + + setVariantInventoryMap( + new Map( + inventory + .filter( + ( + inventoryItem + // eslint-disable-next-line max-len + ): inventoryItem is Response => + !!inventoryItem + ) + .map((i) => { + return [i.variant.id, i.variant] + }) + ) + ) + } + + fetchInventory() + }, [cart.items, inventoryEnabled, client.admin.variants]) + + const { reservations } = useAdminReservations( + { + line_item_id: cart?.items.map((item) => item.id), + }, + { + enabled: inventoryEnabled, + } + ) + + const reservationItemsMap = useMemo(() => { + if (!reservations?.length || !inventoryEnabled) { + return {} + } + + return reservations.reduce( + (acc: Record, item: ReservationItemDTO) => { + if (!item.line_item_id) { + return acc + } + acc[item.line_item_id] = acc[item.line_item_id] + ? [...acc[item.line_item_id], item] + : [item] + return acc + }, + {} + ) + }, [reservations, inventoryEnabled]) + + const { + state: allocationModalIsOpen, + open: showAllocationModal, + close: closeAllocationModal, + } = useToggleState() + + const allItemsReserved = useMemo(() => { + return cart.items.every((item) => { + if ( + !item.variant_id || + !variantInventoryMap.get(item.variant_id)?.inventory.length + ) { + return true + } + + const reservations = reservationItemsMap[item.id] + + return ( + item.quantity === item.fulfilled_quantity || + (reservations && + sum(reservations.map((r) => r.quantity)) === + item.quantity - (item.fulfilled_quantity || 0)) + ) + }) + }, [cart.items, variantInventoryMap, reservationItemsMap]) + + const actionables = useMemo(() => { + const actionables: ActionType[] = [] + if (inventoryEnabled && !allItemsReserved) { + actionables.push({ + label: "Allocate", + onClick: showAllocationModal, + }) + } + return actionables + }, [inventoryEnabled, showAllocationModal, allItemsReserved]) + + return ( + + ) + } + actionables={actionables} + > +
+ {cart?.items?.map((item, i) => ( +
+
+
+ {item?.thumbnail ? ( + + ) : ( + + )} +
+
+ + {item.title} + + {item?.variant && ( + + {item.variant.sku} + + )} +
+
+
+
+
+ {formatAmountWithSymbol({ + amount: (item?.total ?? 0) / item.quantity, + currency: region?.currency_code ?? "", + digits: 2, + tax: [], + })} +
+
+ x {item.quantity} +
+ {inventoryEnabled && ( + + )} +
+ {formatAmountWithSymbol({ + amount: item.total ?? 0, + currency: region?.currency_code ?? "", + digits: 2, + tax: [], + })} +
+
+
+ {region?.currency_code.toUpperCase()} +
+
+
+ ))} + + {cart?.discounts?.map((discount, index) => ( +
+
+ Discount:{" "} + + {discount.code} + +
+
+ - + {formatAmountWithSymbol({ + amount: cart?.discount_total, + currency: region?.currency_code || "", + digits: 2, + tax: region?.tax_rate, + })} +
+
+ ))} + + + +
+ {allocationModalIsOpen && ( + + )} +
+ ) +} + +export default DraftSummaryCard diff --git a/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx b/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx index eaaff40d69..509239788f 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx @@ -261,7 +261,7 @@ const SummaryCard: React.FC = ({ order, reservations }) => { {allocationModalIsOpen && ( )} diff --git a/packages/admin-ui/ui/src/domain/orders/details/order-line/index.tsx b/packages/admin-ui/ui/src/domain/orders/details/order-line/index.tsx index bf99b4a8a8..238eb56841 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/order-line/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/order-line/index.tsx @@ -1,17 +1,10 @@ -import { LineItem, ReservationItemDTO } from "@medusajs/medusa" -import { useAdminStockLocations, useAdminVariantsInventory } from "medusa-react" +import { LineItem } from "@medusajs/medusa" +import { ReservationItemDTO } from "@medusajs/types" -import Button from "../../../../components/fundamentals/button" -import CheckCircleFillIcon from "../../../../components/fundamentals/icons/check-circle-fill-icon" -import CircleQuarterSolid from "../../../../components/fundamentals/icons/circle-quarter-solid" -import EditAllocationDrawer from "../allocations/edit-allocation-modal" import ImagePlaceholder from "../../../../components/fundamentals/image-placeholder" -import React from "react" -import Tooltip from "../../../../components/atoms/tooltip" -import WarningCircleIcon from "../../../../components/fundamentals/icons/warning-circle" import { formatAmountWithSymbol } from "../../../../utils/prices" -import { sum } from "lodash" import { useFeatureFlag } from "../../../../providers/feature-flag-provider" +import ReservationIndicator from "../../components/reservation-indicator/reservation-indicator" type OrderLineProps = { item: LineItem @@ -77,123 +70,4 @@ const OrderLine = ({ item, currencyCode, reservations }: OrderLineProps) => { ) } -const ReservationIndicator = ({ - reservations, - lineItem, -}: { - reservations?: ReservationItemDTO[] - lineItem: LineItem -}) => { - const { variant, isLoading, isFetching } = useAdminVariantsInventory( - lineItem.variant_id!, - { - enabled: !!lineItem?.variant_id, - } - ) - - const { stock_locations } = useAdminStockLocations({ - id: reservations?.map((r) => r.location_id) || [], - }) - - const [reservation, setReservation] = - React.useState(null) - - const locationMap = new Map(stock_locations?.map((l) => [l.id, l.name]) || []) - - const reservationsSum = sum(reservations?.map((r) => r.quantity) || []) - - const allocatableSum = lineItem.quantity - (lineItem?.fulfilled_quantity || 0) - - const awaitingAllocation = allocatableSum - reservationsSum - - if ( - isLoading || - isFetching || - !lineItem.variant_id || - !variant?.inventory.length - ) { - return
- } - - return ( -
- - {reservationsSum || awaitingAllocation ? ( -
- {!!awaitingAllocation && ( - - {awaitingAllocation} items await allocation - - )} - {reservations?.map((reservation) => ( - setReservation(reservation)} - /> - ))} -
- ) : ( - - This item has been fulfilled. - - )} -
- } - side="bottom" - > - {awaitingAllocation ? ( - reservationsSum ? ( - - ) : ( - - ) - ) : ( - - )} - - - {reservation && ( - setReservation(null)} - reservation={reservation} - item={lineItem} - /> - )} -
- ) -} - -const EditAllocationButton = ({ - reservation, - locationName, - onClick, -}: { - reservation: ReservationItemDTO - totalReservedQuantity: number - locationName?: string - lineItem: LineItem - onClick: () => void -}) => { - return ( -
- {`${reservation.quantity} item: ${locationName}`} - -
- ) -} - export default OrderLine diff --git a/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx b/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx index bd3993bd10..e57c3b889d 100644 --- a/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx +++ b/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx @@ -13,12 +13,10 @@ import Avatar from "../../../components/atoms/avatar" import BackButton from "../../../components/atoms/back-button" import CopyToClipboard from "../../../components/atoms/copy-to-clipboard" import Spinner from "../../../components/atoms/spinner" -import Badge from "../../../components/fundamentals/badge" import Button from "../../../components/fundamentals/button" import DetailsIcon from "../../../components/fundamentals/details-icon" import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon" import TruckIcon from "../../../components/fundamentals/icons/truck-icon" -import ImagePlaceholder from "../../../components/fundamentals/image-placeholder" import StatusDot from "../../../components/fundamentals/status-indicator" import JSONView from "../../../components/molecules/json-view" import BodyCard from "../../../components/organisms/body-card" @@ -31,6 +29,7 @@ import { getErrorMessage } from "../../../utils/error-messages" import extractCustomerName from "../../../utils/extract-customer-name" import { formatAmountWithSymbol } from "../../../utils/prices" import AddressModal from "../details/address-modal" +import DraftSummaryCard from "../details/detail-cards/draft-summary" import { DisplayTotal, FormattedAddress } from "../details/templates" type DeletePromptData = { @@ -211,108 +210,7 @@ const DraftOrderDetails = () => { - -
- {cart?.items?.map((item, i) => ( -
-
-
- {item?.thumbnail ? ( - - ) : ( - - )} -
-
- - {item.title} - - {item?.variant && ( - - {item.variant.sku} - - )} -
-
-
-
-
- {formatAmountWithSymbol({ - amount: (item?.total ?? 0) / item.quantity, - currency: region?.currency_code ?? "", - digits: 2, - tax: [], - })} -
-
- x {item.quantity} -
-
- {formatAmountWithSymbol({ - amount: item.total ?? 0, - currency: region?.currency_code ?? "", - digits: 2, - tax: [], - })} -
-
-
- {region?.currency_code.toUpperCase()} -
-
-
- ))} - - {cart?.discounts?.map((discount, index) => ( -
-
- Discount:{" "} - - {discount.code} - -
-
- - - {formatAmountWithSymbol({ - amount: cart?.discount_total, - currency: region?.currency_code || "", - digits: 2, - tax: region?.tax_rate, - })} -
-
- ))} - - - -
-
+