feat: Redemption Code
This commit is contained in:
parent
45b9b523b1
commit
2c4518cbab
@ -19,6 +19,7 @@
|
||||
"Order Management": "Order Management",
|
||||
"Payment Config": "Payment Config",
|
||||
"Product Management": "Product Management",
|
||||
"Redemption Management": "Redemption Management",
|
||||
"Register": "Register",
|
||||
"Reset Subscribe": "Reset Subscribe",
|
||||
"Server Management": "Server Management",
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"Order Management": "订单管理",
|
||||
"Payment Config": "支付配置",
|
||||
"Product Management": "商品管理",
|
||||
"Redemption Management": "兑换码管理",
|
||||
"Register": "注册",
|
||||
"Reset Subscribe": "重置订阅",
|
||||
"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