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 08:17:38 +02:00
committed by GitHub
parent 58c78a7f62
commit 2a6be52236
4 changed files with 170 additions and 152 deletions

View File

@@ -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",

View File

@@ -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(),
})

View File

@@ -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>

View File

@@ -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>