From c3f26a68266ce2b4427c04b03120e4a441f29eec Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:29:59 +0100 Subject: [PATCH] feat(dashboard,medusa): Draft order detail (#6703) **What** - Adds draft order details page - Adds Shipping and Billing address forms to both draft order and order pages - Adds Email form to both draft order and order pages - Adds transfer ownership form to draft order, order and customer pages - Update Combobox component allowing it to work with async data (`useInfiniteQuery`) **@medusajs/medusa** - Include country as a default relation of draft order addresses --- .changeset/fast-crabs-drop.md | 5 + packages/admin-next/dashboard/package.json | 1 + .../public/locales/en-US/translation.json | 53 +- .../components/common/combobox/combobox.tsx | 459 +++++++++++------- .../common/customer-info/customer-info.tsx | 267 ++++++++++ .../components/common/customer-info/index.ts | 1 + .../debounced-search/debounced-search.tsx | 46 -- .../common/debounced-search/index.ts | 1 - .../generic-forward-ref.tsx | 7 + .../common/generic-forward-ref/index.ts | 1 + .../components/common/skeleton/skeleton.tsx | 2 +- .../forms/address-form/address-form.tsx | 213 ++++++++ .../components/forms/address-form/index.ts | 1 + .../forms/email-form/email-form.tsx | 42 ++ .../src/components/forms/email-form/index.ts | 1 + .../forms/transfer-ownership-form/index.ts | 1 + .../transfer-ownership-form.tsx | 261 ++++++++++ .../fulfillment-status-cell.tsx | 22 +- .../payment-status-cell.tsx | 14 +- .../dashboard/src/lib/order-helpers.ts | 50 ++ .../admin-next/dashboard/src/lib/schemas.ts | 36 ++ .../src/providers/router-provider/v1.tsx | 56 ++- .../customer-order-section.tsx | 47 +- .../index.ts | 1 + ...transfer-customer-order-ownership-form.tsx | 72 +++ .../customer-transfer-ownership.tsx | 28 ++ .../customer-transfer-ownership/index.ts | 1 + .../edit-address-form/edit-address-form.tsx | 98 ++++ .../common/edit-address-form/index.ts | 1 + .../draft-order-billing-address.tsx | 50 ++ .../draft-order-billing-address/index.ts | 1 + .../draft-order-customer-section.tsx | 71 +++ .../draft-order-customer-section/index.ts | 1 + .../draft-order-general-section.tsx | 228 +++++++++ .../draft-order-general-section/index.ts | 1 + .../draft-order-summary-section.tsx | 269 ++++++++++ .../draft-order-summary-section/index.ts | 1 + .../draft-order-detail/details.tsx | 3 - .../draft-order-detail/draft-order-detail.tsx | 43 ++ .../draft-orders/draft-order-detail/index.ts | 3 +- .../draft-orders/draft-order-detail/loader.ts | 21 + .../edit-draft-order-email-form.tsx | 66 +++ .../edit-draft-order-email-form/index.ts | 1 + .../draft-order-email/draft-order-email.tsx | 28 ++ .../draft-orders/draft-order-email/index.ts | 1 + .../draft-order-list-table.tsx | 2 +- .../draft-order-shipping-address.tsx | 50 ++ .../draft-order-shipping-address/index.ts | 1 + .../index.ts | 1 + .../transfer-draft-order-ownership-form.tsx | 72 +++ .../draft-order-transfer-ownership.tsx | 28 ++ .../draft-order-transfer-ownership/index.ts | 1 + .../edit-order-address-form.tsx | 96 ++++ .../orders/common/edit-address-form/index.ts | 1 + .../orders/order-billing-address/index.ts | 1 + .../order-billing-address.tsx | 50 ++ .../order-customer-section.tsx | 284 +---------- .../order-general-section.tsx | 41 +- .../orders/order-detail/order-detail.tsx | 3 +- .../edit-order-email-form.tsx | 65 +++ .../components/edit-order-email-form/index.ts | 1 + .../src/routes/orders/order-email/index.ts | 1 + .../routes/orders/order-email/order-email.tsx | 28 ++ .../orders/order-shipping-address/index.ts | 1 + .../order-shipping-address.tsx | 50 ++ .../transfer-order-ownership-form/index.ts | 1 + .../transfer-order-ownership-form.tsx | 72 +++ .../orders/order-transfer-ownership/index.ts | 1 + .../order-transfer-ownership.tsx | 28 ++ .../api/routes/admin/draft-orders/index.ts | 2 + 70 files changed, 2884 insertions(+), 573 deletions(-) create mode 100644 .changeset/fast-crabs-drop.md create mode 100644 packages/admin-next/dashboard/src/components/common/customer-info/customer-info.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/customer-info/index.ts delete mode 100644 packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx delete mode 100644 packages/admin-next/dashboard/src/components/common/debounced-search/index.ts create mode 100644 packages/admin-next/dashboard/src/components/common/generic-forward-ref/generic-forward-ref.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/generic-forward-ref/index.ts create mode 100644 packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx create mode 100644 packages/admin-next/dashboard/src/components/forms/address-form/index.ts create mode 100644 packages/admin-next/dashboard/src/components/forms/email-form/email-form.tsx create mode 100644 packages/admin-next/dashboard/src/components/forms/email-form/index.ts create mode 100644 packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/index.ts create mode 100644 packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx create mode 100644 packages/admin-next/dashboard/src/lib/order-helpers.ts create mode 100644 packages/admin-next/dashboard/src/lib/schemas.ts create mode 100644 packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/transfer-customer-order-ownership-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/customer-transfer-ownership.tsx create mode 100644 packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/edit-address-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/draft-order-billing-address.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/draft-order-customer-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/draft-order-general-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/draft-order-summary-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/index.ts delete mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/details.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/draft-order-detail.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/edit-draft-order-email-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/draft-order-email.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/draft-order-shipping-address.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/transfer-draft-order-ownership-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/draft-order-transfer-ownership.tsx create mode 100644 packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/edit-order-address-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-billing-address/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-billing-address/order-billing-address.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/edit-order-email-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-email/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-email/order-email.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-shipping-address/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-shipping-address/order-shipping-address.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/transfer-order-ownership-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/order-transfer-ownership.tsx diff --git a/.changeset/fast-crabs-drop.md b/.changeset/fast-crabs-drop.md new file mode 100644 index 0000000000..9219a4f602 --- /dev/null +++ b/.changeset/fast-crabs-drop.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): Include `country` in draft orders' carts' default relations to allow properly displaying addresses. diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 7337b56e67..faea370361 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -6,6 +6,7 @@ "generate:static": "node ./scripts/generate-countries.js && prettier --write ./src/lib/countries.ts && node ./scripts/generate-currencies.js && prettier --write ./src/lib/currencies.ts", "dev": "vite", "build": "vite build", + "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "main": "index.html", diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index b8cf1c9d1e..63dffaa0e3 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -91,9 +91,46 @@ "defaultMessage": "An error occurred while rendering this page." }, "addresses": { - "shippingAddress": "Shipping Address", - "billingAddress": "Billing Address", - "sameAsShipping": "Same as shipping address" + "shippingAddress": { + "header": "Shipping Address", + "editHeader": "Edit Shipping Address", + "editLabel": "Shipping address", + "label": "Shipping address" + }, + "billingAddress": { + "header": "Billing Address", + "editHeader": "Edit Billing Address", + "editLabel": "Billing address", + "label": "Billing address", + "sameAsShipping": "Same as shipping address" + }, + "contactHeading": "Contact", + "locationHeading": "Location" + }, + "email": { + "editHeader": "Edit Email", + "editLabel": "Email", + "label": "Email" + }, + "transferOwnership": { + "header": "Transfer Ownership", + "label": "Transfer ownership", + "details": { + "order": "Order details", + "draft": "Draft details" + }, + "currentOwner": { + "label": "Current owner", + "hint": "The current owner of the order." + }, + "newOwner": { + "label": "New owner", + "hint": "The new owner to transfer the order to." + }, + "validation": { + "mustBeDifferent": "The new owner must be different from the current owner.", + "required": "New owner is required." + } }, "products": { "domain": "Products", @@ -257,6 +294,13 @@ "draftOrders": { "domain": "Draft Orders", "deleteWarning": "You are about to delete the draft order {{id}}. This action cannot be undone.", + "paymentLinkLabel": "Payment link", + "cartIdLabel": "Cart ID", + "markAsPaid": { + "label": "Mark as paid", + "warningTitle": "Mark as Paid", + "warningDescription": "You are about to mark the draft order as paid. This action cannot be undone, and collecting payment will not be possible later." + }, "status": { "open": "Open", "completed": "Completed" @@ -741,7 +785,8 @@ "summary": "Summary", "label": "Label", "rate": "Rate", - "requiresShipping": "Requires shipping" + "requiresShipping": "Requires shipping", + "draft": "Draft" }, "dateTime": { "years_one": "Year", diff --git a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx b/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx index c4afbc229a..7fca6fcea2 100644 --- a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx +++ b/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx @@ -12,217 +12,318 @@ import * as Popover from "@radix-ui/react-popover" import { matchSorter } from "match-sorter" import { ComponentPropsWithoutRef, - forwardRef, + ForwardedRef, + useCallback, useImperativeHandle, useMemo, useRef, useState, + useTransition, } from "react" import { useTranslation } from "react-i18next" +import { genericForwardRef } from "../generic-forward-ref" type ComboboxOption = { value: string label: string } -interface ComboboxProps +type Value = string[] | string + +interface ComboboxProps extends Omit, "onChange" | "value"> { - value?: string[] - onChange?: (value?: string[]) => void + value?: T + onChange?: (value?: T) => void + searchValue?: string + onSearchValueChange?: (value: string) => void options: ComboboxOption[] + fetchNextPage?: () => void + isFetchingNextPage?: boolean } -export const Combobox = forwardRef( - ( - { - value: controlledValue, - onChange, - options, - className, - placeholder, - ...inputProps - }, - ref - ) => { - const [open, setOpen] = useState(false) - const { t } = useTranslation() +const ComboboxImpl = ( + { + value: controlledValue, + onChange, + searchValue: controlledSearchValue, + onSearchValueChange, + options, + className, + placeholder, + fetchNextPage, + isFetchingNextPage, + ...inputProps + }: ComboboxProps, + ref: ForwardedRef +) => { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() + const { t } = useTranslation() - const comboboxRef = useRef(null) - const listboxRef = useRef(null) + const comboboxRef = useRef(null) + const listboxRef = useRef(null) - useImperativeHandle(ref, () => comboboxRef.current!) + useImperativeHandle(ref, () => comboboxRef.current!) - const isControlled = controlledValue !== undefined - const [searchValue, setSearchValue] = useState("") - const [uncontrolledValue, setUncontrolledValue] = useState([]) + const isValueControlled = controlledValue !== undefined + const isSearchControlled = controlledSearchValue !== undefined - const selectedValues = isControlled ? controlledValue : uncontrolledValue + const isArrayValue = Array.isArray(controlledValue) + const emptyState = (isArrayValue ? [] : "") as T - const handleValueChange = (newValues?: string[]) => { - if (!isControlled) { - setUncontrolledValue(newValues || []) - } - if (onChange) { - onChange(newValues) - } + const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState( + controlledSearchValue || "" + ) + const [uncontrolledValue, setUncontrolledValue] = useState(emptyState) + + const searchValue = isSearchControlled + ? controlledSearchValue + : uncontrolledSearchValue + const selectedValues = isValueControlled ? controlledValue : uncontrolledValue + + const handleValueChange = (newValues?: T) => { + if (!isArrayValue) { + const label = options.find((o) => o.value === newValues)?.label + + setUncontrolledSearchValue(label || "") } - /** - * Filter and sort the options based on the search value, - * and whether the value is already selected. - */ - const matches = useMemo(() => { - return matchSorter(options, searchValue, { - keys: ["label"], - baseSort: (a, b) => { - const aIndex = selectedValues.indexOf(a.item.value) - const bIndex = selectedValues.indexOf(b.item.value) + if (!isValueControlled) { + setUncontrolledValue(newValues || emptyState) + } + if (onChange) { + onChange(newValues) + } - if (aIndex === -1 && bIndex === -1) { - return 0 - } + setUncontrolledSearchValue("") + } - if (aIndex === -1) { - return 1 - } + const handleSearchChange = (query: string) => { + setUncontrolledSearchValue(query) - if (bIndex === -1) { - return -1 - } + if (onSearchValueChange) { + onSearchValueChange(query) + } + } - return aIndex - bIndex - }, - }) - }, [options, searchValue, selectedValues]) + /** + * Filter and sort the options based on the search value, + * and whether the value is already selected. + * + * This is only used when the search value is uncontrolled. + */ + const matches = useMemo(() => { + if (isSearchControlled) { + return [] + } - const hasValues = selectedValues.length > 0 - const showSelected = hasValues && !searchValue && !open - const hidePlaceholder = showSelected || open + return matchSorter(options, uncontrolledSearchValue, { + keys: ["label"], + baseSort: (a, b) => { + const aIndex = selectedValues.indexOf(a.item.value) + const bIndex = selectedValues.indexOf(b.item.value) - return ( - - { - setSearchValue(value) - }} - > - -
{ + const first = entries[0] + if (first.isIntersecting) { + fetchNextPage?.() + } + }, + { threshold: 1 } + ) + ) + + const lastOptionRef = useCallback( + (node: HTMLDivElement) => { + if (isFetchingNextPage) { + return + } + if (observer.current) { + observer.current.disconnect() + } + if (node) { + observer.current.observe(node) + } + }, + [isFetchingNextPage] + ) + + const hasValue = selectedValues.length > 0 + + const showTag = hasValue && isArrayValue + const showSelected = showTag && !searchValue && !open + + const hideInput = !isArrayValue && !open + const selectedLabel = options.find((o) => o.value === selectedValues)?.label + + const hidePlaceholder = showSelected || open + + const results = isSearchControlled ? options : matches + + return ( + + handleValueChange(value as T)} + value={uncontrolledSearchValue} + setValue={(query) => { + startTransition(() => handleSearchChange(query)) + }} + > + +
+ {showTag && ( +
+ {selectedValues.length} + +
+ )} +
+ {showSelected && ( + + {t("general.selected")} + )} - > - {hasValues && ( -
- {selectedValues.length} - + {hideInput && ( +
+ + {selectedLabel} +
)} -
- {showSelected && ( - - {t("general.selected")} - - )} - -
- -
- - - event.preventDefault()} - onInteractOutside={(event) => { - const target = event.target as Element | null - const isCombobox = target === comboboxRef.current - const inListbox = target && listboxRef.current?.contains(target) - - if (isCombobox || inListbox) { - event.preventDefault() - } - }} - > - - {matches.map(({ value, label }) => ( - +
+ +
+
+ + event.preventDefault()} + onInteractOutside={(event) => { + const target = event.target as Element | null + const isCombobox = target === comboboxRef.current + const inListbox = target && listboxRef.current?.contains(target) + + if (isCombobox || inListbox) { + event.preventDefault() + } + }} + aria-busy={isPending} + > + + {results.map(({ value, label }) => ( + + + + + + {label} + + + ))} + {!!fetchNextPage &&
} + {isFetchingNextPage && ( +
+
+
+ )} + {!results.length && ( +
+ - - - - - {label} - - - ))} - {!matches.length && ( -
- - {t("general.noResultsTitle")} - -
- )} - - - - - - ) - } -) -Combobox.displayName = "Combobox" + {t("general.noResultsTitle")} +
+
+ )} + + + + + + ) +} + +export const Combobox = genericForwardRef(ComboboxImpl) diff --git a/packages/admin-next/dashboard/src/components/common/customer-info/customer-info.tsx b/packages/admin-next/dashboard/src/components/common/customer-info/customer-info.tsx new file mode 100644 index 0000000000..2a031778a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/customer-info/customer-info.tsx @@ -0,0 +1,267 @@ +import { Address, Cart, Order } from "@medusajs/medusa" +import { Avatar, Copy, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +const ID = ({ data }: { data: Cart | Order }) => { + const { t } = useTranslation() + + const id = data.customer_id + const name = getCartOrOrderCustomer(data) + const email = data.email + const fallback = (name || email).charAt(0).toUpperCase() + + return ( +
+ + {t("fields.id")} + + +
+ + + {name || email} + +
+ +
+ ) +} + +const Company = ({ data }: { data: Order | Cart }) => { + const { t } = useTranslation() + const company = + data.shipping_address?.company || data.billing_address?.company + + if (!company) { + return null + } + + return ( +
+ + {t("fields.company")} + + + {company} + +
+ ) +} + +const Contact = ({ data }: { data: Cart | Order }) => { + const { t } = useTranslation() + + const phone = data.shipping_address?.phone || data.billing_address?.phone + const email = data.email + + return ( +
+ + {t("orders.customer.contactLabel")} + +
+
+ + {email} + + +
+ +
+
+ {phone && ( +
+ + {phone} + + +
+ +
+
+ )} +
+
+ ) +} + +const AddressPrint = ({ + address, + type, +}: { + address: Address | null + type: "shipping" | "billing" +}) => { + const { t } = useTranslation() + + return ( +
+ + {type === "shipping" + ? t("addresses.shippingAddress.label") + : t("addresses.billingAddress.label")} + + {address ? ( +
+ + {getFormattedAddress({ address }).map((line, i) => { + return ( + + {line} +
+
+ ) + })} +
+
+ +
+
+ ) : ( + + - + + )} +
+ ) +} + +const Addresses = ({ data }: { data: Cart | Order }) => { + const { t } = useTranslation() + + return ( +
+ + {!isSameAddress(data.shipping_address, data.billing_address) ? ( + + ) : ( +
+ + {t("addresses.billingAddress.label")} + + + {t("addresses.billingAddress.sameAsShipping")} + +
+ )} +
+ ) +} + +export const CustomerInfo = Object.assign( + {}, + { + ID, + Company, + Contact, + Addresses, + } +) + +const isSameAddress = (a: Address | null, b: Address | null) => { + if (!a || !b) { + return false + } + + return ( + a.first_name === b.first_name && + a.last_name === b.last_name && + a.address_1 === b.address_1 && + a.address_2 === b.address_2 && + a.city === b.city && + a.postal_code === b.postal_code && + a.province === b.province && + a.country_code === b.country_code + ) +} + +const getFormattedAddress = ({ address }: { address: Address }) => { + const { + first_name, + last_name, + company, + address_1, + address_2, + city, + postal_code, + province, + country, + country_code, + } = address + + const name = [first_name, last_name].filter(Boolean).join(" ") + + const formattedAddress = [] + + if (name) { + formattedAddress.push(name) + } + + if (company) { + formattedAddress.push(company) + } + + if (address_1) { + formattedAddress.push(address_1) + } + + if (address_2) { + formattedAddress.push(address_2) + } + + const cityProvincePostal = [city, province, postal_code] + .filter(Boolean) + .join(" ") + + if (cityProvincePostal) { + formattedAddress.push(cityProvincePostal) + } + + if (country) { + formattedAddress.push(country.display_name) + } else if (country_code) { + formattedAddress.push(country_code.toUpperCase()) + } + + return formattedAddress +} + +const getCartOrOrderCustomer = (obj: Cart | Order) => { + const { first_name: sFirstName, last_name: sLastName } = + obj.shipping_address || {} + const { first_name: bFirstName, last_name: bLastName } = + obj.billing_address || {} + const { first_name: cFirstName, last_name: cLastName } = obj.customer || {} + + const customerName = [cFirstName, cLastName].filter(Boolean).join(" ") + const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ") + const billingName = [bFirstName, bLastName].filter(Boolean).join(" ") + + const name = customerName || shippingName || billingName + + return name +} diff --git a/packages/admin-next/dashboard/src/components/common/customer-info/index.ts b/packages/admin-next/dashboard/src/components/common/customer-info/index.ts new file mode 100644 index 0000000000..892c7d76ba --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/customer-info/index.ts @@ -0,0 +1 @@ +export * from "./customer-info" diff --git a/packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx b/packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx deleted file mode 100644 index d763a2ec3f..0000000000 --- a/packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Input } from "@medusajs/ui" -import { ComponentProps, useEffect, useState } from "react" -import { useTranslation } from "react-i18next" - -type DebouncedSearchProps = Omit< - ComponentProps, - "value" | "defaultValue" | "onChange" | "type" -> & { - debounce?: number - value: string - onChange: (value: string) => void -} - -export const DebouncedSearch = ({ - value: initialValue, - onChange, - debounce = 500, - size = "small", - placeholder, - ...props -}: DebouncedSearchProps) => { - const [value, setValue] = useState(initialValue) - const { t } = useTranslation() - - useEffect(() => { - setValue(initialValue) - }, [initialValue]) - - useEffect(() => { - const timeout = setTimeout(() => { - onChange?.(value) - }, debounce) - - return () => clearTimeout(timeout) - }, [value]) - - return ( - setValue(e.target.value)} - /> - ) -} diff --git a/packages/admin-next/dashboard/src/components/common/debounced-search/index.ts b/packages/admin-next/dashboard/src/components/common/debounced-search/index.ts deleted file mode 100644 index 0bcdec8cc5..0000000000 --- a/packages/admin-next/dashboard/src/components/common/debounced-search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./debounced-search" diff --git a/packages/admin-next/dashboard/src/components/common/generic-forward-ref/generic-forward-ref.tsx b/packages/admin-next/dashboard/src/components/common/generic-forward-ref/generic-forward-ref.tsx new file mode 100644 index 0000000000..4e300e010e --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/generic-forward-ref/generic-forward-ref.tsx @@ -0,0 +1,7 @@ +import { ReactNode, Ref, RefAttributes, forwardRef } from "react" + +export function genericForwardRef( + render: (props: P, ref: Ref) => ReactNode +): (props: P & RefAttributes) => ReactNode { + return forwardRef(render) as any +} diff --git a/packages/admin-next/dashboard/src/components/common/generic-forward-ref/index.ts b/packages/admin-next/dashboard/src/components/common/generic-forward-ref/index.ts new file mode 100644 index 0000000000..8c8576686c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/generic-forward-ref/index.ts @@ -0,0 +1 @@ +export * from "./generic-forward-ref" diff --git a/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx b/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx index 0ad49b6f96..0aa1807bdb 100644 --- a/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx +++ b/packages/admin-next/dashboard/src/components/common/skeleton/skeleton.tsx @@ -8,7 +8,7 @@ export const Skeleton = ({ className }: SkeletonProps) => { return (
diff --git a/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx b/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx new file mode 100644 index 0000000000..2c87dd0cd4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/address-form/address-form.tsx @@ -0,0 +1,213 @@ +import { Country } from "@medusajs/medusa" +import { Heading, Input, Select, clx } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { z } from "zod" + +import { Control } from "react-hook-form" +import { AddressSchema } from "../../../lib/schemas" +import { CountrySelect } from "../../common/country-select" +import { Form } from "../../common/form" + +type AddressFieldValues = z.infer + +type AddressFormProps = { + control: Control + countries?: Country[] + layout: "grid" | "stack" +} + +export const AddressForm = ({ + control, + countries, + layout, +}: AddressFormProps) => { + const { t } = useTranslation() + + const style = clx("gap-4", { + "flex flex-col": layout === "stack", + "grid grid-cols-2": layout === "grid", + }) + + return ( +
+
+ {t("addresses.contactHeading")} +
+ { + return ( + + {t("fields.firstName")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.lastName")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.company")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.phone")} + + + + + + ) + }} + /> +
+
+
+ {t("addresses.locationHeading")} +
+ { + return ( + + {t("fields.address")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.address2")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.city")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.postalCode")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.province")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.country")} + + {countries ? ( + + ) : ( + // When no countries are provided, use the country select component that has a built-in list of all countries + )} + + + + ) + }} + /> +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/forms/address-form/index.ts b/packages/admin-next/dashboard/src/components/forms/address-form/index.ts new file mode 100644 index 0000000000..ea5dea619f --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/address-form/index.ts @@ -0,0 +1 @@ +export * from "./address-form" diff --git a/packages/admin-next/dashboard/src/components/forms/email-form/email-form.tsx b/packages/admin-next/dashboard/src/components/forms/email-form/email-form.tsx new file mode 100644 index 0000000000..b100334ae1 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/email-form/email-form.tsx @@ -0,0 +1,42 @@ +import { Input, clx } from "@medusajs/ui" +import { Control } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { EmailSchema } from "../../../lib/schemas" +import { Form } from "../../common/form" + +type EmailFieldValues = z.infer + +type EmailFormProps = { + control: Control + layout?: "grid" | "stack" +} + +export const EmailForm = ({ control, layout = "stack" }: EmailFormProps) => { + const { t } = useTranslation() + + return ( +
+ { + return ( + + {t("fields.email")} + + + + + + ) + }} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/forms/email-form/index.ts b/packages/admin-next/dashboard/src/components/forms/email-form/index.ts new file mode 100644 index 0000000000..ba4619f68c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/email-form/index.ts @@ -0,0 +1 @@ +export * from "./email-form" diff --git a/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/index.ts b/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/index.ts new file mode 100644 index 0000000000..b8021da3f9 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/index.ts @@ -0,0 +1 @@ +export * from "./transfer-ownership-form" diff --git a/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx b/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx new file mode 100644 index 0000000000..2c4e212889 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/forms/transfer-ownership-form/transfer-ownership-form.tsx @@ -0,0 +1,261 @@ +import { Customer, DraftOrder, Order } from "@medusajs/medusa" +import { Select, Text, clx } from "@medusajs/ui" +import { useInfiniteQuery } from "@tanstack/react-query" +import { format } from "date-fns" +import { debounce } from "lodash" +import { useAdminCustomer } from "medusa-react" +import { PropsWithChildren, useCallback, useEffect, useState } from "react" +import { Control, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { z } from "zod" +import { medusa } from "../../../lib/medusa" +import { getStylizedAmount } from "../../../lib/money-amount-helpers" +import { + getOrderFulfillmentStatus, + getOrderPaymentStatus, +} from "../../../lib/order-helpers" +import { TransferOwnershipSchema } from "../../../lib/schemas" +import { Combobox } from "../../common/combobox" +import { Form } from "../../common/form" +import { Skeleton } from "../../common/skeleton" + +type TransferOwnerShipFieldValues = z.infer + +type TransferOwnerShipFormProps = { + /** + * The Order or DraftOrder to transfer ownership of. + */ + order: Order | DraftOrder + /** + * React Hook Form control object. + */ + control: Control +} + +const isOrder = (order: Order | DraftOrder): order is Order => { + return "customer" in order +} + +export const TransferOwnerShipForm = ({ + order, + control, +}: TransferOwnerShipFormProps) => { + const { t } = useTranslation() + + const [query, setQuery] = useState("") + const [debouncedQuery, setDebouncedQuery] = useState("") + + const isOrderType = isOrder(order) + const currentOwnerId = useWatch({ + control, + name: "current_owner_id", + }) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedUpdate = useCallback( + debounce((query) => setDebouncedQuery(query), 300), + [] + ) + + useEffect(() => { + debouncedUpdate(query) + + return () => debouncedUpdate.cancel() + }, [query, debouncedUpdate]) + + const { + customer: owner, + isLoading: isLoadingOwner, + isError: isOwnerError, + error: ownerError, + } = useAdminCustomer(currentOwnerId) + + const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery( + ["customers", debouncedQuery], + async ({ pageParam = 0 }) => { + const res = await medusa.admin.customers.list({ + q: debouncedQuery, + limit: 10, + offset: pageParam, + has_account: true, // Only show customers with confirmed accounts + }) + return res + }, + { + getNextPageParam: (lastPage) => { + const moreCustomersExist = + lastPage.count > lastPage.offset + lastPage.limit + return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined + }, + keepPreviousData: true, + } + ) + + const createLabel = (customer?: Customer) => { + if (!customer) { + return "" + } + + const { first_name, last_name, email } = customer + + const name = [first_name, last_name].filter(Boolean).join(" ") + + if (name) { + return `${name} (${email})` + } + + return email + } + + const ownerReady = !isLoadingOwner && owner + + const options = + data?.pages + .map((p) => + p.customers.map((c) => ({ + label: createLabel(c), + value: c.id, + })) + ) + .flat() || [] + + if (isOwnerError) { + throw ownerError + } + + return ( +
+
+ + {isOrderType + ? t("transferOwnership.details.order") + : t("transferOwnership.details.draft")} + + {isOrderType ? ( + + ) : ( + + )} +
+
+
+ + {t("transferOwnership.currentOwner.label")} + + + {t("transferOwnership.currentOwner.hint")} + +
+ {ownerReady ? ( + + ) : ( + + )} +
+ { + return ( + +
+ {t("transferOwnership.newOwner.label")} + {t("transferOwnership.newOwner.hint")} +
+ + + + +
+ ) + }} + /> +
+ ) +} + +const OrderDetailsTable = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + const { label: fulfillmentLabel } = getOrderFulfillmentStatus( + t, + order.fulfillment_status + ) + + const { label: paymentLabel } = getOrderPaymentStatus(t, order.payment_status) + + return ( + + + + + + +
+ ) +} + +const DraftOrderDetailsTable = ({ draft }: { draft: DraftOrder }) => { + const { t } = useTranslation() + + return ( + + + + + +
+ ) +} + +const DateRow = ({ date }: { date: string | Date }) => { + const { t } = useTranslation() + + const formattedDate = format(new Date(date), "dd MMM yyyy") + + return +} + +const TotalRow = ({ + total, + currencyCode, +}: { + total: number + currencyCode: string +}) => { + return +} + +const Row = ({ label, value }: { label: string; value: string }) => { + return ( +
+
{label}
+
{value}
+
+ ) +} + +const Table = ({ children }: PropsWithChildren) => { + return
{children}
+} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx index f3de326ebb..c3d7f076fb 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx @@ -1,5 +1,6 @@ import type { FulfillmentStatus } from "@medusajs/medusa" import { useTranslation } from "react-i18next" +import { getOrderFulfillmentStatus } from "../../../../../lib/order-helpers" import { StatusCell } from "../../common/status-cell" type FulfillmentStatusCellProps = { @@ -11,26 +12,7 @@ export const FulfillmentStatusCell = ({ }: FulfillmentStatusCellProps) => { const { t } = useTranslation() - const [label, color] = { - not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"], - partially_fulfilled: [ - t("orders.fulfillment.status.partiallyFulfilled"), - "orange", - ], - fulfilled: [t("orders.fulfillment.status.fulfilled"), "green"], - partially_shipped: [ - t("orders.fulfillment.status.partiallyShipped"), - "orange", - ], - shipped: [t("orders.fulfillment.status.shipped"), "green"], - partially_returned: [ - t("orders.fulfillment.status.partiallyReturned"), - "orange", - ], - returned: [t("orders.fulfillment.status.returned"), "green"], - canceled: [t("orders.fulfillment.status.canceled"), "red"], - requires_action: [t("orders.fulfillment.status.requiresAction"), "orange"], - }[status] as [string, "red" | "orange" | "green"] + const { label, color } = getOrderFulfillmentStatus(t, status) return {label} } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx index 5b95a9aebf..2f5f1f8a6b 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx @@ -1,5 +1,6 @@ import type { PaymentStatus } from "@medusajs/medusa" import { useTranslation } from "react-i18next" +import { getOrderPaymentStatus } from "../../../../../lib/order-helpers" import { StatusCell } from "../../common/status-cell" type PaymentStatusCellProps = { @@ -9,18 +10,7 @@ type PaymentStatusCellProps = { export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => { const { t } = useTranslation() - const [label, color] = { - not_paid: [t("orders.payment.status.notPaid"), "red"], - awaiting: [t("orders.payment.status.awaiting"), "orange"], - captured: [t("orders.payment.status.captured"), "green"], - refunded: [t("orders.payment.status.refunded"), "green"], - partially_refunded: [ - t("orders.payment.status.partiallyRefunded"), - "orange", - ], - canceled: [t("orders.payment.status.canceled"), "red"], - requires_action: [t("orders.payment.status.requiresAction"), "orange"], - }[status] as [string, "red" | "orange" | "green"] + const { label, color } = getOrderPaymentStatus(t, status) return {label} } diff --git a/packages/admin-next/dashboard/src/lib/order-helpers.ts b/packages/admin-next/dashboard/src/lib/order-helpers.ts new file mode 100644 index 0000000000..bb777bef6a --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/order-helpers.ts @@ -0,0 +1,50 @@ +import { FulfillmentStatus, PaymentStatus } from "@medusajs/medusa" +import { TFunction } from "i18next" + +export const getOrderPaymentStatus = ( + t: TFunction<"translation">, + status: PaymentStatus +) => { + const [label, color] = { + not_paid: [t("orders.payment.status.notPaid"), "red"], + awaiting: [t("orders.payment.status.awaiting"), "orange"], + captured: [t("orders.payment.status.captured"), "green"], + refunded: [t("orders.payment.status.refunded"), "green"], + partially_refunded: [ + t("orders.payment.status.partiallyRefunded"), + "orange", + ], + canceled: [t("orders.payment.status.canceled"), "red"], + requires_action: [t("orders.payment.status.requiresAction"), "orange"], + }[status] as [string, "red" | "orange" | "green"] + + return { label, color } +} + +export const getOrderFulfillmentStatus = ( + t: TFunction<"translation">, + status: FulfillmentStatus +) => { + const [label, color] = { + not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"], + partially_fulfilled: [ + t("orders.fulfillment.status.partiallyFulfilled"), + "orange", + ], + fulfilled: [t("orders.fulfillment.status.fulfilled"), "green"], + partially_shipped: [ + t("orders.fulfillment.status.partiallyShipped"), + "orange", + ], + shipped: [t("orders.fulfillment.status.shipped"), "green"], + partially_returned: [ + t("orders.fulfillment.status.partiallyReturned"), + "orange", + ], + returned: [t("orders.fulfillment.status.returned"), "green"], + canceled: [t("orders.fulfillment.status.canceled"), "red"], + requires_action: [t("orders.fulfillment.status.requiresAction"), "orange"], + }[status] as [string, "red" | "orange" | "green"] + + return { label, color } +} diff --git a/packages/admin-next/dashboard/src/lib/schemas.ts b/packages/admin-next/dashboard/src/lib/schemas.ts new file mode 100644 index 0000000000..e264606627 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/schemas.ts @@ -0,0 +1,36 @@ +import i18n from "i18next" +import { z } from "zod" + +export const AddressSchema = z.object({ + first_name: z.string().min(1), + last_name: z.string().min(1), + company: z.string().optional(), + address_1: z.string().min(1), + address_2: z.string().optional(), + city: z.string().min(1), + postal_code: z.string().min(1), + province: z.string().optional(), + country_code: z.string().min(1), + phone: z.string().optional(), +}) + +export const EmailSchema = z.object({ + email: z.string().email(), +}) + +export const TransferOwnershipSchema = z + .object({ + current_owner_id: z.string().min(1), + new_owner_id: z + .string() + .min(1, i18n.t("transferOwnership.validation.required")), + }) + .superRefine((data, ctx) => { + if (data.current_owner_id === data.new_owner_id) { + return ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["new_owner_id"], + message: i18n.t("transferOwnership.validation.mustBeDifferent"), + }) + } + }) diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx index 32196430f8..1d5ec8b053 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx @@ -2,6 +2,7 @@ import type { AdminCollectionsRes, AdminCustomerGroupsRes, AdminCustomersRes, + AdminDraftOrdersRes, AdminGiftCardsRes, AdminOrdersRes, AdminProductsRes, @@ -98,6 +99,27 @@ export const v1Routes: RouteObject[] = [ crumb: (data: AdminOrdersRes) => `Order #${data.order.display_id}`, }, + children: [ + { + path: "shipping-address", + lazy: () => + import("../../routes/orders/order-shipping-address"), + }, + { + path: "billing-address", + lazy: () => + import("../../routes/orders/order-billing-address"), + }, + { + path: "email", + lazy: () => import("../../routes/orders/order-email"), + }, + { + path: "transfer-ownership", + lazy: () => + import("../../routes/orders/order-transfer-ownership"), + }, + ], }, ], }, @@ -108,7 +130,7 @@ export const v1Routes: RouteObject[] = [ }, children: [ { - index: true, + path: "", lazy: () => import("../../routes/draft-orders/draft-order-list"), }, @@ -116,6 +138,38 @@ export const v1Routes: RouteObject[] = [ path: ":id", lazy: () => import("../../routes/draft-orders/draft-order-detail"), + handle: { + crumb: (data: AdminDraftOrdersRes) => + `Draft #${data.draft_order.display_id}`, + }, + children: [ + { + path: "transfer-ownership", + lazy: () => + import( + "../../routes/draft-orders/draft-order-transfer-ownership" + ), + }, + { + path: "shipping-address", + lazy: () => + import( + "../../routes/draft-orders/draft-order-shipping-address" + ), + }, + { + path: "billing-address", + lazy: () => + import( + "../../routes/draft-orders/draft-order-billing-address" + ), + }, + { + path: "email", + lazy: () => + import("../../routes/draft-orders/draft-order-email"), + }, + ], }, ], }, diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx index cd66c88970..1b91412fa5 100644 --- a/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx +++ b/packages/admin-next/dashboard/src/routes/customers/customer-detail/components/customer-order-section/customer-order-section.tsx @@ -1,7 +1,11 @@ -import { Customer } from "@medusajs/medusa" +import { ArrowPath } from "@medusajs/icons" +import { Customer, Order } from "@medusajs/medusa" import { Button, Container, Heading } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" import { useAdminOrders } from "medusa-react" +import { useMemo } from "react" import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns" import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters" @@ -37,9 +41,7 @@ export const CustomerOrderSection = ({ } ) - const columns = useOrderTableColumns({ - exclude: ["customer"], - }) + const columns = useColumns() const filters = useOrderTableFilters() const { table } = useDataTable({ @@ -80,3 +82,40 @@ export const CustomerOrderSection = ({ ) } + +const CustomerOrderActions = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( + , + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useOrderTableColumns({ exclude: ["customer"] }) + + return useMemo( + () => [ + ...base, + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [base] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/index.ts b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/index.ts new file mode 100644 index 0000000000..252fcf8d47 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/index.ts @@ -0,0 +1 @@ +export * from "./transfer-customer-order-ownership-form" diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/transfer-customer-order-ownership-form.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/transfer-customer-order-ownership-form.tsx new file mode 100644 index 0000000000..eb52a56780 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/components/transfer-customer-order-ownership-form/transfer-customer-order-ownership-form.tsx @@ -0,0 +1,72 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Order } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateOrder } from "medusa-react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { TransferOwnershipSchema } from "../../../../../lib/schemas" + +type TransferCustomerOrderOwnershipFormProps = { + order: Order +} + +export const TransferCustomerOrderOwnershipForm = ({ + order, +}: TransferCustomerOrderOwnershipFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + current_owner_id: order.customer_id, + new_owner_id: "", + }, + resolver: zodResolver(TransferOwnershipSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id) + + const handleSubmit = form.handleSubmit(async (values) => { + mutateAsync( + { + customer_id: values.new_owner_id, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/customer-transfer-ownership.tsx b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/customer-transfer-ownership.tsx new file mode 100644 index 0000000000..6438ee5751 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/customer-transfer-ownership.tsx @@ -0,0 +1,28 @@ +import { Heading } from "@medusajs/ui" +import { useAdminOrder } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { TransferCustomerOrderOwnershipForm } from "./components/transfer-customer-order-ownership-form" + +export const CustomerTransferOwnership = () => { + const { t } = useTranslation() + const { order_id } = useParams() + + const { order, isLoading, isError, error } = useAdminOrder(order_id!) + + const ready = !isLoading && order + + if (isError) { + throw error + } + + return ( + + + {t("transferOwnership.header")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/index.ts b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/index.ts new file mode 100644 index 0000000000..447df1676c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/customer-transfer-ownership/index.ts @@ -0,0 +1 @@ +export { CustomerTransferOwnership as Component } from "./customer-transfer-ownership" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/edit-address-form.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/edit-address-form.tsx new file mode 100644 index 0000000000..fe44904025 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/edit-address-form.tsx @@ -0,0 +1,98 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Country, DraftOrder } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateDraftOrder } from "medusa-react" +import { DefaultValues, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { AddressForm } from "../../../../components/forms/address-form" +import { RouteDrawer, useRouteModal } from "../../../../components/route-modal" +import { AddressSchema } from "../../../../lib/schemas" + +type AddressType = "shipping" | "billing" + +type EditDraftOrderAddressFormProps = { + draftOrder: DraftOrder + countries: Country[] + type: AddressType +} + +export const EditDraftOrderAddressForm = ({ + draftOrder, + countries, + type, +}: EditDraftOrderAddressFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: getDefaultValues(draftOrder, type), + resolver: zodResolver(AddressSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateDraftOrder(draftOrder.id) + + const handleSumbit = form.handleSubmit(async (values) => { + const update = { + [type === "shipping" ? "shipping_address" : "billing_address"]: values, + } + + mutateAsync(update, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} + +const getDefaultValues = ( + draftOrder: DraftOrder, + type: AddressType +): DefaultValues> => { + const address = + type === "shipping" + ? draftOrder.cart.shipping_address + : draftOrder.cart.billing_address + + return { + first_name: address?.first_name ?? "", + last_name: address?.last_name ?? "", + address_1: address?.address_1 ?? "", + address_2: address?.address_2 ?? "", + city: address?.city ?? "", + postal_code: address?.postal_code ?? "", + province: address?.province ?? "", + country_code: address?.country_code ?? "", + phone: address?.phone ?? "", + company: address?.company ?? "", + } +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/index.ts new file mode 100644 index 0000000000..ed3a4a32d8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/common/edit-address-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-address-form" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/draft-order-billing-address.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/draft-order-billing-address.tsx new file mode 100644 index 0000000000..652ac2abd7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/draft-order-billing-address.tsx @@ -0,0 +1,50 @@ +import { Heading } from "@medusajs/ui" +import { useAdminDraftOrder, useAdminRegion } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditDraftOrderAddressForm } from "../common/edit-address-form" + +export const DraftOrderBillingAddress = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!) + + const regionId = draft_order?.cart?.region_id + + const { + region, + isLoading: isLoadingRegion, + isError: isErrorRegion, + error: errorRegion, + } = useAdminRegion(regionId!, { + enabled: !!regionId, + }) + + const ready = !isLoading && draft_order && !isLoadingRegion && region + const countries = region?.countries || [] + + if (isError) { + throw error + } + + if (isErrorRegion) { + throw errorRegion + } + + return ( + + + {t("addresses.billingAddress.editHeader")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/index.ts new file mode 100644 index 0000000000..e5496533b1 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-billing-address/index.ts @@ -0,0 +1 @@ +export { DraftOrderBillingAddress as Component } from "./draft-order-billing-address" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/draft-order-customer-section.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/draft-order-customer-section.tsx new file mode 100644 index 0000000000..e06cf2d24a --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/draft-order-customer-section.tsx @@ -0,0 +1,71 @@ +import { DraftOrder } from "@medusajs/medusa" +import { Container, Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { CustomerInfo } from "../../../../../components/common/customer-info" + +type DraftOrderCustomerSectionProps = { + draftOrder: DraftOrder +} + +export const DraftOrderCustomerSection = ({ + draftOrder, +}: DraftOrderCustomerSectionProps) => { + return ( + +
+ + + + + + ) +} + +const Header = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.customer")} + , + }, + ], + }, + { + actions: [ + { + label: t("addresses.shippingAddress.editLabel"), + to: "shipping-address", + icon: , + }, + { + label: t("addresses.billingAddress.editLabel"), + to: "billing-address", + icon: , + }, + ], + }, + { + actions: [ + { + label: t("email.editLabel"), + to: `email`, + icon: , + }, + ], + }, + ]} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/index.ts new file mode 100644 index 0000000000..be5dcaea0b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-customer-section/index.ts @@ -0,0 +1 @@ +export * from "./draft-order-customer-section" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/draft-order-general-section.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/draft-order-general-section.tsx new file mode 100644 index 0000000000..0ca9d6f957 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/draft-order-general-section.tsx @@ -0,0 +1,228 @@ +import { CreditCard, Trash } from "@medusajs/icons" +import { DraftOrder, Order } from "@medusajs/medusa" +import { + Container, + Copy, + Heading, + StatusBadge, + Text, + usePrompt, +} from "@medusajs/ui" +import { format } from "date-fns" +import { + useAdminDeleteDraftOrder, + useAdminDraftOrderRegisterPayment, + useAdminStore, +} from "medusa-react" +import { useTranslation } from "react-i18next" + +import { useNavigate } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { InlineLink } from "../../../../../components/common/inline-link" +import { Skeleton } from "../../../../../components/common/skeleton" + +type DraftOrderGeneralSectionProps = { + draftOrder: DraftOrder +} + +export const DraftOrderGeneralSection = ({ + draftOrder, +}: DraftOrderGeneralSectionProps) => { + const { t } = useTranslation() + const prompt = usePrompt() + const navigate = useNavigate() + + const { color, label } = { + open: { + color: "orange", + label: t("draftOrders.status.open"), + }, + completed: { + color: "green", + label: t("draftOrders.status.completed"), + }, + }[draftOrder.status] as { color: "green" | "orange"; label: string } + + const { mutateAsync } = useAdminDeleteDraftOrder(draftOrder.id) + const { mutateAsync: markAsPaid } = useAdminDraftOrderRegisterPayment( + draftOrder.id + ) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("orders.cancelWarning", { + id: `#${draftOrder.display_id}`, + }), + confirmText: t("actions.continue"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync(undefined) + } + + const handleMarkAsPaid = async () => { + const res = await prompt({ + title: t("draftOrders.markAsPaid.warningTitle"), + description: t("draftOrders.markAsPaid.warningDescription"), + confirmText: t("actions.continue"), + cancelText: t("actions.cancel"), + variant: "confirmation", + }) + + if (!res) { + return + } + + await markAsPaid(undefined, { + onSuccess: ({ order }) => { + navigate(`/orders/${order.id}`) + }, + }) + } + + return ( + +
+
+
+ #{draftOrder.display_id} + +
+ + {format(new Date(draftOrder.created_at), "dd MMM, yyyy, HH:mm:ss")} + +
+
+ {label} + , + }, + ], + }, + { + actions: [ + { + label: t("actions.delete"), + onClick: handleDelete, + icon: , + }, + ], + }, + ]} + /> +
+
+ + {!draftOrder.order && } +
+ ) +} + +const OrderLink = ({ order }: { order: Order | null }) => { + const { t } = useTranslation() + + if (!order) { + return null + } + + return ( +
+ + {t("fields.order")} + + + {`#${order.display_id}`} + +
+ ) +} + +const formatPaymentLink = (paymentLink: string, cartId: string) => { + // Validate that the payment link template is valid + if (!/{.*?}/.test(paymentLink)) { + return null + } + + return paymentLink.replace(/{.*?}/, cartId) +} + +const PaymentLink = ({ cartId }: { cartId: string }) => { + const { t } = useTranslation() + const { store, isLoading, isError, error } = useAdminStore() + + /** + * If the store has a payment link template, we format the payment link. + * Otherwise, we display the cart id. + */ + const paymentLink = store?.payment_link_template + ? formatPaymentLink(store.payment_link_template, cartId) + : null + + if (isError) { + throw error + } + + const renderContent = () => { + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (paymentLink) { + return ( +
+ + {t("draftOrders.paymentLinkLabel")} + +
+ + + {paymentLink} + + + +
+
+ ) + } + + return ( +
+ + {t("draftOrders.cartIdLabel")} + +
+ + {cartId} + + +
+
+ ) + } + + return renderContent() +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/index.ts new file mode 100644 index 0000000000..7955be9ac6 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-general-section/index.ts @@ -0,0 +1 @@ +export * from "./draft-order-general-section" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/draft-order-summary-section.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/draft-order-summary-section.tsx new file mode 100644 index 0000000000..950eed4322 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/draft-order-summary-section.tsx @@ -0,0 +1,269 @@ +import { Buildings } from "@medusajs/icons" +import { Cart, DraftOrder, LineItem } from "@medusajs/medusa" +import { ReservationItemDTO } from "@medusajs/types" +import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui" +import { useAdminReservations } from "medusa-react" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { Thumbnail } from "../../../../../components/common/thumbnail" +import { + getLocaleAmount, + getStylizedAmount, +} from "../../../../../lib/money-amount-helpers" + +type DraftOrderSummarySectionProps = { + draftOrder: DraftOrder +} + +export const DraftOrderSummarySection = ({ + draftOrder, +}: DraftOrderSummarySectionProps) => { + return ( + +
+ + + + + ) +} + +const Header = () => { + const { t } = useTranslation() + + return ( +
+ {t("fields.summary")} + , + }, + ], + }, + ]} + /> +
+ ) +} + +const Item = ({ + item, + currencyCode, + reservation, +}: { + item: LineItem + currencyCode: string + reservation?: ReservationItemDTO | null +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+ + {item.title} + + {item.variant.sku && ( +
+ {item.variant.sku} + +
+ )} + {item.variant.title} +
+
+
+
+ + {getLocaleAmount(item.unit_price, currencyCode)} + +
+
+
+ + {item.quantity}x + +
+
+ + {reservation + ? t("orders.reservations.allocatedLabel") + : t("orders.reservations.notAllocatedLabel")} + +
+
+
+ + {getLocaleAmount(item.subtotal || 0, currencyCode)} + +
+
+
+ ) +} + +const ItemBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => { + const { reservations, isError, error } = useAdminReservations({ + line_item_id: draftOrder.cart.items.map((i) => i.id), + }) + + if (isError) { + throw error + } + + return ( +
+ {draftOrder.cart.items.map((item) => { + const reservation = reservations + ? reservations.find((r) => r.line_item_id === item.id) + : null + + return ( + + ) + })} +
+ ) +} + +const Cost = ({ + label, + value, + secondaryValue, +}: { + label: string + value: string | number + secondaryValue: string +}) => ( +
+ + {label} + +
+ + {secondaryValue} + +
+
+ + {value} + +
+
+) + +const calculateCartTaxRate = (cart: Cart) => { + const { + tax_total, + subtotal = 0, + discount_total = 0, + gift_card_total = 0, + shipping_total = 0, + } = cart + + const preTaxTotal = + subtotal - discount_total - gift_card_total + shipping_total + + return ((tax_total || 0) / preTaxTotal) * 100 +} + +const CostBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => { + const { t } = useTranslation() + + // Calculate tax rate since it's not included in the cart + const taxRate = calculateCartTaxRate(draftOrder.cart) + + return ( +
+ + 0 + ? draftOrder.cart.discounts.map((d) => d.code).join(", ") + : "-" + } + value={ + (draftOrder.cart.discount_total || 0) > 0 + ? `- ${getLocaleAmount( + draftOrder.cart.discount_total || 0, + draftOrder.cart.region.currency_code + )}` + : "-" + } + /> + sm.shipping_option.name) + .join(", ")} + value={getLocaleAmount( + draftOrder.cart.shipping_total || 0, + draftOrder.cart.region.currency_code + )} + /> + +
+ ) +} + +const Total = ({ draftOrder }: { draftOrder: DraftOrder }) => { + const { t } = useTranslation() + + return ( +
+ + {t("fields.total")} + + + {getStylizedAmount( + draftOrder.cart.total || 0, + draftOrder.cart.region.currency_code + )} + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/index.ts new file mode 100644 index 0000000000..be1c730b9b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/components/draft-order-summary-section/index.ts @@ -0,0 +1 @@ +export * from "./draft-order-summary-section" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/details.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/details.tsx deleted file mode 100644 index c674982706..0000000000 --- a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/details.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const DraftOrderDetails = () => { - return
Draft Order Details
-} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/draft-order-detail.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/draft-order-detail.tsx new file mode 100644 index 0000000000..d1ca5f6866 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/draft-order-detail.tsx @@ -0,0 +1,43 @@ +import { useAdminDraftOrder } from "medusa-react" +import { Outlet, useLoaderData, useParams } from "react-router-dom" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { DraftOrderCustomerSection } from "./components/draft-order-customer-section" +import { DraftOrderGeneralSection } from "./components/draft-order-general-section" +import { DraftOrderSummarySection } from "./components/draft-order-summary-section" +import { draftOrderLoader } from "./loader" + +export const DraftOrderDetail = () => { + const { id } = useParams() + const initialData = useLoaderData() as Awaited< + ReturnType + > + + const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!, { + initialData, + }) + + if (isLoading || !draft_order) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( +
+
+ + +
+ +
+ +
+
+ +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/index.ts index c593eef663..4930efc537 100644 --- a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/index.ts +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/index.ts @@ -1 +1,2 @@ -export { DraftOrderDetails as Component } from "./details" +export { DraftOrderDetail as Component } from "./draft-order-detail" +export { draftOrderLoader as loader } from "./loader" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/loader.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/loader.ts new file mode 100644 index 0000000000..43a794d465 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-detail/loader.ts @@ -0,0 +1,21 @@ +import { AdminDraftOrdersRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminDraftOrderKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const draftOrderDetailQuery = (id: string) => ({ + queryKey: adminDraftOrderKeys.detail(id), + queryFn: async () => medusa.admin.draftOrders.retrieve(id), +}) + +export const draftOrderLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = draftOrderDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/edit-draft-order-email-form.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/edit-draft-order-email-form.tsx new file mode 100644 index 0000000000..51d5caf716 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/edit-draft-order-email-form.tsx @@ -0,0 +1,66 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { DraftOrder } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateDraftOrder } from "medusa-react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { EmailForm } from "../../../../../components/forms/email-form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { EmailSchema } from "../../../../../lib/schemas" + +type EditDraftOrderEmailFormProps = { + draftOrder: DraftOrder +} + +export const EditDraftOrderEmailForm = ({ + draftOrder, +}: EditDraftOrderEmailFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + email: draftOrder.cart.email, + }, + resolver: zodResolver(EmailSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateDraftOrder(draftOrder.id) + + const handleSumbit = form.handleSubmit(async (values) => { + mutateAsync(values, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/index.ts new file mode 100644 index 0000000000..eeb7bd1a64 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/components/edit-draft-order-email-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-draft-order-email-form" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/draft-order-email.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/draft-order-email.tsx new file mode 100644 index 0000000000..5952c818e8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/draft-order-email.tsx @@ -0,0 +1,28 @@ +import { Heading } from "@medusajs/ui" +import { useAdminDraftOrder } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditDraftOrderEmailForm } from "./components/edit-draft-order-email-form/edit-draft-order-email-form" + +export const DraftOrderEmail = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!) + + const ready = !isLoading && draft_order + + if (isError) { + throw error + } + + return ( + + + {t("email.editHeader")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/index.ts new file mode 100644 index 0000000000..c8f86df3c2 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-email/index.ts @@ -0,0 +1 @@ +export { DraftOrderEmail as Component } from "./draft-order-email" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-list/components/draft-order-list-table/draft-order-list-table.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-list/components/draft-order-list-table/draft-order-list-table.tsx index 84a97da27e..0e97de2bbe 100644 --- a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-list/components/draft-order-list-table/draft-order-list-table.tsx +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-list/components/draft-order-list-table/draft-order-list-table.tsx @@ -20,7 +20,7 @@ export const DraftOrderListTable = () => { useAdminDraftOrders( { ...searchParams, - expand: "cart,cart.customer", + // expand: "cart,cart.customer", }, { keepPreviousData: true, diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/draft-order-shipping-address.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/draft-order-shipping-address.tsx new file mode 100644 index 0000000000..acf0273e0b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/draft-order-shipping-address.tsx @@ -0,0 +1,50 @@ +import { Heading } from "@medusajs/ui" +import { useAdminDraftOrder, useAdminRegion } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditDraftOrderAddressForm } from "../common/edit-address-form" + +export const DraftOrderShippingAddress = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!) + + const regionId = draft_order?.cart?.region_id + + const { + region, + isLoading: isLoadingRegion, + isError: isErrorRegion, + error: errorRegion, + } = useAdminRegion(regionId!, { + enabled: !!regionId, + }) + + const ready = !isLoading && draft_order && !isLoadingRegion && region + const countries = region?.countries || [] + + if (isError) { + throw error + } + + if (isErrorRegion) { + throw errorRegion + } + + return ( + + + {t("addresses.shippingAddress.editHeader")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/index.ts new file mode 100644 index 0000000000..e42cab2524 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-shipping-address/index.ts @@ -0,0 +1 @@ +export { DraftOrderShippingAddress as Component } from "./draft-order-shipping-address" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/index.ts new file mode 100644 index 0000000000..8339cc0e11 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/index.ts @@ -0,0 +1 @@ +export * from "./transfer-draft-order-ownership-form" diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/transfer-draft-order-ownership-form.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/transfer-draft-order-ownership-form.tsx new file mode 100644 index 0000000000..31c966a9c4 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/components/transfer-draft-order-ownership-form/transfer-draft-order-ownership-form.tsx @@ -0,0 +1,72 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { DraftOrder } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateDraftOrder } from "medusa-react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { TransferOwnershipSchema } from "../../../../../lib/schemas" + +type TransferDraftOrderOwnershipFormProps = { + draftOrder: DraftOrder +} + +export const TransferDraftOrderOwnershipForm = ({ + draftOrder, +}: TransferDraftOrderOwnershipFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + current_owner_id: draftOrder.cart.customer_id, + new_owner_id: "", + }, + resolver: zodResolver(TransferOwnershipSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateDraftOrder(draftOrder.id) + + const handleSubmit = form.handleSubmit(async (values) => { + mutateAsync( + { + customer_id: values.new_owner_id, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/draft-order-transfer-ownership.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/draft-order-transfer-ownership.tsx new file mode 100644 index 0000000000..c2f8ceb08f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/draft-order-transfer-ownership.tsx @@ -0,0 +1,28 @@ +import { Heading } from "@medusajs/ui" +import { useAdminDraftOrder } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { TransferDraftOrderOwnershipForm } from "./components/transfer-draft-order-ownership-form" + +export const DraftOrderTransferOwnership = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { draft_order, isLoading, isError, error } = useAdminDraftOrder(id!) + + const ready = !isLoading && draft_order + + if (isError) { + throw error + } + + return ( + + + {t("transferOwnership.header")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/index.ts new file mode 100644 index 0000000000..0cc817a512 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/draft-order-transfer-ownership/index.ts @@ -0,0 +1 @@ +export { DraftOrderTransferOwnership as Component } from "./draft-order-transfer-ownership" diff --git a/packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/edit-order-address-form.tsx b/packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/edit-order-address-form.tsx new file mode 100644 index 0000000000..eb38ed5faf --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/edit-order-address-form.tsx @@ -0,0 +1,96 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Country, Order } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateOrder } from "medusa-react" +import { DefaultValues, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { AddressForm } from "../../../../components/forms/address-form" +import { RouteDrawer, useRouteModal } from "../../../../components/route-modal" +import { AddressSchema } from "../../../../lib/schemas" + +type AddressType = "shipping" | "billing" + +type EditOrderAddressFormProps = { + order: Order + countries: Country[] + type: AddressType +} + +export const EditOrderAddressForm = ({ + order, + countries, + type, +}: EditOrderAddressFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: getDefaultValues(order, type), + resolver: zodResolver(AddressSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id) + + const handleSumbit = form.handleSubmit(async (values) => { + const update = { + [type === "shipping" ? "shipping_address" : "billing_address"]: values, + } + + mutateAsync(update, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} + +const getDefaultValues = ( + order: Order, + type: AddressType +): DefaultValues> => { + const address = + type === "shipping" ? order.shipping_address : order.billing_address + + return { + first_name: address?.first_name ?? "", + last_name: address?.last_name ?? "", + address_1: address?.address_1 ?? "", + address_2: address?.address_2 ?? "", + city: address?.city ?? "", + postal_code: address?.postal_code ?? "", + province: address?.province ?? "", + country_code: address?.country_code ?? "", + phone: address?.phone ?? "", + company: address?.company ?? "", + } +} diff --git a/packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/index.ts new file mode 100644 index 0000000000..b8446f0317 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/common/edit-address-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-order-address-form" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-billing-address/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-billing-address/index.ts new file mode 100644 index 0000000000..f62921add9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-billing-address/index.ts @@ -0,0 +1 @@ +export { OrderBillingAddress as Component } from "./order-billing-address" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-billing-address/order-billing-address.tsx b/packages/admin-next/dashboard/src/routes/orders/order-billing-address/order-billing-address.tsx new file mode 100644 index 0000000000..aed50ec435 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-billing-address/order-billing-address.tsx @@ -0,0 +1,50 @@ +import { Heading } from "@medusajs/ui" +import { useAdminOrder, useAdminRegion } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditOrderAddressForm } from "../common/edit-address-form" + +export const OrderBillingAddress = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { order, isLoading, isError, error } = useAdminOrder(id!) + + const regionId = order?.region_id + + const { + region, + isLoading: isLoadingRegion, + isError: isErrorRegion, + error: errorRegion, + } = useAdminRegion(regionId!, { + enabled: !!regionId, + }) + + const ready = !isLoading && order && !isLoadingRegion && region + const countries = region?.countries || [] + + if (isError) { + throw error + } + + if (isErrorRegion) { + throw errorRegion + } + + return ( + + + {t("addresses.billingAddress.editHeader")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx index b941bf80b3..818f943496 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx @@ -1,10 +1,10 @@ -import { Address as MedusaAddress, Order } from "@medusajs/medusa" -import { Avatar, Container, Copy, Heading, Text } from "@medusajs/ui" +import { Order } from "@medusajs/medusa" +import { Container, Heading } from "@medusajs/ui" import { useTranslation } from "react-i18next" -import { Link } from "react-router-dom" import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons" import { ActionMenu } from "../../../../../components/common/action-menu" +import { CustomerInfo } from "../../../../../components/common/customer-info" type OrderCustomerSectionProps = { order: Order @@ -13,16 +13,16 @@ type OrderCustomerSectionProps = { export const OrderCustomerSection = ({ order }: OrderCustomerSectionProps) => { return ( -
- - - - +
+ + + + ) } -const Header = ({ order }: { order: Order }) => { +const Header = () => { const { t } = useTranslation() return ( @@ -33,8 +33,8 @@ const Header = ({ order }: { order: Order }) => { { actions: [ { - label: t("orders.customer.transferOwnership"), - to: `#`, // TODO: Open modal to transfer ownership + label: t("transferOwnership.label"), + to: `transfer-ownership`, icon: , }, ], @@ -42,13 +42,13 @@ const Header = ({ order }: { order: Order }) => { { actions: [ { - label: t("orders.customer.editShippingAddress"), - to: `#`, // TODO: Open modal to edit shipping address + label: t("addresses.shippingAddress.editLabel"), + to: "shipping-address", icon: , }, { - label: t("orders.customer.editBillingAddress"), - to: `#`, // TODO: Open modal to edit billing address + label: t("addresses.billingAddress.editLabel"), + to: "billing-address", icon: , }, ], @@ -56,8 +56,8 @@ const Header = ({ order }: { order: Order }) => { { actions: [ { - label: t("orders.customer.editEmail"), - to: `#`, // TODO: Open modal to edit email + label: t("email.editLabel"), + to: `email`, icon: , }, ], @@ -67,253 +67,3 @@ const Header = ({ order }: { order: Order }) => {
) } - -const getCustomerName = (order: Order) => { - const { first_name: sFirstName, last_name: sLastName } = - order.shipping_address || {} - const { first_name: bFirstName, last_name: bLastName } = - order.billing_address || {} - const { first_name: cFirstName, last_name: cLastName } = order.customer || {} - - const customerName = [cFirstName, cLastName].filter(Boolean).join(" ") - const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ") - const billingName = [bFirstName, bLastName].filter(Boolean).join(" ") - - const name = customerName || shippingName || billingName - - return name -} - -const ID = ({ order }: { order: Order }) => { - const { t } = useTranslation() - - const id = order.customer_id - const name = getCustomerName(order) - const email = order.email - const fallback = (name || email).charAt(0).toUpperCase() - - return ( -
- - {t("fields.id")} - - -
- - - {name || email} - -
- -
- ) -} - -const Company = ({ order }: { order: Order }) => { - const { t } = useTranslation() - const company = - order.shipping_address?.company || order.billing_address?.company - - if (!company) { - return null - } - - return ( -
- - {t("fields.company")} - - - {company} - -
- ) -} - -const Contact = ({ order }: { order: Order }) => { - const { t } = useTranslation() - - const phone = order.shipping_address?.phone || order.billing_address?.phone - const email = order.email - - return ( -
- - {t("orders.customer.contactLabel")} - -
-
- - {email} - - -
- -
-
- {phone && ( -
- - {phone} - - -
- -
-
- )} -
-
- ) -} - -const isSameAddress = (a: MedusaAddress | null, b: MedusaAddress | null) => { - if (!a || !b) { - return false - } - - return ( - a.first_name === b.first_name && - a.last_name === b.last_name && - a.address_1 === b.address_1 && - a.address_2 === b.address_2 && - a.city === b.city && - a.postal_code === b.postal_code && - a.province === b.province && - a.country_code === b.country_code - ) -} - -const getFormattedAddress = ({ address }: { address: MedusaAddress }) => { - const { - first_name, - last_name, - company, - address_1, - address_2, - city, - postal_code, - province, - country, - } = address - - const name = [first_name, last_name].filter(Boolean).join(" ") - - const formattedAddress = [] - - if (name) { - formattedAddress.push(name) - } - - if (company) { - formattedAddress.push(company) - } - - if (address_1) { - formattedAddress.push(address_1) - } - - if (address_2) { - formattedAddress.push(address_2) - } - - const cityProvincePostal = [city, province, postal_code] - .filter(Boolean) - .join(" ") - - if (cityProvincePostal) { - formattedAddress.push(cityProvincePostal) - } - - if (country) { - formattedAddress.push(country.display_name) - } - - return formattedAddress -} - -const Address = ({ - address, - type, -}: { - address: MedusaAddress | null - type: "shipping" | "billing" -}) => { - const { t } = useTranslation() - - return ( -
- - {type === "shipping" - ? t("addresses.shippingAddress") - : t("addresses.billingAddress")} - - {address ? ( -
- - {getFormattedAddress({ address }).map((line, i) => { - return ( - - {line} -
-
- ) - })} -
-
- -
-
- ) : ( - - - - - )} -
- ) -} - -const Addresses = ({ order }: { order: Order }) => { - const { t } = useTranslation() - - return ( -
-
- {!isSameAddress(order.shipping_address, order.billing_address) ? ( -
- ) : ( -
- - {t("addresses.billingAddress")} - - - {t("addresses.sameAsShipping")} - -
- )} -
- ) -} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx index e6d50e2f7c..86a80d2d9f 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx @@ -12,6 +12,10 @@ import { format } from "date-fns" import { useAdminCancelOrder } from "medusa-react" import { useTranslation } from "react-i18next" import { ActionMenu } from "../../../../../components/common/action-menu" +import { + getOrderFulfillmentStatus, + getOrderPaymentStatus, +} from "../../../../../lib/order-helpers" type OrderGeneralSectionProps = { order: Order @@ -80,26 +84,10 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => { const FulfillmentBadge = ({ order }: { order: Order }) => { const { t } = useTranslation() - const [label, color] = { - not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"], - partially_fulfilled: [ - t("orders.fulfillment.status.partiallyFulfilled"), - "orange", - ], - fulfilled: [t("orders.fulfillment.status.fulfilled"), "green"], - partially_shipped: [ - t("orders.fulfillment.status.partiallyShipped"), - "orange", - ], - shipped: [t("orders.fulfillment.status.shipped"), "green"], - partially_returned: [ - t("orders.fulfillment.status.partiallyReturned"), - "orange", - ], - returned: [t("orders.fulfillment.status.returned"), "green"], - canceled: [t("orders.fulfillment.status.canceled"), "red"], - requires_action: [t("orders.fulfillment.status.requresAction"), "orange"], - }[order.fulfillment_status] as [string, "red" | "orange" | "green"] + const { label, color } = getOrderFulfillmentStatus( + t, + order.fulfillment_status + ) return ( @@ -111,18 +99,7 @@ const FulfillmentBadge = ({ order }: { order: Order }) => { const PaymentBadge = ({ order }: { order: Order }) => { const { t } = useTranslation() - const [label, color] = { - not_paid: [t("orders.payment.status.notPaid"), "red"], - awaiting: [t("orders.payment.status.awaiting"), "orange"], - captured: [t("orders.payment.status.captured"), "green"], - refunded: [t("orders.payment.status.refunded"), "green"], - partially_refunded: [ - t("orders.payment.status.partiallyRefunded"), - "orange", - ], - canceled: [t("orders.payment.status.canceled"), "red"], - requires_action: [t("orders.payment.status.requresAction"), "orange"], - }[order.payment_status] as [string, "red" | "orange" | "green"] + const { label, color } = getOrderPaymentStatus(t, order.payment_status) return ( diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx index 108b7f5939..09971d840e 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx @@ -1,5 +1,5 @@ import { useAdminOrder } from "medusa-react" -import { useLoaderData, useParams } from "react-router-dom" +import { Outlet, useLoaderData, useParams } from "react-router-dom" import { JsonViewSection } from "../../../components/common/json-view-section" import { OrderCustomerSection } from "./components/order-customer-section" import { OrderFulfillmentSection } from "./components/order-fulfillment-section" @@ -47,6 +47,7 @@ export const OrderDetail = () => {
+
) } diff --git a/packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/edit-order-email-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/edit-order-email-form.tsx new file mode 100644 index 0000000000..c7dc8db16b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/edit-order-email-form.tsx @@ -0,0 +1,65 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Order } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateOrder } from "medusa-react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" + +import { EmailForm } from "../../../../../components/forms/email-form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { EmailSchema } from "../../../../../lib/schemas" + +type EditOrderEmailFormProps = { + order: Order +} + +export const EditOrderEmailForm = ({ order }: EditOrderEmailFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + email: order.email, + }, + resolver: zodResolver(EmailSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id) + + const handleSumbit = form.handleSubmit(async (values) => { + mutateAsync(values, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/index.ts new file mode 100644 index 0000000000..55443133a7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-email/components/edit-order-email-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-order-email-form" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-email/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-email/index.ts new file mode 100644 index 0000000000..8147831ad4 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-email/index.ts @@ -0,0 +1 @@ +export { OrderEmail as Component } from "./order-email" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-email/order-email.tsx b/packages/admin-next/dashboard/src/routes/orders/order-email/order-email.tsx new file mode 100644 index 0000000000..2d03a5a23b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-email/order-email.tsx @@ -0,0 +1,28 @@ +import { Heading } from "@medusajs/ui" +import { useAdminOrder } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditOrderEmailForm } from "./components/edit-order-email-form" + +export const OrderEmail = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { order, isLoading, isError, error } = useAdminOrder(id!) + + const ready = !isLoading && order + + if (isError) { + throw error + } + + return ( + + + {t("email.editHeader")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-shipping-address/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-shipping-address/index.ts new file mode 100644 index 0000000000..70f03bcdbb --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-shipping-address/index.ts @@ -0,0 +1 @@ +export { OrderShippingAddress as Component } from "./order-shipping-address" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-shipping-address/order-shipping-address.tsx b/packages/admin-next/dashboard/src/routes/orders/order-shipping-address/order-shipping-address.tsx new file mode 100644 index 0000000000..a9620722f5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-shipping-address/order-shipping-address.tsx @@ -0,0 +1,50 @@ +import { Heading } from "@medusajs/ui" +import { useAdminOrder, useAdminRegion } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { EditOrderAddressForm } from "../common/edit-address-form" + +export const OrderShippingAddress = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { order, isLoading, isError, error } = useAdminOrder(id!) + + const regionId = order?.region_id + + const { + region, + isLoading: isLoadingRegion, + isError: isErrorRegion, + error: errorRegion, + } = useAdminRegion(regionId!, { + enabled: !!regionId, + }) + + const ready = !isLoading && order && !isLoadingRegion && region + const countries = region?.countries || [] + + if (isError) { + throw error + } + + if (isErrorRegion) { + throw errorRegion + } + + return ( + + + {t("addresses.shippingAddress.editHeader")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/index.ts new file mode 100644 index 0000000000..811d6c8109 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/index.ts @@ -0,0 +1 @@ +export * from "./transfer-order-ownership-form" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/transfer-order-ownership-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/transfer-order-ownership-form.tsx new file mode 100644 index 0000000000..8682e5d2bf --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/components/transfer-order-ownership-form/transfer-order-ownership-form.tsx @@ -0,0 +1,72 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Order } from "@medusajs/medusa" +import { Button } from "@medusajs/ui" +import { useAdminUpdateOrder } from "medusa-react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { TransferOwnerShipForm } from "../../../../../components/forms/transfer-ownership-form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { TransferOwnershipSchema } from "../../../../../lib/schemas" + +type TransferOrderOwnershipFormProps = { + order: Order +} + +export const TransferOrderOwnershipForm = ({ + order, +}: TransferOrderOwnershipFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + current_owner_id: order.customer_id, + new_owner_id: "", + }, + resolver: zodResolver(TransferOwnershipSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateOrder(order.id) + + const handleSubmit = form.handleSubmit(async (values) => { + mutateAsync( + { + customer_id: values.new_owner_id, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
+ + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/index.ts new file mode 100644 index 0000000000..a1e6617014 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/index.ts @@ -0,0 +1 @@ +export { OrderTransferOwnership as Component } from "./order-transfer-ownership" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/order-transfer-ownership.tsx b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/order-transfer-ownership.tsx new file mode 100644 index 0000000000..dad0effd3d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-transfer-ownership/order-transfer-ownership.tsx @@ -0,0 +1,28 @@ +import { Heading } from "@medusajs/ui" +import { useAdminOrder } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { TransferOrderOwnershipForm } from "./components/transfer-order-ownership-form" + +export const OrderTransferOwnership = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { order, isLoading, isError, error } = useAdminOrder(id!) + + const ready = !isLoading && order + + if (isError) { + throw error + } + + return ( + + + {t("transferOwnership.header")} + + {ready && } + + ) +} diff --git a/packages/medusa/src/api/routes/admin/draft-orders/index.ts b/packages/medusa/src/api/routes/admin/draft-orders/index.ts index b729753cfc..7defd39a91 100644 --- a/packages/medusa/src/api/routes/admin/draft-orders/index.ts +++ b/packages/medusa/src/api/routes/admin/draft-orders/index.ts @@ -68,7 +68,9 @@ export const defaultAdminDraftOrdersCartRelations = [ "items.adjustments", "payment", "shipping_address", + "shipping_address.country", "billing_address", + "billing_address.country", "region.payment_providers", "shipping_methods", "payment_sessions",