1205 lines
42 KiB
TypeScript
1205 lines
42 KiB
TypeScript
"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<MetricType, string> = {
|
||
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<SortablePanelProps> = ({
|
||
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<string, ProjectsAnalyticsResponse>
|
||
>({});
|
||
|
||
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<string, ProjectsAnalyticsResponse> = {};
|
||
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<string>();
|
||
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 (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={cn(
|
||
"relative",
|
||
panel.colSpan === 1 ? "lg:col-span-1" : "lg:col-span-2"
|
||
)}
|
||
>
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
<div
|
||
{...attributes}
|
||
{...listeners}
|
||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||
>
|
||
<GripVertical className="h-4 w-4" />
|
||
</div>
|
||
<CardTitle className="text-base truncate">{panel.title}</CardTitle>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 shrink-0"
|
||
onClick={() => onDelete(panel.id)}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* Common Panel Settings */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm border-b pb-4">
|
||
{/* Chart Type */}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">Тип графика</Label>
|
||
<Select
|
||
value={panel.chartType}
|
||
onValueChange={(value) =>
|
||
onUpdate({ ...panel, chartType: value as ChartType })
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="line">Линейный</SelectItem>
|
||
<SelectItem value="area">Областной</SelectItem>
|
||
<SelectItem value="bar">Столбчатый</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Date Range */}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">Период</Label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full justify-start text-left font-normal h-9"
|
||
>
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{dateRangeLabel}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="start">
|
||
<Calendar
|
||
initialFocus
|
||
mode="range"
|
||
numberOfMonths={2}
|
||
locale={ru}
|
||
defaultMonth={panel.dateRange?.from}
|
||
selected={panel.dateRange as any}
|
||
onSelect={(range: any) =>
|
||
onUpdate({ ...panel, dateRange: range })
|
||
}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Grouping */}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">Группировка</Label>
|
||
<Select
|
||
value={panel.grouping}
|
||
onValueChange={(value) =>
|
||
onUpdate({ ...panel, grouping: value as GroupingType })
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="day">По дням</SelectItem>
|
||
<SelectItem value="week">По неделям</SelectItem>
|
||
<SelectItem value="month">По месяцам</SelectItem>
|
||
<SelectItem value="quarter">По кварталам</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Date Grouping */}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">
|
||
Группировка по датам
|
||
</Label>
|
||
<Select
|
||
value={panel.dateGrouping}
|
||
onValueChange={(value) =>
|
||
onUpdate({
|
||
...panel,
|
||
dateGrouping: value as "purchase_date" | "link_date" | "placement_date",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="purchase_date">Дата покупки</SelectItem>
|
||
<SelectItem value="link_date">
|
||
Дата формирования ссылки
|
||
</SelectItem>
|
||
<SelectItem value="placement_date">Дата размещения</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Series (Graphs) */}
|
||
<div className="space-y-3">
|
||
{panel.series.map((series, index) => (
|
||
<div
|
||
key={series.id}
|
||
className="flex items-center gap-2 p-3 border rounded-lg"
|
||
>
|
||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||
{/* Projects Filter */}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">
|
||
Проекты {index + 1}
|
||
</Label>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full justify-start text-left font-normal h-9"
|
||
>
|
||
{!series.projectIds || series.projectIds.length === 0
|
||
? "Выберите проекты"
|
||
: series.projectIds.length === 1
|
||
? projects.find((p) => p.id === series.projectIds[0])
|
||
?.title || "Проект"
|
||
: `${series.projectIds.length} проекта`}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent className="w-64 p-2">
|
||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||
{projects.map((project) => {
|
||
const isSelected = (series.projectIds || []).includes(
|
||
project.id
|
||
);
|
||
return (
|
||
<div
|
||
key={project.id}
|
||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent cursor-pointer"
|
||
onClick={() => {
|
||
const currentIds = series.projectIds || [];
|
||
const newIds = isSelected
|
||
? currentIds.filter((id) => id !== project.id)
|
||
: [...currentIds, project.id];
|
||
handleUpdateSeries({ ...series, projectIds: newIds });
|
||
}}
|
||
>
|
||
<Checkbox checked={isSelected} />
|
||
<span className="text-sm">{project.title}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
{/* Metric Select */}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">
|
||
Метрика {index + 1}
|
||
</Label>
|
||
<Select
|
||
value={series.metric}
|
||
onValueChange={(value) =>
|
||
handleUpdateSeries({
|
||
...series,
|
||
metric: value as MetricType,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="avg_conversion">
|
||
Средняя конверсия закупов
|
||
</SelectItem>
|
||
<SelectItem value="total_cost">Сумма расходов</SelectItem>
|
||
<SelectItem value="total_discounts">Сумма скидок</SelectItem>
|
||
<SelectItem value="avg_discount_percent">
|
||
Средний % скидки
|
||
</SelectItem>
|
||
<SelectItem value="purchases_count">Кол-во закупов</SelectItem>
|
||
<SelectItem value="avg_cpm">Средний CPM</SelectItem>
|
||
<SelectItem value="avg_post_cost">
|
||
Средняя стоимость поста
|
||
</SelectItem>
|
||
<SelectItem value="clicks_count">Кол-во переходов</SelectItem>
|
||
<SelectItem value="reach_volume">Объём охвата</SelectItem>
|
||
<SelectItem value="avg_cpf">Средний CPF</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
{panel.series.length > 1 && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 shrink-0"
|
||
onClick={() => handleDeleteSeries(series.id)}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleAddSeries}
|
||
className="w-full"
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Добавить график для сравнения
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Chart */}
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-[300px]">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : panel.series.length === 0 ? (
|
||
<div className="flex items-center justify-center h-[300px] border-2 border-dashed rounded-lg">
|
||
<p className="text-sm text-muted-foreground">
|
||
Добавьте график для отображения данных
|
||
</p>
|
||
</div>
|
||
) : chartData.length === 0 ? (
|
||
<div className="flex items-center justify-center h-[300px] border-2 border-dashed rounded-lg">
|
||
<p className="text-sm text-muted-foreground">
|
||
Нет данных для отображения
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<ChartContainer config={chartConfig} className="h-[300px] w-full">
|
||
<LineChart data={chartData}>
|
||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||
<XAxis
|
||
dataKey="periodLabel"
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
angle={-45}
|
||
textAnchor="end"
|
||
height={60}
|
||
/>
|
||
<YAxis
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
tickFormatter={(value) => {
|
||
// Use formatter from first series
|
||
const firstSeries = panel.series[0];
|
||
if (firstSeries) {
|
||
const formatter = getMetricFormatter(firstSeries.metric);
|
||
return formatter(value);
|
||
}
|
||
return value.toString();
|
||
}}
|
||
/>
|
||
<ChartTooltip
|
||
content={<ChartTooltipContent />}
|
||
/>
|
||
<ChartLegend content={<ChartLegendContent />} />
|
||
{panel.series.map((series) => (
|
||
<Line
|
||
key={series.id}
|
||
type="monotone"
|
||
dataKey={`series_${series.id}`}
|
||
stroke={chartConfig[`series_${series.id}`]?.color}
|
||
strokeWidth={2}
|
||
dot={false}
|
||
connectNulls
|
||
/>
|
||
))}
|
||
</LineChart>
|
||
</ChartContainer>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Main Component
|
||
// ============================================================================
|
||
|
||
export default function ProjectsAnalyticsPage() {
|
||
const params = useParams();
|
||
const workspaceId = params?.workspaceId as string;
|
||
const { projects, hasAnyAnalyticsPermission } = useWorkspace();
|
||
|
||
if (!hasAnyAnalyticsPermission) {
|
||
return (
|
||
<>
|
||
<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">
|
||
<Card className="flex flex-col items-center justify-center py-16">
|
||
<CardContent className="text-center">
|
||
<h2 className="text-xl font-semibold mb-2">Доступ запрещён</h2>
|
||
<p className="text-muted-foreground">
|
||
У вас нет прав для просмотра аналитики. Обратитесь к администратору workspace.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const [panels, setPanels] = useState<ChartPanelConfig[]>([]);
|
||
const [tableData, setTableData] = useState<ProjectsAnalyticsResponse | null>(null);
|
||
const [tableLoading, setTableLoading] = useState(false);
|
||
const [tableGrouping, setTableGrouping] = useState<GroupingType>("month");
|
||
const [tableDateGrouping, setTableDateGrouping] = useState<"purchase_date" | "link_date" | "placement_date">("placement_date");
|
||
const [tableDateRange, setTableDateRange] = useState<DateRangeType>(() => ({
|
||
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 (
|
||
<>
|
||
<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">
|
||
Настраиваемые панели графиков с drag-and-drop
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleAddPanel}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Добавить панель
|
||
</Button>
|
||
</div>
|
||
|
||
{panels.length === 0 ? (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||
<p className="text-muted-foreground mb-4">
|
||
Нет панелей. Создайте первую панель для начала работы.
|
||
</p>
|
||
<Button onClick={handleAddPanel}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Создать панель
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={panels.map((p) => p.id)}
|
||
strategy={rectSortingStrategy}
|
||
>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{panels.map((panel) => (
|
||
<SortablePanel
|
||
key={panel.id}
|
||
panel={panel}
|
||
projects={projects}
|
||
onUpdate={handleUpdatePanel}
|
||
onDelete={handleDeletePanel}
|
||
workspaceId={workspaceId}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
)}
|
||
|
||
{/* Table */}
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Таблица показателей</CardTitle>
|
||
<CardDescription>
|
||
Периоды в столбцах, показатели в строках
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Select
|
||
value={tableGrouping}
|
||
onValueChange={(value) =>
|
||
setTableGrouping(value as GroupingType)
|
||
}
|
||
>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="day">По дням</SelectItem>
|
||
<SelectItem value="week">По неделям</SelectItem>
|
||
<SelectItem value="month">По месяцам</SelectItem>
|
||
<SelectItem value="quarter">По кварталам</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={tableDateGrouping}
|
||
onValueChange={(value) =>
|
||
setTableDateGrouping(
|
||
value as "purchase_date" | "link_date" | "placement_date"
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="purchase_date">Дата покупки</SelectItem>
|
||
<SelectItem value="link_date">
|
||
Дата формирования ссылки
|
||
</SelectItem>
|
||
<SelectItem value="placement_date">Дата размещения</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="outline" size="sm">
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{tableDateRangeLabel}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="end">
|
||
<Calendar
|
||
initialFocus
|
||
mode="range"
|
||
numberOfMonths={2}
|
||
locale={ru}
|
||
defaultMonth={tableDateRange?.from}
|
||
selected={tableDateRange as any}
|
||
onSelect={(range: any) => setTableDateRange(range)}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<Button variant="outline" onClick={handleExportTable}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
Экспорт
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{tableLoading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : !tableData || tableData.periods.length === 0 ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<p className="text-muted-foreground">Нет данных для отображения</p>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-md border overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="sticky left-0 bg-background z-10">
|
||
Показатель
|
||
</TableHead>
|
||
{tableData.periods.map((period) => (
|
||
<TableHead key={period.period} className="text-center min-w-[120px]">
|
||
{period.period_label}
|
||
</TableHead>
|
||
))}
|
||
<TableHead className="text-center font-semibold min-w-[120px]">
|
||
Итого
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
<TableRow>
|
||
<TableCell className="sticky left-0 bg-background z-10 font-medium">
|
||
Сумма расходов
|
||
</TableCell>
|
||
{tableData.periods.map((period) => (
|
||
<TableCell key={period.period} className="text-center">
|
||
{formatCurrency(period.metrics.total_cost)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="text-center font-semibold">
|
||
{formatCurrency(tableData.totals.total_cost)}
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="sticky left-0 bg-background z-10 font-medium">
|
||
Кол-во закупов
|
||
</TableCell>
|
||
{tableData.periods.map((period) => (
|
||
<TableCell key={period.period} className="text-center">
|
||
{formatNumber(period.metrics.purchases_count)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="text-center font-semibold">
|
||
{formatNumber(tableData.totals.purchases_count)}
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="sticky left-0 bg-background z-10 font-medium">
|
||
Подписчиков
|
||
</TableCell>
|
||
{tableData.periods.map((period) => (
|
||
<TableCell key={period.period} className="text-center">
|
||
{formatNumber(period.metrics.total_subscriptions)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="text-center font-semibold">
|
||
{formatNumber(tableData.totals.total_subscriptions)}
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="sticky left-0 bg-background z-10 font-medium">
|
||
Просмотров
|
||
</TableCell>
|
||
{tableData.periods.map((period) => (
|
||
<TableCell key={period.period} className="text-center">
|
||
{formatNumber(period.metrics.total_views)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="text-center font-semibold">
|
||
{formatNumber(tableData.totals.total_views)}
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="sticky left-0 bg-background z-10 font-medium">
|
||
Средний CPF
|
||
</TableCell>
|
||
{tableData.periods.map((period) => (
|
||
<TableCell key={period.period} className="text-center">
|
||
{formatCurrency(period.metrics.avg_cpf)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="text-center font-semibold">
|
||
{formatCurrency(tableData.totals.avg_cpf)}
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="sticky left-0 bg-background z-10 font-medium">
|
||
Средний CPM
|
||
</TableCell>
|
||
{tableData.periods.map((period) => (
|
||
<TableCell key={period.period} className="text-center">
|
||
{formatCurrency(period.metrics.avg_cpm)}
|
||
</TableCell>
|
||
))}
|
||
<TableCell className="text-center font-semibold">
|
||
{formatCurrency(tableData.totals.avg_cpm)}
|
||
</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|