Files
lotteryAdmin/src/modules/config/risk-cap-runtime-panel.tsx
kang af982bb9f7 feat(api, agents, i18n): enhance settlement features and multi-language support
Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
2026-06-05 18:00:59 +08:00

291 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import { getAdminDraws } from "@/api/admin-draws";
import { getAdminRiskPools } from "@/api/admin-risk";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigSection } from "@/modules/config/config-section";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListItem } from "@/types/api/admin-draws";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
type PoolFilter = "all" | "sold_out" | "high_risk";
export function RiskCapRuntimePanel() {
const { t } = useTranslation(["config", "risk", "draws", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
const [drawsLoading, setDrawsLoading] = useState(true);
const [drawId, setDrawId] = useState<string>("");
const [numberQ, setNumberQ] = useState("");
const [appliedNumber, setAppliedNumber] = useState("");
const [poolFilter, setPoolFilter] = useState<PoolFilter>("all");
const [pools, setPools] = useState<AdminRiskPoolRow[]>([]);
const [currencyCode, setCurrencyCode] = useState<string | null>(null);
const [poolsLoading, setPoolsLoading] = useState(false);
const [poolsError, setPoolsError] = useState<string | null>(null);
const selectedDraw = useMemo(
() => draws.find((d) => String(d.id) === drawId) ?? null,
[draws, drawId],
);
const loadDraws = useCallback(async () => {
setDrawsLoading(true);
try {
const data = await getAdminDraws({ page: 1, per_page: 50 });
setDraws(data.items);
if (data.items.length > 0) {
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDraws([]);
} finally {
setDrawsLoading(false);
}
}, []);
const loadPools = useCallback(async () => {
if (!drawId) {
setPools([]);
return;
}
const id = Number(drawId);
if (!Number.isFinite(id)) {
return;
}
setPoolsLoading(true);
setPoolsError(null);
try {
const data = await getAdminRiskPools(id, {
page: 1,
per_page: 200,
normalized_number: appliedNumber.trim() || undefined,
sold_out_only: poolFilter === "sold_out",
high_risk_only: poolFilter === "high_risk",
sort: poolFilter === "high_risk" ? "usage_desc" : "number_asc",
});
setPools(data.items);
setCurrencyCode(data.currency_code);
} catch (e) {
setPoolsError(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setPools([]);
} finally {
setPoolsLoading(false);
}
}, [appliedNumber, drawId, poolFilter]);
useAsyncEffect(() => {
void loadDraws();
}, []);
useAsyncEffect(() => {
void loadPools();
}, [appliedNumber, drawId, poolFilter]);
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
return (
<ConfigSection
title={t("riskCap.runtime.title", { ns: "config" })}
description={t("riskCap.runtime.description", { ns: "config" })}
>
<div className="flex flex-wrap items-end gap-3">
<div className="grid min-w-[12rem] flex-1 gap-1.5">
<Label htmlFor="risk-cap-draw">{t("riskCap.runtime.drawLabel", { ns: "config" })}</Label>
<Select
value={drawId}
onValueChange={(v) => setDrawId(v == null ? "" : String(v))}
disabled={drawsLoading || draws.length === 0}
>
<SelectTrigger id="risk-cap-draw" className="font-mono">
<SelectValue>
{(v) => {
if (v == null || v === "") {
return t("riskCap.runtime.drawPlaceholder", { ns: "config" });
}
const draw = draws.find((d) => String(d.id) === String(v));
return draw ? `${draw.draw_no} · ${draw.status}` : String(v);
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{draws.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.draw_no} · {d.status}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{riskBase ? (
<div className="flex flex-wrap gap-2 pb-0.5">
<Link href={`${riskBase}/pools`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("subnav.riskPools", { ns: "draws" })}
</Link>
<Link
href={`${riskBase}/pools?filter=high_risk`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("filterHighRisk", { ns: "risk" })}
</Link>
<Link
href={`${riskBase}/pools?filter=sold_out`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("filterSoldOut", { ns: "risk" })}
</Link>
<Link href={`${riskBase}/occupancy`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("subnav.riskLockLogs", { ns: "draws" })}
</Link>
</div>
) : null}
</div>
{drawId ? (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="grid gap-1.5">
<Label htmlFor="risk-cap-number-q">{t("riskCap.occupancy.searchLabel", { ns: "config" })}</Label>
<Input
id="risk-cap-number-q"
className="w-[140px] font-mono"
placeholder={t("riskCap.occupancy.searchPlaceholder", { ns: "config" })}
value={numberQ}
onChange={(e) => setNumberQ(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2">
{(
[
{ id: "all", label: t("riskCap.runtime.filterAll", { ns: "config" }) },
{ id: "sold_out", label: t("riskCap.runtime.filterSoldOut", { ns: "config" }) },
{ id: "high_risk", label: t("riskCap.runtime.filterHighRisk", { ns: "config" }) },
] as const
).map((f) => (
<Button
key={f.id}
type="button"
size="sm"
variant={poolFilter === f.id ? "default" : "outline"}
onClick={() => setPoolFilter(f.id)}
>
{f.label}
</Button>
))}
</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setAppliedNumber(numberQ.trim());
}}
>
{t("actions.search", { ns: "common" })}
</Button>
<Button type="button" size="sm" variant="outline" disabled={poolsLoading} onClick={() => void loadPools()}>
{t("versionActions.refresh", { ns: "config" })}
</Button>
{pools.length > 0 ? (
<AdminTableExportButton
tableId="risk-cap-runtime-pools"
filename={`risk-pools-${selectedDraw?.draw_no ?? drawId}`}
/>
) : null}
</div>
{poolsError ? <p className="text-sm text-destructive">{poolsError}</p> : null}
<div className="admin-table-shell">
<Table id="risk-cap-runtime-pools">
<TableHeader>
<TableRow>
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{poolsLoading ? (
<AdminTableLoadingRow colSpan={5} />
) : pools.length === 0 ? (
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
) : (
pools.map((row) => (
<TableRow
key={row.normalized_number}
className={cn(
row.is_sold_out && "bg-destructive/5",
!row.is_sold_out && (row.usage_ratio ?? 0) >= 0.8 && "bg-amber-500/10",
)}
>
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{formatAdminMinorUnits(row.locked_amount, currencyCode ?? undefined)}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{row.usage_ratio != null ? `${Math.round(row.usage_ratio * 100)}%` : "—"}
</TableCell>
<TableCell className="text-center text-xs">
{row.is_sold_out
? t("riskCap.runtime.soldYes", { ns: "config" })
: t("riskCap.runtime.soldNo", { ns: "config" })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<p className="text-xs text-muted-foreground">
{t("riskCap.runtime.manageHint", { ns: "config" })}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("riskCap.runtime.noDraws", { ns: "config" })}</p>
)}
</ConfigSection>
);
}