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
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactElement } from "react";
|
|
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts";
|
|
|
|
import {
|
|
ChartContainer,
|
|
ChartLegend,
|
|
ChartLegendContent,
|
|
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";
|
|
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
|
|
import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics";
|
|
|
|
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
|
|
|
function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number {
|
|
switch (metric) {
|
|
case "bet":
|
|
return row.total_bet_minor;
|
|
case "payout":
|
|
return row.total_payout_minor;
|
|
case "profit":
|
|
return row.approx_house_gross_minor;
|
|
default:
|
|
return row.total_bet_minor;
|
|
}
|
|
}
|
|
|
|
function metricBarFill(metric: DashboardAnalyticsMetric, value: number): string {
|
|
if (metric === "payout") {
|
|
return DASHBOARD_CHART_COLORS.rose;
|
|
}
|
|
if (metric === "profit") {
|
|
return value >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
|
}
|
|
return DASHBOARD_CHART_COLORS.primary;
|
|
}
|
|
|
|
export function DailyTrendChart({
|
|
series,
|
|
metric,
|
|
formatMoney,
|
|
currency,
|
|
}: {
|
|
series: AdminReportDailyProfitRow[];
|
|
metric: DashboardAnalyticsMetric;
|
|
formatMoney: MoneyFormatter;
|
|
currency: string | null;
|
|
}): ReactElement {
|
|
const { t } = useTranslation("dashboard");
|
|
|
|
const chartConfig = useMemo(
|
|
() =>
|
|
buildTrendChartConfig({
|
|
bet: t("chartLegend.bet"),
|
|
payout: t("chartLegend.payout"),
|
|
profit: t("chartLegend.profit"),
|
|
}),
|
|
[t],
|
|
);
|
|
|
|
const chartData = useMemo(
|
|
() =>
|
|
series.map((day) => ({
|
|
date: day.business_date.slice(5),
|
|
fullDate: day.business_date,
|
|
bet: day.total_bet_minor,
|
|
payout: day.total_payout_minor,
|
|
profit: day.approx_house_gross_minor,
|
|
profitAbs: Math.abs(day.approx_house_gross_minor),
|
|
})),
|
|
[series],
|
|
);
|
|
|
|
if (series.length === 0) {
|
|
return <DashboardChartEmpty />;
|
|
}
|
|
|
|
const plotHeight = series.length <= 7 ? 240 : series.length <= 14 ? 260 : 280;
|
|
|
|
if (metric === "overview") {
|
|
return (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto w-full"
|
|
style={{ height: plotHeight }}
|
|
>
|
|
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
|
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
interval={series.length > 14 ? Math.ceil(series.length / 7) - 1 : 0}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(_, payload) => {
|
|
const row = payload?.[0]?.payload as { fullDate?: string } | undefined;
|
|
return row?.fullDate ?? "";
|
|
}}
|
|
formatter={(value, name, item) => {
|
|
if (name === "profit") {
|
|
const row = item?.payload as { profit?: number } | undefined;
|
|
return formatMoney(row?.profit ?? Number(value), currency);
|
|
}
|
|
return formatMoney(Number(value), currency);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="bet" fill="var(--color-bet)" radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey="payout" fill="var(--color-payout)" radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey="profitAbs" name="profit" fill="var(--color-profit)" radius={[4, 4, 0, 0]} />
|
|
<ChartLegend content={<ChartLegendContent />} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
);
|
|
}
|
|
|
|
const activeKey = metric === "bet" ? "bet" : metric === "payout" ? "payout" : "profitAbs";
|
|
const dataKey = metric === "profit" ? "profitAbs" : activeKey;
|
|
|
|
return (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto w-full"
|
|
style={{ height: plotHeight }}
|
|
>
|
|
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
|
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
interval={series.length > 14 ? Math.ceil(series.length / 7) - 1 : 0}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(_, payload) => {
|
|
const row = payload?.[0]?.payload as { fullDate?: string } | undefined;
|
|
return row?.fullDate ?? "";
|
|
}}
|
|
formatter={(value) => formatMoney(Number(value), currency)}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey={dataKey} radius={[4, 4, 0, 0]}>
|
|
{chartData.map((row) => (
|
|
<Cell
|
|
key={row.fullDate}
|
|
fill={metricBarFill(metric, row.profit)}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ChartContainer>
|
|
);
|
|
}
|
|
|
|
export function PlayBreakdownChart({
|
|
rows,
|
|
metric,
|
|
formatMoney,
|
|
currency,
|
|
playLabel,
|
|
compact = false,
|
|
}: {
|
|
rows: AdminDashboardAnalyticsPlayRow[];
|
|
metric: DashboardAnalyticsMetric;
|
|
formatMoney: MoneyFormatter;
|
|
currency: string | null;
|
|
playLabel: (code: string, dimension: number) => string;
|
|
compact?: boolean;
|
|
}): ReactElement {
|
|
const { t } = useTranslation("dashboard");
|
|
const activeMetric = metric === "overview" ? "bet" : metric;
|
|
|
|
const chartConfig = useMemo(
|
|
() =>
|
|
buildTrendChartConfig({
|
|
bet: t("chartLegend.bet"),
|
|
payout: t("chartLegend.payout"),
|
|
profit: t("chartLegend.profit"),
|
|
}),
|
|
[t],
|
|
);
|
|
|
|
const chartData = useMemo(
|
|
() =>
|
|
rows.map((row) => {
|
|
const value = playMetricValue(row, activeMetric);
|
|
return {
|
|
id: `${row.play_code}-${row.dimension}`,
|
|
label: playLabel(row.play_code, row.dimension),
|
|
value: Math.abs(value),
|
|
signed: value,
|
|
payout: row.total_payout_minor,
|
|
profit: row.approx_house_gross_minor,
|
|
fill: metricBarFill(activeMetric, value),
|
|
};
|
|
}),
|
|
[rows, activeMetric, playLabel],
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return <DashboardChartEmpty message={t("analytics.noPlayData")} />;
|
|
}
|
|
|
|
const chartHeight = compact
|
|
? Math.max(160, rows.length * 32 + 24)
|
|
: Math.min(480, Math.max(180, rows.length * 36 + 48));
|
|
|
|
return (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto w-full min-w-0"
|
|
style={{ height: chartHeight }}
|
|
>
|
|
<BarChart
|
|
accessibilityLayer
|
|
layout="vertical"
|
|
data={chartData}
|
|
margin={{ top: 4, right: 8, bottom: 4, left: 0 }}
|
|
>
|
|
<XAxis type="number" hide />
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(_, payload) => {
|
|
const row = payload?.[0]?.payload as { label?: string } | undefined;
|
|
return row?.label ?? "";
|
|
}}
|
|
formatter={(value, _name, item) => {
|
|
const row = item.payload as { signed: number; payout: number; profit: number };
|
|
if (metric === "overview") {
|
|
return (
|
|
<span className="font-mono tabular-nums">
|
|
{formatMoney(row.signed, currency)} · {t("playBreakdownHint", {
|
|
payout: formatMoney(row.payout, currency),
|
|
profit: formatMoney(row.profit, currency),
|
|
})}
|
|
</span>
|
|
);
|
|
}
|
|
return formatMoney(row.signed, currency);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey="value" radius={4} barSize={compact ? 12 : 14}>
|
|
{chartData.map((entry) => (
|
|
<Cell key={entry.id} fill={entry.fill} />
|
|
))}
|
|
</Bar>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="label"
|
|
width={compact ? 76 : 100}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tick={{ fontSize: 10 }}
|
|
tickFormatter={(value) =>
|
|
typeof value === "string" && value.length > 10 ? `${value.slice(0, 10)}…` : String(value)
|
|
}
|
|
/>
|
|
</BarChart>
|
|
</ChartContainer>
|
|
);
|
|
}
|
|
|
|
export function PeriodCompareStrip({
|
|
series,
|
|
formatMoney,
|
|
currency,
|
|
}: {
|
|
series: AdminReportDailyProfitRow[];
|
|
formatMoney: MoneyFormatter;
|
|
currency: string | null;
|
|
}): ReactElement {
|
|
const { t } = useTranslation("dashboard");
|
|
const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0);
|
|
const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0);
|
|
const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0);
|
|
const maxAbs = Math.max(Math.abs(totalBet), Math.abs(totalPayout), Math.abs(totalProfit), 1);
|
|
const payoutRate = totalBet > 0 ? (totalPayout / totalBet) * 100 : 0;
|
|
const profitRate = totalBet > 0 ? (totalProfit / totalBet) * 100 : 0;
|
|
|
|
const rows = [
|
|
{
|
|
key: "bet",
|
|
label: t("chartLegend.bet"),
|
|
value: totalBet,
|
|
pctText: "100%",
|
|
width: (Math.abs(totalBet) / maxAbs) * 100,
|
|
fill: "var(--chart-1)",
|
|
},
|
|
{
|
|
key: "payout",
|
|
label: t("chartLegend.payout"),
|
|
value: totalPayout,
|
|
pctText: `${payoutRate.toFixed(1)}%`,
|
|
width: (Math.abs(totalPayout) / maxAbs) * 100,
|
|
fill: "var(--chart-5)",
|
|
},
|
|
{
|
|
key: "profit",
|
|
label: t("chartLegend.profit"),
|
|
value: totalProfit,
|
|
pctText: `${profitRate >= 0 ? "+" : ""}${profitRate.toFixed(1)}%`,
|
|
width: (Math.abs(totalProfit) / maxAbs) * 100,
|
|
fill: totalProfit >= 0 ? "var(--chart-2)" : DASHBOARD_CHART_COLORS.warning,
|
|
},
|
|
] as const;
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
{rows.map((row) => (
|
|
<div key={row.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<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={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"
|
|
style={{ width: `${Math.max(2, row.width)}%`, background: row.fill }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|