feat(dashboard): order fulfillment UI (#7262)

* feat: initial impl. of Unfulfilled section and create flow

* feat: create fulfillment

* feat: order <> fulfillment link, fulfillment section

* feat: accept order_id when creating fulfillment

* feat: finish create and cancel

* fix: integration test

* refactor: real Order<>Fulfillment link instead readonly, add link step to the workflow

* fix: revert `order_id` definitions

* chore: add changeset

* fix: build

* fix: address comments

* fix: fetch inventory and location levels for fulfilled variant

* fix: loading inventory details

* add isList to order fulfillment link

* fix: duplicate declaration

* fix: type

* refactor: link orders step, fix client

* fix: move translations to the new file

* fix: pass order id in test

---------

Co-authored-by: olivermrbl <oliver@mrbltech.com>
This commit is contained in:
Frane Polić
2024-05-20 13:41:09 +02:00
committed by GitHub
parent c9bffdfecb
commit 521b4e7926
29 changed files with 713 additions and 49 deletions

View File

@@ -0,0 +1,9 @@
---
"@medusajs/link-modules": patch
"@medusajs/core-flows": patch
"@medusajs/types": patch
"@medusajs/utils": patch
"@medusajs/medusa": patch
---
feat: add Order<>Fulfillment link

View File

@@ -8,6 +8,7 @@ export function generateCreateFulfillmentData(
data: Partial<CreateFulfillmentDTO> & {
provider_id: string
shipping_option_id: string
order_id: string
}
) {
const randomString = Math.random().toString(36).substring(7)
@@ -49,6 +50,7 @@ export function generateCreateFulfillmentData(
},
],
order: data.order ?? {},
order_id: data.order_id,
}
}

View File

@@ -69,6 +69,7 @@ medusaIntegrationTestRunner({
const data = generateCreateFulfillmentData({
provider_id: providerId,
shipping_option_id: shippingOption.id,
order_id: "fake-order",
})
const { errors } = await workflow.run({
input: data,

View File

@@ -140,6 +140,7 @@ medusaIntegrationTestRunner({
const data = generateCreateFulfillmentData({
provider_id: providerId,
shipping_option_id: shippingOption.id,
order_id: "order_123",
})
const response = await api

View File

@@ -529,8 +529,20 @@
"unfulfilledItems": "Unfulfilled Items",
"statusLabel": "Fulfillment status",
"statusTitle": "Fulfillment Status",
"awaitingFullfillmentBadge": "Awaiting fulfillment",
"fulfillItems": "Fulfill items",
"awaitingFulfillmentBadge": "Awaiting fulfillment",
"number": "Fulfillment #{{number}}",
"itemsToFulfill": "Items to fulfill",
"create": "Create Fulfillment",
"available": "Available",
"inStock": "In stock",
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
"locationDescription": "Choose which location you want to fulfill items from.",
"error": {
"wrongQuantity": "Only one item is available for fulfillment",
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
"noItems": "No items to fulfill."
},
"status": {
"notFulfilled": "Not fulfilled",
"partiallyFulfilled": "Partially fulfilled",
@@ -543,6 +555,7 @@
"requiresAction": "Requires action"
},
"toast": {
"created": "Fulfillment created successfully",
"canceled": "Fulfillment successfully canceled",
"fulfillmentShipped": "Cannot cancel an already shipped fulfillment"
},

View File

@@ -0,0 +1,43 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { ordersQueryKeys } from "./orders"
const FULFILLMENTS_QUERY_KEY = "fulfillments" as const
export const fulfillmentsQueryKeys = queryKeysFactory(FULFILLMENTS_QUERY_KEY)
export const useCreateFulfillment = (
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) => client.fulfillments.create(payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: fulfillmentsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelFulfillment = (
id: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: () => client.fulfillments.cancel(id),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: fulfillmentsQueryKeys.lists() })
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -536,8 +536,21 @@
"unfulfilledItems": "Unfulfilled Items",
"statusLabel": "Fulfillment status",
"statusTitle": "Fulfillment Status",
"fulfillItems": "Fulfill items",
"awaitingFulfillmentBadge": "Awaiting fulfillment",
"awaitingFullfillmentBadge": "Awaiting fulfillment",
"number": "Fulfillment #{{number}}",
"itemsToFulfill": "Items to fulfill",
"create": "Create Fulfillment",
"available": "Available",
"inStock": "In stock",
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
"locationDescription": "Choose which location you want to fulfill items from.",
"error": {
"wrongQuantity": "Only one item is available for fulfillment",
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
"noItems": "No items to fulfill."
},
"status": {
"notFulfilled": "Not fulfilled",
"partiallyFulfilled": "Partially fulfilled",
@@ -550,6 +563,7 @@
"requiresAction": "Requires action"
},
"toast": {
"created": "Fulfillment created successfully",
"canceled": "Fulfillment successfully canceled",
"fulfillmentShipped": "Cannot cancel an already shipped fulfillment"
},

View File

@@ -9,13 +9,14 @@ import { customers } from "./customers"
import { fulfillmentProviders } from "./fulfillment-providers"
import { inventoryItems } from "./inventory"
import { invites } from "./invites"
import { orders } from "./orders"
import { payments } from "./payments"
import { priceLists } from "./price-lists"
import { productTypes } from "./product-types"
import { products } from "./products"
import { promotions } from "./promotions"
import { regions } from "./regions"
import { orders } from "./orders"
import { fulfillments } from "./fulfillments"
import { reservations } from "./reservations"
import { salesChannels } from "./sales-channels"
import { shippingOptions } from "./shipping-options"
@@ -49,6 +50,7 @@ export const client = {
invites: invites,
inventoryItems: inventoryItems,
reservations: reservations,
fulfillments: fulfillments,
fulfillmentProviders: fulfillmentProviders,
products: products,
productTypes: productTypes,

View File

@@ -0,0 +1,17 @@
import { CreateFulfillmentDTO } from "@medusajs/types"
import { FulfillmentRes } from "../../types/api-responses"
import { postRequest } from "./common"
async function createFulfillment(payload: CreateFulfillmentDTO) {
return postRequest<FulfillmentRes>(`/admin/fulfillments`, payload)
}
async function cancelFulfillment(id: string) {
return postRequest<FulfillmentRes>(`/admin/fulfillments/${id}/cancel`)
}
export const fulfillments = {
create: createFulfillment,
cancel: cancelFulfillment,
}

View File

@@ -14,3 +14,19 @@ export function pick(obj: Record<string, any>, keys: string[]) {
return ret
}
/**
* Remove properties that are `null` or `undefined` from the object.
* @param obj
*/
export function cleanNonValues(obj: Record<string, any>) {
const ret: Record<string, any> = {}
for (const key in obj) {
if (obj[key] !== null && typeof obj[key] !== "undefined") {
ret[key] = obj[key]
}
}
return ret
}

View File

@@ -0,0 +1,5 @@
import { OrderLineItemDTO } from "@medusajs/types"
export const getFulfillableQuantity = (item: OrderLineItemDTO) => {
return item.quantity - item.detail.fulfilled_quantity
}

View File

@@ -159,6 +159,13 @@ export const RouteMap: RouteObject[] = [
{
path: ":id",
lazy: () => import("../../v2-routes/orders/order-detail"),
children: [
{
path: "fulfillment",
lazy: () =>
import("../../v2-routes/orders/order-create-fulfillment"),
},
],
},
],
},

View File

@@ -6,6 +6,7 @@ import {
CampaignDTO,
CurrencyDTO,
CustomerGroupDTO,
FulfillmentDTO,
FulfillmentProviderDTO,
InventoryNext,
InviteDTO,
@@ -73,6 +74,11 @@ export type RegionRes = { region: RegionDTO }
export type RegionListRes = { regions: RegionDTO[] } & ListRes
export type RegionDeleteRes = DeleteRes
// Fulfillments
export type FulfillmentRes = { fulfillment: FulfillmentDTO }
export type FulfillmentListRes = { fulfillments: FulfillmentDTO[] } & ListRes
export type FulfillmentDeleteRes = DeleteRes
// Reservations
export type ReservationRes = { reservation: InventoryNext.ReservationItemDTO }
export type ReservationListRes = {

View File

@@ -8,7 +8,7 @@ const inventoryDetailQuery = (id: string) => ({
queryKey: inventoryItemsQueryKeys.detail(id),
queryFn: async () =>
client.inventoryItems.retrieve(id, {
fields: "*variants",
fields: "*variant",
}),
})

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const CreateFulfillmentSchema = z.object({
quantity: z.record(z.string(), z.number()),
location_id: z.string(),
send_notification: z.boolean().optional(),
})

View File

@@ -0,0 +1 @@
export * from "./order-create-fulfillment-form"

View File

@@ -0,0 +1,289 @@
import React, { useEffect, useState } from "react"
import * as zod from "zod"
import { useTranslation } from "react-i18next"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm, useWatch } from "react-hook-form"
import { Alert, Button, Select, toast } from "@medusajs/ui"
import { OrderDTO } from "@medusajs/types"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { CreateFulfillmentSchema } from "./constants"
import { Form } from "../../../../../components/common/form"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { useCreateFulfillment } from "../../../../../hooks/api/fulfillment"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { useFulfillmentProviders } from "../../../../../hooks/api/fulfillment-providers"
import { cleanNonValues, pick } from "../../../../../lib/common"
type OrderCreateFulfillmentFormProps = {
order: OrderDTO
}
export function OrderCreateFulfillmentForm({
order,
}: OrderCreateFulfillmentFormProps) {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { mutateAsync: createOrderFulfillment, isLoading: isMutating } =
useCreateFulfillment()
const { fulfillment_providers } = useFulfillmentProviders({
region_id: order.region_id,
})
const [fulfillableItems, setFulfillableItems] = useState(() =>
order.items.filter((item) => getFulfillableQuantity(item) > 0)
)
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: {
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 handleSubmit = form.handleSubmit(async (data) => {
try {
await createOrderFulfillment({
location_id: data.location_id,
/**
* TODO: send notification flag
*/
// no_notification: !data.send_notification,
delivery_address: cleanNonValues(
pick(order.shipping_address, [
"first_name",
"last_name",
"phone",
"company",
"address_1",
"address_2",
"city",
"country_code",
"province",
"postal_code",
"metadata",
])
), // TODO: this should be pulled from order in the workflow
provider_id: fulfillment_providers[0]?.id,
items: Object.entries(data.quantity)
.filter(([, value]) => !!value)
.map(([item_id, quantity]) => {
const item = order.items.find((i) => i.id === item_id)
return {
quantity,
line_item_id: item_id,
title: item.title,
barcode: item.variant.barcode || "",
sku: item.variant_sku || "",
}
}),
// TODO: should be optional in the enpoint?
labels: [
{
tracking_number: "TODO",
tracking_url: "TODO",
label_url: "TODO",
},
],
order: {}, // TODO ?
order_id: order.id, // TEMP link for now
})
handleSuccess(`/orders/${order.id}`)
toast.success(t("general.success"), {
description: t("orders.fulfillment.toast.created"),
dismissLabel: t("actions.close"),
})
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
})
useEffect(() => {
if (stock_locations?.length) {
form.setValue("location_id", stock_locations[0].id)
}
}, [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,
})
useEffect(() => {
if (!fulfillableItems.length) {
form.setError("root", {
type: "manual",
message: t("orders.fulfillment.error.noItems"),
})
}
}, [fulfillableItems.length])
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}>
{t("orders.fulfillment.create")}
</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 divide-y">
<div className="flex-1">
<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>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Item className="mt-8">
<Form.Label>
{t("orders.fulfillment.itemsToFulfill")}
</Form.Label>
<Form.Hint>
{t("orders.fulfillment.itemsToFulfillDesc")}
</Form.Hint>
<div className="flex flex-col gap-y-1">
{fulfillableItems.map((item) => (
<OrderCreateFulfillmentItem
key={item.id}
form={form}
item={item}
onItemRemove={onItemRemove}
locationId={selectedLocationId}
currencyCode={order.currency_code}
/>
))}
</div>
</Form.Item>
{form.formState.errors.root && (
<Alert
variant="error"
dismissible={false}
className="flex items-center"
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>
{/*<div className="mt-8 pt-8 ">*/}
{/* <Form.Field*/}
{/* control={form.control}*/}
{/* name="send_notification"*/}
{/* render={({ field: { onChange, value, ...field } }) => {*/}
{/* return (*/}
{/* <Form.Item>*/}
{/* <div className="flex items-center justify-between">*/}
{/* <Form.Label>*/}
{/* {t("orders.returns.sendNotification")}*/}
{/* </Form.Label>*/}
{/* <Form.Control>*/}
{/* <Form.Control>*/}
{/* <Switch*/}
{/* checked={!!value}*/}
{/* onCheckedChange={onChange}*/}
{/* {...field}*/}
{/* />*/}
{/* </Form.Control>*/}
{/* </Form.Control>*/}
{/* </div>*/}
{/* <Form.Hint className="!mt-1">*/}
{/* {t("orders.returns.sendNotificationHint")}*/}
{/* </Form.Hint>*/}
{/* <Form.ErrorMessage />*/}
{/* </Form.Item>*/}
{/* )*/}
{/* }}*/}
{/* />*/}
{/*</div>*/}
</div>
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,172 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Trash } from "@medusajs/icons"
import * as zod from "zod"
import { LineItem } from "@medusajs/medusa"
import { Input, Text } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { CreateFulfillmentSchema } from "./constants"
import { Form } from "../../../../../components/common/form"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useProductVariant } from "../../../../../hooks/api/products.tsx"
type OrderEditItemProps = {
item: LineItem
currencyCode: string
locationId?: string
onItemRemove: (itemId: string) => void
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
}
export function OrderCreateFulfillmentItem({
item,
currencyCode,
form,
locationId,
onItemRemove,
}: OrderEditItemProps) {
const { t } = useTranslation()
const { variant } = useProductVariant(
item.variant.product_id,
item.variant_id,
{
fields: "*inventory,*inventory.location_levels",
}
)
const hasInventoryItem = !!variant?.inventory.length
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 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 rounded-xl ">
<div className="flex gap-x-2 border-b p-3 text-sm">
<div className="flex flex-grow items-center gap-x-3">
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{item.title}
</Text>
{item.variant.sku && <span>({item.variant.sku})</span>}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.variant.title}
</Text>
</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"}
</span>
)}
</div>
<div className="flex items-center">
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.remove"),
icon: <Trash />,
onClick: () => onItemRemove(item.id),
},
],
},
]}
/>
</div>
</div>
<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)
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>
)
}}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { OrderCreateFulfillment as Component } from "./order-create-fulfillments"

View File

@@ -0,0 +1,25 @@
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { OrderCreateFulfillmentForm } from "./components/order-create-fulfillment-form"
import { useOrder } from "../../../hooks/api/orders"
export function OrderCreateFulfillment() {
const { id } = useParams()
const { order, isLoading, isError, error } = useOrder(id!, {
fields: "currency_code,*items,*items.variant,*shipping_address",
})
if (isError) {
throw error
}
const ready = !isLoading && order
return (
<RouteFocusModal>
{ready && <OrderCreateFulfillmentForm order={order} />}
</RouteFocusModal>
)
}

View File

@@ -1,9 +1,10 @@
import { XCircle } from "@medusajs/icons"
import { Buildings, XCircle } from "@medusajs/icons"
import {
LineItem,
Fulfillment as MedusaFulfillment,
Order,
} from "@medusajs/medusa"
FulfillmentDTO,
OrderDTO,
OrderLineItemDTO,
ProductVariantDTO,
} from "@medusajs/types"
import {
Container,
Copy,
@@ -17,17 +18,16 @@ import {
import { format } from "date-fns"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { FulfillmentDTO, OrderDTO, OrderItemDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Skeleton } from "../../../../../components/common/skeleton"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { useStockLocation } from "../../../../../hooks/api/stock-locations"
import { useCancelFulfillment } from "../../../../../hooks/api/fulfillment"
type OrderFulfillmentSectionProps = {
order: OrderDTO
order: OrderDTO & { fulfillments: FulfillmentDTO[] }
}
export const OrderFulfillmentSection = ({
@@ -39,7 +39,7 @@ export const OrderFulfillmentSection = ({
<div className="flex flex-col gap-y-2">
<UnfulfilledItemBreakdown order={order} />
{fulfillments.map((f, index) => (
<Fulfillment key={f.id} index={index} fulfillment={f} />
<Fulfillment key={f.id} index={index} fulfillment={f} order={order} />
))}
</div>
)
@@ -49,7 +49,7 @@ const UnfulfilledItem = ({
item,
currencyCode,
}: {
item: OrderItemDTO
item: OrderLineItemDTO & { variant: ProductVariantDTO }
currencyCode: string
}) => {
return (
@@ -87,7 +87,10 @@ const UnfulfilledItem = ({
</div>
<div className="flex items-center justify-end">
<Text>
<span className="tabular-nums">{item.quantity}</span>x
<span className="tabular-nums">
{item.quantity - item.detail.fulfilled_quantity}
</span>
x
</Text>
</div>
<div className="flex items-center justify-end">
@@ -100,19 +103,16 @@ const UnfulfilledItem = ({
)
}
const UnfulfilledItemBreakdown = ({ order }: { order: Order }) => {
const UnfulfilledItemBreakdown = ({
order,
}: {
order: OrderDTO & { fulfillments: FulfillmentDTO[] }
}) => {
const { t } = useTranslation()
const fulfillmentItems = order.fulfillments?.map((f) =>
f.items.map((i) => ({ id: i.item_id, quantity: i.quantity }))
)
// Create an array of order items that haven't been fulfilled or at least not fully fulfilled
const unfulfilledItems = order.items.filter(
(i) =>
!fulfillmentItems?.some((fi) =>
fi.some((f) => f.id === i.id && f.quantity === i.quantity)
)
const unfulfilledItems = order.items!.filter(
(i) => i.detail.fulfilled_quantity < i.quantity
)
if (!unfulfilledItems.length) {
@@ -125,9 +125,21 @@ const UnfulfilledItemBreakdown = ({ order }: { order: Order }) => {
<Heading level="h2">{t("orders.fulfillment.unfulfilledItems")}</Heading>
<div className="flex items-center gap-x-4">
<StatusBadge color="red" className="text-nowrap">
{t("orders.fulfillment.awaitingFullfillmentBadge")}
{t("orders.fulfillment.awaitingFulfillmentBadge")}
</StatusBadge>
<ActionMenu groups={[]} />
<ActionMenu
groups={[
{
actions: [
{
label: t("orders.fulfillment.fulfillItems"),
icon: <Buildings />,
to: `/orders/${order.id}/fulfillment`,
},
],
},
]}
/>
</div>
</div>
<div>
@@ -145,9 +157,11 @@ const UnfulfilledItemBreakdown = ({ order }: { order: Order }) => {
const Fulfillment = ({
fulfillment,
order,
index,
}: {
fulfillment: FulfillmentDTO
order: OrderDTO
index: number
}) => {
const { t } = useTranslation()
@@ -176,7 +190,7 @@ const Fulfillment = ({
statusTimestamp = fulfillment.shipped_at
}
const { mutateAsync } = {} // useCancelFulfillment(order.id)
const { mutateAsync } = useCancelFulfillment(fulfillment.id)
const handleCancel = async () => {
if (fulfillment.shipped_at) {
@@ -242,6 +256,7 @@ const Fulfillment = ({
label: t("actions.cancel"),
icon: <XCircle />,
onClick: handleCancel,
disabled: !!fulfillment.canceled_at,
},
],
},
@@ -255,9 +270,9 @@ const Fulfillment = ({
</Text>
<ul>
{fulfillment.items.map((f_item) => (
<li key={f_item.item_id}>
<li key={f_item.line_item_id}>
<Text size="small" leading="compact">
{f_item.item.quantity}x {f_item.item.title}
{f_item.quantity}x {f_item.title}
</Text>
</li>
))}
@@ -270,7 +285,7 @@ const Fulfillment = ({
</Text>
{stock_location ? (
<Link
to={`/settings/locations/${stock_location.id}`}
to={`/settings/shipping/${stock_location.id}`}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg"
>
<Text size="small" leading="compact">

View File

@@ -23,6 +23,8 @@ const DEFAULT_RELATIONS = [
"*billing_address",
"*sales_channel",
"*promotion",
"*fulfillments",
"*fulfillments.items",
]
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(

View File

@@ -40,7 +40,7 @@ export const OrderDetail = () => {
<OrderGeneralSection order={order} />
<OrderSummarySection order={order} />
{/*<OrderPaymentSection order={order} />*/}
{/*<OrderFulfillmentSection order={order} />*/}
<OrderFulfillmentSection order={order} />
<div className="flex flex-col gap-y-2 xl:hidden">
<OrderCustomerSection order={order} />
<OrderActivitySection order={order} />

View File

@@ -1,6 +1,12 @@
import { FulfillmentDTO, FulfillmentWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { createFulfillmentStep } from "../steps"
import { Modules } from "@medusajs/utils"
import { createLinkStep } from "../../common"
export const createFulfillmentWorkflowId = "create-fulfillment-workflow"
export const createFulfillmentWorkflow = createWorkflow(
@@ -8,6 +14,22 @@ export const createFulfillmentWorkflow = createWorkflow(
(
input: WorkflowData<FulfillmentWorkflow.CreateFulfillmentWorkflowInput>
): WorkflowData<FulfillmentDTO> => {
return createFulfillmentStep(input)
const fulfillment = createFulfillmentStep(input)
const link = transform(
{ order_id: input.order_id, fulfillment },
(data) => {
return [
{
[Modules.ORDER]: { order_id: data.order_id },
[Modules.FULFILLMENT]: { fulfillment_id: data.fulfillment.id },
},
]
}
)
createLinkStep(link)
return fulfillment
}
)

View File

@@ -205,6 +205,7 @@ function prepareFulfillmentData({
labels: [] as FulfillmentWorkflow.CreateFulfillmentLabelWorkflowDTO[],
delivery_address: order.shipping_address ?? ({} as any), // TODO: should it be the stock location address?
order: {} as FulfillmentWorkflow.CreateFulfillmentOrderWorkflowDTO, // TODO see what todo here, is that even necessary?
order_id: input.order_id,
},
}
}
@@ -318,22 +319,8 @@ export const createReturnOrderWorkflow = createWorkflow(
prepareFulfillmentData
)
const fulfillment = createFulfillmentWorkflow.runAsStep(fulfillmentData)
createFulfillmentWorkflow.runAsStep(fulfillmentData)
// TODO call the createReturn from the fulfillment provider
const link = transform(
{ order_id: input.order_id, fulfillment },
(data) => {
return [
{
[Modules.ORDER]: { order_id: data.order_id },
[Modules.FULFILLMENT]: { fulfillment_id: data.fulfillment.id },
},
]
}
)
createLinkStep(link)
}
)

View File

@@ -180,4 +180,7 @@ export type CreateFulfillmentWorkflowInput = {
* The associated fulfillment order.
*/
order: CreateFulfillmentOrderWorkflowDTO
// TODO: revisit - either remove `order_id` or `order`
order_id: string
}

View File

@@ -23,6 +23,7 @@ export type AdminCancelFulfillmentType = z.infer<typeof AdminCancelFulfillment>
export const AdminCancelFulfillment = z.object({})
export type AdminCreateFulfillmentType = z.infer<typeof AdminCreateFulfillment>
// TODO: revisit the data shape this endpoint accepts
export const AdminCreateFulfillment = z.object({
location_id: z.string(),
provider_id: z.string(),
@@ -30,6 +31,7 @@ export const AdminCreateFulfillment = z.object({
items: z.array(AdminCreateFulfillmentItem),
labels: z.array(AdminCreateFulfillmentLabel),
order: z.object({}),
order_id: z.string(),
metadata: z.record(z.unknown()).optional().nullable(),
})

View File

@@ -9,6 +9,7 @@ export * from "./product-variant-inventory-item"
export * from "./product-variant-price-set"
export * from "./publishable-api-key-sales-channel"
export * from "./readonly"
export * from "./order-fulfillment"
export * from "./region-payment-provider"
export * from "./sales-channel-location"
export * from "./shipping-option-price-set"

View File

@@ -7,7 +7,7 @@ export const OrderFulfillment: ModuleJoinerConfig = {
isLink: true,
databaseConfig: {
tableName: "order_fulfillment",
idPrefix: "orderful",
idPrefix: "ordful",
},
alias: [
{