feat(dashboard, i18n): enhance agent dashboard and localization support

Updated the agent dashboard to include new metrics for today's bets and payouts, improving visibility for users. Enhanced localization files with additional hints and labels for better user experience across English, Nepali, and Chinese. Introduced new functions for formatting business dates and improved the handling of analytics permissions in the dashboard components.
This commit is contained in:
2026-06-16 14:18:58 +08:00
parent a4454a54a4
commit d4cf4ff436
17 changed files with 534 additions and 415 deletions

View File

@@ -10,40 +10,45 @@ 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_DASHBOARD_ANALYTICS_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 { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
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 {
DashboardKpiCard,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
import type { AdminDashboardAgentOverview, AdminDashboardWarning } 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>
);
function buildTodayBetHint(
businessDate: string,
latestBetAt: string | null,
t: (key: string, opts?: Record<string, unknown>) => string,
formatDt: (iso: string) => string,
): string {
const dateHint = t("todayBusinessDateHint", { date: businessDate });
if (latestBetAt) {
return `${dateHint} · ${t("agent.latestBetAt", { time: formatDt(latestBetAt) })}`;
}
return `${dateHint} · ${t("agent.noBetToday")}`;
}
export function AgentDashboardConsole(): ReactElement {
@@ -52,6 +57,9 @@ export function AgentDashboardConsole(): ReactElement {
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const agent = profile?.agent ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
@@ -65,10 +73,10 @@ export function AgentDashboardConsole(): ReactElement {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
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(
() => ({
@@ -78,6 +86,8 @@ export function AgentDashboardConsole(): ReactElement {
[agent?.id, agent?.site_code],
);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
@@ -90,7 +100,7 @@ export function AgentDashboardConsole(): ReactElement {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.agent_overview);
setCanFinance(d.capabilities.draw_finance_risk);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
@@ -110,8 +120,7 @@ export function AgentDashboardConsole(): ReactElement {
void load(false);
}, []);
const currency = "NPR";
const displayCurrency = overview?.currency_code ?? currency;
const displayCurrency = overview?.currency_code ?? "NPR";
return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
@@ -144,31 +153,36 @@ export function AgentDashboardConsole(): ReactElement {
</Alert>
) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg: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">
<div className="grid gap-3 sm:grid-cols-2 lg: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")
}
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
/>
<DashboardKpiCard
label={t("agent.todayShareProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
signedAmountMinor={overview.today_profit_minor}
currencyCode={displayCurrency}
icon={<BarChart3 className="size-4" />}
hint={t("agent.shareRate", { rate: overview.total_share_rate })}
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}`}
/>
<DashboardKpiCard
label={t("agent.activePlayersToday")}
@@ -193,7 +207,7 @@ export function AgentDashboardConsole(): ReactElement {
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-2xl font-semibold tabular-nums">
<p className="break-all text-2xl font-semibold tabular-nums leading-tight">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
@@ -202,19 +216,15 @@ export function AgentDashboardConsole(): ReactElement {
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<AgentMetric
<div className="grid gap-3 sm:grid-cols-2">
<DashboardScopeMetric
label={t("agent.creditAllocatedLabel")}
value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
/>
<AgentMetric
<DashboardScopeMetric
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", {
@@ -231,24 +241,21 @@ export function AgentDashboardConsole(): ReactElement {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.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("agent.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("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 className="space-y-2">
<DashboardStatRow
label={t("agent.sevenDayBet")}
value={formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
/>
<DashboardStatRow
label={t("agent.sevenDayPayoutLabel")}
value={formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency)}
/>
<DashboardSignedStatRow
label={t("agent.sevenDayShareProfitLabel")}
amountMinor={overview.seven_day_profit_minor}
currencyCode={displayCurrency}
/>
<p className="pt-1 text-xs text-muted-foreground">{t("agent.shareProfitScopeHint")}</p>
</CardContent>
</Card>
@@ -257,19 +264,19 @@ export function AgentDashboardConsole(): ReactElement {
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<AgentMetric
<DashboardScopeMetric
label={t("agent.directChildren")}
value={String(overview.direct_child_count)}
/>
<AgentMetric
<DashboardScopeMetric
label={t("agent.directPlayers")}
value={String(overview.direct_player_count)}
/>
<AgentMetric
<DashboardScopeMetric
label={t("agent.subtreeAgents")}
value={String(overview.subtree_agent_count)}
/>
<AgentMetric
<DashboardScopeMetric
label={t("agent.teamPlayers")}
value={String(overview.team_player_count)}
/>
@@ -277,7 +284,11 @@ export function AgentDashboardConsole(): ReactElement {
</Card>
</div>
</section>
) : null}
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
{t("agent.overviewEmpty")}
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
@@ -286,18 +297,15 @@ export function AgentDashboardConsole(): ReactElement {
loading={loading}
/>
{canFinance ? (
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canFinance}
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
/>
) : (
<Alert className="border-muted">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert>
)}
) : !loading ? (
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
) : null}
</div>
);
}

View File

@@ -21,7 +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 { signedMoneyClass, signedTrendClassName } 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";
@@ -56,16 +56,8 @@ function computeDeltaPercent(series: number[]): string | null {
return `${sign} ${Math.abs(pct).toFixed(1)}%`;
}
function deltaClassName(series: number[]): string {
if (series.length < 2) {
return "text-muted-foreground";
}
const last = series[series.length - 1];
const prev = series[series.length - 2];
if (last >= prev) {
return "text-emerald-600 dark:text-emerald-400";
}
return "text-destructive";
function deltaClassName(series: number[], mode: "higherBetter" | "lowerBetter" | "signed"): string {
return signedTrendClassName(series, mode);
}
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
@@ -190,13 +182,13 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
) : null}
{loading ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid min-w-0 gap-3 sm:grid-cols-2 2xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
))}
</div>
) : summary ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid min-w-0 gap-3 sm:grid-cols-2 2xl:grid-cols-3">
<DashboardKpiCard
label={t("analytics.summaryBet")}
value={formatMoney(summary.total_bet_minor, currency)}
@@ -208,7 +200,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.bet}
deltaLabel={
computeDeltaPercent(sparklines.bet) ? (
<span className={deltaClassName(sparklines.bet)}>
<span className={deltaClassName(sparklines.bet, "higherBetter")}>
{computeDeltaPercent(sparklines.bet)}
</span>
) : undefined
@@ -229,7 +221,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.payout}
deltaLabel={
computeDeltaPercent(sparklines.payout) ? (
<span className={deltaClassName(sparklines.payout)}>
<span className={deltaClassName(sparklines.payout, "lowerBetter")}>
{computeDeltaPercent(sparklines.payout)}
</span>
) : undefined
@@ -241,8 +233,8 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
? t("analytics.summaryShareProfit")
: t("analytics.summaryProfit")
}
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
signedAmountMinor={summary.approx_house_gross_minor}
currencyCode={currency}
hint={
profitScope === "share_profit"
? t("analytics.shareProfitHint")
@@ -256,7 +248,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.profit}
deltaLabel={
computeDeltaPercent(sparklines.profit) ? (
<span className={deltaClassName(sparklines.profit)}>
<span className={deltaClassName(sparklines.profit, "signed")}>
{computeDeltaPercent(sparklines.profit)}
</span>
) : undefined

View File

@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react";
import { AlertTriangle, BarChart3, ClipboardList, RefreshCw, Shield, TrendingUp, Wallet } from "lucide-react";
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
@@ -18,6 +18,9 @@ import {
} from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
import {
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -39,12 +42,18 @@ import type {
AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue,
AdminDashboardTodayFinance,
AdminDashboardWarning,
} from "@/types/api/admin-dashboard";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
// recharts 图表组件懒加载,避免 ~200KB 进入主 bundle
const DashboardKpiCard = dynamic(
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardKpiCard })),
{ ssr: false },
);
const DashboardPanelCard = dynamic(
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardPanelCard })),
{ ssr: false },
@@ -172,6 +181,8 @@ export function DashboardConsole(): ReactElement {
const [lifetimeFinance, setLifetimeFinance] = useState<AdminDashboardLifetimeFinance | null>(
null,
);
const [todayFinance, setTodayFinance] = useState<AdminDashboardTodayFinance | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [platformRisk, setPlatformRisk] = useState<AdminDashboardPlatformRisk | null>(null);
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
@@ -193,6 +204,8 @@ export function DashboardConsole(): ReactElement {
setDrawPanel(null);
setResultBatchQueue(null);
setLifetimeFinance(null);
setTodayFinance(null);
setApiWarnings([]);
setPlatformRisk(null);
setDrawId(null);
setRiskLocked(0);
@@ -214,6 +227,8 @@ export function DashboardConsole(): ReactElement {
}
setResultBatchQueue(d.result_batch_queue);
setLifetimeFinance(d.lifetime_finance);
setTodayFinance(d.today_finance);
setApiWarnings(d.warnings ?? []);
setPlatformRisk(d.platform_risk);
if (d.draw != null) {
setDrawPanel(d.draw);
@@ -239,7 +254,10 @@ export function DashboardConsole(): ReactElement {
}, []);
const currency =
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
todayFinance?.currency_code
?? lifetimeFinance?.currency_code
?? finance?.currency_code
?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
@@ -296,6 +314,13 @@ export function DashboardConsole(): ReactElement {
</Alert>
) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
<section className="flex min-w-0 flex-col gap-4">
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${hall?.seconds_to_close ?? 0}:${loading ? "loading" : "ready"}`}
@@ -303,6 +328,42 @@ export function DashboardConsole(): ReactElement {
drawId={drawId}
loading={loading}
/>
{canFinance && !loading && todayFinance ? (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
label={t("todayBetTotal")}
value={formatDashboardMoneyMinor(todayFinance.total_bet_minor, currency)}
icon={<TrendingUp className="size-4" />}
hint={t("todayBusinessDateHint", { date: todayFinance.business_date })}
/>
<DashboardKpiCard
label={t("todayProfit")}
signedAmountMinor={todayFinance.approx_house_gross_minor}
currencyCode={currency}
icon={<BarChart3 className="size-4" />}
hint={t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(todayFinance.total_payout_minor, currency),
})}
/>
<DashboardKpiCard
label={t("lifetimeProfit")}
signedAmountMinor={lifetimeFinance?.approx_house_gross_minor}
currencyCode={currency}
icon={<Wallet className="size-4" />}
hint={
lifetimeFinance
? t("lifetimeActivityHint", {
draws: lifetimeFinance.draw_count,
days: lifetimeFinance.business_day_count,
})
: undefined
}
value={lifetimeFinance ? undefined : "—"}
/>
</div>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<DashboardPanelCard
href={pendingReviewHref(drawId, resultBatchQueue)}
@@ -337,7 +398,7 @@ export function DashboardConsole(): ReactElement {
>
<AbnormalTransferPanelFooter
total={abnormalTransferTotal}
walletPermission={capabilities?.wallet_transfer_view ?? true}
walletPermission={capabilities?.wallet_transfer_view ?? false}
/>
</DashboardPanelCard>
@@ -345,16 +406,6 @@ export function DashboardConsole(): ReactElement {
href="/admin/risk"
title={t("riskCapUsage")}
value={`${platformUsagePct.toFixed(1)}%`}
subtitle={
platformCap > 0
? t("platformLockedAndCap", {
locked: formatMoneyMinor(platformLocked, currency),
cap: formatMoneyMinor(platformCap, currency),
})
: t("platformCapNotConfigured", {
locked: formatMoneyMinor(platformLocked, currency),
})
}
actionLabel={t("occupancyDetails")}
icon={<Shield className="size-5" aria-hidden />}
accent={
@@ -380,7 +431,7 @@ export function DashboardConsole(): ReactElement {
<DashboardPanelCard
href="/admin/reports"
title={t("payoutComposition")}
title={t("lifetimePayout")}
value={
lifetimeFinance
? formatMoneyMinor(lifetimeFinance.total_payout_minor, currency)

View File

@@ -36,6 +36,7 @@ import {
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { cn } from "@/lib/utils";
import { SignedMoney, signedMoneyClass } from "@/lib/admin-signed-money";
import {
buildBatchProgressConfig,
buildFinanceStructureConfig,
@@ -46,6 +47,7 @@ import {
DASHBOARD_CHART_COLORS,
} from "@/modules/dashboard/dashboard-chart-config";
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
import { formatDashboardSignedMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDashboardLifetimeFinance } from "@/types/api/admin-dashboard";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
@@ -179,6 +181,73 @@ function kpiAccentClass(accent: DashboardKpiAccent): string {
}
}
/** 站点/代理卡片内 label | amount 行,金额过长时可换行 */
export function DashboardStatRow({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 break-all text-right font-semibold tabular-nums leading-tight",
valueClassName ?? "text-foreground",
)}
>
{value}
</span>
</div>
);
}
/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */
export function DashboardSignedStatRow({
label,
amountMinor,
currencyCode,
}: {
label: string;
amountMinor: number;
currencyCode: string | null;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<SignedMoney
amount={amountMinor}
emphasize
className="min-w-0 break-all text-right leading-tight"
>
{formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
</SignedMoney>
</div>
);
}
/** 规模/授信等栅格指标,金额可换行 */
export function DashboardScopeMetric({
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 break-all text-base font-semibold tabular-nums leading-tight text-foreground">
{value}
</p>
</div>
);
}
/** 财务概览区紧凑 KPI避免 StatCard 在窄栅格内撑破布局 */
export function DashboardKpiCard({
label,
@@ -187,43 +256,60 @@ export function DashboardKpiCard({
icon,
accent = "primary",
valueClassName,
signedAmountMinor,
currencyCode,
sparklineValues,
deltaLabel,
}: {
label: string;
value: ReactNode;
value?: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: DashboardKpiAccent;
/** 覆盖主数值颜色(如盈亏红绿) */
valueClassName?: string;
/** 盈亏类 KPI自动带 +/- 与红绿 */
signedAmountMinor?: number;
currencyCode?: string | null;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
const resolvedValue =
typeof signedAmountMinor === "number" && currencyCode !== undefined
? formatDashboardSignedMoneyMinor(signedAmountMinor, currencyCode)
: value;
const resolvedValueClassName =
typeof signedAmountMinor === "number"
? signedMoneyClass(signedAmountMinor, true)
: valueClassName;
const valueTitle =
typeof resolvedValue === "string" || typeof resolvedValue === "number"
? String(resolvedValue)
: undefined;
return (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
<div className="flex min-w-0 items-start gap-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg",
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent),
)}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<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}
</div>
</div>
<p
title={valueTitle}
className={cn(
"mt-2 break-words text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
resolvedValueClassName ?? "text-foreground",
)}
>
{resolvedValue}
</p>
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
{sparklineValues && sparklineValues.length >= 2 ? (
<div className="mt-3 flex justify-end">
<MiniSparkline
@@ -239,7 +325,7 @@ export function DashboardKpiCard({
</div>
) : null}
{hint ? (
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
) : null}
</div>
);
@@ -477,9 +563,7 @@ export function DashboardPanelCard({
</p>
)}
{subtitle && !loading ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{subtitle}
</p>
<div className="mt-2 text-xs leading-snug text-muted-foreground">{subtitle}</div>
) : null}
</div>
@@ -621,19 +705,48 @@ export function CapUsageBar({
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
if (compact) {
const lockedLabel = formatMoney(locked, currency);
const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset");
return (
<div
className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("riskCapUsage")}
>
<div className="space-y-2.5">
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-primary/5 px-2.5 py-2 ring-1 ring-primary/15">
<p className="text-[10px] font-medium uppercase tracking-wide text-primary/80">
{t("platformLockedLabel")}
</p>
<p
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
title={lockedLabel}
>
{lockedLabel}
</p>
</div>
<div className="rounded-lg bg-muted/50 px-2.5 py-2 ring-1 ring-border/60">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t("platformCapLabel")}
</p>
<p
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
title={capLabel}
>
{capLabel}
</p>
</div>
</div>
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${pct}%`, backgroundColor: fill }}
/>
className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("riskCapUsage")}
>
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${pct}%`, backgroundColor: fill }}
/>
</div>
</div>
);
}
@@ -741,6 +854,12 @@ export function FinanceStructureChart({
<p className="text-center text-xs text-muted-foreground">
{t("payoutRateOfBet", { rate: payoutRate })}
</p>
<p className="text-center text-sm tabular-nums">
<span className="text-muted-foreground">{t("houseGross")} </span>
<SignedMoney amount={gross} emphasize>
{formatDashboardSignedMoneyMinor(gross, currency)}
</SignedMoney>
</p>
<ChartLegend content={<ChartLegendContent />} />
</div>
);

View File

@@ -10,40 +10,43 @@ 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 { PRD_DASHBOARD_ANALYTICS_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 { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
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 {
DashboardKpiCard,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import {
formatDashboardMoneyMinor,
formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardSiteOverview } from "@/types/api/admin-dashboard";
import type { AdminDashboardSiteOverview, AdminDashboardWarning } 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>
);
function buildTodayBetHint(
businessDate: string,
latestBetAt: string | null,
t: (key: string, opts?: Record<string, unknown>) => string,
formatDt: (iso: string) => string,
): string {
const dateHint = t("todayBusinessDateHint", { date: businessDate });
if (latestBetAt) {
return `${dateHint} · ${t("site.latestBetAt", { time: formatDt(latestBetAt) })}`;
}
return `${dateHint} · ${t("site.noBetToday")}`;
}
export function SiteDashboardConsole(): ReactElement {
@@ -53,6 +56,7 @@ export function SiteDashboardConsole(): ReactElement {
const profile = useAdminProfile();
const site = profile?.site ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
@@ -66,6 +70,7 @@ export function SiteDashboardConsole(): ReactElement {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
@@ -78,7 +83,7 @@ export function SiteDashboardConsole(): ReactElement {
[overview?.site_code, site?.code],
);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -92,6 +97,7 @@ export function SiteDashboardConsole(): ReactElement {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_overview);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
@@ -144,31 +150,36 @@ export function SiteDashboardConsole(): ReactElement {
</Alert>
) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg: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">
<div className="grid gap-3 sm:grid-cols-2 lg: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")
}
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
/>
<DashboardKpiCard
label={t("site.todayProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
signedAmountMinor={overview.today_profit_minor}
currencyCode={displayCurrency}
icon={<BarChart3 className="size-4" />}
hint={t("site.profitScopeHint")}
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
hint={t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}
/>
<DashboardKpiCard
label={t("site.activePlayersToday")}
@@ -192,24 +203,21 @@ export function SiteDashboardConsole(): ReactElement {
<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 className="space-y-2">
<DashboardStatRow
label={t("site.sevenDayBet")}
value={formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
/>
<DashboardStatRow
label={t("site.sevenDayPayout")}
value={formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency)}
/>
<DashboardSignedStatRow
label={t("site.sevenDayProfit")}
amountMinor={overview.seven_day_profit_minor}
currencyCode={displayCurrency}
/>
<p className="pt-1 text-xs text-muted-foreground">{t("site.profitScopeHint")}</p>
</CardContent>
</Card>
@@ -218,24 +226,31 @@ export function SiteDashboardConsole(): ReactElement {
<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)} />
<DashboardScopeMetric label={t("site.agentCount")} value={String(overview.agent_count)} />
<DashboardScopeMetric 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(
<div className="col-span-2 rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{t("site.topAgentTodayLabel")}</p>
<p className="mt-1 font-medium text-foreground">
{overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
</p>
<p className="mt-0.5 break-all text-sm font-semibold tabular-nums">
{formatDashboardMoneyMinor(
overview.top_agent_today.total_bet_minor,
displayCurrency,
),
})}
)}
</p>
</div>
) : null}
</CardContent>
</Card>
</div>
</section>
) : null}
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
{t("site.overviewEmpty")}
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}

View File

@@ -35,7 +35,6 @@ import {
} from "@/api/admin-reports";
import {
buildReportJobParameters,
REPORT_UI_SERVER_FULL_EXPORT,
REPORT_UI_TO_JOB_TYPE,
type ReportUiKey,
} from "@/lib/report-export-map";
@@ -239,37 +238,6 @@ function resolveDisplayCurrency(apiCode?: string | null): string {
return fallback?.trim() || "NPR";
}
function reportTimeAxisKey(key: ReportKey): "businessDate" | "recordCreatedAt" | null {
switch (key) {
case "daily_profit":
case "player_win_loss":
case "play_dimension":
case "rebate_commission":
return "businessDate";
case "player_transfer":
case "admin_audit":
return "recordCreatedAt";
default:
return null;
}
}
function reportDisclaimerKey(key: ReportKey): string | null {
switch (key) {
case "draw_profit":
case "daily_profit":
case "player_win_loss":
case "play_dimension":
return "items.profit_reports.disclaimer";
case "player_transfer":
return "items.player_transfer.disclaimer";
case "rebate_commission":
return "items.rebate_commission.disclaimer";
default:
return null;
}
}
const emptySearch: SearchState = {
open: null,
query: "",
@@ -328,41 +296,6 @@ function formatExportInstant(iso: string | null | undefined): ExportCell {
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
}
function toCsvValue(value: ExportCell): string {
if (value == null) {
return "";
}
const stringValue = String(value);
if (/[",\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
async function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): Promise<void> {
if (rows.length === 0) {
throw new LotteryApiBizError("no_data", -1, null);
}
if (format === "csv") {
const headers = Object.keys(rows[0]);
const lines = [
headers.map(toCsvValue).join(","),
...rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
];
const blob = new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8;" });
downloadBlob(blob, `${filename}.csv`);
return;
}
const XLSX = await import("xlsx");
const worksheet = XLSX.utils.json_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
XLSX.writeFile(workbook, `${filename}.xlsx`);
}
function buildDailyProfitRowsAndSummary(
items: AdminReportDailyProfitRow[],
total: number,
@@ -742,7 +675,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}, [filteredReports, selectedKey]);
const pageScopedLabel = useCallback(
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
(statKey: string) => t(`preview.stats.${statKey}`),
[t],
);
@@ -1233,9 +1166,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
setPage(1);
}
const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey);
async function exportViaServer(format: ExportFormat): Promise<void> {
async function exportReport(format: ExportFormat): Promise<void> {
if (!canExportReports) {
return;
}
@@ -1260,10 +1191,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
toast.success(
t("exportServerSuccess", {
t("exportSuccess", {
report: t(`items.${selectedReport.key}.title`),
format: t(`formats.${format}`),
jobNo: job.job_no,
}),
);
} catch (err) {
@@ -1273,36 +1203,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}
}
function exportPreview(format: ExportFormat): void {
if (!canExportReports) {
return;
}
if (!result || result.rows.length === 0) {
toast.info(t("empty"));
return;
}
setExporting(format);
try {
exportRows(result.rows, exportFileBase, t(`items.${selectedReport.key}.title`), format);
toast.success(t("exportSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`) }));
} catch (err) {
toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed"));
} finally {
setExporting(null);
}
}
function exportReport(format: ExportFormat): void {
if (!canExportReports) {
return;
}
if (usesServerExport) {
void exportViaServer(format);
return;
}
exportPreview(format);
}
const renderSearchPicker = (kind: SearchKind) => {
const value =
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
@@ -1726,21 +1626,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
})}
</div>
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
{reportTimeAxisKey(selectedReport.key) ? (
<p className="text-xs text-muted-foreground">{t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}</p>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{selectedReport.fields.map(renderField)}
</div>
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1 text-xs text-muted-foreground">
<div>{t("filterPanel")}</div>
<div>{t("queryHint")}</div>
</div>
<div className="flex shrink-0 gap-2">
<div className="flex justify-end gap-2 border-t border-border/60 pt-3">
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")}
</Button>
@@ -1756,7 +1648,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<Database data-icon="inline-start" />
{loading ? t("querying") : t("query")}
</Button>
</div>
</div>
</CardContent>
</Card>
@@ -1770,70 +1661,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
))}
</div>
{reportDisclaimerKey(selectedReport.key) ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
{t(reportDisclaimerKey(selectedReport.key)!)}
</div>
) : null}
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div>
<div className="flex flex-col items-end gap-1.5">
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("csv")}
>
<FileDown data-icon="inline-start" />
{t("formats.csvServer")}
</Button>
<Button
type="button"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
{t("formats.excelServer")}
</Button>
</div>
{result && result.rows.length > 0 ? (
<>
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("csv")}
>
{t("formats.csvPreview")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("excel")}
>
{t("formats.excelPreview")}
</Button>
</div>
</>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3 pt-3">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
{t("preview.summaryScopeHint")}
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => void exportReport("csv")}
>
<FileDown data-icon="inline-start" />
{t("formats.csv")}
</Button>
<Button
type="button"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => void exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
{t("formats.excel")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-3">
<Table id="reports-preview-table">
<TableHeader>
<TableRow>