Files
lotteryAdmin/src/modules/dashboard/agent-dashboard-console.tsx

500 lines
21 KiB
TypeScript

"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 { 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 { 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 { 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 { 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 {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
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" });
return formatAdminCalendarToday(locale, weekday);
}, [i18n.language, i18n.resolvedLanguage, t]);
useAdminCurrencyCatalog();
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<AdminDashboardAgentOverview | null>(null);
const [canFinance, setCanFinance] = useState(false);
const analyticsScope = useMemo(
() => ({
siteCode: agent?.site_code ?? "",
agentNodeId: agent?.id,
}),
[agent?.id, agent?.site_code],
);
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.agent_overview);
setCanFinance(d.capabilities.draw_finance_risk);
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 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">
<div className="min-w-0">
<h1 className="admin-list-title">{t("agent.title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
{agent
? t("agent.subtitle", { name: agent.name || agent.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-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 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>
</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>
{overview.latest_bet_at
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
: t("agent.noBetToday")}
</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.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}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canFinance ? (
<DashboardAnalyticsPanel
enabled={canFinance}
playOptions={playOptions}
scope={analyticsScope}
/>
) : (
<Alert className="border-muted">
<AlertTitle>{t("notice")}</AlertTitle>
<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>
);
}