diff --git a/apps/admin/public/assets/locales/en-US/menu.json b/apps/admin/public/assets/locales/en-US/menu.json index 3ffc716..4c17948 100644 --- a/apps/admin/public/assets/locales/en-US/menu.json +++ b/apps/admin/public/assets/locales/en-US/menu.json @@ -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", diff --git a/apps/admin/public/assets/locales/zh-CN/menu.json b/apps/admin/public/assets/locales/zh-CN/menu.json index d24feb6..54fe7ab 100644 --- a/apps/admin/public/assets/locales/zh-CN/menu.json +++ b/apps/admin/public/assets/locales/zh-CN/menu.json @@ -19,6 +19,7 @@ "Order Management": "订单管理", "Payment Config": "支付配置", "Product Management": "商品管理", + "Redemption Management": "兑换码管理", "Register": "注册", "Reset Subscribe": "重置订阅", "Server Management": "服务器管理", diff --git a/apps/admin/src/routes/dashboard/redemption/index.lazy.tsx b/apps/admin/src/routes/dashboard/redemption/index.lazy.tsx new file mode 100644 index 0000000..cd60cb3 --- /dev/null +++ b/apps/admin/src/routes/dashboard/redemption/index.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import Redemption from "@/sections/redemption"; + +export const Route = createLazyFileRoute("/dashboard/redemption/")({ + component: Redemption, +}); diff --git a/apps/admin/src/sections/redemption/index.tsx b/apps/admin/src/sections/redemption/index.tsx new file mode 100644 index 0000000..0ad6bb1 --- /dev/null +++ b/apps/admin/src/sections/redemption/index.tsx @@ -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(null); + const { subscribes } = useSubscribe(); + const ref = useRef(null); + return ( + <> + + action={ref} + actions={{ + render: (row) => [ + , + + 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")} + />, + { + 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={ + + } + />, + ], + batchRender: (rows) => [ + { + 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={ + + } + />, + ], + }} + 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 = { + 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 }) => ( +
+ + {t("totalCount", "Total")}: {row.original.total_count} + + + {t("remainingCount", "Remaining")}:{" "} + {row.original.total_count - (row.original.used_count || 0)} + + + {t("usedCount", "Used")}: {row.original.used_count || 0} + +
+ ), + }, + { + accessorKey: "status", + header: t("status", "Status"), + cell: ({ row }) => ( + { + 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: ( + + 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, + }; + }} + /> + + + ); +} diff --git a/apps/admin/src/sections/redemption/redemption-form.tsx b/apps/admin/src/sections/redemption/redemption-form.tsx new file mode 100644 index 0000000..07e7202 --- /dev/null +++ b/apps/admin/src/sections/redemption/redemption-form.tsx @@ -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 { + onSubmit: (data: T) => Promise | boolean; + initialValues?: T; + loading?: boolean; + trigger: string; + title: string; +} + +export default function RedemptionForm>({ + onSubmit, + initialValues, + loading, + trigger, + title, +}: RedemptionFormProps) { + 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 ( + + + + + + + {title} + + +
+ + {isEdit && ( + ( + + {t("form.code", "Code")} + + { + form.setValue(field.name, value); + }} + placeholder={t("form.code", "Code")} + value={field.value} + /> + + + + )} + /> + )} + {!isEdit && ( + ( + + + {t("form.batchCount", "Batch Count")} + + + { + form.setValue(field.name, value); + }} + /> + + + + )} + /> + )} + ( + + + {t("form.subscribePlan", "Subscribe Plan")} + + + + 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} + /> + + + + )} + /> + ( + + + {t("form.unitTime", "Unit Time")} + + + + 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} + /> + + + + )} + /> + ( + + + {t("form.duration", "Duration")} + + + { + form.setValue(field.name, value); + }} + /> + + + + )} + /> + ( + + + {t("form.totalCount", "Total Count")} + + + { + form.setValue(field.name, value); + }} + /> + + + + )} + /> + + +
+ + + + +
+
+ ); +} diff --git a/apps/admin/src/sections/redemption/redemption-records.tsx b/apps/admin/src/sections/redemption/redemption-records.tsx new file mode 100644 index 0000000..b1a677d --- /dev/null +++ b/apps/admin/src/sections/redemption/redemption-records.tsx @@ -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([]); + 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 ( + + + + + {t("records", "Redemption Records")} + + +
+ {loading ? ( +
+ {t("loading", "Loading...")} +
+ ) : records.length === 0 ? ( +
+ {t("noRecords", "No records found")} +
+ ) : ( + <> + + + + ID + {t("userId", "User ID")} + {t("subscribeId", "Subscribe ID")} + {t("unitTime", "Unit Time")} + {t("quantity", "Quantity")} + {t("redeemedAt", "Redeemed At")} + + + + {records.map((record) => ( + + {record.id} + {record.user_id} + {record.subscribe_id} + {record.unit_time} + {record.quantity} + + {record.redeemed_at + ? formatDate(record.redeemed_at) + : "--"} + + + ))} + +
+ {total > pagination.size && ( +
+ + {t("total", "Total")}: {total} + +
+ + +
+
+ )} + + )} +
+
+
+ ); +}