feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support
Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color
This commit is contained in:
@@ -1,20 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -22,24 +10,18 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_HUB_ACCESS_ANY,
|
||||
PRD_PLAYERS_ACCESS_ANY,
|
||||
PRD_REPORTS_VIEW_ACCESS_ANY,
|
||||
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
PRD_TICKETS_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
formatDashboardCreditMajor,
|
||||
formatDashboardMoneyMinor,
|
||||
@@ -49,14 +31,27 @@ import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function AgentMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDashboardConsole(): ReactElement {
|
||||
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const agent = profile?.agent ?? null;
|
||||
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
|
||||
@@ -118,47 +113,6 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
const currency = "NPR";
|
||||
const displayCurrency = overview?.currency_code ?? currency;
|
||||
|
||||
const quickLinks = useMemo(() => {
|
||||
const links: { href: string; label: string; icon: ReactElement }[] = [];
|
||||
if (adminHasAnyPermission(permissions, [...PRD_TICKETS_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/tickets",
|
||||
label: t("agent.quickLinks.tickets"),
|
||||
icon: <Ticket className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_PLAYERS_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/players",
|
||||
label: t("agent.quickLinks.players"),
|
||||
icon: <Users className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/reports",
|
||||
label: t("agent.quickLinks.reports"),
|
||||
icon: <BarChart3 className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_AGENT_HUB_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/agents",
|
||||
label: t("agent.quickLinks.agents"),
|
||||
icon: <Network className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/settlement-center",
|
||||
label: t("agent.quickLinks.bills"),
|
||||
icon: <Wallet className="size-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [permissions, t]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -191,270 +145,134 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : overview ? (
|
||||
<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>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DashboardKpiCard
|
||||
label={t("agent.todayBet")}
|
||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={
|
||||
overview.latest_bet_at
|
||||
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
||||
: t("agent.noBetToday")
|
||||
}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.todayShareProfit")}
|
||||
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
hint={t("agent.shareRate", { rate: overview.total_share_rate })}
|
||||
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.activePlayersToday")}
|
||||
value={overview.active_player_count_today}
|
||||
icon={<Users className="size-4" />}
|
||||
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.pendingBills")}
|
||||
value={overview.pending_bill_count}
|
||||
icon={<Wallet className="size-4" />}
|
||||
hint={t("agent.pendingUnpaid", {
|
||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
||||
})}
|
||||
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("agent.creditAvailable", {
|
||||
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<AgentMetric
|
||||
label={t("agent.creditAllocatedLabel")}
|
||||
value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
|
||||
/>
|
||||
<AgentMetric
|
||||
label={t("agent.creditUsedLabel")}
|
||||
value={formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
|
||||
/>
|
||||
<AgentMetric
|
||||
label={t("agent.pendingBills")}
|
||||
value={String(overview.pending_bill_count)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.lineMeta", {
|
||||
depth: overview.depth,
|
||||
childAgent: overview.can_create_child_agent ? t("agent.yes") : t("agent.no"),
|
||||
player: overview.can_create_player ? t("agent.yes") : t("agent.no"),
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.sevenDayTitle")}</CardTitle>
|
||||
</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.todayShareProfit")}</p>
|
||||
<p className="mt-2 text-2xl font-semibold tabular-nums">
|
||||
{formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("agent.todayBet")}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
|
||||
</span>
|
||||
</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>
|
||||
{overview.latest_bet_at
|
||||
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
||||
: t("agent.noBetToday")}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("agent.todayShareProfit")}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
signedMoneyClass(overview.seven_day_profit_minor, true),
|
||||
)}
|
||||
>
|
||||
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<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.sevenDayShareProfit", {
|
||||
amount: formatDashboardSignedMoneyMinor(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>
|
||||
<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.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.topMomentumPayout", {
|
||||
amount: formatDashboardMoneyMinor(
|
||||
overview.top_agent_today.total_payout_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}
|
||||
<AgentMetric
|
||||
label={t("agent.directChildren")}
|
||||
value={String(overview.direct_child_count)}
|
||||
/>
|
||||
<AgentMetric
|
||||
label={t("agent.directPlayers")}
|
||||
value={String(overview.direct_player_count)}
|
||||
/>
|
||||
<AgentMetric
|
||||
label={t("agent.subtreeAgents")}
|
||||
value={String(overview.subtree_agent_count)}
|
||||
/>
|
||||
<AgentMetric
|
||||
label={t("agent.teamPlayers")}
|
||||
value={String(overview.team_player_count)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -480,21 +298,6 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quickLinks.length > 0 ? (
|
||||
<section className="flex flex-wrap gap-2">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 gap-1.5")}
|
||||
>
|
||||
{link.icon}
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
@@ -241,6 +242,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
: t("analytics.summaryProfit")
|
||||
}
|
||||
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
|
||||
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
|
||||
hint={
|
||||
profitScope === "share_profit"
|
||||
? t("analytics.shareProfitHint")
|
||||
@@ -452,7 +454,14 @@ export function DashboardAgentRankingCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 text-right text-xs font-semibold tabular-nums",
|
||||
rankingMetric === "profit"
|
||||
? signedMoneyClass(row.approx_house_gross_minor, true)
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
{formatRowValue(row)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,22 +2,9 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ClipboardList,
|
||||
Diamond,
|
||||
FileSearch,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
Ticket,
|
||||
Wallet,
|
||||
BarChart3,
|
||||
Scale,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react";
|
||||
|
||||
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
@@ -275,23 +262,6 @@ export function DashboardConsole(): ReactElement {
|
||||
});
|
||||
const showAnalytics = canFinance;
|
||||
|
||||
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
|
||||
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-4" /> },
|
||||
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-4" /> },
|
||||
{
|
||||
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
|
||||
label: t("quickLinks.results"),
|
||||
icon: <FileSearch className="size-4" />,
|
||||
},
|
||||
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-4" /> },
|
||||
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-4" /> },
|
||||
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-4" /> },
|
||||
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <BarChart3 className="size-4" /> },
|
||||
{ href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: <Scale className="size-4" /> },
|
||||
{ href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: <Shield className="size-4" /> },
|
||||
{ href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: <Settings className="size-4" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -518,42 +488,20 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
|
||||
{!showAnalytics ? (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
|
||||
{quickLinks.map((q) => (
|
||||
<Link
|
||||
key={q.href + q.label}
|
||||
href={q.href}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
|
||||
{q.icon}
|
||||
</span>
|
||||
<span className="line-clamp-2">{q.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -576,26 +524,6 @@ export function DashboardConsole(): ReactElement {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
|
||||
{quickLinks.map((q) => (
|
||||
<Link
|
||||
key={q.href + q.label}
|
||||
href={q.href}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
|
||||
{q.icon}
|
||||
</span>
|
||||
<span className="line-clamp-2">{q.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { isAgentOperator, isSiteAdminOperator } from "@/lib/admin-session-variants";
|
||||
import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
|
||||
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
|
||||
import { SiteDashboardConsole } from "@/modules/dashboard/site-dashboard-console";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */
|
||||
/** 超管/平台账号走全站仪表盘;站点管理员走站点仪表盘;代理经营账号走代理仪表盘。 */
|
||||
export function DashboardPageClient(): ReactElement {
|
||||
const profile = useAdminProfile();
|
||||
const isAgentOperator =
|
||||
profile?.agent != null && profile.is_super_admin !== true;
|
||||
|
||||
if (isAgentOperator) {
|
||||
if (isAgentOperator(profile)) {
|
||||
return <AgentDashboardConsole />;
|
||||
}
|
||||
|
||||
if (isSiteAdminOperator(profile)) {
|
||||
return <SiteDashboardConsole />;
|
||||
}
|
||||
|
||||
return <DashboardConsole />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildTrendChartConfig, DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
|
||||
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
|
||||
@@ -332,7 +334,14 @@ export function PeriodCompareStrip({
|
||||
<span className="text-sm font-medium text-foreground">{row.label}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{row.pctText}</span>
|
||||
</div>
|
||||
<div className="mb-1 text-sm font-semibold tabular-nums">{formatMoney(row.value, currency)}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 text-sm font-semibold tabular-nums",
|
||||
row.key === "profit" ? signedMoneyClass(row.value, true) : undefined,
|
||||
)}
|
||||
>
|
||||
{formatMoney(row.value, currency)}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
|
||||
@@ -186,6 +186,7 @@ export function DashboardKpiCard({
|
||||
hint,
|
||||
icon,
|
||||
accent = "primary",
|
||||
valueClassName,
|
||||
sparklineValues,
|
||||
deltaLabel,
|
||||
}: {
|
||||
@@ -194,6 +195,8 @@ export function DashboardKpiCard({
|
||||
hint?: ReactNode;
|
||||
icon: ReactNode;
|
||||
accent?: DashboardKpiAccent;
|
||||
/** 覆盖主数值颜色(如盈亏红绿) */
|
||||
valueClassName?: string;
|
||||
sparklineValues?: number[];
|
||||
deltaLabel?: ReactNode;
|
||||
}): ReactElement {
|
||||
@@ -210,7 +213,12 @@ export function DashboardKpiCard({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 truncate text-xl font-bold tabular-nums tracking-tight",
|
||||
valueClassName ?? "text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
|
||||
|
||||
255
src/modules/dashboard/site-dashboard-console.tsx
Normal file
255
src/modules/dashboard/site-dashboard-console.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import {
|
||||
formatDashboardMoneyMinor,
|
||||
formatDashboardSignedMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDashboardSiteOverview } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function SiteMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteDashboardConsole(): ReactElement {
|
||||
const { t, i18n } = useTranslation(["dashboard", "common"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const site = profile?.site ?? null;
|
||||
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
|
||||
|
||||
return formatAdminCalendarToday(locale, weekday);
|
||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
|
||||
|
||||
const analyticsScope = useMemo(
|
||||
() => ({
|
||||
siteCode: site?.code ?? overview?.site_code ?? "",
|
||||
}),
|
||||
[overview?.site_code, site?.code],
|
||||
);
|
||||
|
||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.site_overview);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
setDrawId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const displayCurrency = overview?.currency_code ?? "NPR";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="admin-list-title">{t("site.title")}</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{site
|
||||
? t("site.subtitle", { name: site.name || site.code })
|
||||
: todayLabel}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={loading || refreshing}
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : overview ? (
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DashboardKpiCard
|
||||
label={t("site.todayBet")}
|
||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={
|
||||
overview.latest_bet_at
|
||||
? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
||||
: t("site.noBetToday")
|
||||
}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.todayProfit")}
|
||||
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
hint={t("site.profitScopeHint")}
|
||||
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.activePlayersToday")}
|
||||
value={overview.active_player_count_today}
|
||||
icon={<Users className="size-4" />}
|
||||
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.pendingBills")}
|
||||
value={overview.pending_bill_count}
|
||||
icon={<Wallet className="size-4" />}
|
||||
hint={t("site.pendingUnpaid", {
|
||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
||||
})}
|
||||
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("site.sevenDayTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("site.todayBet")}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("site.sevenDayProfit")}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
signedMoneyClass(overview.seven_day_profit_minor, true),
|
||||
)}
|
||||
>
|
||||
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("site.scaleTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<SiteMetric label={t("site.agentCount")} value={String(overview.agent_count)} />
|
||||
<SiteMetric label={t("site.playerCount")} value={String(overview.player_count)} />
|
||||
{overview.top_agent_today ? (
|
||||
<div className="col-span-2 rounded-lg border bg-muted/20 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
{t("site.topAgentToday", {
|
||||
name: overview.top_agent_today.agent_name || overview.top_agent_today.agent_code,
|
||||
amount: formatDashboardMoneyMinor(
|
||||
overview.top_agent_today.total_bet_minor,
|
||||
displayCurrency,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
hall={hall}
|
||||
drawId={drawId}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{canAnalytics ? (
|
||||
<DashboardAnalyticsPanel
|
||||
enabled={canAnalytics}
|
||||
playOptions={playOptions}
|
||||
scope={analyticsScope}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user