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:
@@ -578,7 +578,7 @@
|
||||
"reservation": {
|
||||
"header": "Reservation of {{itemName}}",
|
||||
"editItemDetails": "Edit reservation",
|
||||
"orderID": "Order ID",
|
||||
"lineItemId": "Line item ID",
|
||||
"description": "Description",
|
||||
"location": "Location",
|
||||
"inStockAtLocation": "In stock at this location",
|
||||
@@ -765,6 +765,22 @@
|
||||
"allocatedLabel": "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": {
|
||||
"title": "Mark fulfillment shipped",
|
||||
"trackingNumber": "Tracking number",
|
||||
@@ -2055,7 +2071,9 @@
|
||||
},
|
||||
"labels": {
|
||||
"productVariant": "Product Variant",
|
||||
"prices": "Prices"
|
||||
"prices": "Prices",
|
||||
"available": "Available",
|
||||
"inStock": "In stock"
|
||||
},
|
||||
"fields": {
|
||||
"amount": "Amount",
|
||||
@@ -2071,6 +2089,7 @@
|
||||
"inventoryItems": "Inventory items",
|
||||
"inventoryItem": "Inventory item",
|
||||
"requiredQuantity": "Required quantity",
|
||||
"qty": "Qty",
|
||||
"description": "Description",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
|
||||
@@ -217,6 +217,11 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-create-fulfillment"),
|
||||
},
|
||||
{
|
||||
path: "allocate-items",
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-allocate-items"),
|
||||
},
|
||||
{
|
||||
path: ":f_id/create-shipment",
|
||||
lazy: () =>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { AdminInventoryItemResponse } from "@medusajs/types"
|
||||
|
||||
import { ActionMenu } from "../../../../components/common/action-menu"
|
||||
import { InventoryItemRes } from "../../../../types/api-responses"
|
||||
import { PencilSquare } from "@medusajs/icons"
|
||||
import { SectionRow } from "../../../../components/common/section"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type InventoryItemGeneralSectionProps = {
|
||||
inventoryItem: InventoryItemRes["inventory_item"]
|
||||
inventoryItem: AdminInventoryItemResponse["inventory_item"]
|
||||
}
|
||||
export const InventoryItemGeneralSection = ({
|
||||
inventoryItem,
|
||||
|
||||
@@ -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())),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./order-allocate-items-form"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderAllocateItems as Component } from "./order-allocate-items"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,17 @@ import {
|
||||
OrderLineItemDTO,
|
||||
ReservationItemDTO,
|
||||
} 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 { useNavigate } from "react-router-dom"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||
@@ -12,18 +21,65 @@ import {
|
||||
getLocaleAmount,
|
||||
getStylizedAmount,
|
||||
} from "../../../../../lib/money-amount-helpers"
|
||||
import { useReservationItems } from "../../../../../hooks/api/reservations"
|
||||
|
||||
type OrderSummarySectionProps = {
|
||||
order: AdminOrder
|
||||
}
|
||||
|
||||
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 (
|
||||
<Container className="divide-y divide-dashed p-0">
|
||||
<Header order={order} />
|
||||
<ItemBreakdown order={order} />
|
||||
<CostBreakdown 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>
|
||||
)
|
||||
}
|
||||
@@ -71,6 +127,7 @@ const Item = ({
|
||||
reservation?: ReservationItemDTO | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isInventoryManaged = item.variant.manage_inventory
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,14 +169,16 @@ const Item = ({
|
||||
</Text>
|
||||
</div>
|
||||
<div className="overflow-visible">
|
||||
<StatusBadge
|
||||
color={reservation ? "green" : "orange"}
|
||||
className="text-nowrap"
|
||||
>
|
||||
{reservation
|
||||
? t("orders.reservations.allocatedLabel")
|
||||
: t("orders.reservations.notAllocatedLabel")}
|
||||
</StatusBadge>
|
||||
{isInventoryManaged && (
|
||||
<StatusBadge
|
||||
color={reservation ? "green" : "orange"}
|
||||
className="text-nowrap"
|
||||
>
|
||||
{reservation
|
||||
? t("orders.reservations.allocatedLabel")
|
||||
: t("orders.reservations.notAllocatedLabel")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -133,27 +192,23 @@ const Item = ({
|
||||
}
|
||||
|
||||
const ItemBreakdown = ({ order }: { order: AdminOrder }) => {
|
||||
// const { reservations, isError, error } = useAdminReservations({
|
||||
// line_item_id: order.items.map((i) => i.id),
|
||||
// })
|
||||
|
||||
// if (isError) {
|
||||
// throw error
|
||||
// }
|
||||
const { reservations } = useReservationItems({
|
||||
line_item_id: order.items.map((i) => i.id),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{order.items.map((item) => {
|
||||
// const reservation = reservations
|
||||
// ? reservations.find((r) => r.line_item_id === item.id)
|
||||
// : null
|
||||
const reservation = reservations
|
||||
? reservations.find((r) => r.line_item_id === item.id)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
item={item}
|
||||
currencyCode={order.currency_code}
|
||||
reservation={null /* TODO: fetch reservation for this item */}
|
||||
reservation={reservation}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_RELATIONS = [
|
||||
"*customer",
|
||||
"*items", // -> we get LineItem here with added `quantity` and `detail` which is actually an OrderItem (which is a parent object to LineItem in the DB)
|
||||
"*items.variant.options",
|
||||
"+items.variant.manage_inventory",
|
||||
"*shipping_address",
|
||||
"*billing_address",
|
||||
"*sales_channel",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AdminReservationResponse, StockLocationDTO } from "@medusajs/types"
|
||||
import { AdminReservationResponse } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
@@ -8,6 +8,7 @@ import { SectionRow } from "../../../../../components/common/section"
|
||||
import { useInventoryItem } from "../../../../../hooks/api/inventory"
|
||||
import { useStockLocation } from "../../../../../hooks/api/stock-locations"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useOrder } from "../../../../../hooks/api"
|
||||
|
||||
type ReservationGeneralSectionProps = {
|
||||
reservation: AdminReservationResponse["reservation"]
|
||||
@@ -61,8 +62,8 @@ export const ReservationGeneralSection = ({
|
||||
/>
|
||||
</div>
|
||||
<SectionRow
|
||||
title={t("inventory.reservation.orderID")}
|
||||
value={reservation.line_item_id} // TODO fetch order
|
||||
title={t("inventory.reservation.lineItemId")}
|
||||
value={reservation.line_item_id} // TODO fetch order instead + add link
|
||||
/>
|
||||
<SectionRow
|
||||
title={t("inventory.reservation.description")}
|
||||
|
||||
Reference in New Issue
Block a user