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.
395 lines
16 KiB
TypeScript
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 />
|
|
</>
|
|
);
|
|
}
|