Files
tgex-frontend/app/(dashboard)/analytics/creatives/page.tsx
2025-12-01 07:43:47 +03:00

607 lines
21 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";
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>
</>
);
}