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": {
|
"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",
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
Reference in New Issue
Block a user