feat(dashboard,types): add credit lines + loyalty changes (#11885)

* feat(dashboard,types): add credit lines + loyalty changes

* chore: fix types

* chore: fix specs

* chore: use correct plugin name

* chore: use new currency input

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2025-06-12 20:12:03 +02:00
committed by GitHub
parent 65f9333501
commit 44d1d18689
21 changed files with 883 additions and 113 deletions

View File

@@ -15,6 +15,7 @@ export * from "./notification"
export * from "./orders"
export * from "./payment-collections"
export * from "./payments"
export * from "./plugins"
export * from "./price-lists"
export * from "./product-types"
export * from "./product-variants"

View File

@@ -1,5 +1,5 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { CreateOrderCreditLineDTO, HttpTypes } from "@medusajs/types"
import {
QueryKey,
useMutation,
@@ -354,3 +354,28 @@ export const useCancelOrderTransfer = (
...options,
})
}
export const useCreateOrderCreditLine = (
orderId: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderResponse,
FetchError,
Omit<CreateOrderCreditLineDTO, "order_id">
>
) => {
return useMutation({
mutationFn: (payload) => sdk.admin.order.createCreditLine(orderId, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -0,0 +1,28 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const PLUGINS_QUERY_KEY = "plugins" as const
export const pluginsQueryKeys = queryKeysFactory(PLUGINS_QUERY_KEY)
export const usePlugins = (
options?: Omit<
UseQueryOptions<
any,
FetchError,
HttpTypes.AdminPluginsListResponse,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.plugin.list(),
queryKey: pluginsQueryKeys.list(),
...options,
})
return { ...data, ...rest }
}

View File

@@ -3783,6 +3783,78 @@
"orders": {
"type": "object",
"properties": {
"giftCardsStoreCreditLines": {
"type": "string"
},
"creditLines": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"total": {
"type": "string"
},
"creditOrDebit": {
"type": "string"
},
"createCreditLine": {
"type": "string"
},
"createCreditLineSuccess": {
"type": "string"
},
"createCreditLineError": {
"type": "string"
},
"createCreditLineDescription": {
"type": "string"
},
"operation": {
"type": "string"
},
"credit": {
"type": "string"
},
"debit": {
"type": "string"
},
"debitDescription": {
"type": "string"
},
"creditDescription": {
"type": "string"
}
}
},
"balanceSettlement": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"settlementType": {
"type": "string"
},
"settlementTypes": {
"type": "object",
"properties": {
"paymentMethod": {
"type": "string"
},
"paymentMethodDescription": {
"type": "string"
},
"creditLine": {
"type": "string"
},
"creditLineDescription": {
"type": "string"
}
}
}
}
},
"domain": {
"type": "string"
},
@@ -3954,6 +4026,9 @@
"totalPaidByCustomer": {
"type": "string"
},
"totalStoreCreditRefunds": {
"type": "string"
},
"capture": {
"type": "string"
},
@@ -4055,6 +4130,7 @@
"title",
"isReadyToBeCaptured",
"totalPaidByCustomer",
"totalStoreCreditRefunds",
"capture",
"capture_short",
"refund",
@@ -10601,6 +10677,12 @@
"amount": {
"type": "string"
},
"reference": {
"type": "string"
},
"reference_id": {
"type": "string"
},
"refundAmount": {
"type": "string"
},
@@ -10859,6 +10941,9 @@
"paidTotal": {
"type": "string"
},
"creditTotal": {
"type": "string"
},
"totalExclTax": {
"type": "string"
},

View File

@@ -1010,6 +1010,31 @@
}
},
"orders": {
"giftCardsStoreCreditLines": "Gift cards & credit lines",
"creditLines": {
"title": "Credit lines",
"total": "Sum of all credit lines",
"creditOrDebit": "Credit / Debit",
"createCreditLine": "Create credit line",
"createCreditLineSuccess": "Credit line created successfully",
"createCreditLineError": "Error creating credit line",
"createCreditLineDescription": "Create a credit line for amount {{amount}}",
"operation": "Operation",
"credit": "Credit",
"creditDescription": "Adds a positive sum to the order",
"debit": "Debit",
"debitDescription": "Subtracts a negative sum from the order"
},
"balanceSettlement": {
"title": "Balance settlement",
"settlementType": "Settlement type",
"settlementTypes": {
"paymentMethod": "Payment method",
"paymentMethodDescription": "Refund amount to the payment method",
"creditLine": "Store credit",
"creditLineDescription": "Refund amount as store credit"
}
},
"domain": "Orders",
"claim": "Claim",
"exchange": "Exchange",
@@ -1056,6 +1081,7 @@
"title": "Payments",
"isReadyToBeCaptured": "Payment <0/> is ready to be captured.",
"totalPaidByCustomer": "Total paid by customer",
"totalStoreCreditRefunds": "Total store credit refunds",
"capture": "Capture payment",
"capture_short": "Capture",
"refund": "Refund",
@@ -2847,6 +2873,8 @@
},
"fields": {
"amount": "Amount",
"reference": "Reference",
"reference_id": "Reference ID",
"refundAmount": "Refund amount",
"name": "Name",
"default": "Default",
@@ -2932,7 +2960,8 @@
"orders": "Orders",
"account": "Account",
"total": "Order Total",
"paidTotal": "Total captured",
"paidTotal": "Paid Total",
"creditTotal": "Credit Lines Total",
"totalExclTax": "Total excl. tax",
"subtotal": "Subtotal",
"shipping": "Shipping",

View File

@@ -0,0 +1,8 @@
import { OrderCreditLineDTO } from "@medusajs/types"
export const getTotalCreditLines = (creditLines: OrderCreditLineDTO[]) =>
creditLines.reduce((acc, creditLine) => {
acc = acc + (creditLine.amount as number)
return acc
}, 0)

View File

@@ -0,0 +1,8 @@
import { HttpTypes } from "@medusajs/types"
export const getPaymentsFromOrder = (order: HttpTypes.AdminOrder) => {
return order.payment_collections
.map((collection: HttpTypes.AdminPaymentCollection) => collection.payments)
.flat(1)
.filter(Boolean) as HttpTypes.AdminPayment[]
}

View File

@@ -0,0 +1,7 @@
import { HttpTypes } from "@medusajs/types"
export const LOYALTY_PLUGIN_NAME = "@medusajs/loyalty-plugin"
export const getLoyaltyPlugin = (plugins: HttpTypes.AdminPlugin[]) => {
return plugins?.find((plugin) => plugin.name === LOYALTY_PLUGIN_NAME)
}

View File

@@ -0,0 +1 @@
export * from "./order-balance-settlement-form"

View File

@@ -0,0 +1,398 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminOrder, AdminPayment } from "@medusajs/types"
import {
Button,
clx,
CurrencyInput,
Divider,
Input,
Label,
RadioGroup,
Select,
Textarea,
toast,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
import { formatValue } from "react-currency-input-field"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import {
useCreateOrderCreditLine,
useRefundPayment,
} from "../../../../../hooks/api"
import { currencies } from "../../../../../lib/data/currencies"
import { formatCurrency } from "../../../../../lib/format-currency"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
const OrderBalanceSettlementSchema = zod.object({
settlement_type: zod.enum(["credit_line", "refund"]),
refund: zod
.object({
amount: zod.string().or(zod.number()).optional(),
note: zod.string().optional(),
})
.optional(),
credit_line: zod
.object({
amount: zod.string().or(zod.number()).optional(),
reference: zod.string().optional(),
reference_id: zod.string().optional(),
note: zod.string().optional(),
})
.optional(),
})
export const OrderBalanceSettlementForm = ({
order,
}: {
order: AdminOrder
}) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [activePayment, setActivePayment] = useState<AdminPayment | null>(null)
const payments = getPaymentsFromOrder(order)
const pendingDifference = order.summary.pending_difference * -1
const form = useForm<zod.infer<typeof OrderBalanceSettlementSchema>>({
defaultValues: {
settlement_type: "refund",
refund: {
amount: 0,
},
credit_line: {
amount: 0,
},
},
resolver: zodResolver(OrderBalanceSettlementSchema),
})
const { mutateAsync: createCreditLine, isPending: isCreditLinePending } =
useCreateOrderCreditLine(order.id)
const { mutateAsync: createRefund, isPending: isRefundPending } =
useRefundPayment(order.id, activePayment?.id!)
const settlementType = form.watch("settlement_type")
const handleSubmit = form.handleSubmit(async (data) => {
if (data.settlement_type === "credit_line") {
await createCreditLine(
{
amount: parseFloat(data.credit_line!.amount! as string) * -1,
reference: data.credit_line!.reference ?? "order",
reference_id: data.credit_line!.reference_id ?? order.id,
},
{
onSuccess: () => {
toast.success(t("orders.creditLines.createCreditLineSuccess"))
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
}
if (data.settlement_type === "refund") {
await createRefund(
{
amount: parseFloat(data.refund!.amount! as string),
note: data.refund!.note,
},
{
onSuccess: () => {
toast.success(
t("orders.payment.refundPaymentSuccess", {
amount: formatCurrency(
parseFloat(data.refund!.amount! as string),
order.currency_code!
),
})
)
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
}
})
const currency = useMemo(
() => currencies[order.currency_code.toUpperCase()],
[order.currency_code]
)
useEffect(() => {
form.clearErrors()
const minimum = activePayment?.amount
? Math.min(pendingDifference, activePayment.amount)
: pendingDifference
if (settlementType === "refund") {
form.setValue("refund.amount", activePayment ? minimum : 0)
}
if (settlementType === "credit_line") {
form.setValue("credit_line.amount", minimum)
}
}, [settlementType, activePayment, pendingDifference, form])
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
onSubmit={handleSubmit}
className="flex size-full flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex-1 overflow-auto">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-4">
<Label className="txt-compact-small font-sans font-medium">
{t("orders.balanceSettlement.settlementType")}
</Label>
<RadioGroup
className="flex flex-col gap-y-3"
value={settlementType}
onValueChange={(value: "credit_line" | "refund") =>
form.setValue("settlement_type", value)
}
>
<RadioGroup.ChoiceBox
value={"refund"}
description={t(
"orders.balanceSettlement.settlementTypes.paymentMethodDescription"
)}
label={t(
"orders.balanceSettlement.settlementTypes.paymentMethod"
)}
className={clx("basis-1/2")}
/>
<RadioGroup.ChoiceBox
value={"credit_line"}
description={t(
"orders.balanceSettlement.settlementTypes.creditLineDescription"
)}
label={t(
"orders.balanceSettlement.settlementTypes.creditLine"
)}
className={clx("basis-1/2")}
/>
</RadioGroup>
</div>
<Divider />
{settlementType === "refund" && (
<>
<div className="flex flex-col gap-y-4">
<Select
onValueChange={(value) => {
setActivePayment(payments.find((p) => p.id === value)!)
}}
>
<Label className="txt-compact-small mb-[-6px] font-sans font-medium">
{t("orders.payment.selectPaymentToRefund")}
</Label>
<Select.Trigger>
<Select.Value
placeholder={t("orders.payment.selectPaymentToRefund")}
/>
</Select.Trigger>
<Select.Content>
{payments.map((payment) => {
const totalRefunded =
payment.refunds?.reduce(
(acc, next) => next.amount + acc,
0
) ?? 0
return (
<Select.Item
value={payment!.id}
key={payment.id}
disabled={
!!payment.canceled_at ||
totalRefunded >= payment.amount
}
>
<span>
{getLocaleAmount(
payment.amount as number,
payment.currency_code
)}
{" - "}
</span>
<span>{payment.provider_id}</span>
<span> - ({payment.id.replace("pay_", "")})</span>
</Select.Item>
)
})}
</Select.Content>
</Select>
</div>
<Form.Field
control={form.control}
name="refund.amount"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.amount")}</Form.Label>
<Form.Control>
<CurrencyInput
{...field}
min={0}
placeholder={formatValue({
value: "0",
decimalScale: currency.decimal_digits,
})}
decimalScale={currency.decimal_digits}
symbol={currency.symbol_native}
code={currency.code}
value={field.value}
onValueChange={(_value, _name, values) =>
onChange(values?.value ? values?.value : "")
}
autoFocus
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name={`refund.note`}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.note")}</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</>
)}
{settlementType === "credit_line" && (
<>
<Form.Field
control={form.control}
name="credit_line.amount"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.amount")}</Form.Label>
<Form.Control>
<CurrencyInput
{...field}
min={0}
placeholder={formatValue({
value: "0",
decimalScale: currency.decimal_digits,
})}
decimalScale={currency.decimal_digits}
symbol={currency.symbol_native}
code={currency.code}
value={field.value}
onValueChange={(_value, _name, values) =>
onChange(values?.value ? values?.value : "")
}
autoFocus
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="credit_line.reference"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.reference")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="credit_line.reference_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.reference_id")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</>
)}
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button
isLoading={isCreditLinePending || isRefundPending}
type="submit"
variant="primary"
size="small"
disabled={!!Object.keys(form.formState.errors || {}).length}
>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./components/order-balance-settlement-form"

View File

@@ -9,6 +9,7 @@ import {
toast,
} from "@medusajs/ui"
import { useEffect, useMemo } from "react"
import { formatValue } from "react-currency-input-field"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useNavigate, useSearchParams } from "react-router-dom"
@@ -19,26 +20,20 @@ import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useRefundPayment } from "../../../../../hooks/api"
import { currencies } from "../../../../../lib/data/currencies"
import { formatCurrency } from "../../../../../lib/format-currency"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../order-detail/components/order-payment-section"
import { formatValue } from "react-currency-input-field"
import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
type CreateRefundFormProps = {
order: HttpTypes.AdminOrder
refundReasons: HttpTypes.AdminRefundReason[]
}
const CreateRefundSchema = zod.object({
amount: zod.string().or(zod.number()),
refund_reason_id: zod.string().nullish(),
note: zod.string().optional(),
})
export const CreateRefundForm = ({
order,
refundReasons,
}: CreateRefundFormProps) => {
export const CreateRefundForm = ({ order }: CreateRefundFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const navigate = useNavigate()
@@ -81,14 +76,16 @@ export const CreateRefundForm = ({
await mutateAsync(
{
amount: parseFloat(data.amount as string),
refund_reason_id: data.refund_reason_id,
note: data.note,
},
{
onSuccess: () => {
toast.success(
t("orders.payment.refundPaymentSuccess", {
amount: formatCurrency(data.amount, payment?.currency_code!),
amount: formatCurrency(
data.amount as number,
payment?.currency_code!
),
})
)
@@ -196,30 +193,6 @@ export const CreateRefundForm = ({
}}
/>
{/* TODO: Bring this back when we have a refund reason management UI */}
{/* <Form.Field
control={form.control}
name="refund_reason_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.refundReason")}</Form.Label>
<Form.Control>
<Combobox
{...field}
options={refundReasons.map((pp) => ({
label: upperCaseFirst(pp.label),
value: pp.id,
}))}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/> */}
<Form.Field
control={form.control}
name={`note`}

View File

@@ -2,7 +2,9 @@ import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals"
import { useOrder, useRefundReasons } from "../../../hooks/api"
import { useOrder, usePlugins } from "../../../hooks/api"
import { getLoyaltyPlugin } from "../../../lib/plugins"
import { OrderBalanceSettlementForm } from "../order-balance-settlement"
import { DEFAULT_FIELDS } from "../order-detail/constants"
import { CreateRefundForm } from "./components/create-refund-form"
@@ -13,16 +15,8 @@ export const OrderCreateRefund = () => {
fields: DEFAULT_FIELDS,
})
const {
refund_reasons: refundReasons,
isLoading: isRefundReasonsLoading,
isError: isRefundReasonsError,
error: refundReasonsError,
} = useRefundReasons()
if (isRefundReasonsError) {
throw refundReasonsError
}
const { plugins = [] } = usePlugins()
const loyaltyPlugin = getLoyaltyPlugin(plugins)
return (
<RouteDrawer>
@@ -30,9 +24,8 @@ export const OrderCreateRefund = () => {
<Heading>{t("orders.payment.createRefund")}</Heading>
</RouteDrawer.Header>
{order && !isRefundReasonsLoading && refundReasons && (
<CreateRefundForm order={order} refundReasons={refundReasons} />
)}
{order && !loyaltyPlugin && <CreateRefundForm order={order} />}
{order && loyaltyPlugin && <OrderBalanceSettlementForm order={order} />}
</RouteDrawer>
)
}

View File

@@ -30,7 +30,7 @@ import { useCancelReturn, useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date"
import { getFormattedAddress } from "../../../../../lib/addresses"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../order-payment-section"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
import ActivityItems from "./activity-items"
import ChangeDetailsTooltip from "./change-details-tooltip"
@@ -392,12 +392,12 @@ const useActivityItems = (order: AdminOrder): Activity[] => {
edit.status === "requested"
? edit.requested_at
: edit.status === "confirmed"
? edit.confirmed_at
: edit.status === "declined"
? edit.declined_at
: edit.status === "canceled"
? edit.canceled_at
: edit.created_at,
? edit.confirmed_at
: edit.status === "declined"
? edit.declined_at
: edit.status === "canceled"
? edit.canceled_at
: edit.created_at,
children: isConfirmed ? <OrderEditBody edit={edit} /> : null,
})
}

View File

@@ -1,3 +1,4 @@
import { OrderCreditLineDTO } from "@medusajs/framework/types"
import { ArrowDownRightMini, DocumentText, XCircle } from "@medusajs/icons"
import { AdminOrder, AdminPayment, HttpTypes } from "@medusajs/types"
import {
@@ -22,20 +23,19 @@ import {
getStylizedAmount,
} from "../../../../../lib/money-amount-helpers"
import { getOrderPaymentStatus } from "../../../../../lib/order-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
import { getTotalCaptured, getTotalPending } from "../../../../../lib/payment"
import { getLoyaltyPlugin } from "../../../../../lib/plugins"
type OrderPaymentSectionProps = {
order: HttpTypes.AdminOrder
plugins: HttpTypes.AdminPlugin[]
}
export const getPaymentsFromOrder = (order: HttpTypes.AdminOrder) => {
return order.payment_collections
.map((collection: HttpTypes.AdminPaymentCollection) => collection.payments)
.flat(1)
.filter(Boolean) as HttpTypes.AdminPayment[]
}
export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
export const OrderPaymentSection = ({
order,
plugins,
}: OrderPaymentSectionProps) => {
const payments = getPaymentsFromOrder(order)
const refunds = payments
@@ -49,6 +49,7 @@ export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
<PaymentBreakdown
order={order}
plugins={plugins}
payments={payments}
refunds={refunds}
currencyCode={order.currency_code}
@@ -59,7 +60,7 @@ export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => {
)
}
const Header = ({ order }) => {
const Header = ({ order }: { order: HttpTypes.AdminOrder }) => {
const { t } = useTranslation()
const { label, color } = getOrderPaymentStatus(t, order.payment_status)
@@ -279,23 +280,94 @@ const Payment = ({
)
}
const CreditLine = ({
creditLine,
currencyCode,
plugins,
}: {
creditLine: OrderCreditLineDTO
currencyCode: string
plugins: HttpTypes.AdminPlugin[]
}) => {
const loyaltyPlugin = getLoyaltyPlugin(plugins)
if (!loyaltyPlugin) {
return null
}
const prettyReference = creditLine.reference
?.split("_")
.join(" ")
.split("-")
.join(" ")
const prettyReferenceId = creditLine.reference_id ? (
<DisplayId id={creditLine.reference_id} />
) : null
return (
<div className="divide-y divide-dashed">
<div className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_20px] items-center gap-x-4 px-6 py-4 sm:grid-cols-[1fr_1fr_1fr_20px]">
<div className="w-full min-w-[60px] overflow-hidden">
<Text
size="small"
leading="compact"
weight="plus"
className="truncate"
>
{loyaltyPlugin ? (
<Text size="small" leading="compact" weight="plus">
Store credit refund
</Text>
) : (
<DisplayId id={creditLine.id} />
)}
</Text>
<Text size="small" leading="compact">
{format(
new Date(creditLine.created_at as string),
"dd MMM, yyyy, HH:mm:ss"
)}
</Text>
</div>
<div className="hidden items-center justify-end sm:flex">
<Text size="small" leading="compact" className="capitalize">
{prettyReference} ({prettyReferenceId})
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(creditLine.amount as number, currencyCode)}
</Text>
</div>
</div>
</div>
)
}
const PaymentBreakdown = ({
order,
payments,
refunds,
currencyCode,
plugins,
}: {
order: HttpTypes.AdminOrder
payments: HttpTypes.AdminPayment[]
refunds: HttpTypes.AdminRefund[]
currencyCode: string
plugins: HttpTypes.AdminPlugin[]
}) => {
/**
* Refunds that are not associated with a payment.
*/
const orderRefunds = refunds.filter((refund) => refund.payment_id === null)
const creditLines = order.credit_lines ?? []
const creditLineRefunds = creditLines.filter(
(creditLine) => (creditLine.amount as number) < 0
)
const entries = [...orderRefunds, ...payments]
const entries = [...orderRefunds, ...payments, ...creditLineRefunds]
.sort((a, b) => {
return (
new Date(a.created_at as string).getTime() -
@@ -303,13 +375,20 @@ const PaymentBreakdown = ({
)
})
.map((entry) => {
return {
event: entry,
type: entry.id.startsWith("pay_") ? "payment" : "refund",
let type = entry.id.startsWith("pay_") ? "payment" : "refund"
if (entry.id.startsWith("ordcl_")) {
type = "credit_line_refund"
}
return { event: entry, type }
}) as (
| { type: "payment"; event: HttpTypes.AdminPayment }
| { type: "refund"; event: HttpTypes.AdminRefund }
| {
type: "credit_line_refund"
event: OrderCreditLineDTO
}
)[]
return (
@@ -336,6 +415,15 @@ const PaymentBreakdown = ({
currencyCode={currencyCode}
/>
)
case "credit_line_refund":
return (
<CreditLine
key={event.id}
creditLine={event}
currencyCode={currencyCode}
plugins={plugins}
/>
)
}
})}
</div>
@@ -347,8 +435,8 @@ const Total = ({ order }: { order: AdminOrder }) => {
const totalPending = getTotalPending(order.payment_collections)
return (
<div>
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col gap-y-4 px-6 py-4">
<div className="flex items-center justify-between">
<Text size="small" weight="plus" leading="compact">
{t("orders.payment.totalPaidByCustomer")}
</Text>
@@ -362,7 +450,7 @@ const Total = ({ order }: { order: AdminOrder }) => {
</div>
{order.status !== "canceled" && totalPending > 0 && (
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center justify-between">
<Text size="small" weight="plus" leading="compact">
Total pending
</Text>

View File

@@ -1,6 +1,6 @@
import { ReactNode, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { Link } from "react-router-dom"
import {
ArrowDownRightMini,
@@ -19,6 +19,7 @@ import {
AdminOrderLineItem,
AdminOrderPreview,
AdminPaymentCollection,
AdminPlugin,
AdminRegion,
AdminReturn,
} from "@medusajs/types"
@@ -37,7 +38,9 @@ import {
} from "@medusajs/ui"
import { AdminReservation } from "@medusajs/types/src/http"
import { format } from "date-fns"
import { ActionMenu } from "../../../../../components/common/action-menu"
import DisplayId from "../../../../../components/common/display-id/display-id"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useClaims } from "../../../../../hooks/api/claims"
import { useExchanges } from "../../../../../hooks/api/exchanges"
@@ -46,6 +49,7 @@ import { useMarkPaymentCollectionAsPaid } from "../../../../../hooks/api/payment
import { useReservationItems } from "../../../../../hooks/api/reservations"
import { useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date"
import { getTotalCreditLines } from "../../../../../lib/credit-line"
import { formatCurrency } from "../../../../../lib/format-currency"
import {
getLocaleAmount,
@@ -53,6 +57,7 @@ import {
isAmountLessThenRoundingError,
} from "../../../../../lib/money-amount-helpers"
import { getTotalCaptured } from "../../../../../lib/payment"
import { getLoyaltyPlugin } from "../../../../../lib/plugins"
import { getReturnableQuantity } from "../../../../../lib/rma"
import { CopyPaymentLink } from "../copy-payment-link/copy-payment-link"
import ReturnInfoPopover from "./return-info-popover"
@@ -60,9 +65,13 @@ import ShippingInfoPopover from "./shipping-info-popover"
type OrderSummarySectionProps = {
order: AdminOrder
plugins: AdminPlugin[]
}
export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
export const OrderSummarySection = ({
order,
plugins,
}: OrderSummarySectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
@@ -133,8 +142,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
const showPayment =
unpaidPaymentCollection && pendingDifference > 0 && isAmountSignificant
const showRefund =
unpaidPaymentCollection && pendingDifference < 0 && isAmountSignificant
const showRefund = pendingDifference < 0 && isAmountSignificant
const handleMarkAsPaid = async (
paymentCollection: AdminPaymentCollection
@@ -181,6 +189,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => {
<Header order={order} orderPreview={orderPreview} />
<ItemBreakdown order={order} reservations={reservations!} />
<CostBreakdown order={order} />
<CreditLinesBreakdown order={order} plugins={plugins} />
<Total order={order} />
{(showAllocateButton || showReturns || showPayment || showRefund) && (
@@ -398,12 +407,7 @@ const Item = ({
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
<Text size="small" leading="compact" className="text-ui-fg-base">
{item.title}
</Text>
@@ -741,6 +745,109 @@ const CostBreakdown = ({
)
}
const CreditLinesBreakdown = ({
order,
plugins,
}: {
order: AdminOrder & { region?: AdminRegion | null }
plugins: AdminPlugin[]
}) => {
const { t } = useTranslation()
const [isCreditLinesOpen, setIsCreditLinesOpen] = useState(false)
const creditLines = order.credit_lines ?? []
const loyaltyPlugin = getLoyaltyPlugin(plugins)
if (creditLines.length === 0) {
return null
}
return (
<div className="text-ui-fg-subtle flex flex-col">
<>
<div
onClick={() => setIsCreditLinesOpen((o) => !o)}
className="bg-ui-bg-component flex cursor-pointer items-center justify-between border border-dashed px-6 py-4"
>
<div className="flex items-center gap-2">
<TriangleDownMini
style={{
transform: `rotate(${isCreditLinesOpen ? 0 : -90}deg)`,
}}
/>
<span className="text-ui-fg-muted txt-small select-none">
{loyaltyPlugin
? t("orders.giftCardsStoreCreditLines")
: t("orders.creditLines.title")}
</span>
</div>
<div>
<Text size="small" leading="compact">
{getLocaleAmount(order.credit_line_total, order.currency_code)}
</Text>
</div>
</div>
{isCreditLinesOpen && (
<div className="flex flex-col">
{creditLines.map((creditLine) => {
const prettyReference = creditLine.reference
?.split("_")
.join(" ")
.split("-")
.join(" ")
const prettyReferenceId = creditLine.reference_id ? (
<DisplayId id={creditLine.reference_id} />
) : null
return (
<div
className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_1fr] items-center px-6 py-4 py-4 sm:grid-cols-[1fr_1fr_1fr]"
key={creditLine.id}
>
<div className="w-full min-w-[60px] overflow-hidden">
<Text
size="small"
leading="compact"
weight="plus"
className="truncate"
>
<DisplayId id={creditLine.id} />
</Text>
<Text size="small" leading="compact">
{format(
new Date(creditLine.created_at),
"dd MMM, yyyy, HH:mm:ss"
)}
</Text>
</div>
<div className="hidden items-center justify-end gap-x-2 sm:flex">
<Text size="small" leading="compact" className="capitalize">
{prettyReference} ({prettyReferenceId})
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(
creditLine.amount as number,
order.currency_code
)}
</Text>
</div>
</div>
)
})}
</div>
)}
</>
</div>
)
}
const InventoryKitBreakdown = ({ item }: { item: AdminOrderLineItem }) => {
const { t } = useTranslation()
@@ -1028,39 +1135,19 @@ const Total = ({ order }: { order: AdminOrder }) => {
return (
<div className=" flex flex-col gap-y-2 px-6 py-4">
<div className="text-ui-fg-base flex items-center justify-between">
<Text
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{t("fields.total")}
</Text>
<Text
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
{getStylizedAmount(order.total, order.currency_code)}
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{getStylizedAmount(order.original_total, order.currency_code)}
</Text>
</div>
<div className="text-ui-fg-base flex items-center justify-between">
<Text
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{t("fields.paidTotal")}
</Text>
<Text
weight="plus"
className="text-ui-fg-subtle"
size="small"
leading="compact"
>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{getStylizedAmount(
getTotalCaptured(order.payment_collections || []),
order.currency_code
@@ -1068,12 +1155,24 @@ const Total = ({ order }: { order: AdminOrder }) => {
</Text>
</div>
<div className="text-ui-fg-base flex items-center justify-between">
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{t("fields.creditTotal")}
</Text>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{getStylizedAmount(
getTotalCreditLines(order.credit_lines ?? []),
order.currency_code
)}
</Text>
</div>
<div className="text-ui-fg-base flex items-center justify-between">
<Text
className="text-ui-fg-subtle text-semibold"
size="small"
leading="compact"
weight="plus"
>
{t("orders.returns.outstandingAmount")}
</Text>
@@ -1081,7 +1180,6 @@ const Total = ({ order }: { order: AdminOrder }) => {
className="text-ui-fg-subtle text-bold"
size="small"
leading="compact"
weight="plus"
>
{getStylizedAmount(
order.summary.pending_difference || 0,

View File

@@ -9,8 +9,10 @@ const DEFAULT_PROPERTIES = [
"metadata",
// --- TOTALS ---
"total",
"credit_line_total",
"item_total",
"shipping_subtotal",
"original_total",
"subtotal",
"discount_total",
"discount_subtotal",
@@ -36,6 +38,7 @@ const DEFAULT_RELATIONS = [
"*sales_channel",
"*promotion",
"*shipping_methods",
"*credit_lines",
"*fulfillments",
"+fulfillments.shipping_option.service_zone.fulfillment_set.type",
"*fulfillments.items",

View File

@@ -3,6 +3,7 @@ import { useLoaderData, useParams } from "react-router-dom"
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
import { TwoColumnPage } from "../../../components/layout/pages"
import { useOrder, useOrderPreview } from "../../../hooks/api/orders"
import { usePlugins } from "../../../hooks/api/plugins"
import { useExtension } from "../../../providers/extension-provider"
import { ActiveOrderClaimSection } from "./components/active-order-claim-section"
import { ActiveOrderExchangeSection } from "./components/active-order-exchange-section"
@@ -22,6 +23,7 @@ export const OrderDetail = () => {
const { id } = useParams()
const { getWidgets } = useExtension()
const { plugins = [] } = usePlugins()
const { order, isLoading, isError, error } = useOrder(
id!,
@@ -81,8 +83,8 @@ export const OrderDetail = () => {
<ActiveOrderExchangeSection orderPreview={orderPreview!} />
<ActiveOrderReturnSection orderPreview={orderPreview!} />
<OrderGeneralSection order={order} />
<OrderSummarySection order={order} />
<OrderPaymentSection order={order} />
<OrderSummarySection order={order} plugins={plugins} />
<OrderPaymentSection order={order} plugins={plugins} />
<OrderFulfillmentSection order={order} />
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>