feat(dashboard): order edit UI (#8700)

**What**
- order edit create flow
- active order edit panel

**Note**
- basic implementation of the flow, edge cases from the design such as fulfilled quantities validation will be added in a followup
This commit is contained in:
Frane Polić
2024-08-23 08:42:06 +02:00
committed by GitHub
parent 02c6574e01
commit 49353f8c3c
29 changed files with 1470 additions and 16 deletions
@@ -0,0 +1,202 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query"
import { HttpTypes } from "@medusajs/types"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { ordersQueryKeys } from "./orders"
export const useCreateOrderEdit = (
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
HttpTypes.AdminInitiateOrderEditRequest
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminInitiateOrderEditRequest) =>
sdk.admin.orderEdit.initiateRequest(payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useRequestOrderEdit = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
void
>
) => {
return useMutation({
mutationFn: () => sdk.admin.orderEdit.request(id),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useConfirmOrderEdit = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
void
>
) => {
return useMutation({
mutationFn: () => sdk.admin.orderEdit.confirm(id),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useCancelOrderEdit = (
orderId: string,
options?: UseMutationOptions<any, Error, any>
) => {
return useMutation({
mutationFn: () => sdk.admin.orderEdit.cancelRequest(orderId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddOrderEditItems = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
HttpTypes.AdminAddOrderEditItems
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminAddOrderEditItems) =>
sdk.admin.orderEdit.addItems(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
/**
* Update (quantity) of an item that was originally on the order.
*/
export const useUpdateOrderEditOriginalItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
HttpTypes.AdminUpdateOrderEditItem & { itemId: string }
>
) => {
return useMutation({
mutationFn: ({
itemId,
...payload
}: HttpTypes.AdminUpdateOrderEditItem & { itemId: string }) => {
return sdk.admin.orderEdit.updateOriginalItem(id, itemId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
/**
* Update (quantity) of an item that was added to the order edit.
*/
export const useUpdateOrderEditAddedItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
HttpTypes.AdminUpdateOrderEditItem & { actionId: string }
>
) => {
return useMutation({
mutationFn: ({
actionId,
...payload
}: HttpTypes.AdminUpdateOrderEditItem & { actionId: string }) => {
return sdk.admin.orderEdit.updateAddedItem(id, actionId, payload)
},
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
/**
* Remove item that was added to the edit.
* To remove an original item on the order, set quantity to 0.
*/
export const useRemoveOrderEditItem = (
id: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderEditPreviewResponse,
Error,
string
>
) => {
return useMutation({
mutationFn: (actionId: string) =>
sdk.admin.orderEdit.removeAddedItem(id, actionId),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(id),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -45,6 +45,7 @@ export const useOrder = (
export const useOrderPreview = (
id: string,
query?: HttpTypes.AdminOrderFilters,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminOrderPreviewResponse,
@@ -56,7 +57,7 @@ export const useOrderPreview = (
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => sdk.admin.order.retrievePreview(id),
queryFn: async () => sdk.admin.order.retrievePreview(id, query),
queryKey: ordersQueryKeys.preview(id),
...options,
})
@@ -29,6 +29,9 @@
"expired": "Expired",
"active": "Active",
"revoked": "Revoked",
"new": "New",
"modified": "Modified",
"removed": "Removed",
"admin": "Admin",
"store": "Store",
"details": "Details",
@@ -94,14 +97,17 @@
"actions": {
"save": "Save",
"saveAsDraft": "Save as draft",
"duplicate": "Duplicate",
"publish": "Publish",
"create": "Create",
"delete": "Delete",
"remove": "Remove",
"revoke": "Revoke",
"cancel": "Cancel",
"forceConfirm": "Force confirm",
"enable": "Enable",
"disable": "Disable",
"undo": "Undo",
"complete": "Complete",
"viewDetails": "View details",
"back": "Back",
@@ -806,7 +812,7 @@
"summary": {
"requestReturn": "Request return",
"allocateItems": "Allocate items",
"editItems": "Edit items"
"editOrder": "Edit order"
},
"payment": {
"title": "Payments",
@@ -840,7 +846,6 @@
"paymentLink": "Copy payment link for {{ amount }}",
"selectPaymentToRefund": "Select payment to refund"
},
"edits": {
"title": "Edit order",
"currentItems": "Current items",
@@ -849,7 +854,23 @@
"addItems": "Add items",
"amountPaid": "Amount paid",
"newTotal": "New total",
"differenceDue": "Difference due"
"differenceDue": "Difference due",
"create": "Edit Order",
"currentTotal": "Current total",
"noteHint": "Add an internal note for the edit",
"cancelSuccessToast": "Order edit canceled",
"createSuccessToast": "Order edit request created",
"activeChangeError": "There is already active order change on the order (return, claim, exchange etc.). Please finish or cancel the change before editing the order.",
"panel": {
"title": "Order edit requested"
},
"toast": {
"canceledSuccessfully": "Order edit cancelled",
"confirmedSuccessfully": "Order edit confirmed"
},
"validation": {
"quantityLowerThanFulfillment": "Cannot set quantity to be less then or equal to fulfilled quantity"
}
},
"returns": {
"create": "Create Return",
@@ -2326,7 +2347,9 @@
"productVariant": "Product Variant",
"prices": "Prices",
"available": "Available",
"inStock": "In stock"
"inStock": "In stock",
"added": "Added",
"removed": "Removed"
},
"fields": {
"amount": "Amount",
@@ -2342,7 +2365,6 @@
"inventoryItems": "Inventory items",
"inventoryItem": "Inventory item",
"requiredQuantity": "Required quantity",
"qty": "Qty",
"description": "Description",
"email": "Email",
"password": "Password",
@@ -2365,6 +2387,7 @@
"reason": "Reason",
"none": "none",
"all": "all",
"search": "Search",
"percentage": "Percentage",
"sales_channels": "Sales Channels",
"customer_groups": "Customer Groups",
@@ -257,6 +257,10 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/orders/order-create-exchange"),
},
{
path: "edits",
lazy: () => import("../../routes/orders/order-create-edit"),
},
{
path: "refund",
lazy: () =>
@@ -0,0 +1,80 @@
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useState } from "react"
import { DataTable } from "../../../../../components/table/data-table"
import { useVariants } from "../../../../../hooks/api"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useOrderEditItemTableFilters } from "./use-order-edit-item-table-filters"
import { useOrderEditItemTableQuery } from "./use-order-edit-item-table-query"
import { useOrderEditItemsTableColumns } from "./use-order-edit-item-table-columns"
const PAGE_SIZE = 50
const PREFIX = "rit"
type AddExchangeOutboundItemsTableProps = {
onSelectionChange: (ids: string[]) => void
currencyCode: string
}
export const AddOrderEditItemsTable = ({
onSelectionChange,
currencyCode,
}: AddExchangeOutboundItemsTableProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
setRowSelection(newState)
onSelectionChange(Object.keys(newState))
}
const { searchParams, raw } = useOrderEditItemTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const { variants = [], count } = useVariants({
...searchParams,
fields: "*inventory_items.inventory.location_levels,+inventory_quantity",
})
const columns = useOrderEditItemsTableColumns(currencyCode)
const filters = useOrderEditItemTableFilters()
const { table } = useDataTable({
data: variants,
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: (_row) => {
// TODO: Check inventory here. Check if other validations needs to be made
return true
},
rowSelection: {
state: rowSelection,
updater,
},
})
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
filters={filters}
pagination
layout="fill"
search
orderBy={["product_id", "title", "sku"]}
prefix={PREFIX}
queryObject={raw}
/>
</div>
)
}
@@ -0,0 +1 @@
export * from "./add-order-edit-items-table.tsx"
@@ -0,0 +1,68 @@
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import {
ProductCell,
ProductHeader,
} from "../../../../../components/table/table-cells/product/product-cell"
const columnHelper = createColumnHelper<any>()
export const useOrderEditItemsTableColumns = (currencyCode: string) => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
const isSelectable = row.getCanSelect()
return (
<Checkbox
disabled={!isSelectable}
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.display({
id: "product",
header: () => <ProductHeader />,
cell: ({ row }) => {
return <ProductCell product={row.original.product} />
},
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ getValue }) => {
return getValue() || "-"
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
}),
],
[t, currencyCode]
)
}
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useOrderEditItemTableFilters = () => {
const { t } = useTranslation()
const filters: Filter[] = [
{
key: "created_at",
label: t("fields.createdAt"),
type: "date",
},
{
key: "updated_at",
label: t("fields.updatedAt"),
type: "date",
},
]
return filters
}
@@ -0,0 +1,26 @@
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useOrderEditItemTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
["q", "offset", "order", "created_at", "updated_at"],
prefix
)
const { offset, created_at, updated_at, ...rest } = raw
const searchParams = {
...rest,
limit: pageSize,
offset: offset ? Number(offset) : 0,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
}
return { searchParams, raw }
}
@@ -0,0 +1 @@
export * from "./order-edit-create-form.tsx"
@@ -0,0 +1,213 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminOrder, AdminOrderPreview } from "@medusajs/types"
import { Button, Heading, Input, Switch, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { Form } from "../../../../../components/common/form"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { CreateOrderEditSchemaType, OrderEditCreateSchema } from "./schema"
import {
useCancelOrderEdit,
useRequestOrderEdit,
} from "../../../../../hooks/api/order-edits"
import { OrderEditItemsSection } from "./order-edit-items-section"
type ReturnCreateFormProps = {
order: AdminOrder
preview: AdminOrderPreview
}
export const OrderEditCreateForm = ({
order,
preview,
}: ReturnCreateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
/**
* MUTATIONS
*/
const { mutateAsync: cancelOrderEditRequest, isPending: isCanceling } =
useCancelOrderEdit(order.id)
const { mutateAsync: requestOrderEdit, isPending: isRequesting } =
useRequestOrderEdit(order.id)
const isRequestRunning = isCanceling || isRequesting
/**
* FORM
*/
const form = useForm<CreateOrderEditSchemaType>({
defaultValues: () => {
return Promise.resolve({
note: "", // TODO: add note when update edit route is added
send_notification: false, // TODO: not supported in the API ATM
})
},
resolver: zodResolver(OrderEditCreateSchema),
})
const handleSubmit = form.handleSubmit(async (data) => {
try {
await requestOrderEdit()
toast.success(t("orders.edits.createSuccessToast"))
handleSuccess()
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
})
}
})
return (
<RouteFocusModal.Form
form={form}
onClose={(isSubmitSuccessful) => {
if (!isSubmitSuccessful) {
cancelOrderEditRequest(undefined, {
onSuccess: () => {
toast.success(t("orders.edits.cancelSuccessToast"))
},
onError: (error) => {
toast.error(error.message)
},
})
}
}}
>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex size-full justify-center overflow-y-auto">
<div className="mt-16 w-[720px] max-w-[100%] px-4 md:p-0">
<Heading level="h1">{t("orders.edits.create")}</Heading>
<OrderEditItemsSection preview={preview} order={order} />
{/*TOTALS SECTION*/}
<div className="mt-8 border-y border-dotted py-4">
<div className="mb-2 flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.edits.currentTotal")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(order.total, order.currency_code)}
</span>
</div>
<div className="mb-2 flex items-center justify-between">
<span className="txt-small text-ui-fg-subtle">
{t("orders.edits.newTotal")}
</span>
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(preview.total, order.currency_code)}
</span>
</div>
<div className="mt-4 flex items-center justify-between border-t border-dotted pt-4">
<span className="txt-small font-medium">
{t("orders.exchanges.refundAmount")}
</span>
<span className="txt-small font-medium">
{getStylizedAmount(
preview.summary.pending_difference,
order.currency_code
)}
</span>
</div>
</div>
{/*NOTE*/}
<Form.Field
control={form.control}
name="note"
render={({ field }) => {
return (
<Form.Item>
<div className="mt-8 flex">
<div className="block flex-1">
<Form.Label>{t("fields.note")}</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.edits.noteHint")}
</Form.Hint>
</div>
<div className="w-full flex-1 flex-grow">
<Form.Control>
<Input {...field} placeholder={t("fields.note")} />
</Form.Control>
</div>
</div>
</Form.Item>
)
}}
/>
{/*SEND NOTIFICATION*/}
<div className="bg-ui-bg-field mt-8 rounded-lg border py-2 pl-2 pr-4">
<Form.Field
control={form.control}
name="send_notification"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center">
<Form.Control className="mr-4 self-start">
<Switch
className="mt-[2px]"
checked={!!value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
<div className="block">
<Form.Label>
{t("orders.returns.sendNotification")}
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.returns.sendNotificationHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex w-full items-center justify-end gap-x-4">
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button type="button" variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
isLoading={isRequestRunning}
>
{t("actions.save")}
</Button>
</div>
</div>
</RouteFocusModal.Footer>
</form>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1,231 @@
import { ArrowUturnLeft, DocumentSeries, XCircle } from "@medusajs/icons"
import { AdminOrderLineItem } from "@medusajs/types"
import { Badge, Input, Text, toast } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { useMemo } from "react"
import {
useAddOrderEditItems,
useRemoveOrderEditItem,
useUpdateOrderEditAddedItem,
useUpdateOrderEditOriginalItem,
} from "../../../../../hooks/api/order-edits"
type OrderEditItemProps = {
item: AdminOrderLineItem
currencyCode: string
orderId: string
}
function OrderEditItem({ item, currencyCode, orderId }: OrderEditItemProps) {
const { t } = useTranslation()
const { mutateAsync: addItems } = useAddOrderEditItems(orderId)
const { mutateAsync: updateAddedItem } = useUpdateOrderEditAddedItem(orderId)
const { mutateAsync: updateOriginalItem } =
useUpdateOrderEditOriginalItem(orderId)
const { mutateAsync: undoAction } = useRemoveOrderEditItem(orderId)
const isAddedItem = useMemo(
() => !!item.actions?.find((a) => a.action === "ITEM_ADD"),
[item]
)
const isItemUpdated = useMemo(
() => !!item.actions?.find((a) => a.action === "ITEM_UPDATE"),
[item]
)
const isItemRemoved = useMemo(() => {
// To be removed item needs to have updated quantity
const updateAction = item.actions?.find((a) => a.action === "ITEM_UPDATE")
return !!updateAction && item.quantity === item.detail.fulfilled_quantity
}, [item])
/**
* HANDLERS
*/
const onUpdate = async (quantity: number) => {
if (quantity <= item.detail.fulfilled_quantity) {
toast.error(t("orders.edits.validation.quantityLowerThanFulfillment"))
return
}
if (quantity === item.quantity) {
return
}
const addItemAction = item.actions?.find((a) => a.action === "ITEM_ADD")
try {
if (addItemAction) {
await updateAddedItem({ quantity, actionId: addItemAction.id })
} else {
await updateOriginalItem({ quantity, itemId: item.id })
}
} catch (e) {
toast.error(e.message)
}
}
const onRemove = async () => {
const addItemAction = item.actions?.find((a) => a.action === "ITEM_ADD")
try {
if (addItemAction) {
await undoAction(addItemAction.id)
} else {
await updateOriginalItem({
quantity: item.detail.fulfilled_quantity, //
itemId: item.id,
})
}
} catch (e) {
toast.error(e.message)
}
}
const onRemoveUndo = async () => {
const updateItemAction = item.actions?.find(
(a) => a.action === "ITEM_UPDATE"
)
try {
if (updateItemAction) {
await undoAction(updateItemAction.id) // Remove action that updated items quantity to fulfilled quantity which makes it "removed"
}
} catch (e) {
toast.error(e.message)
}
}
const onDuplicate = async () => {
try {
await addItems({
items: [
{
variant_id: item.variant_id,
quantity: item.quantity,
},
],
})
} catch (e) {
toast.error(e.message)
}
}
return (
<div
key={item.quantity}
className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl "
>
<div className="flex flex-col items-center gap-x-2 gap-y-2 p-3 text-sm md:flex-row">
<div className="flex flex-1 items-center justify-between">
<div className="flex flex-row items-center gap-x-3">
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{item.title}{" "}
</Text>
{item.variant_sku && <span>({item.variant_sku})</span>}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.product_title}
</Text>
</div>
</div>
{isAddedItem && (
<Badge size="2xsmall" rounded="full" color="blue" className="mr-1">
{t("general.new")}
</Badge>
)}
{isItemRemoved ? (
<Badge size="2xsmall" rounded="full" color="red" className="mr-1">
{t("general.removed")}
</Badge>
) : (
isItemUpdated && (
<Badge
size="2xsmall"
rounded="full"
color="orange"
className="mr-1"
>
{t("general.modified")}
</Badge>
)
)}
</div>
<div className="flex flex-1 justify-between">
<div className="flex flex-grow items-center gap-2">
<Input
className="bg-ui-bg-base txt-small w-[67px] rounded-lg [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
disabled={item.detail.fulfilled_quantity === item.quantity}
min={item.detail.fulfilled_quantity}
defaultValue={item.quantity}
onBlur={(e) => {
const val = e.target.value
const payload = val === "" ? null : Number(val)
if (payload) {
onUpdate(payload)
}
}}
/>
<Text className="txt-small text-ui-fg-subtle">
{t("fields.qty")}
</Text>
</div>
<div className="text-ui-fg-subtle txt-small mr-2 flex flex-shrink-0">
<MoneyAmountCell currencyCode={currencyCode} amount={item.total} />
</div>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.duplicate"),
onClick: onDuplicate,
icon: <DocumentSeries />,
},
],
},
{
actions: [
!isItemRemoved
? {
label: t("actions.remove"),
onClick: onRemove,
icon: <XCircle />,
disabled:
item.detail.fulfilled_quantity === item.quantity,
}
: {
label: t("actions.undo"),
onClick: onRemoveUndo,
icon: <ArrowUturnLeft />,
},
].filter(Boolean),
},
]}
/>
</div>
</div>
</div>
)
}
export { OrderEditItem }
@@ -0,0 +1,142 @@
import { useMemo, useState } from "react"
import { AdminOrder, AdminOrderPreview } from "@medusajs/types"
import { Button, Heading, Input, toast } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import {
RouteFocusModal,
StackedFocusModal,
useStackedModal,
} from "../../../../../components/modals"
import { AddOrderEditItemsTable } from "../add-order-edit-items-table"
import { OrderEditItem } from "./order-edit-item"
import { useAddOrderEditItems } from "../../../../../hooks/api/order-edits"
type ExchangeInboundSectionProps = {
order: AdminOrder
preview: AdminOrderPreview
}
let addedVariants: string[] = []
export const OrderEditItemsSection = ({
order,
preview,
}: ExchangeInboundSectionProps) => {
const { t } = useTranslation()
/**
* STATE
*/
const { setIsOpen } = useStackedModal()
const [filterTerm, setFilterTerm] = useState("")
/*
* MUTATIONS
*/
const { mutateAsync: addItems, isPending } = useAddOrderEditItems(order.id)
/**
* CALLBACKS
*/
const onItemsSelected = async () => {
try {
await addItems({
items: addedVariants.map((i) => ({
variant_id: i,
quantity: 1,
})),
})
} catch (e) {
toast.error(e.message)
}
setIsOpen("inbound-items", false)
}
const filteredItems = useMemo(() => {
return preview.items.filter(
(i) =>
i.title.toLowerCase().includes(filterTerm) ||
i.product_title.toLowerCase().includes(filterTerm)
)
}, [preview, filterTerm])
return (
<div>
<div className="mb-3 mt-8 flex items-center justify-between">
<Heading level="h2">{t("fields.items")}</Heading>
<div className="flex gap-2">
<Input
value={filterTerm}
onChange={(e) => setFilterTerm(e.target.value)}
placeholder={t("fields.search")}
autoComplete="off"
type="search"
/>
<StackedFocusModal id="inbound-items">
<StackedFocusModal.Trigger asChild>
<Button variant="secondary" size="small">
{t("actions.addItems")}
</Button>
</StackedFocusModal.Trigger>
<StackedFocusModal.Content>
<StackedFocusModal.Header />
<AddOrderEditItemsTable
currencyCode={order.currency_code}
onSelectionChange={(finalSelection) => {
addedVariants = finalSelection
}}
/>
<StackedFocusModal.Footer>
<div className="flex w-full items-center justify-end gap-x-4">
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button type="button" variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
key="submit-button"
type="submit"
variant="primary"
size="small"
role="button"
disabled={isPending}
onClick={async () => await onItemsSelected()}
>
{t("actions.save")}
</Button>
</div>
</div>
</StackedFocusModal.Footer>
</StackedFocusModal.Content>
</StackedFocusModal>
</div>
</div>
{filteredItems.map((item) => (
<OrderEditItem
key={item.id}
item={item}
orderId={order.id}
currencyCode={order.currency_code}
/>
))}
{filterTerm && !filteredItems.length && (
<div
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
className="bg-ui-bg-field mt-4 block h-[56px] w-full rounded-lg border border-dashed"
/>
)}
</div>
)
}
@@ -0,0 +1,8 @@
import { z } from "zod"
export const OrderEditCreateSchema = z.object({
note: z.string().optional(),
send_notification: z.boolean().optional(),
})
export type CreateOrderEditSchemaType = z.infer<typeof OrderEditCreateSchema>
@@ -0,0 +1 @@
export { OrderEditCreate as Component } from "./order-edit-create.tsx"
@@ -0,0 +1,65 @@
import { toast } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/modals"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { DEFAULT_FIELDS } from "../order-detail/constants"
import { OrderEditCreateForm } from "./components/order-edit-create-form"
import { useCreateOrderEdit } from "../../../hooks/api/order-edits"
let IS_REQUEST_RUNNING = false
export const OrderEditCreate = () => {
const { id } = useParams()
const navigate = useNavigate()
const { t } = useTranslation()
const { order } = useOrder(id!, {
fields: DEFAULT_FIELDS,
})
const { order: preview } = useOrderPreview(id!)
const { mutateAsync: createOrderEdit } = useCreateOrderEdit(order.id)
useEffect(() => {
async function run() {
if (IS_REQUEST_RUNNING || !preview) {
return
}
if (preview.order_change) {
if (preview.order_change.change_type !== "edit") {
navigate(`/orders/${preview.id}`, { replace: true })
toast.error(t("orders.edits.activeChangeError"))
}
return
}
IS_REQUEST_RUNNING = true
try {
const { order } = await createOrderEdit({
order_id: preview.id,
})
} catch (e) {
toast.error(e.message)
navigate(`/orders/${preview.id}`, { replace: true })
} finally {
IS_REQUEST_RUNNING = false
}
}
run()
}, [preview])
return (
<RouteFocusModal>
{preview && order && (
<OrderEditCreateForm order={order} preview={preview} />
)}
</RouteFocusModal>
)
}
@@ -41,7 +41,7 @@ function ExchangeInboundItem({
return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
<div className="flex flex-col items-center gap-x-2 gap-y-2 border-b p-3 text-sm md:flex-row">
<div className="flex flex-col items-center gap-x-2 gap-y-2 p-3 text-sm md:flex-row">
<div className="flex flex-1 items-center gap-x-3">
<Thumbnail src={item.thumbnail} />
@@ -34,7 +34,7 @@ function ExchangeOutboundItem({
return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
<div className="flex flex-col items-center gap-x-2 gap-y-2 border-b p-3 text-sm md:flex-row">
<div className="flex flex-col items-center gap-x-2 gap-y-2 p-3 text-sm md:flex-row">
<div className="flex flex-1 items-center gap-x-3">
<Thumbnail src={previewItem.thumbnail} />
@@ -0,0 +1 @@
export * from "./order-active-edit-section"
@@ -0,0 +1,177 @@
import { Button, Container, Copy, Heading, toast } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ExclamationCircleSolid } from "@medusajs/icons"
import { useOrderPreview } from "../../../../../hooks/api"
import {
useCancelOrderEdit,
useConfirmOrderEdit,
} from "../../../../../hooks/api/order-edits"
import { useMemo } from "react"
import { HttpTypes } from "@medusajs/types"
import { Thumbnail } from "../../../../../components/common/thumbnail"
type OrderActiveEditSectionProps = {
order: HttpTypes.AdminOrder
quantity: number
}
function EditItem({
item,
quantity,
}: {
item: HttpTypes.AdminOrderLineItem
quantity: number
}) {
return (
<div key={item.id} className="text-ui-fg-subtle items-center gap-x-2">
<div className="flex items-center gap-x-2">
<div className="w-fit min-w-[27px]">
<span className="txt-small tabular-nums">{quantity}</span>x
</div>
<Thumbnail src={item.thumbnail} />
<span className="txt-small txt-subtile font-medium">{item.title}</span>
{item.variant_sku && " · "}
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<span className="txt-small">{item.variant_sku}</span>
<Copy content={item.variant_sku} className="text-ui-fg-muted" />
</div>
)}
</div>
</div>
)
}
export const OrderActiveEditSection = ({
order,
}: OrderActiveEditSectionProps) => {
const { t } = useTranslation()
const { order: orderPreview } = useOrderPreview(order.id)
const { mutateAsync: cancelOrderEdit } = useCancelOrderEdit(order.id)
const { mutateAsync: confirmOrderEdit } = useConfirmOrderEdit(order.id)
const [addedItems, removedItems] = useMemo(() => {
const added = []
const removed = []
const orderLookupMap = new Map(order.items!.map((i) => [i.id, i]))
;(orderPreview?.items || []).forEach((currentItem) => {
const originalItem = orderLookupMap.get(currentItem.id)
if (!originalItem) {
added.push({ item: currentItem, quantity: currentItem.quantity })
return
}
if (originalItem.quantity > currentItem.quantity) {
removed.push({
item: currentItem,
quantity: originalItem.quantity - currentItem.quantity,
})
}
if (originalItem.quantity < currentItem.quantity) {
added.push({
item: currentItem,
quantity: currentItem.quantity - originalItem.quantity,
})
}
})
return [added, removed]
}, [orderPreview])
const onConfirmOrderEdit = async () => {
try {
await confirmOrderEdit()
toast.success(t("orders.edits.toast.confirmedSuccessfully"))
} catch (e) {
toast.error(e.message)
}
}
const onCancelOrderEdit = async () => {
try {
await cancelOrderEdit()
toast.success(t("orders.edits.toast.canceledSuccessfully"))
} catch (e) {
toast.error(e.message)
}
}
if (!orderPreview || orderPreview.order_change?.change_type !== "edit") {
return null
}
return (
<div
style={{
background:
"repeating-linear-gradient(-45deg, rgb(212, 212, 216, 0.15), rgb(212, 212, 216,.15) 10px, transparent 10px, transparent 20px)",
}}
className="-m-4 mb-1 border-b p-4"
>
<Container className="flex items-center justify-between p-0">
<div className="flex w-full flex-col divide-y divide-dashed">
<div className="flex items-center gap-2 px-6 py-4">
<ExclamationCircleSolid className="text-blue-500" />
<Heading level="h2">{t("orders.edits.panel.title")}</Heading>
</div>
{/*ADDED ITEMS*/}
{!!addedItems.length && (
<div className="txt-small text-ui-fg-subtle flex flex-row px-6 py-4">
<span className="flex-1 font-medium">{t("labels.added")}</span>
<div className="flex flex-1 flex-col gap-y-2">
{addedItems.map(({ item, quantity }) => (
<EditItem key={item.id} item={item} quantity={quantity} />
))}
</div>
</div>
)}
{/*REMOVED ITEMS*/}
{!!removedItems.length && (
<div className="txt-small text-ui-fg-subtle flex flex-row px-6 py-4">
<span className="flex-1 font-medium">{t("labels.removed")}</span>
<div className="flex flex-1 flex-col gap-y-2">
{removedItems.map(({ item, quantity }) => (
<EditItem key={item.id} item={item} quantity={quantity} />
))}
</div>
</div>
)}
<div className="bg-ui-bg-subtle flex items-center justify-end gap-x-2 rounded-b-xl px-4 py-4">
<Button
size="small"
variant="secondary"
onClick={onConfirmOrderEdit}
>
{t("actions.forceConfirm")}
</Button>
<Button
size="small"
variant="secondary"
onClick={onCancelOrderEdit}
>
{t("actions.cancel")}
</Button>
</div>
</div>
</Container>
</div>
)
}
@@ -9,6 +9,7 @@ import {
ArrowUturnLeft,
DocumentText,
ExclamationCircle,
PencilSquare,
} from "@medusajs/icons"
import {
AdminClaim,
@@ -255,11 +256,20 @@ const Header = ({
groups={[
{
actions: [
// {
// label: t("orders.summary.editItems"),
// to: `/orders/${order.id}/edit`,
// icon: <PencilSquare />,
// },
{
label: t("orders.summary.editOrder"),
to: `/orders/${order.id}/edits`,
icon: <PencilSquare />,
disabled:
(orderPreview?.order_change &&
orderPreview?.order_change?.change_type !== "edit") ||
(orderPreview?.order_change?.change_type === "edit" &&
orderPreview?.order_change?.status === "requested"),
},
],
},
{
actions: [
// {
// label: t("orders.summary.allocateItems"),
// to: "#", // TODO: Open modal to allocate items
@@ -15,6 +15,7 @@ import after from "virtual:medusa/widgets/order/details/after"
import before from "virtual:medusa/widgets/order/details/before"
import sideAfter from "virtual:medusa/widgets/order/details/side/after"
import sideBefore from "virtual:medusa/widgets/order/details/side/before"
import { OrderActiveEditSection } from "./components/order-active-edit-section"
export const OrderDetail = () => {
const initialData = useLoaderData() as Awaited<ReturnType<typeof orderLoader>>
@@ -50,6 +51,7 @@ export const OrderDetail = () => {
})}
<div className="flex flex-col gap-x-4 lg:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-3">
<OrderActiveEditSection order={order} />
<OrderGeneralSection order={order} />
<OrderSummarySection order={order} />
<OrderPaymentSection order={order} />
@@ -13,7 +13,7 @@ import { throwIfOrderIsCancelled } from "../../utils/order-validation"
/**
* This step validates that an order-edit can be requested for an order.
*/
export const beginorderEditValidationStep = createStep(
export const beginOrderEditValidationStep = createStep(
"begin-order-edit-validation",
async function ({ order }: { order: OrderDTO }) {
throwIfOrderIsCancelled({ order })
@@ -37,7 +37,7 @@ export const beginOrderEditOrderWorkflow = createWorkflow(
throw_if_key_not_found: true,
})
beginorderEditValidationStep({ order })
beginOrderEditValidationStep({ order })
const orderChangeInput = transform({ input }, ({ input }) => {
return {
+3
View File
@@ -33,6 +33,7 @@ import { TaxRate } from "./tax-rate"
import { TaxRegion } from "./tax-region"
import { Upload } from "./upload"
import { User } from "./user"
import { OrderEdit } from "./order-edit"
export class Admin {
public invite: Invite
@@ -56,6 +57,7 @@ export class Admin {
public inventoryItem: InventoryItem
public notification: Notification
public order: Order
public orderEdit: OrderEdit
public return: Return
public claim: Claim
public exchange: Exchange
@@ -92,6 +94,7 @@ export class Admin {
this.inventoryItem = new InventoryItem(client)
this.notification = new Notification(client)
this.order = new Order(client)
this.orderEdit = new OrderEdit(client)
this.return = new Return(client)
this.claim = new Claim(client)
this.taxRate = new TaxRate(client)
@@ -0,0 +1,140 @@
import { HttpTypes } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class OrderEdit {
private client: Client
constructor(client: Client) {
this.client = client
}
async initiateRequest(
body: HttpTypes.AdminInitiateOrderEditRequest,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits`,
{
method: "POST",
headers,
body,
query,
}
)
}
async request(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits/${id}/request`,
{
method: "POST",
headers,
query,
}
)
}
async confirm(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits/${id}/confirm`,
{
method: "POST",
headers,
query,
}
)
}
async cancelRequest(
id: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditDeleteResponse>(
`/admin/order-edits/${id}`,
{
method: "DELETE",
headers,
query,
}
)
}
async addItems(
id: string,
body: HttpTypes.AdminAddOrderEditItems,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits/${id}/items`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateOriginalItem(
id: string,
itemId: string,
body: HttpTypes.AdminUpdateOrderEditItem,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits/${id}/items/item/${itemId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async updateAddedItem(
id: string,
actionId: string,
body: HttpTypes.AdminUpdateOrderEditItem,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits/${id}/items/${actionId}`,
{
method: "POST",
headers,
body,
query,
}
)
}
async removeAddedItem(
id: string,
actionId: string,
query?: HttpTypes.SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderEditPreviewResponse>(
`/admin/order-edits/${id}/items/${actionId}`,
{
method: "DELETE",
headers,
query,
}
)
}
}
+5 -1
View File
@@ -23,7 +23,11 @@ export class Order {
)
}
async retrievePreview(id: string, headers?: ClientHeaders) {
async retrievePreview(
id: string,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderPreviewResponse>(
`/admin/orders/${id}/preview`,
{
@@ -1 +1,2 @@
export * from "./responses"
export * from "./payloads"
@@ -0,0 +1,22 @@
export interface AdminInitiateOrderEditRequest {
order_id: string
description?: string
internal_note?: string
metadata?: Record<string, unknown>
}
export interface AdminAddOrderEditItems {
items: {
variant_id: string
quantity: number
unit_price?: number
internal_note?: string
allow_backorder?: boolean
metadata?: Record<string, unknown>
}[]
}
export interface AdminUpdateOrderEditItem {
quantity?: number
internal_note?: string | null
}
@@ -7,3 +7,9 @@ export interface AdminOrderEditPreviewResponse {
export interface AdminOrderEditResponse {
order_change: OrderChangeDTO
}
export interface AdminOrderEditDeleteResponse {
id: string
object: "order-edit"
deleted: true
}