Files
tgex-frontend/app/dashboard/[workspaceId]/analytics/spending/page.tsx
2025-12-29 10:12:13 +03:00

502 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Spending Analytics Page - Enhanced with Charts
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation";
import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info } from "lucide-react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
LabelList,
} from "recharts";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatShortCurrency = (value: number) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
return value.toString();
};
const formatDateLabel = (date: string, grouping: DateGrouping) => {
const d = new Date(date);
const g = grouping.toLowerCase();
if (g === "day") {
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
} else if (g === "week") {
return `Нед. ${d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`;
} else {
return d.toLocaleDateString("ru-RU", { month: "short", year: "2-digit" });
}
};
// ============================================================================
// Chart Types
// ============================================================================
type ChartType = "line" | "bar";
type MetricKey = "cost" | "subscriptions" | "views";
// ============================================================================
// Chart Configurations
// ============================================================================
const chartConfig: ChartConfig = {
cost: {
label: "Расходы",
color: "hsl(var(--chart-1))",
},
subscriptions: {
label: "Подписчики",
color: "hsl(var(--chart-2))",
},
views: {
label: "Просмотры",
color: "hsl(var(--chart-3))",
},
};
// ============================================================================
// Component
// ============================================================================
export default function SpendingAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [grouping, setGrouping] = useState<DateGrouping>("month");
// Chart settings
const [chartType, setChartType] = useState<ChartType>("line");
const [showLabels, setShowLabels] = useState(false);
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]);
useEffect(() => {
loadData();
}, [workspaceId, projectFilterId, grouping]);
const loadData = async () => {
try {
setLoading(true);
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: projectFilterId,
grouping,
});
setSpending(data);
} catch (err) {
console.error("Failed to load spending:", err);
} finally {
setLoading(false);
}
};
// Format chart data
const chartData = useMemo(() => {
if (!spending?.chart_data) return [];
return spending.chart_data.map((item) => ({
period: item.period,
dateLabel: formatDateLabel(item.period, grouping),
cost: item.cost,
subscriptions: item.subscriptions,
views: item.views,
}));
}, [spending, grouping]);
// Toggle metric
const handleToggleMetric = (metric: MetricKey) => {
if (selectedMetrics.includes(metric)) {
if (selectedMetrics.length > 1) {
setSelectedMetrics(selectedMetrics.filter((m) => m !== metric));
}
} else {
setSelectedMetrics([...selectedMetrics, metric]);
}
};
// Render chart
const renderChart = () => {
if (chartData.length === 0) return null;
const ChartComponent = chartType === "line" ? LineChart : BarChart;
return (
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
<ChartComponent
data={chartData}
margin={{ left: 12, right: 12, top: showLabels ? 20 : 12, bottom: 12 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={formatShortCurrency}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
return payload[0].payload.period;
}
return "";
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{selectedMetrics.map((metric) =>
chartType === "line" ? (
<Line
key={metric}
dataKey={metric}
type="monotone"
stroke={`var(--color-${metric})`}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
>
{showLabels && (
<LabelList
dataKey={metric}
position="top"
formatter={(value: number) =>
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
}
className="fill-foreground text-[10px]"
/>
)}
</Line>
) : (
<Bar
key={metric}
dataKey={metric}
fill={`var(--color-${metric})`}
radius={[4, 4, 0, 0]}
>
{showLabels && (
<LabelList
dataKey={metric}
position="top"
formatter={(value: number) =>
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
}
className="fill-foreground text-[10px]"
/>
)}
</Bar>
)
)}
</ChartComponent>
</ChartContainer>
);
};
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
{ label: "Расходы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Динамика расходов</h1>
<p className="text-muted-foreground">Анализ расходов по периодам</p>
</div>
<Select value={grouping} onValueChange={(v) => setGrouping(v as DateGrouping)}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
</SelectContent>
</Select>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : spending ? (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(spending.total_subscriptions)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(spending.total_views)}</div>
</CardContent>
</Card>
</div>
{/* Chart with Settings */}
{chartData.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>График динамики</CardTitle>
<CardDescription>
Визуализация данных по выбранным метрикам
</CardDescription>
</div>
{/* Chart Settings */}
<div className="flex items-center gap-4">
{/* Chart Type Toggle */}
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={chartType === "line" ? "secondary" : "ghost"}
size="sm"
onClick={() => setChartType("line")}
aria-label="Линейный график"
>
<LineChartIcon className="h-4 w-4" />
</Button>
<Button
variant={chartType === "bar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setChartType("bar")}
aria-label="Столбчатая диаграмма"
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
{/* Labels Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="show-labels"
checked={showLabels}
onCheckedChange={(checked) => setShowLabels(checked === true)}
/>
<Label htmlFor="show-labels" className="text-sm cursor-pointer">
Подписи
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Показывать значения на точках графика
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* Metrics Selection */}
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
<span className="text-sm text-muted-foreground">Метрики:</span>
{(["cost", "subscriptions", "views"] as MetricKey[]).map((metric) => (
<div key={metric} className="flex items-center gap-2">
<Checkbox
id={`metric-${metric}`}
checked={selectedMetrics.includes(metric)}
onCheckedChange={() => handleToggleMetric(metric)}
/>
<Label
htmlFor={`metric-${metric}`}
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: `var(--color-${metric})` }}
/>
{chartConfig[metric].label}
</Label>
</div>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">{renderChart()}</CardContent>
</Card>
)}
{/* Data Table */}
{spending.chart_data && spending.chart_data.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Данные по периодам</CardTitle>
<CardDescription>{spending.chart_data.length} периодов</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Subscription</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPM
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
</Tooltip>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spending.chart_data.map((item, index) => {
const cps = item.subscriptions > 0 ? item.cost / item.subscriptions : null;
const cpm = item.views > 0 ? (item.cost / item.views) * 1000 : null;
return (
<TableRow key={index}>
<TableCell className="font-medium">{item.period}</TableCell>
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
<TableCell className="text-right">
{formatNumber(item.subscriptions)}
</TableCell>
<TableCell className="text-right">{formatNumber(item.views)}</TableCell>
<TableCell className="text-right">
{cps ? formatCurrency(cps) : "—"}
</TableCell>
<TableCell className="text-right">
{cpm ? formatCurrency(cpm) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
<p className="text-muted-foreground text-center">
Создайте размещения для отображения статистики
</p>
</CardContent>
</Card>
)}
</>
) : null}
</div>
</TooltipProvider>
);
}