feat: update analitycs
This commit is contained in:
606
app/(dashboard)/analytics/creatives/page.tsx
Normal file
606
app/(dashboard)/analytics/creatives/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
368
app/(dashboard)/analytics/target-channels/page.tsx
Normal file
368
app/(dashboard)/analytics/target-channels/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user