Files
lotteryAdmin/src/modules/risk/risk-pools-console.tsx
kang 641c87ff50 feat(docs, agents, risk): enhance documentation, API queries, and UI components
Updated the public documentation site with improved layout and accessibility, including new sections for client integration and admin guides. Enhanced API queries by adding 'active_only' and 'group_by' parameters for better data filtering in risk management. Refined UI components for agent management, ensuring consistent styling and improved user experience across the application. Added localization support for new documentation content in English and Nepali.
2026-06-15 17:21:50 +08:00

395 lines
16 KiB
TypeScript

"use client";
import { Eye, Lock, Unlock } from "lucide-react";
import { useCallback, 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 {
getAdminRiskPools,
postAdminRiskPoolManualClose,
postAdminRiskPoolRecover,
} from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_RESULT_MANAGE, PRD_RISK_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { RiskPoolsPageTitleKey } from "@/modules/risk/risk-display";
import type { AdminRiskPoolListData, AdminRiskPoolRow } from "@/types/api/admin-risk";
const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc"; label: string }[] =
[
{ value: "usage_desc", label: "sortUsageDesc" },
{ value: "locked_desc", label: "sortLockedDesc" },
{ value: "remaining_asc", label: "sortRemainingAsc" },
{ value: "number_asc", label: "sortNumberAsc" },
];
function riskSortLabel(
value: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc",
t: (key: string) => string,
): string {
const option = SORT_OPTIONS.find((item) => item.value === value);
return option ? t(option.label) : value;
}
export type RiskPoolListFilter = "all" | "active" | "sold_out" | "high_risk";
type RiskPoolsConsoleProps = {
drawId: number;
/** @deprecated 优先使用 titleKey */
title?: string;
titleKey?: RiskPoolsPageTitleKey;
/** @deprecated 使用 initialFilter */
soldOutOnly?: boolean;
initialFilter?: RiskPoolListFilter;
defaultSort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
allowSortChange?: boolean;
};
function resolveInitialFilter(
initialFilter: RiskPoolListFilter | undefined,
soldOutOnly: boolean | undefined,
): RiskPoolListFilter {
if (initialFilter) {
return initialFilter;
}
if (soldOutOnly) {
return "sold_out";
}
return "active";
}
export function RiskPoolsConsole({
drawId,
title,
titleKey,
soldOutOnly,
initialFilter: initialFilterProp,
defaultSort = "usage_desc",
allowSortChange = true,
}: RiskPoolsConsoleProps) {
const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
PRD_RISK_MANAGE,
PRD_DRAW_RESULT_MANAGE,
]);
const initialFilter = resolveInitialFilter(initialFilterProp, soldOutOnly);
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
const exportLabels = useExportLabels("riskPools");
useAdminCurrencyCatalog();
const [sort, setSort] = useState(defaultSort);
const [filter, setFilter] = useState<RiskPoolListFilter>(initialFilter);
const [number, setNumber] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [data, setData] = useState<AdminRiskPoolListData | null>(null);
const [loading, setLoading] = useState(true);
const [actingNumber, setActingNumber] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const d = await getAdminRiskPools(drawId, {
page,
per_page: perPage,
sold_out_only: filter === "sold_out",
high_risk_only: filter === "high_risk",
active_only: filter === "active" && number.trim() === "",
normalized_number: number.trim(),
sort,
});
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("loadPoolsFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, filter, number, page, perPage, sort]);
useAsyncEffect(() => {
void load();
}, [drawId, filter, number, page, perPage, sort]);
const handleManualStatus = useCallback(
async (row: AdminRiskPoolRow) => {
setActingNumber(row.normalized_number);
try {
const updated = row.is_sold_out
? await postAdminRiskPoolRecover(drawId, row.normalized_number)
: await postAdminRiskPoolManualClose(drawId, row.normalized_number);
setData((current) => {
if (!current) return current;
return {
...current,
items: current.items.map((item) =>
item.normalized_number === updated.normalized_number ? updated : item,
),
};
});
toast.success(row.is_sold_out ? t("recoverSuccess") : t("manualCloseSuccess"));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed"));
} finally {
setActingNumber(null);
}
},
[drawId, t],
);
return (
<>
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="risk-pool-number" className="sm:w-20 sm:shrink-0">
{t("searchNumber")}
</Label>
<Input
id="risk-pool-number"
inputMode="numeric"
value={number}
maxLength={4}
placeholder={t("searchNumberPlaceholder")}
className="h-8 w-full font-mono sm:w-32"
onChange={(event) => {
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
setPage(1);
}}
/>
</div>
<div className="admin-list-field">
<Label className="sm:w-20 sm:shrink-0">{t("riskFilter")}</Label>
<Select
modal={false}
value={filter}
onValueChange={(v) => {
if (!v) return;
setFilter(v as RiskPoolListFilter);
setPage(1);
}}
>
<SelectTrigger id="risk-pool-filter" size="sm" className="h-8 w-full sm:w-40">
<SelectValue>
{filter === "all"
? t("filterAll")
: filter === "active"
? t("filterActive")
: filter === "sold_out"
? t("filterSoldOut")
: t("filterHighRisk")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t("filterActive")}</SelectItem>
<SelectItem value="all">{t("filterAll")}</SelectItem>
<SelectItem value="sold_out">{t("filterSoldOut")}</SelectItem>
<SelectItem value="high_risk">{t("filterHighRisk")}</SelectItem>
</SelectContent>
</Select>
</div>
{allowSortChange ? (
<div className="admin-list-field">
<Label htmlFor="risk-pool-sort" className="sm:w-20 sm:shrink-0">
{t("sort")}
</Label>
<Select
modal={false}
value={sort}
onValueChange={(v) => {
if (!v) return;
setSort(v as typeof sort);
setPage(1);
}}
>
<SelectTrigger id="risk-pool-sort" size="sm" className="h-8 w-full sm:w-44">
<SelectValue>{riskSortLabel(sort, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="admin-list-actions">
<AdminTableExportButton
tableId={`risk-pools-table-${drawId}`}
filename={pageTitle ?? exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<>
<div className="admin-table-shell">
<Table id={`risk-pools-table-${drawId}`}>
<TableHeader>
<TableRow>
<TableHead>{t("searchNumber")}</TableHead>
<TableHead className="text-center">{t("capAmount")}</TableHead>
<TableHead className="text-center">{t("lockedAmount")}</TableHead>
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
<TableHead className="text-center">{t("usageRatio")}</TableHead>
<TableHead>{t("poolStatus")}</TableHead>
<TableHead className="sticky right-0 z-10 text-center">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
const acting = actingNumber === row.normalized_number;
const currencyCode = data?.currency_code ?? "NPR";
return (
<TableRow
key={row.normalized_number}
className={cn(
row.is_sold_out
? "bg-red-50/90 hover:bg-red-50 dark:bg-red-950/25 dark:hover:bg-red-950/35"
: highRisk
? "bg-orange-50/90 hover:bg-orange-50 dark:bg-orange-950/25 dark:hover:bg-orange-950/35"
: null,
)}
>
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
</TableCell>
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
</TableCell>
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
</TableCell>
<TableCell className="text-center text-sm tabular-nums">
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex h-6 items-center rounded px-2 text-xs font-medium",
row.is_sold_out
? "bg-red-600 text-white"
: highRisk
? "bg-orange-500 text-white"
: "bg-muted text-muted-foreground",
)}
>
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
</span>
</TableCell>
<TableCell className="sticky right-0 z-10 text-center">
<AdminRowActionsMenu
busy={acting}
actions={[
{
key: "view",
label: t("view"),
icon: Eye,
href: `/admin/draws/${drawId}/risk/pools/${row.normalized_number}`,
},
{
key: "toggle",
label: row.is_sold_out ? t("recover") : t("close"),
icon: row.is_sold_out ? Unlock : Lock,
destructive: !row.is_sold_out,
hidden: !canManageRiskPools,
onClick: () =>
requestConfirm({
title: row.is_sold_out
? t("confirm.recoverTitle")
: t("confirm.closeTitle"),
description: row.is_sold_out
? t("confirm.recoverDescription", {
number: row.normalized_number,
})
: t("confirm.closeDescription", {
number: row.normalized_number,
}),
confirmVariant: row.is_sold_out ? "default" : "destructive",
onConfirm: () => handleManualStatus(row),
}),
},
]}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId={`risk-pools-${drawId}-${filter}`}
total={data.meta.total}
page={data.meta.current_page}
lastPage={data.meta.last_page}
perPage={data.meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
</CardContent>
</Card>
<ConfirmDialog />
</>
);
}