feat: update analitycs

This commit is contained in:
ivannoskov
2025-12-01 07:43:47 +03:00
parent 270e75e192
commit e06b53d133
9 changed files with 1631 additions and 3 deletions

View File

@@ -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<TargetChannel[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState<string>("");
const [dateFrom, setDateFrom] = useState<Date | undefined>();
const [dateTo, setDateTo] = useState<Date | undefined>();
const [creatives, setCreatives] = useState<CreativeAnalytics[]>([]);
const [filteredCreatives, setFilteredCreatives] = useState<
CreativeAnalytics[]
>([]);
const [loading, setLoading] = useState(true);
const [loadingData, setLoadingData] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(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 <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
}
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
};
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 (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/analytics" },
{ label: "Креативы" },
]}
/>
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/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-3xl font-bold tracking-tight">
Аналитика креативов
</h1>
<p className="text-muted-foreground mt-1">
Производительность рекламных креативов
</p>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{channels.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет активных целевых каналов. Добавьте канал для начала работы с
аналитикой.
</AlertDescription>
</Alert>
) : (
<>
{/* Фильтры */}
<Card>
<CardHeader>
<CardTitle>Фильтры</CardTitle>
<CardDescription>
Настройте параметры для отображения данных
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>Целевой канал</Label>
<Select
value={selectedChannelId}
onValueChange={setSelectedChannelId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Дата от</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateFrom && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom ? (
format(dateFrom, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите дату</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateFrom}
onSelect={setDateFrom}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Дата до</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateTo && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateTo ? (
format(dateTo, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите дату</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateTo}
onSelect={setDateTo}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<Button onClick={loadCreativesData} disabled={!selectedChannelId}>
Применить фильтры
</Button>
</CardContent>
</Card>
{/* Таблица */}
{loadingData ? (
<Card>
<CardContent className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : filteredCreatives.length > 0 ? (
<Card>
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>Креативы</CardTitle>
<CardDescription>
Показатели производительности креативов
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Экспорт
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportToExcel}>
Экспорт в Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToCSV}>
Экспорт в CSV (.csv)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToTXT}>
Экспорт в TXT (.txt)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("name")}
>
Название
{getSortIcon("name")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("placements_count")}
>
Размещений
{getSortIcon("placements_count")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("total_cost")}
>
Общая стоимость
{getSortIcon("total_cost")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("total_subscriptions")}
>
Подписки
{getSortIcon("total_subscriptions")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("total_views")}
>
Просмотры
{getSortIcon("total_views")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("avg_cpf")}
>
Средний CPF
{getSortIcon("avg_cpf")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("avg_cpm")}
>
Средний CPM
{getSortIcon("avg_cpm")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell className="font-medium">
{creative.name}
</TableCell>
<TableCell className="text-right">
{formatNumber(creative.placements_count)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(creative.total_cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(creative.total_subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(creative.total_views)}
</TableCell>
<TableCell className="text-right">
{creative.avg_cpf !== null
? formatCurrency(creative.avg_cpf)
: "—"}
</TableCell>
<TableCell className="text-right">
{creative.avg_cpm !== null
? formatCurrency(creative.avg_cpm)
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
creatives.length === 0 &&
!loadingData && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет данных по креативам. Примените фильтры для отображения
данных.
</AlertDescription>
</Alert>
)
)}
</>
)}
</div>
</>
);
}

View File

@@ -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<TargetChannel[]>([]);
const [charts, setCharts] = useState<ChartData[]>([
{
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<string | null>(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<string, SpendingDataPoint>();
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 (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/analytics" },
{ label: "Целевые каналы" },
]}
/>
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/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-3xl font-bold tracking-tight">
Аналитика целевых каналов
</h1>
<p className="text-muted-foreground mt-1">
Создайте графики для анализа показателей
</p>
</div>
<Button onClick={handleAddChart}>
<Plus className="mr-2 h-4 w-4" />
Добавить график
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{channels.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет активных целевых каналов. Добавьте канал для начала работы с
аналитикой.
</AlertDescription>
</Alert>
) : (
<>
{/* Графики */}
<div className="space-y-6">
{charts.map((chart, index) => (
<ChartConfigPanel
key={chart.id}
channels={channels}
data={chart.data}
loading={chart.loading}
onBuild={(config) => handleBuildChart(chart.id, config)}
onRemove={
charts.length > 1
? () => handleRemoveChart(chart.id)
: undefined
}
showRemoveButton={charts.length > 1}
chartNumber={index + 1}
/>
))}
</div>
{/* Таблица */}
{tableData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Табличное представление</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписки</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="text-right">CPM</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tableData.map((row) => (
<TableRow key={row.period}>
<TableCell className="font-medium">
{row.period}
</TableCell>
<TableCell className="text-right">
{formatCurrency(row.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(row.subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(row.views)}
</TableCell>
<TableCell className="text-right">
{row.cpf !== null ? formatCurrency(row.cpf) : "—"}
</TableCell>
<TableCell className="text-right">
{row.cpm !== null ? formatCurrency(row.cpm) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)}
</div>
</>
);
}

View File

@@ -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<AnalyticsMetric, string> = {
cost: "Расходы",
subscriptions: "Подписки",
views: "Просмотры",
cpf: "CPF",
cpm: "CPM",
};
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
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 (
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[350px] w-full"
>
<BarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
top: showDataLabels ? 20 : 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) => `Период: ${value}`}
formatter={(value, name) => [
formatValue(
value as number,
name as AnalyticsMetric
),
chartConfig[name as AnalyticsMetric]?.label || name,
]}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{metrics.map((metric) => (
<Bar
key={metric}
dataKey={metric}
fill={chartConfig[metric]?.color}
radius={4}
label={
showDataLabels
? {
position: "top",
formatter: (value: number) =>
formatValue(value, metric),
fontSize: 10,
}
: undefined
}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
);
}
return (
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[350px] w-full"
>
<LineChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
top: showDataLabels ? 20 : 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) => `Период: ${value}`}
formatter={(value, name) => [
formatValue(
value as number,
name as AnalyticsMetric
),
chartConfig[name as AnalyticsMetric]?.label || name,
]}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{metrics.map((metric) => (
<Line
key={metric}
dataKey={metric}
type="monotone"
stroke={chartConfig[metric]?.color}
strokeWidth={2}
dot={showDataLabels}
label={
showDataLabels
? {
position: "top",
formatter: (value: number) =>
formatValue(value, metric),
fontSize: 10,
}
: undefined
}
/>
))}
</LineChart>
</ChartContainer>
</CardContent>
);
};

View File

@@ -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<ChartConfigPanelProps> = ({
channels,
data,
loading,
onBuild,
onRemove,
showRemoveButton = false,
chartNumber = 1,
}) => {
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
const [dateFrom, setDateFrom] = useState<Date | undefined>();
const [dateTo, setDateTo] = useState<Date | undefined>();
const [grouping, setGrouping] = useState<DateGrouping>("day");
const [chartType, setChartType] = useState<"line" | "bar">("line");
const [showDataLabels, setShowDataLabels] = useState(false);
// Отслеживание изменений настроек
const [lastBuiltConfig, setLastBuiltConfig] = useState<string | null>(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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>График #{chartNumber}</CardTitle>
<CardDescription className="mt-1">
Настройте параметры и постройте график
</CardDescription>
</div>
{showRemoveButton && onRemove && (
<Button variant="ghost" size="sm" onClick={onRemove}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Настройки */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Выбор каналов */}
<div className="space-y-2">
<Label>Целевые каналы</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start"
>
{selectedChannelIds.length === 0
? "Выберите каналы"
: `Выбрано: ${selectedChannelIds.length}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-2">
{channels.map((channel) => (
<div
key={channel.id}
className="flex items-center space-x-2"
>
<Checkbox
id={`channel-${channel.id}`}
checked={selectedChannelIds.includes(channel.id)}
onCheckedChange={() => toggleChannel(channel.id)}
/>
<label
htmlFor={`channel-${channel.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{channel.title}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Выбор метрик */}
<div className="space-y-2">
<Label>Метрики</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start"
>
{selectedMetrics.length === 0
? "Выберите метрики"
: `Выбрано: ${selectedMetrics.length}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-2">
{METRICS.map((metric) => (
<div
key={metric.value}
className="flex items-center space-x-2"
>
<Checkbox
id={`metric-${metric.value}`}
checked={selectedMetrics.includes(metric.value)}
onCheckedChange={() => toggleMetric(metric.value)}
/>
<label
htmlFor={`metric-${metric.value}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{metric.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Период от */}
<div className="space-y-2">
<Label>Дата от</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateFrom && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom ? (
format(dateFrom, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateFrom}
onSelect={setDateFrom}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{/* Период до */}
<div className="space-y-2">
<Label>Дата до</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateTo && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateTo ? (
format(dateTo, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateTo}
onSelect={setDateTo}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center gap-4">
{/* Группировка */}
<div className="flex-1 space-y-2">
<Label>Группировка</Label>
<Select
value={grouping}
onValueChange={(v) => setGrouping(v as DateGrouping)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{GROUPINGS.map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Тип графика */}
<div className="space-y-2">
<Label>Тип</Label>
<div className="flex gap-1 border rounded-md">
<Button
variant={chartType === "line" ? "default" : "ghost"}
size="sm"
onClick={() => setChartType("line")}
className="rounded-r-none"
>
<LineChart className="h-4 w-4" />
</Button>
<Button
variant={chartType === "bar" ? "default" : "ghost"}
size="sm"
onClick={() => setChartType("bar")}
className="rounded-l-none"
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Подписи значений */}
<div className="space-y-2">
<Label>Подписи</Label>
<div className="flex items-center space-x-2 h-9">
<Switch
id="show-data-labels"
checked={showDataLabels}
onCheckedChange={setShowDataLabels}
/>
<Label htmlFor="show-data-labels" className="cursor-pointer">
{showDataLabels ? "Вкл" : "Выкл"}
</Label>
</div>
</div>
{/* Кнопка построения */}
<div className="space-y-2">
<Label className="invisible">Построить</Label>
<Button onClick={handleBuild} disabled={isButtonDisabled}>
<TrendingUp className={cn("mr-2 h-4 w-4", loading && "animate-pulse")} />
{getButtonText()}
</Button>
</div>
</div>
{/* График */}
{data && (
<AnalyticsChart
data={data.chart_data}
metrics={selectedMetrics}
chartType={chartType}
showDataLabels={showDataLabels}
/>
)}
</CardContent>
</Card>
);
};

View File

@@ -94,11 +94,11 @@ const data = {
url: "/analytics/costs", url: "/analytics/costs",
}, },
{ {
title: "По каналам", title: "Целевые каналы",
url: "/analytics/channels", url: "/analytics/target-channels",
}, },
{ {
title: "По креативам", title: "Креативы",
url: "/analytics/creatives", url: "/analytics/creatives",
}, },
], ],

31
components/ui/switch.tsx Normal file
View File

@@ -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<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -313,6 +313,25 @@ export interface ExternalChannelsAnalytics {
external_channels: ExternalChannelAnalytics[]; 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 // Common API Responses
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -26,6 +26,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",

31
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.2)(react@19.2.0) 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': '@radix-ui/react-tabs':
specifier: ^1.1.13 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) 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': '@types/react':
optional: true 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': '@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies: peerDependencies:
@@ -3685,6 +3701,21 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.2 '@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)': '@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: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3