feat(dashboard): create shipment flow (#7898)

**What**
- add "Mark shipped" to Fulfillment section

---

CLOSES CORE-2427
This commit is contained in:
Frane Polić
2024-07-02 16:50:15 +02:00
committed by GitHub
parent e3a0df3ba0
commit 87375db9ef
12 changed files with 277 additions and 12 deletions

View File

@@ -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,
})
}

View File

@@ -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.",

View File

@@ -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"),
},
],
},
],

View File

@@ -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(),
})

View File

@@ -0,0 +1 @@
export * from "./order-create-shipment-form.tsx"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { OrderCreateShipment as Component } from "./order-create-shipment"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -24,6 +24,7 @@ const DEFAULT_RELATIONS = [
"*promotion",
"*fulfillments",
"*fulfillments.items",
"*fulfillments.labels",
]
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(

View File

@@ -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,

View File

@@ -1,5 +1,4 @@
import {
AdminCancelOrderFulfillment,
FindParams,
HttpTypes,
PaginatedResponse,