feat: global platform update

This commit is contained in:
ivannoskov
2026-01-07 14:17:43 +03:00
parent 25b194ad41
commit 3b6b97447b
22 changed files with 1536 additions and 309 deletions

View File

@@ -1,16 +1,46 @@
"use client";
// ============================================================================
// Dashboard Home Page
// Dashboard Overview (Главная)
// ============================================================================
import { useEffect, useState } from "react";
import { Loader2, ShoppingCart, Users, TrendingUp, BarChart3 } from "lucide-react";
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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { SpendingAnalytics } from "@/lib/types/api";
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
@@ -23,24 +53,94 @@ const formatNumber = (value: number | null | undefined, showSign = false): strin
return formatted;
};
const formatCurrency = (value: number | null | undefined): string => {
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: 0,
maximumFractionDigits: 0,
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, currentProject, isLoading: workspaceLoading, projects } = useWorkspace();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
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<DateRange | 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) {
@@ -51,19 +151,109 @@ export default function DashboardPage() {
const loadData = async () => {
try {
setLoading(true);
const data = await demoAwareAnalyticsApi.spending(currentWorkspace.id, {
project_id: currentProject?.id,
const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, {
project_id: projectFilterId,
date_from: dateFromIso,
date_to: dateToIso,
});
setSpending(data);
setOverview(data);
setProjectPage(1);
} catch (err) {
console.error("Failed to load spending:", err);
console.error("Failed to load overview:", err);
} finally {
setLoading(false);
}
};
loadData();
}, [currentWorkspace?.id, currentProject?.id]);
}, [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 (
@@ -92,134 +282,378 @@ export default function DashboardPage() {
);
}
// Calculate totals and averages
const totalCost = spending?.total_cost ?? 0;
const totalSubscriptions = spending?.total_subscriptions ?? 0;
const totalViews = spending?.total_views ?? 0;
const avgCps = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null;
const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null;
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<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}
onSelect={(range) => setDateRange(range)}
/>
</PopoverContent>
</Popover>
</div>
{/* Строка 1 — компактные показатели */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{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 ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex h-[240px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="text-2xl font-bold">{formatCurrency(totalCost)}</div>
<ChartContainer
config={{
subscriptions_delta: {
label: "Δ подписок",
color: "hsl(var(--chart-1))",
},
}}
className="aspect-auto h-[260px] w-full"
>
<BarChart data={subscriptionsChartData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="dateLabel" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ReferenceLine y={0} stroke="#cbd5e1" />
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
label={false}
>
{subscriptionsChartData.map((entry) => (
<Cell
key={`cell-${entry.date}`}
fill={entry.value >= 0 ? "#22c55e" : "#ef4444"}
/>
))}
</Bar>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ""}
formatter={(value: number) => [formatNumber(value, true), "Δ подписок"]}
/>
}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
<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 ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex h-[240px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="text-2xl font-bold">{formatNumber(totalSubscriptions)}</div>
<ChartContainer
config={{
cost: {
label: "Затраты",
color: "hsl(var(--chart-2))",
},
}}
className="aspect-auto h-[260px] w-full"
>
<BarChart data={costChartData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="dateLabel" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatCurrency(value, 0)}
/>
<Bar dataKey="value" fill="var(--color-cost)" radius={[4, 4, 0, 0]} />
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ""}
formatter={(value: number) => [formatCurrency(value, 0), "Затраты"]}
/>
}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPS</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<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 ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex h-[240px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="text-2xl font-bold">{formatCurrency(avgCps)}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
<BarChart3 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">{formatCurrency(avgCpm)}</div>
<ChartContainer
config={{
cpf: {
label: "CPF",
color: "hsl(var(--chart-3))",
},
}}
className="aspect-auto h-[260px] w-full"
>
<BarChart data={cpfChartData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="dateLabel" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatCurrency(value, 0)}
/>
<Bar dataKey="value" fill="var(--color-cpf)" radius={[4, 4, 0, 0]} />
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ""}
formatter={(value: number) => [formatCurrency(value, 2), "CPF"]}
/>
}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Быстрый старт</CardTitle>
<CardDescription>Начните с основных действий</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<a
href={`/dashboard/${currentWorkspace.id}/placements/new`}
className="block text-sm text-primary hover:underline"
>
Создать размещение
</a>
<a
href={`/dashboard/${currentWorkspace.id}/creatives/new`}
className="block text-sm text-primary hover:underline"
>
Создать креатив
</a>
<a
href={`/dashboard/${currentWorkspace.id}/analytics`}
className="block text-sm text-primary hover:underline"
>
Посмотреть аналитику
</a>
</CardContent>
{/* Строка 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>
<CardHeader>
<CardTitle>Проекты</CardTitle>
<CardDescription>
{projects.length > 0
? `${projects.length} активных проектов`
: "Нет активных проектов"}
</CardDescription>
<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>
{projects.length > 0 ? (
<div className="space-y-2">
{projects.slice(0, 5).map((project) => (
<div
key={project.id}
className="flex items-center justify-between text-sm"
>
<span className="font-medium">{project.title}</span>
{project.username && (
<span className="text-muted-foreground">@{project.username}</span>
)}
</div>
))}
{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>
) : (
<p className="text-sm text-muted-foreground">
Добавьте бота в канал через Telegram для создания проекта
</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>
@@ -228,4 +662,3 @@ export default function DashboardPage() {
</>
);
}