feat(admin-shared,dashboard,js-sdk,types): refund reasons in dashboard (#13575)

CLOSES CORE-1209

This PR just adds the stuff necessary to support refund reasons in the dashboard. It adds the option in the settings tab and allows viewing, creating, editing and deleting refund reasons. I hate to open such a big PR but most of it is copy pasted from the return reasons. Major difference is only the fact that refund reasons don't have a `value` field
This commit is contained in:
William Bouchard
2025-09-23 15:51:40 +00:00
committed by GitHub
parent 543c9f7d0f
commit 5e827ec95d
39 changed files with 1190 additions and 131 deletions
@@ -0,0 +1 @@
export { RefundReasonCreateForm } from "./refund-reason-create-form"
@@ -0,0 +1,131 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text, Textarea, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useCreateRefundReason } from "../../../../../hooks/api"
const RefundReasonCreateSchema = z.object({
label: z.string().min(1),
description: z.string().optional(),
})
export const RefundReasonCreateForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof RefundReasonCreateSchema>>({
defaultValues: {
label: "",
description: "",
},
resolver: zodResolver(RefundReasonCreateSchema),
})
const { mutateAsync, isPending } = useCreateRefundReason()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ refund_reason }) => {
toast.success(
t("refundReasons.create.successToast", {
label: refund_reason.label,
})
)
handleSuccess(`../`)
},
onError: (error) => {
toast.error(error.message)
},
})
})
return (
<RouteFocusModal.Form form={form}>
<KeyboundForm
className="flex size-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex flex-1 justify-center overflow-auto px-6 py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div className="flex flex-col gap-y-1">
<RouteFocusModal.Title asChild>
<Heading>{t("refundReasons.create.header")}</Heading>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<Text size="small" className="text-ui-fg-subtle">
{t("refundReasons.create.subtitle")}
</Text>
</RouteFocusModal.Description>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="label"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("refundReasons.fields.label.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t(
"refundReasons.fields.label.placeholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("refundReasons.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder={t(
"refundReasons.fields.description.placeholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary" type="button">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1 @@
export { RefundReasonCreate as Component } from "./refund-reason-create"
@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/modals"
import { RefundReasonCreateForm } from "./components/refund-reason-create-form"
export const RefundReasonCreate = () => {
return (
<RouteFocusModal>
<RefundReasonCreateForm />
</RouteFocusModal>
)
}
@@ -0,0 +1 @@
export { RefundReasonEditForm } from "./refund-reason-edit-form"
@@ -0,0 +1,119 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, Input, Textarea, toast } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Form } from "../../../../../components/common/form"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useUpdateRefundReason } from "../../../../../hooks/api/refund-reasons"
type RefundReasonEditFormProps = {
refundReason: HttpTypes.AdminRefundReason
}
const RefundReasonEditSchema = z.object({
label: z.string().min(1),
description: z.string().optional(),
})
export const RefundReasonEditForm = ({
refundReason,
}: RefundReasonEditFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof RefundReasonEditSchema>>({
defaultValues: {
label: refundReason.label,
description: refundReason.description ?? undefined,
},
resolver: zodResolver(RefundReasonEditSchema),
})
const { mutateAsync, isPending } = useUpdateRefundReason(refundReason.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: ({ refund_reason }) => {
toast.success(
t("refundReasons.edit.successToast", {
label: refund_reason.label,
})
)
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
})
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
className="flex size-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
<Form.Field
control={form.control}
name="label"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("refundReasons.fields.label.label")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={t("refundReasons.fields.label.placeholder")}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("refundReasons.fields.description.label")}
</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder={t(
"refundReasons.fields.description.placeholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small" type="button">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export { RefundReasonEdit as Component } from "./refund-reason-edit"
@@ -0,0 +1,33 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals"
import { useRefundReason } from "../../../hooks/api/refund-reasons"
import { RefundReasonEditForm } from "./components/refund-reason-edit-form"
export const RefundReasonEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const { refund_reason, isPending, isError, error } = useRefundReason(id!)
const ready = !isPending && !!refund_reason
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("refundReasons.edit.header")}</Heading>
</RouteDrawer.Title>
<RouteDrawer.Description className="sr-only">
{t("refundReasons.edit.subtitle")}
</RouteDrawer.Description>
</RouteDrawer.Header>
{ready && <RefundReasonEditForm refundReason={refund_reason} />}
</RouteDrawer>
)
}
@@ -0,0 +1 @@
export { RefundReasonListTable } from "./refund-reason-list-table"
@@ -0,0 +1,130 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, createDataTableColumnHelper, toast, usePrompt, } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { DataTable } from "../../../../../components/data-table"
import { useDeleteRefundReasonLazy, useRefundReasons, } from "../../../../../hooks/api"
import { useRefundReasonTableColumns } from "../../../../../hooks/table/columns"
import { useRefundReasonTableQuery } from "../../../../../hooks/table/query"
const PAGE_SIZE = 20
export const RefundReasonListTable = () => {
const { t } = useTranslation()
const { searchParams } = useRefundReasonTableQuery({
pageSize: PAGE_SIZE,
})
const { refund_reasons, count, isPending, isError, error } = useRefundReasons(
searchParams,
{
placeholderData: keepPreviousData,
}
)
const columns = useColumns()
if (isError) {
throw error
}
return (
<Container className="divide-y px-0 py-0">
<DataTable
data={refund_reasons}
columns={columns}
rowCount={count}
pageSize={PAGE_SIZE}
getRowId={(row) => row.id}
heading={t("refundReasons.domain")}
subHeading={t("refundReasons.subtitle")}
emptyState={{
empty: {
heading: t("general.noRecordsMessage"),
},
filtered: {
heading: t("general.noRecordsMessage"),
description: t("general.noRecordsMessageFiltered"),
},
}}
actions={[
{
label: t("actions.create"),
to: "create",
},
]}
isLoading={isPending}
enableSearch={true}
/>
</Container>
)
}
const columnHelper = createDataTableColumnHelper<HttpTypes.AdminRefundReason>()
const useColumns = () => {
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const base = useRefundReasonTableColumns()
const { mutateAsync } = useDeleteRefundReasonLazy()
const handleDelete = useCallback(
async (refundReason: HttpTypes.AdminRefundReason) => {
const confirm = await prompt({
title: t("general.areYouSure"),
description: t("refundReasons.delete.confirmation", {
label: refundReason.label,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!confirm) {
return
}
await mutateAsync(refundReason.id, {
onSuccess: () => {
toast.success(t("refundReasons.delete.successToast"))
},
onError: (e) => {
toast.error(e.message)
},
})
},
[t, prompt, mutateAsync]
)
return useMemo(
() => [
...base,
columnHelper.action({
actions: (ctx) => [
[
{
icon: <PencilSquare />,
label: t("actions.edit"),
onClick: () =>
navigate(
`/settings/refund-reasons/${ctx.row.original.id}/edit`
),
},
],
[
{
icon: <Trash />,
label: t("actions.delete"),
onClick: () => handleDelete(ctx.row.original),
},
],
],
}),
],
[base, handleDelete, navigate, t]
)
}
@@ -0,0 +1,2 @@
export { refundReasonListLoader as loader } from "./loader"
export { RefundReasonList as Component } from "./refund-reason-list"
@@ -0,0 +1,16 @@
import { refundReasonsQueryKeys } from "../../../hooks/api"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
const refundReasonListQuery = () => ({
queryKey: refundReasonsQueryKeys.list(),
queryFn: async () => sdk.admin.refundReason.list(),
})
export const refundReasonListLoader = async () => {
const query = refundReasonListQuery()
return (
queryClient.getQueryData(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -0,0 +1,21 @@
import { SingleColumnPage } from "../../../components/layout/pages"
import { useExtension } from "../../../providers/extension-provider"
import { RefundReasonListTable } from "./components/refund-reason-list-table"
export const RefundReasonList = () => {
const { getWidgets } = useExtension()
return (
<SingleColumnPage
showMetadata={false}
showJSON={false}
hasOutlet
widgets={{
after: getWidgets("refund_reason.list.after"),
before: getWidgets("refund_reason.list.before"),
}}
>
<RefundReasonListTable />
</SingleColumnPage>
)
}