feat: all project setup

This commit is contained in:
ivannoskov
2025-11-10 12:07:24 +03:00
parent fbfd7719bb
commit b0a9934220
99 changed files with 19597 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
"use client";
// ============================================================================
// Auth Complete Page
// ============================================================================
import React, { useEffect, useState, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/auth/auth-provider";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
function AuthCompleteContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { login } = useAuth();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading"
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setStatus("error");
setError("Токен авторизации не найден");
return;
}
const completeAuth = async () => {
try {
await login(token);
setStatus("success");
// Redirect after short delay
setTimeout(() => {
router.push("/");
}, 1500);
} catch (err: any) {
setStatus("error");
setError(err?.error?.message || "Ошибка авторизации");
}
};
completeAuth();
}, [searchParams, login, router]);
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">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4">
{status === "loading" && (
<Loader2 className="h-12 w-12 animate-spin text-primary" />
)}
{status === "success" && (
<CheckCircle className="h-12 w-12 text-green-500" />
)}
{status === "error" && (
<AlertCircle className="h-12 w-12 text-destructive" />
)}
</div>
<CardTitle>
{status === "loading" && "Авторизация..."}
{status === "success" && "Успешно!"}
{status === "error" && "Ошибка"}
</CardTitle>
<CardDescription>
{status === "loading" && "Пожалуйста, подождите"}
{status === "success" && "Перенаправляем в приложение..."}
{status === "error" && error}
</CardDescription>
</CardHeader>
{status === "error" && (
<CardContent>
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button onClick={() => router.push("/login")} className="w-full">
Вернуться к входу
</Button>
</CardContent>
)}
</Card>
</div>
);
}
export default function AuthCompletePage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
}
>
<AuthCompleteContent />
</Suspense>
);
}

135
app/(auth)/login/page.tsx Normal file
View File

@@ -0,0 +1,135 @@
"use client";
// ============================================================================
// Login Page
// ============================================================================
import React, { useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { USE_MOCKS, BOT_USERNAME } from "@/lib/utils/constants";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertCircle, LogIn, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function LoginPage() {
const { loginMock, error: authError } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleTelegramLogin = () => {
const telegramUrl = `https://t.me/${BOT_USERNAME}?start=login`;
window.open(telegramUrl, "_blank");
};
const handleMockLogin = async () => {
try {
setLoading(true);
setError(null);
await loginMock();
} catch (err: any) {
setError(err?.error?.message || "Ошибка входа");
} finally {
setLoading(false);
}
};
const displayError = error || authError;
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">
<Card className="w-full max-w-md">
<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">
<span className="text-2xl font-bold text-primary-foreground">
TG
</span>
</div>
<CardTitle className="text-2xl">TGEX</CardTitle>
<CardDescription>
Система управления рекламными закупками в Telegram
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{displayError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{displayError}</AlertDescription>
</Alert>
)}
{USE_MOCKS && (
<div className="space-y-3">
<Button
onClick={handleMockLogin}
disabled={loading}
className="w-full"
size="lg"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Вход...
</>
) : (
<>
<LogIn className="mr-2 h-4 w-4" />
Войти как тестовый пользователь
</>
)}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
или
</span>
</div>
</div>
</div>
)}
<Button
onClick={handleTelegramLogin}
variant={USE_MOCKS ? "outline" : "default"}
className="w-full"
size="lg"
>
<svg
className="mr-2 h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18.717-.962 3.767-1.362 5.001-.169.523-.506.697-.831.715-.708.064-1.245-.468-1.931-.918-.988-.646-1.547-1.048-2.508-1.678-.111-.073-.336-.218-.325-.409.009-.169.169-.316.373-.503l3.067-2.854c.234-.217.136-.359-.15-.223l-3.845 2.365c-.516.324-.989.472-1.411.461-.675-.018-1.969-.393-2.937-.719-.688-.232-1.236-.354-1.188-.755.025-.211.325-.426.9-.645 4.089-1.781 6.785-2.954 8.09-3.518 3.853-1.636 4.65-1.918 5.17-1.926.115-.002.371.026.536.161.141.114.18.267.198.375.019.108.042.353.024.545z" />
</svg>
Войти через Telegram
</Button>
{USE_MOCKS && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
<strong>Режим разработки:</strong> Используются моки данных.
Бекенд не требуется.
</AlertDescription>
</Alert>
)}
<p className="text-xs text-center text-muted-foreground mt-4">
Войдя в систему, вы соглашаетесь с условиями использования
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,294 @@
"use client";
// ============================================================================
// Analytics Costs Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { analyticsApi, channelsApi } from "@/lib/api";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import { Loader2, AlertCircle, DollarSign } from "lucide-react";
import type { CostsReport } from "@/lib/types/api";
import type { TargetChannel } from "@/lib/types/api";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
export default function AnalyticsCostsPage() {
const [report, setReport] = useState<CostsReport | null>(null);
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [period, setPeriod] = useState<"day" | "week" | "month">("week");
const [targetChannelId, setTargetChannelId] = useState<string | undefined>(
undefined
);
useEffect(() => {
loadData();
}, [period, targetChannelId]);
const loadData = async () => {
try {
setLoading(true);
const [reportData, channelsData] = await Promise.all([
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
channels.length > 0
? Promise.resolve({ data: channels })
: channelsApi.list({}),
]);
setReport(reportData);
if (channels.length === 0) {
setChannels(channelsData.data);
}
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки аналитики");
} finally {
setLoading(false);
}
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Аналитика", href: "/analytics" },
{ label: "Затраты" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Затраты на рекламу
</h1>
<p className="text-muted-foreground mt-1">
Динамика расходов по периодам
</p>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Фильтры</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">
Период группировки
</label>
<Select
value={period}
onValueChange={(value: any) => setPeriod(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Целевой канал</label>
<Select
value={targetChannelId || "all"}
onValueChange={(value) =>
setTargetChannelId(value === "all" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{loading ? (
<Card>
<CardContent className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : report ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего затрат
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(report.totalCost)}
</div>
<p className="text-xs text-muted-foreground mt-1">
За выбранный период
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Средние затраты
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(report.averageCost)}
</div>
<p className="text-xs text-muted-foreground mt-1">
В среднем за период
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Периодов
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(report.periods.length)}
</div>
<p className="text-xs text-muted-foreground mt-1">
С активностью
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>График затрат</CardTitle>
<CardDescription>Динамика расходов на рекламу</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={report.periods}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="date"
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(value) => `${formatNumber(value)}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
formatter={(value: any) => [
`${formatNumber(value)}`,
"Затраты",
]}
/>
<Legend />
<Bar
dataKey="total_cost"
name="Затраты"
fill="hsl(var(--primary))"
radius={[8, 8, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Детализация по периодам</CardTitle>
<CardDescription>Затраты и количество закупов</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{report.periods.map((period) => (
<div
key={period.date}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div>
<p className="font-medium">{period.date}</p>
<p className="text-sm text-muted-foreground">
{formatNumber(period.purchases_count)} закупов
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold">
{formatCurrency(period.total_cost)}
</p>
<p className="text-sm text-muted-foreground">
ср. {formatNumber(period.avg_cost)}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</>
) : null}
</div>
</>
);
}

View File

@@ -0,0 +1,348 @@
"use client";
// ============================================================================
// Analytics Overview Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { analyticsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatPercent,
} from "@/lib/utils/format";
import {
BarChart3,
Loader2,
AlertCircle,
TrendingUp,
TrendingDown,
Users,
ShoppingCart,
DollarSign,
} from "lucide-react";
import type { AnalyticsOverview } from "@/lib/types/api";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
export default function AnalyticsPage() {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadOverview();
}, []);
const loadOverview = async () => {
try {
setLoading(true);
const data = await analyticsApi.overview();
setOverview(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки аналитики");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Аналитика" },
]}
/>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (error || !overview) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Аналитика" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error || "Не удалось загрузить аналитику"}
</AlertDescription>
</Alert>
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Аналитика" },
{ label: "Обзор" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Аналитика</h1>
<p className="text-muted-foreground mt-1">
Сводная статистика за последний месяц
</p>
</div>
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(overview.totalPurchases)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.purchasesChange >= 0 ? (
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.purchasesChange >= 0
? "text-green-600"
: "text-red-600"
}
>
{overview.purchasesChange >= 0 ? "+" : ""}
{formatNumber(overview.purchasesChange)}
</span>
<span className="text-muted-foreground ml-1">
за прошлый месяц
</span>
</div>
</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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(overview.totalFollowers)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.followersChange >= 0 ? (
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.followersChange >= 0
? "text-green-600"
: "text-red-600"
}
>
{overview.followersChange >= 0 ? "+" : ""}
{formatNumber(overview.followersChange)}
</span>
<span className="text-muted-foreground ml-1">
за прошлый месяц
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(overview.averageCpf)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.cpfChange <= 0 ? (
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.cpfChange <= 0 ? "text-green-600" : "text-red-600"
}
>
{formatPercent(overview.cpfChange, true)}
</span>
<span className="text-muted-foreground ml-1">
от прошлого месяца
</span>
</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>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(overview.averageCpm)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.cpmChange <= 0 ? (
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.cpmChange <= 0 ? "text-green-600" : "text-red-600"
}
>
{formatPercent(overview.cpmChange, true)}
</span>
<span className="text-muted-foreground ml-1">
от прошлого месяца
</span>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Топ каналов по CPF</CardTitle>
<CardDescription>
Самые эффективные внешние каналы
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overview.topChannelsByCpf?.length > 0 ? (
overview.topChannelsByCpf.slice(0, 5).map((item, index) => (
<div
key={item.channel_id}
className="flex items-center gap-3"
>
<Badge
variant="outline"
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.channel_name}
</p>
<p className="text-xs text-muted-foreground">
{formatNumber(item.total_purchases)} закупов
</p>
</div>
<div className="text-right">
<p className="font-medium">
{formatMetric(item.avg_cpf)}
</p>
<p className="text-xs text-muted-foreground">CPF</p>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-4">
Нет данных
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Топ креативов</CardTitle>
<CardDescription>
Самые эффективные рекламные креативы
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overview.topCreativesByCpf?.length > 0 ? (
overview.topCreativesByCpf.slice(0, 5).map((item, index) => (
<div
key={item.creative_id}
className="flex items-center gap-3"
>
<Badge
variant="outline"
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.creative_name}
</p>
<p className="text-xs text-muted-foreground">
{formatNumber(item.total_subscriptions)} подписчиков
</p>
</div>
<div className="text-right">
<p className="font-medium">
{formatMetric(item.avg_cpf)}
</p>
<p className="text-xs text-muted-foreground">CPF</p>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-4">
Нет данных
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,360 @@
"use client";
// ============================================================================
// Target Channel Detail Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { channelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatUsername,
formatDate,
} from "@/lib/utils/format";
import {
Target,
Users,
TrendingUp,
ArrowLeft,
Loader2,
AlertCircle,
ExternalLink as ExternalLinkIcon,
BarChart3,
} from "lucide-react";
import type { TargetChannelDetail } from "@/lib/types/api";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function ChannelDetailPage({ params }: PageProps) {
const resolvedParams = use(params);
const router = useRouter();
const [channel, setChannel] = useState<TargetChannelDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadChannel = async () => {
try {
setLoading(true);
const data = await channelsApi.get(resolvedParams.id);
setChannel(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки канала");
} finally {
setLoading(false);
}
};
loadChannel();
}, [resolvedParams.id]);
const handleToggleActive = async () => {
if (!channel) return;
try {
const updated = await channelsApi.update(channel.id, {
is_active: !channel.is_active,
});
setChannel({ ...channel, is_active: updated.is_active });
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления канала");
}
};
const handleDelete = async () => {
if (!channel) return;
if (!confirm("Вы уверены, что хотите отключить этот канал?")) return;
try {
await channelsApi.delete(channel.id);
router.push("/channels");
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления канала");
}
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Целевые каналы", href: "/channels" },
{ label: "Загрузка..." },
]}
/>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (error || !channel) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Целевые каналы", href: "/channels" },
{ label: "Ошибка" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error || "Канал не найден"}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href="/channels">
<ArrowLeft className="mr-2 h-4 w-4" />
Вернуться к каналам
</Link>
</Button>
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Целевые каналы", href: "/channels" },
{ label: channel.title },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Target className="h-6 w-6" />
<h1 className="text-3xl font-bold tracking-tight">
{channel.title}
</h1>
<Badge variant={channel.is_active ? "default" : "secondary"}>
{channel.is_active ? "Активен" : "Отключен"}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{channel.username ? (
<a
href={`https://t.me/${channel.username.replace("@", "")}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center gap-1"
>
{formatUsername(channel.username)}
<ExternalLinkIcon className="h-3 w-3" />
</a>
) : (
<span>Приватный канал</span>
)}
<span></span>
<span>Добавлен {formatDate(channel.created_at)}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleToggleActive}>
{channel.is_active ? "Отключить" : "Включить"}
</Button>
<Button variant="destructive" onClick={handleDelete}>
Удалить
</Button>
</div>
</div>
{channel.description && (
<Card>
<CardHeader>
<CardTitle>Описание</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{channel.description}</p>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего закупов
</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(channel.total_purchases)}
</div>
<p className="text-xs text-muted-foreground mt-1">
За весь период
</p>
</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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(channel.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground mt-1">Всего</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(channel.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Стоимость подписчика
</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="stats" className="w-full">
<TabsList>
<TabsTrigger value="stats">Статистика по периодам</TabsTrigger>
<TabsTrigger value="purchases">Последние закупы</TabsTrigger>
</TabsList>
<TabsContent value="stats" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Статистика по периодам</CardTitle>
<CardDescription>
Динамика привлечения подписчиков
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">Затраты</TableHead>
<TableHead className="text-right">CPF</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channel.stats_by_period.map((stat, index) => (
<TableRow key={index}>
<TableCell className="font-medium capitalize">
{stat.period}
</TableCell>
<TableCell className="text-right">
{formatNumber(stat.subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(stat.cost)}
</TableCell>
<TableCell className="text-right">
{formatMetric(stat.cpf)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="purchases" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Последние закупы</CardTitle>
<CardDescription>
5 последних рекламных размещений
</CardDescription>
</CardHeader>
<CardContent>
{channel.recent_purchases.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Закупов пока нет
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Внешний канал</TableHead>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channel.recent_purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">
{purchase.external_channel.title}
</TableCell>
<TableCell>
{formatDate(
purchase.actual_date || purchase.scheduled_date
)}
</TableCell>
<TableCell className="text-right">
{formatNumber(purchase.subscriptions_count)}
</TableCell>
<TableCell className="text-right">
{formatMetric(purchase.cpf)}
</TableCell>
<TableCell className="text-right">
<Button asChild variant="ghost" size="sm">
<Link href={`/purchases/${purchase.id}`}>
Открыть
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
// ============================================================================
// Target Channels List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { channelsApi } from "@/lib/api";
import { formatNumber, formatMetric, formatUsername } from "@/lib/utils/format";
import {
Target,
Users,
TrendingUp,
ExternalLink as ExternalLinkIcon,
Loader2,
AlertCircle,
Plus,
Eye,
} from "lucide-react";
import type { TargetChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function ChannelsPage() {
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"all" | "active" | "inactive">(
"all"
);
useEffect(() => {
const loadChannels = async () => {
try {
setLoading(true);
const response = await channelsApi.list();
setChannels(response.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setLoading(false);
}
};
loadChannels();
}, []);
const filteredChannels = channels.filter((channel) => {
if (activeTab === "active") return channel.is_active;
if (activeTab === "inactive") return !channel.is_active;
return true;
});
const handleToggleActive = async (id: string, isActive: boolean) => {
try {
await channelsApi.update(id, { is_active: !isActive });
setChannels((prev) =>
prev.map((ch) => (ch.id === id ? { ...ch, is_active: !isActive } : ch))
);
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления канала");
}
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Целевые каналы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Целевые каналы
</h1>
<p className="text-muted-foreground mt-1">
Каналы, в которые вы приводите подписчиков
</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Подключение нового канала</CardTitle>
<CardDescription>
Добавьте бота в свой Telegram-канал с правами администратора
</CardDescription>
</div>
<Button asChild>
<a
href="https://t.me/tgex_bot?startgroup=admin"
target="_blank"
rel="noopener noreferrer"
>
<Plus className="mr-2 h-4 w-4" />
Подключить канал
</a>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-lg border bg-muted/50 p-4">
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>Откройте свой Telegram-канал</li>
<li>Добавьте @tgex_bot в администраторы канала</li>
<li>
Предоставьте боту права:{" "}
<strong>создавать инвайт-ссылки</strong> и{" "}
<strong>видеть вступления</strong>
</li>
<li>Канал автоматически появится в списке ниже</li>
</ol>
</div>
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as any)}
className="w-full"
>
<TabsList>
<TabsTrigger value="all">Все ({channels.length})</TabsTrigger>
<TabsTrigger value="active">
Активные ({channels.filter((c) => c.is_active).length})
</TabsTrigger>
<TabsTrigger value="inactive">
Отключенные ({channels.filter((c) => !c.is_active).length})
</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-4">
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<Target className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{activeTab === "all" && "Нет подключенных каналов"}
{activeTab === "active" && "Нет активных каналов"}
{activeTab === "inactive" && "Нет отключенных каналов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
Добавьте бота в свой канал, чтобы начать работу
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredChannels.map((channel) => (
<Card
key={channel.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="truncate flex items-center gap-2">
<Target className="h-4 w-4 shrink-0" />
{channel.title}
</CardTitle>
<CardDescription className="truncate mt-1">
{channel.username ? (
<a
href={`https://t.me/${channel.username.replace(
"@",
""
)}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{formatUsername(channel.username)}
</a>
) : (
<span className="text-muted-foreground">
Приватный канал
</span>
)}
</CardDescription>
</div>
<Badge
variant={channel.is_active ? "default" : "secondary"}
>
{channel.is_active ? "Активен" : "Отключен"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{channel.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{channel.description}
</p>
)}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(channel.total_purchases)}
</div>
<div className="text-xs text-muted-foreground">
Закупов
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(channel.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Подписчиков
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatMetric(channel.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
asChild
variant="default"
size="sm"
className="flex-1"
>
<Link href={`/channels/${channel.id}`}>
<Eye className="mr-2 h-4 w-4" />
Подробнее
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleToggleActive(channel.id, channel.is_active)
}
>
{channel.is_active ? "Отключить" : "Включить"}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@@ -0,0 +1,321 @@
"use client";
// ============================================================================
// Creative Detail Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { creativesApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatDate,
} from "@/lib/utils/format";
import {
Folder,
ArrowLeft,
Loader2,
AlertCircle,
Archive,
Trash2,
BarChart3,
Users,
TrendingUp,
} from "lucide-react";
import type { CreativeDetail } from "@/lib/types/api";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function CreativeDetailPage({ params }: PageProps) {
const resolvedParams = use(params);
const router = useRouter();
const [creative, setCreative] = useState<CreativeDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadCreative = async () => {
try {
setLoading(true);
const data = await creativesApi.get(resolvedParams.id);
setCreative(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки креатива");
} finally {
setLoading(false);
}
};
loadCreative();
}, [resolvedParams.id]);
const handleArchive = async () => {
if (!creative) return;
try {
await creativesApi.update(creative.id, {
is_archived: !creative.is_archived,
});
setCreative({ ...creative, is_archived: !creative.is_archived });
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления креатива");
}
};
const handleDelete = async () => {
if (!creative) return;
if (!confirm(`Удалить креатив "${creative.name}"?`)) return;
try {
await creativesApi.delete(creative.id);
router.push("/creatives");
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления креатива");
}
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Креативы", href: "/creatives" },
{ label: "Загрузка..." },
]}
/>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (error || !creative) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Креативы", href: "/creatives" },
{ label: "Ошибка" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error || "Креатив не найден"}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href="/creatives">
<ArrowLeft className="mr-2 h-4 w-4" />
Вернуться к креативам
</Link>
</Button>
</div>
</>
);
}
const previewText = creative.text.replace(
"{link}",
"https://t.me/+InviteLinkExample"
);
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Креативы", href: "/creatives" },
{ label: creative.name },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Folder className="h-6 w-6" />
<h1 className="text-3xl font-bold tracking-tight">
{creative.name}
</h1>
{creative.is_archived && <Badge variant="secondary">Архив</Badge>}
</div>
<div className="text-sm text-muted-foreground">
Целевой канал: {creative.target_channel.title}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleArchive}>
<Archive className="mr-2 h-4 w-4" />
{creative.is_archived ? "Разархивировать" : "Архивировать"}
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего закупов
</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(creative.total_purchases)}
</div>
<p className="text-xs text-muted-foreground mt-1">
С этим креативом
</p>
</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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(creative.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground mt-1">Всего</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(creative.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground mt-1">
По всем закупам
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Текст креатива</CardTitle>
<CardDescription>Шаблон с переменной {"{link}"}</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-sm whitespace-pre-wrap font-sans">
{creative.text}
</pre>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Предпросмотр</CardTitle>
<CardDescription>
С подставленной пригласительной ссылкой
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm whitespace-pre-wrap">{previewText}</p>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Закупы с этим креативом</CardTitle>
<CardDescription>История использования креатива</CardDescription>
</CardHeader>
<CardContent>
{creative.purchases.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Закупов с этим креативом пока нет
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Внешний канал</TableHead>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Стоимость</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{creative.purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">
{purchase.external_channel.title}
</TableCell>
<TableCell>
{formatDate(
purchase.actual_date || purchase.scheduled_date
)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(purchase.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(purchase.subscriptions_count)}
</TableCell>
<TableCell className="text-right">
{formatMetric(purchase.cpf)}
</TableCell>
<TableCell className="text-right">
<Button asChild variant="ghost" size="sm">
<Link href={`/purchases/${purchase.id}`}>
Открыть
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
// ============================================================================
// Create Creative Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { creativesApi, channelsApi } from "@/lib/api";
import { AlertCircle, Loader2, ArrowLeft, Info } from "lucide-react";
import type { TargetChannel } from "@/lib/types/api";
export default function CreateCreativePage() {
const router = useRouter();
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: "",
text: "",
target_channel_id: "",
});
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
const response = await channelsApi.list({ is_active: true });
setChannels(response.data);
} catch (err) {
console.error("Error loading channels:", err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation
if (!formData.name.trim()) {
setError("Введите название креатива");
return;
}
if (!formData.text.trim()) {
setError("Введите текст креатива");
return;
}
if (!formData.text.includes("{link}")) {
setError("Текст должен содержать переменную {link} для вставки ссылки");
return;
}
if (!formData.target_channel_id) {
setError("Выберите целевой канал");
return;
}
try {
setIsLoading(true);
await creativesApi.create(formData);
router.push("/creatives");
} catch (err: any) {
setError(err?.error?.message || "Ошибка создания креатива");
} finally {
setIsLoading(false);
}
};
const previewText = formData.text.replace(
"{link}",
"https://t.me/+InviteLinkExample"
);
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Креативы", href: "/creatives" },
{ label: "Создание" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Создать креатив
</h1>
<p className="text-muted-foreground mt-1">
Новый шаблон рекламного сообщения
</p>
</div>
<Button asChild variant="outline">
<a href="/creatives">
<ArrowLeft className="mr-2 h-4 w-4" />
Назад
</a>
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Основная информация</CardTitle>
<CardDescription>
Название и целевой канал креатива
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Название креатива *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder='Например: Креатив "Присоединяйся"'
/>
</div>
<div className="space-y-2">
<Label htmlFor="target_channel_id">Целевой канал *</Label>
<Select
value={formData.target_channel_id}
onValueChange={(value) =>
setFormData({ ...formData, target_channel_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Текст сообщения</CardTitle>
<CardDescription>
Используйте переменную {"{link}"} для вставки ссылки
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="text">Текст креатива *</Label>
<Textarea
id="text"
value={formData.text}
onChange={(e) =>
setFormData({ ...formData, text: e.target.value })
}
placeholder="🔥 Присоединяйся к нашему сообществу!&#10;&#10;Узнавай первым о новых возможностях.&#10;&#10;👉 {link}"
rows={8}
className="font-mono text-sm"
/>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>Обязательно</strong> включите переменную {"{link}"} в
текст. На её место будет подставлена пригласительная ссылка в
целевой канал.
</AlertDescription>
</Alert>
</CardContent>
</Card>
{formData.text && (
<Card>
<CardHeader>
<CardTitle>Предпросмотр</CardTitle>
<CardDescription>
Так будет выглядеть сообщение для размещения
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm whitespace-pre-wrap">{previewText}</p>
</div>
</CardContent>
</Card>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.push("/creatives")}
>
Отмена
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание...
</>
) : (
"Создать креатив"
)}
</Button>
</div>
</form>
</div>
</>
);
}

View File

@@ -0,0 +1,255 @@
"use client";
// ============================================================================
// Creatives List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { creativesApi } from "@/lib/api";
import { formatNumber, formatMetric, truncate } from "@/lib/utils/format";
import {
Folder,
Loader2,
AlertCircle,
Plus,
Eye,
Pencil,
Archive,
Trash2,
} from "lucide-react";
import type { Creative } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function CreativesPage() {
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"all" | "active" | "archived">(
"active"
);
useEffect(() => {
loadCreatives();
}, []);
const loadCreatives = async () => {
try {
setLoading(true);
const response = await creativesApi.list();
setCreatives(response.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки креативов");
} finally {
setLoading(false);
}
};
const handleArchive = async (id: string, isArchived: boolean) => {
try {
await creativesApi.update(id, { is_archived: !isArchived });
loadCreatives();
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления креатива");
}
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Удалить креатив "${name}"?`)) return;
try {
await creativesApi.delete(id);
loadCreatives();
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления креатива");
}
};
const filteredCreatives = creatives.filter((creative) => {
if (activeTab === "active") return !creative.is_archived;
if (activeTab === "archived") return creative.is_archived;
return true;
});
return (
<>
<DashboardHeader
breadcrumbs={[{ label: "Главная", href: "/" }, { label: "Креативы" }]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Креативы</h1>
<p className="text-muted-foreground mt-1">
Шаблоны рекламных сообщений
</p>
</div>
<Button asChild>
<Link href="/creatives/new">
<Plus className="mr-2 h-4 w-4" />
Создать креатив
</Link>
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as any)}
className="w-full"
>
<TabsList>
<TabsTrigger value="active">
Активные ({creatives.filter((c) => !c.is_archived).length})
</TabsTrigger>
<TabsTrigger value="archived">
Архив ({creatives.filter((c) => c.is_archived).length})
</TabsTrigger>
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-4">
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredCreatives.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{activeTab === "all" && "Нет креативов"}
{activeTab === "active" && "Нет активных креативов"}
{activeTab === "archived" && "Нет архивных креативов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
Создайте первый креатив для рекламных сообщений
</p>
{activeTab === "active" && (
<Button asChild className="mt-4">
<Link href="/creatives/new">
<Plus className="mr-2 h-4 w-4" />
Создать креатив
</Link>
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCreatives.map((creative) => (
<Card
key={creative.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="truncate text-base">
{creative.name}
</CardTitle>
{creative.is_archived && (
<Badge variant="secondary">Архив</Badge>
)}
</div>
<CardDescription className="text-xs">
{creative.target_channel.title}
</CardDescription>
</div>
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-sm whitespace-pre-wrap line-clamp-4">
{creative.text}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(creative.total_purchases)}
</div>
<div className="text-xs text-muted-foreground">
Закупов
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(creative.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Подписчиков
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatMetric(creative.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
asChild
variant="default"
size="sm"
className="flex-1"
>
<Link href={`/creatives/${creative.id}`}>
<Eye className="mr-2 h-4 w-4" />
Открыть
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleArchive(creative.id, creative.is_archived)
}
>
<Archive className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDelete(creative.id, creative.name)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@@ -0,0 +1,320 @@
"use client";
// ============================================================================
// External Channel Detail Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { externalChannelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatUsername,
formatDate,
formatCompactNumber,
} from "@/lib/utils/format";
import {
ExternalLink as ExternalLinkIcon,
Users,
TrendingUp,
ArrowLeft,
Loader2,
AlertCircle,
BarChart3,
Pencil,
Trash2,
} from "lucide-react";
import type { ExternalChannelDetail } from "@/lib/types/api";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function ExternalChannelDetailPage({ params }: PageProps) {
const resolvedParams = use(params);
const router = useRouter();
const [channel, setChannel] = useState<ExternalChannelDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadChannel = async () => {
try {
setLoading(true);
const data = await externalChannelsApi.get(resolvedParams.id);
setChannel(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки канала");
} finally {
setLoading(false);
}
};
loadChannel();
}, [resolvedParams.id]);
const handleDelete = async () => {
if (!channel) return;
if (!confirm(`Удалить канал "${channel.title}"?`)) return;
try {
await externalChannelsApi.delete(channel.id);
router.push("/external-channels");
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления канала");
}
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Внешние каналы", href: "/external-channels" },
{ label: "Загрузка..." },
]}
/>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (error || !channel) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Внешние каналы", href: "/external-channels" },
{ label: "Ошибка" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error || "Канал не найден"}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href="/external-channels">
<ArrowLeft className="mr-2 h-4 w-4" />
Вернуться к каналам
</Link>
</Button>
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Внешние каналы", href: "/external-channels" },
{ label: channel.title },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<ExternalLinkIcon className="h-6 w-6" />
<h1 className="text-3xl font-bold tracking-tight">
{channel.title}
</h1>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{channel.username ? (
<a
href={channel.link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center gap-1"
>
{formatUsername(channel.username)}
<ExternalLinkIcon className="h-3 w-3" />
</a>
) : (
<a
href={channel.link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center gap-1"
>
{channel.link}
<ExternalLinkIcon className="h-3 w-3" />
</a>
)}
<span></span>
<span>Добавлен {formatDate(channel.created_at)}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</div>
{channel.description && (
<Card>
<CardHeader>
<CardTitle>Описание</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{channel.description}</p>
</CardContent>
</Card>
)}
<div className="grid gap-4 md: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>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{channel.subscribers_count
? formatCompactNumber(channel.subscribers_count)
: "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">В канале</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего закупов
</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(channel.total_purchases)}
</div>
<p className="text-xs text-muted-foreground mt-1">
В этом канале
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(channel.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground mt-1">
По всем закупам
</p>
</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>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(channel.avg_cpm)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Стоимость 1000 просмотров
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>История закупов</CardTitle>
<CardDescription>
Все рекламные размещения в этом канале
</CardDescription>
</CardHeader>
<CardContent>
{channel.purchases.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Закупов в этом канале пока нет
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Целевой канал</TableHead>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Стоимость</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channel.purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">
{purchase.target_channel.title}
</TableCell>
<TableCell>
{formatDate(
purchase.actual_date || purchase.scheduled_date
)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(purchase.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(purchase.subscriptions_count)}
</TableCell>
<TableCell className="text-right">
{formatMetric(purchase.cpf)}
</TableCell>
<TableCell className="text-right">
<Button asChild variant="ghost" size="sm">
<Link href={`/purchases/${purchase.id}`}>
Открыть
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,338 @@
"use client";
// ============================================================================
// External Channels Import Page
// ============================================================================
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { externalChannelsApi } from "@/lib/api";
import {
FileUp,
AlertCircle,
CheckCircle,
Loader2,
Download,
ArrowLeft,
X,
} from "lucide-react";
import type { ExternalChannelImportResponse } from "@/lib/types/api";
export default function ImportExternalChannelsPage() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [result, setResult] = useState<ExternalChannelImportResponse | null>(
null
);
const [error, setError] = useState<string | null>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
handleFileSelect(droppedFile);
}
};
const handleFileSelect = (selectedFile: File) => {
// Проверка типа файла
const validTypes = [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
];
if (
!validTypes.includes(selectedFile.type) &&
!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)
) {
setError("Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
handleFileSelect(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setError(null);
// Для примера используем первый целевой канал (tc1)
// В реальном приложении нужно дать пользователю выбрать
const importResult = await externalChannelsApi.import(file, "tc1");
setResult(importResult);
setFile(null);
} catch (err: any) {
setError(err?.error?.message || "Ошибка импорта файла");
} finally {
setIsUploading(false);
}
};
const handleReset = () => {
setFile(null);
setResult(null);
setError(null);
};
const handleGoToChannels = () => {
router.push("/external-channels");
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Внешние каналы", href: "/external-channels" },
{ label: "Импорт" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Импорт внешних каналов
</h1>
<p className="text-muted-foreground mt-1">
Загрузите файл Excel с данными из Tgstat
</p>
</div>
<Button asChild variant="outline">
<Link href="/external-channels">
<ArrowLeft className="mr-2 h-4 w-4" />
Назад
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Формат файла</CardTitle>
<CardDescription>
Требования к структуре Excel файла
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm">
<p>Файл должен содержать следующие колонки:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>
<strong>Название</strong> - название канала
</li>
<li>
<strong>Username</strong> - username канала (с @ или без)
</li>
<li>
<strong>Ссылка</strong> - ссылка на канал (t.me/...)
</li>
<li>
<strong>Подписчики</strong> - количество подписчиков (число)
</li>
<li>
<strong>Описание</strong> - краткое описание (опционально)
</li>
</ul>
<div className="pt-2">
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Скачать шаблон
</Button>
</div>
</div>
</CardContent>
</Card>
{!result ? (
<Card>
<CardHeader>
<CardTitle>Загрузка файла</CardTitle>
<CardDescription>
Перетащите файл или выберите его
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-12 text-center transition-colors
${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25"
}
${file ? "bg-muted/50" : ""}
`}
>
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-2">
<FileUp className="h-8 w-8 text-primary" />
</div>
<div>
<p className="font-medium">{file.name}</p>
<p className="text-sm text-muted-foreground">
{(file.size / 1024).toFixed(2)} KB
</p>
</div>
<Button variant="outline" size="sm" onClick={handleReset}>
<X className="mr-2 h-4 w-4" />
Выбрать другой файл
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center">
<FileUp className="h-12 w-12 text-muted-foreground" />
</div>
<div>
<p className="text-lg font-medium">
Перетащите файл сюда
</p>
<p className="text-sm text-muted-foreground">
или нажмите кнопку ниже
</p>
</div>
<div>
<label htmlFor="file-upload">
<Button asChild variant="outline">
<span>
<FileUp className="mr-2 h-4 w-4" />
Выбрать файл
</span>
</Button>
</label>
<input
id="file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileInputChange}
className="hidden"
/>
</div>
</div>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{file && (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleReset}>
Отмена
</Button>
<Button onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Импорт...
</>
) : (
<>
<FileUp className="mr-2 h-4 w-4" />
Импортировать
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<CheckCircle className="h-6 w-6 text-green-500" />
<CardTitle>Импорт завершен</CardTitle>
</div>
<CardDescription>Результаты обработки файла</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<div className="flex items-center justify-between p-3 rounded-lg bg-green-50 dark:bg-green-950/20">
<span className="text-sm font-medium">
Импортировано каналов
</span>
<Badge variant="default">{result.imported}</Badge>
</div>
{result.skipped > 0 && (
<div className="flex items-center justify-between p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20">
<span className="text-sm font-medium">Пропущено</span>
<Badge variant="secondary">{result.skipped}</Badge>
</div>
)}
</div>
{result.errors.length > 0 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<p className="font-medium mb-2">
Обнаружены ошибки при импорте:
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{result.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleReset}>
Импортировать еще
</Button>
<Button onClick={handleGoToChannels}>Перейти к каналам</Button>
</div>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,406 @@
"use client";
// ============================================================================
// External Channels List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { externalChannelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatUsername,
formatCompactNumber,
} from "@/lib/utils/format";
import {
ExternalLink as ExternalLinkIcon,
Loader2,
AlertCircle,
Plus,
Search,
FileUp,
Eye,
Pencil,
Trash2,
Users,
} from "lucide-react";
import type { ExternalChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function ExternalChannelsPage() {
const [channels, setChannels] = useState<ExternalChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
// Form state
const [formData, setFormData] = useState({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
setLoading(true);
const response = await externalChannelsApi.list();
setChannels(response.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
setIsCreating(true);
await externalChannelsApi.create({
title: formData.title,
username: formData.username || undefined,
link: formData.link,
subscribers_count: formData.subscribers_count
? parseInt(formData.subscribers_count)
: undefined,
description: formData.description || undefined,
target_channel_ids: [], // По умолчанию не привязываем
});
setIsCreateDialogOpen(false);
setFormData({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
loadChannels();
} catch (err: any) {
alert(err?.error?.message || "Ошибка создания канала");
} finally {
setIsCreating(false);
}
};
const handleDelete = async (id: string, title: string) => {
if (!confirm(`Удалить канал "${title}"?`)) return;
try {
await externalChannelsApi.delete(id);
loadChannels();
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления канала");
}
};
const filteredChannels = channels.filter(
(channel) =>
channel.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
channel.username?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Внешние каналы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Внешние каналы
</h1>
<p className="text-muted-foreground mt-1">
Каналы, в которых покупаете рекламу
</p>
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/external-channels/import">
<FileUp className="mr-2 h-4 w-4" />
Импорт из Excel
</Link>
</Button>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Добавить канал
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Добавить внешний канал</DialogTitle>
<DialogDescription>
Добавьте канал, в котором планируете покупать рекламу
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Название *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
placeholder="Название канала"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="link">Ссылка *</Label>
<Input
id="link"
value={formData.link}
onChange={(e) =>
setFormData({ ...formData, link: e.target.value })
}
placeholder="https://t.me/channel_name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
placeholder="@channel_name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="subscribers">Количество подписчиков</Label>
<Input
id="subscribers"
type="number"
value={formData.subscribers_count}
onChange={(e) =>
setFormData({
...formData,
subscribers_count: e.target.value,
})
}
placeholder="50000"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Описание</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
placeholder="Краткое описание канала"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Отмена
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание...
</>
) : (
"Создать"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию или username..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Badge variant="secondary">
Всего: {formatNumber(filteredChannels.length)}
</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<ExternalLinkIcon className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ? "Каналы не найдены" : "Нет внешних каналов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery
? "Попробуйте изменить поисковый запрос"
: "Добавьте каналы вручную или импортируйте из Excel"}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredChannels.map((channel) => (
<Card
key={channel.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate flex items-center gap-2">
<ExternalLinkIcon className="h-4 w-4 shrink-0" />
{channel.title}
</CardTitle>
<CardDescription className="truncate mt-1">
{channel.username ? (
<a
href={channel.link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{formatUsername(channel.username)}
</a>
) : (
<a
href={channel.link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-xs"
>
{channel.link}
</a>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{channel.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{channel.description}
</p>
)}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border bg-muted/50 p-2">
<div className="flex items-center justify-center gap-1">
<Users className="h-3 w-3 text-muted-foreground" />
<div className="text-sm font-medium">
{channel.subscribers_count
? formatCompactNumber(channel.subscribers_count)
: "—"}
</div>
</div>
<div className="text-xs text-muted-foreground">
Подписчики
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(channel.total_purchases)}
</div>
<div className="text-xs text-muted-foreground">
Закупов
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatMetric(channel.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">CPF</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
asChild
variant="default"
size="sm"
className="flex-1"
>
<Link href={`/external-channels/${channel.id}`}>
<Eye className="mr-2 h-4 w-4" />
Подробнее
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(channel.id, channel.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,22 @@
// ============================================================================
// Dashboard Layout
// ============================================================================
import { AppSidebar } from "@/components/layout/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { ProtectedRoute } from "@/components/auth/protected-route";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProtectedRoute>
<SidebarProvider>
<AppSidebar />
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>
</ProtectedRoute>
);
}

186
app/(dashboard)/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
"use client";
// ============================================================================
// Dashboard Home Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { analyticsApi } from "@/lib/api";
import {
formatCurrency,
formatNumber,
formatMetric,
formatPercent,
} from "@/lib/utils/format";
import {
BarChart3,
TrendingUp,
Users,
ShoppingCart,
Loader2,
} from "lucide-react";
import type { AnalyticsOverview } from "@/lib/types/api";
export default function DashboardPage() {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadOverview = async () => {
try {
const data = await analyticsApi.overview();
setOverview(data);
} catch (error) {
console.error("Error loading overview:", error);
} finally {
setLoading(false);
}
};
loadOverview();
}, []);
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<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" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(overview?.totalPurchases)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(overview?.purchasesChange || 0, true)} за
прошлый месяц
</p>
</>
)}
</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" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(overview?.totalFollowers)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(overview?.followersChange || 0, true)} за
прошлый месяц
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
<TrendingUp 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">
{formatMetric(overview?.averageCpf)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpfChange || 0, true)} от прошлого
месяца
</p>
</>
)}
</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">
{formatMetric(overview?.averageCpm)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpmChange || 0, true)} от прошлого
месяца
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-1">
<Card>
<CardHeader>
<CardTitle>Быстрый старт</CardTitle>
<CardDescription>Начните с основных действий</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<a
href="/purchases/new"
className="block text-sm text-primary hover:underline"
>
Создать новый закуп
</a>
<a
href="/creatives/new"
className="block text-sm text-primary hover:underline"
>
Создать креатив
</a>
<a
href="/external-channels/import"
className="block text-sm text-primary hover:underline"
>
Импортировать каналы
</a>
<a
href="/analytics"
className="block text-sm text-primary hover:underline"
>
Посмотреть аналитику
</a>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,500 @@
"use client";
// ============================================================================
// Purchase Detail Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { purchasesApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatDate,
formatPercent,
formatUsername,
} from "@/lib/utils/format";
import {
ShoppingCart,
ArrowLeft,
Loader2,
AlertCircle,
Archive,
Trash2,
BarChart3,
Users,
TrendingUp,
Eye,
Copy,
ExternalLink as ExternalLinkIcon,
Check,
Clock,
CheckCircle,
} from "lucide-react";
import type { PurchaseDetail } from "@/lib/types/api";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function PurchaseDetailPage({ params }: PageProps) {
const resolvedParams = use(params);
const router = useRouter();
const [purchase, setPurchase] = useState<PurchaseDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const loadPurchase = async () => {
try {
setLoading(true);
const data = await purchasesApi.get(resolvedParams.id);
setPurchase(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки закупа");
} finally {
setLoading(false);
}
};
loadPurchase();
}, [resolvedParams.id]);
const handleArchive = async () => {
if (!purchase) return;
try {
await purchasesApi.update(purchase.id, {
is_archived: !purchase.is_archived,
});
setPurchase({ ...purchase, is_archived: !purchase.is_archived });
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления закупа");
}
};
const handleDelete = async () => {
if (!purchase) return;
if (!confirm("Удалить закуп?")) return;
try {
await purchasesApi.delete(purchase.id);
router.push("/purchases");
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления закупа");
}
};
const handleCopyLink = () => {
if (!purchase) return;
navigator.clipboard.writeText(purchase.invite_link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Закупы", href: "/purchases" },
{ label: "Загрузка..." },
]}
/>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (error || !purchase) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Закупы", href: "/purchases" },
{ label: "Ошибка" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error || "Закуп не найден"}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href="/purchases">
<ArrowLeft className="mr-2 h-4 w-4" />
Вернуться к закупам
</Link>
</Button>
</div>
</>
);
}
const messageText = purchase.creative.text.replace(
"{link}",
purchase.invite_link
);
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Закупы", href: "/purchases" },
{ label: `Закуп #${purchase.id.slice(0, 8)}` },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<ShoppingCart className="h-6 w-6" />
<h1 className="text-3xl font-bold tracking-tight">
{purchase.external_channel.title}
</h1>
{purchase.is_archived ? (
<Badge variant="secondary">Архив</Badge>
) : purchase.actual_date ? (
<Badge variant="default">
<CheckCircle className="h-3 w-3 mr-1" />
Завершено
</Badge>
) : (
<Badge variant="outline">
<Clock className="h-3 w-3 mr-1" />
Запланировано
</Badge>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>
Целевой канал:{" "}
<Link
href={`/channels/${purchase.target_channel.id}`}
className="hover:underline font-medium"
>
{purchase.target_channel.title}
</Link>
</span>
<span></span>
<span>
Креатив:{" "}
<Link
href={`/creatives/${purchase.creative.id}`}
className="hover:underline font-medium"
>
{purchase.creative.name}
</Link>
</span>
<span></span>
<span>
Дата размещения:{" "}
{formatDate(purchase.actual_date || purchase.scheduled_date)}
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleArchive}>
<Archive className="mr-2 h-4 w-4" />
{purchase.is_archived ? "Разархивировать" : "Архивировать"}
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(purchase.cost)}
</div>
</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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(purchase.subscriptions_count)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Просмотры</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{purchase.views_count
? formatNumber(purchase.views_count)
: "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPF</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{purchase.cpf ? `${formatMetric(purchase.cpf)}` : "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Конверсия</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{purchase.conversion_rate
? formatPercent(purchase.conversion_rate)
: "—"}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Пригласительная ссылка</CardTitle>
<CardDescription>
Используется для отслеживания подписок
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<code className="flex-1 p-2 bg-muted rounded text-sm break-all">
{purchase.invite_link}
</code>
<Button onClick={handleCopyLink} variant="outline" size="icon">
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{purchase.post_link && (
<div className="pt-2">
<Label className="text-sm text-muted-foreground">
Ссылка на рекламный пост:
</Label>
<a
href={purchase.post_link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm hover:underline mt-1"
>
{purchase.post_link}
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Рекламное сообщение</CardTitle>
<CardDescription>Текст для размещения</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm whitespace-pre-wrap">{messageText}</p>
</div>
</CardContent>
</Card>
</div>
{purchase.comment && (
<Card>
<CardHeader>
<CardTitle>Комментарий</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{purchase.comment}
</p>
</CardContent>
</Card>
)}
<Tabs defaultValue="subscriptions" className="w-full">
<TabsList>
<TabsTrigger value="subscriptions">
Подписки ({purchase.subscriptions.length})
</TabsTrigger>
{purchase.views_history && purchase.views_history.length > 0 && (
<TabsTrigger value="views">
История просмотров ({purchase.views_history.length})
</TabsTrigger>
)}
</TabsList>
<TabsContent value="subscriptions" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Подписки</CardTitle>
<CardDescription>
Пользователи, перешедшие по ссылке
</CardDescription>
</CardHeader>
<CardContent>
{purchase.subscriptions.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Подписок пока нет
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Username</TableHead>
<TableHead>Дата подписки</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchase.subscriptions.map((subscription) => (
<TableRow key={subscription.id}>
<TableCell className="font-medium">
{subscription.user_first_name}{" "}
{subscription.user_last_name}
</TableCell>
<TableCell>
{subscription.user_username
? formatUsername(subscription.user_username)
: "—"}
</TableCell>
<TableCell>
{formatDate(subscription.subscribed_at)}
</TableCell>
<TableCell>
{subscription.is_active ? (
<Badge variant="default">Активен</Badge>
) : (
<Badge variant="secondary">Отписался</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{purchase.views_history && purchase.views_history.length > 0 && (
<TabsContent value="views" className="mt-4">
<Card>
<CardHeader>
<CardTitle>История просмотров</CardTitle>
<CardDescription>
Динамика просмотров рекламного поста
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">Прирост</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchase.views_history.map((item, index) => {
const prevViews =
index > 0
? purchase.views_history![index - 1].views
: 0;
const growth = item.views - prevViews;
return (
<TableRow key={item.fetched_at}>
<TableCell>{formatDate(item.fetched_at)}</TableCell>
<TableCell className="text-right">
{formatNumber(item.views)}
</TableCell>
<TableCell className="text-right">
{index > 0 && (
<span
className={
growth > 0
? "text-green-600"
: "text-muted-foreground"
}
>
+{formatNumber(growth)}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
</>
);
}
function Label({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={className}>{children}</div>;
}

View File

@@ -0,0 +1,503 @@
"use client";
// ============================================================================
// Create Purchase Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
purchasesApi,
channelsApi,
externalChannelsApi,
creativesApi,
} from "@/lib/api";
import {
AlertCircle,
Loader2,
ArrowLeft,
Info,
Copy,
Check,
} from "lucide-react";
import type { TargetChannel, ExternalChannel, Creative } from "@/lib/types/api";
export default function CreatePurchasePage() {
const router = useRouter();
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [externalChannels, setExternalChannels] = useState<ExternalChannel[]>(
[]
);
const [creatives, setCreatives] = useState<Creative[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string>("");
const [copied, setCopied] = useState(false);
const [formData, setFormData] = useState({
target_channel_id: "",
external_channel_id: "",
creative_id: "",
scheduled_date: "",
cost: "",
comment: "",
post_link: "",
invite_link_type: "public" as "public" | "private",
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [channelsRes, externalChannelsRes, creativesRes] =
await Promise.all([
channelsApi.list({ is_active: true }),
externalChannelsApi.list(),
creativesApi.list(),
]);
setChannels(channelsRes.data);
setExternalChannels(externalChannelsRes.data);
setCreatives(creativesRes.data.filter((c) => !c.is_archived));
} catch (err) {
console.error("Error loading data:", err);
}
};
// Фильтр креативов по выбранному целевому каналу
const filteredCreatives = formData.target_channel_id
? creatives.filter(
(c) => c.target_channel_id === formData.target_channel_id
)
: creatives;
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation
if (!formData.target_channel_id) {
setError("Выберите целевой канал");
return;
}
if (!formData.external_channel_id) {
setError("Выберите внешний канал");
return;
}
if (!formData.creative_id) {
setError("Выберите креатив");
return;
}
if (!formData.scheduled_date) {
setError("Укажите дату размещения");
return;
}
if (!formData.cost || parseFloat(formData.cost) <= 0) {
setError("Укажите корректную стоимость");
return;
}
try {
setIsLoading(true);
const result = await purchasesApi.create({
target_channel_id: formData.target_channel_id,
external_channel_id: formData.external_channel_id,
creative_id: formData.creative_id,
scheduled_date: formData.scheduled_date,
cost: parseFloat(formData.cost),
comment: formData.comment || undefined,
post_link: formData.post_link || undefined,
invite_link_type: formData.invite_link_type,
});
// Показываем сгенерированную ссылку
setInviteLink(result.invite_link);
} catch (err: any) {
setError(err?.error?.message || "Ошибка создания закупа");
} finally {
setIsLoading(false);
}
};
const handleCopyLink = () => {
navigator.clipboard.writeText(inviteLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopyMessage = () => {
if (!selectedCreative) return;
const message = selectedCreative.text.replace("{link}", inviteLink);
navigator.clipboard.writeText(message);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (inviteLink) {
const messageText = selectedCreative
? selectedCreative.text.replace("{link}", inviteLink)
: "";
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Закупы", href: "/purchases" },
{ label: "Создание" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
<Card className="border-green-500">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<Check className="h-5 w-5" />
Закуп успешно создан!
</CardTitle>
<CardDescription>
Используйте ссылку и готовое сообщение для размещения
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Пригласительная ссылка</Label>
<div className="flex gap-2">
<Input value={inviteLink} readOnly className="font-mono" />
<Button onClick={handleCopyLink} variant="outline">
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Готовое сообщение для размещения</Label>
<div className="relative">
<Textarea
value={messageText}
readOnly
rows={8}
className="pr-12"
/>
<Button
onClick={handleCopyMessage}
variant="ghost"
size="sm"
className="absolute top-2 right-2"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Скопируйте готовое сообщение и разместите его во внешнем
канале. Все переходы по ссылке будут автоматически
отслеживаться.
</AlertDescription>
</Alert>
<div className="flex gap-2 pt-4">
<Button asChild>
<Link href="/purchases">Перейти к закупам</Link>
</Button>
<Button
variant="outline"
onClick={() => {
setInviteLink("");
setFormData({
target_channel_id: "",
external_channel_id: "",
creative_id: "",
scheduled_date: "",
cost: "",
comment: "",
post_link: "",
invite_link_type: "public",
});
}}
>
Создать еще
</Button>
</div>
</CardContent>
</Card>
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Закупы", href: "/purchases" },
{ label: "Создание" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Создать закуп</h1>
<p className="text-muted-foreground mt-1">
Новое рекламное размещение
</p>
</div>
<Button asChild variant="outline">
<Link href="/purchases">
<ArrowLeft className="mr-2 h-4 w-4" />
Назад
</Link>
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Основная информация</CardTitle>
<CardDescription>
Выберите каналы и креатив для размещения
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="target_channel_id">Целевой канал *</Label>
<Select
value={formData.target_channel_id}
onValueChange={(value) =>
setFormData({
...formData,
target_channel_id: value,
creative_id: "", // Сбросить креатив при смене канала
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="external_channel_id">Внешний канал *</Label>
<Select
value={formData.external_channel_id}
onValueChange={(value) =>
setFormData({ ...formData, external_channel_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{externalChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="creative_id">Креатив *</Label>
<Select
value={formData.creative_id}
onValueChange={(value) =>
setFormData({ ...formData, creative_id: value })
}
disabled={!formData.target_channel_id}
>
<SelectTrigger>
<SelectValue
placeholder={
formData.target_channel_id
? "Выберите креатив"
: "Сначала выберите целевой канал"
}
/>
</SelectTrigger>
<SelectContent>
{filteredCreatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Детали размещения</CardTitle>
<CardDescription>
Стоимость, дата и параметры ссылки
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="scheduled_date">Дата размещения *</Label>
<Input
id="scheduled_date"
type="date"
value={formData.scheduled_date}
onChange={(e) =>
setFormData({
...formData,
scheduled_date: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cost">Стоимость () *</Label>
<Input
id="cost"
type="number"
step="0.01"
value={formData.cost}
onChange={(e) =>
setFormData({ ...formData, cost: e.target.value })
}
placeholder="1000"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="post_link">Ссылка на рекламное сообщение</Label>
<Input
id="post_link"
type="url"
value={formData.post_link}
onChange={(e) =>
setFormData({ ...formData, post_link: e.target.value })
}
placeholder="https://t.me/channel/123"
/>
<p className="text-xs text-muted-foreground">
Для автоматического отслеживания просмотров
</p>
</div>
<div className="space-y-2">
<Label>Тип пригласительной ссылки *</Label>
<RadioGroup
value={formData.invite_link_type}
onValueChange={(value: "public" | "private") =>
setFormData({ ...formData, invite_link_type: value })
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="public" id="public" />
<Label htmlFor="public" className="font-normal">
Открытая (пользователь сразу вступает в канал)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="private" id="private" />
<Label htmlFor="private" className="font-normal">
С одобрением (запрос на вступление через бота)
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="comment">Комментарий</Label>
<Textarea
id="comment"
value={formData.comment}
onChange={(e) =>
setFormData({ ...formData, comment: e.target.value })
}
placeholder="Дополнительные заметки о закупе"
rows={3}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.push("/purchases")}
>
Отмена
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание...
</>
) : (
"Создать закуп"
)}
</Button>
</div>
</form>
</div>
</>
);
}

View File

@@ -0,0 +1,381 @@
"use client";
// ============================================================================
// Purchases List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { purchasesApi, channelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatDate,
formatPercent,
} from "@/lib/utils/format";
import {
ShoppingCart,
Loader2,
AlertCircle,
Plus,
Search,
Filter,
Eye,
CheckCircle,
Clock,
Archive,
} from "lucide-react";
import type { Purchase, TargetChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function PurchasesPage() {
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterChannel, setFilterChannel] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
const [purchasesRes, channelsRes] = await Promise.all([
purchasesApi.list(),
channelsApi.list({}),
]);
setPurchases(purchasesRes.data);
setChannels(channelsRes.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки данных");
} finally {
setLoading(false);
}
};
const filteredPurchases = purchases.filter((purchase) => {
// Поиск
const matchesSearch =
purchase.external_channel.title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.target_channel.title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.creative.name.toLowerCase().includes(searchQuery.toLowerCase());
// Фильтр по каналу
const matchesChannel =
filterChannel === "all" || purchase.target_channel.id === filterChannel;
// Фильтр по статусу
let matchesStatus = true;
if (filterStatus === "completed") {
matchesStatus = !!purchase.actual_date;
} else if (filterStatus === "scheduled") {
matchesStatus = !purchase.actual_date;
} else if (filterStatus === "archived") {
matchesStatus = purchase.is_archived;
}
return matchesSearch && matchesChannel && matchesStatus;
});
// Статистика
const stats = {
total: purchases.length,
completed: purchases.filter((p) => p.actual_date).length,
scheduled: purchases.filter((p) => !p.actual_date).length,
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
totalSubscriptions: purchases.reduce(
(sum, p) => sum + p.subscriptions_count,
0
),
avgCpf:
purchases.length > 0
? purchases.reduce((sum, p) => sum + (p.cpf || 0), 0) / purchases.length
: 0,
};
return (
<>
<DashboardHeader
breadcrumbs={[{ label: "Главная", href: "/" }, { label: "Закупы" }]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Закупы</h1>
<p className="text-muted-foreground mt-1">
Рекламные размещения во внешних каналах
</p>
</div>
<Button asChild>
<Link href="/purchases/new">
<Plus className="mr-2 h-4 w-4" />
Создать закуп
</Link>
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(stats.total)}
</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.completed} завершено, {stats.scheduled} запланировано
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Общие затраты
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(stats.totalCost)}
</div>
<p className="text-xs text-muted-foreground mt-1">
За все закупы
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Подписчиков привлечено
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(stats.totalSubscriptions)}
</div>
<p className="text-xs text-muted-foreground mt-1">Всего</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(stats.avgCpf)}
</div>
<p className="text-xs text-muted-foreground mt-1">
По всем закупам
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Фильтры</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Select value={filterChannel} onValueChange={setFilterChannel}>
<SelectTrigger>
<SelectValue placeholder="Целевой канал" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger>
<SelectValue placeholder="Статус" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все статусы</SelectItem>
<SelectItem value="completed">Завершено</SelectItem>
<SelectItem value="scheduled">Запланировано</SelectItem>
<SelectItem value="archived">Архив</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
Найдено: {formatNumber(filteredPurchases.length)}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Список закупов</CardTitle>
<CardDescription>Все рекламные размещения</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredPurchases.length === 0 ? (
<div className="text-center py-12">
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
? "Закупы не найдены"
: "Нет закупов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
? "Попробуйте изменить фильтры"
: "Создайте первый закуп"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Статус</TableHead>
<TableHead>Целевой канал</TableHead>
<TableHead>Внешний канал</TableHead>
<TableHead>Креатив</TableHead>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Стоимость</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="text-right">Конверсия</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell>
{purchase.is_archived ? (
<Badge variant="secondary">
<Archive className="h-3 w-3 mr-1" />
Архив
</Badge>
) : purchase.actual_date ? (
<Badge variant="default">
<CheckCircle className="h-3 w-3 mr-1" />
Завершено
</Badge>
) : (
<Badge variant="outline">
<Clock className="h-3 w-3 mr-1" />
Запланировано
</Badge>
)}
</TableCell>
<TableCell className="font-medium">
{purchase.target_channel.title}
</TableCell>
<TableCell>{purchase.external_channel.title}</TableCell>
<TableCell className="max-w-[200px] truncate">
{purchase.creative.name}
</TableCell>
<TableCell>
{formatDate(
purchase.actual_date || purchase.scheduled_date
)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(purchase.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(purchase.subscriptions_count)}
</TableCell>
<TableCell className="text-right">
{purchase.cpf ? `${formatMetric(purchase.cpf)}` : "—"}
</TableCell>
<TableCell className="text-right">
{purchase.conversion_rate
? formatPercent(purchase.conversion_rate)
: "—"}
</TableCell>
<TableCell className="text-right">
<Button asChild variant="ghost" size="sm">
<Link href={`/purchases/${purchase.id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
app/globals.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

36
app/layout.tsx Normal file
View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TGEX - Управление рекламными закупками",
description:
"Система для владельцев каналов для управления рекламными закупками в Telegram",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ru" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

26
app/providers.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client";
// ============================================================================
// App Providers
// ============================================================================
import React from "react";
import { AuthProvider } from "@/components/auth/auth-provider";
import { ThemeProvider } from "@/components/theme-provider";
interface ProvidersProps {
children: React.ReactNode;
}
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
);
};