feat: global platform update
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertCircle, LogIn, Loader2 } from "lucide-react";
|
import { AlertCircle, LogIn, Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { loginMock, error: authError } = useAuth();
|
const { loginMock, error: authError } = useAuth();
|
||||||
@@ -43,15 +44,19 @@ export default function LoginPage() {
|
|||||||
const displayError = error || authError;
|
const displayError = error || authError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-4">
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#2B1D49] via-[#5F31F4] to-[#2B1D49] p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center space-y-2">
|
<CardHeader className="text-center space-y-2">
|
||||||
<div className="mx-auto w-12 h-12 bg-primary rounded-lg flex items-center justify-center mb-2">
|
<div className="mx-auto w-12 h-12 flex items-center justify-center mb-2">
|
||||||
<span className="text-2xl font-bold text-primary-foreground">
|
<Image
|
||||||
TG
|
src="/logo.webp"
|
||||||
</span>
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt="Smartpost"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">TGEX</CardTitle>
|
<CardTitle className="text-2xl">Smartpost</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Система управления рекламными закупками в Telegram
|
Система управления рекламными закупками в Telegram
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -1,16 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Dashboard Home Page
|
// Dashboard Overview (Главная)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Loader2, ShoppingCart, Users, TrendingUp, BarChart3 } from "lucide-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 { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
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
|
// Helpers
|
||||||
@@ -23,24 +53,94 @@ const formatNumber = (value: number | null | undefined, showSign = false): strin
|
|||||||
return formatted;
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number | null | undefined): string => {
|
const formatCurrency = (
|
||||||
|
value: number | null | undefined,
|
||||||
|
digits = 0
|
||||||
|
): string => {
|
||||||
if (value === null || value === undefined) return "—";
|
if (value === null || value === undefined) return "—";
|
||||||
return new Intl.NumberFormat("ru-RU", {
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "RUB",
|
currency: "RUB",
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: digits,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: digits,
|
||||||
}).format(value);
|
}).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
|
// Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { currentWorkspace, currentProject, isLoading: workspaceLoading, projects } = useWorkspace();
|
const {
|
||||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
currentWorkspace,
|
||||||
|
isLoading: workspaceLoading,
|
||||||
|
selectedProjects,
|
||||||
|
projects,
|
||||||
|
getProjectFilterId,
|
||||||
|
} = useWorkspace();
|
||||||
|
|
||||||
|
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!currentWorkspace) {
|
if (!currentWorkspace) {
|
||||||
@@ -51,19 +151,109 @@ export default function DashboardPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await demoAwareAnalyticsApi.spending(currentWorkspace.id, {
|
const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, {
|
||||||
project_id: currentProject?.id,
|
project_id: projectFilterId,
|
||||||
|
date_from: dateFromIso,
|
||||||
|
date_to: dateToIso,
|
||||||
});
|
});
|
||||||
setSpending(data);
|
setOverview(data);
|
||||||
|
setProjectPage(1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load spending:", err);
|
console.error("Failed to load overview:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
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) {
|
if (workspaceLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
{/* Stats Cards */}
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card/40 p-3">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div>
|
||||||
<Card>
|
<p className="text-sm text-muted-foreground">Статистика за период</p>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<p className="text-base font-semibold">{dateRangeLabel}</p>
|
||||||
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
|
</div>
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="border-border/70">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
<CardTitle className="text-base font-semibold">Траты по дням</CardTitle>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<DeltaPill delta={overview?.total_cost.delta_percent} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="border-border/70">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Средний CPS</CardTitle>
|
<CardTitle className="text-base font-semibold">Стоимость подписчика (CPF)</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
<DeltaPill delta={overview?.avg_cpf.delta_percent} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{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>
|
<ChartContainer
|
||||||
)}
|
config={{
|
||||||
</CardContent>
|
cpf: {
|
||||||
</Card>
|
label: "CPF",
|
||||||
|
color: "hsl(var(--chart-3))",
|
||||||
<Card>
|
},
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
}}
|
||||||
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
|
className="aspect-auto h-[260px] w-full"
|
||||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
>
|
||||||
</CardHeader>
|
<BarChart data={cpfChartData} margin={{ left: 8, right: 8, top: 8 }}>
|
||||||
<CardContent>
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
{loading ? (
|
<XAxis dataKey="dateLabel" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<YAxis
|
||||||
) : (
|
tickLine={false}
|
||||||
<div className="text-2xl font-bold">{formatCurrency(avgCpm)}</div>
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Строка 3 — таблицы */}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<Card className="border-border/70">
|
||||||
<CardHeader>
|
<Tabs value={cpfMode} onValueChange={(value) => setCpfMode(value as "top" | "worst")}>
|
||||||
<CardTitle>Быстрый старт</CardTitle>
|
<CardHeader className="flex flex-col gap-2 pb-3">
|
||||||
<CardDescription>Начните с основных действий</CardDescription>
|
<div className="flex items-center justify-between">
|
||||||
</CardHeader>
|
<CardTitle className="text-base font-semibold">Топ / Антитоп по CPF</CardTitle>
|
||||||
<CardContent className="space-y-2">
|
<TabsList className="h-8">
|
||||||
<a
|
<TabsTrigger value="top" className="h-8 px-3 text-sm">
|
||||||
href={`/dashboard/${currentWorkspace.id}/placements/new`}
|
Топ 5
|
||||||
className="block text-sm text-primary hover:underline"
|
</TabsTrigger>
|
||||||
>
|
<TabsTrigger value="worst" className="h-8 px-3 text-sm">
|
||||||
→ Создать размещение
|
Антитоп 5
|
||||||
</a>
|
</TabsTrigger>
|
||||||
<a
|
</TabsList>
|
||||||
href={`/dashboard/${currentWorkspace.id}/creatives/new`}
|
</div>
|
||||||
className="block text-sm text-primary hover:underline"
|
</CardHeader>
|
||||||
>
|
<CardContent>
|
||||||
→ Создать креатив
|
{loading ? (
|
||||||
</a>
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
<a
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
href={`/dashboard/${currentWorkspace.id}/analytics`}
|
</div>
|
||||||
className="block text-sm text-primary hover:underline"
|
) : (
|
||||||
>
|
<>
|
||||||
→ Посмотреть аналитику
|
<TabsContent value="top" className="mt-0">
|
||||||
</a>
|
{overview?.top_channels_by_cpf?.length ? (
|
||||||
</CardContent>
|
<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>
|
||||||
|
|
||||||
<Card>
|
<Card className="border-border/70">
|
||||||
<CardHeader>
|
<CardHeader className="flex items-center justify-between pb-3">
|
||||||
<CardTitle>Проекты</CardTitle>
|
<CardTitle className="text-base font-semibold">Сумма закупов по проектам</CardTitle>
|
||||||
<CardDescription>
|
<div className="flex items-center gap-2">
|
||||||
{projects.length > 0
|
<Button
|
||||||
? `${projects.length} активных проектов`
|
size="sm"
|
||||||
: "Нет активных проектов"}
|
variant="outline"
|
||||||
</CardDescription>
|
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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{projects.length > 0 ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
{projects.slice(0, 5).map((project) => (
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : paginatedProjects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Нет данных</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<Table>
|
||||||
Добавьте бота в канал через Telegram для создания проекта
|
<TableHeader>
|
||||||
</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -228,4 +662,3 @@ export default function DashboardPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function OnboardingPage() {
|
|||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||||
<Megaphone className="h-8 w-8 text-primary" />
|
<Megaphone className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">Добро пожаловать в TGEX!</CardTitle>
|
<CardTitle className="text-2xl">Добро пожаловать в Smartpost!</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Создайте свой первый воркспейс для начала работы
|
Создайте свой первый воркспейс для начала работы
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "TGEX - Управление рекламными закупками",
|
title: "Smartpost - Управление рекламными закупками",
|
||||||
description:
|
description:
|
||||||
"Система для владельцев каналов для управления рекламными закупками в Telegram",
|
"Система для владельцев каналов для управления рекламными закупками в Telegram",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ import {
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type NavGuideState = {
|
||||||
|
highlightedItem?: string;
|
||||||
|
expandedItems?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({
|
||||||
items,
|
items,
|
||||||
|
guideState,
|
||||||
}: {
|
}: {
|
||||||
items: {
|
items: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -40,10 +47,12 @@ export function NavMain({
|
|||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
|
guideState?: NavGuideState;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const isCollapsed = state === "collapsed";
|
const isCollapsed = state === "collapsed";
|
||||||
|
const highlightedItem = guideState?.highlightedItem;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
@@ -53,6 +62,11 @@ export function NavMain({
|
|||||||
const isActive = pathname === item.url;
|
const isActive = pathname === item.url;
|
||||||
const hasActiveSubmenu =
|
const hasActiveSubmenu =
|
||||||
hasSubmenu && item.items!.some((sub) => pathname === sub.url);
|
hasSubmenu && item.items!.some((sub) => pathname === sub.url);
|
||||||
|
const shouldForceOpen = guideState?.expandedItems?.includes(item.title);
|
||||||
|
const isGuideHighlight = highlightedItem === item.title;
|
||||||
|
const highlightedButtonClass = isGuideHighlight
|
||||||
|
? "ring-2 ring-primary/70"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// В свернутом состоянии показываем DropdownMenu для элементов с подменю
|
// В свернутом состоянии показываем DropdownMenu для элементов с подменю
|
||||||
if (isCollapsed && hasSubmenu) {
|
if (isCollapsed && hasSubmenu) {
|
||||||
@@ -63,6 +77,7 @@ export function NavMain({
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
isActive={isActive || hasActiveSubmenu}
|
isActive={isActive || hasActiveSubmenu}
|
||||||
|
className={cn(highlightedButtonClass)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
@@ -99,9 +114,11 @@ export function NavMain({
|
|||||||
// Обычное отображение для развернутого состояния
|
// Обычное отображение для развернутого состояния
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
key={item.title}
|
key={`${item.title}-${shouldForceOpen ? "guide" : "default"}`}
|
||||||
asChild
|
asChild
|
||||||
defaultOpen={isActive || hasActiveSubmenu}
|
defaultOpen={
|
||||||
|
shouldForceOpen || isActive || hasActiveSubmenu
|
||||||
|
}
|
||||||
className="group/collapsible"
|
className="group/collapsible"
|
||||||
>
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@@ -111,6 +128,7 @@ export function NavMain({
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
isActive={isActive || hasActiveSubmenu}
|
isActive={isActive || hasActiveSubmenu}
|
||||||
|
className={cn(highlightedButtonClass)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
@@ -142,6 +160,7 @@ export function NavMain({
|
|||||||
asChild
|
asChild
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
|
className={cn(highlightedButtonClass)}
|
||||||
>
|
>
|
||||||
<Link href={item.url}>
|
<Link href={item.url}>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LogOut, User } from "lucide-react";
|
import { Building2, Check, LogOut, Plus } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
@@ -19,19 +18,26 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import type { Workspace } from "@/lib/types/api";
|
||||||
|
|
||||||
export function NavUser({
|
export function NavUser({
|
||||||
user,
|
user,
|
||||||
|
workspaceSwitcher,
|
||||||
}: {
|
}: {
|
||||||
user: {
|
user: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
};
|
};
|
||||||
|
workspaceSwitcher?: {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
currentWorkspaceId?: string;
|
||||||
|
onSelect: (workspace: Workspace) => void;
|
||||||
|
onCreate?: () => void;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@@ -91,6 +97,51 @@ export function NavUser({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
{workspaceSwitcher && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
Воркспейсы
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{workspaceSwitcher.workspaces.length > 0 ? (
|
||||||
|
workspaceSwitcher.workspaces.map((workspace) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={workspace.id}
|
||||||
|
onClick={() => workspaceSwitcher.onSelect(workspace)}
|
||||||
|
className="gap-2 p-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
|
<Building2 className="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 truncate">{workspace.name}</span>
|
||||||
|
{workspaceSwitcher.currentWorkspaceId === workspace.id && (
|
||||||
|
<Check className="size-4 ml-auto" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
Нет доступных воркспейсов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{workspaceSwitcher.onCreate && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={workspaceSwitcher.onCreate}
|
||||||
|
className="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Создать воркспейс
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
AnalyticsQueryParams,
|
AnalyticsQueryParams,
|
||||||
SpendingAnalyticsQueryParams,
|
SpendingAnalyticsQueryParams,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
OverviewAnalytics,
|
||||||
|
OverviewAnalyticsQueryParams,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
@@ -49,4 +51,13 @@ export const analyticsApi = {
|
|||||||
`/workspaces/${workspaceId}/analytics/spending`,
|
`/workspaces/${workspaceId}/analytics/spending`,
|
||||||
{ params }
|
{ params }
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overview analytics for dashboard
|
||||||
|
*/
|
||||||
|
overview: (workspaceId: string, params?: OverviewAnalyticsQueryParams) =>
|
||||||
|
api.get<OverviewAnalytics>(
|
||||||
|
`/workspaces/${workspaceId}/analytics/overview`,
|
||||||
|
{ params }
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
SpendingAnalytics,
|
SpendingAnalytics,
|
||||||
CreativeAnalyticsItem,
|
CreativeAnalyticsItem,
|
||||||
ChannelAnalyticsItem,
|
ChannelAnalyticsItem,
|
||||||
|
OverviewAnalytics,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
import {
|
import {
|
||||||
demoWorkspace,
|
demoWorkspace,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
demoSpendingAnalytics,
|
demoSpendingAnalytics,
|
||||||
demoCreativeAnalytics,
|
demoCreativeAnalytics,
|
||||||
demoChannelAnalytics,
|
demoChannelAnalytics,
|
||||||
|
demoOverviewAnalytics,
|
||||||
} from "./mock-data";
|
} from "./mock-data";
|
||||||
import { DEMO_USER } from "./constants";
|
import { DEMO_USER } from "./constants";
|
||||||
|
|
||||||
@@ -191,6 +193,13 @@ export const demoApi = {
|
|||||||
}
|
}
|
||||||
return Promise.resolve(paginate(items));
|
return Promise.resolve(paginate(items));
|
||||||
},
|
},
|
||||||
|
overview: (_params?: {
|
||||||
|
project_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}): Promise<OverviewAnalytics> => {
|
||||||
|
return Promise.resolve(demoOverviewAnalytics);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
demoProjects,
|
demoProjects,
|
||||||
demoWorkspace,
|
demoWorkspace,
|
||||||
demoMember,
|
demoMember,
|
||||||
|
demoOverviewAnalytics,
|
||||||
} from "./mock-data";
|
} from "./mock-data";
|
||||||
import type {
|
import type {
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
@@ -29,6 +30,7 @@ import type {
|
|||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
DateGrouping,
|
DateGrouping,
|
||||||
|
OverviewAnalytics,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
import {
|
import {
|
||||||
workspacesApi,
|
workspacesApi,
|
||||||
@@ -305,5 +307,14 @@ export const demoAwareAnalyticsApi = {
|
|||||||
}
|
}
|
||||||
return analyticsApi.channels(workspaceId, params);
|
return analyticsApi.channels(workspaceId, params);
|
||||||
},
|
},
|
||||||
|
overview: async (
|
||||||
|
workspaceId: string,
|
||||||
|
params?: { project_id?: string; date_from?: string; date_to?: string }
|
||||||
|
): Promise<OverviewAnalytics> => {
|
||||||
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
return demoOverviewAnalytics;
|
||||||
|
}
|
||||||
|
return analyticsApi.overview(workspaceId, params);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
SpendingAnalytics,
|
SpendingAnalytics,
|
||||||
CreativeAnalyticsItem,
|
CreativeAnalyticsItem,
|
||||||
ChannelAnalyticsItem,
|
ChannelAnalyticsItem,
|
||||||
|
OverviewAnalytics,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants";
|
import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants";
|
||||||
|
|
||||||
@@ -581,3 +582,125 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const demoOverviewAnalytics: OverviewAnalytics = {
|
||||||
|
total_cost: { value: 523400.5, delta_percent: 12.4 },
|
||||||
|
total_reach: { value: 380000, delta_percent: -8.1 },
|
||||||
|
placements_count: { value: 74, delta_percent: 5.7 },
|
||||||
|
subscriptions_count: { value: 14250, delta_percent: 18.2 },
|
||||||
|
avg_cpm: { value: 1376.06, delta_percent: 2.9 },
|
||||||
|
avg_cpf: { value: 36.74, delta_percent: -5.4 },
|
||||||
|
daily_stats: [
|
||||||
|
{ date: "2024-06-01", cost: 18450, subscriptions: 420, subscriptions_delta: -15, cpf: 43.93 },
|
||||||
|
{ date: "2024-06-02", cost: 21700, subscriptions: 505, subscriptions_delta: 85, cpf: 42.97 },
|
||||||
|
{ date: "2024-06-03", cost: 15800, subscriptions: 380, subscriptions_delta: -125, cpf: 41.58 },
|
||||||
|
{ date: "2024-06-04", cost: 26500, subscriptions: 640, subscriptions_delta: 260, cpf: 41.41 },
|
||||||
|
{ date: "2024-06-05", cost: 30200, subscriptions: 715, subscriptions_delta: 75, cpf: 42.24 },
|
||||||
|
{ date: "2024-06-06", cost: 18800, subscriptions: 455, subscriptions_delta: -260, cpf: 41.32 },
|
||||||
|
{ date: "2024-06-07", cost: 22400, subscriptions: 520, subscriptions_delta: 65, cpf: 43.08 },
|
||||||
|
],
|
||||||
|
top_channels_by_cpf: [
|
||||||
|
{
|
||||||
|
channel_id: "chan-0005-0000-0000-000000000005",
|
||||||
|
title: "Финансовая грамотность",
|
||||||
|
username: "finance_edu",
|
||||||
|
cpf: 18.7,
|
||||||
|
total_cost: 44200,
|
||||||
|
subscriptions: 2365,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0003-0000-0000-000000000003",
|
||||||
|
title: "Программисты",
|
||||||
|
username: "programmers_hub",
|
||||||
|
cpf: 21.8,
|
||||||
|
total_cost: 38900,
|
||||||
|
subscriptions: 1785,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0001-0000-0000-000000000001",
|
||||||
|
title: "Технологии Будущего",
|
||||||
|
username: "tech_future",
|
||||||
|
cpf: 23.4,
|
||||||
|
total_cost: 35500,
|
||||||
|
subscriptions: 1517,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0002-0000-0000-000000000002",
|
||||||
|
title: "Инвестиции Pro",
|
||||||
|
username: "invest_pro",
|
||||||
|
cpf: 24.6,
|
||||||
|
total_cost: 33100,
|
||||||
|
subscriptions: 1346,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0006-0000-0000-000000000006",
|
||||||
|
title: "Startup Daily",
|
||||||
|
username: "startup_daily",
|
||||||
|
cpf: 25.2,
|
||||||
|
total_cost: 28800,
|
||||||
|
subscriptions: 1143,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
worst_channels_by_cpf: [
|
||||||
|
{
|
||||||
|
channel_id: "chan-0010-0000-0000-000000000010",
|
||||||
|
title: "Crypto Buzz",
|
||||||
|
username: "cryptobuzz",
|
||||||
|
cpf: 84.5,
|
||||||
|
total_cost: 9900,
|
||||||
|
subscriptions: 117,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0009-0000-0000-000000000009",
|
||||||
|
title: "Ads Unlimited",
|
||||||
|
username: "ads_unlimited",
|
||||||
|
cpf: 72.3,
|
||||||
|
total_cost: 8200,
|
||||||
|
subscriptions: 113,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0008-0000-0000-000000000008",
|
||||||
|
title: "News Mix",
|
||||||
|
username: "newsmix",
|
||||||
|
cpf: 66.2,
|
||||||
|
total_cost: 11400,
|
||||||
|
subscriptions: 172,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0007-0000-0000-000000000007",
|
||||||
|
title: "Random Talks",
|
||||||
|
username: "randomtalks",
|
||||||
|
cpf: 61.9,
|
||||||
|
total_cost: 13400,
|
||||||
|
subscriptions: 216,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel_id: "chan-0004-0000-0000-000000000004",
|
||||||
|
title: "Маркетинг Pro",
|
||||||
|
username: "marketing_pro",
|
||||||
|
cpf: 58.4,
|
||||||
|
total_cost: 12700,
|
||||||
|
subscriptions: 217,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
project_spending: [
|
||||||
|
{
|
||||||
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
|
project_title: "Крипто Новости",
|
||||||
|
project_username: "crypto_news_demo",
|
||||||
|
total_cost: 244000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
|
project_title: "IT Вакансии",
|
||||||
|
project_username: "it_jobs_demo",
|
||||||
|
total_cost: 182400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project_id: "proj-0003-0000-0000-000000000003",
|
||||||
|
project_title: "Стартапы и Бизнес",
|
||||||
|
project_username: "startups_demo",
|
||||||
|
total_cost: 96900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,55 @@ export interface SpendingAnalyticsQueryParams {
|
|||||||
grouping?: DateGrouping;
|
grouping?: DateGrouping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overview Analytics
|
||||||
|
export interface OverviewMetric {
|
||||||
|
value: number;
|
||||||
|
delta_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewDailyStat {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
cost: number;
|
||||||
|
subscriptions: number;
|
||||||
|
subscriptions_delta: number;
|
||||||
|
cpf: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewChannelCpf {
|
||||||
|
channel_id: string;
|
||||||
|
title: string;
|
||||||
|
username: string | null;
|
||||||
|
cpf: number;
|
||||||
|
total_cost: number;
|
||||||
|
subscriptions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewProjectSpending {
|
||||||
|
project_id: string;
|
||||||
|
project_title: string;
|
||||||
|
project_username: string | null;
|
||||||
|
total_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewAnalytics {
|
||||||
|
total_cost: OverviewMetric;
|
||||||
|
total_reach: OverviewMetric;
|
||||||
|
placements_count: OverviewMetric;
|
||||||
|
subscriptions_count: OverviewMetric;
|
||||||
|
avg_cpm: OverviewMetric;
|
||||||
|
avg_cpf: OverviewMetric;
|
||||||
|
daily_stats: OverviewDailyStat[];
|
||||||
|
top_channels_by_cpf: OverviewChannelCpf[];
|
||||||
|
worst_channels_by_cpf: OverviewChannelCpf[];
|
||||||
|
project_spending: OverviewProjectSpending[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewAnalyticsQueryParams {
|
||||||
|
project_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Common Types
|
// Common Types
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
BIN
public/logo.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/logo_full_dark.webp
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
public/logo_full_white.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/logo_full_white_2.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |