feat(admin, settlement, dashboard): strengthen permission gating and billing workflows

This commit is contained in:
2026-06-09 13:44:19 +08:00
parent 7e65c53732
commit b7278e68a4
41 changed files with 900 additions and 199 deletions

View File

@@ -5,9 +5,13 @@ import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart3,
Flame,
Landmark,
Network,
RefreshCw,
Sparkles,
Ticket,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
@@ -48,7 +52,7 @@ export function AgentDashboardConsole(): ReactElement {
const tRef = useTranslationRef(["dashboard", "common"]);
const profile = useAdminProfile();
const agent = profile?.agent ?? null;
const permissions = profile?.permissions ?? [];
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
@@ -109,6 +113,7 @@ export function AgentDashboardConsole(): ReactElement {
}, []);
const currency = "NPR";
const displayCurrency = overview?.currency_code ?? currency;
const quickLinks = useMemo(() => {
const links: { href: string; label: string; icon: ReactElement }[] = [];
@@ -189,78 +194,268 @@ export function AgentDashboardConsole(): ReactElement {
))}
</div>
) : overview ? (
<section className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-2xl font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.credit_limit, currency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, currency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.creditAllocated", {
amount: formatDashboardCreditMajor(overview.allocated_credit, currency),
})}
{" · "}
{t("agent.creditUsed", {
amount: formatDashboardCreditMajor(overview.used_credit, currency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.shareRate", { rate: overview.total_share_rate })}
{" · "}
{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}
</p>
</CardContent>
</Card>
<section className="space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[1.35fr_0.95fr]">
<Card className="overflow-hidden border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.18),_transparent_42%),linear-gradient(135deg,_#0f172a,_#111827_55%,_#1f2937)] text-white shadow-[0_20px_60px_-25px_rgba(15,23,42,0.65)]">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-emerald-200/80">
{t("agent.heroEyebrow")}
</p>
<CardTitle className="mt-2 text-2xl font-semibold">
{t("agent.heroTitle", { name: overview.agent_name || overview.agent_code })}
</CardTitle>
</div>
<div className="rounded-full border border-white/15 bg-white/10 p-2">
<Sparkles className="size-4 text-emerald-200" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayBet")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayPayout")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayProfit")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_profit_minor, displayCurrency)}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.activePlayersToday")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.betOrdersToday")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.bet_order_count_today}</p>
</div>
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.pendingBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-300">
<span>{t("agent.shareRate", { rate: overview.total_share_rate })}</span>
<span>{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}</span>
<span>
{overview.latest_bet_at
? t("agent.latestBetAt", { time: new Date(overview.latest_bet_at).toLocaleString() })
: t("agent.noBetToday")}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
<p className="text-lg font-semibold tabular-nums">{overview.subtree_agent_count}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.98),_rgba(241,245,249,0.92))]">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-3xl font-semibold tabular-nums text-slate-900">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-slate-500">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditAllocatedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditUsedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
</p>
</div>
</div>
<p className="text-xs text-slate-500">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
</div>
<Card className="sm:col-span-2 xl:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.pendingBills")}</CardTitle>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0 text-xs")}
>
{t("agent.viewBills")}
</Link>
) : null}
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, currency),
})}
</p>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<TrendingUp className="size-4 text-emerald-600" />
{t("agent.sevenDayTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayPayout", {
amount: formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayProfit", {
amount: formatDashboardMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="size-4 text-sky-600" />
{t("agent.teamTitle")}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.subtree_agent_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.teamPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.team_player_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Wallet className="size-4 text-amber-600" />
{t("agent.pendingBills")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="size-4 text-rose-600" />
{t("agent.topMomentum")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{overview.top_agent_today ? (
<>
<p className="text-sm font-semibold text-slate-900">
{overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.topMomentumHint", {
profit: formatDashboardMoneyMinor(
overview.top_agent_today.approx_house_gross_minor,
displayCurrency,
),
})}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("agent.noBetToday")}</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Landmark className="size-4 text-slate-700" />
{t("agent.managementFocus")}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBet")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusPlayers")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.quickStatsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreateChildAgent")}</span>
<span className="font-medium">
{overview.can_create_child_agent ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreatePlayer")}</span>
<span className="font-medium">
{overview.can_create_player ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.lineDepth")}</span>
<span className="font-medium tabular-nums">{overview.depth}</span>
</div>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}
>
{t("agent.viewBills")}
</Link>
) : null}
</CardContent>
</Card>
</div>
</section>
) : null}