505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
} from "@workspace/ui/components/alert";
|
|
import { Badge } from "@workspace/ui/components/badge";
|
|
import { Button } from "@workspace/ui/components/button";
|
|
import { Switch } from "@workspace/ui/components/switch";
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "@workspace/ui/components/tabs";
|
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
|
import Empty from "@workspace/ui/composed/empty";
|
|
import {
|
|
ProTable,
|
|
type ProTableActions,
|
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
|
import {
|
|
createPromoPrice,
|
|
createPromoRule,
|
|
deletePromoPrice,
|
|
deletePromoRule,
|
|
getPromoPriceList,
|
|
getPromoRuleList,
|
|
getPromoUsageList,
|
|
updatePromoRule,
|
|
} from "@workspace/ui/services/admin/promo";
|
|
import { CircleAlert } from "lucide-react";
|
|
import { useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { useSubscribe } from "@/stores/subscribe";
|
|
import { formatDate } from "@/utils/common";
|
|
import PriceForm from "./price-form";
|
|
import RuleDetail from "./rule-detail";
|
|
import RuleForm from "./rule-form";
|
|
import { formatCurrency, parseOptionalNumber, promoRuleTypes } from "./utils";
|
|
|
|
type RuleFilters = {
|
|
search?: string;
|
|
type?: API.PromoRuleType;
|
|
enabled?: string;
|
|
};
|
|
|
|
type PriceFilters = {
|
|
promo_rule_id?: string;
|
|
subscribe_id?: string;
|
|
};
|
|
|
|
type UsageFilters = PriceFilters & {
|
|
user_id?: string;
|
|
order_no?: string;
|
|
};
|
|
|
|
function ErrorState({
|
|
title,
|
|
description,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<Alert className="mx-auto max-w-md" variant="destructive">
|
|
<CircleAlert aria-hidden="true" />
|
|
<AlertTitle>{title}</AlertTitle>
|
|
<AlertDescription>{description}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
export default function PromoPage() {
|
|
const { t } = useTranslation("promo");
|
|
const { subscribes, getSubscribeName } = useSubscribe();
|
|
const [loading, setLoading] = useState(false);
|
|
const [rules, setRules] = useState<API.PromoRule[]>([]);
|
|
const [detailRule, setDetailRule] = useState<API.PromoRule>();
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const ruleRef = useRef<ProTableActions>(null);
|
|
const priceRef = useRef<ProTableActions>(null);
|
|
const usageRef = useRef<ProTableActions>(null);
|
|
|
|
return (
|
|
<>
|
|
<Tabs className="space-y-4" defaultValue="rules">
|
|
<TabsList className="grid w-full grid-cols-3 md:w-fit">
|
|
<TabsTrigger value="rules">{t("rules", "Rules")}</TabsTrigger>
|
|
<TabsTrigger value="prices">{t("prices", "Prices")}</TabsTrigger>
|
|
<TabsTrigger value="usage">{t("usage", "Usage")}</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="rules">
|
|
<ProTable<API.PromoRule, RuleFilters>
|
|
action={ruleRef}
|
|
actions={{
|
|
render: (row) => [
|
|
<Button
|
|
key="detail"
|
|
onClick={() => {
|
|
setDetailRule(row);
|
|
setDetailOpen(true);
|
|
}}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
{t("detail", "Detail")}
|
|
</Button>,
|
|
<RuleForm
|
|
initialValues={row}
|
|
key="edit"
|
|
loading={loading}
|
|
onSubmit={async (values) => {
|
|
setLoading(true);
|
|
try {
|
|
await updatePromoRule({ id: row.id }, values);
|
|
toast.success(t("updateSuccess", "Update Success"));
|
|
ruleRef.current?.refresh();
|
|
setLoading(false);
|
|
return true;
|
|
} catch (_error) {
|
|
setLoading(false);
|
|
return false;
|
|
}
|
|
}}
|
|
title={t("editRule", "Edit Rule")}
|
|
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 deletePromoRule({ id: row.id });
|
|
toast.success(t("deleteSuccess", "Delete Success"));
|
|
ruleRef.current?.refresh();
|
|
}}
|
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
|
trigger={
|
|
<Button size="sm" variant="destructive">
|
|
{t("delete", "Delete")}
|
|
</Button>
|
|
}
|
|
/>,
|
|
],
|
|
}}
|
|
columns={[
|
|
{
|
|
accessorKey: "name",
|
|
header: t("name", "Name"),
|
|
},
|
|
{
|
|
accessorKey: "type",
|
|
header: t("type", "Type"),
|
|
cell: ({ row }) => (
|
|
<Badge variant="outline">
|
|
{t(`types.${row.original.type}`, row.original.type)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "priority",
|
|
header: t("priority", "Priority"),
|
|
},
|
|
{
|
|
accessorKey: "enabled",
|
|
header: t("enabled", "Enabled"),
|
|
cell: ({ row }) => (
|
|
<Switch
|
|
defaultChecked={row.original.enabled}
|
|
onCheckedChange={async (checked) => {
|
|
await updatePromoRule(
|
|
{ id: row.original.id },
|
|
{ ...row.original, enabled: checked }
|
|
);
|
|
toast.success(t("updateSuccess", "Update Success"));
|
|
ruleRef.current?.refresh();
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "start_time",
|
|
header: t("startTime", "Start Time"),
|
|
cell: ({ row }) =>
|
|
row.original.start_time
|
|
? formatDate(row.original.start_time)
|
|
: "--",
|
|
},
|
|
{
|
|
accessorKey: "end_time",
|
|
header: t("endTime", "End Time"),
|
|
cell: ({ row }) =>
|
|
row.original.end_time
|
|
? formatDate(row.original.end_time)
|
|
: "--",
|
|
},
|
|
]}
|
|
empty={<Empty description={t("emptyRules", "No promo rules")} />}
|
|
error={
|
|
<ErrorState
|
|
description={t(
|
|
"loadErrorDescription",
|
|
"Refresh the table or try again later."
|
|
)}
|
|
title={t("loadRulesError", "Failed to load promo rules")}
|
|
/>
|
|
}
|
|
header={{
|
|
toolbar: (
|
|
<RuleForm
|
|
loading={loading}
|
|
onSubmit={async (values) => {
|
|
setLoading(true);
|
|
try {
|
|
await createPromoRule(values);
|
|
toast.success(t("createSuccess", "Create Success"));
|
|
ruleRef.current?.refresh();
|
|
setLoading(false);
|
|
return true;
|
|
} catch (_error) {
|
|
setLoading(false);
|
|
return false;
|
|
}
|
|
}}
|
|
title={t("createRule", "Create Rule")}
|
|
trigger={t("createRule", "Create Rule")}
|
|
/>
|
|
),
|
|
}}
|
|
params={[
|
|
{ key: "search", placeholder: t("searchRule", "Rule name") },
|
|
{
|
|
key: "type",
|
|
placeholder: t("type", "Type"),
|
|
options: promoRuleTypes.map((type) => ({
|
|
label: t(`types.${type}`, type),
|
|
value: type,
|
|
})),
|
|
},
|
|
{
|
|
key: "enabled",
|
|
placeholder: t("enabled", "Enabled"),
|
|
options: [
|
|
{ label: t("yes", "Yes"), value: "true" },
|
|
{ label: t("no", "No"), value: "false" },
|
|
],
|
|
},
|
|
]}
|
|
request={async (pagination, filters) => {
|
|
const { data } = await getPromoRuleList({
|
|
...pagination,
|
|
search: filters.search?.trim() || undefined,
|
|
type: filters.type,
|
|
enabled:
|
|
filters.enabled === undefined || filters.enabled === ""
|
|
? undefined
|
|
: filters.enabled === "true",
|
|
});
|
|
const list = data.data?.list || [];
|
|
setRules(list);
|
|
return {
|
|
list,
|
|
total: data.data?.total || 0,
|
|
};
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="prices">
|
|
<ProTable<API.PromoPrice, PriceFilters>
|
|
action={priceRef}
|
|
actions={{
|
|
render: (row) => [
|
|
<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 deletePromoPrice({ id: row.id });
|
|
toast.success(t("deleteSuccess", "Delete Success"));
|
|
priceRef.current?.refresh();
|
|
}}
|
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
|
trigger={
|
|
<Button size="sm" variant="destructive">
|
|
{t("delete", "Delete")}
|
|
</Button>
|
|
}
|
|
/>,
|
|
],
|
|
}}
|
|
columns={[
|
|
{
|
|
accessorKey: "promo_rule_id",
|
|
header: t("rule", "Rule"),
|
|
cell: ({ row }) =>
|
|
row.original.promo_rule_id
|
|
? rules.find(
|
|
(rule) => rule.id === row.original.promo_rule_id
|
|
)?.name || `#${row.original.promo_rule_id}`
|
|
: "--",
|
|
},
|
|
{
|
|
accessorKey: "subscribe_id",
|
|
header: t("subscribe", "Subscribe"),
|
|
cell: ({ row }) =>
|
|
row.original.subscribe_name ||
|
|
getSubscribeName(row.original.subscribe_id),
|
|
},
|
|
{
|
|
accessorKey: "quantity",
|
|
header: t("quantity", "Quantity"),
|
|
cell: ({ row }) => row.original.quantity || "--",
|
|
},
|
|
{
|
|
accessorKey: "unit_price",
|
|
header: t("unitPrice", "Unit Price"),
|
|
cell: ({ row }) => formatCurrency(row.original.unit_price),
|
|
},
|
|
{
|
|
accessorKey: "promo_price",
|
|
header: t("promoPrice", "Promo Price"),
|
|
cell: ({ row }) => formatCurrency(row.original.promo_price),
|
|
},
|
|
]}
|
|
empty={<Empty description={t("emptyPrices", "No promo prices")} />}
|
|
error={
|
|
<ErrorState
|
|
description={t(
|
|
"loadErrorDescription",
|
|
"Refresh the table or try again later."
|
|
)}
|
|
title={t("loadPricesError", "Failed to load promo prices")}
|
|
/>
|
|
}
|
|
header={{
|
|
toolbar: (
|
|
<PriceForm
|
|
loading={loading}
|
|
onSubmit={async (values) => {
|
|
setLoading(true);
|
|
try {
|
|
await createPromoPrice(values);
|
|
toast.success(t("createSuccess", "Create Success"));
|
|
priceRef.current?.refresh();
|
|
setLoading(false);
|
|
return true;
|
|
} catch (_error) {
|
|
setLoading(false);
|
|
return false;
|
|
}
|
|
}}
|
|
rules={rules}
|
|
subscribes={subscribes}
|
|
/>
|
|
),
|
|
}}
|
|
params={[
|
|
{
|
|
key: "promo_rule_id",
|
|
placeholder: t("rule", "Rule"),
|
|
options: rules.map((rule) => ({
|
|
label: rule.name,
|
|
value: String(rule.id),
|
|
})),
|
|
},
|
|
{
|
|
key: "subscribe_id",
|
|
placeholder: t("subscribe", "Subscribe"),
|
|
options: subscribes
|
|
.filter((item) => typeof item.id === "number")
|
|
.map((item) => ({
|
|
label: item.name || `#${item.id}`,
|
|
value: String(item.id),
|
|
})),
|
|
},
|
|
]}
|
|
request={async (pagination, filters) => {
|
|
const { data } = await getPromoPriceList({
|
|
...pagination,
|
|
promo_rule_id: parseOptionalNumber(filters.promo_rule_id),
|
|
subscribe_id: parseOptionalNumber(filters.subscribe_id),
|
|
});
|
|
return {
|
|
list: data.data?.list || [],
|
|
total: data.data?.total || 0,
|
|
};
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="usage">
|
|
<ProTable<API.PromoUsage, UsageFilters>
|
|
action={usageRef}
|
|
columns={[
|
|
{
|
|
accessorKey: "promo_rule_id",
|
|
header: t("rule", "Rule"),
|
|
cell: ({ row }) =>
|
|
row.original.rule_name ||
|
|
rules.find((rule) => rule.id === row.original.promo_rule_id)
|
|
?.name ||
|
|
`#${row.original.promo_rule_id}`,
|
|
},
|
|
{
|
|
accessorKey: "user_id",
|
|
header: t("userId", "User ID"),
|
|
cell: ({ row }) => `#${row.original.user_id}`,
|
|
},
|
|
{
|
|
accessorKey: "subscribe_id",
|
|
header: t("subscribe", "Subscribe"),
|
|
cell: ({ row }) =>
|
|
row.original.subscribe_name ||
|
|
getSubscribeName(row.original.subscribe_id),
|
|
},
|
|
{
|
|
accessorKey: "quantity",
|
|
header: t("quantity", "Quantity"),
|
|
cell: ({ row }) => row.original.quantity || "--",
|
|
},
|
|
{
|
|
accessorKey: "order_no",
|
|
header: t("orderNo", "Order No."),
|
|
},
|
|
{
|
|
accessorKey: "promo_price",
|
|
header: t("promoPrice", "Promo Price"),
|
|
cell: ({ row }) => formatCurrency(row.original.promo_price),
|
|
},
|
|
{
|
|
accessorKey: "used_at",
|
|
header: t("usedAt", "Used At"),
|
|
cell: ({ row }) => formatDate(row.original.used_at),
|
|
},
|
|
]}
|
|
empty={
|
|
<Empty description={t("emptyUsage", "No promo usage records")} />
|
|
}
|
|
error={
|
|
<ErrorState
|
|
description={t(
|
|
"loadErrorDescription",
|
|
"Refresh the table or try again later."
|
|
)}
|
|
title={t("loadUsageError", "Failed to load promo usage")}
|
|
/>
|
|
}
|
|
params={[
|
|
{
|
|
key: "promo_rule_id",
|
|
placeholder: t("rule", "Rule"),
|
|
options: rules.map((rule) => ({
|
|
label: rule.name,
|
|
value: String(rule.id),
|
|
})),
|
|
},
|
|
{
|
|
key: "subscribe_id",
|
|
placeholder: t("subscribe", "Subscribe"),
|
|
options: subscribes
|
|
.filter((item) => typeof item.id === "number")
|
|
.map((item) => ({
|
|
label: item.name || `#${item.id}`,
|
|
value: String(item.id),
|
|
})),
|
|
},
|
|
{ key: "user_id", placeholder: t("userId", "User ID") },
|
|
{ key: "order_no", placeholder: t("orderNo", "Order No.") },
|
|
]}
|
|
request={async (pagination, filters) => {
|
|
const { data } = await getPromoUsageList({
|
|
...pagination,
|
|
promo_rule_id: parseOptionalNumber(filters.promo_rule_id),
|
|
subscribe_id: parseOptionalNumber(filters.subscribe_id),
|
|
user_id: parseOptionalNumber(filters.user_id),
|
|
order_no: filters.order_no?.trim() || undefined,
|
|
});
|
|
return {
|
|
list: data.data?.list || [],
|
|
total: data.data?.total || 0,
|
|
};
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
<RuleDetail
|
|
onOpenChange={setDetailOpen}
|
|
open={detailOpen}
|
|
rule={detailRule}
|
|
/>
|
|
</>
|
|
);
|
|
}
|