502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|