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

303 lines
9.6 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";
// ============================================================================
// Channels Analytics Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2,
Building2,
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 { ChannelAnalyticsItem } 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 ChannelsAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { getProjectFilterId } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]);
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.channels(workspaceId, {
project_id: projectFilterId,
size: 100,
});
setChannels(response.items);
} catch (err) {
console.error("Failed to load channels 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 sortedChannels = [...channels].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_cps ?? 0;
bVal = b.avg_cps ?? 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>
) : sortedChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 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>{sortedChannels.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>
{sortedChannels.map((channel) => (
<TableRow key={channel.channel_id}>
<TableCell>
<div className="font-medium">{channel.channel_title}</div>
{channel.channel_username && (
<div className="text-xs text-muted-foreground">
@{channel.channel_username}
</div>
)}
</TableCell>
<TableCell>{channel.placements_count}</TableCell>
<TableCell>{formatCurrency(channel.total_cost)}</TableCell>
<TableCell>
{formatNumber(channel.total_subscriptions)}
</TableCell>
<TableCell>{formatNumber(channel.total_views)}</TableCell>
<TableCell>{formatCurrency(channel.avg_cps)}</TableCell>
<TableCell>{formatCurrency(channel.avg_cpm)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}