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>
</>
);
}