feat(dashboard) admin 3.0 order edit (#6665)
**What** - added Order Edit creation flow **NOTES** - since the state is managed on the server upon changing input / adding items a request is fired to update the edit - on save we only confirm the edit --- **TODO** - [x] item removal functionality --- https://github.com/medusajs/medusa/assets/16856471/01aa85ea-1fb1-4dff-9cf4-d8d79029c2cc
This commit is contained in:
1 parent
873c21355c
commit
189b03c485
16 files changed
+917
-7
No files matched your search
@@ -6,6 +6,8 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"end": "End",
|
"end": "End",
|
||||||
|
"open": "Open",
|
||||||
|
"close": "Close",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"range": "Range",
|
"range": "Range",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
"confirm": "Confirm",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"clearAll": "Clear all",
|
"clearAll": "Clear all",
|
||||||
@@ -257,6 +260,16 @@
|
|||||||
"requiresAction": "Requires action"
|
"requiresAction": "Requires action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"edits": {
|
||||||
|
"title": "Edit order",
|
||||||
|
"currentItems": "Current items",
|
||||||
|
"currentItemsDescription": "Adjust item quantity or remove.",
|
||||||
|
"addItemsDescription": "You can add new items to the order.",
|
||||||
|
"addItems": "Add items",
|
||||||
|
"amountPaid": "Amount paid",
|
||||||
|
"newTotal": "New total",
|
||||||
|
"differenceDue": "Difference due"
|
||||||
|
},
|
||||||
"reservations": {
|
"reservations": {
|
||||||
"allocatedLabel": "Allocated",
|
"allocatedLabel": "Allocated",
|
||||||
"notAllocatedLabel": "Not allocated"
|
"notAllocatedLabel": "Not allocated"
|
||||||
@@ -736,6 +749,7 @@
|
|||||||
"availability": "Availability",
|
"availability": "Availability",
|
||||||
"inventory": "Inventory",
|
"inventory": "Inventory",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
|
"note": "Note",
|
||||||
"taxInclusivePricing": "Tax inclusive pricing",
|
"taxInclusivePricing": "Tax inclusive pricing",
|
||||||
"taxRate": "Tax Rate",
|
"taxRate": "Tax Rate",
|
||||||
"taxCode": "Tax Code",
|
"taxCode": "Tax Code",
|
||||||
|
|||||||
+11
-5
@@ -6,14 +6,16 @@ type MoneyAmountCellProps = {
|
|||||||
currencyCode: string
|
currencyCode: string
|
||||||
amount?: number | null
|
amount?: number | null
|
||||||
align?: "left" | "right"
|
align?: "left" | "right"
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoneyAmountCell = ({
|
export const MoneyAmountCell = ({
|
||||||
currencyCode,
|
currencyCode,
|
||||||
amount,
|
amount,
|
||||||
align = "left",
|
align = "left",
|
||||||
|
className,
|
||||||
}: MoneyAmountCellProps) => {
|
}: MoneyAmountCellProps) => {
|
||||||
if (!amount) {
|
if (typeof amount === "undefined" || amount === null) {
|
||||||
return <PlaceholderCell />
|
return <PlaceholderCell />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,10 +23,14 @@ export const MoneyAmountCell = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clx("flex h-full w-full items-center overflow-hidden", {
|
className={clx(
|
||||||
"justify-start text-left": align === "left",
|
"flex h-full w-full items-center overflow-hidden",
|
||||||
"justify-end text-right": align === "right",
|
{
|
||||||
})}
|
"justify-start text-left": align === "left",
|
||||||
|
"justify-end text-right": align === "right",
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{formatted}</span>
|
<span className="truncate">{formatted}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export const v1Routes: RouteObject[] = [
|
|||||||
lazy: () =>
|
lazy: () =>
|
||||||
import("../../routes/orders/order-transfer-ownership"),
|
import("../../routes/orders/order-transfer-ownership"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "edit",
|
||||||
|
lazy: () => import("../../routes/orders/order-edit"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ const Header = ({ order }: { order: Order }) => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: t("orders.summary.editItems"),
|
label: t("orders.summary.editItems"),
|
||||||
to: "#", // TODO: Open modal to edit items
|
to: `/orders/${order.id}/edit`,
|
||||||
icon: <PencilSquare />,
|
icon: <PencilSquare />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const orderExpand =
|
export const orderExpand =
|
||||||
"items,items.variant,items.variant.options,sales_channel,shipping_methods,shipping_methods.shipping_option,discounts,payments,customer,shipping_address,shipping_address.country,billing_address,billing_address.country,fulfillments,fulfillments.items,fulfillments.items.item,fulfillments.tracking_links,refunds"
|
"items,items.variant,items.variant.options,sales_channel,shipping_methods,shipping_methods.shipping_option,discounts,payments,customer,shipping_address,shipping_address.country,billing_address,billing_address.country,fulfillments,fulfillments.items,fulfillments.items.item,fulfillments.tracking_links,refunds,edits,edits.items,edits.items.variant,edits.items.variant.product"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./order-edit-form"
|
||||||
+359
@@ -0,0 +1,359 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import * as zod from "zod"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
import {
|
||||||
|
adminOrderEditsKeys,
|
||||||
|
adminOrderKeys,
|
||||||
|
useAdminCancelOrderEdit,
|
||||||
|
useAdminConfirmOrderEdit,
|
||||||
|
useAdminOrderEditAddLineItem,
|
||||||
|
useAdminUpdateOrderEdit,
|
||||||
|
} from "medusa-react"
|
||||||
|
|
||||||
|
import { Button, clx, Heading, Text, Textarea } from "@medusajs/ui"
|
||||||
|
import { Order, OrderEdit } from "@medusajs/medusa"
|
||||||
|
|
||||||
|
import {
|
||||||
|
RouteFocusModal,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../../components/route-modal"
|
||||||
|
import { SplitView } from "../../../../../components/layout/split-view"
|
||||||
|
import { VariantTable } from "../variant-table"
|
||||||
|
|
||||||
|
import { medusa, queryClient } from "../../../../../lib/medusa.ts"
|
||||||
|
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
import { OrderEditItem } from "./order-edit-item"
|
||||||
|
|
||||||
|
type OrderEditFormProps = {
|
||||||
|
order: Order
|
||||||
|
orderEdit: OrderEdit
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuantitiesSchema = zod.union(
|
||||||
|
zod.record(zod.string(), zod.number().optional()),
|
||||||
|
zod.object({ note: zod.string().optional() })
|
||||||
|
)
|
||||||
|
|
||||||
|
export function OrderEditForm({ order, orderEdit }: OrderEditFormProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
const [, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STATE
|
||||||
|
*/
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isAddingItems, setIsAddingItems] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FORM
|
||||||
|
*/
|
||||||
|
const form = useForm<zod.infer<typeof QuantitiesSchema>>({
|
||||||
|
defaultValues: order.items.reduce(
|
||||||
|
(acc, i) => {
|
||||||
|
acc[i.id] = i.quantity
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ note: orderEdit.internal_note || "" }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD HOOKS
|
||||||
|
*/
|
||||||
|
const { mutateAsync: confirmOrderEdit } = useAdminConfirmOrderEdit(
|
||||||
|
orderEdit.id
|
||||||
|
)
|
||||||
|
const { mutateAsync: cancelOrderEdit } = useAdminCancelOrderEdit(orderEdit.id)
|
||||||
|
const { mutateAsync: addLineItemToOrderEdit } = useAdminOrderEditAddLineItem(
|
||||||
|
orderEdit.id
|
||||||
|
)
|
||||||
|
const { mutateAsync: updateOrderEdit } = useAdminUpdateOrderEdit(orderEdit.id)
|
||||||
|
|
||||||
|
const onQuantityChangeComplete = async (itemId: string) => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
const quantity = form.getValues()[itemId]
|
||||||
|
|
||||||
|
// if (form.getValues()[itemId] === 0) {
|
||||||
|
// await medusa.admin.orderEdits.removeLineItem(orderEdit.id, itemId)
|
||||||
|
// } else
|
||||||
|
|
||||||
|
if (quantity > 0) {
|
||||||
|
await medusa.admin.orderEdits.updateLineItem(orderEdit.id, itemId, {
|
||||||
|
quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
adminOrderEditsKeys.detail(orderEdit.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItems = useMemo(
|
||||||
|
() =>
|
||||||
|
orderEdit?.items
|
||||||
|
.sort((i1, i2) => i1.id.localeCompare(i2.id))
|
||||||
|
.filter((i) => i.original_item_id) || [],
|
||||||
|
[orderEdit]
|
||||||
|
)
|
||||||
|
const addedItems = useMemo(
|
||||||
|
() =>
|
||||||
|
orderEdit?.items
|
||||||
|
.sort((i1, i2) => i1.id.localeCompare(i2.id))
|
||||||
|
.filter((i) => !i.original_item_id) || [],
|
||||||
|
[orderEdit]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EFFECTS
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderEdit) {
|
||||||
|
orderEdit?.items.forEach((i) => {
|
||||||
|
form.setValue(i.id, i.quantity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [orderEdit?.items.length])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HANDLERS
|
||||||
|
*/
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setSearchParams(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVariantsSelect = async (variantIds: string[]) => {
|
||||||
|
setIsAddingItems(true)
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
variantIds.map((id) =>
|
||||||
|
addLineItemToOrderEdit({ variant_id: id, quantity: 1 })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries(adminOrderKeys.detail(order.id))
|
||||||
|
} finally {
|
||||||
|
setIsAddingItems(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemRemove = async (itemId: string) => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
const change = orderEdit.changes.find(
|
||||||
|
(change) =>
|
||||||
|
change.line_item_id === itemId ||
|
||||||
|
change.original_line_item_id === itemId
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (change) {
|
||||||
|
if (change.type === "item_add") {
|
||||||
|
await medusa.admin.orderEdits.deleteItemChange(
|
||||||
|
orderEdit.id,
|
||||||
|
change.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (change.type === "item_update") {
|
||||||
|
await medusa.admin.orderEdits.deleteItemChange(
|
||||||
|
orderEdit.id,
|
||||||
|
change.id
|
||||||
|
)
|
||||||
|
await medusa.admin.orderEdits.removeLineItem(orderEdit.id, itemId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await medusa.admin.orderEdits.removeLineItem(orderEdit.id, itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
adminOrderEditsKeys.detail(orderEdit.id)
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.note !== orderEdit?.internal_note) {
|
||||||
|
await updateOrderEdit({ internal_note: data.note })
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirmOrderEdit() // TODO error notification if fails
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSuccess(`/orders/${order.id}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO pass on "cancel" close
|
||||||
|
const handlCancel = async () => {
|
||||||
|
await cancelOrderEdit()
|
||||||
|
|
||||||
|
handleSuccess(`/orders/${order.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal.Form form={form}>
|
||||||
|
<form className="flex h-full flex-col" onSubmit={handleSubmit}>
|
||||||
|
<RouteFocusModal.Header>
|
||||||
|
<div className="flex h-full items-center justify-end gap-x-2">
|
||||||
|
<RouteFocusModal.Close asChild>
|
||||||
|
<Button variant="secondary" size="small">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</RouteFocusModal.Close>
|
||||||
|
<Button type="submit" size="small" isLoading={isSubmitting}>
|
||||||
|
{t("actions.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteFocusModal.Header>
|
||||||
|
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center overflow-hidden">
|
||||||
|
<SplitView open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<SplitView.Content>
|
||||||
|
<div className="conatiner mx-auto max-w-[720px]">
|
||||||
|
<div className={clx("flex h-full w-full flex-col pt-16")}>
|
||||||
|
<Heading level="h1" className="pb-8 text-left">
|
||||||
|
{t("orders.edits.title")}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text className="text-ui-fg-base mb-1" weight="plus">
|
||||||
|
{t("orders.edits.currentItems")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-ui-fg-subtle mb-4">
|
||||||
|
{t("orders.edits.currentItemsDescription")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{currentItems.map((item) => (
|
||||||
|
<OrderEditItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
form={form}
|
||||||
|
currencyCode={order.currency_code}
|
||||||
|
onRemove={onItemRemove}
|
||||||
|
onQuantityChangeComplete={onQuantityChangeComplete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-b border-dashed pb-10 ">
|
||||||
|
<Text className="text-ui-fg-base mb-1 mt-8" weight="plus">
|
||||||
|
{t("orders.edits.addItems")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-ui-fg-subtle mb-2">
|
||||||
|
{t("orders.edits.addItemsDescription")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!!addedItems.length &&
|
||||||
|
addedItems.map((item) => (
|
||||||
|
<div key={item.id} className="pb-4 pt-2">
|
||||||
|
<OrderEditItem
|
||||||
|
item={item}
|
||||||
|
form={form}
|
||||||
|
currencyCode={order.currency_code}
|
||||||
|
onRemove={onItemRemove}
|
||||||
|
onQuantityChangeComplete={onQuantityChangeComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="m mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
{t("orders.edits.addItems")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-2 border-b border-dashed py-10">
|
||||||
|
<div className="text-ui-fg-subtle flex justify-between text-sm">
|
||||||
|
<Text className="min-w-[50%] ">
|
||||||
|
{t("orders.edits.amountPaid")}
|
||||||
|
</Text>
|
||||||
|
<MoneyAmountCell
|
||||||
|
align="right"
|
||||||
|
currencyCode={order.currency_code}
|
||||||
|
amount={order.paid_total - order.refunded_total}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-subtle flex justify-between text-sm ">
|
||||||
|
<Text className="min-w-[50%] ">
|
||||||
|
{t("orders.edits.newTotal")}
|
||||||
|
</Text>
|
||||||
|
<MoneyAmountCell
|
||||||
|
align="right"
|
||||||
|
currencyCode={order.currency_code}
|
||||||
|
amount={orderEdit.total}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-ui-fg-base flex justify-between text-sm">
|
||||||
|
<Text className="min-w-[50%]" weight="plus">
|
||||||
|
{t("orders.edits.differenceDue")}
|
||||||
|
</Text>
|
||||||
|
<MoneyAmountCell
|
||||||
|
align="right"
|
||||||
|
currencyCode={order.currency_code}
|
||||||
|
amount={orderEdit.total - order.paid_total}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-10">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="note"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Text
|
||||||
|
className="text-ui-fg-base mb-1"
|
||||||
|
weight="plus"
|
||||||
|
>
|
||||||
|
{t("fields.note")}
|
||||||
|
</Text>
|
||||||
|
<Form.Control>
|
||||||
|
<Textarea {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SplitView.Content>
|
||||||
|
<SplitView.Drawer>
|
||||||
|
<VariantTable
|
||||||
|
isAddingItems={isAddingItems}
|
||||||
|
onSave={onVariantsSelect}
|
||||||
|
order={order}
|
||||||
|
/>
|
||||||
|
</SplitView.Drawer>
|
||||||
|
</SplitView>
|
||||||
|
</RouteFocusModal.Body>
|
||||||
|
</form>
|
||||||
|
</RouteFocusModal.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { UseFormReturn } from "react-hook-form"
|
||||||
|
|
||||||
|
import { Input, Text } from "@medusajs/ui"
|
||||||
|
import { LineItem } from "@medusajs/medusa"
|
||||||
|
|
||||||
|
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
|
||||||
|
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||||
|
import { Trash } from "@medusajs/icons"
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
|
||||||
|
type OrderEditItemProps = {
|
||||||
|
item: LineItem
|
||||||
|
currencyCode: string
|
||||||
|
|
||||||
|
form: UseFormReturn<Record<string, number>>
|
||||||
|
onQuantityChangeComplete: (id: string) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderEditItem({
|
||||||
|
item,
|
||||||
|
currencyCode,
|
||||||
|
form,
|
||||||
|
onQuantityChangeComplete,
|
||||||
|
onRemove,
|
||||||
|
}: OrderEditItemProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const thumbnail = item.thumbnail
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl">
|
||||||
|
<div className="flex gap-x-2 border-b p-3 text-sm">
|
||||||
|
<div className="flex flex-grow items-center gap-x-3">
|
||||||
|
<Thumbnail src={thumbnail} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div>
|
||||||
|
<Text as="span" weight="plus">
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{item.variant.sku && <span>(${item.variant.sku})</span>}
|
||||||
|
</div>
|
||||||
|
<Text as="div" className="text-ui-fg-subtle">
|
||||||
|
{item.variant.title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-ui-fg-subtle flex flex-shrink-0">
|
||||||
|
<MoneyAmountCell currencyCode={currencyCode} amount={item.total} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("actions.remove"),
|
||||||
|
icon: <Trash />,
|
||||||
|
onClick: () => onRemove(item.id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="block p-3 text-sm">
|
||||||
|
<Text weight="plus" className="mb-2">
|
||||||
|
{t("fields.quantity")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name={item.id}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Control>
|
||||||
|
<Input
|
||||||
|
className="bg-ui-bg-base w-full rounded-lg"
|
||||||
|
min={1}
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
field.onChange(val === "" ? null : Number(val))
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (typeof form.getValues()[item.id] === "undefined") {
|
||||||
|
field.onChange(1)
|
||||||
|
}
|
||||||
|
onQuantityChangeComplete(item.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OrderEditItem }
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./variant-table"
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useMemo } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table"
|
||||||
|
import { Checkbox } from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
ProductCell,
|
||||||
|
ProductHeader,
|
||||||
|
} from "../../../../../components/table/table-cells/product/product-cell"
|
||||||
|
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
|
||||||
|
import { PricedVariant } from "@medusajs/client-types"
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<PricedVariant>()
|
||||||
|
|
||||||
|
export const useVariantTableColumns = (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 }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "product",
|
||||||
|
header: () => <ProductHeader />,
|
||||||
|
cell: ({ row }) => <ProductCell product={row.original.product} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("sku", {
|
||||||
|
header: t("fields.sku"),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("title", {
|
||||||
|
header: t("fields.variant"),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "amount",
|
||||||
|
header: () => (
|
||||||
|
<div className="text-small text-right font-semibold text-gray-500">
|
||||||
|
{t("fields.price")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row: { original } }) => {
|
||||||
|
if (!original.original_price_incl_tax) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const showOriginal = original.calculated_price_type !== "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
{showOriginal && (
|
||||||
|
<span className="text-gray-400 line-through">
|
||||||
|
<MoneyAmountCell
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
amount={original.original_price_incl_tax}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
<MoneyAmountCell
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
amount={original.calculated_price_incl_tax}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Filter } from "../../../../../components/table/data-table"
|
||||||
|
|
||||||
|
export const useVariantTableFilters = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
{ label: t("fields.createdAt"), key: "created_at" },
|
||||||
|
{ label: t("fields.updatedAt"), key: "updated_at" },
|
||||||
|
].map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
type: "date",
|
||||||
|
}))
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { AdminGetVariantsParams } from "@medusajs/medusa"
|
||||||
|
|
||||||
|
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||||
|
|
||||||
|
export const useVariantTableQuery = ({
|
||||||
|
pageSize = 50,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
pageSize?: number
|
||||||
|
prefix: string
|
||||||
|
}) => {
|
||||||
|
const raw = useQueryParams(
|
||||||
|
["offset", "q", "title", "customer_id", "inventory_quantity"],
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchParams: AdminGetVariantsParams = {
|
||||||
|
limit: pageSize,
|
||||||
|
offset: raw.offset ? Number(raw.offset) : 0,
|
||||||
|
q: raw.q,
|
||||||
|
title: raw.title,
|
||||||
|
customer_id: raw.customer_id,
|
||||||
|
inventory_quantity: raw.inventory_quantity
|
||||||
|
? Number(raw.inventory_quantity)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchParams,
|
||||||
|
raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
import { PricedVariant } from "@medusajs/client-types"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useAdminVariants } from "medusa-react"
|
||||||
|
import { Button } from "@medusajs/ui"
|
||||||
|
import { Order } from "@medusajs/medusa"
|
||||||
|
|
||||||
|
import { DataTable } from "../../../../../components/table/data-table"
|
||||||
|
import { useDataTable } from "../../../../../hooks/use-data-table.tsx"
|
||||||
|
import { SplitView } from "../../../../../components/layout/split-view"
|
||||||
|
|
||||||
|
import { useVariantTableQuery } from "./use-variant-table-query"
|
||||||
|
import { useVariantTableColumns } from "./use-variant-table-columns"
|
||||||
|
import { useVariantTableFilters } from "./use-variant-table-filters"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export type Option = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer = ({
|
||||||
|
onSave,
|
||||||
|
isAddingItems,
|
||||||
|
}: {
|
||||||
|
isAddingItems: boolean
|
||||||
|
onSave: () => void
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||||
|
<SplitView.Close type="button" asChild>
|
||||||
|
<Button variant="secondary" size="small">
|
||||||
|
{t("general.close")}
|
||||||
|
</Button>
|
||||||
|
</SplitView.Close>
|
||||||
|
<Button
|
||||||
|
isLoading={isAddingItems}
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{t("general.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariantTableProps = {
|
||||||
|
onSave: (ids: string[]) => Promise<void>
|
||||||
|
isAddingItems: boolean
|
||||||
|
order: Order
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VariantTable = ({
|
||||||
|
onSave,
|
||||||
|
order,
|
||||||
|
isAddingItems,
|
||||||
|
}: VariantTableProps) => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [intermediate, setIntermediate] = useState<string[]>([])
|
||||||
|
|
||||||
|
const { searchParams, raw } = useVariantTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { variants, count, isLoading, isError, error } = useAdminVariants(
|
||||||
|
{
|
||||||
|
...searchParams,
|
||||||
|
region_id: order.region_id,
|
||||||
|
cart_id: order.cart_id,
|
||||||
|
customer_id: order.customer_id,
|
||||||
|
currency_code: order.currency_code,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const updater: OnChangeFn<RowSelectionState> = (fn) => {
|
||||||
|
const newState: RowSelectionState =
|
||||||
|
typeof fn === "function" ? fn(rowSelection) : fn
|
||||||
|
|
||||||
|
const added = Object.keys(newState).filter(
|
||||||
|
(k) => newState[k] !== rowSelection[k]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (added.length) {
|
||||||
|
const addedProducts = (variants?.filter((v) => added.includes(v.id!)) ??
|
||||||
|
[]) as PricedVariant[]
|
||||||
|
|
||||||
|
if (addedProducts.length > 0) {
|
||||||
|
const newConditions = addedProducts.map((p) => p.id!)
|
||||||
|
|
||||||
|
setIntermediate((prev) => {
|
||||||
|
const filteredPrev = prev.filter((p) => newState[p])
|
||||||
|
return Array.from(new Set([...filteredPrev, ...newConditions]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setRowSelection(newState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = Object.keys(rowSelection).filter(
|
||||||
|
(k) => newState[k] !== rowSelection[k]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (removed.length) {
|
||||||
|
setIntermediate((prev) => {
|
||||||
|
return prev.filter((p) => !removed.includes(p))
|
||||||
|
})
|
||||||
|
|
||||||
|
setRowSelection(newState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(intermediate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useVariantTableColumns(order.currency_code)
|
||||||
|
const filters = useVariantTableFilters()
|
||||||
|
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: (variants ?? []) as PricedVariant[],
|
||||||
|
columns: columns,
|
||||||
|
count,
|
||||||
|
enablePagination: true,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
enableRowSelection: true,
|
||||||
|
rowSelection: {
|
||||||
|
state: rowSelection,
|
||||||
|
updater,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
count={count}
|
||||||
|
queryObject={raw}
|
||||||
|
pagination
|
||||||
|
search
|
||||||
|
filters={filters}
|
||||||
|
isLoading={isLoading}
|
||||||
|
layout="fill"
|
||||||
|
orderBy={["title", "created_at", "updated_at"]}
|
||||||
|
/>
|
||||||
|
<Footer isAddingItems={isAddingItems} onSave={handleSave} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { orderLoader as loader } from "./loader"
|
||||||
|
export { OrderEdit as Component } from "./order-edit"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AdminOrdersRes } from "@medusajs/medusa"
|
||||||
|
import { Response } from "@medusajs/medusa-js"
|
||||||
|
import { adminOrderKeys } from "medusa-react"
|
||||||
|
import { LoaderFunctionArgs } from "react-router-dom"
|
||||||
|
|
||||||
|
import { medusa, queryClient } from "../../../lib/medusa"
|
||||||
|
import { orderExpand } from "../order-detail/constants"
|
||||||
|
|
||||||
|
const orderEditQuery = (id: string) => ({
|
||||||
|
queryKey: adminOrderKeys.detail(id),
|
||||||
|
queryFn: async () =>
|
||||||
|
medusa.admin.orders.retrieve(id, {
|
||||||
|
expand: orderExpand,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const orderLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||||
|
const id = params.id
|
||||||
|
const query = orderEditQuery(id!)
|
||||||
|
|
||||||
|
return (
|
||||||
|
queryClient.getQueryData<Response<AdminOrdersRes>>(query.queryKey) ??
|
||||||
|
(await queryClient.fetchQuery(query))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
useAdminCreateOrderEdit,
|
||||||
|
useAdminOrder,
|
||||||
|
useAdminOrderEdit,
|
||||||
|
} from "medusa-react"
|
||||||
|
import { useLoaderData, useParams } from "react-router-dom"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { orderLoader } from "./loader"
|
||||||
|
import { orderExpand } from "../order-detail/constants"
|
||||||
|
import { RouteFocusModal } from "../../../components/route-modal"
|
||||||
|
import { OrderEditForm } from "./components/order-edit-form"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag to ensure OE creation in useEffect is only executed once
|
||||||
|
*/
|
||||||
|
let isOECreationRunning = false
|
||||||
|
|
||||||
|
export const OrderEdit = () => {
|
||||||
|
const { mutateAsync: createOrderEdit } = useAdminCreateOrderEdit()
|
||||||
|
const initialData = useLoaderData() as Awaited<ReturnType<typeof orderLoader>>
|
||||||
|
|
||||||
|
const { id } = useParams()
|
||||||
|
|
||||||
|
const { order, isLoading, isError, error } = useAdminOrder(
|
||||||
|
id!,
|
||||||
|
{
|
||||||
|
expand: orderExpand,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// find created OE - there should exist only one per Order
|
||||||
|
const _orderEdit = order?.edits.find((oe) => oe.status === "created")
|
||||||
|
|
||||||
|
const { order_edit: orderEdit } = useAdminOrderEdit(
|
||||||
|
_orderEdit?.id as unknown as string,
|
||||||
|
{
|
||||||
|
expand: "changes,items,items.variant",
|
||||||
|
},
|
||||||
|
{ enabled: !!_orderEdit?.id }
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
if (!order || !!_orderEdit || isOECreationRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOECreationRunning = true
|
||||||
|
await createOrderEdit({
|
||||||
|
order_id: order.id,
|
||||||
|
// created_by: // TODO
|
||||||
|
})
|
||||||
|
isOECreationRunning = false
|
||||||
|
})()
|
||||||
|
}, [order])
|
||||||
|
|
||||||
|
if (isLoading || !order || !orderEdit) {
|
||||||
|
// TODO: Add loader
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
{!isLoading && order && (
|
||||||
|
<OrderEditForm order={order} orderEdit={orderEdit} />
|
||||||
|
)}
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user