"use client"; // ============================================================================ // Dashboard Overview (Главная) // ============================================================================ import { useEffect, useMemo, useState } from "react"; import { format, subDays, startOfDay, endOfDay } from "date-fns"; import { ru } from "date-fns/locale"; import { ArrowDown, ArrowUp, Calendar as CalendarIcon, BarChart3, BarChartHorizontal, Gauge, Loader2, Sparkles, TrendingUp, Users, } from "lucide-react"; import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, XAxis, YAxis, } from "recharts"; import { DateRange } from "react-day-picker"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { demoAwareAnalyticsApi } from "@/lib/demo"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { cn } from "@/lib/utils"; import type { OverviewAnalytics } from "@/lib/types/api"; // ============================================================================ // Helpers // ============================================================================ const formatNumber = (value: number | null | undefined, showSign = false): string => { if (value === null || value === undefined) return "—"; const formatted = new Intl.NumberFormat("ru-RU").format(value); if (showSign && value > 0) return `+${formatted}`; return formatted; }; const formatCurrency = ( value: number | null | undefined, digits = 0 ): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: digits, maximumFractionDigits: digits, }).format(value); }; const formatDateShort = (value: string): string => { const [year, month, day] = value.split("-"); if (!year || !month || !day) return value; return `${day}.${month}`; }; const DeltaPill: React.FC<{ delta?: number }> = ({ delta }) => { if (delta === undefined || delta === null) return null; const isPositive = delta > 0; const isZero = delta === 0; const color = isZero ? "bg-muted text-muted-foreground" : isPositive ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200" : "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-200"; const Icon = isZero ? Sparkles : isPositive ? ArrowUp : ArrowDown; const sign = delta > 0 ? "+" : ""; return ( {sign} {delta.toFixed(1)}% ); }; // ============================================================================ // Component // ============================================================================ export default function DashboardPage() { const { currentWorkspace, isLoading: workspaceLoading, selectedProjects, projects, getProjectFilterId, } = useWorkspace(); const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(true); const [cpfMode, setCpfMode] = useState<"top" | "worst">("top"); const [projectPage, setProjectPage] = useState(1); const pageSize = 5; const [dateRange, setDateRange] = useState(() => ({ from: subDays(new Date(), 29), to: new Date(), })); const dateFromIso = useMemo(() => { return dateRange?.from ? startOfDay(dateRange.from).toISOString() : undefined; }, [dateRange?.from]); const dateToIso = useMemo(() => { return dateRange?.to ? endOfDay(dateRange.to).toISOString() : undefined; }, [dateRange?.to]); const dateRangeLabel = useMemo(() => { if (dateRange?.from && dateRange?.to) { const sameYear = dateRange.from.getFullYear() === dateRange.to.getFullYear(); const sameMonth = sameYear && dateRange.from.getMonth() === dateRange.to.getMonth(); const fromFormat = sameMonth ? "d MMM" : "d MMM yyyy"; return `${format(dateRange.from, fromFormat, { locale: ru })} — ${format(dateRange.to, "d MMM yyyy", { locale: ru })}`; } if (dateRange?.from) { return format(dateRange.from, "d MMM yyyy", { locale: ru }); } return "За всё время"; }, [dateRange]); const projectFilterId = getProjectFilterId(); useEffect(() => { if (!currentWorkspace) { setLoading(false); return; } const loadData = async () => { try { setLoading(true); const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, { project_id: projectFilterId, date_from: dateFromIso, date_to: dateToIso, }); setOverview(data); setProjectPage(1); } catch (err) { console.error("Failed to load overview:", err); } finally { setLoading(false); } }; loadData(); }, [currentWorkspace?.id, projectFilterId, dateFromIso, dateToIso]); const stats = useMemo( () => [ { title: "Сумма закупов", value: overview?.total_cost.value, delta: overview?.total_cost.delta_percent, format: (v: number | null | undefined) => formatCurrency(v, 0), icon: BarChartHorizontal, }, { title: "Общий охват", value: overview?.total_reach.value, delta: overview?.total_reach.delta_percent, format: (v: number | null | undefined) => formatNumber(v), icon: Users, }, { title: "Всего размещений", value: overview?.placements_count.value, delta: overview?.placements_count.delta_percent, format: (v: number | null | undefined) => formatNumber(v), icon: Gauge, }, { title: "Кол-во подписок", value: overview?.subscriptions_count.value, delta: overview?.subscriptions_count.delta_percent, format: (v: number | null | undefined) => formatNumber(v), icon: TrendingUp, }, { title: "Средний CPM", value: overview?.avg_cpm.value, delta: overview?.avg_cpm.delta_percent, format: (v: number | null | undefined) => formatCurrency(v, 2), icon: BarChart3, }, { title: "Средний CPF", value: overview?.avg_cpf.value, delta: overview?.avg_cpf.delta_percent, format: (v: number | null | undefined) => formatCurrency(v, 2), icon: Sparkles, }, ], [overview] ); const dailyStats = overview?.daily_stats ?? []; const subscriptionsChartData = useMemo( () => dailyStats.map((item) => ({ date: item.date, dateLabel: formatDateShort(item.date), value: item.subscriptions_delta, })), [dailyStats] ); const costChartData = useMemo( () => dailyStats.map((item) => ({ date: item.date, dateLabel: formatDateShort(item.date), value: item.cost, })), [dailyStats] ); const cpfChartData = useMemo( () => dailyStats.map((item) => ({ date: item.date, dateLabel: formatDateShort(item.date), value: item.cpf, })), [dailyStats] ); const projectSpending = overview?.project_spending ?? []; const totalProjectPages = Math.max(1, Math.ceil(projectSpending.length / pageSize)); const paginatedProjects = projectSpending.slice( (projectPage - 1) * pageSize, projectPage * pageSize ); if (workspaceLoading) { return ( <>
); } if (!currentWorkspace) { return ( <>

Нет доступных воркспейсов

Создайте новый воркспейс для начала работы

); } return ( <>

Статистика за период

{dateRangeLabel}

setDateRange(range)} />
{/* Строка 1 — компактные показатели */}
{stats.map((item) => (
{item.title}
{loading ? ( ) : (
{item.format(item.value)}
)}
))}
{/* Строка 2 — три диаграммы */}
Динамика подписчиков по дням {loading ? (
) : ( {subscriptionsChartData.map((entry) => ( = 0 ? "#22c55e" : "#ef4444"} /> ))} payload?.[0]?.payload?.date || ""} formatter={(value: number) => [formatNumber(value, true), "Δ подписок"]} /> } /> )}
Траты по дням {loading ? (
) : ( formatCurrency(value, 0)} /> payload?.[0]?.payload?.date || ""} formatter={(value: number) => [formatCurrency(value, 0), "Затраты"]} /> } /> )}
Стоимость подписчика (CPF) {loading ? (
) : ( formatCurrency(value, 0)} /> payload?.[0]?.payload?.date || ""} formatter={(value: number) => [formatCurrency(value, 2), "CPF"]} /> } /> )}
{/* Строка 3 — таблицы */}
setCpfMode(value as "top" | "worst")}>
Топ / Антитоп по CPF Топ 5 Антитоп 5
{loading ? (
) : ( <> {overview?.top_channels_by_cpf?.length ? ( Канал CPF {overview.top_channels_by_cpf.slice(0, 5).map((row) => (
{row.title}
{row.username && ( @{row.username} )}
{formatCurrency(row.cpf, 2)}
))}
) : (

Нет данных

)}
{overview?.worst_channels_by_cpf?.length ? ( Канал CPF {overview.worst_channels_by_cpf.slice(0, 5).map((row) => (
{row.title}
{row.username && ( @{row.username} )}
{formatCurrency(row.cpf, 2)}
))}
) : (

Нет данных

)}
)}
Сумма закупов по проектам
{projectPage}/{totalProjectPages}
{loading ? (
) : paginatedProjects.length === 0 ? (

Нет данных

) : ( Проект Сумма {paginatedProjects.map((row) => (
{row.project_title}
{row.project_username && ( @{row.project_username} )}
{formatCurrency(row.total_cost, 0)}
))}
)}
); }