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:
9
.changeset/long-islands-press.md
Normal file
9
.changeset/long-islands-press.md
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -140,6 +140,7 @@ medusaIntegrationTestRunner({
|
||||
const data = generateCreateFulfillmentData({
|
||||
provider_id: providerId,
|
||||
shipping_option_id: shippingOption.id,
|
||||
order_id: "order_123",
|
||||
})
|
||||
|
||||
const response = await api
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
43
packages/admin-next/dashboard/src/hooks/api/fulfillment.tsx
Normal file
43
packages/admin-next/dashboard/src/hooks/api/fulfillment.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
packages/admin-next/dashboard/src/lib/client/fulfillments.ts
Normal file
17
packages/admin-next/dashboard/src/lib/client/fulfillments.ts
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
5
packages/admin-next/dashboard/src/lib/order-item.ts
Normal file
5
packages/admin-next/dashboard/src/lib/order-item.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OrderLineItemDTO } from "@medusajs/types"
|
||||
|
||||
export const getFulfillableQuantity = (item: OrderLineItemDTO) => {
|
||||
return item.quantity - item.detail.fulfilled_quantity
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -8,7 +8,7 @@ const inventoryDetailQuery = (id: string) => ({
|
||||
queryKey: inventoryItemsQueryKeys.detail(id),
|
||||
queryFn: async () =>
|
||||
client.inventoryItems.retrieve(id, {
|
||||
fields: "*variants",
|
||||
fields: "*variant",
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./order-create-fulfillment-form"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderCreateFulfillment as Component } from "./order-create-fulfillments"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -23,6 +23,8 @@ const DEFAULT_RELATIONS = [
|
||||
"*billing_address",
|
||||
"*sales_channel",
|
||||
"*promotion",
|
||||
"*fulfillments",
|
||||
"*fulfillments.items",
|
||||
]
|
||||
|
||||
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -180,4 +180,7 @@ export type CreateFulfillmentWorkflowInput = {
|
||||
* The associated fulfillment order.
|
||||
*/
|
||||
order: CreateFulfillmentOrderWorkflowDTO
|
||||
|
||||
// TODO: revisit - either remove `order_id` or `order`
|
||||
order_id: string
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@ export const OrderFulfillment: ModuleJoinerConfig = {
|
||||
isLink: true,
|
||||
databaseConfig: {
|
||||
tableName: "order_fulfillment",
|
||||
idPrefix: "orderful",
|
||||
idPrefix: "ordful",
|
||||
},
|
||||
alias: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user