feat(dashboard): create shipment flow (#7898)
**What** - add "Mark shipped" to Fulfillment section --- CLOSES CORE-2427
This commit is contained in:
@@ -2,9 +2,10 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query"
|
||||
|
||||
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 { ordersQueryKeys } from "./orders"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
const FULFILLMENTS_QUERY_KEY = "fulfillments" as const
|
||||
export const fulfillmentsQueryKeys = queryKeysFactory(FULFILLMENTS_QUERY_KEY)
|
||||
@@ -41,3 +42,24 @@ export const useCancelFulfillment = (
|
||||
...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",
|
||||
"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": {
|
||||
"cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.",
|
||||
"unfulfilledItems": "Unfulfilled Items",
|
||||
@@ -683,12 +691,12 @@
|
||||
"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",
|
||||
"markAsShipped": "Mark as shipped",
|
||||
"itemsToFulfillDesc": "Choose items and quantities to fulfill",
|
||||
"locationDescription": "Choose which location you want to fulfill items from.",
|
||||
"sendNotificationHint": "Notify customers about the created fulfillment.",
|
||||
|
||||
@@ -208,6 +208,11 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-create-fulfillment"),
|
||||
},
|
||||
{
|
||||
path: ":f_id/create-shipment",
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-create-shipment"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./order-create-shipment-form.tsx"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
Tooltip,
|
||||
toast,
|
||||
usePrompt,
|
||||
Button,
|
||||
} from "@medusajs/ui"
|
||||
import { format } from "date-fns"
|
||||
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 { Skeleton } from "../../../../../components/common/skeleton"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||
@@ -157,6 +158,7 @@ const Fulfillment = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const showLocation = !!fulfillment.location_id
|
||||
|
||||
@@ -168,8 +170,8 @@ const Fulfillment = ({
|
||||
}
|
||||
)
|
||||
|
||||
let statusText = "Fulfilled"
|
||||
let statusColor: "orange" | "green" | "red" = "orange"
|
||||
let statusText = "Awaiting shipping"
|
||||
let statusColor: "blue" | "green" | "red" = "blue"
|
||||
let statusTimestamp = fulfillment.created_at
|
||||
|
||||
if (fulfillment.canceled_at) {
|
||||
@@ -184,6 +186,8 @@ const Fulfillment = ({
|
||||
|
||||
const { mutateAsync } = useCancelOrderFulfillment(order.id, fulfillment.id)
|
||||
|
||||
const showShippingButton = !fulfillment.canceled_at && !fulfillment.shipped_at
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (fulfillment.shipped_at) {
|
||||
toast.warning(t("general.warning"), {
|
||||
@@ -303,11 +307,11 @@ const Fulfillment = ({
|
||||
{t("orders.fulfillment.trackingLabel")}
|
||||
</Text>
|
||||
<div>
|
||||
{fulfillment.tracking_links &&
|
||||
fulfillment.tracking_links.length > 0 ? (
|
||||
{fulfillment.labels && fulfillment.labels.length > 0 ? (
|
||||
<ul>
|
||||
{fulfillment.tracking_links.map((tlink) => {
|
||||
const hasUrl = tlink.url && tlink.url.length > 0
|
||||
{fulfillment.labels.map((tlink) => {
|
||||
const hasUrl =
|
||||
tlink.url && tlink.url.length > 0 && tlink.url !== "#"
|
||||
|
||||
if (hasUrl) {
|
||||
return (
|
||||
@@ -342,6 +346,16 @@ const Fulfillment = ({
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const DEFAULT_RELATIONS = [
|
||||
"*promotion",
|
||||
"*fulfillments",
|
||||
"*fulfillments.items",
|
||||
"*fulfillments.labels",
|
||||
]
|
||||
|
||||
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 { ClientHeaders } from "../types"
|
||||
|
||||
@@ -46,7 +46,7 @@ export class Fulfillment {
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return await this.client.fetch<HttpTypes.AdminFulfillmentResponse>(
|
||||
`/admin/fulfillments/${id}/shipments`,
|
||||
`/admin/fulfillments/${id}/shipment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AdminCancelOrderFulfillment,
|
||||
FindParams,
|
||||
HttpTypes,
|
||||
PaginatedResponse,
|
||||
|
||||
Reference in New Issue
Block a user