Files
tgex-frontend/app/dashboard/[workspaceId]/analytics/creatives/page.tsx
2025-12-29 10:12:13 +03:00

306 lines
9.7 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";
// ============================================================================
// Creatives Analytics Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Folder,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { CreativeAnalyticsItem } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Types
// ============================================================================
type SortField =
| "placements"
| "cost"
| "subscriptions"
| "views"
| "cps"
| "cpm";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Component
// ============================================================================
export default function CreativesAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { getProjectFilterId } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [creatives, setCreatives] = useState<CreativeAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true);
const [sortField, setSortField] = useState<SortField | null>("cost");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
useEffect(() => {
loadData();
}, [workspaceId, projectFilterId]);
const loadData = async () => {
try {
setLoading(true);
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
project_id: projectFilterId,
size: 100,
});
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives analytics:", err);
} finally {
setLoading(false);
}
};
// Sort handler
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") setSortOrder("desc");
else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
} else setSortOrder("asc");
} else {
setSortField(field);
setSortOrder("desc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
return <ArrowUpDown className="h-4 w-4 ml-1" />;
};
// Sort data
const sortedCreatives = [...creatives].sort((a, b) => {
if (!sortField || !sortOrder) return 0;
let aVal = 0;
let bVal = 0;
switch (sortField) {
case "placements":
aVal = a.placements_count;
bVal = b.placements_count;
break;
case "cost":
aVal = a.total_cost;
bVal = b.total_cost;
break;
case "subscriptions":
aVal = a.total_subscriptions;
bVal = b.total_subscriptions;
break;
case "views":
aVal = a.total_views;
bVal = b.total_views;
break;
case "cps":
aVal = a.avg_cpf ?? 0;
bVal = b.avg_cpf ?? 0;
break;
case "cpm":
aVal = a.avg_cpm ?? 0;
bVal = b.avg_cpm ?? 0;
break;
}
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
{ label: "Креативы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Аналитика по креативам
</h1>
<p className="text-muted-foreground">
Сравнение эффективности рекламных материалов
</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : sortedCreatives.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
<p className="text-muted-foreground text-center">
Создайте размещения для отображения статистики по креативам
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Креативы</CardTitle>
<CardDescription>
{sortedCreatives.length} креативов с размещениями
</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Креатив</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("placements")}
>
Размещений
{getSortIcon("placements")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
Расходы
{getSortIcon("cost")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("subscriptions")}
>
Подписчики
{getSortIcon("subscriptions")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("views")}
>
Просмотры
{getSortIcon("views")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cps")}
>
Ср. CPS
{getSortIcon("cps")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cpm")}
>
Ср. CPM
{getSortIcon("cpm")}
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="font-medium hover:underline"
>
{creative.name}
</Link>
</TableCell>
<TableCell>{creative.placements_count}</TableCell>
<TableCell>{formatCurrency(creative.total_cost)}</TableCell>
<TableCell>
{formatNumber(creative.total_subscriptions)}
</TableCell>
<TableCell>{formatNumber(creative.total_views)}</TableCell>
<TableCell>{formatCurrency(creative.avg_cpf)}</TableCell>
<TableCell>{formatCurrency(creative.avg_cpm)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}