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:
![Screenshot 2024-09-03 at 17 57 18](https://github.com/user-attachments/assets/733799b6-4ba2-4841-9626-982e0c398694)

After:

![Screenshot 2024-09-03 at 17 51 26](https://github.com/user-attachments/assets/5caf4e3b-312a-40dd-b0ad-3eb4bbba0044)
This commit is contained in:
Frane Polić
2024-09-04 06:17:38 +00:00
committed by GitHub
parent 58c78a7f62
commit 2a6be52236
4 changed files with 170 additions and 152 deletions
@@ -1076,6 +1076,7 @@
"itemsToFulfillDesc": "Choose items and quantities to fulfill", "itemsToFulfillDesc": "Choose items and quantities to fulfill",
"locationDescription": "Choose which location you want to fulfill items from.", "locationDescription": "Choose which location you want to fulfill items from.",
"sendNotificationHint": "Notify customers about the created fulfillment.", "sendNotificationHint": "Notify customers about the created fulfillment.",
"methodDescription": "Choose a different shipping method from the one customer selected",
"error": { "error": {
"wrongQuantity": "Only one item is available for fulfillment", "wrongQuantity": "Only one item is available for fulfillment",
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}", "wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
@@ -2464,6 +2465,7 @@
"newPassword": "New Password", "newPassword": "New Password",
"repeatNewPassword": "Repeat New Password", "repeatNewPassword": "Repeat New Password",
"categories": "Categories", "categories": "Categories",
"shippingMethod": "Shipping method",
"configurations": "Configurations", "configurations": "Configurations",
"conditions": "Conditions", "conditions": "Conditions",
"category": "Category", "category": "Category",
@@ -4,5 +4,6 @@ export const CreateFulfillmentSchema = z.object({
quantity: z.record(z.string(), z.number()), quantity: z.record(z.string(), z.number()),
location_id: z.string(), location_id: z.string(),
shipping_option_id: z.string().optional(),
send_notification: z.boolean().optional(), send_notification: z.boolean().optional(),
}) })
@@ -16,8 +16,9 @@ import {
import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders" import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders"
import { useStockLocations } from "../../../../../hooks/api/stock-locations" import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { getFulfillableQuantity } from "../../../../../lib/order-item" import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { CreateFulfillmentSchema } from "./constants"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item" import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
import { CreateFulfillmentSchema } from "./constants"
import { useShippingOptions } from "../../../../../hooks/api"
type OrderCreateFulfillmentFormProps = { type OrderCreateFulfillmentFormProps = {
order: AdminOrder order: AdminOrder
@@ -38,24 +39,23 @@ export function OrderCreateFulfillmentForm({
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({ const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: { defaultValues: {
quantity: fulfillableItems.reduce( quantity: fulfillableItems.reduce((acc, item) => {
(acc, item) => { acc[item.id] = getFulfillableQuantity(item)
acc[item.id] = getFulfillableQuantity(item) return acc
return acc }, {} as Record<string, number>),
},
{} as Record<string, number>
),
send_notification: !order.no_notification, send_notification: !order.no_notification,
}, },
resolver: zodResolver(CreateFulfillmentSchema), resolver: zodResolver(CreateFulfillmentSchema),
}) })
const { stock_locations = [] } = useStockLocations() const { stock_locations = [] } = useStockLocations()
const { shipping_options = [] } = useShippingOptions()
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
try { try {
await createOrderFulfillment({ await createOrderFulfillment({
location_id: data.location_id, location_id: data.location_id,
// shipping_option_id: data.shipping_option_id,
no_notification: !data.send_notification, no_notification: !data.send_notification,
items: Object.entries(data.quantity) items: Object.entries(data.quantity)
.filter(([, value]) => !!value) .filter(([, value]) => !!value)
@@ -78,23 +78,6 @@ export function OrderCreateFulfillmentForm({
} }
}, [stock_locations?.length]) }, [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({ const selectedLocationId = useWatch({
name: "location_id", name: "location_id",
control: form.control, control: form.control,
@@ -119,13 +102,10 @@ export function OrderCreateFulfillmentForm({
}) })
} }
const quantityMap = itemsToFulfill.reduce( const quantityMap = itemsToFulfill.reduce((acc, item) => {
(acc, item) => { acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO) return acc
return acc }, {} as Record<string, number>)
},
{} as Record<string, number>
)
form.setValue("quantity", quantityMap) form.setValue("quantity", quantityMap)
}, [...fulfilledQuantityArray]) }, [...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"> <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 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 w-full max-w-[736px] flex-col justify-center px-2 pb-2">
<div className="flex flex-col divide-y"> <div className="flex flex-col divide-y divide-dashed">
<div className="flex-1"> <div className="pb-8">
<Form.Field <Form.Field
control={form.control} control={form.control}
name="location_id" name="location_id"
render={({ field: { onChange, ref, ...field } }) => { render={({ field: { onChange, ref, ...field } }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label>{t("fields.location")}</Form.Label> <div className="flex flex-col gap-2 xl:flex-row xl:items-center">
<Form.Hint> <div className="flex-1">
{t("orders.fulfillment.locationDescription")} <Form.Label>{t("fields.location")}</Form.Label>
</Form.Hint> <Form.Hint>
<Form.Control> {t("orders.fulfillment.locationDescription")}
<Select onValueChange={onChange} {...field}> </Form.Hint>
<Select.Trigger </div>
className="bg-ui-bg-base" <div className="flex-1">
ref={ref} <Form.Control>
> <Select onValueChange={onChange} {...field}>
<Select.Value /> <Select.Trigger
</Select.Trigger> className="bg-ui-bg-base"
<Select.Content> ref={ref}
{stock_locations.map((l) => ( >
<Select.Item key={l.id} value={l.id}> <Select.Value />
{l.name} </Select.Trigger>
</Select.Item> <Select.Content>
))} {stock_locations.map((l) => (
</Select.Content> <Select.Item key={l.id} value={l.id}>
</Select> {l.name}
</Form.Control> </Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
</div>
</div>
<Form.ErrorMessage /> <Form.ErrorMessage />
</Form.Item> </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.Item className="mt-8">
<Form.Label> <Form.Label>
{t("orders.fulfillment.itemsToFulfill")} {t("orders.fulfillment.itemsToFulfill")}
@@ -202,9 +232,7 @@ export function OrderCreateFulfillmentForm({
key={item.id} key={item.id}
form={form} form={form}
item={item} item={item}
onItemRemove={onItemRemove}
locationId={selectedLocationId} locationId={selectedLocationId}
currencyCode={order.currency_code}
/> />
) )
})} })}
@@ -218,14 +246,6 @@ export function OrderCreateFulfillmentForm({
classNameInner="flex justify-between flex-1 items-center" classNameInner="flex justify-between flex-1 items-center"
> >
{form.formState.errors.root.message} {form.formState.errors.root.message}
<Button
variant="transparent"
size="small"
type="button"
onClick={resetItems}
>
{t("actions.reset")}
</Button>
</Alert> </Alert>
)} )}
</div> </div>
@@ -1,19 +1,16 @@
import { Trash } from "@medusajs/icons"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { Input, Text } from "@medusajs/ui" import { Input, Text } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form" 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 { 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 = { type OrderEditItemProps = {
item: HttpTypes.AdminOrderLineItem item: HttpTypes.AdminOrderLineItem
currencyCode: string currencyCode: string
@@ -24,10 +21,8 @@ type OrderEditItemProps = {
export function OrderCreateFulfillmentItem({ export function OrderCreateFulfillmentItem({
item, item,
currencyCode,
form, form,
locationId, locationId,
onItemRemove,
}: OrderEditItemProps) { }: OrderEditItemProps) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -39,8 +34,6 @@ export function OrderCreateFulfillmentItem({
} }
) )
const hasInventoryItem = !!variant?.inventory.length
const { availableQuantity, inStockQuantity } = useMemo(() => { const { availableQuantity, inStockQuantity } = useMemo(() => {
if (!variant || !locationId) { if (!variant || !locationId) {
return {} return {}
@@ -70,8 +63,8 @@ export function OrderCreateFulfillmentItem({
return ( return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl "> <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-col gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row">
<div className="flex flex-grow items-center gap-x-3"> <div className="flex flex-1 items-center gap-x-3">
<Thumbnail src={item.thumbnail} /> <Thumbnail src={item.thumbnail} />
<div className="flex flex-col"> <div className="flex flex-col">
<div> <div>
@@ -86,84 +79,86 @@ export function OrderCreateFulfillmentItem({
</div> </div>
</div> </div>
<div className="text-ui-fg-subtle txt-small mr-2 flex flex-shrink-0 flex-col items-center"> <div className="flex flex-1 items-center gap-x-1">
<MoneyAmountCell <div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
className="justify-end"
currencyCode={currencyCode} <div className="text-small flex flex-1 flex-col">
amount={item.total} <span className="text-ui-fg-subtle font-medium">
/> {t("orders.fulfillment.available")}
{hasInventoryItem && (
<span>
{t("orders.fulfillment.available")}: {availableQuantity || "N/A"}{" "}
· {t("orders.fulfillment.inStock")}: {inStockQuantity || "N/A"}
</span> </span>
)} <span className="text-ui-fg-subtle">
</div> {availableQuantity || "N/A"}
</span>
</div>
<div className="flex items-center"> <div className="flex flex-1 items-center gap-x-1">
<ActionMenu <div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
groups={[
{
actions: [
{
label: t("actions.remove"),
icon: <Trash />,
onClick: () => onItemRemove(item.id),
},
],
},
]}
/>
</div>
</div>
<div className="block p-3 text-sm"> <div className="flex flex-col">
<div className="flex-1"> <span className="text-ui-fg-subtle font-medium">
<Text weight="plus" className="txt-small mb-2"> {t("orders.fulfillment.inStock")}
{t("fields.quantity")} </span>
</Text> <span className="text-ui-fg-subtle">
<Form.Field {inStockQuantity || "N/A"}{" "}
control={form.control} {inStockQuantity && (
name={`quantity.${item.id}`} <span className="font-medium text-red-500">
rules={{ required: true, min: minValue, max: maxValue }} -{form.getValues(`quantity.${item.id}`)}
render={({ field }) => { </span>
return ( )}
<Form.Item> </span>
<Form.Control> </div>
<Input </div>
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)
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)) { field.onChange(val)
if (val < minValue || val > maxValue) {
form.setError(`quantity.${item.id}`, { if (!isNaN(val)) {
type: "manual", if (val < minValue || val > maxValue) {
message: t( form.setError(`quantity.${item.id}`, {
"orders.fulfillment.error.wrongQuantity", type: "manual",
{ message: t(
count: maxValue, "orders.fulfillment.error.wrongQuantity",
number: maxValue, {
} count: maxValue,
), number: maxValue,
}) }
} else { ),
form.clearErrors(`quantity.${item.id}`) })
} else {
form.clearErrors(`quantity.${item.id}`)
}
} }
} }}
}} />
/> </Form.Control>
</Form.Control> <Form.ErrorMessage />
<Form.ErrorMessage /> </Form.Item>
</Form.Item> )
) }}
}} />
/>
<span className="text-ui-fg-subtle">
/ {item.quantity} {t("fields.qty")}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>