Files
tgex-frontend/app/dashboard/[workspaceId]/page.tsx
2026-01-07 15:46:30 +03:00

675 lines
25 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";
// ============================================================================
// 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";
type DateRangeType = {
from?: Date;
to?: Date;
} | undefined;
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,
type ChartConfig,
} 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 (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
<Icon className="h-3 w-3" />
{sign}
{delta.toFixed(1)}%
</span>
);
};
// ============================================================================
// Component
// ============================================================================
export default function DashboardPage() {
const {
currentWorkspace,
isLoading: workspaceLoading,
selectedProjects,
projects,
getProjectFilterId,
} = useWorkspace();
const [overview, setOverview] = useState<OverviewAnalytics | null>(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<DateRangeType | undefined>(() => ({
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 (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (!currentWorkspace) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Нет доступных воркспейсов</h2>
<p className="text-muted-foreground">
Создайте новый воркспейс для начала работы
</p>
</div>
</div>
</>
);
}
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card/40 p-3">
<div>
<p className="text-sm text-muted-foreground">Статистика за период</p>
<p className="text-base font-semibold">{dateRangeLabel}</p>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"min-w-[260px] justify-start text-left font-normal",
!dateRange?.from && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.from ? (
dateRange.to ? (
<>
{format(dateRange.from, "d MMM yyyy", { locale: ru })} {" "}
{format(dateRange.to, "d MMM yyyy", { locale: ru })}
</>
) : (
format(dateRange.from, "d MMM yyyy", { locale: ru })
)
) : (
<span>Выберите период</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
numberOfMonths={2}
locale={ru}
defaultMonth={dateRange?.from}
selected={dateRange as any}
onSelect={(range: any) => setDateRange(range)}
/>
</PopoverContent>
</Popover>
</div>
{/* Строка 1 — компактные показатели */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
{stats.map((item) => (
<Card key={item.title} className="border-border/70">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-sm font-medium text-muted-foreground">
{item.title}
</CardTitle>
<DeltaPill delta={item.delta} />
</div>
<item.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<div className="text-2xl font-bold">{item.format(item.value)}</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Строка 2 — три диаграммы */}
<div className="grid gap-4 lg:grid-cols-3">
<Card className="border-border/70">
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Динамика подписчиков по дням</CardTitle>
<DeltaPill delta={overview?.subscriptions_count.delta_percent} />
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-[240px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<ChartContainer
config={{
subscriptions_delta: {
label: "Δ подписок",
},
} satisfies ChartConfig}
className="aspect-auto h-[260px] w-full"
>
<BarChart accessibilityLayer data={subscriptionsChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={10}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ReferenceLine y={0} stroke="hsl(var(--border))" />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel hideIndicator />}
/>
<Bar dataKey="value" radius={8}>
{subscriptionsChartData.map((entry) => (
<Cell
key={`cell-${entry.date}`}
fill={
entry.value >= 0
? "var(--chart-1)"
: "var(--chart-2)"
}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-border/70">
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Траты по дням</CardTitle>
<DeltaPill delta={overview?.total_cost.delta_percent} />
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-[240px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<ChartContainer
config={{
cost: {
label: "Затраты",
color: "var(--chart-2)",
},
} satisfies ChartConfig}
className="aspect-auto h-[260px] w-full"
>
<BarChart accessibilityLayer data={costChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={10}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatCurrency(value, 0)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar dataKey="value" fill="var(--color-cost)" radius={8} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-border/70">
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Стоимость подписчика (CPF)</CardTitle>
<DeltaPill delta={overview?.avg_cpf.delta_percent} />
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-[240px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<ChartContainer
config={{
cpf: {
label: "CPF",
color: "var(--chart-3)",
},
} satisfies ChartConfig}
className="aspect-auto h-[260px] w-full"
>
<BarChart accessibilityLayer data={cpfChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={10}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatCurrency(value, 0)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar dataKey="value" fill="var(--color-cpf)" radius={8} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div>
{/* Строка 3 — таблицы */}
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-border/70">
<Tabs value={cpfMode} onValueChange={(value) => setCpfMode(value as "top" | "worst")}>
<CardHeader className="flex flex-col gap-2 pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">Топ / Антитоп по CPF</CardTitle>
<TabsList className="h-8">
<TabsTrigger value="top" className="h-8 px-3 text-sm">
Топ 5
</TabsTrigger>
<TabsTrigger value="worst" className="h-8 px-3 text-sm">
Антитоп 5
</TabsTrigger>
</TabsList>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-[200px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<TabsContent value="top" className="mt-0">
{overview?.top_channels_by_cpf?.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/3">Канал</TableHead>
<TableHead className="text-right">CPF</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.top_channels_by_cpf.slice(0, 5).map((row) => (
<TableRow key={row.channel_id}>
<TableCell className="space-y-0.5">
<div className="font-medium">{row.title}</div>
{row.username && (
<a
href={`https://t.me/${row.username}`}
className="text-xs text-primary hover:underline"
target="_blank"
rel="noreferrer"
>
@{row.username}
</a>
)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(row.cpf, 2)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">Нет данных</p>
)}
</TabsContent>
<TabsContent value="worst" className="mt-0">
{overview?.worst_channels_by_cpf?.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/3">Канал</TableHead>
<TableHead className="text-right">CPF</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.worst_channels_by_cpf.slice(0, 5).map((row) => (
<TableRow key={row.channel_id}>
<TableCell className="space-y-0.5">
<div className="font-medium">{row.title}</div>
{row.username && (
<a
href={`https://t.me/${row.username}`}
className="text-xs text-primary hover:underline"
target="_blank"
rel="noreferrer"
>
@{row.username}
</a>
)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(row.cpf, 2)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">Нет данных</p>
)}
</TabsContent>
</>
)}
</CardContent>
</Tabs>
</Card>
<Card className="border-border/70">
<CardHeader className="flex items-center justify-between pb-3">
<CardTitle className="text-base font-semibold">Сумма закупов по проектам</CardTitle>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setProjectPage((p) => Math.max(1, p - 1))}
disabled={projectPage === 1}
>
Назад
</Button>
<span className="text-sm text-muted-foreground">
{projectPage}/{totalProjectPages}
</span>
<Button
size="sm"
variant="outline"
onClick={() => setProjectPage((p) => Math.min(totalProjectPages, p + 1))}
disabled={projectPage === totalProjectPages}
>
Вперед
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-[200px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : paginatedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Нет данных</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/3">Проект</TableHead>
<TableHead className="text-right">Сумма</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProjects.map((row) => (
<TableRow key={row.project_id}>
<TableCell className="space-y-0.5">
<div className="font-medium">{row.project_title}</div>
{row.project_username && (
<span className="text-xs text-muted-foreground">@{row.project_username}</span>
)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(row.total_cost, 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
</>
);
}