"use client"; // ============================================================================ // Projects Analytics Page with Drag-and-Drop Panels // ============================================================================ import { useEffect, useState, useMemo, useCallback } from "react"; import { useParams } from "next/navigation"; import { Loader2, Plus, X, GripVertical, Calendar as CalendarIcon, Download, } from "lucide-react"; import { format, subDays, startOfDay, endOfDay } from "date-fns"; import { ru } from "date-fns/locale"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; 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 { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, type ChartConfig, } from "@/components/ui/chart"; import { LineChart, Line, AreaChart, Area, BarChart, Bar, CartesianGrid, XAxis, YAxis, } from "recharts"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import type { Project, ProjectsAnalyticsResponse, ProjectsAnalyticsMetrics, } from "@/lib/types/api"; // ============================================================================ // Types // ============================================================================ type DateRangeType = { from?: Date; to?: Date; } | undefined; type MetricType = | "avg_conversion" | "total_cost" | "total_discounts" | "avg_discount_percent" | "purchases_count" | "avg_cpm" | "avg_post_cost" | "clicks_count" | "reach_volume" | "avg_cpf"; type GroupingType = "day" | "week" | "month" | "quarter"; type ChartType = "area" | "bar" | "line"; interface ChartSeries { id: string; projectIds: string[]; metric: MetricType; } interface ChartPanelConfig { id: string; title: string; series: ChartSeries[]; // Массив графиков (линий) chartType: ChartType; // Тип графика dateRange: DateRangeType; grouping: GroupingType; dateGrouping: "purchase_date" | "link_date" | "placement_date"; colSpan: 1 | 2; } // ============================================================================ // 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 formatPercent = (value: number | null) => { if (value === null) return "—"; return `${value.toFixed(1)}%`; }; const getMetricLabel = (metric: MetricType): string => { const labels: Record = { avg_conversion: "Средняя конверсия закупов", total_cost: "Сумма расходов", total_discounts: "Сумма скидок", avg_discount_percent: "Средний % скидки", purchases_count: "Кол-во закупов", avg_cpm: "Средний CPM", avg_post_cost: "Средняя стоимость поста", clicks_count: "Кол-во переходов", reach_volume: "Объём охвата", avg_cpf: "Средний CPF", }; return labels[metric]; }; const getMetricFormatter = (metric: MetricType) => { if (metric === "avg_discount_percent" || metric === "avg_conversion") { return formatPercent; } if ( metric === "total_cost" || metric === "total_discounts" || metric === "avg_cpm" || metric === "avg_post_cost" || metric === "avg_cpf" ) { return formatCurrency; } return formatNumber; }; const getMetricValue = ( metrics: ProjectsAnalyticsMetrics, metric: MetricType ): number | null => { switch (metric) { case "total_cost": return metrics.total_cost; case "purchases_count": return metrics.purchases_count; case "avg_cpf": return metrics.avg_cpf; case "avg_cpm": return metrics.avg_cpm; case "avg_post_cost": return metrics.avg_post_cost; case "clicks_count": return metrics.clicks_count; case "reach_volume": return metrics.reach_volume; case "total_discounts": return metrics.total_discounts; case "avg_discount_percent": return metrics.avg_discount_percent; case "avg_conversion": return metrics.avg_conversion; default: return null; } }; // Chart color keys for different series const CHART_COLOR_KEYS = [ "chart-1", "chart-2", "chart-3", "chart-4", "chart-5", ]; // ============================================================================ // Sortable Panel Component // ============================================================================ interface SortablePanelProps { panel: ChartPanelConfig; projects: Project[]; onUpdate: (panel: ChartPanelConfig) => void; onDelete: (id: string) => void; workspaceId: string; } const SortablePanel: React.FC = ({ panel, projects, onUpdate, onDelete, workspaceId, }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: panel.id }); const [loading, setLoading] = useState(false); const [seriesData, setSeriesData] = useState< Record >({}); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; // Load chart data for all series useEffect(() => { loadAllSeriesData(); }, [ panel.series, panel.dateRange, panel.grouping, panel.dateGrouping, workspaceId, ]); const loadAllSeriesData = async () => { if (panel.series.length === 0) { setSeriesData({}); return; } try { setLoading(true); const dataPromises = panel.series.map(async (series) => { try { const response = await demoAwareAnalyticsApi.projects(workspaceId, { project_ids: series.projectIds && series.projectIds.length > 0 ? series.projectIds : undefined, date_from: panel.dateRange?.from ? startOfDay(panel.dateRange.from).toISOString() : undefined, date_to: panel.dateRange?.to ? endOfDay(panel.dateRange.to).toISOString() : undefined, grouping: panel.grouping, date_grouping: panel.dateGrouping, metrics: [series.metric], }); return { seriesId: series.id, data: response }; } catch (err) { console.error(`Failed to load data for series ${series.id}:`, err); return { seriesId: series.id, data: null }; } }); const results = await Promise.all(dataPromises); const newSeriesData: Record = {}; results.forEach(({ seriesId, data }) => { if (data) { newSeriesData[seriesId] = data; } }); setSeriesData(newSeriesData); } catch (err) { console.error("Failed to load chart data:", err); } finally { setLoading(false); } }; // Merge all series data into one chart dataset const chartData = useMemo(() => { if (panel.series.length === 0) return []; // Get all unique periods from all series const allPeriods = new Set(); Object.values(seriesData).forEach((data) => { data.periods.forEach((period) => allPeriods.add(period.period)); }); const sortedPeriods = Array.from(allPeriods).sort(); // Create merged data return sortedPeriods.map((period) => { const periodData: any = { period, periodLabel: "", }; // Find period label from first available series const firstSeriesData = Object.values(seriesData)[0]; if (firstSeriesData) { const periodInfo = firstSeriesData.periods.find((p) => p.period === period); if (periodInfo) { periodData.periodLabel = periodInfo.period_label; } } // Add value for each series panel.series.forEach((series, index) => { const data = seriesData[series.id]; if (data) { const periodInfo = data.periods.find((p) => p.period === period); if (periodInfo) { const value = getMetricValue(periodInfo.metrics, series.metric); periodData[`series_${series.id}`] = value ?? null; } else { periodData[`series_${series.id}`] = null; } } else { periodData[`series_${series.id}`] = null; } }); return periodData; }); }, [panel.series, seriesData]); // Build chart config for all series const chartConfig = useMemo(() => { const config: ChartConfig = {}; panel.series.forEach((series, index) => { const projectNames = series.projectIds && series.projectIds.length > 0 ? series.projectIds .map((id) => projects.find((p) => p.id === id)?.title) .filter(Boolean) .join(", ") || "Все проекты" : "Все проекты"; const metricLabel = getMetricLabel(series.metric); const colorIndex = (index % CHART_COLOR_KEYS.length) + 1; config[`series_${series.id}`] = { label: `${metricLabel} (${projectNames})`, color: `var(--chart-${colorIndex})`, }; }); return config; }, [panel.series, projects]) satisfies ChartConfig; const dateRangeLabel = useMemo(() => { if (panel.dateRange?.from && panel.dateRange?.to) { return `${format(panel.dateRange.from, "d MMM", { locale: ru })} — ${format(panel.dateRange.to, "d MMM yyyy", { locale: ru })}`; } if (panel.dateRange?.from) { return format(panel.dateRange.from, "d MMM yyyy", { locale: ru }); } return "За всё время"; }, [panel.dateRange]); const handleAddSeries = () => { const newSeries: ChartSeries = { id: `series-${Date.now()}-${Math.random()}`, projectIds: projects.length > 0 ? [projects[0].id] : [], metric: "total_cost", }; onUpdate({ ...panel, series: [...panel.series, newSeries], }); }; const handleDeleteSeries = (seriesId: string) => { onUpdate({ ...panel, series: panel.series.filter((s) => s.id !== seriesId), }); }; const handleUpdateSeries = (updatedSeries: ChartSeries) => { onUpdate({ ...panel, series: panel.series.map((s) => s.id === updatedSeries.id ? updatedSeries : s ), }); }; return (
{panel.title}
{/* Common Panel Settings */}
{/* Chart Type */}
{/* Date Range */}
onUpdate({ ...panel, dateRange: range }) } />
{/* Grouping */}
{/* Date Grouping */}
{/* Series (Graphs) */}
{panel.series.map((series, index) => (
{/* Projects Filter */}
{projects.map((project) => { const isSelected = (series.projectIds || []).includes( project.id ); return (
{ const currentIds = series.projectIds || []; const newIds = isSelected ? currentIds.filter((id) => id !== project.id) : [...currentIds, project.id]; handleUpdateSeries({ ...series, projectIds: newIds }); }} > {project.title}
); })}
{/* Metric Select */}
{panel.series.length > 1 && ( )}
))}
{/* Chart */} {loading ? (
) : panel.series.length === 0 ? (

Добавьте график для отображения данных

) : chartData.length === 0 ? (

Нет данных для отображения

) : ( { // Use formatter from first series const firstSeries = panel.series[0]; if (firstSeries) { const formatter = getMetricFormatter(firstSeries.metric); return formatter(value); } return value.toString(); }} /> } /> } /> {panel.series.map((series) => ( ))} )}
); }; // ============================================================================ // Main Component // ============================================================================ export default function ProjectsAnalyticsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; const { projects, hasAnyAnalyticsPermission } = useWorkspace(); if (!hasAnyAnalyticsPermission) { return ( <>

Доступ запрещён

У вас нет прав для просмотра аналитики. Обратитесь к администратору workspace.

); } const [panels, setPanels] = useState([]); const [tableData, setTableData] = useState(null); const [tableLoading, setTableLoading] = useState(false); const [tableGrouping, setTableGrouping] = useState("month"); const [tableDateGrouping, setTableDateGrouping] = useState<"purchase_date" | "link_date" | "placement_date">("placement_date"); const [tableDateRange, setTableDateRange] = useState(() => ({ from: subDays(new Date(), 29), to: new Date(), })); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); // Load panels from localStorage useEffect(() => { const saved = localStorage.getItem( `analytics-projects-panels-${workspaceId}` ); if (saved) { try { const parsed = JSON.parse(saved); const panelsWithDates = parsed.map((panel: any) => ({ id: panel.id || `panel-${Date.now()}-${Math.random()}`, title: panel.title || "Панель", chartType: panel.chartType || "line", series: Array.isArray(panel.series) ? panel.series.map((s: any) => ({ id: s.id || `series-${Date.now()}-${Math.random()}`, projectIds: Array.isArray(s.projectIds) ? s.projectIds : [], metric: s.metric || "total_cost", })) : // Migration from old format panel.projectIds || panel.metric ? [ { id: `series-${Date.now()}-${Math.random()}`, projectIds: Array.isArray(panel.projectIds) ? panel.projectIds : [], metric: panel.metric || "total_cost", }, ] : [], dateRange: panel.dateRange ? { from: panel.dateRange.from ? new Date(panel.dateRange.from) : undefined, to: panel.dateRange.to ? new Date(panel.dateRange.to) : undefined, } : undefined, grouping: panel.grouping || "day", dateGrouping: panel.dateGrouping || "placement_date", colSpan: panel.colSpan || 2, })); setPanels(panelsWithDates); } catch { // Ignore parse errors } } }, [workspaceId]); // Save panels to localStorage useEffect(() => { if (panels.length > 0) { localStorage.setItem( `analytics-projects-panels-${workspaceId}`, JSON.stringify(panels) ); } }, [panels, workspaceId]); // Load table data useEffect(() => { loadTableData(); }, [workspaceId, tableGrouping, tableDateGrouping, tableDateRange]); const loadTableData = async () => { try { setTableLoading(true); const response = await demoAwareAnalyticsApi.projects(workspaceId, { date_from: tableDateRange?.from ? startOfDay(tableDateRange.from).toISOString() : undefined, date_to: tableDateRange?.to ? endOfDay(tableDateRange.to).toISOString() : undefined, grouping: tableGrouping, date_grouping: tableDateGrouping, }); setTableData(response); } catch (err) { console.error("Failed to load table data:", err); } finally { setTableLoading(false); } }; const handleAddPanel = () => { const defaultProjectIds = projects.length > 0 ? [projects[0].id] : []; const newPanel: ChartPanelConfig = { id: `panel-${Date.now()}`, title: `Панель ${panels.length + 1}`, chartType: "line", series: [ { id: `series-${Date.now()}-${Math.random()}`, projectIds: defaultProjectIds, metric: "total_cost", }, ], dateRange: { from: subDays(new Date(), 29), to: new Date(), }, grouping: "day", dateGrouping: "placement_date", colSpan: 2, }; setPanels([...panels, newPanel]); }; const handleDeletePanel = (id: string) => { setPanels(panels.filter((p) => p.id !== id)); }; const handleUpdatePanel = (updatedPanel: ChartPanelConfig) => { setPanels(panels.map((p) => (p.id === updatedPanel.id ? updatedPanel : p))); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setPanels((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 handleExportTable = () => { if (!tableData) return; // TODO: Implement export to Excel/CSV console.log("Export table", tableData); }; const tableDateRangeLabel = useMemo(() => { if (tableDateRange?.from && tableDateRange?.to) { return `${format(tableDateRange.from, "d MMM", { locale: ru })} — ${format(tableDateRange.to, "d MMM yyyy", { locale: ru })}`; } if (tableDateRange?.from) { return format(tableDateRange.from, "d MMM yyyy", { locale: ru }); } return "За всё время"; }, [tableDateRange]); return ( <>

Аналитика по проектам

Настраиваемые панели графиков с drag-and-drop

{panels.length === 0 ? (

Нет панелей. Создайте первую панель для начала работы.

) : ( p.id)} strategy={rectSortingStrategy} >
{panels.map((panel) => ( ))}
)} {/* Table */}
Таблица показателей Периоды в столбцах, показатели в строках
setTableDateRange(range)} />
{tableLoading ? (
) : !tableData || tableData.periods.length === 0 ? (

Нет данных для отображения

) : (
Показатель {tableData.periods.map((period) => ( {period.period_label} ))} Итого Сумма расходов {tableData.periods.map((period) => ( {formatCurrency(period.metrics.total_cost)} ))} {formatCurrency(tableData.totals.total_cost)} Кол-во закупов {tableData.periods.map((period) => ( {formatNumber(period.metrics.purchases_count)} ))} {formatNumber(tableData.totals.purchases_count)} Подписчиков {tableData.periods.map((period) => ( {formatNumber(period.metrics.total_subscriptions)} ))} {formatNumber(tableData.totals.total_subscriptions)} Просмотров {tableData.periods.map((period) => ( {formatNumber(period.metrics.total_views)} ))} {formatNumber(tableData.totals.total_views)} Средний CPF {tableData.periods.map((period) => ( {formatCurrency(period.metrics.avg_cpf)} ))} {formatCurrency(tableData.totals.avg_cpf)} Средний CPM {tableData.periods.map((period) => ( {formatCurrency(period.metrics.avg_cpm)} ))} {formatCurrency(tableData.totals.avg_cpm)}
)}
); }