feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates

Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
2026-06-04 18:01:05 +08:00
parent c2eac2fafc
commit 65eaeecf8c
139 changed files with 8852 additions and 1435 deletions

View File

@@ -11,6 +11,7 @@ import { getAdminPlayer } from "@/api/admin-player";
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { buttonVariants } from "@/components/ui/button";
@@ -29,6 +30,13 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
import { formatAdminMinorUnits } from "@/lib/money";
import {
isCreditFundingPlayer,
playerAuthSourceLabel,
playerFundingModeLabel,
playerShowsTransferOrders,
} from "@/lib/player-funding";
import { formatPlayerCreditAmount } from "@/lib/admin-player-display";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
@@ -209,7 +217,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
}, [player, loadTxns]);
useAsyncEffect(() => {
if (!player) return;
if (!player || !playerShowsTransferOrders(player)) return;
void loadTransfers();
}, [player, loadTransfers]);
@@ -217,6 +225,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
return <AdminLoadingState minHeight="8rem" className="py-8" />;
}
const isCreditPlayer = player ? isCreditFundingPlayer(player) : false;
const showTransferTab = player ? playerShowsTransferOrders(player) : false;
if (playerErr || !player) {
return (
<div className="space-y-4">
@@ -224,7 +235,11 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<ArrowLeft className="size-4" aria-hidden />
{t("backToList")}
</Link>
<p className="text-sm text-destructive">{playerErr ?? t("states.noData", { ns: "common" })}</p>
{playerErr ? (
<p className="text-sm text-destructive">{playerErr}</p>
) : (
<AdminNoResourceState />
)}
</div>
);
}
@@ -263,11 +278,13 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
{t("tabTickets")}
</TabsTrigger>
<TabsTrigger value="wallet" className="rounded-none px-3">
{t("tabWalletTxns")}
</TabsTrigger>
<TabsTrigger value="transfers" className="rounded-none px-3">
{t("tabTransferOrders")}
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
</TabsTrigger>
{showTransferTab ? (
<TabsTrigger value="transfers" className="rounded-none px-3">
{t("tabTransferOrders")}
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="overview" className="mt-0 space-y-4">
@@ -289,6 +306,12 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</dl>
<dl className="space-y-3">
<ProfileField label={t("currency")}>{player.default_currency}</ProfileField>
<ProfileField label={t("fundingMode")}>
{playerFundingModeLabel(player, t)}
</ProfileField>
<ProfileField label={t("authSource")}>
{playerAuthSourceLabel(player, t)}
</ProfileField>
<ProfileField label={t("status")}>
<PlayerStatusBadge status={player.status} t={t} />
</ProfileField>
@@ -312,11 +335,40 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("walletsSection")}</CardTitle>
<CardTitle className="text-base">
{isCreditPlayer ? t("creditSection") : t("walletsSection")}
</CardTitle>
</CardHeader>
<CardContent>
{player.wallets.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
{isCreditPlayer ? (
<dl className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">{t("creditLimit")}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
{player.credit_limit != null
? formatPlayerCreditAmount(player.credit_limit, player.default_currency)
: "—"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("availableCredit")}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
{player.available_credit != null
? formatPlayerCreditAmount(player.available_credit, player.default_currency)
: "—"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("usedCredit")}</p>
<p className="mt-1 text-sm tabular-nums text-muted-foreground">
{player.used_credit != null
? formatPlayerCreditAmount(player.used_credit, player.default_currency)
: "—"}
</p>
</div>
</dl>
) : player.wallets.length === 0 ? (
<AdminNoResourceState className="text-sm text-muted-foreground" />
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{player.wallets.map((w: AdminPlayerWalletRow) => (
@@ -399,11 +451,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!ticketsLoading && tickets.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>
@@ -428,7 +476,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TabsContent value="wallet" className="mt-0">
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("tabWalletTxns")}</CardTitle>
<CardTitle className="admin-list-title">
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-table-shell">
@@ -466,11 +516,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!txnsLoading && txns.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>
@@ -492,6 +538,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</Card>
</TabsContent>
{showTransferTab ? (
<TabsContent value="transfers" className="mt-0">
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
@@ -531,11 +578,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!transfersLoading && transfers.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>
@@ -556,6 +599,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</CardContent>
</Card>
</TabsContent>
) : null}
</Tabs>
</div>
);

View File

@@ -23,6 +23,7 @@ import {
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
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";
@@ -61,9 +62,11 @@ import {
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
import type { AdminPlayerRow } from "@/types/api/admin-player";
function playerStatusLabelT(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
@@ -72,15 +75,6 @@ function playerStatusLabelT(status: number, t: (key: string) => string): string
return String(status);
}
function preferredDisplayWallet(row: AdminPlayerRow): AdminPlayerWalletRow | null {
const { wallets, default_currency } = row;
if (wallets.length === 0) {
return null;
}
const code = default_currency.trim().toUpperCase();
return wallets.find((w) => w.currency_code.toUpperCase() === code) ?? wallets[0];
}
const PLAYER_STATUS_OPTIONS = [
{ value: 0, label: "statusNormal" },
{ value: 1, label: "statusFrozen" },
@@ -108,6 +102,7 @@ export function PlayersConsole(): React.ReactElement {
const [perPage, setPerPage] = useState(10);
const [keyword, setKeyword] = useState(keywordFromUrl);
const [query, setQuery] = useState(keywordFromUrl);
const [siteFilter, setSiteFilter] = useState("");
const [items, setItems] = useState<AdminPlayerRow[]>([]);
const [total, setTotal] = useState(0);
@@ -138,14 +133,43 @@ export function PlayersConsole(): React.ReactElement {
[items, editingAccountId],
);
const showSiteFilter =
isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1;
const scopeHint = useMemo(() => {
if (isSuperAdmin) {
return siteFilter.trim() !== ""
? t("scopeFilteredSite", { site: siteFilter.trim() })
: t("scopeAllSites");
}
if (boundAgent) {
return t("scopeAgentLine", {
site: boundAgent.site_code,
name: boundAgent.name,
});
}
const sites = profile?.accessible_sites ?? [];
if (sites.length === 1) {
return t("scopeSingleSite", { site: sites[0].code });
}
if (sites.length > 1) {
return siteFilter.trim() !== ""
? t("scopeFilteredSite", { site: siteFilter.trim() })
: t("scopeMultiSite", { count: sites.length });
}
return "";
}, [boundAgent, isSuperAdmin, profile?.accessible_sites, siteFilter, t]);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const scopedSite = siteFilter.trim();
const data = await getAdminPlayers({
page,
per_page: perPage,
keyword: query.trim() || undefined,
...(scopedSite !== "" ? { site_code: scopedSite } : {}),
});
setItems(data.items);
setTotal(data.meta.total);
@@ -159,11 +183,11 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query]);
}, [page, perPage, query, siteFilter]);
useAsyncEffect(() => {
void load();
}, [page, perPage, query]);
}, [page, perPage, query, siteFilter]);
useAsyncEffect(() => {
setKeyword(keywordFromUrl);
@@ -385,7 +409,36 @@ export function PlayersConsole(): React.ReactElement {
</Button>
) : null}
</div>
{scopeHint ? (
<p className="text-xs text-muted-foreground">{scopeHint}</p>
) : null}
<div className="admin-list-toolbar">
{showSiteFilter ? (
<div className="admin-list-field">
<Label htmlFor="player-site-filter" className="sm:w-20 sm:shrink-0">
{t("filterSite")}
</Label>
<Select
value={siteFilter || "__all__"}
onValueChange={(value) => {
setSiteFilter(value === "__all__" ? "" : value);
setPage(1);
}}
>
<SelectTrigger id="player-site-filter" className="w-full sm:w-[12rem]">
<SelectValue placeholder={t("filterAllSites")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{(isSuperAdmin ? siteOptions : profile?.accessible_sites ?? []).map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.name ? `${site.name} (${site.code})` : site.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
{t("search")}
@@ -454,6 +507,7 @@ export function PlayersConsole(): React.ReactElement {
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
@@ -463,16 +517,12 @@ export function PlayersConsole(): React.ReactElement {
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={12} />
<AdminTableLoadingRow colSpan={13} />
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
) : (
items.map((row) => {
const displayWallet = preferredDisplayWallet(row);
const balances = playerBalanceCells(row, formatAdminMinorUnits);
return (
<TableRow key={row.id}>
<TableCell className="tabular-nums">#{row.id}</TableCell>
@@ -486,18 +536,14 @@ export function PlayersConsole(): React.ReactElement {
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell>{row.default_currency}</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{displayWallet
? formatAdminMinorUnits(displayWallet.balance, displayWallet.currency_code)
: "—"}
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{displayWallet
? formatAdminMinorUnits(
displayWallet.available_balance,
displayWallet.currency_code,
)
: "—"}
{balances.balance}
</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{balances.available}
</TableCell>
<TableCell>
{row.status === 2 ? (