feat(dashboard,core-flows,medusa): update fulfillment flows (#7589)

* fix: fulfillment ops

* fix: cancel fulfillment route

* fix: adjustInventoryLevelsStep throwing

* feat: cancel order and fix endpoint

* fix: type

* feat: order domain sdk

* feat: delete unused file

* fix: import
This commit is contained in:
Frane Polić
2024-06-06 08:58:21 +02:00
committed by GitHub
parent da3837f2bc
commit d285e60961
26 changed files with 296 additions and 183 deletions
@@ -1,5 +1,7 @@
import type { FulfillmentStatus } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { FulfillmentStatus } from "@medusajs/types"
import { getOrderFulfillmentStatus } from "../../../../../lib/order-helpers"
import { StatusCell } from "../../common/status-cell"
@@ -1,40 +0,0 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const ORDERS_QUERY_KEY = "orders" as const
export const ordersQueryKeys = queryKeysFactory(ORDERS_QUERY_KEY)
export const useOrder = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => client.orders.retrieve(id, query),
queryKey: ordersQueryKeys.detail(id, query),
...options,
})
return { ...data, ...rest }
}
export const useOrders = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => client.orders.list(query),
queryKey: ordersQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
@@ -0,0 +1,101 @@
import {
QueryKey,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { sdk } from "../../lib/client"
const ORDERS_QUERY_KEY = "orders" as const
export const ordersQueryKeys = queryKeysFactory(ORDERS_QUERY_KEY)
export const useOrder = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.order.retrieve(id, query),
queryKey: ordersQueryKeys.detail(id, query),
...options,
})
return { ...data, ...rest }
}
export const useOrders = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.order.list(query),
queryKey: ordersQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useCreateOrderFulfillment = (
orderId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) =>
sdk.admin.order.createFulfillment(orderId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelOrderFulfillment = (
orderId: string,
fulfillmentId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: (payload: { no_notification?: boolean }) =>
sdk.admin.order.cancelFulfillment(orderId, fulfillmentId, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelOrder = (
orderId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: () => sdk.admin.order.cancel(orderId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -76,6 +76,7 @@
"back": "Back",
"close": "Close",
"continue": "Continue",
"reset": "Reset",
"confirm": "Confirm",
"edit": "Edit",
"download": "Download",
@@ -572,6 +573,7 @@
"inStock": "In stock",
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
"locationDescription": "Choose which location you want to fulfill items from.",
"sendNotificationHint": "Notify customers about the created fulfillment.",
"error": {
"wrongQuantity": "Only one item is available for fulfillment",
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
@@ -8,7 +8,6 @@ import { fulfillmentProviders } from "./fulfillment-providers"
import { fulfillments } from "./fulfillments"
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"
@@ -3,10 +3,18 @@ import { CreateFulfillmentDTO } from "@medusajs/types"
import { FulfillmentRes } from "../../types/api-responses"
import { postRequest } from "./common"
/**
* Create a fulfillment.
* NOTE: to create a fulfillment on an order use orders/:id/fulfillments endpoint
*/
async function createFulfillment(payload: CreateFulfillmentDTO) {
return postRequest<FulfillmentRes>(`/admin/fulfillments`, payload)
}
/**
* Cancel a fulfillment.
* NOTE: to cancel a fulfillment on an order use orders/:id/fulfillments/:id/cancel endpoint
*/
async function cancelFulfillment(id: string) {
return postRequest<FulfillmentRes>(`/admin/fulfillments/${id}/cancel`)
}
@@ -1,15 +0,0 @@
import { getRequest } from "./common"
import { OrderListRes, OrderRes } from "../../types/api-responses"
async function retrieveOrder(id: string, query?: Record<string, any>) {
return getRequest<OrderRes, Record<string, any>>(`/admin/orders/${id}`, query)
}
async function listOrders(query?: Record<string, any>) {
return getRequest<OrderListRes, Record<string, any>>(`/admin/orders`, query)
}
export const orders = {
list: listOrders,
retrieve: retrieveOrder,
}
@@ -14,22 +14,22 @@ import { useTranslation } from "react-i18next"
import { HttpTypes } from "@medusajs/types"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { DataGrid } from "../../../../../components/grid/data-grid/index.ts"
import { CurrencyCell } from "../../../../../components/grid/grid-cells/common/currency-cell/index.ts"
import { DataGridMeta } from "../../../../../components/grid/types.ts"
import { DataGrid } from "../../../../../components/grid/data-grid/index"
import { CurrencyCell } from "../../../../../components/grid/grid-cells/common/currency-cell/index"
import { DataGridMeta } from "../../../../../components/grid/types"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal/index.ts"
import { useCurrencies } from "../../../../../hooks/api/currencies.tsx"
import { useRegions } from "../../../../../hooks/api/regions.tsx"
import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options.ts"
import { useStore } from "../../../../../hooks/api/store.tsx"
} from "../../../../../components/route-modal/index"
import { useCurrencies } from "../../../../../hooks/api/currencies"
import { useRegions } from "../../../../../hooks/api/regions"
import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options"
import { useStore } from "../../../../../hooks/api/store"
import {
getDbAmount,
getPresentationalAmount,
} from "../../../../../lib/money-amount-helpers.ts"
import { ExtendedProductDTO } from "../../../../../types/api-responses.ts"
} from "../../../../../lib/money-amount-helpers"
import { ExtendedProductDTO } from "../../../../../types/api-responses"
const getInitialCurrencyPrices = (prices: PriceDTO[]) => {
const ret: Record<string, number> = {}
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { AdminOrder } from "@medusajs/types"
import { Alert, Button, Select, toast } from "@medusajs/ui"
import { Alert, Button, Select, Switch, toast } from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
import { Form } from "../../../../../components/common/form"
@@ -12,13 +12,12 @@ import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { useCreateFulfillment } from "../../../../../hooks/api/fulfillment"
import { useFulfillmentProviders } from "../../../../../hooks/api/fulfillment-providers"
import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { cleanNonValues, pick } from "../../../../../lib/common"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { CreateFulfillmentSchema } from "./constants"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
import { useCreateOrderFulfillment } from "../../../../../hooks/api/orders"
import { CreateFulfillmentSchema } from "./constants"
type OrderCreateFulfillmentFormProps = {
order: AdminOrder
@@ -30,8 +29,8 @@ export function OrderCreateFulfillmentForm({
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { mutateAsync: createOrderFulfillment, isLoading: isMutating } =
useCreateFulfillment()
const { mutateAsync: createOrderFulfillment, isPending: isMutating } =
useCreateOrderFulfillment(order.id)
const { fulfillment_providers } = useFulfillmentProviders({
region_id: order.region_id,
@@ -43,14 +42,11 @@ export function OrderCreateFulfillmentForm({
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: {
quantity: fulfillableItems.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
},
{} as Record<string, number>
),
// send_notification: !order.no_notification,
quantity: fulfillableItems.reduce((acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
}, {} as Record<string, number>),
send_notification: !order.no_notification,
},
resolver: zodResolver(CreateFulfillmentSchema),
})
@@ -61,49 +57,13 @@ export function OrderCreateFulfillmentForm({
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,
no_notification: !data.send_notification,
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
.map(([id, quantity]) => ({
id,
quantity,
})),
})
handleSuccess(`/orders/${order.id}`)
@@ -252,36 +212,36 @@ export function OrderCreateFulfillmentForm({
)}
</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 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.fulfillment.sendNotificationHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</div>
@@ -21,10 +21,10 @@ import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Skeleton } from "../../../../../components/common/skeleton"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useCancelFulfillment } from "../../../../../hooks/api/fulfillment"
import { useStockLocation } from "../../../../../hooks/api/stock-locations"
import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { useCancelOrderFulfillment } from "../../../../../hooks/api/orders"
type OrderFulfillmentSectionProps = {
order: AdminOrder
@@ -186,7 +186,7 @@ const Fulfillment = ({
statusTimestamp = fulfillment.shipped_at
}
const { mutateAsync } = useCancelFulfillment(fulfillment.id)
const { mutateAsync } = useCancelOrderFulfillment(order.id, fulfillment.id)
const handleCancel = async () => {
if (fulfillment.shipped_at) {
@@ -206,7 +206,7 @@ const Fulfillment = ({
if (res) {
try {
await mutateAsync(fulfillment.id)
await mutateAsync()
toast.success(t("general.success"), {
description: t("orders.fulfillment.toast.canceled"),
@@ -15,6 +15,7 @@ import {
getOrderFulfillmentStatus,
getOrderPaymentStatus,
} from "../../../../../lib/order-helpers"
import { useCancelOrder } from "../../../../../hooks/api/orders"
type OrderGeneralSectionProps = {
order: Order
@@ -24,7 +25,7 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = { mutateAsync: () => {} } // cancel order
const { mutateAsync } = useCancelOrder(order.id)
const handleCancel = async () => {
const res = await prompt({
@@ -40,7 +41,7 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
return
}
await mutateAsync(undefined)
await mutateAsync()
}
return (
@@ -60,17 +61,18 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-1.5">
<PaymentBadge order={order} />
{/*TODO: SHOW ORDER STATUS INSTEAD OF FULFILLMENT STATUS HERE - if the last fulfillment is canceled it looks like the order is canceled*/}
<FulfillmentBadge order={order} />
</div>
<ActionMenu
groups={[
{
actions: [
// {
// label: t("actions.cancel"),
// onClick: handleCancel,
// icon: <XCircle />,
// },
{
label: t("actions.cancel"),
onClick: handleCancel,
icon: <XCircle />,
},
],
},
]}
@@ -83,11 +85,6 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
const FulfillmentBadge = ({ order }: { order: Order }) => {
const { t } = useTranslation()
/**
* TODO: revisit when Order<>Fulfillment are linked
*/
return null
const { label, color } = getOrderFulfillmentStatus(
t,
order.fulfillment_status
@@ -3,7 +3,6 @@ const DEFAULT_PROPERTIES = [
"status",
"created_at",
"email",
// "fulfillment_status", // -> TODO replacement for this
// "payment_status", // -> TODO replacement for this
"display_id",
"currency_code",
@@ -4,7 +4,6 @@ const DEFAULT_PROPERTIES = [
"created_at",
"email",
"display_id",
// "fulfillment_status", // -> TODO replacement for this
// "payment_status", // -> TODO replacement for this
"total",
"currency_code",
@@ -4,7 +4,7 @@ import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type CancelOrderFulfillmentStepInput = CancelOrderFulfillmentDTO
export const cancelOrderFulfillmentStepId = "cancel-order-fullfillment"
export const cancelOrderFulfillmentStepId = "cancel-order-fulfillment"
export const cancelOrderFulfillmentStep = createStep(
cancelOrderFulfillmentStepId,
async (data: CancelOrderFulfillmentStepInput, { container }) => {
@@ -87,11 +87,14 @@ function prepareInventoryUpdate({
}[] = []
for (const item of fulfillment.items) {
inventoryAdjustment.push({
inventory_item_id: item.inventory_item_id as string,
location_id: fulfillment.location_id,
adjustment: item.quantity,
})
// if this is `null` this means that item is from variant that has `manage_inventory` false
if (item.inventory_item_id) {
inventoryAdjustment.push({
inventory_item_id: item.inventory_item_id as string,
location_id: fulfillment.location_id,
adjustment: item.quantity,
})
}
}
return {
+3
View File
@@ -5,6 +5,7 @@ import { Product } from "./product"
import { ProductCollection } from "./product-collection"
import { Region } from "./region"
import { Upload } from "./upload"
import { Order } from "./order"
export class Admin {
public invite: Invite
@@ -13,6 +14,7 @@ export class Admin {
public product: Product
public upload: Upload
public region: Region
public order: Order
constructor(client: Client) {
this.invite = new Invite(client)
@@ -21,5 +23,6 @@ export class Admin {
this.product = new Product(client)
this.upload = new Upload(client)
this.region = new Region(client)
this.order = new Order(client)
}
}
+81
View File
@@ -0,0 +1,81 @@
import {
AdminCancelOrderFulfillment,
FindParams,
HttpTypes,
PaginatedResponse,
SelectParams,
} from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class Order {
private client: Client
constructor(client: Client) {
this.client = client
}
async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) {
return await this.client.fetch<{ order: HttpTypes.AdminOrder }>(
`/admin/orders/${id}`,
{
query,
headers,
}
)
}
async list(
queryParams?: FindParams & HttpTypes.AdminOrderFilters,
headers?: ClientHeaders
) {
return await this.client.fetch<
PaginatedResponse<{ orders: HttpTypes.AdminOrder[] }>
>(`/admin/orders`, {
query: queryParams,
headers,
})
}
async cancel(id: string, headers?: ClientHeaders) {
return await this.client.fetch<{ order: HttpTypes.AdminOrder }>(
`/admin/orders/${id}/cancel`,
{
method: "POST",
headers,
}
)
}
async createFulfillment(
id: string,
body: HttpTypes.AdminCreateOrderFulfillment,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<{ order: HttpTypes.AdminOrder }>(
`/admin/orders/${id}/fulfillments`,
{
method: "POST",
headers,
body,
query,
}
)
}
async cancelFulfillment(
id: string,
fulfillmentId: string,
body: HttpTypes.AdminCancelOrderFulfillment,
headers?: ClientHeaders
) {
return await this.client.fetch<{ order: HttpTypes.AdminOrder }>(
`/admin/orders/${id}/fulfillments/${fulfillmentId}/cancel`,
{
method: "POST",
headers,
body,
}
)
}
}
@@ -1,11 +1,24 @@
import {
BaseOrder,
BaseOrderAddress,
BaseOrderFilters,
BaseOrderLineItem,
BaseOrderShippingMethod,
} from "./common"
export interface AdminOrder extends BaseOrder {}
export interface AdminOrderLineItem extends BaseOrderLineItem {}
export interface AdminOrderFilters extends BaseOrderFilters {}
export interface AdminOrderAddress extends BaseOrderAddress {}
export interface AdminOrderShippingMethod extends BaseOrderShippingMethod {}
export interface AdminCreateOrderFulfillment {
items: { id: string; quantity: number }[]
location_id?: string
no_notification?: boolean
metadata?: Record<string, any>
}
export interface AdminCancelOrderFulfillment {
no_notification?: boolean
}
+2 -2
View File
@@ -1109,7 +1109,7 @@ export interface OrderDTO {
raw_original_shipping_tax_total: BigNumberRawValue
}
type PaymentStatus =
export type PaymentStatus =
| "not_paid"
| "awaiting"
| "authorized"
@@ -1121,7 +1121,7 @@ type PaymentStatus =
| "canceled"
| "requires_action"
type FulfillmentStatus =
export type FulfillmentStatus =
| "not_fulfilled"
| "partially_fulfilled"
| "fulfilled"
@@ -8,7 +8,7 @@ import {
MedusaResponse,
} from "../../../../../types/routing"
export const GET = async (
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
@@ -20,6 +20,7 @@ export const POST = async (
const input = {
...req.validatedBody,
order_id: req.params.id,
fulfillment_id: req.params.fulfillment_id,
}
await cancelOrderFulfillmentWorkflow(req.scope).run({
@@ -12,8 +12,8 @@ export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const worklow = getOrderDetailWorkflow(req.scope)
const { result } = await worklow.run({
const workflow = getOrderDetailWorkflow(req.scope)
const { result } = await workflow.run({
input: {
fields: req.remoteQueryConfig.fields,
order_id: req.params.id,
@@ -7,6 +7,7 @@ import {
AdminCompleteOrder,
AdminGetOrdersOrderParams,
AdminGetOrdersParams,
AdminOrderCancelFulfillment,
AdminOrderCreateFulfillment,
} from "./validators"
@@ -78,9 +79,9 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [
},
{
method: ["POST"],
matcher: "/admin/orders/:id/fulfillments/cancel",
matcher: "/admin/orders/:id/fulfillments/:fulfillment_id/cancel",
middlewares: [
// validateAndTransformBody(),
validateAndTransformBody(AdminOrderCancelFulfillment),
validateAndTransformQuery(
AdminGetOrdersOrderParams,
QueryConfig.retrieveTransformQueryConfig
@@ -81,7 +81,6 @@ export type AdminOrderCreateShipmentType = z.infer<
>
export const AdminOrderCancelFulfillment = z.object({
fulfillment_id: z.string(),
no_notification: z.boolean().optional(),
})