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

369 lines
12 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 { 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>
</>
);
}