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.
291 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|