feat(dashboard): allocate items (#8021)

**What**
- add Allocate items flow

---


https://github.com/medusajs/medusa/assets/16856471/8485c8dc-b1f2-4239-bb22-996345d5d2ad
This commit is contained in:
Frane Polić
2024-07-12 09:57:00 +02:00
committed by GitHub
parent ba06c9557c
commit 4c2e9a3239
12 changed files with 820 additions and 27 deletions

View File

@@ -578,7 +578,7 @@
"reservation": { "reservation": {
"header": "Reservation of {{itemName}}", "header": "Reservation of {{itemName}}",
"editItemDetails": "Edit reservation", "editItemDetails": "Edit reservation",
"orderID": "Order ID", "lineItemId": "Line item ID",
"description": "Description", "description": "Description",
"location": "Location", "location": "Location",
"inStockAtLocation": "In stock at this location", "inStockAtLocation": "In stock at this location",
@@ -765,6 +765,22 @@
"allocatedLabel": "Allocated", "allocatedLabel": "Allocated",
"notAllocatedLabel": "Not allocated" "notAllocatedLabel": "Not allocated"
}, },
"allocateItems": {
"action": "Allocate items",
"title": "Allocate order items",
"locationDescription": "Choose which location you want to allocate from.",
"itemsToAllocate": "Items to allocate",
"itemsToAllocateDesc": "Select the number of items you wish to allocate",
"search": "Search items",
"consistsOf": "Consists of {{num}}x inventory items",
"requires": "Requires {{num}} per variant",
"toast": {
"created": "Items successfully allocated"
},
"error": {
"quantityNotAllocated": "There are unallocated items."
}
},
"shipment": { "shipment": {
"title": "Mark fulfillment shipped", "title": "Mark fulfillment shipped",
"trackingNumber": "Tracking number", "trackingNumber": "Tracking number",
@@ -2055,7 +2071,9 @@
}, },
"labels": { "labels": {
"productVariant": "Product Variant", "productVariant": "Product Variant",
"prices": "Prices" "prices": "Prices",
"available": "Available",
"inStock": "In stock"
}, },
"fields": { "fields": {
"amount": "Amount", "amount": "Amount",
@@ -2071,6 +2089,7 @@
"inventoryItems": "Inventory items", "inventoryItems": "Inventory items",
"inventoryItem": "Inventory item", "inventoryItem": "Inventory item",
"requiredQuantity": "Required quantity", "requiredQuantity": "Required quantity",
"qty": "Qty",
"description": "Description", "description": "Description",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",

View File

@@ -217,6 +217,11 @@ export const RouteMap: RouteObject[] = [
lazy: () => lazy: () =>
import("../../routes/orders/order-create-fulfillment"), import("../../routes/orders/order-create-fulfillment"),
}, },
{
path: "allocate-items",
lazy: () =>
import("../../routes/orders/order-allocate-items"),
},
{ {
path: ":f_id/create-shipment", path: ":f_id/create-shipment",
lazy: () => lazy: () =>

View File

@@ -1,13 +1,13 @@
import { Container, Heading } from "@medusajs/ui" import { Container, Heading } from "@medusajs/ui"
import { AdminInventoryItemResponse } from "@medusajs/types"
import { ActionMenu } from "../../../../components/common/action-menu" import { ActionMenu } from "../../../../components/common/action-menu"
import { InventoryItemRes } from "../../../../types/api-responses"
import { PencilSquare } from "@medusajs/icons" import { PencilSquare } from "@medusajs/icons"
import { SectionRow } from "../../../../components/common/section" import { SectionRow } from "../../../../components/common/section"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
type InventoryItemGeneralSectionProps = { type InventoryItemGeneralSectionProps = {
inventoryItem: InventoryItemRes["inventory_item"] inventoryItem: AdminInventoryItemResponse["inventory_item"]
} }
export const InventoryItemGeneralSection = ({ export const InventoryItemGeneralSection = ({
inventoryItem, inventoryItem,

View File

@@ -0,0 +1,6 @@
import { z } from "zod"
export const AllocateItemsSchema = z.object({
location_id: z.string(),
quantity: z.record(z.string(), z.number().or(z.string())),
})

View File

@@ -0,0 +1 @@
export * from "./order-allocate-items-form"

View File

@@ -0,0 +1,330 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { AdminOrder, InventoryItemDTO, OrderLineItemDTO } from "@medusajs/types"
import { Alert, Button, Heading, Input, Select, toast } from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { Form } from "../../../../../components/common/form"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useCreateReservationItem } from "../../../../../hooks/api/reservations"
import { OrderAllocateItemsItem } from "./order-allocate-items-item"
import { AllocateItemsSchema } from "./constants"
import { queryClient } from "../../../../../lib/query-client"
import { ordersQueryKeys } from "../../../../../hooks/api/orders"
type OrderAllocateItemsFormProps = {
order: AdminOrder
}
export function OrderAllocateItemsForm({ order }: OrderAllocateItemsFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [disableSubmit, setDisableSubmit] = useState(false)
const [filterTerm, setFilterTerm] = useState("")
const { mutateAsync: allocateItems, isPending: isMutating } =
useCreateReservationItem()
const itemsToAllocate = useMemo(
() =>
order.items.filter(
(item) =>
item.variant.manage_inventory &&
item.variant.inventory.length &&
item.quantity - item.detail.fulfilled_quantity > 0
),
[order.items]
)
const filteredItems = useMemo(() => {
return itemsToAllocate.filter(
(i) =>
i.variant.title.toLowerCase().includes(filterTerm) ||
i.variant.product.title.toLowerCase().includes(filterTerm)
)
}, [itemsToAllocate, filterTerm])
// TODO - empty state UI
const noItemsToAllocate = !itemsToAllocate.length
const form = useForm<zod.infer<typeof AllocateItemsSchema>>({
defaultValues: {
location_id: "",
quantity: defaultAllocations(itemsToAllocate),
},
resolver: zodResolver(AllocateItemsSchema),
})
const { stock_locations = [] } = useStockLocations()
const handleSubmit = form.handleSubmit(async (data) => {
try {
const payload = Object.entries(data.quantity)
.filter(([key]) => !key.endsWith("-"))
.map(([key, quantity]) => [...key.split("-"), quantity])
if (payload.some((d) => d[2] === "")) {
form.setError("root.quantityNotAllocated", {
type: "manual",
message: t("orders.allocateItems.error.quantityNotAllocated"),
})
return
}
const promises = payload.map(([itemId, inventoryId, quantity]) =>
allocateItems({
location_id: data.location_id,
inventory_item_id: inventoryId,
line_item_id: itemId,
quantity,
})
)
/**
* TODO: we should have bulk endpoint for this so this is executed in a workflow and can be reverted
*/
await Promise.all(promises)
// invalidate order details so we get new item.variant.inventory items
await queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
handleSuccess(`/orders/${order.id}`)
toast.success(t("general.success"), {
description: t("orders.allocateItems.toast.created"),
dismissLabel: t("actions.close"),
})
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
const onQuantityChange = (
inventoryItem: InventoryItemDTO,
lineItem: OrderLineItemDTO,
hasInventoryKit: boolean,
value: number | null,
isRoot?: boolean
) => {
let shouldDisableSubmit = false
const key =
isRoot && hasInventoryKit
? `quantity.${lineItem.id}-`
: `quantity.${lineItem.id}-${inventoryItem.id}`
form.setValue(key, value)
if (value) {
const location = inventoryItem.location_levels.find(
(l) => l.location_id === selectedLocationId
)
if (location) {
if (location.available_quantity < value) {
shouldDisableSubmit = true
}
}
}
if (hasInventoryKit && !isRoot) {
// changed subitem in the kit -> we need to set parent to "-"
form.resetField(`quantity.${lineItem.id}-`, { defaultValue: "" })
}
if (hasInventoryKit && isRoot) {
// changed root -> we need to set items to parent quantity x required_quantity
const item = itemsToAllocate.find((i) => i.id === lineItem.id)
item.variant.inventory_items.forEach((ii, ind) => {
const num = value || 0
const inventory = item.variant.inventory[ind]
form.setValue(
`quantity.${lineItem.id}-${inventory.id}`,
num * ii.required_quantity
)
if (value) {
const location = inventory.location_levels.find(
(l) => l.location_id === selectedLocationId
)
if (location) {
if (location.available_quantity < value) {
shouldDisableSubmit = true
}
}
}
})
}
form.clearErrors("root.quantityNotAllocated")
setDisableSubmit(shouldDisableSubmit)
}
const selectedLocationId = useWatch({
name: "location_id",
control: form.control,
})
useEffect(() => {
if (selectedLocationId) {
form.setValue("quantity", defaultAllocations(itemsToAllocate))
}
}, [selectedLocationId])
const allocationError =
form.formState.errors?.root?.quantityNotAllocated?.message
return (
<RouteFocusModal.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
size="small"
type="submit"
isLoading={isMutating}
disabled={!selectedLocationId || disableSubmit}
>
{t("orders.allocateItems.action")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
<div className="flex size-full flex-col items-center overflow-auto p-16">
<div className="flex w-full max-w-[736px] flex-col justify-center px-2 pb-2">
<div className="flex flex-col gap-8 divide-y divide-dashed">
<Heading>{t("orders.allocateItems.title")}</Heading>
<div className="flex-1 divide-y divide-dashed pt-8">
<Form.Field
control={form.control}
name="location_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center gap-3">
<div className="flex-1">
<Form.Label>{t("fields.location")}</Form.Label>
<Form.Hint>
{t("orders.allocateItems.locationDescription")}
</Form.Hint>
</div>
<div className="flex-1">
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger
className="bg-ui-bg-base"
ref={ref}
>
<Select.Value />
</Select.Trigger>
<Select.Content>
{stock_locations.map((l) => (
<Select.Item key={l.id} value={l.id}>
{l.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Item className="mt-8 pt-8">
<div className="flex flex-row items-center">
<div className="flex-1">
<Form.Label>
{t("orders.allocateItems.itemsToAllocate")}
</Form.Label>
<Form.Hint>
{t("orders.allocateItems.itemsToAllocateDesc")}
</Form.Hint>
</div>
<div className="flex-1">
<Input
value={filterTerm}
onChange={(e) => setFilterTerm(e.target.value)}
placeholder={t("orders.allocateItems.search")}
autoComplete="off"
type="search"
/>
</div>
</div>
{allocationError && (
<Alert className="mb-4" dismissible variant="error">
{allocationError}
</Alert>
)}
<div className="flex flex-col gap-y-1">
{filteredItems.map((item) => (
<OrderAllocateItemsItem
key={item.id}
form={form}
item={item}
locationId={selectedLocationId}
onQuantityChange={onQuantityChange}
/>
))}
</div>
</Form.Item>
</div>
</div>
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
function defaultAllocations(items: OrderLineItemDTO) {
const ret = {}
items.forEach((item) => {
const hasInventoryKit = item.variant.inventory_items.length > 1
ret[
hasInventoryKit
? `${item.id}-`
: `${item.id}-${item.variant.inventory[0].id}`
] = ""
if (hasInventoryKit) {
item.variant.inventory.forEach((i) => {
ret[`${item.id}-${i.id}`] = ""
})
}
})
return ret
}

View File

@@ -0,0 +1,348 @@
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { InventoryItemDTO, OrderLineItemDTO } from "@medusajs/types"
import {
Component,
ExclamationCircleSolid,
TriangleDownMini,
} from "@medusajs/icons"
import { UseFormReturn, useWatch } from "react-hook-form"
import { Input, Text, clx } from "@medusajs/ui"
import * as zod from "zod"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { Form } from "../../../../../components/common/form"
import { AllocateItemsSchema } from "./constants"
type OrderEditItemProps = {
item: OrderLineItemDTO
locationId?: string
form: UseFormReturn<zod.infer<typeof AllocateItemsSchema>>
onQuantityChange: (
inventoryItem: InventoryItemDTO,
lineItem: OrderLineItemDTO,
hasInventoryKit: boolean,
value: number | null,
isRoot?: boolean
) => {}
}
export function OrderAllocateItemsItem({
item,
form,
locationId,
onQuantityChange,
}: OrderEditItemProps) {
const { t } = useTranslation()
const variant = item.variant
const inventory = item.variant.inventory
const [isOpen, setIsOpen] = useState(false)
const quantityField = useWatch({
control: form.control,
name: "quantity",
})
const hasInventoryKit =
!!variant?.inventory_items.length && variant?.inventory_items.length > 1
const { availableQuantity, inStockQuantity } = useMemo(() => {
if (!variant || !locationId) {
return {}
}
const { inventory } = variant
const locationInventory = inventory[0]?.location_levels?.find(
(inv) => inv.location_id === locationId
)
if (!locationInventory) {
return {}
}
return {
availableQuantity: locationInventory.available_quantity,
inStockQuantity: locationInventory.stocked_quantity,
}
}, [variant, locationId])
const hasQuantityError =
!hasInventoryKit &&
availableQuantity &&
quantityField[`${item.id}-${item.variant.inventory[0].id}`] &&
quantityField[`${item.id}-${item.variant.inventory[0].id}`] >
availableQuantity
const minValue = 0
const maxValue = Math.min(
getFulfillableQuantity(item),
availableQuantity || Number.MAX_SAFE_INTEGER
)
return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 min-w-[720px] divide-y divide-dashed rounded-xl">
<div className="flex items-center gap-x-3 p-3 text-sm">
<div className="flex flex-1 items-center">
<div className="flex items-center gap-x-3">
{hasQuantityError && (
<ExclamationCircleSolid className="text-ui-fg-error" />
)}
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div className="flex flex-row">
<Text className="txt-small flex" as="span" weight="plus">
{item.variant.product.title}
</Text>
{item.variant.sku && (
<span className="text-ui-fg-subtle">
{" "}
({item.variant.sku})
</span>
)}
{hasInventoryKit && (
<Component className="text-ui-fg-muted ml-2 overflow-visible pt-[2px]" />
)}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.title}
</Text>
</div>
</div>
</div>
<div
className={clx(
"flex flex-1 items-center gap-x-3",
hasInventoryKit ? "justify-end" : "justify-between"
)}
>
{!hasInventoryKit && (
<>
<div className="flex items-center gap-3">
<div className="bg-ui-border-strong block h-[12px] w-[1px]" />
<div className="txt-small flex flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("labels.available")}
</span>
<span className="text-ui-fg-muted">
{availableQuantity || "-"}
{availableQuantity &&
!hasInventoryKit &&
quantityField[
`${item.id}-${item.variant.inventory[0].id}`
] && (
<span className="text-ui-fg-error txt-small ml-1">
-
{
quantityField[
`${item.id}-${item.variant.inventory[0].id}`
]
}
</span>
)}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="bg-ui-border-strong block h-[12px] w-[1px]" />
<div className="txt-small flex flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("labels.inStock")}
</span>
<span className="text-ui-fg-muted">
{inStockQuantity || "-"}
</span>
</div>
</div>
</>
)}
<div className="flex items-center gap-3">
<div className="bg-ui-border-strong block h-[12px] w-[1px]" />
<div className="text-ui-fg-subtle txt-small mr-2 flex flex-row items-center gap-2">
<Form.Field
control={form.control}
name={
hasInventoryKit
? `quantity.${item.id}-`
: `quantity.${item.id}-${item.variant.inventory[0].id}`
}
rules={{
required: !hasInventoryKit,
min: !hasInventoryKit && minValue,
max: maxValue,
}}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
className="bg-ui-bg-base txt-small w-[46px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
{...field}
disabled={!locationId}
onChange={(e) => {
const val =
e.target.value === ""
? null
: Number(e.target.value)
onQuantityChange(
item.variant.inventory[0],
item,
hasInventoryKit,
val,
true
)
}}
/>
</Form.Control>
</Form.Item>
)
}}
/>{" "}
/ {item.quantity} {t("fields.qty")}
</div>
</div>
</div>
</div>
{hasInventoryKit && (
<div className="px-4 py-2">
<div
onClick={() => setIsOpen((o) => !o)}
className="flex items-center gap-x-2"
>
<TriangleDownMini
style={{ transform: `rotate(${isOpen ? -90 : 0}deg)` }}
className="text-ui-fg-muted -mt-[1px]"
/>
<span className="txt-small text-ui-fg-muted cursor-pointer">
{t("orders.allocateItems.consistsOf", {
num: inventory.length,
})}
</span>
</div>
</div>
)}
{isOpen &&
variant.inventory.map((i, ind) => {
const location = i.location_levels.find(
(l) => l.location_id === locationId
)
const hasQuantityError =
!!quantityField[`${item.id}-${i.id}`] &&
quantityField[`${item.id}-${i.id}`] > location.available_quantity
return (
<div key={i.id} className="txt-small flex items-center gap-x-3 p-4">
<div className="flex flex-1 flex-row items-center gap-3">
{hasQuantityError && (
<ExclamationCircleSolid className="text-ui-fg-error" />
)}
<div className="flex flex-col">
<span className="text-ui-fg-subtle">{i.title}</span>
<span className="text-ui-fg-muted">
{t("orders.allocateItems.requires", {
num: variant.inventory_items[ind].required_quantity,
})}
</span>
</div>
</div>
<div className="flex flex-1 flex-row justify-between">
<div className="flex items-center gap-3">
<div className="bg-ui-border-strong block h-[12px] w-[1px]" />
<div className="txt-small flex flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("labels.available")}
</span>
<span className="text-ui-fg-muted">
{location?.available_quantity || "-"}
{location?.available_quantity &&
quantityField[`${item.id}-${i.id}`] && (
<span className="text-ui-fg-error txt-small ml-1">
-{quantityField[`${item.id}-${i.id}`]}
</span>
)}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="bg-ui-border-strong block h-[12px] w-[1px]" />
<div className="txt-small flex flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("labels.inStock")}
</span>
<span className="text-ui-fg-muted">
{location?.stocked_quantity || "-"}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="bg-ui-border-strong block h-[12px] w-[1px]" />
<div className="text-ui-fg-subtle txt-small mr-1 flex flex-row items-center gap-2">
<Form.Field
control={form.control}
name={`quantity.${item.id}-${i.id}`}
rules={{
required: true,
min: 0,
max: location?.available_quantity,
}}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
className="bg-ui-bg-base txt-small w-[46px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
{...field}
disabled={!locationId}
onChange={(e) => {
const val =
e.target.value === ""
? null
: Number(e.target.value)
onQuantityChange(
i,
item,
hasInventoryKit,
val
)
}}
/>
</Form.Control>
</Form.Item>
)
}}
/>
/{" "}
{item.quantity *
variant.inventory_items[ind].required_quantity}{" "}
{t("fields.qty")}
</div>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1 @@
export { OrderAllocateItems as Component } from "./order-allocate-items"

View File

@@ -0,0 +1,26 @@
import { useParams } from "react-router-dom"
import { useOrder } from "../../../hooks/api/orders"
import { RouteFocusModal } from "../../../components/modals"
import { OrderAllocateItemsForm } from "./components/order-create-fulfillment-form"
export function OrderAllocateItems() {
const { id } = useParams()
const { order, isLoading, isError, error } = useOrder(id!, {
fields:
"currency_code,*items,*items.variant,+items.variant.product.title,*items.variant.inventory,*items.variant.inventory.location_levels,*items.variant.inventory_items,*shipping_address",
})
if (isError) {
throw error
}
const ready = !isLoading && order
return (
<RouteFocusModal>
{ready && <OrderAllocateItemsForm order={order} />}
</RouteFocusModal>
)
}

View File

@@ -3,8 +3,17 @@ import {
OrderLineItemDTO, OrderLineItemDTO,
ReservationItemDTO, ReservationItemDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui" import {
Button,
Container,
Copy,
Heading,
StatusBadge,
Text,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useMemo } from "react"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { Thumbnail } from "../../../../../components/common/thumbnail" import { Thumbnail } from "../../../../../components/common/thumbnail"
@@ -12,18 +21,65 @@ import {
getLocaleAmount, getLocaleAmount,
getStylizedAmount, getStylizedAmount,
} from "../../../../../lib/money-amount-helpers" } from "../../../../../lib/money-amount-helpers"
import { useReservationItems } from "../../../../../hooks/api/reservations"
type OrderSummarySectionProps = { type OrderSummarySectionProps = {
order: AdminOrder order: AdminOrder
} }
export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => { export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { reservations } = useReservationItems({
line_item_id: order.items.map((i) => i.id),
})
/**
* Show Allocation button only if there are unfulfilled items that don't have reservations
*/
const showAllocateButton = useMemo(() => {
if (!reservations) {
return false
}
const reservationsMap = new Map(
reservations.map((r) => [r.line_item_id, r.id])
)
for (const item of order.items) {
// Inventory is managed
if (item.variant?.manage_inventory) {
// There are items that are unfulfilled
if (item.quantity - item.detail.fulfilled_quantity > 0) {
// Reservation for this item doesn't exist
if (!reservationsMap.has(item.id)) {
return true
}
}
}
}
return false
}, [reservations])
return ( return (
<Container className="divide-y divide-dashed p-0"> <Container className="divide-y divide-dashed p-0">
<Header order={order} /> <Header order={order} />
<ItemBreakdown order={order} /> <ItemBreakdown order={order} />
<CostBreakdown order={order} /> <CostBreakdown order={order} />
<Total order={order} /> <Total order={order} />
{showAllocateButton && (
<div className="bg-ui-bg-subtle flex items-center justify-end rounded-b-xl px-4 py-4">
<Button
onClick={() => navigate(`./allocate-items`)}
variant="secondary"
>
{t("orders.allocateItems.action")}
</Button>
</div>
)}
</Container> </Container>
) )
} }
@@ -71,6 +127,7 @@ const Item = ({
reservation?: ReservationItemDTO | null reservation?: ReservationItemDTO | null
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const isInventoryManaged = item.variant.manage_inventory
return ( return (
<div <div
@@ -112,14 +169,16 @@ const Item = ({
</Text> </Text>
</div> </div>
<div className="overflow-visible"> <div className="overflow-visible">
<StatusBadge {isInventoryManaged && (
color={reservation ? "green" : "orange"} <StatusBadge
className="text-nowrap" color={reservation ? "green" : "orange"}
> className="text-nowrap"
{reservation >
? t("orders.reservations.allocatedLabel") {reservation
: t("orders.reservations.notAllocatedLabel")} ? t("orders.reservations.allocatedLabel")
</StatusBadge> : t("orders.reservations.notAllocatedLabel")}
</StatusBadge>
)}
</div> </div>
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
@@ -133,27 +192,23 @@ const Item = ({
} }
const ItemBreakdown = ({ order }: { order: AdminOrder }) => { const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
// const { reservations, isError, error } = useAdminReservations({ const { reservations } = useReservationItems({
// line_item_id: order.items.map((i) => i.id), line_item_id: order.items.map((i) => i.id),
// }) })
// if (isError) {
// throw error
// }
return ( return (
<div> <div>
{order.items.map((item) => { {order.items.map((item) => {
// const reservation = reservations const reservation = reservations
// ? reservations.find((r) => r.line_item_id === item.id) ? reservations.find((r) => r.line_item_id === item.id)
// : null : null
return ( return (
<Item <Item
key={item.id} key={item.id}
item={item} item={item}
currencyCode={order.currency_code} currencyCode={order.currency_code}
reservation={null /* TODO: fetch reservation for this item */} reservation={reservation}
/> />
) )
})} })}

View File

@@ -18,6 +18,7 @@ const DEFAULT_RELATIONS = [
"*customer", "*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", // -> 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.options", "*items.variant.options",
"+items.variant.manage_inventory",
"*shipping_address", "*shipping_address",
"*billing_address", "*billing_address",
"*sales_channel", "*sales_channel",

View File

@@ -1,4 +1,4 @@
import { AdminReservationResponse, StockLocationDTO } from "@medusajs/types" import { AdminReservationResponse } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui" import { Container, Heading } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
@@ -8,6 +8,7 @@ import { SectionRow } from "../../../../../components/common/section"
import { useInventoryItem } from "../../../../../hooks/api/inventory" import { useInventoryItem } from "../../../../../hooks/api/inventory"
import { useStockLocation } from "../../../../../hooks/api/stock-locations" import { useStockLocation } from "../../../../../hooks/api/stock-locations"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useOrder } from "../../../../../hooks/api"
type ReservationGeneralSectionProps = { type ReservationGeneralSectionProps = {
reservation: AdminReservationResponse["reservation"] reservation: AdminReservationResponse["reservation"]
@@ -61,8 +62,8 @@ export const ReservationGeneralSection = ({
/> />
</div> </div>
<SectionRow <SectionRow
title={t("inventory.reservation.orderID")} title={t("inventory.reservation.lineItemId")}
value={reservation.line_item_id} // TODO fetch order value={reservation.line_item_id} // TODO fetch order instead + add link
/> />
<SectionRow <SectionRow
title={t("inventory.reservation.description")} title={t("inventory.reservation.description")}