From e06b53d1334460f77bc6a29754e69e6fe4acab0a Mon Sep 17 00:00:00 2001 From: ivannoskov Date: Mon, 1 Dec 2025 07:43:47 +0300 Subject: [PATCH] feat: update analitycs --- app/(dashboard)/analytics/creatives/page.tsx | 606 ++++++++++++++++++ .../analytics/target-channels/page.tsx | 368 +++++++++++ components/analytics/analytics-chart.tsx | 181 ++++++ components/analytics/chart-config-panel.tsx | 391 +++++++++++ components/layout/app-sidebar.tsx | 6 +- components/ui/switch.tsx | 31 + lib/types/api.ts | 19 + package.json | 1 + pnpm-lock.yaml | 31 + 9 files changed, 1631 insertions(+), 3 deletions(-) create mode 100644 app/(dashboard)/analytics/creatives/page.tsx create mode 100644 app/(dashboard)/analytics/target-channels/page.tsx create mode 100644 components/analytics/analytics-chart.tsx create mode 100644 components/analytics/chart-config-panel.tsx create mode 100644 components/ui/switch.tsx diff --git a/app/(dashboard)/analytics/creatives/page.tsx b/app/(dashboard)/analytics/creatives/page.tsx new file mode 100644 index 0000000..0bb4496 --- /dev/null +++ b/app/(dashboard)/analytics/creatives/page.tsx @@ -0,0 +1,606 @@ +"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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { ru } from "date-fns/locale"; +import { channelsApi, analyticsApi } from "@/lib/api"; +import { formatCurrency, formatNumber } from "@/lib/utils/format"; +import { + Loader2, + AlertCircle, + CalendarIcon, + ArrowUpDown, + ArrowUp, + ArrowDown, + Download, +} from "lucide-react"; +import type { TargetChannel, CreativeAnalytics } from "@/lib/types/api"; +import * as XLSX from "xlsx"; + +type SortField = + | "name" + | "placements_count" + | "total_cost" + | "total_subscriptions" + | "total_views" + | "avg_cpf" + | "avg_cpm" + | null; +type SortDirection = "asc" | "desc" | null; + +export default function CreativesAnalyticsPage() { + const [channels, setChannels] = useState([]); + const [selectedChannelId, setSelectedChannelId] = useState(""); + const [dateFrom, setDateFrom] = useState(); + const [dateTo, setDateTo] = useState(); + const [creatives, setCreatives] = useState([]); + const [filteredCreatives, setFilteredCreatives] = useState< + CreativeAnalytics[] + >([]); + const [loading, setLoading] = useState(true); + const [loadingData, setLoadingData] = useState(false); + const [error, setError] = useState(null); + const [sortField, setSortField] = useState(null); + const [sortDirection, setSortDirection] = 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); + if (activeChannels.length > 0) { + setSelectedChannelId(activeChannels[0].id); + } + } catch (err: any) { + setError(err?.error?.message || "Ошибка загрузки каналов"); + } finally { + setLoading(false); + } + }; + + const loadCreativesData = async () => { + if (!selectedChannelId) return; + + try { + setLoadingData(true); + const response = await analyticsApi.creatives({ + target_channel_id: selectedChannelId, + }); + setCreatives(response.creatives); + setFilteredCreatives(response.creatives); + } catch (err: any) { + alert(err?.error?.message || "Ошибка загрузки данных"); + } finally { + setLoadingData(false); + } + }; + + // Сортировка + const handleSort = (field: SortField) => { + if (sortField === field) { + if (sortDirection === "asc") { + setSortDirection("desc"); + } else if (sortDirection === "desc") { + setSortField(null); + setSortDirection(null); + } + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + const getSortIcon = (field: SortField) => { + if (sortField !== field) { + return ; + } + if (sortDirection === "asc") { + return ; + } + return ; + }; + + useEffect(() => { + if (!sortField || !sortDirection) { + setFilteredCreatives([...creatives]); + return; + } + + const sorted = [...creatives].sort((a, b) => { + let aValue: string | number | null = 0; + let bValue: string | number | null = 0; + + switch (sortField) { + case "name": + aValue = a.name; + bValue = b.name; + break; + case "placements_count": + aValue = a.placements_count; + bValue = b.placements_count; + break; + case "total_cost": + aValue = a.total_cost; + bValue = b.total_cost; + break; + case "total_subscriptions": + aValue = a.total_subscriptions; + bValue = b.total_subscriptions; + break; + case "total_views": + aValue = a.total_views; + bValue = b.total_views; + break; + case "avg_cpf": + aValue = a.avg_cpf || 0; + bValue = b.avg_cpf || 0; + break; + case "avg_cpm": + aValue = a.avg_cpm || 0; + bValue = b.avg_cpm || 0; + break; + } + + if (typeof aValue === "string" && typeof bValue === "string") { + return sortDirection === "asc" + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + } + + if (typeof aValue === "number" && typeof bValue === "number") { + return sortDirection === "asc" ? aValue - bValue : bValue - aValue; + } + + return 0; + }); + + setFilteredCreatives(sorted); + }, [sortField, sortDirection, creatives]); + + // Экспорт + const exportToExcel = () => { + const data = filteredCreatives.map((creative) => ({ + Название: creative.name, + Размещений: creative.placements_count, + "Общая стоимость": creative.total_cost, + Подписки: creative.total_subscriptions, + Просмотры: creative.total_views, + "Средний CPF": creative.avg_cpf || 0, + "Средний CPM": creative.avg_cpm || 0, + })); + + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Креативы"); + + const colWidths = [ + { wch: 30 }, + { wch: 12 }, + { wch: 15 }, + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + { wch: 12 }, + ]; + ws["!cols"] = colWidths; + + XLSX.writeFile( + wb, + `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.xlsx` + ); + }; + + const exportToCSV = () => { + const data = filteredCreatives.map((creative) => ({ + Название: creative.name, + Размещений: creative.placements_count, + "Общая стоимость": creative.total_cost, + Подписки: creative.total_subscriptions, + Просмотры: creative.total_views, + "Средний CPF": creative.avg_cpf || 0, + "Средний CPM": creative.avg_cpm || 0, + })); + + const headers = Object.keys(data[0]); + const csvContent = [ + headers.join(","), + ...data.map((row) => + headers + .map((header) => { + const value = row[header as keyof typeof row]; + return value !== null && value !== undefined ? value : ""; + }) + .join(",") + ), + ].join("\n"); + + const blob = new Blob(["\ufeff" + csvContent], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.csv`; + link.click(); + URL.revokeObjectURL(link.href); + }; + + const exportToTXT = () => { + const data = filteredCreatives.map((creative) => ({ + Название: creative.name, + Размещений: creative.placements_count, + "Общая стоимость": creative.total_cost, + Подписки: creative.total_subscriptions, + Просмотры: creative.total_views, + "Средний CPF": creative.avg_cpf || 0, + "Средний CPM": creative.avg_cpm || 0, + })); + + const headers = Object.keys(data[0]); + const columnWidths = headers.map((header) => { + const maxLength = Math.max( + header.length, + ...data.map((row) => + String(row[header as keyof typeof row] || "").length + ) + ); + return Math.min(maxLength + 2, 30); + }); + + const txtContent = [ + headers.map((header, i) => header.padEnd(columnWidths[i])).join(" | "), + columnWidths.map((width) => "-".repeat(width)).join("-+-"), + ...data.map((row) => + headers + .map((header, i) => + String(row[header as keyof typeof row] || "").padEnd(columnWidths[i]) + ) + .join(" | ") + ), + ].join("\n"); + + const blob = new Blob([txtContent], { type: "text/plain;charset=utf-8" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.txt`; + link.click(); + URL.revokeObjectURL(link.href); + }; + + if (loading) { + return ( + <> + +
+ +
+ + ); + } + + return ( + <> + +
+
+
+

+ Аналитика креативов +

+

+ Производительность рекламных креативов +

+
+
+ + {error && ( + + + {error} + + )} + + {channels.length === 0 ? ( + + + + Нет активных целевых каналов. Добавьте канал для начала работы с + аналитикой. + + + ) : ( + <> + {/* Фильтры */} + + + Фильтры + + Настройте параметры для отображения данных + + + +
+
+ + +
+ +
+ + + + + + + + + +
+ +
+ + + + + + + + + +
+
+ + +
+
+ + {/* Таблица */} + {loadingData ? ( + + + + + + ) : filteredCreatives.length > 0 ? ( + + +
+ Креативы + + Показатели производительности креативов + +
+ + + + + + + + Экспорт в Excel (.xlsx) + + + Экспорт в CSV (.csv) + + + Экспорт в TXT (.txt) + + + +
+ + + + + handleSort("name")} + > + Название + {getSortIcon("name")} + + handleSort("placements_count")} + > + Размещений + {getSortIcon("placements_count")} + + handleSort("total_cost")} + > + Общая стоимость + {getSortIcon("total_cost")} + + handleSort("total_subscriptions")} + > + Подписки + {getSortIcon("total_subscriptions")} + + handleSort("total_views")} + > + Просмотры + {getSortIcon("total_views")} + + handleSort("avg_cpf")} + > + Средний CPF + {getSortIcon("avg_cpf")} + + handleSort("avg_cpm")} + > + Средний CPM + {getSortIcon("avg_cpm")} + + + + + {filteredCreatives.map((creative) => ( + + + {creative.name} + + + {formatNumber(creative.placements_count)} + + + {formatCurrency(creative.total_cost)} + + + {formatNumber(creative.total_subscriptions)} + + + {formatNumber(creative.total_views)} + + + {creative.avg_cpf !== null + ? formatCurrency(creative.avg_cpf) + : "—"} + + + {creative.avg_cpm !== null + ? formatCurrency(creative.avg_cpm) + : "—"} + + + ))} + +
+
+
+ ) : ( + creatives.length === 0 && + !loadingData && ( + + + + Нет данных по креативам. Примените фильтры для отображения + данных. + + + ) + )} + + )} +
+ + ); +} + diff --git a/app/(dashboard)/analytics/target-channels/page.tsx b/app/(dashboard)/analytics/target-channels/page.tsx new file mode 100644 index 0000000..118c923 --- /dev/null +++ b/app/(dashboard)/analytics/target-channels/page.tsx @@ -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([]); + const [charts, setCharts] = useState([ + { + 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(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(); + + 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 ? ( + + + + Нет активных целевых каналов. Добавьте канал для начала работы с + аналитикой. + + + ) : ( + <> + {/* Графики */} +
+ {charts.map((chart, index) => ( + handleBuildChart(chart.id, config)} + onRemove={ + charts.length > 1 + ? () => handleRemoveChart(chart.id) + : undefined + } + showRemoveButton={charts.length > 1} + chartNumber={index + 1} + /> + ))} +
+ + {/* Таблица */} + {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) : "—"} + + + ))} + +
+
+
+ )} + + )} +
+ + ); +} + diff --git a/components/analytics/analytics-chart.tsx b/components/analytics/analytics-chart.tsx new file mode 100644 index 0000000..6270e64 --- /dev/null +++ b/components/analytics/analytics-chart.tsx @@ -0,0 +1,181 @@ +"use client"; + +import React from "react"; +import { CartesianGrid, Line, LineChart, XAxis, Bar, BarChart } from "recharts"; +import { CardContent } from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, +} from "@/components/ui/chart"; +import { formatCurrency, formatNumber } from "@/lib/utils/format"; +import type { SpendingDataPoint, AnalyticsMetric } from "@/lib/types/api"; + +interface AnalyticsChartProps { + data: SpendingDataPoint[]; + metrics: AnalyticsMetric[]; + chartType: "line" | "bar"; + showDataLabels?: boolean; +} + +const METRIC_LABELS: Record = { + cost: "Расходы", + subscriptions: "Подписки", + views: "Просмотры", + cpf: "CPF", + cpm: "CPM", +}; + +export const AnalyticsChart: React.FC = ({ + data, + metrics, + chartType, + showDataLabels = false, +}) => { + // Создаем конфигурацию для chart + const chartConfig: ChartConfig = metrics.reduce((acc, metric, index) => { + acc[metric] = { + label: METRIC_LABELS[metric], + color: `var(--chart-${index + 1})`, + }; + return acc; + }, {} as ChartConfig); + + const formatValue = (value: number | null, metric: AnalyticsMetric) => { + if (value === null) return "—"; + if (metric === "cost" || metric === "cpf" || metric === "cpm") { + return formatCurrency(value); + } + return formatNumber(value); + }; + + if (chartType === "bar") { + return ( + + + + + + `Период: ${value}`} + formatter={(value, name) => [ + formatValue( + value as number, + name as AnalyticsMetric + ), + chartConfig[name as AnalyticsMetric]?.label || name, + ]} + /> + } + /> + } /> + {metrics.map((metric) => ( + + formatValue(value, metric), + fontSize: 10, + } + : undefined + } + /> + ))} + + + + ); + } + + return ( + + + + + + `Период: ${value}`} + formatter={(value, name) => [ + formatValue( + value as number, + name as AnalyticsMetric + ), + chartConfig[name as AnalyticsMetric]?.label || name, + ]} + /> + } + /> + } /> + {metrics.map((metric) => ( + + formatValue(value, metric), + fontSize: 10, + } + : undefined + } + /> + ))} + + + + ); +}; + diff --git a/components/analytics/chart-config-panel.tsx b/components/analytics/chart-config-panel.tsx new file mode 100644 index 0000000..71cc40c --- /dev/null +++ b/components/analytics/chart-config-panel.tsx @@ -0,0 +1,391 @@ +"use client"; + +import React, { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { ru } from "date-fns/locale"; +import { CalendarIcon, X, TrendingUp, BarChart3, LineChart } from "lucide-react"; +import type { + TargetChannel, + AnalyticsMetric, + DateGrouping, +} from "@/lib/types/api"; +import { AnalyticsChart } from "./analytics-chart"; +import type { SpendingAnalytics } from "@/lib/types/api"; + +interface ChartConfigPanelProps { + channels: TargetChannel[]; + data: SpendingAnalytics | null; + loading: boolean; + onBuild: (config: { + targetChannelIds: string[]; + metrics: AnalyticsMetric[]; + dateFrom: Date | null; + dateTo: Date | null; + grouping: DateGrouping; + }) => void; + onRemove?: () => void; + showRemoveButton?: boolean; + chartNumber?: number; +} + +const METRICS: { value: AnalyticsMetric; label: string }[] = [ + { value: "cost", label: "Расходы" }, + { value: "subscriptions", label: "Подписки" }, + { value: "views", label: "Просмотры" }, + { value: "cpf", label: "CPF (Cost Per Follow)" }, + { value: "cpm", label: "CPM (Cost Per Mille)" }, +]; + +const GROUPINGS: { value: DateGrouping; label: string }[] = [ + { value: "day", label: "По дням" }, + { value: "week", label: "По неделям" }, + { value: "month", label: "По месяцам" }, + { value: "quarter", label: "По кварталам" }, + { value: "year", label: "По годам" }, +]; + +export const ChartConfigPanel: React.FC = ({ + channels, + data, + loading, + onBuild, + onRemove, + showRemoveButton = false, + chartNumber = 1, +}) => { + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + const [selectedMetrics, setSelectedMetrics] = useState([]); + const [dateFrom, setDateFrom] = useState(); + const [dateTo, setDateTo] = useState(); + const [grouping, setGrouping] = useState("day"); + const [chartType, setChartType] = useState<"line" | "bar">("line"); + const [showDataLabels, setShowDataLabels] = useState(false); + + // Отслеживание изменений настроек + const [lastBuiltConfig, setLastBuiltConfig] = useState(null); + const [isBuilt, setIsBuilt] = useState(false); + + // Текущая конфигурация как строка для сравнения + const currentConfigString = JSON.stringify({ + selectedChannelIds, + selectedMetrics, + dateFrom: dateFrom?.toISOString(), + dateTo: dateTo?.toISOString(), + grouping, + }); + + // Проверяем, изменились ли настройки + const hasChanges = isBuilt && lastBuiltConfig !== currentConfigString; + + const toggleChannel = (channelId: string) => { + setSelectedChannelIds((prev) => + prev.includes(channelId) + ? prev.filter((id) => id !== channelId) + : [...prev, channelId] + ); + }; + + const toggleMetric = (metric: AnalyticsMetric) => { + setSelectedMetrics((prev) => + prev.includes(metric) + ? prev.filter((m) => m !== metric) + : [...prev, metric] + ); + }; + + const handleBuild = () => { + if (selectedChannelIds.length === 0 || selectedMetrics.length === 0) { + alert("Выберите хотя бы один канал и одну метрику"); + return; + } + + onBuild({ + targetChannelIds: selectedChannelIds, + metrics: selectedMetrics, + dateFrom: dateFrom || null, + dateTo: dateTo || null, + grouping, + }); + + setLastBuiltConfig(currentConfigString); + setIsBuilt(true); + }; + + const getButtonText = () => { + if (loading) return "Загрузка..."; + if (!isBuilt) return "Построить график"; + if (hasChanges) return "Обновить график"; + return "График построен"; + }; + + const isButtonDisabled = loading || (isBuilt && !hasChanges); + + return ( + + +
+
+ График #{chartNumber} + + Настройте параметры и постройте график + +
+ {showRemoveButton && onRemove && ( + + )} +
+
+ + {/* Настройки */} +
+ {/* Выбор каналов */} +
+ + + + + + +
+ {channels.map((channel) => ( +
+ toggleChannel(channel.id)} + /> + +
+ ))} +
+
+
+
+ + {/* Выбор метрик */} +
+ + + + + + +
+ {METRICS.map((metric) => ( +
+ toggleMetric(metric.value)} + /> + +
+ ))} +
+
+
+
+ + {/* Период от */} +
+ + + + + + + + + +
+ + {/* Период до */} +
+ + + + + + + + + +
+
+ +
+ {/* Группировка */} +
+ + +
+ + {/* Тип графика */} +
+ +
+ + +
+
+ + {/* Подписи значений */} +
+ +
+ + +
+
+ + {/* Кнопка построения */} +
+ + +
+
+ + {/* График */} + {data && ( + + )} +
+
+ ); +}; + diff --git a/components/layout/app-sidebar.tsx b/components/layout/app-sidebar.tsx index e1458b6..762250b 100644 --- a/components/layout/app-sidebar.tsx +++ b/components/layout/app-sidebar.tsx @@ -94,11 +94,11 @@ const data = { url: "/analytics/costs", }, { - title: "По каналам", - url: "/analytics/channels", + title: "Целевые каналы", + url: "/analytics/target-channels", }, { - title: "По креативам", + title: "Креативы", url: "/analytics/creatives", }, ], diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 0000000..6a2b524 --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/lib/types/api.ts b/lib/types/api.ts index b7e34c7..9702834 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -313,6 +313,25 @@ export interface ExternalChannelsAnalytics { external_channels: ExternalChannelAnalytics[]; } +// Analytics Configuration Types +export type AnalyticsMetric = "cost" | "subscriptions" | "views" | "cpf" | "cpm"; + +export interface ChartConfig { + id: string; + targetChannelIds: string[]; // Multiple selection + metrics: AnalyticsMetric[]; + dateFrom: Date | null; + dateTo: Date | null; + grouping: DateGrouping; +} + +export interface SpendingAnalyticsQueryParams { + target_channel_id?: string; + date_from?: string; + date_to?: string; + grouping?: DateGrouping; +} + // ---------------------------------------------------------------------------- // Common API Responses // ---------------------------------------------------------------------------- diff --git a/package.json b/package.json index ec75daa..1c7924e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0323b5c..47e2428 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -941,6 +944,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.13': resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: @@ -3685,6 +3701,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3