diff --git a/.changeset/long-islands-press.md b/.changeset/long-islands-press.md new file mode 100644 index 0000000000..11a63093db --- /dev/null +++ b/.changeset/long-islands-press.md @@ -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 diff --git a/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts b/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts index 5e993db0ce..0c2607413e 100644 --- a/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts +++ b/integration-tests/modules/__tests__/fixtures/fulfillment/index.ts @@ -8,6 +8,7 @@ export function generateCreateFulfillmentData( data: Partial & { 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, } } diff --git a/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts b/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts index 0fd778c651..9fc790d1bb 100644 --- a/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts +++ b/integration-tests/modules/__tests__/fulfillment/fulfillment.workflows.spec.ts @@ -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, diff --git a/integration-tests/modules/__tests__/fulfillment/index.spec.ts b/integration-tests/modules/__tests__/fulfillment/index.spec.ts index 87a937072b..fddf8a31a6 100644 --- a/integration-tests/modules/__tests__/fulfillment/index.spec.ts +++ b/integration-tests/modules/__tests__/fulfillment/index.spec.ts @@ -140,6 +140,7 @@ medusaIntegrationTestRunner({ const data = generateCreateFulfillmentData({ provider_id: providerId, shipping_option_id: shippingOption.id, + order_id: "order_123", }) const response = await api diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 3fb5f382ca..86f2606a15 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -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" }, diff --git a/packages/admin-next/dashboard/src/hooks/api/fulfillment.tsx b/packages/admin-next/dashboard/src/hooks/api/fulfillment.tsx new file mode 100644 index 0000000000..c4fc54968e --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/fulfillment.tsx @@ -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 +) => { + 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 +) => { + 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, + }) +} diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 59a4287d1b..89e8b9b26c 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -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" }, diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index 4dbbfe9969..c74f7174be 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -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, diff --git a/packages/admin-next/dashboard/src/lib/client/fulfillments.ts b/packages/admin-next/dashboard/src/lib/client/fulfillments.ts new file mode 100644 index 0000000000..1751a2a159 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/fulfillments.ts @@ -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(`/admin/fulfillments`, payload) +} + +async function cancelFulfillment(id: string) { + return postRequest(`/admin/fulfillments/${id}/cancel`) +} + +export const fulfillments = { + create: createFulfillment, + cancel: cancelFulfillment, +} diff --git a/packages/admin-next/dashboard/src/lib/common.ts b/packages/admin-next/dashboard/src/lib/common.ts index 9b1c4f8b5f..f5c786f1e6 100644 --- a/packages/admin-next/dashboard/src/lib/common.ts +++ b/packages/admin-next/dashboard/src/lib/common.ts @@ -14,3 +14,19 @@ export function pick(obj: Record, keys: string[]) { return ret } + +/** + * Remove properties that are `null` or `undefined` from the object. + * @param obj + */ +export function cleanNonValues(obj: Record) { + const ret: Record = {} + + for (const key in obj) { + if (obj[key] !== null && typeof obj[key] !== "undefined") { + ret[key] = obj[key] + } + } + + return ret +} diff --git a/packages/admin-next/dashboard/src/lib/order-item.ts b/packages/admin-next/dashboard/src/lib/order-item.ts new file mode 100644 index 0000000000..2d09973e9d --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/order-item.ts @@ -0,0 +1,5 @@ +import { OrderLineItemDTO } from "@medusajs/types" + +export const getFulfillableQuantity = (item: OrderLineItemDTO) => { + return item.quantity - item.detail.fulfilled_quantity +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index c8572fe7b8..d2e231c2ed 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -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"), + }, + ], }, ], }, diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index 1631ce3aa4..5c4e28b311 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -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 = { diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts index 2f853019be..b83c17ef0a 100644 --- a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts @@ -8,7 +8,7 @@ const inventoryDetailQuery = (id: string) => ({ queryKey: inventoryItemsQueryKeys.detail(id), queryFn: async () => client.inventoryItems.retrieve(id, { - fields: "*variants", + fields: "*variant", }), }) diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/constants.ts b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/constants.ts new file mode 100644 index 0000000000..67c6c4cfc6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/constants.ts @@ -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(), +}) diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/index.ts new file mode 100644 index 0000000000..81950c9e04 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/index.ts @@ -0,0 +1 @@ +export * from "./order-create-fulfillment-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx new file mode 100644 index 0000000000..157e847439 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-form.tsx @@ -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>({ + defaultValues: { + quantity: fulfillableItems.reduce((acc, item) => { + acc[item.id] = getFulfillableQuantity(item) + return acc + }, {} as Record), + // 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 ( + +
+ +
+ + + + +
+
+ +
+
+
+
+ { + return ( + + {t("fields.location")} + + {t("orders.fulfillment.locationDescription")} + + + + + + + ) + }} + /> + + + + {t("orders.fulfillment.itemsToFulfill")} + + + {t("orders.fulfillment.itemsToFulfillDesc")} + + +
+ {fulfillableItems.map((item) => ( + + ))} +
+
+ {form.formState.errors.root && ( + + {form.formState.errors.root.message} + + + )} +
+ + {/*
*/} + {/* {*/} + {/* return (*/} + {/* */} + {/*
*/} + {/* */} + {/* {t("orders.returns.sendNotification")}*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/*
*/} + {/* */} + {/* {t("orders.returns.sendNotificationHint")}*/} + {/* */} + {/* */} + {/*
*/} + {/* )*/} + {/* }}*/} + {/* />*/} + {/*
*/} +
+
+
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx new file mode 100644 index 0000000000..1b5de5d792 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/components/order-create-fulfillment-form/order-create-fulfillment-item.tsx @@ -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> +} + +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 ( +
+
+
+ +
+
+ + {item.title} + + {item.variant.sku && ({item.variant.sku})} +
+ + {item.variant.title} + +
+
+ +
+ + {hasInventoryItem && ( + + {t("orders.fulfillment.available")}: {availableQuantity || "N/A"}{" "} + ยท {t("orders.fulfillment.inStock")}: {inStockQuantity || "N/A"} + + )} +
+ +
+ , + onClick: () => onItemRemove(item.id), + }, + ], + }, + ]} + /> +
+
+ +
+
+ + {t("fields.quantity")} + + { + return ( + + + { + 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}`) + } + } + }} + /> + + + + ) + }} + /> +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/index.ts b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/index.ts new file mode 100644 index 0000000000..9c7c1a6e40 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/index.ts @@ -0,0 +1 @@ +export { OrderCreateFulfillment as Component } from "./order-create-fulfillments" diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/order-create-fulfillments.tsx b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/order-create-fulfillments.tsx new file mode 100644 index 0000000000..4ff8cd4cd6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-create-fulfillment/order-create-fulfillments.tsx @@ -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 ( + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx b/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx index 939f3919c1..53571bdc7c 100644 --- a/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx @@ -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 = ({
{fulfillments.map((f, index) => ( - + ))}
) @@ -49,7 +49,7 @@ const UnfulfilledItem = ({ item, currencyCode, }: { - item: OrderItemDTO + item: OrderLineItemDTO & { variant: ProductVariantDTO } currencyCode: string }) => { return ( @@ -87,7 +87,10 @@ const UnfulfilledItem = ({
- {item.quantity}x + + {item.quantity - item.detail.fulfilled_quantity} + + x
@@ -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 }) => { {t("orders.fulfillment.unfulfilledItems")}
- {t("orders.fulfillment.awaitingFullfillmentBadge")} + {t("orders.fulfillment.awaitingFulfillmentBadge")} - + , + to: `/orders/${order.id}/fulfillment`, + }, + ], + }, + ]} + />
@@ -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: , onClick: handleCancel, + disabled: !!fulfillment.canceled_at, }, ], }, @@ -255,9 +270,9 @@ const Fulfillment = ({
    {fulfillment.items.map((f_item) => ( -
  • +
  • - {f_item.item.quantity}x {f_item.item.title} + {f_item.quantity}x {f_item.title}
  • ))} @@ -270,7 +285,7 @@ const Fulfillment = ({ {stock_location ? ( diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/constants.ts b/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/constants.ts index d1725847a1..7e34881bd4 100644 --- a/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/constants.ts +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/constants.ts @@ -23,6 +23,8 @@ const DEFAULT_RELATIONS = [ "*billing_address", "*sales_channel", "*promotion", + "*fulfillments", + "*fulfillments.items", ] export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join( diff --git a/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/order-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/order-detail.tsx index e02fb3e9fe..d7b7f03011 100644 --- a/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/order-detail.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/orders/order-detail/order-detail.tsx @@ -40,7 +40,7 @@ export const OrderDetail = () => { {/**/} - {/**/} +
    diff --git a/packages/core/core-flows/src/fulfillment/workflows/create-fulfillment.ts b/packages/core/core-flows/src/fulfillment/workflows/create-fulfillment.ts index 7e58448404..47951d64a5 100644 --- a/packages/core/core-flows/src/fulfillment/workflows/create-fulfillment.ts +++ b/packages/core/core-flows/src/fulfillment/workflows/create-fulfillment.ts @@ -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 ): WorkflowData => { - 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 } ) diff --git a/packages/core/core-flows/src/order/workflows/create-return.ts b/packages/core/core-flows/src/order/workflows/create-return.ts index 4cfc4d1431..4563fba76b 100644 --- a/packages/core/core-flows/src/order/workflows/create-return.ts +++ b/packages/core/core-flows/src/order/workflows/create-return.ts @@ -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) } ) diff --git a/packages/core/types/src/workflow/fulfillment/create-fulfillment.ts b/packages/core/types/src/workflow/fulfillment/create-fulfillment.ts index 44c15deb82..bae310ccf1 100644 --- a/packages/core/types/src/workflow/fulfillment/create-fulfillment.ts +++ b/packages/core/types/src/workflow/fulfillment/create-fulfillment.ts @@ -180,4 +180,7 @@ export type CreateFulfillmentWorkflowInput = { * The associated fulfillment order. */ order: CreateFulfillmentOrderWorkflowDTO + + // TODO: revisit - either remove `order_id` or `order` + order_id: string } diff --git a/packages/medusa/src/api-v2/admin/fulfillments/validators.ts b/packages/medusa/src/api-v2/admin/fulfillments/validators.ts index 05350675b4..764f45f96f 100644 --- a/packages/medusa/src/api-v2/admin/fulfillments/validators.ts +++ b/packages/medusa/src/api-v2/admin/fulfillments/validators.ts @@ -23,6 +23,7 @@ export type AdminCancelFulfillmentType = z.infer export const AdminCancelFulfillment = z.object({}) export type AdminCreateFulfillmentType = z.infer +// 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(), }) diff --git a/packages/modules/link-modules/src/definitions/index.ts b/packages/modules/link-modules/src/definitions/index.ts index 37498fc482..2fbb5a885e 100644 --- a/packages/modules/link-modules/src/definitions/index.ts +++ b/packages/modules/link-modules/src/definitions/index.ts @@ -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" diff --git a/packages/modules/link-modules/src/definitions/order-fulfillment.ts b/packages/modules/link-modules/src/definitions/order-fulfillment.ts index f134c3370c..d64c155ab1 100644 --- a/packages/modules/link-modules/src/definitions/order-fulfillment.ts +++ b/packages/modules/link-modules/src/definitions/order-fulfillment.ts @@ -7,7 +7,7 @@ export const OrderFulfillment: ModuleJoinerConfig = { isLink: true, databaseConfig: { tableName: "order_fulfillment", - idPrefix: "orderful", + idPrefix: "ordful", }, alias: [ {