feat: Redemption Code
This commit is contained in:
parent
45b9b523b1
commit
2c4518cbab
@ -19,6 +19,7 @@
|
|||||||
"Order Management": "Order Management",
|
"Order Management": "Order Management",
|
||||||
"Payment Config": "Payment Config",
|
"Payment Config": "Payment Config",
|
||||||
"Product Management": "Product Management",
|
"Product Management": "Product Management",
|
||||||
|
"Redemption Management": "Redemption Management",
|
||||||
"Register": "Register",
|
"Register": "Register",
|
||||||
"Reset Subscribe": "Reset Subscribe",
|
"Reset Subscribe": "Reset Subscribe",
|
||||||
"Server Management": "Server Management",
|
"Server Management": "Server Management",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"Order Management": "订单管理",
|
"Order Management": "订单管理",
|
||||||
"Payment Config": "支付配置",
|
"Payment Config": "支付配置",
|
||||||
"Product Management": "商品管理",
|
"Product Management": "商品管理",
|
||||||
|
"Redemption Management": "兑换码管理",
|
||||||
"Register": "注册",
|
"Register": "注册",
|
||||||
"Reset Subscribe": "重置订阅",
|
"Reset Subscribe": "重置订阅",
|
||||||
"Server Management": "服务器管理",
|
"Server Management": "服务器管理",
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import Redemption from "@/sections/redemption";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/redemption/")({
|
||||||
|
component: Redemption,
|
||||||
|
});
|
||||||
249
apps/admin/src/sections/redemption/index.tsx
Normal file
249
apps/admin/src/sections/redemption/index.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
batchDeleteRedemptionCode,
|
||||||
|
createRedemptionCode,
|
||||||
|
deleteRedemptionCode,
|
||||||
|
getRedemptionCodeList,
|
||||||
|
toggleRedemptionCodeStatus,
|
||||||
|
updateRedemptionCode,
|
||||||
|
} from "@workspace/ui/services/admin/redemption";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
|
import RedemptionForm from "./redemption-form";
|
||||||
|
import RedemptionRecords from "./redemption-records";
|
||||||
|
|
||||||
|
export default function Redemption() {
|
||||||
|
const { t } = useTranslation("redemption");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [recordsOpen, setRecordsOpen] = useState(false);
|
||||||
|
const [selectedCodeId, setSelectedCodeId] = useState<number | null>(null);
|
||||||
|
const { subscribes } = useSubscribe();
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }>
|
||||||
|
action={ref}
|
||||||
|
actions={{
|
||||||
|
render: (row) => [
|
||||||
|
<Button
|
||||||
|
key="records"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCodeId(row.id);
|
||||||
|
setRecordsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("records", "Records")}
|
||||||
|
</Button>,
|
||||||
|
<RedemptionForm<API.UpdateRedemptionCodeRequest>
|
||||||
|
initialValues={row}
|
||||||
|
key="edit"
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateRedemptionCode({ ...values });
|
||||||
|
toast.success(t("updateSuccess", "Update Success"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("editRedemptionCode", "Edit Redemption Code")}
|
||||||
|
trigger={t("edit", "Edit")}
|
||||||
|
/>,
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteWarning",
|
||||||
|
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||||
|
)}
|
||||||
|
key="delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteRedemptionCode({ id: row.id });
|
||||||
|
toast.success(t("deleteSuccess", "Delete Success"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
batchRender: (rows) => [
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteWarning",
|
||||||
|
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||||
|
)}
|
||||||
|
key="delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await batchDeleteRedemptionCode({
|
||||||
|
ids: rows.map((item) => item.id),
|
||||||
|
});
|
||||||
|
toast.success(t("deleteSuccess", "Delete Success"));
|
||||||
|
ref.current?.reset();
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "code",
|
||||||
|
header: t("code", "Code"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "subscribe_plan",
|
||||||
|
header: t("subscribePlan", "Subscribe Plan"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const plan = subscribes?.find(
|
||||||
|
(s) => s.id === row.getValue("subscribe_plan")
|
||||||
|
);
|
||||||
|
return plan?.name || "--";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "unit_time",
|
||||||
|
header: t("unitTime", "Unit Time"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const unitTime = row.getValue("unit_time") as string;
|
||||||
|
const unitTimeMap: Record<string, string> = {
|
||||||
|
day: t("form.day", "Day"),
|
||||||
|
month: t("form.month", "Month"),
|
||||||
|
quarter: t("form.quarter", "Quarter"),
|
||||||
|
half_year: t("form.halfYear", "Half Year"),
|
||||||
|
year: t("form.year", "Year"),
|
||||||
|
};
|
||||||
|
return unitTimeMap[unitTime] || unitTime;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "quantity",
|
||||||
|
header: t("duration", "Duration"),
|
||||||
|
cell: ({ row }) => `${row.original.quantity}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "total_count",
|
||||||
|
header: t("totalCount", "Total Count"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>
|
||||||
|
{t("totalCount", "Total")}: {row.original.total_count}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("remainingCount", "Remaining")}:{" "}
|
||||||
|
{row.original.total_count - (row.original.used_count || 0)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("usedCount", "Used")}: {row.original.used_count || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: t("status", "Status"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.getValue("status") === 1}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
await toggleRedemptionCodeStatus({
|
||||||
|
id: row.original.id,
|
||||||
|
status: checked ? 1 : 0,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
checked
|
||||||
|
? t("updateSuccess", "Update Success")
|
||||||
|
: t("updateSuccess", "Update Success")
|
||||||
|
);
|
||||||
|
ref.current?.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
header={{
|
||||||
|
toolbar: (
|
||||||
|
<RedemptionForm<API.CreateRedemptionCodeRequest>
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createRedemptionCode(values);
|
||||||
|
toast.success(t("createSuccess", "Create Success"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("createRedemptionCode", "Create Redemption Code")}
|
||||||
|
trigger={t("create", "Create")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
params={[
|
||||||
|
{
|
||||||
|
key: "subscribe_plan",
|
||||||
|
placeholder: t("subscribePlan", "Subscribe Plan"),
|
||||||
|
options: subscribes?.map((item) => ({
|
||||||
|
label: item.name!,
|
||||||
|
value: String(item.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "unit_time",
|
||||||
|
placeholder: t("unitTime", "Unit Time"),
|
||||||
|
options: [
|
||||||
|
{ label: t("form.day", "Day"), value: "day" },
|
||||||
|
{ label: t("form.month", "Month"), value: "month" },
|
||||||
|
{ label: t("form.quarter", "Quarter"), value: "quarter" },
|
||||||
|
{ label: t("form.halfYear", "Half Year"), value: "half_year" },
|
||||||
|
{ label: t("form.year", "Year"), value: "year" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "code",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
request={async (pagination, filters) => {
|
||||||
|
const { data } = await getRedemptionCodeList({
|
||||||
|
...pagination,
|
||||||
|
...filters,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RedemptionRecords
|
||||||
|
codeId={selectedCodeId}
|
||||||
|
open={recordsOpen}
|
||||||
|
onOpenChange={setRecordsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
apps/admin/src/sections/redemption/redemption-form.tsx
Normal file
289
apps/admin/src/sections/redemption/redemption-form.tsx
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@workspace/ui/components/form";
|
||||||
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||||
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
id: z.number().optional(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
batch_count: z.number().optional(),
|
||||||
|
total_count: z.number().min(1, "Total count is required"),
|
||||||
|
subscribe_plan: z.number().min(1, "Subscribe plan is required"),
|
||||||
|
unit_time: z.string().min(1, "Unit time is required"),
|
||||||
|
quantity: z.number().min(1, "Quantity is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RedemptionFormProps<T> {
|
||||||
|
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||||
|
initialValues?: T;
|
||||||
|
loading?: boolean;
|
||||||
|
trigger: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RedemptionForm<T extends Record<string, any>>({
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
loading,
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
}: RedemptionFormProps<T>) {
|
||||||
|
const { t } = useTranslation("redemption");
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
...initialValues,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form?.reset(initialValues);
|
||||||
|
}, [form, initialValues]);
|
||||||
|
|
||||||
|
async function handleSubmit(data: { [x: string]: any }) {
|
||||||
|
// When editing, merge the id from initialValues
|
||||||
|
const submitData = initialValues?.id
|
||||||
|
? { ...data, id: initialValues.id }
|
||||||
|
: data;
|
||||||
|
const bool = await onSubmit(submitData as T);
|
||||||
|
if (bool) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscribes } = useSubscribe();
|
||||||
|
const isEdit = !!initialValues?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{title}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-4 px-6 pt-4"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
>
|
||||||
|
{isEdit && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("form.code", "Code")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
disabled
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.setValue(field.name, value);
|
||||||
|
}}
|
||||||
|
placeholder={t("form.code", "Code")}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isEdit && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="batch_count"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.batchCount", "Batch Count")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={1}
|
||||||
|
placeholder={t(
|
||||||
|
"form.batchCountPlaceholder",
|
||||||
|
"Batch Count"
|
||||||
|
)}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.setValue(field.name, value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="subscribe_plan"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.subscribePlan", "Subscribe Plan")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox<number, false>
|
||||||
|
onChange={(value) => {
|
||||||
|
form.setValue(field.name, value);
|
||||||
|
}}
|
||||||
|
options={subscribes?.map((item) => ({
|
||||||
|
value: item.id!,
|
||||||
|
label: item.name!,
|
||||||
|
}))}
|
||||||
|
placeholder={t(
|
||||||
|
"form.selectPlan",
|
||||||
|
"Select Subscribe Plan"
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="unit_time"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.unitTime", "Unit Time")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox<string, false>
|
||||||
|
onChange={(value) => {
|
||||||
|
form.setValue(field.name, value);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "day", label: t("form.day", "Day") },
|
||||||
|
{ value: "month", label: t("form.month", "Month") },
|
||||||
|
{ value: "quarter", label: t("form.quarter", "Quarter") },
|
||||||
|
{ value: "half_year", label: t("form.halfYear", "Half Year") },
|
||||||
|
{ value: "year", label: t("form.year", "Year") },
|
||||||
|
]}
|
||||||
|
placeholder={t(
|
||||||
|
"form.selectUnitTime",
|
||||||
|
"Select Unit Time"
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="quantity"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.duration", "Duration")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={1}
|
||||||
|
placeholder={t(
|
||||||
|
"form.durationPlaceholder",
|
||||||
|
"Duration"
|
||||||
|
)}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.setValue(field.name, value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="total_count"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.totalCount", "Total Count")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={1}
|
||||||
|
placeholder={t(
|
||||||
|
"form.totalCountPlaceholder",
|
||||||
|
"Total Count"
|
||||||
|
)}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.setValue(field.name, value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</ScrollArea>
|
||||||
|
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||||
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("form.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||||
|
{loading && (
|
||||||
|
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||||
|
)}{" "}
|
||||||
|
{t("form.confirm", "Confirm")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/admin/src/sections/redemption/redemption-records.tsx
Normal file
142
apps/admin/src/sections/redemption/redemption-records.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import { getRedemptionRecordList } from "@workspace/ui/services/admin/redemption";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
|
||||||
|
interface RedemptionRecordsProps {
|
||||||
|
codeId: number | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RedemptionRecords({
|
||||||
|
codeId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: RedemptionRecordsProps) {
|
||||||
|
const { t } = useTranslation("redemption");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, size: 10 });
|
||||||
|
|
||||||
|
const fetchRecords = async () => {
|
||||||
|
if (!codeId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRedemptionRecordList({
|
||||||
|
...pagination,
|
||||||
|
code_id: codeId,
|
||||||
|
});
|
||||||
|
setRecords(data.data?.list || []);
|
||||||
|
setTotal(data.data?.total || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch redemption records:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && codeId) {
|
||||||
|
fetchRecords();
|
||||||
|
}
|
||||||
|
}, [open, codeId, pagination]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("records", "Redemption Records")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<span>{t("loading", "Loading...")}</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{t("noRecords", "No records found")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>{t("userId", "User ID")}</TableHead>
|
||||||
|
<TableHead>{t("subscribeId", "Subscribe ID")}</TableHead>
|
||||||
|
<TableHead>{t("unitTime", "Unit Time")}</TableHead>
|
||||||
|
<TableHead>{t("quantity", "Quantity")}</TableHead>
|
||||||
|
<TableHead>{t("redeemedAt", "Redeemed At")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{records.map((record) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell>{record.id}</TableCell>
|
||||||
|
<TableCell>{record.user_id}</TableCell>
|
||||||
|
<TableCell>{record.subscribe_id}</TableCell>
|
||||||
|
<TableCell>{record.unit_time}</TableCell>
|
||||||
|
<TableCell>{record.quantity}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.redeemed_at
|
||||||
|
? formatDate(record.redeemed_at)
|
||||||
|
: "--"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{total > pagination.size && (
|
||||||
|
<div className="flex justify-between items-center mt-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("total", "Total")}: {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
onClick={() =>
|
||||||
|
setPagination((p) => ({ ...p, page: p.page - 1 }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("previous", "Previous")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={pagination.page * pagination.size >= total}
|
||||||
|
onClick={() =>
|
||||||
|
setPagination((p) => ({ ...p, page: p.page + 1 }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("next", "Next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user