Files
tgex-frontend/app/dashboard/[workspaceId]/analytics/projects/page.tsx
2026-01-12 12:46:29 +03:00

1181 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 } = useWorkspace();
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>
</>
);
}