"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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; 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; width: "full" | "half"; } // Компонент для sortable item interface SortableChartItemProps { chart: ChartData; index: number; channels: TargetChannel[]; chartsLength: number; onBuild: (chartId: string, config: ChartData["config"]) => void; onRemove: (chartId: string) => void; onWidthChange: (chartId: string, width: "full" | "half") => void; } const SortableChartItem: React.FC = ({ chart, index, channels, chartsLength, onBuild, onRemove, onWidthChange, }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: chart.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
onBuild(chart.id, config)} onRemove={chartsLength > 1 ? () => onRemove(chart.id) : undefined} onWidthChange={(width) => onWidthChange(chart.id, width)} showRemoveButton={chartsLength > 1} chartNumber={index + 1} dragHandleProps={{ ...attributes, ...listeners }} />
); }; export default function TargetChannelsAnalyticsPage() { const [channels, setChannels] = useState([]); const [charts, setCharts] = useState([ { id: "chart-1", config: { targetChannelIds: [], metrics: [], dateFrom: null, dateTo: null, grouping: "day", }, data: null, loading: false, width: "full", }, ]); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const [loading, setLoading] = useState(true); const [error, setError] = useState(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, width: "full", }, ]); }; const handleRemoveChart = (chartId: string) => { if (charts.length === 1) { alert("Должен остаться хотя бы один график"); return; } setCharts(charts.filter((c) => c.id !== chartId)); }; const handleWidthChange = (chartId: string, width: "full" | "half") => { setCharts( charts.map((chart) => chart.id === chartId ? { ...chart, width } : chart ) ); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setCharts((items) => { const oldIndex = items.findIndex((item) => item.id === active.id); const newIndex = items.findIndex((item) => item.id === over.id); return arrayMove(items, oldIndex, newIndex); }); } }; // Собираем все данные для таблицы const getAllTableData = (): SpendingDataPoint[] => { const allData: SpendingDataPoint[] = []; const periodMap = new Map(); 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 ( <>
); } return ( <>

Аналитика целевых каналов

Создайте графики для анализа показателей

{error && ( {error} )} {channels.length === 0 ? ( Нет активных целевых каналов. Добавьте канал для начала работы с аналитикой. ) : ( <> {/* Графики */} c.id)} strategy={verticalListSortingStrategy} >
{charts.map((chart, index) => ( ))}
{/* Таблица */} {tableData.length > 0 && ( Табличное представление Период Расходы Подписки Просмотры CPF CPM {tableData.map((row) => ( {row.period} {formatCurrency(row.cost)} {formatNumber(row.subscriptions)} {formatNumber(row.views)} {row.cpf !== null ? formatCurrency(row.cpf) : "—"} {row.cpm !== null ? formatCurrency(row.cpm) : "—"} ))}
)} )}
); }