feat: update analitycs

This commit is contained in:
ivannoskov
2025-12-01 07:43:47 +03:00
parent 270e75e192
commit e06b53d133
9 changed files with 1631 additions and 3 deletions

View File

@@ -0,0 +1,368 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChartConfigPanel } from "@/components/analytics/chart-config-panel";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { channelsApi, analyticsApi } from "@/lib/api";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import { Plus, Loader2, AlertCircle, TrendingUp } from "lucide-react";
import type {
TargetChannel,
SpendingAnalytics,
AnalyticsMetric,
DateGrouping,
SpendingDataPoint,
} from "@/lib/types/api";
import { format } from "date-fns";
interface ChartData {
id: string;
config: {
targetChannelIds: string[];
metrics: AnalyticsMetric[];
dateFrom: Date | null;
dateTo: Date | null;
grouping: DateGrouping;
};
data: SpendingAnalytics | null;
loading: boolean;
}
export default function TargetChannelsAnalyticsPage() {
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [charts, setCharts] = useState<ChartData[]>([
{
id: "chart-1",
config: {
targetChannelIds: [],
metrics: [],
dateFrom: null,
dateTo: null,
grouping: "day",
},
data: null,
loading: false,
},
]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
setLoading(true);
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter((c) => c.is_active);
setChannels(activeChannels);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setLoading(false);
}
};
const handleBuildChart = async (
chartId: string,
config: ChartData["config"]
) => {
const chartIndex = charts.findIndex((c) => c.id === chartId);
if (chartIndex === -1) return;
// Обновляем состояние графика
const updatedCharts = [...charts];
updatedCharts[chartIndex].loading = true;
updatedCharts[chartIndex].config = config;
setCharts(updatedCharts);
try {
// Загружаем данные для каждого канала
const promises = config.targetChannelIds.map((channelId) =>
analyticsApi.spending({
target_channel_id: channelId,
date_from: config.dateFrom
? format(config.dateFrom, "yyyy-MM-dd'T'HH:mm:ss'Z'")
: undefined,
date_to: config.dateTo
? format(config.dateTo, "yyyy-MM-dd'T'HH:mm:ss'Z'")
: undefined,
grouping: config.grouping,
})
);
const results = await Promise.all(promises);
// Объединяем данные (если несколько каналов)
const combinedData: SpendingAnalytics = results.reduce(
(acc, result) => {
acc.total_cost += result.total_cost;
acc.total_subscriptions += result.total_subscriptions;
acc.total_views += result.total_views;
// Объединяем chart_data по периодам
result.chart_data.forEach((dataPoint) => {
const existing = acc.chart_data.find(
(d) => d.period === dataPoint.period
);
if (existing) {
existing.cost += dataPoint.cost;
existing.subscriptions += dataPoint.subscriptions;
existing.views += dataPoint.views;
// CPF и CPM пересчитаем после
} else {
acc.chart_data.push({ ...dataPoint });
}
});
return acc;
},
{
total_cost: 0,
total_subscriptions: 0,
total_views: 0,
avg_cpf: null,
avg_cpm: null,
chart_data: [] as SpendingDataPoint[],
}
);
// Пересчитываем CPF и CPM для каждого периода
combinedData.chart_data.forEach((dataPoint) => {
dataPoint.cpf =
dataPoint.subscriptions > 0
? dataPoint.cost / dataPoint.subscriptions
: null;
dataPoint.cpm =
dataPoint.views > 0 ? (dataPoint.cost / dataPoint.views) * 1000 : null;
});
// Пересчитываем средние значения
combinedData.avg_cpf =
combinedData.total_subscriptions > 0
? combinedData.total_cost / combinedData.total_subscriptions
: null;
combinedData.avg_cpm =
combinedData.total_views > 0
? (combinedData.total_cost / combinedData.total_views) * 1000
: null;
// Сортируем по периодам
combinedData.chart_data.sort((a, b) =>
a.period.localeCompare(b.period)
);
// Обновляем данные графика
const finalCharts = [...updatedCharts];
finalCharts[chartIndex].data = combinedData;
finalCharts[chartIndex].loading = false;
setCharts(finalCharts);
} catch (err: any) {
alert(err?.error?.message || "Ошибка загрузки данных");
const finalCharts = [...updatedCharts];
finalCharts[chartIndex].loading = false;
setCharts(finalCharts);
}
};
const handleAddChart = () => {
setCharts([
...charts,
{
id: `chart-${Date.now()}`,
config: {
targetChannelIds: [],
metrics: [],
dateFrom: null,
dateTo: null,
grouping: "day",
},
data: null,
loading: false,
},
]);
};
const handleRemoveChart = (chartId: string) => {
if (charts.length === 1) {
alert("Должен остаться хотя бы один график");
return;
}
setCharts(charts.filter((c) => c.id !== chartId));
};
// Собираем все данные для таблицы
const getAllTableData = (): SpendingDataPoint[] => {
const allData: SpendingDataPoint[] = [];
const periodMap = new Map<string, SpendingDataPoint>();
charts.forEach((chart) => {
if (!chart.data) return;
chart.data.chart_data.forEach((dataPoint) => {
const existing = periodMap.get(dataPoint.period);
if (existing) {
existing.cost += dataPoint.cost;
existing.subscriptions += dataPoint.subscriptions;
existing.views += dataPoint.views;
// Пересчитываем CPF и CPM
existing.cpf =
existing.subscriptions > 0
? existing.cost / existing.subscriptions
: null;
existing.cpm =
existing.views > 0 ? (existing.cost / existing.views) * 1000 : null;
} else {
periodMap.set(dataPoint.period, { ...dataPoint });
}
});
});
return Array.from(periodMap.values()).sort((a, b) =>
a.period.localeCompare(b.period)
);
};
const tableData = getAllTableData();
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/analytics" },
{ label: "Целевые каналы" },
]}
/>
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/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-3xl font-bold tracking-tight">
Аналитика целевых каналов
</h1>
<p className="text-muted-foreground mt-1">
Создайте графики для анализа показателей
</p>
</div>
<Button onClick={handleAddChart}>
<Plus className="mr-2 h-4 w-4" />
Добавить график
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{channels.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет активных целевых каналов. Добавьте канал для начала работы с
аналитикой.
</AlertDescription>
</Alert>
) : (
<>
{/* Графики */}
<div className="space-y-6">
{charts.map((chart, index) => (
<ChartConfigPanel
key={chart.id}
channels={channels}
data={chart.data}
loading={chart.loading}
onBuild={(config) => handleBuildChart(chart.id, config)}
onRemove={
charts.length > 1
? () => handleRemoveChart(chart.id)
: undefined
}
showRemoveButton={charts.length > 1}
chartNumber={index + 1}
/>
))}
</div>
{/* Таблица */}
{tableData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Табличное представление</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписки</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="text-right">CPM</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tableData.map((row) => (
<TableRow key={row.period}>
<TableCell className="font-medium">
{row.period}
</TableCell>
<TableCell className="text-right">
{formatCurrency(row.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(row.subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(row.views)}
</TableCell>
<TableCell className="text-right">
{row.cpf !== null ? formatCurrency(row.cpf) : "—"}
</TableCell>
<TableCell className="text-right">
{row.cpm !== null ? formatCurrency(row.cpm) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)}
</div>
</>
);
}