Added 'agentNodeId' to the analytics scope in the SiteDashboardConsole for improved data handling. Introduced 'lastPage' calculation in the SettlementOperationsPanel to enhance pagination logic. Updated wallet console to use the TFunction type for better type safety in translation functions.
257 lines
9.9 KiB
TypeScript
257 lines
9.9 KiB
TypeScript
"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 ?? "",
|
|
agentNodeId: undefined,
|
|
}),
|
|
[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>
|
|
);
|
|
}
|