336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getAdminRiskPools,
|
|
postAdminRiskPoolManualClose,
|
|
postAdminRiskPoolRecover,
|
|
} from "@/api/admin-risk";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
|
import { Button } from "@/components/ui/button";
|
|
import { buttonVariants } 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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|
import { formatAdminMinorUnits } from "@/lib/money";
|
|
import { cn } from "@/lib/utils";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
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;
|
|
}
|
|
|
|
type RiskFilter = "all" | "sold_out" | "high_risk";
|
|
|
|
type RiskPoolsConsoleProps = {
|
|
drawId: number;
|
|
title: string;
|
|
soldOutOnly: boolean;
|
|
defaultSort: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
|
allowSortChange?: boolean;
|
|
};
|
|
|
|
export function RiskPoolsConsole({
|
|
drawId,
|
|
title,
|
|
soldOutOnly,
|
|
defaultSort,
|
|
allowSortChange = false,
|
|
}: RiskPoolsConsoleProps) {
|
|
const { t } = useTranslation(["risk", "common"]);
|
|
useAdminCurrencyCatalog();
|
|
const [sort, setSort] = useState(defaultSort);
|
|
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
|
|
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",
|
|
normalized_number: number.trim(),
|
|
sort,
|
|
});
|
|
setData(d);
|
|
} catch (e) {
|
|
const msg =
|
|
e instanceof LotteryApiBizError ? e.message : t("loadPoolsFailed");
|
|
setError(msg);
|
|
setData(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [drawId, filter, number, page, perPage, sort, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
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 space-y-3">
|
|
<CardTitle className="admin-list-title">{title}</CardTitle>
|
|
<div className="admin-list-toolbar">
|
|
<div className="admin-list-field">
|
|
<Label htmlFor="risk-pool-number" className="sm:w-16 sm:shrink-0">
|
|
{t("searchNumber")}
|
|
</Label>
|
|
<Input
|
|
id="risk-pool-number"
|
|
value={number}
|
|
maxLength={4}
|
|
placeholder={t("searchNumberPlaceholder")}
|
|
className="h-8 w-full font-mono sm:w-52"
|
|
onChange={(event) => {
|
|
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
|
|
setPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="admin-list-field">
|
|
<Label className="sm:w-16 sm:shrink-0">{t("riskFilter")}</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
["all", t("filterAll")],
|
|
["sold_out", t("filterSoldOut")],
|
|
["high_risk", t("filterHighRisk")],
|
|
].map(([value, label]) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
size="sm"
|
|
variant={filter === value ? "default" : "outline"}
|
|
onClick={() => {
|
|
setFilter(value as RiskFilter);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{allowSortChange ? (
|
|
<div className="admin-list-field">
|
|
<Label htmlFor="risk-pool-sort" className="sm:w-10 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="w-full sm:w-52">
|
|
<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={title}
|
|
sheetName="风险池"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content">
|
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
{loading && !data ? (
|
|
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
) : (
|
|
<>
|
|
<div className="admin-table-shell">
|
|
<Table id={`risk-pools-table-${drawId}`}>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("searchNumber")}</TableHead>
|
|
<TableHead className="text-right">{t("capAmount")}</TableHead>
|
|
<TableHead className="text-right">{t("lockedAmount")}</TableHead>
|
|
<TableHead className="text-right">{t("remainingAmount")}</TableHead>
|
|
<TableHead className="text-right">{t("usageRatio")}</TableHead>
|
|
<TableHead>{t("poolStatus")}</TableHead>
|
|
<TableHead className="text-right">{t("actions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(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-right text-sm tabular-nums">
|
|
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
|
|
</TableCell>
|
|
<TableCell className="text-right text-sm tabular-nums">
|
|
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
|
|
</TableCell>
|
|
<TableCell className="text-right text-sm tabular-nums">
|
|
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
|
|
</TableCell>
|
|
<TableCell className="text-right 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="text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={row.is_sold_out ? "outline" : "destructive"}
|
|
disabled={acting}
|
|
onClick={() => void handleManualStatus(row)}
|
|
>
|
|
{row.is_sold_out ? t("recover") : t("close")}
|
|
</Button>
|
|
<Link
|
|
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
|
|
className={cn(
|
|
buttonVariants({ variant: "link", size: "sm" }),
|
|
"h-8 px-0",
|
|
)}
|
|
>
|
|
{t("view")}
|
|
</Link>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{data ? (
|
|
<AdminListPaginationFooter
|
|
selectId={`risk-pools-${drawId}-${soldOutOnly ? "so" : "all"}`}
|
|
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>
|
|
);
|
|
}
|