feat(dashboard): update create fulfillment UI part 1 (#8972)
**What** - update Create fulfillment modal according to the design **Note** - in a followup I will add support for inventory kits **Question** - should we support overriding shipping method as per design? --- Before:  After: 
This commit is contained in:
@@ -1076,6 +1076,7 @@
|
||||
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
|
||||
"locationDescription": "Choose which location you want to fulfill items from.",
|
||||
"sendNotificationHint": "Notify customers about the created fulfillment.",
|
||||
"methodDescription": "Choose a different shipping method from the one customer selected",
|
||||
"error": {
|
||||
"wrongQuantity": "Only one item is available for fulfillment",
|
||||
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
|
||||
@@ -2464,6 +2465,7 @@
|
||||
"newPassword": "New Password",
|
||||
"repeatNewPassword": "Repeat New Password",
|
||||
"categories": "Categories",
|
||||
"shippingMethod": "Shipping method",
|
||||
"configurations": "Configurations",
|
||||
"conditions": "Conditions",
|
||||
"category": "Category",
|
||||
|
||||
@@ -4,5 +4,6 @@ export const CreateFulfillmentSchema = z.object({
|
||||
quantity: z.record(z.string(), z.number()),
|
||||
|
||||
location_id: z.string(),
|
||||
shipping_option_id: z.string().optional(),
|
||||
send_notification: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders"
|
||||
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
|
||||
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
||||
import { CreateFulfillmentSchema } from "./constants"
|
||||
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
|
||||
import { CreateFulfillmentSchema } from "./constants"
|
||||
import { useShippingOptions } from "../../../../../hooks/api"
|
||||
|
||||
type OrderCreateFulfillmentFormProps = {
|
||||
order: AdminOrder
|
||||
@@ -38,24 +39,23 @@ export function OrderCreateFulfillmentForm({
|
||||
|
||||
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
|
||||
defaultValues: {
|
||||
quantity: fulfillableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
quantity: fulfillableItems.reduce((acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item)
|
||||
return acc
|
||||
}, {} as Record<string, number>),
|
||||
send_notification: !order.no_notification,
|
||||
},
|
||||
resolver: zodResolver(CreateFulfillmentSchema),
|
||||
})
|
||||
|
||||
const { stock_locations = [] } = useStockLocations()
|
||||
const { shipping_options = [] } = useShippingOptions()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await createOrderFulfillment({
|
||||
location_id: data.location_id,
|
||||
// shipping_option_id: data.shipping_option_id,
|
||||
no_notification: !data.send_notification,
|
||||
items: Object.entries(data.quantity)
|
||||
.filter(([, value]) => !!value)
|
||||
@@ -78,23 +78,6 @@ export function OrderCreateFulfillmentForm({
|
||||
}
|
||||
}, [stock_locations?.length])
|
||||
|
||||
const onItemRemove = (itemId: string) => {
|
||||
setFulfillableItems((state) => state.filter((i) => i.id !== itemId))
|
||||
form.unregister(`quantity.${itemId}`)
|
||||
}
|
||||
|
||||
const resetItems = () => {
|
||||
const items = (order.items || []).filter(
|
||||
(item) => getFulfillableQuantity(item) > 0
|
||||
)
|
||||
setFulfillableItems(items)
|
||||
|
||||
items.forEach((i) =>
|
||||
form.register(`quantity.${i.id}`, { value: getFulfillableQuantity(i) })
|
||||
)
|
||||
form.clearErrors("root")
|
||||
}
|
||||
|
||||
const selectedLocationId = useWatch({
|
||||
name: "location_id",
|
||||
control: form.control,
|
||||
@@ -119,13 +102,10 @@ export function OrderCreateFulfillmentForm({
|
||||
})
|
||||
}
|
||||
|
||||
const quantityMap = itemsToFulfill.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
const quantityMap = itemsToFulfill.reduce((acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
form.setValue("quantity", quantityMap)
|
||||
}, [...fulfilledQuantityArray])
|
||||
@@ -152,41 +132,91 @@ export function OrderCreateFulfillmentForm({
|
||||
<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 divide-y">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col divide-y divide-dashed">
|
||||
<div className="pb-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="location_id"
|
||||
render={({ field: { onChange, ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.location")}</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("orders.fulfillment.locationDescription")}
|
||||
</Form.Hint>
|
||||
<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 className="flex flex-col gap-2 xl:flex-row xl:items-center">
|
||||
<div className="flex-1">
|
||||
<Form.Label>{t("fields.location")}</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("orders.fulfillment.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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*<div className="py-8">*/}
|
||||
{/* <Form.Field*/}
|
||||
{/* control={form.control}*/}
|
||||
{/* name="shipping_option_id"*/}
|
||||
{/* render={({ field: { onChange, ref, ...field } }) => {*/}
|
||||
{/* return (*/}
|
||||
{/* <Form.Item>*/}
|
||||
{/* <div className="flex flex-col gap-2 xl:flex-row xl:items-center">*/}
|
||||
{/* <div className="flex-1">*/}
|
||||
{/* <Form.Label>*/}
|
||||
{/* {t("fields.shippingMethod")}*/}
|
||||
{/* </Form.Label>*/}
|
||||
{/* <Form.Hint>*/}
|
||||
{/* {t("orders.fulfillment.methodDescription")}*/}
|
||||
{/* </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>*/}
|
||||
{/* {shipping_options.map((o) => (*/}
|
||||
{/* <Select.Item key={o.id} value={o.id}>*/}
|
||||
{/* {o.name}*/}
|
||||
{/* </Select.Item>*/}
|
||||
{/* ))}*/}
|
||||
{/* </Select.Content>*/}
|
||||
{/* </Select>*/}
|
||||
{/* </Form.Control>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* <Form.ErrorMessage />*/}
|
||||
{/* </Form.Item>*/}
|
||||
{/* )*/}
|
||||
{/* }}*/}
|
||||
{/* />*/}
|
||||
{/*</div>*/}
|
||||
<div>
|
||||
<Form.Item className="mt-8">
|
||||
<Form.Label>
|
||||
{t("orders.fulfillment.itemsToFulfill")}
|
||||
@@ -202,9 +232,7 @@ export function OrderCreateFulfillmentForm({
|
||||
key={item.id}
|
||||
form={form}
|
||||
item={item}
|
||||
onItemRemove={onItemRemove}
|
||||
locationId={selectedLocationId}
|
||||
currencyCode={order.currency_code}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -218,14 +246,6 @@ export function OrderCreateFulfillmentForm({
|
||||
classNameInner="flex justify-between flex-1 items-center"
|
||||
>
|
||||
{form.formState.errors.root.message}
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={resetItems}
|
||||
>
|
||||
{t("actions.reset")}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Trash } from "@medusajs/icons"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { Input, Text } from "@medusajs/ui"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu/index.ts"
|
||||
import { Form } from "../../../../../components/common/form/index.ts"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail/index.ts"
|
||||
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell/index.ts"
|
||||
import { useProductVariant } from "../../../../../hooks/api/products.tsx"
|
||||
import { getFulfillableQuantity } from "../../../../../lib/order-item.ts"
|
||||
import { CreateFulfillmentSchema } from "./constants.ts"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
import { Form } from "../../../../../components/common/form/index"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail/index"
|
||||
import { useProductVariant } from "../../../../../hooks/api/products"
|
||||
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
||||
import { CreateFulfillmentSchema } from "./constants"
|
||||
|
||||
type OrderEditItemProps = {
|
||||
item: HttpTypes.AdminOrderLineItem
|
||||
currencyCode: string
|
||||
@@ -24,10 +21,8 @@ type OrderEditItemProps = {
|
||||
|
||||
export function OrderCreateFulfillmentItem({
|
||||
item,
|
||||
currencyCode,
|
||||
form,
|
||||
locationId,
|
||||
onItemRemove,
|
||||
}: OrderEditItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -39,8 +34,6 @@ export function OrderCreateFulfillmentItem({
|
||||
}
|
||||
)
|
||||
|
||||
const hasInventoryItem = !!variant?.inventory.length
|
||||
|
||||
const { availableQuantity, inStockQuantity } = useMemo(() => {
|
||||
if (!variant || !locationId) {
|
||||
return {}
|
||||
@@ -70,8 +63,8 @@ export function OrderCreateFulfillmentItem({
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
|
||||
<div className="flex gap-x-2 border-b p-3 text-sm">
|
||||
<div className="flex flex-grow items-center gap-x-3">
|
||||
<div className="flex flex-col gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row">
|
||||
<div className="flex flex-1 items-center gap-x-3">
|
||||
<Thumbnail src={item.thumbnail} />
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
@@ -86,84 +79,86 @@ export function OrderCreateFulfillmentItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-ui-fg-subtle txt-small mr-2 flex flex-shrink-0 flex-col items-center">
|
||||
<MoneyAmountCell
|
||||
className="justify-end"
|
||||
currencyCode={currencyCode}
|
||||
amount={item.total}
|
||||
/>
|
||||
{hasInventoryItem && (
|
||||
<span>
|
||||
{t("orders.fulfillment.available")}: {availableQuantity || "N/A"}{" "}
|
||||
· {t("orders.fulfillment.inStock")}: {inStockQuantity || "N/A"}
|
||||
<div className="flex flex-1 items-center gap-x-1">
|
||||
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
|
||||
|
||||
<div className="text-small flex flex-1 flex-col">
|
||||
<span className="text-ui-fg-subtle font-medium">
|
||||
{t("orders.fulfillment.available")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-ui-fg-subtle">
|
||||
{availableQuantity || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.remove"),
|
||||
icon: <Trash />,
|
||||
onClick: () => onItemRemove(item.id),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-x-1">
|
||||
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
|
||||
|
||||
<div className="block p-3 text-sm">
|
||||
<div className="flex-1">
|
||||
<Text weight="plus" className="txt-small mb-2">
|
||||
{t("fields.quantity")}
|
||||
</Text>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`quantity.${item.id}`}
|
||||
rules={{ required: true, min: minValue, max: maxValue }}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
className="bg-ui-bg-base txt-small w-full rounded-lg"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
e.target.value === "" ? null : Number(e.target.value)
|
||||
<div className="flex flex-col">
|
||||
<span className="text-ui-fg-subtle font-medium">
|
||||
{t("orders.fulfillment.inStock")}
|
||||
</span>
|
||||
<span className="text-ui-fg-subtle">
|
||||
{inStockQuantity || "N/A"}{" "}
|
||||
{inStockQuantity && (
|
||||
<span className="font-medium text-red-500">
|
||||
-{form.getValues(`quantity.${item.id}`)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
field.onChange(val)
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`quantity.${item.id}`}
|
||||
rules={{ required: true, min: minValue, max: maxValue }}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
className="bg-ui-bg-base txt-small w-[50px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
e.target.value === ""
|
||||
? null
|
||||
: Number(e.target.value)
|
||||
|
||||
if (!isNaN(val)) {
|
||||
if (val < minValue || val > maxValue) {
|
||||
form.setError(`quantity.${item.id}`, {
|
||||
type: "manual",
|
||||
message: t(
|
||||
"orders.fulfillment.error.wrongQuantity",
|
||||
{
|
||||
count: maxValue,
|
||||
number: maxValue,
|
||||
}
|
||||
),
|
||||
})
|
||||
} else {
|
||||
form.clearErrors(`quantity.${item.id}`)
|
||||
field.onChange(val)
|
||||
|
||||
if (!isNaN(val)) {
|
||||
if (val < minValue || val > maxValue) {
|
||||
form.setError(`quantity.${item.id}`, {
|
||||
type: "manual",
|
||||
message: t(
|
||||
"orders.fulfillment.error.wrongQuantity",
|
||||
{
|
||||
count: maxValue,
|
||||
number: maxValue,
|
||||
}
|
||||
),
|
||||
})
|
||||
} else {
|
||||
form.clearErrors(`quantity.${item.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="text-ui-fg-subtle">
|
||||
/ {item.quantity} {t("fields.qty")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user