feat(dashboard): create shipment flow (#7898)
**What** - add "Mark shipped" to Fulfillment section --- CLOSES CORE-2427
This commit is contained in:
1 parent
e3a0df3ba0
commit
87375db9ef
12 files changed
+277
-12
No files matched your search
@@ -2,9 +2,10 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query"
|
|||||||
|
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
|
|
||||||
import { client } from "../../lib/client"
|
import { client, sdk } from "../../lib/client"
|
||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { ordersQueryKeys } from "./orders"
|
import { ordersQueryKeys } from "./orders"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
const FULFILLMENTS_QUERY_KEY = "fulfillments" as const
|
const FULFILLMENTS_QUERY_KEY = "fulfillments" as const
|
||||||
export const fulfillmentsQueryKeys = queryKeysFactory(FULFILLMENTS_QUERY_KEY)
|
export const fulfillmentsQueryKeys = queryKeysFactory(FULFILLMENTS_QUERY_KEY)
|
||||||
@@ -41,3 +42,24 @@ export const useCancelFulfillment = (
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useCreateShipment = (
|
||||||
|
fulfillmentId: string,
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
{ order: HttpTypes.AdminOrder },
|
||||||
|
Error,
|
||||||
|
HttpTypes.AdminCreateFulfillmentShipment
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: HttpTypes.AdminCreateFulfillmentShipment) =>
|
||||||
|
sdk.admin.fulfillment.createShipment(fulfillmentId, payload),
|
||||||
|
onSuccess: (data: any, variables: any, context: any) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ordersQueryKeys.details(),
|
||||||
|
})
|
||||||
|
options?.onSuccess?.(data, variables, context)
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -676,6 +676,14 @@
|
|||||||
"allocatedLabel": "Allocated",
|
"allocatedLabel": "Allocated",
|
||||||
"notAllocatedLabel": "Not allocated"
|
"notAllocatedLabel": "Not allocated"
|
||||||
},
|
},
|
||||||
|
"shipment": {
|
||||||
|
"title": "Mark fulfillment shipped",
|
||||||
|
"trackingNumber": "Tracking number",
|
||||||
|
"addTracking": "Add additional tracking number",
|
||||||
|
"sendNotification": "Send notification",
|
||||||
|
"sendNotificationHint": "Notify the customer about this shipment.",
|
||||||
|
"toastCreated": "Shipment created successfully."
|
||||||
|
},
|
||||||
"fulfillment": {
|
"fulfillment": {
|
||||||
"cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.",
|
"cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.",
|
||||||
"unfulfilledItems": "Unfulfilled Items",
|
"unfulfilledItems": "Unfulfilled Items",
|
||||||
@@ -683,12 +691,12 @@
|
|||||||
"statusTitle": "Fulfillment Status",
|
"statusTitle": "Fulfillment Status",
|
||||||
"fulfillItems": "Fulfill items",
|
"fulfillItems": "Fulfill items",
|
||||||
"awaitingFulfillmentBadge": "Awaiting fulfillment",
|
"awaitingFulfillmentBadge": "Awaiting fulfillment",
|
||||||
"awaitingFullfillmentBadge": "Awaiting fulfillment",
|
|
||||||
"number": "Fulfillment #{{number}}",
|
"number": "Fulfillment #{{number}}",
|
||||||
"itemsToFulfill": "Items to fulfill",
|
"itemsToFulfill": "Items to fulfill",
|
||||||
"create": "Create Fulfillment",
|
"create": "Create Fulfillment",
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
"inStock": "In stock",
|
"inStock": "In stock",
|
||||||
|
"markAsShipped": "Mark as shipped",
|
||||||
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
|
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
|
||||||
"locationDescription": "Choose which location you want to fulfill items from.",
|
"locationDescription": "Choose which location you want to fulfill items from.",
|
||||||
"sendNotificationHint": "Notify customers about the created fulfillment.",
|
"sendNotificationHint": "Notify customers about the created fulfillment.",
|
||||||
|
|||||||
@@ -208,6 +208,11 @@ export const RouteMap: RouteObject[] = [
|
|||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/orders/order-create-fulfillment"),
|
import("../../routes/orders/order-create-fulfillment"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ":f_id/create-shipment",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/orders/order-create-shipment"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const CreateShipmentSchema = z.object({
|
||||||
|
labels: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
tracking_number: z.string(),
|
||||||
|
// TODO: this 2 are not optional in the API
|
||||||
|
tracking_url: z.string().optional(),
|
||||||
|
label_url: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
send_notification: z.boolean().optional(),
|
||||||
|
})
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./order-create-shipment-form.tsx"
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
|
||||||
|
import { AdminFulfillment, AdminOrder } from "@medusajs/types"
|
||||||
|
import { Button, Heading, Input, Switch, toast } from "@medusajs/ui"
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form"
|
||||||
|
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
import {
|
||||||
|
RouteFocusModal,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/modals"
|
||||||
|
import { useCreateOrderShipment } from "../../../../../hooks/api/orders"
|
||||||
|
import { CreateShipmentSchema } from "./constants"
|
||||||
|
import { useCreateShipment } from "../../../../../hooks/api/fulfillment.tsx"
|
||||||
|
|
||||||
|
type OrderCreateFulfillmentFormProps = {
|
||||||
|
order: AdminOrder
|
||||||
|
fulfillment: AdminFulfillment
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderCreateShipmentForm({
|
||||||
|
order,
|
||||||
|
fulfillment,
|
||||||
|
}: OrderCreateFulfillmentFormProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const { mutateAsync: createShipment, isPending: isMutating } =
|
||||||
|
useCreateShipment(fulfillment.id)
|
||||||
|
|
||||||
|
const form = useForm<zod.infer<typeof CreateShipmentSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
labels: [{ tracking_number: "" }],
|
||||||
|
send_notification: !order.no_notification, //TODO: not supported in the API
|
||||||
|
},
|
||||||
|
resolver: zodResolver(CreateShipmentSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fields: labels, append } = useFieldArray({
|
||||||
|
name: "labels",
|
||||||
|
control: form.control,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await createShipment({
|
||||||
|
labels: data.labels
|
||||||
|
.filter((l) => !!l.tracking_number)
|
||||||
|
.map((l) => ({
|
||||||
|
tracking_number: l.tracking_number,
|
||||||
|
tracking_url: "#",
|
||||||
|
label_url: "#",
|
||||||
|
})),
|
||||||
|
// no_notification: !data.send_notification,
|
||||||
|
})
|
||||||
|
|
||||||
|
handleSuccess(`/orders/${order.id}`)
|
||||||
|
|
||||||
|
toast.success(t("general.success"), {
|
||||||
|
description: t("orders.shipment.toastCreated"),
|
||||||
|
dismissLabel: t("actions.close"),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(t("general.error"), {
|
||||||
|
description: e.message,
|
||||||
|
dismissLabel: t("actions.close"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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("actions.save")}
|
||||||
|
</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 flex-1 flex-col">
|
||||||
|
<Heading className="mb-4">
|
||||||
|
{t("orders.shipment.title")}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{labels.map((label, index) => (
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.tracking_number`}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item className="mb-4">
|
||||||
|
{index === 0 && (
|
||||||
|
<Form.Label>
|
||||||
|
{t("orders.shipment.trackingNumber")}
|
||||||
|
</Form.Label>
|
||||||
|
)}
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} placeholder="123-456-789" />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => append({ tracking_number: "" })}
|
||||||
|
className="self-end"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{t("orders.shipment.addTracking")}
|
||||||
|
</Button>
|
||||||
|
</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.shipment.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.shipment.sendNotificationHint")}
|
||||||
|
</Form.Hint>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouteFocusModal.Body>
|
||||||
|
</form>
|
||||||
|
</RouteFocusModal.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrderCreateShipment as Component } from "./order-create-shipment"
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { RouteFocusModal } from "../../../components/modals"
|
||||||
|
import { useOrder } from "../../../hooks/api/orders"
|
||||||
|
import { OrderCreateShipmentForm } from "./components/order-create-shipment-form"
|
||||||
|
|
||||||
|
export function OrderCreateShipment() {
|
||||||
|
const { id, f_id } = useParams()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useOrder(id!, {
|
||||||
|
fields: "*fulfillments",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = !isLoading && order
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
{ready && (
|
||||||
|
<OrderCreateShipmentForm
|
||||||
|
order={order}
|
||||||
|
fulfillment={order.fulfillments?.find((f) => f.id === f_id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+21
-7
@@ -9,10 +9,11 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
toast,
|
toast,
|
||||||
usePrompt,
|
usePrompt,
|
||||||
|
Button,
|
||||||
} from "@medusajs/ui"
|
} from "@medusajs/ui"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Link } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { Skeleton } from "../../../../../components/common/skeleton"
|
import { Skeleton } from "../../../../../components/common/skeleton"
|
||||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||||
@@ -157,6 +158,7 @@ const Fulfillment = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const showLocation = !!fulfillment.location_id
|
const showLocation = !!fulfillment.location_id
|
||||||
|
|
||||||
@@ -168,8 +170,8 @@ const Fulfillment = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
let statusText = "Fulfilled"
|
let statusText = "Awaiting shipping"
|
||||||
let statusColor: "orange" | "green" | "red" = "orange"
|
let statusColor: "blue" | "green" | "red" = "blue"
|
||||||
let statusTimestamp = fulfillment.created_at
|
let statusTimestamp = fulfillment.created_at
|
||||||
|
|
||||||
if (fulfillment.canceled_at) {
|
if (fulfillment.canceled_at) {
|
||||||
@@ -184,6 +186,8 @@ const Fulfillment = ({
|
|||||||
|
|
||||||
const { mutateAsync } = useCancelOrderFulfillment(order.id, fulfillment.id)
|
const { mutateAsync } = useCancelOrderFulfillment(order.id, fulfillment.id)
|
||||||
|
|
||||||
|
const showShippingButton = !fulfillment.canceled_at && !fulfillment.shipped_at
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (fulfillment.shipped_at) {
|
if (fulfillment.shipped_at) {
|
||||||
toast.warning(t("general.warning"), {
|
toast.warning(t("general.warning"), {
|
||||||
@@ -303,11 +307,11 @@ const Fulfillment = ({
|
|||||||
{t("orders.fulfillment.trackingLabel")}
|
{t("orders.fulfillment.trackingLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<div>
|
<div>
|
||||||
{fulfillment.tracking_links &&
|
{fulfillment.labels && fulfillment.labels.length > 0 ? (
|
||||||
fulfillment.tracking_links.length > 0 ? (
|
|
||||||
<ul>
|
<ul>
|
||||||
{fulfillment.tracking_links.map((tlink) => {
|
{fulfillment.labels.map((tlink) => {
|
||||||
const hasUrl = tlink.url && tlink.url.length > 0
|
const hasUrl =
|
||||||
|
tlink.url && tlink.url.length > 0 && tlink.url !== "#"
|
||||||
|
|
||||||
if (hasUrl) {
|
if (hasUrl) {
|
||||||
return (
|
return (
|
||||||
@@ -342,6 +346,16 @@ const Fulfillment = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showShippingButton && (
|
||||||
|
<div className="bg-ui-bg-subtle flex items-center justify-end rounded-b-xl px-4 py-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`./${fulfillment.id}/create-shipment`)}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{t("orders.fulfillment.markAsShipped")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ const DEFAULT_RELATIONS = [
|
|||||||
"*promotion",
|
"*promotion",
|
||||||
"*fulfillments",
|
"*fulfillments",
|
||||||
"*fulfillments.items",
|
"*fulfillments.items",
|
||||||
|
"*fulfillments.labels",
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(
|
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes, SelectParams } from "@medusajs/types"
|
||||||
import { Client } from "../client"
|
import { Client } from "../client"
|
||||||
import { ClientHeaders } from "../types"
|
import { ClientHeaders } from "../types"
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export class Fulfillment {
|
|||||||
headers?: ClientHeaders
|
headers?: ClientHeaders
|
||||||
) {
|
) {
|
||||||
return await this.client.fetch<HttpTypes.AdminFulfillmentResponse>(
|
return await this.client.fetch<HttpTypes.AdminFulfillmentResponse>(
|
||||||
`/admin/fulfillments/${id}/shipments`,
|
`/admin/fulfillments/${id}/shipment`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AdminCancelOrderFulfillment,
|
|
||||||
FindParams,
|
FindParams,
|
||||||
HttpTypes,
|
HttpTypes,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
|||||||
Reference in new issue
Block a user