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:
@@ -6,6 +6,8 @@
|
||||
"add": "Add",
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
"open": "Open",
|
||||
"close": "Close",
|
||||
"apply": "Apply",
|
||||
"range": "Range",
|
||||
"search": "Search",
|
||||
@@ -55,6 +57,7 @@
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"continue": "Continue",
|
||||
"confirm": "Confirm",
|
||||
"edit": "Edit",
|
||||
"download": "Download",
|
||||
"clearAll": "Clear all",
|
||||
@@ -257,6 +260,16 @@
|
||||
"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": {
|
||||
"allocatedLabel": "Allocated",
|
||||
"notAllocatedLabel": "Not allocated"
|
||||
@@ -736,6 +749,7 @@
|
||||
"availability": "Availability",
|
||||
"inventory": "Inventory",
|
||||
"optional": "Optional",
|
||||
"note": "Note",
|
||||
"taxInclusivePricing": "Tax inclusive pricing",
|
||||
"taxRate": "Tax Rate",
|
||||
"taxCode": "Tax Code",
|
||||
|
||||
@@ -6,14 +6,16 @@ type MoneyAmountCellProps = {
|
||||
currencyCode: string
|
||||
amount?: number | null
|
||||
align?: "left" | "right"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const MoneyAmountCell = ({
|
||||
currencyCode,
|
||||
amount,
|
||||
align = "left",
|
||||
className,
|
||||
}: MoneyAmountCellProps) => {
|
||||
if (!amount) {
|
||||
if (typeof amount === "undefined" || amount === null) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
@@ -21,10 +23,14 @@ export const MoneyAmountCell = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("flex h-full w-full items-center overflow-hidden", {
|
||||
"justify-start text-left": align === "left",
|
||||
"justify-end text-right": align === "right",
|
||||
})}
|
||||
className={clx(
|
||||
"flex h-full w-full items-center overflow-hidden",
|
||||
{
|
||||
"justify-start text-left": align === "left",
|
||||
"justify-end text-right": align === "right",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{formatted}</span>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +120,10 @@ export const v1Routes: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/orders/order-transfer-ownership"),
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () => import("../../routes/orders/order-edit"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -38,7 +38,7 @@ const Header = ({ order }: { order: Order }) => {
|
||||
actions: [
|
||||
{
|
||||
label: t("orders.summary.editItems"),
|
||||
to: "#", // TODO: Open modal to edit items
|
||||
to: `/orders/${order.id}/edit`,
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./order-edit-form"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./variant-table"
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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