diff --git a/apps/admin/src/sections/user/user-subscription/index.tsx b/apps/admin/src/sections/user/user-subscription/index.tsx index 36793e0..999a704 100644 --- a/apps/admin/src/sections/user/user-subscription/index.tsx +++ b/apps/admin/src/sections/user/user-subscription/index.tsx @@ -32,11 +32,27 @@ import { formatDate } from "@/utils/common"; import { SubscriptionDetail } from "./subscription-detail"; import { SubscriptionForm } from "./subscription-form"; +type SubscriptionFormPayload = Parameters< + React.ComponentProps["onSubmit"] +>[0]; + interface SharedInfo { ownerUserId: number; familyId: number; } +function toUserSubscribePayload(values: SubscriptionFormPayload) { + return { + subscribe_id: values.subscribe_id ?? 0, + traffic: values.traffic ?? 0, + expired_at: values.expired_at ?? 0, + upload: values.upload ?? 0, + download: values.download ?? 0, + speed_limit: values.speed_limit ?? 0, + traffic_limit: values.traffic_limit ?? [], + }; +} + export default function UserSubscription({ userId }: { userId: number }) { const { t } = useTranslation("user"); const [loading, setLoading] = useState(false); @@ -164,9 +180,8 @@ export default function UserSubscription({ userId }: { userId: number }) { onSubmit={async (values) => { setLoading(true); await updateUserSubscribe({ - user_id: Number(userId), user_subscribe_id: row.id, - ...values, + ...toUserSubscribePayload(values), }); toast.success(t("updateSuccess", "Updated successfully")); ref.current?.refresh(); @@ -352,7 +367,7 @@ export default function UserSubscription({ userId }: { userId: number }) { setLoading(true); await createUserSubscribe({ user_id: Number(userId), - ...values, + ...toUserSubscribePayload(values), }); toast.success(t("createSuccess", "Created successfully")); ref.current?.refresh(); diff --git a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx index 51a2085..01dbe8f 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx @@ -20,83 +20,189 @@ import { AlertTriangle, Gauge } from "lucide-react"; import { type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { Display } from "@/components/display"; import { IpLink } from "@/components/ip-link"; import { formatDate } from "@/utils/common"; +function formatRules(rules?: API.TrafficLimitRule[]) { + return rules && rules.length > 0 ? rules : []; +} + function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { const { t } = useTranslation("user"); const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); useEffect(() => { - getUserSubscribeById({ id: subscriptionId }).then(({ data }) => { - if (data.data) setDetail(data.data); - }); + setLoading(true); + setError(false); + getUserSubscribeById({ id: subscriptionId }) + .then(({ data }) => { + setDetail(data.data || null); + }) + .catch(() => { + setError(true); + setDetail(null); + }) + .finally(() => { + setLoading(false); + }); }, [subscriptionId]); - if (!detail || detail.status !== 1) return null; + if (loading) { + return ( +
+ {t("loading", "Loading")}... +
+ ); + } + + if (error) { + return ( +
+ {t("loadFailed", "Failed to load data")} +
+ ); + } + + if (!detail) { + return ( +
+ {t("noSubscriptionDetail", "No subscription detail")} +
+ ); + } const baseSpeed = detail.subscribe?.speed_limit ?? 0; + const userSpeed = detail.speed_limit ?? 0; const effectiveSpeed = detail.effective_speed ?? 0; const isThrottled = detail.is_throttled; - - if (baseSpeed === 0 && !isThrottled) return null; + const packageRules = formatRules(detail.subscribe?.traffic_limit); + const userRules = formatRules(detail.traffic_limit); return (
- {isThrottled ? ( - - ) : ( - - )} -
-
- {isThrottled ? ( - <> - {t("throttled", "Speed Throttled")} - - {effectiveSpeed} Mbps - - {baseSpeed > 0 && ( - - {baseSpeed} Mbps - - )} - - ) : ( - <> - - {t("speedLimit", "Speed Limit")} - - - {effectiveSpeed > 0 - ? `${effectiveSpeed} Mbps` - : t("unlimited", "Unlimited")} - - +
+ {isThrottled ? ( + + ) : ( + + )} +
+
+ + {isThrottled + ? t("throttled", "Speed Throttled") + : t("speedLimitOverview", "Speed Limit Overview")} + + + {effectiveSpeed > 0 + ? `${effectiveSpeed} Mbps` + : t("unlimited", "Unlimited")} + +
+ {isThrottled && detail.throttle_rule && ( +

+ {detail.throttle_rule} +

+ )} + {isThrottled && detail.throttle_start && detail.throttle_end && ( +

+ {formatDate(detail.throttle_start)} ~{" "} + {formatDate(detail.throttle_end)} +

)}
- {isThrottled && detail.throttle_rule && ( -

- {detail.throttle_rule} -

- )} - {isThrottled && detail.throttle_start && detail.throttle_end && ( -

- {formatDate(detail.throttle_start)} ~{" "} - {formatDate(detail.throttle_end)} -

- )} +
+ +
+ {[ + { + label: t("packageSpeedLimit", "Package Speed Limit"), + value: , + }, + { + label: t("userSpeedOverride", "User Speed Override"), + value: , + }, + { + label: t("effectiveSpeedLimit", "Effective Speed Limit"), + value: ( + + ), + }, + ].map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ +
+ +
); } +function TrafficRulesBlock({ + title, + rules, + emptyText, +}: { + title: string; + rules: API.TrafficLimitRule[]; + emptyText: string; +}) { + return ( +
+
{title}
+ {rules.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {rules.map((rule, index) => ( +
+
+ + {rule.stat_value} {rule.stat_type} + + {rule.traffic_usage} GB + {rule.speed_limit} Mbps +
+
+ ))} +
+ )} +
+ ); +} export function SubscriptionDetail({ trigger, userId, @@ -157,9 +263,8 @@ export function SubscriptionDetail({ cell: ({ row }) => ( { - console.log("Switch:", checked); - }} + disabled + title={t("readOnly", "Read only")} /> ), }, diff --git a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx index 37d1f93..e0254d0 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx @@ -3,6 +3,7 @@ import { Button } from "@workspace/ui/components/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -17,8 +18,10 @@ import { SheetTitle, SheetTrigger, } from "@workspace/ui/components/sheet"; +import { Textarea } from "@workspace/ui/components/textarea"; import { Combobox } from "@workspace/ui/composed/combobox"; import { DatePicker } from "@workspace/ui/composed/date-picker"; +import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs"; import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; import { Icon } from "@workspace/ui/composed/icon"; import { unitConversion } from "@workspace/ui/utils/unit-conversions"; @@ -33,20 +36,67 @@ interface Props { title: string; loading?: boolean; initialData?: API.UserSubscribe; - onSubmit: (values: any) => Promise; + onSubmit: (values: SubscriptionFormValues) => Promise; } +const trafficLimitRuleSchema = z.object({ + stat_type: z.enum(["hour", "day"]), + stat_value: z.number().int().min(1), + traffic_usage: z.number().int().min(0), + speed_limit: z.number().int().min(0), +}); + const formSchema = z.object({ subscribe_id: z.number().optional(), traffic: z.number().optional(), speed_limit: z.number().optional(), - device_limit: z.number().optional(), + traffic_limit: z.array(trafficLimitRuleSchema).optional(), + traffic_limit_json: z.string().superRefine((value, ctx) => { + if (!value.trim()) return; + try { + const parsed: unknown = JSON.parse(value); + const result = z.array(trafficLimitRuleSchema).safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid traffic limit JSON", + }); + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid JSON", + }); + } + }), expired_at: z.number().nullish().optional(), upload: z.number().optional(), download: z.number().optional(), id: z.number().optional(), }); +type SubscriptionFormValues = z.infer; + +type TrafficLimitFormRule = z.infer; + +function parseTrafficLimitJson(value: string): API.TrafficLimitRule[] { + if (!value.trim()) return []; + const parsed: unknown = JSON.parse(value); + const result = z.array(trafficLimitRuleSchema).safeParse(parsed); + return result.success ? normalizeTrafficLimitRules(result.data) : []; +} + +function normalizeTrafficLimitRules( + rules?: TrafficLimitFormRule[] +): API.TrafficLimitRule[] { + return (rules || []).map((item) => ({ + stat_type: item.stat_type === "hour" ? "hour" : "day", + stat_value: Number(item.stat_value) || 1, + traffic_usage: Number(item.traffic_usage) || 0, + speed_limit: Number(item.speed_limit) || 0, + })); +} + export function SubscriptionForm({ trigger, title, @@ -57,11 +107,18 @@ export function SubscriptionForm({ const { t } = useTranslation("user"); const [open, setOpen] = useState(false); - const form = useForm({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { subscribe_id: initialData?.subscribe_id || 0, traffic: initialData?.traffic || 0, + speed_limit: initialData?.speed_limit || 0, + traffic_limit: normalizeTrafficLimitRules(initialData?.traffic_limit), + traffic_limit_json: JSON.stringify( + normalizeTrafficLimitRules(initialData?.traffic_limit), + null, + 2 + ), upload: initialData?.upload || 0, download: initialData?.download || 0, expired_at: initialData?.expire_time || 0, @@ -69,8 +126,18 @@ export function SubscriptionForm({ }, }); - const handleSubmit = async (values: any) => { - const success = await onSubmit(values); + const handleSubmit = async (values: SubscriptionFormValues) => { + const trafficLimitFromJson = parseTrafficLimitJson( + values.traffic_limit_json + ); + const success = await onSubmit({ + ...values, + traffic_limit: normalizeTrafficLimitRules( + trafficLimitFromJson.length > 0 + ? trafficLimitFromJson + : values.traffic_limit + ), + }); if (success) { setOpen(false); form.reset(); @@ -154,6 +221,33 @@ export function SubscriptionForm({ )} /> + ( + + {t("speedLimit", "Speed Limit")} + + { + form.setValue(field.name, value as number); + }} + suffix="Mbps" + /> + + + {t( + "userSpeedLimitDescription", + "Set a user-level speed override. Use 0 to inherit the subscription limit." + )} + + + + )} + /> )} /> + ( + + + {t("trafficLimitRules", "Traffic Limit Rules")} + + + + className="grid grid-cols-1 gap-3 md:grid-cols-4" + fields={[ + { + name: "stat_type", + type: "select", + placeholder: t("statType", "Statistics Type"), + options: [ + { label: t("hour", "Hour"), value: "hour" }, + { label: t("day", "Day"), value: "day" }, + ], + }, + { + name: "stat_value", + type: "number", + placeholder: t("statValue", "Time Value"), + min: 1, + }, + { + name: "traffic_usage", + type: "number", + placeholder: t( + "trafficUsageGb", + "Traffic Usage (GB)" + ), + min: 0, + }, + { + name: "speed_limit", + type: "number", + placeholder: t( + "speedLimitMbps", + "Speed Limit (Mbps)" + ), + min: 0, + }, + ]} + onChange={(items) => { + const normalized = + normalizeTrafficLimitRules(items); + field.onChange(normalized); + form.setValue( + "traffic_limit_json", + JSON.stringify(normalized, null, 2), + { shouldValidate: true } + ); + }} + value={field.value} + /> + + + {t( + "userTrafficLimitDescription", + "Optional user-level traffic throttle rules. Traffic usage is stored in GB for each rule." + )} + + + + )} + /> + ( + + + {t("trafficLimitJson", "Traffic Limit JSON")} + + +