feat(admin-ui): Implement allocations on draft orders (#3753)

This commit is contained in:
Rares Stefan
2023-04-21 18:42:52 +02:00
committed by GitHub
parent 2cc2acb6e0
commit ba45a316a7
7 changed files with 420 additions and 238 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/admin-ui": patch
---
feat(admin): Implement allocations on draft orders
@@ -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<ReservationItemDTO | null>(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 <div className="w-[20px]" />
}
return (
<div className={awaitingAllocation ? "text-rose-50" : "text-grey-40"}>
<Tooltip
content={
<div className="inter-small-regular flex flex-col items-center px-1 pt-1 pb-2">
{reservationsSum || awaitingAllocation ? (
<div className="gap-y-base grid grid-cols-1 divide-y">
{!!awaitingAllocation && (
<span className="flex w-full items-center">
{awaitingAllocation} items await allocation
</span>
)}
{reservations?.map((reservation) => (
<EditAllocationButton
key={reservation.id}
locationName={locationMap.get(reservation.location_id)}
totalReservedQuantity={reservationsSum}
reservation={reservation}
lineItem={lineItem}
onClick={() => setReservation(reservation)}
/>
))}
</div>
) : (
<span className="flex w-full items-center">
This item has been fulfilled.
</span>
)}
</div>
}
side="bottom"
>
{awaitingAllocation ? (
reservationsSum ? (
<CircleQuarterSolid size={20} />
) : (
<WarningCircleIcon fillType="solid" size={20} />
)
) : (
<CheckCircleFillIcon size={20} />
)}
</Tooltip>
{reservation && (
<EditAllocationDrawer
totalReservedQuantity={reservationsSum}
close={() => setReservation(null)}
reservation={reservation}
item={lineItem}
/>
)}
</div>
)
}
const EditAllocationButton = ({
reservation,
locationName,
onClick,
}: {
reservation: ReservationItemDTO
totalReservedQuantity: number
locationName?: string
lineItem: LineItem
onClick: () => void
}) => {
return (
<div className="pt-base first:pt-0">
{`${reservation.quantity} item: ${locationName}`}
<Button
onClick={onClick}
variant="ghost"
size="small"
className="mt-2 w-full border"
>
Edit Allocation
</Button>
</div>
)
}
export default ReservationIndicator
@@ -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<string, ReservationItemDTO[]>
close: () => void
}
const AllocateItemsModal: React.FC<AllocateItemsModalProps> = ({
order,
items,
close,
reservationItemsMap,
}) => {
@@ -175,7 +176,7 @@ const AllocateItemsModal: React.FC<AllocateItemsModalProps> = ({
<p className="inter-base-regular">
Select the number of items that you wish to allocate.
</p>
{order.items?.map((item, i) => {
{items?.map((item, i) => {
return (
<AllocationLineItem
form={nestedForm(form, `items.${i}` as "items.0")}
@@ -0,0 +1,272 @@
import React, { useMemo } from "react"
import { sum } from "lodash"
import {
AdminGetVariantsVariantInventoryRes,
DraftOrder,
VariantInventory,
} from "@medusajs/medusa"
import Badge from "../../../../components/fundamentals/badge"
import ImagePlaceholder from "../../../../components/fundamentals/image-placeholder"
import BodyCard from "../../../../components/organisms/body-card"
import { formatAmountWithSymbol } from "../../../../utils/prices"
import { DisplayTotal } from "../templates"
import { ActionType } from "../../../../components/molecules/actionables"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import useToggleState from "../../../../hooks/use-toggle-state"
import { useAdminReservations, useMedusa } from "medusa-react"
import { ReservationItemDTO } from "@medusajs/types"
import ReservationIndicator from "../../components/reservation-indicator/reservation-indicator"
import AllocateItemsModal from "../allocations/allocate-items-modal"
import { Response } from "@medusajs/medusa-js"
import StatusIndicator from "../../../../components/fundamentals/status-indicator"
type DraftSummaryCardProps = {
order: DraftOrder
}
const DraftSummaryCard: React.FC<DraftSummaryCardProps> = ({ 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<string, VariantInventory>
>(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<AdminGetVariantsVariantInventoryRes> =>
!!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<string, ReservationItemDTO[]>, 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 (
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
title="Summary"
status={
isFeatureEnabled("inventoryService") &&
Array.isArray(reservations) && (
<StatusIndicator
onClick={allItemsReserved ? undefined : showAllocationModal}
variant={allItemsReserved ? "success" : "danger"}
title={allItemsReserved ? "Allocated" : "Awaits allocation"}
className="rounded-rounded border px-3 py-1.5"
/>
)
}
actionables={actionables}
>
<div className="mt-6">
{cart?.items?.map((item, i) => (
<div
key={i}
className="hover:bg-grey-5 rounded-rounded mx-[-5px] mb-1 flex h-[64px] justify-between py-2 px-[5px]"
>
<div className="flex justify-center space-x-4">
<div className="rounded-rounded flex h-[48px] w-[36px] items-center justify-center">
{item?.thumbnail ? (
<img
src={item.thumbnail}
className="rounded-rounded object-cover"
/>
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col justify-center">
<span className="inter-small-regular text-grey-90 max-w-[225px] truncate">
{item.title}
</span>
{item?.variant && (
<span className="inter-small-regular text-grey-50">
{item.variant.sku}
</span>
)}
</div>
</div>
<div className="flex items-center">
<div className="small:space-x-2 medium:space-x-4 large:space-x-6 mr-3 flex">
<div className="inter-small-regular text-grey-50">
{formatAmountWithSymbol({
amount: (item?.total ?? 0) / item.quantity,
currency: region?.currency_code ?? "",
digits: 2,
tax: [],
})}
</div>
<div className="inter-small-regular text-grey-50">
x {item.quantity}
</div>
{inventoryEnabled && (
<ReservationIndicator
reservations={reservationItemsMap[item.id]}
lineItem={item}
/>
)}
<div className="inter-small-regular text-grey-90">
{formatAmountWithSymbol({
amount: item.total ?? 0,
currency: region?.currency_code ?? "",
digits: 2,
tax: [],
})}
</div>
</div>
<div className="inter-small-regular text-grey-50">
{region?.currency_code.toUpperCase()}
</div>
</div>
</div>
))}
<DisplayTotal
currency={region?.currency_code}
totalAmount={order?.cart?.subtotal}
totalTitle={"Subtotal"}
/>
{cart?.discounts?.map((discount, index) => (
<div key={index} className="mt-4 flex items-center justify-between">
<div className="inter-small-regular text-grey-90 flex items-center">
Discount:{" "}
<Badge className="ml-3" variant="default">
{discount.code}
</Badge>
</div>
<div className="inter-small-regular text-grey-90">
-
{formatAmountWithSymbol({
amount: cart?.discount_total,
currency: region?.currency_code || "",
digits: 2,
tax: region?.tax_rate,
})}
</div>
</div>
))}
<DisplayTotal
currency={region?.currency_code}
totalAmount={cart?.shipping_total}
totalTitle={"Shipping"}
/>
<DisplayTotal
currency={region?.currency_code}
totalAmount={cart?.tax_total}
totalTitle={`Tax`}
/>
<DisplayTotal
currency={region?.currency_code}
variant="large"
totalAmount={cart?.total}
totalTitle={`Total`}
/>
</div>
{allocationModalIsOpen && (
<AllocateItemsModal
reservationItemsMap={reservationItemsMap}
items={cart.items}
close={closeAllocationModal}
/>
)}
</BodyCard>
)
}
export default DraftSummaryCard
@@ -261,7 +261,7 @@ const SummaryCard: React.FC<SummaryCardProps> = ({ order, reservations }) => {
{allocationModalIsOpen && (
<AllocateItemsModal
reservationItemsMap={reservationItemsMap}
order={order}
items={order.items}
close={closeAllocationModal}
/>
)}
@@ -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<ReservationItemDTO | null>(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 <div className="w-[20px]" />
}
return (
<div className={awaitingAllocation ? "text-rose-50" : "text-grey-40"}>
<Tooltip
content={
<div className="inter-small-regular flex flex-col items-center px-1 pt-1 pb-2">
{reservationsSum || awaitingAllocation ? (
<div className="gap-y-base grid grid-cols-1 divide-y">
{!!awaitingAllocation && (
<span className="flex w-full items-center">
{awaitingAllocation} items await allocation
</span>
)}
{reservations?.map((reservation) => (
<EditAllocationButton
key={reservation.id}
locationName={locationMap.get(reservation.location_id)}
totalReservedQuantity={reservationsSum}
reservation={reservation}
lineItem={lineItem}
onClick={() => setReservation(reservation)}
/>
))}
</div>
) : (
<span className="flex w-full items-center">
This item has been fulfilled.
</span>
)}
</div>
}
side="bottom"
>
{awaitingAllocation ? (
reservationsSum ? (
<CircleQuarterSolid size={20} />
) : (
<WarningCircleIcon fillType="solid" size={20} />
)
) : (
<CheckCircleFillIcon size={20} />
)}
</Tooltip>
{reservation && (
<EditAllocationDrawer
totalReservedQuantity={reservationsSum}
close={() => setReservation(null)}
reservation={reservation}
item={lineItem}
/>
)}
</div>
)
}
const EditAllocationButton = ({
reservation,
locationName,
onClick,
}: {
reservation: ReservationItemDTO
totalReservedQuantity: number
locationName?: string
lineItem: LineItem
onClick: () => void
}) => {
return (
<div className="pt-base first:pt-0">
{`${reservation.quantity} item: ${locationName}`}
<Button
onClick={onClick}
variant="ghost"
size="small"
className="mt-2 w-full border"
>
Edit Allocation
</Button>
</div>
)
}
export default OrderLine
@@ -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 = () => {
</div>
</div>
</BodyCard>
<BodyCard className={"mb-4 h-auto min-h-0 w-full"} title="Summary">
<div className="mt-6">
{cart?.items?.map((item, i) => (
<div
key={i}
className="hover:bg-grey-5 rounded-rounded mx-[-5px] mb-1 flex h-[64px] justify-between py-2 px-[5px]"
>
<div className="flex justify-center space-x-4">
<div className="rounded-rounded flex h-[48px] w-[36px] items-center justify-center">
{item?.thumbnail ? (
<img
src={item.thumbnail}
className="rounded-rounded object-cover"
/>
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col justify-center">
<span className="inter-small-regular text-grey-90 max-w-[225px] truncate">
{item.title}
</span>
{item?.variant && (
<span className="inter-small-regular text-grey-50">
{item.variant.sku}
</span>
)}
</div>
</div>
<div className="flex items-center">
<div className="small:space-x-2 medium:space-x-4 large:space-x-6 mr-3 flex">
<div className="inter-small-regular text-grey-50">
{formatAmountWithSymbol({
amount: (item?.total ?? 0) / item.quantity,
currency: region?.currency_code ?? "",
digits: 2,
tax: [],
})}
</div>
<div className="inter-small-regular text-grey-50">
x {item.quantity}
</div>
<div className="inter-small-regular text-grey-90">
{formatAmountWithSymbol({
amount: item.total ?? 0,
currency: region?.currency_code ?? "",
digits: 2,
tax: [],
})}
</div>
</div>
<div className="inter-small-regular text-grey-50">
{region?.currency_code.toUpperCase()}
</div>
</div>
</div>
))}
<DisplayTotal
currency={region?.currency_code}
totalAmount={draft_order?.cart?.subtotal}
totalTitle={"Subtotal"}
/>
{cart?.discounts?.map((discount, index) => (
<div
key={index}
className="mt-4 flex items-center justify-between"
>
<div className="inter-small-regular text-grey-90 flex items-center">
Discount:{" "}
<Badge className="ml-3" variant="default">
{discount.code}
</Badge>
</div>
<div className="inter-small-regular text-grey-90">
-
{formatAmountWithSymbol({
amount: cart?.discount_total,
currency: region?.currency_code || "",
digits: 2,
tax: region?.tax_rate,
})}
</div>
</div>
))}
<DisplayTotal
currency={region?.currency_code}
totalAmount={cart?.shipping_total}
totalTitle={"Shipping"}
/>
<DisplayTotal
currency={region?.currency_code}
totalAmount={cart?.tax_total}
totalTitle={`Tax`}
/>
<DisplayTotal
currency={region?.currency_code}
variant="large"
totalAmount={cart?.total}
totalTitle={`Total`}
/>
</div>
</BodyCard>
<DraftSummaryCard order={draft_order} />
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
title="Payment"