feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -1,286 +0,0 @@
"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 } from "@/lib/api";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import { Loader2, AlertCircle, DollarSign } from "lucide-react";
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
export default function AnalyticsCostsPage() {
const { selectedChannel } = useTargetChannel();
const [report, setReport] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [grouping, setGrouping] = useState<DateGrouping>("week");
useEffect(() => {
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [grouping, selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const reportData = await analyticsApi.spending({
grouping,
target_channel_id: selectedChannel.id,
});
setReport(reportData);
} 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="space-y-2">
<label className="text-sm font-medium">
Период группировки
</label>
<Select
value={grouping}
onValueChange={(value: DateGrouping) => setGrouping(value)}
>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
<SelectItem value="quarter">По кварталам</SelectItem>
<SelectItem value="year">По годам</SelectItem>
</SelectContent>
</Select>
</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-4">
<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.total_cost)}
</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.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">
Просмотры
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(report.total_views)}
</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">
{report.avg_cpf ? formatCurrency(report.avg_cpf) : "—"}
</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.chart_data}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="period"
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="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.chart_data.map((period) => (
<div
key={period.period}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div>
<p className="font-medium">{period.period}</p>
<p className="text-sm text-muted-foreground">
{formatNumber(period.subscriptions)} подписок {formatNumber(period.views)} просмотров
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold">
{formatCurrency(period.cost)}
</p>
<p className="text-sm text-muted-foreground">
{period.cpf ? `CPF: ${formatCurrency(period.cpf)}` : "—"}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</>
) : null}
</div>
</>
);
}

View File

@@ -1,606 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { channelsApi, analyticsApi } from "@/lib/api";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import {
Loader2,
AlertCircle,
CalendarIcon,
ArrowUpDown,
ArrowUp,
ArrowDown,
Download,
} from "lucide-react";
import type { TargetChannel, CreativeAnalytics } from "@/lib/types/api";
import * as XLSX from "xlsx";
type SortField =
| "name"
| "placements_count"
| "total_cost"
| "total_subscriptions"
| "total_views"
| "avg_cpf"
| "avg_cpm"
| null;
type SortDirection = "asc" | "desc" | null;
export default function CreativesAnalyticsPage() {
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState<string>("");
const [dateFrom, setDateFrom] = useState<Date | undefined>();
const [dateTo, setDateTo] = useState<Date | undefined>();
const [creatives, setCreatives] = useState<CreativeAnalytics[]>([]);
const [filteredCreatives, setFilteredCreatives] = useState<
CreativeAnalytics[]
>([]);
const [loading, setLoading] = useState(true);
const [loadingData, setLoadingData] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
setLoading(true);
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter((c) => c.is_active);
setChannels(activeChannels);
if (activeChannels.length > 0) {
setSelectedChannelId(activeChannels[0].id);
}
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setLoading(false);
}
};
const loadCreativesData = async () => {
if (!selectedChannelId) return;
try {
setLoadingData(true);
const response = await analyticsApi.creatives({
target_channel_id: selectedChannelId,
});
setCreatives(response.creatives);
setFilteredCreatives(response.creatives);
} catch (err: any) {
alert(err?.error?.message || "Ошибка загрузки данных");
} finally {
setLoadingData(false);
}
};
// Сортировка
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortField(null);
setSortDirection(null);
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
}
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
};
useEffect(() => {
if (!sortField || !sortDirection) {
setFilteredCreatives([...creatives]);
return;
}
const sorted = [...creatives].sort((a, b) => {
let aValue: string | number | null = 0;
let bValue: string | number | null = 0;
switch (sortField) {
case "name":
aValue = a.name;
bValue = b.name;
break;
case "placements_count":
aValue = a.placements_count;
bValue = b.placements_count;
break;
case "total_cost":
aValue = a.total_cost;
bValue = b.total_cost;
break;
case "total_subscriptions":
aValue = a.total_subscriptions;
bValue = b.total_subscriptions;
break;
case "total_views":
aValue = a.total_views;
bValue = b.total_views;
break;
case "avg_cpf":
aValue = a.avg_cpf || 0;
bValue = b.avg_cpf || 0;
break;
case "avg_cpm":
aValue = a.avg_cpm || 0;
bValue = b.avg_cpm || 0;
break;
}
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
if (typeof aValue === "number" && typeof bValue === "number") {
return sortDirection === "asc" ? aValue - bValue : bValue - aValue;
}
return 0;
});
setFilteredCreatives(sorted);
}, [sortField, sortDirection, creatives]);
// Экспорт
const exportToExcel = () => {
const data = filteredCreatives.map((creative) => ({
Название: creative.name,
Размещений: creative.placements_count,
"Общая стоимость": creative.total_cost,
Подписки: creative.total_subscriptions,
Просмотры: creative.total_views,
"Средний CPF": creative.avg_cpf || 0,
"Средний CPM": creative.avg_cpm || 0,
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Креативы");
const colWidths = [
{ wch: 30 },
{ wch: 12 },
{ wch: 15 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
];
ws["!cols"] = colWidths;
XLSX.writeFile(
wb,
`Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.xlsx`
);
};
const exportToCSV = () => {
const data = filteredCreatives.map((creative) => ({
Название: creative.name,
Размещений: creative.placements_count,
"Общая стоимость": creative.total_cost,
Подписки: creative.total_subscriptions,
Просмотры: creative.total_views,
"Средний CPF": creative.avg_cpf || 0,
"Средний CPM": creative.avg_cpm || 0,
}));
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(","),
...data.map((row) =>
headers
.map((header) => {
const value = row[header as keyof typeof row];
return value !== null && value !== undefined ? value : "";
})
.join(",")
),
].join("\n");
const blob = new Blob(["\ufeff" + csvContent], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.csv`;
link.click();
URL.revokeObjectURL(link.href);
};
const exportToTXT = () => {
const data = filteredCreatives.map((creative) => ({
Название: creative.name,
Размещений: creative.placements_count,
"Общая стоимость": creative.total_cost,
Подписки: creative.total_subscriptions,
Просмотры: creative.total_views,
"Средний CPF": creative.avg_cpf || 0,
"Средний CPM": creative.avg_cpm || 0,
}));
const headers = Object.keys(data[0]);
const columnWidths = headers.map((header) => {
const maxLength = Math.max(
header.length,
...data.map((row) =>
String(row[header as keyof typeof row] || "").length
)
);
return Math.min(maxLength + 2, 30);
});
const txtContent = [
headers.map((header, i) => header.padEnd(columnWidths[i])).join(" | "),
columnWidths.map((width) => "-".repeat(width)).join("-+-"),
...data.map((row) =>
headers
.map((header, i) =>
String(row[header as keyof typeof row] || "").padEnd(columnWidths[i])
)
.join(" | ")
),
].join("\n");
const blob = new Blob([txtContent], { type: "text/plain;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.txt`;
link.click();
URL.revokeObjectURL(link.href);
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/analytics" },
{ label: "Креативы" },
]}
/>
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ 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>
)}
{channels.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет активных целевых каналов. Добавьте канал для начала работы с
аналитикой.
</AlertDescription>
</Alert>
) : (
<>
{/* Фильтры */}
<Card>
<CardHeader>
<CardTitle>Фильтры</CardTitle>
<CardDescription>
Настройте параметры для отображения данных
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>Целевой канал</Label>
<Select
value={selectedChannelId}
onValueChange={setSelectedChannelId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Дата от</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateFrom && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom ? (
format(dateFrom, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите дату</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateFrom}
onSelect={setDateFrom}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Дата до</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateTo && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateTo ? (
format(dateTo, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите дату</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateTo}
onSelect={setDateTo}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<Button onClick={loadCreativesData} disabled={!selectedChannelId}>
Применить фильтры
</Button>
</CardContent>
</Card>
{/* Таблица */}
{loadingData ? (
<Card>
<CardContent className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : filteredCreatives.length > 0 ? (
<Card>
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>Креативы</CardTitle>
<CardDescription>
Показатели производительности креативов
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Экспорт
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportToExcel}>
Экспорт в Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToCSV}>
Экспорт в CSV (.csv)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToTXT}>
Экспорт в TXT (.txt)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("name")}
>
Название
{getSortIcon("name")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("placements_count")}
>
Размещений
{getSortIcon("placements_count")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("total_cost")}
>
Общая стоимость
{getSortIcon("total_cost")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("total_subscriptions")}
>
Подписки
{getSortIcon("total_subscriptions")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("total_views")}
>
Просмотры
{getSortIcon("total_views")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("avg_cpf")}
>
Средний CPF
{getSortIcon("avg_cpf")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("avg_cpm")}
>
Средний CPM
{getSortIcon("avg_cpm")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell className="font-medium">
{creative.name}
</TableCell>
<TableCell className="text-right">
{formatNumber(creative.placements_count)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(creative.total_cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(creative.total_subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(creative.total_views)}
</TableCell>
<TableCell className="text-right">
{creative.avg_cpf !== null
? formatCurrency(creative.avg_cpf)
: "—"}
</TableCell>
<TableCell className="text-right">
{creative.avg_cpm !== null
? formatCurrency(creative.avg_cpm)
: "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
creatives.length === 0 &&
!loadingData && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет данных по креативам. Примените фильтры для отображения
данных.
</AlertDescription>
</Alert>
)
)}
</>
)}
</div>
</>
);
}

View File

@@ -1,242 +0,0 @@
"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 { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
export default function AnalyticsPage() {
const { selectedChannel } = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(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 || !spending) {
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>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(spending.total_cost)}
</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(spending.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>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{spending.avg_cpf ? `${formatMetric(spending.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>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{spending.avg_cpm ? `${formatMetric(spending.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>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={spending.chart_data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="period"
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",
}}
/>
<Line
type="monotone"
dataKey="cost"
stroke="hsl(var(--primary))"
strokeWidth={2}
name="Затраты"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -1,476 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChartConfigPanel } from "@/components/analytics/chart-config-panel";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { channelsApi, analyticsApi } from "@/lib/api";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import { Plus, Loader2, AlertCircle, TrendingUp } from "lucide-react";
import type {
TargetChannel,
SpendingAnalytics,
AnalyticsMetric,
DateGrouping,
SpendingDataPoint,
} from "@/lib/types/api";
import { format } from "date-fns";
interface ChartData {
id: string;
config: {
targetChannelIds: string[];
metrics: AnalyticsMetric[];
dateFrom: Date | null;
dateTo: Date | null;
grouping: DateGrouping;
};
data: SpendingAnalytics | null;
loading: boolean;
width: "full" | "half";
}
// Компонент для sortable item
interface SortableChartItemProps {
chart: ChartData;
index: number;
channels: TargetChannel[];
chartsLength: number;
onBuild: (chartId: string, config: ChartData["config"]) => void;
onRemove: (chartId: string) => void;
onWidthChange: (chartId: string, width: "full" | "half") => void;
}
const SortableChartItem: React.FC<SortableChartItemProps> = ({
chart,
index,
channels,
chartsLength,
onBuild,
onRemove,
onWidthChange,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: chart.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className={chart.width === "half" ? "" : "col-span-full"}>
<ChartConfigPanel
channels={channels}
data={chart.data}
loading={chart.loading}
width={chart.width}
onBuild={(config) => onBuild(chart.id, config)}
onRemove={chartsLength > 1 ? () => onRemove(chart.id) : undefined}
onWidthChange={(width) => onWidthChange(chart.id, width)}
showRemoveButton={chartsLength > 1}
chartNumber={index + 1}
dragHandleProps={{ ...attributes, ...listeners }}
/>
</div>
);
};
export default function TargetChannelsAnalyticsPage() {
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [charts, setCharts] = useState<ChartData[]>([
{
id: "chart-1",
config: {
targetChannelIds: [],
metrics: [],
dateFrom: null,
dateTo: null,
grouping: "day",
},
data: null,
loading: false,
width: "full",
},
]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
setLoading(true);
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter((c) => c.is_active);
setChannels(activeChannels);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setLoading(false);
}
};
const handleBuildChart = async (
chartId: string,
config: ChartData["config"]
) => {
const chartIndex = charts.findIndex((c) => c.id === chartId);
if (chartIndex === -1) return;
// Обновляем состояние графика
const updatedCharts = [...charts];
updatedCharts[chartIndex].loading = true;
updatedCharts[chartIndex].config = config;
setCharts(updatedCharts);
try {
// Загружаем данные для каждого канала
const promises = config.targetChannelIds.map((channelId) =>
analyticsApi.spending({
target_channel_id: channelId,
date_from: config.dateFrom
? format(config.dateFrom, "yyyy-MM-dd'T'HH:mm:ss'Z'")
: undefined,
date_to: config.dateTo
? format(config.dateTo, "yyyy-MM-dd'T'HH:mm:ss'Z'")
: undefined,
grouping: config.grouping,
})
);
const results = await Promise.all(promises);
// Объединяем данные (если несколько каналов)
const combinedData: SpendingAnalytics = results.reduce(
(acc, result) => {
acc.total_cost += result.total_cost;
acc.total_subscriptions += result.total_subscriptions;
acc.total_views += result.total_views;
// Объединяем chart_data по периодам
result.chart_data.forEach((dataPoint) => {
const existing = acc.chart_data.find(
(d) => d.period === dataPoint.period
);
if (existing) {
existing.cost += dataPoint.cost;
existing.subscriptions += dataPoint.subscriptions;
existing.views += dataPoint.views;
// CPF и CPM пересчитаем после
} else {
acc.chart_data.push({ ...dataPoint });
}
});
return acc;
},
{
total_cost: 0,
total_subscriptions: 0,
total_views: 0,
avg_cpf: null,
avg_cpm: null,
chart_data: [] as SpendingDataPoint[],
}
);
// Пересчитываем CPF и CPM для каждого периода
combinedData.chart_data.forEach((dataPoint) => {
dataPoint.cpf =
dataPoint.subscriptions > 0
? dataPoint.cost / dataPoint.subscriptions
: null;
dataPoint.cpm =
dataPoint.views > 0 ? (dataPoint.cost / dataPoint.views) * 1000 : null;
});
// Пересчитываем средние значения
combinedData.avg_cpf =
combinedData.total_subscriptions > 0
? combinedData.total_cost / combinedData.total_subscriptions
: null;
combinedData.avg_cpm =
combinedData.total_views > 0
? (combinedData.total_cost / combinedData.total_views) * 1000
: null;
// Сортируем по периодам
combinedData.chart_data.sort((a, b) =>
a.period.localeCompare(b.period)
);
// Обновляем данные графика
const finalCharts = [...updatedCharts];
finalCharts[chartIndex].data = combinedData;
finalCharts[chartIndex].loading = false;
setCharts(finalCharts);
} catch (err: any) {
alert(err?.error?.message || "Ошибка загрузки данных");
const finalCharts = [...updatedCharts];
finalCharts[chartIndex].loading = false;
setCharts(finalCharts);
}
};
const handleAddChart = () => {
setCharts([
...charts,
{
id: `chart-${Date.now()}`,
config: {
targetChannelIds: [],
metrics: [],
dateFrom: null,
dateTo: null,
grouping: "day",
},
data: null,
loading: false,
width: "full",
},
]);
};
const handleRemoveChart = (chartId: string) => {
if (charts.length === 1) {
alert("Должен остаться хотя бы один график");
return;
}
setCharts(charts.filter((c) => c.id !== chartId));
};
const handleWidthChange = (chartId: string, width: "full" | "half") => {
setCharts(
charts.map((chart) =>
chart.id === chartId ? { ...chart, width } : chart
)
);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setCharts((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
// Собираем все данные для таблицы
const getAllTableData = (): SpendingDataPoint[] => {
const allData: SpendingDataPoint[] = [];
const periodMap = new Map<string, SpendingDataPoint>();
charts.forEach((chart) => {
if (!chart.data) return;
chart.data.chart_data.forEach((dataPoint) => {
const existing = periodMap.get(dataPoint.period);
if (existing) {
existing.cost += dataPoint.cost;
existing.subscriptions += dataPoint.subscriptions;
existing.views += dataPoint.views;
// Пересчитываем CPF и CPM
existing.cpf =
existing.subscriptions > 0
? existing.cost / existing.subscriptions
: null;
existing.cpm =
existing.views > 0 ? (existing.cost / existing.views) * 1000 : null;
} else {
periodMap.set(dataPoint.period, { ...dataPoint });
}
});
});
return Array.from(periodMap.values()).sort((a, b) =>
a.period.localeCompare(b.period)
);
};
const tableData = getAllTableData();
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Аналитика", href: "/analytics" },
{ label: "Целевые каналы" },
]}
/>
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ 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>
<Button onClick={handleAddChart}>
<Plus className="mr-2 h-4 w-4" />
Добавить график
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{channels.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Нет активных целевых каналов. Добавьте канал для начала работы с
аналитикой.
</AlertDescription>
</Alert>
) : (
<>
{/* Графики */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={charts.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid grid-cols-2 gap-6">
{charts.map((chart, index) => (
<SortableChartItem
key={chart.id}
chart={chart}
index={index}
channels={channels}
chartsLength={charts.length}
onBuild={handleBuildChart}
onRemove={handleRemoveChart}
onWidthChange={handleWidthChange}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Таблица */}
{tableData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Табличное представление</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписки</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="text-right">CPM</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tableData.map((row) => (
<TableRow key={row.period}>
<TableCell className="font-medium">
{row.period}
</TableCell>
<TableCell className="text-right">
{formatCurrency(row.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(row.subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(row.views)}
</TableCell>
<TableCell className="text-right">
{row.cpf !== null ? formatCurrency(row.cpf) : "—"}
</TableCell>
<TableCell className="text-right">
{row.cpm !== null ? formatCurrency(row.cpm) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)}
</div>
</>
);
}

View File

@@ -1,26 +0,0 @@
"use client";
// ============================================================================
// Target Channel Detail Page
// ============================================================================
import React, { useEffect } from "react";
import { useRouter } from "next/navigation";
import { use } from "react";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function ChannelDetailPage({ params }: PageProps) {
const router = useRouter();
const resolvedParams = use(params);
useEffect(() => {
// Redirect back to channels list
// Detail page functionality not yet implemented in backend API
router.push("/channels");
}, [router, resolvedParams.id]);
return null;
}

View File

@@ -1,249 +0,0 @@
"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.target_channels);
} catch (err: any) {
setError(
err?.error?.message || err?.detail || "Ошибка загрузки каналов"
);
} 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 handleDisconnect = async (id: string, title: string) => {
if (!confirm(`Вы уверены, что хотите отключить канал "${title}"?`)) {
return;
}
try {
await channelsApi.delete(id);
setChannels((prev) => prev.filter((ch) => ch.id !== id));
} catch (err: any) {
alert(err?.error?.message || err?.detail || "Ошибка отключения канала");
}
};
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">
<div className="text-sm text-muted-foreground">
Telegram ID:{" "}
<code className="text-xs bg-muted px-1 rounded">
{channel.telegram_id}
</code>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handleDisconnect(channel.id, channel.title)
}
className="flex-1"
>
Отключить канал
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@@ -1,286 +0,0 @@
"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 { Creative } 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<Creative | 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, {
status: creative.status === "active" ? "archived" : "active",
});
setCreative({
...creative,
status: creative.status === "active" ? "archived" : "active",
});
} 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.status === "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.status === "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.placements_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>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-lg font-medium">
{creative.target_channel_title}
</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>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-2">Всего размещений:</h4>
<p className="text-2xl font-bold">
{creative.placements_count}
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Создан:</h4>
<p className="text-sm">{formatDate(creative.created_at)}</p>
</div>
<div>
<Button asChild variant="outline" className="w-full">
<Link href={`/purchases?creative_id=${creative.id}`}>
Посмотреть все размещения
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -1,227 +0,0 @@
"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";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreateCreativePage() {
const router = useRouter();
const { selectedChannel } = useTargetChannel();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: "",
text: "",
});
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 (!selectedChannel) {
setError("Выберите целевой канал в верхнем меню");
return;
}
try {
setIsLoading(true);
await creativesApi.create({
...formData,
target_channel_id: selectedChannel.id,
});
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>
{selectedChannel && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Креатив будет создан для канала: <strong>{selectedChannel.title}</strong>
</AlertDescription>
</Alert>
)}
</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

@@ -1,266 +0,0 @@
"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,
formatDate,
} 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";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreativesPage() {
const { selectedChannel } = useTargetChannel();
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(() => {
if (selectedChannel) {
loadCreatives();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadCreatives = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const response = await creativesApi.list({
target_channel_id: selectedChannel.id,
});
setCreatives(response.creatives);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки креативов");
} finally {
setLoading(false);
}
};
const handleArchive = async (
id: string,
currentStatus: "active" | "archived"
) => {
try {
const newStatus = currentStatus === "active" ? "archived" : "active";
await creativesApi.update(id, { status: newStatus });
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.status === "active";
if (activeTab === "archived") return creative.status === "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.status === "active").length})
</TabsTrigger>
<TabsTrigger value="archived">
Архив ({creatives.filter((c) => c.status === "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.status === "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-2 gap-2 text-center">
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(creative.placements_count)}
</div>
<div className="text-xs text-muted-foreground">
Размещений
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-xs font-medium">
{formatDate(creative.created_at)}
</div>
<div className="text-xs text-muted-foreground">
Создан
</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.status)
}
>
<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

@@ -1,26 +0,0 @@
"use client";
// ============================================================================
// External Channel Detail Page
// ============================================================================
import React, { useEffect } from "react";
import { useRouter } from "next/navigation";
import { use } from "react";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function ExternalChannelDetailPage({ params }: PageProps) {
const router = useRouter();
const resolvedParams = use(params);
useEffect(() => {
// Redirect back to external channels list
// Detail page functionality not yet implemented in backend API
router.push("/external-channels");
}, [router, resolvedParams.id]);
return null;
}

View File

@@ -1,527 +0,0 @@
"use client";
// ============================================================================
// External Channels Import Page
// ============================================================================
import React, { useState, useEffect } 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 { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { externalChannelsApi, channelsApi } from "@/lib/api";
import {
parseChannelsFile,
downloadTemplate,
type ParsedChannel,
} from "@/lib/utils/file-parser";
import {
FileUp,
AlertCircle,
CheckCircle,
Loader2,
Download,
ArrowLeft,
X,
FileSpreadsheet,
} from "lucide-react";
import type { TargetChannel } from "@/lib/types/api";
interface ImportResult {
total: number;
successful: number;
failed: number;
errors: string[];
}
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 [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [progress, setProgress] = useState(0);
const [currentChannel, setCurrentChannel] = useState<string>("");
const [targetChannels, setTargetChannels] = useState<TargetChannel[]>([]);
const [selectedTargetChannel, setSelectedTargetChannel] =
useState<string>("");
useEffect(() => {
loadTargetChannels();
}, []);
const loadTargetChannels = async () => {
try {
const response = await channelsApi.list();
setTargetChannels(response.target_channels);
if (response.target_channels.length > 0) {
setSelectedTargetChannel(response.target_channels[0].id);
}
} catch (err) {
console.error("Error loading target channels:", err);
}
};
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 = async (selectedFile: File) => {
if (!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)) {
setParseErrors(["Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"]);
return;
}
setFile(selectedFile);
setParseErrors([]);
setParsedChannels([]);
setImportResult(null);
// Парсим файл
try {
const result = await parseChannelsFile(selectedFile);
setParsedChannels(result.channels);
setParseErrors(result.errors);
} catch (err) {
setParseErrors([
`Ошибка парсинга файла: ${
err instanceof Error ? err.message : "неизвестная ошибка"
}`,
]);
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
handleFileSelect(selectedFile);
}
};
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const handleImport = async () => {
if (!selectedTargetChannel || parsedChannels.length === 0) return;
setIsUploading(true);
setImportResult(null);
setProgress(0);
setCurrentChannel("");
const result: ImportResult = {
total: parsedChannels.length,
successful: 0,
failed: 0,
errors: [],
};
for (let i = 0; i < parsedChannels.length; i++) {
const channel = parsedChannels[i];
setCurrentChannel(channel.title);
setProgress(((i + 1) / parsedChannels.length) * 100);
try {
await externalChannelsApi.create({
telegram_id: channel.telegram_id || 0,
title: channel.title,
username: channel.username || null,
description: channel.description || null,
subscribers_count: channel.subscribers_count || null,
target_channel_ids: [selectedTargetChannel],
});
result.successful++;
} catch (err: any) {
result.failed++;
const errorMsg =
err?.detail || err?.error?.message || "Неизвестная ошибка";
result.errors.push(
`Строка ${channel.row} (${channel.title}): ${errorMsg}`
);
}
// Задержка между запросами (300ms)
if (i < parsedChannels.length - 1) {
await delay(300);
}
}
setImportResult(result);
setIsUploading(false);
setCurrentChannel("");
};
const handleReset = () => {
setFile(null);
setParsedChannels([]);
setParseErrors([]);
setImportResult(null);
setProgress(0);
};
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 или CSV с данными каналов
</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>
Выберите целевой канал для привязки импортируемых каналов
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={selectedTargetChannel}
onValueChange={setSelectedTargetChannel}
disabled={isUploading || targetChannels.length === 0}
>
<SelectTrigger>
<SelectValue placeholder="Выберите целевой канал" />
</SelectTrigger>
<SelectContent>
{targetChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
{channel.username && ` (@${channel.username})`}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Шаблоны */}
<Card>
<CardHeader>
<CardTitle>Скачать шаблон</CardTitle>
<CardDescription>
Используйте шаблон для правильного заполнения данных
</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Button
variant="outline"
onClick={() => downloadTemplate("xlsx")}
disabled={isUploading}
>
<Download className="mr-2 h-4 w-4" />
Шаблон Excel
</Button>
<Button
variant="outline"
onClick={() => downloadTemplate("csv")}
disabled={isUploading}
>
<Download className="mr-2 h-4 w-4" />
Шаблон CSV
</Button>
</CardContent>
</Card>
{/* Загрузка файла */}
<Card>
<CardHeader>
<CardTitle>Загрузка файла</CardTitle>
<CardDescription>
Перетащите файл или нажмите для выбора
</CardDescription>
</CardHeader>
<CardContent>
<div
className={`relative border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
} ${isUploading ? "opacity-50 pointer-events-none" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
id="file-upload"
className="hidden"
accept=".xlsx,.xls,.csv"
onChange={handleFileInputChange}
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className="cursor-pointer flex flex-col items-center"
>
<FileUp className="h-12 w-12 text-muted-foreground mb-4" />
{file ? (
<div className="space-y-2">
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(2)} KB
</p>
{!isUploading && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
handleReset();
}}
>
<X className="h-4 w-4 mr-1" />
Удалить
</Button>
)}
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
Перетащите файл сюда или нажмите для выбора
</p>
<p className="text-xs text-muted-foreground mt-1">
Поддерживаются форматы: .xlsx, .xls, .csv
</p>
</>
)}
</label>
</div>
</CardContent>
</Card>
{/* Результаты парсинга */}
{parsedChannels.length > 0 && !importResult && (
<Card>
<CardHeader>
<CardTitle>Результаты парсинга</CardTitle>
<CardDescription>
Найдено каналов: {parsedChannels.length}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{parseErrors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Ошибки при парсинге:</strong>
<ul className="list-disc list-inside mt-2">
{parseErrors.slice(0, 5).map((error, i) => (
<li key={i} className="text-sm">
{error}
</li>
))}
{parseErrors.length > 5 && (
<li className="text-sm">
...и еще {parseErrors.length - 5}
</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
onClick={handleImport}
disabled={
isUploading ||
!selectedTargetChannel ||
parsedChannels.length === 0
}
className="flex-1"
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Импортировать {parsedChannels.length} каналов
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isUploading}
>
Отмена
</Button>
</div>
</CardContent>
</Card>
)}
{/* Прогресс импорта */}
{isUploading && (
<Card>
<CardHeader>
<CardTitle>Импорт в процессе</CardTitle>
<CardDescription>
{currentChannel && `Импортируется: ${currentChannel}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={progress} className="w-full" />
<p className="text-sm text-center text-muted-foreground">
{Math.round(progress)}% завершено
</p>
</CardContent>
</Card>
)}
{/* Результаты импорта */}
{importResult && (
<Card>
<CardHeader>
<CardTitle>Результаты импорта</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{importResult.total}</div>
<div className="text-xs text-muted-foreground">Всего</div>
</div>
<div className="p-4 border rounded-lg border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{importResult.successful}
</div>
<div className="text-xs text-muted-foreground">Успешно</div>
</div>
<div className="p-4 border rounded-lg border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950">
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{importResult.failed}
</div>
<div className="text-xs text-muted-foreground">Ошибок</div>
</div>
</div>
{importResult.errors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Ошибки импорта:</strong>
<ul className="list-disc list-inside mt-2 max-h-60 overflow-y-auto">
{importResult.errors.map((error, i) => (
<li key={i} className="text-sm">
{error}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{importResult.successful > 0 && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
Успешно импортировано каналов: {importResult.successful}
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
onClick={() => router.push("/external-channels")}
className="flex-1"
>
Перейти к списку каналов
</Button>
<Button variant="outline" onClick={handleReset}>
Импортировать еще
</Button>
</div>
</CardContent>
</Card>
)}
{/* Инструкция */}
<Card>
<CardHeader>
<CardTitle>Формат файла</CardTitle>
<CardDescription>Требования к структуре файла</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p>
<strong>Колонки (в порядке):</strong>
</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>
<strong>Название канала*</strong> - обязательное поле
</li>
<li>
<strong>Username</strong> - без символа @ (опционально)
</li>
<li>
<strong>Telegram ID</strong> - числовой ID канала
(опционально)
</li>
<li>
<strong>Подписчиков</strong> - количество подписчиков
(опционально)
</li>
<li>
<strong>Описание</strong> - описание канала (опционально)
</li>
</ol>
<p className="text-muted-foreground mt-4">
* Первая строка файла должна содержать заголовки и будет
пропущена при импорте
</p>
</div>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -1,617 +0,0 @@
"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, channelsApi } 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,
ArrowUpDown,
ArrowUp,
ArrowDown,
LayoutGrid,
Table2,
} from "lucide-react";
import type { ExternalChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
type ViewMode = "table" | "grid";
type SortField =
| "title"
| "username"
| "subscribers_count"
| "telegram_id"
| null;
type SortDirection = "asc" | "desc" | null;
export default function ExternalChannelsPage() {
const { selectedChannel } = useTargetChannel();
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);
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
// Form state
const [formData, setFormData] = useState({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
useEffect(() => {
if (selectedChannel) {
loadChannels();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadChannels = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
setError(null);
const response = await externalChannelsApi.list(selectedChannel.id);
setChannels(response.external_channels);
} catch (err: any) {
const errorMsg =
typeof err?.detail === "string"
? err.detail
: err?.error?.message || "Ошибка загрузки каналов";
setError(errorMsg);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
setIsCreating(true);
// Получаем первый доступный target channel
const targetChannelsRes = await channelsApi.list();
if (targetChannelsRes.target_channels.length === 0) {
alert("Добавьте сначала целевой канал");
setIsCreating(false);
return;
}
await externalChannelsApi.create({
telegram_id: 0, // Временно, пока не получим реальный ID
title: formData.title,
username: formData.username || null,
subscribers_count: formData.subscribers_count
? parseInt(formData.subscribers_count)
: null,
description: formData.description || null,
target_channel_ids: [targetChannelsRes.target_channels[0].id],
});
setIsCreateDialogOpen(false);
setFormData({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
loadChannels();
} catch (err: any) {
const errorMsg =
typeof err?.detail === "string"
? err.detail
: err?.error?.message || "Ошибка создания канала";
alert(errorMsg);
} 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 handleSort = (field: SortField) => {
if (sortField === field) {
// Переключение: asc -> desc -> null -> asc
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortField(null);
setSortDirection(null);
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
}
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
};
const filteredChannels = channels
.filter(
(channel) =>
channel.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
channel.username?.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
if (!sortField || !sortDirection) return 0;
let aValue: string | number | null = "";
let bValue: string | number | null = "";
switch (sortField) {
case "title":
aValue = a.title;
bValue = b.title;
break;
case "username":
aValue = a.username || "";
bValue = b.username || "";
break;
case "subscribers_count":
aValue = a.subscribers_count || 0;
bValue = b.subscribers_count || 0;
break;
case "telegram_id":
aValue = a.telegram_id;
bValue = b.telegram_id;
break;
}
// Сравнение строк
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
// Сравнение чисел
if (typeof aValue === "number" && typeof bValue === "number") {
return sortDirection === "asc" ? aValue - bValue : bValue - aValue;
}
return 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>
<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 justify-between gap-2">
<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 rounded-lg"
/>
</div>
<Badge variant="secondary">
Всего: {formatNumber(filteredChannels.length)}
</Badge>
</div>
<div className="flex items-center gap-1 border rounded-md">
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="rounded-r-none"
>
<Table2 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="rounded-l-none"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
</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>
) : viewMode === "grid" ? (
<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={`https://t.me/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{formatUsername(channel.username)}
</a>
) : (
<span className="text-xs">
ID: {channel.telegram_id}
</span>
)}
</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="flex items-center gap-2">
<div className="flex-1 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 text-center">
Подписчики
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="destructive"
size="sm"
className="flex-1"
onClick={() => handleDelete(channel.id, channel.title)}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Список внешних каналов</CardTitle>
<CardDescription>
Каналы, в которых покупаете рекламу
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("title")}
>
Название
{getSortIcon("title")}
</TableHead>
<TableHead className="cursor-pointer hover:bg-muted/50">
Username
</TableHead>
<TableHead>Описание</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("subscribers_count")}
>
Подписчики
{getSortIcon("subscribers_count")}
</TableHead>
<TableHead className="text-right cursor-pointer hover:bg-muted/50">
Telegram ID
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<a
href={`https://t.me/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 hover:underline text-primary whitespace-nowrap"
tabIndex={0}
aria-label={`Открыть канал ${channel.title} в Telegram`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
window.open(
`https://t.me/${channel.username}`,
"_blank",
"noopener,noreferrer"
);
}
}}
>
<span className="inline-flex items-center gap-2">
<span>{channel.title}</span>
<ExternalLinkIcon className="h-4 w-4 text-muted-foreground shrink-0" />
</span>
</a>
</div>
</TableCell>
<TableCell>
{channel.username ? (
<div>{formatUsername(channel.username)}</div>
) : (
"—"
)}
</TableCell>
<TableCell>
<div className="max-w-[300px] truncate text-muted-foreground">
{channel.description || "—"}
</div>
</TableCell>
<TableCell className="text-right">
{channel.subscribers_count
? formatNumber(channel.subscribers_count)
: "—"}
</TableCell>
<TableCell className="text-right">
{channel.telegram_id}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDelete(channel.id, channel.title)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@@ -1,22 +0,0 @@
// ============================================================================
// 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>
);
}

View File

@@ -1,365 +0,0 @@
"use client";
// ============================================================================
// Dashboard Home Page
// ============================================================================
import React, { useEffect, useState, useRef } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { analyticsApi, channelsApi } from "@/lib/api";
import {
formatCurrency,
formatNumber,
formatMetric,
formatPercent,
} from "@/lib/utils/format";
import {
BarChart3,
TrendingUp,
Users,
ShoppingCart,
Loader2,
ExternalLink,
CheckCircle2,
} from "lucide-react";
import type { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import { BOT_USERNAME } from "@/lib/utils/constants";
export default function DashboardPage() {
const {
selectedChannel,
channels,
isLoading: channelsLoading,
} = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
// Для модального окна добавления канала
const [showAddChannelDialog, setShowAddChannelDialog] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [initialChannelCount, setInitialChannelCount] = useState(0);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Показываем модальное окно, если нет каналов
useEffect(() => {
if (!channelsLoading && channels.length === 0) {
setShowAddChannelDialog(true);
setInitialChannelCount(0);
}
}, [channelsLoading, channels]);
// Автоматический polling при открытом окне
useEffect(() => {
if (showAddChannelDialog && !isSuccess && initialChannelCount === 0) {
pollingIntervalRef.current = setInterval(() => {
checkForNewChannels();
}, 1000);
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}
}, [showAddChannelDialog, isSuccess, initialChannelCount]);
// Очистка при размонтировании
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
const checkForNewChannels = async () => {
try {
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter(
(c) => c.is_active
);
if (activeChannels.length > initialChannelCount) {
setIsSuccess(true);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setTimeout(() => {
setShowAddChannelDialog(false);
setIsSuccess(false);
window.location.reload();
}, 2000);
}
} catch (err) {
console.error("Error polling channels:", err);
}
};
useEffect(() => {
if (!selectedChannel) {
setLoading(false);
return;
}
const loadData = async () => {
try {
setLoading(true);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (error) {
console.error("Error loading spending data:", error);
} finally {
setLoading(false);
}
};
loadData();
}, [selectedChannel]);
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
{/* Модальное окно добавления первого канала */}
<Dialog open={showAddChannelDialog} onOpenChange={() => {}}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Добро пожаловать!</DialogTitle>
<DialogDescription>
Для начала работы добавьте ваш первый целевой канал
</DialogDescription>
</DialogHeader>
{isSuccess ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="rounded-full bg-green-100 dark:bg-green-900 p-3">
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold">
Канал успешно добавлен!
</h3>
<p className="text-sm text-muted-foreground mt-1">
Страница обновится автоматически...
</p>
</div>
</div>
) : (
<div className="space-y-4">
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">Шаги для добавления канала:</p>
<ol className="list-decimal list-inside space-y-1.5 text-sm">
<li>Откройте свой Telegram канал</li>
<li>Перейдите в настройки канала Администраторы</li>
<li>
Добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
как администратора
</li>
<li>
Предоставьте боту права:{" "}
<span className="font-medium">
Публикация сообщений, Удаление сообщений
</span>
</li>
</ol>
</AlertDescription>
</Alert>
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground text-center">
Ждем добавления бота...
<br />
<span className="text-xs">
Как только вы добавите бота, канал автоматически появится
</span>
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
{!selectedChannel ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">
Выберите целевой канал
</h2>
<p className="text-muted-foreground">
Для начала работы выберите целевой канал в верхнем меню
</p>
</div>
</div>
) : (
<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(spending?.total_cost)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(0 || 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(spending?.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(0 || 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(spending?.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(0 || 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(spending?.avg_cpm)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(0 || 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

@@ -1,400 +0,0 @@
"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 { 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 { Placement } 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<Placement | 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, {
status: purchase.status === "active" ? "archived" : "active",
});
setPurchase({
...purchase,
status: purchase.status === "active" ? "archived" : "active",
});
} 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>
</>
);
}
// Remove message text generation as we don't have creative.text in Placement
const messageText = `Текст сообщения с ссылкой: ${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.status === "archived" ? (
<Badge variant="secondary">Архив</Badge>
) : (
<Badge variant="default">
<CheckCircle 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`}
className="hover:underline font-medium"
>
{purchase.target_channel_title}
</Link>
</span>
<span></span>
<span>
Креатив:{" "}
<Link
href={`/creatives`}
className="hover:underline font-medium"
>
{purchase.creative_name}
</Link>
</span>
<span></span>
<span>
Дата размещения: {formatDate(purchase.placement_date)}
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleArchive}>
<Archive className="mr-2 h-4 w-4" />
{purchase.status === "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">
Статус просмотров
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-lg font-medium">
{purchase.views_availability === "available"
? "Доступны"
: purchase.views_availability === "manual"
? "Вручную"
: purchase.views_availability === "unavailable"
? "Недоступны"
: "Неизвестно"}
</div>
<p className="text-xs text-muted-foreground mt-1">
{purchase.last_views_fetch_at
? `Обновлено: ${formatDate(purchase.last_views_fetch_at)}`
: "Еще не обновлялись"}
</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-lg font-medium">
{purchase.invite_link_type === "approval"
? "С одобрением"
: "Публичная"}
</div>
<p className="text-xs text-muted-foreground mt-1">
{purchase.invite_link_type === "approval"
? "Отслеживание подписчиков"
: "Без отслеживания"}
</p>
</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.ad_post_url && (
<div className="pt-2">
<Label className="text-sm text-muted-foreground">
Ссылка на рекламный пост:
</Label>
<a
href={purchase.ad_post_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm hover:underline mt-1"
>
{purchase.ad_post_url}
<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>
)}
<Card>
<CardHeader>
<CardTitle>Заметка</CardTitle>
<CardDescription>
Детальная информация о размещении доступна через раздел "Подписки"
в главном меню
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Для просмотра списка подписчиков и истории просмотров используйте
соответствующие разделы в главном меню.
</p>
</CardContent>
</Card>
</div>
</>
);
}
function Label({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={className}>{children}</div>;
}

View File

@@ -1,478 +0,0 @@
"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 { ExternalChannel, Creative } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreatePurchasePage() {
const router = useRouter();
const { selectedChannel } = useTargetChannel();
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({
external_channel_id: "",
creative_id: "",
placement_date: "",
cost: "",
comment: "",
post_link: "",
invite_link_type: "public" as "public" | "approval",
});
useEffect(() => {
if (selectedChannel) {
loadData();
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
const [externalChannelsRes, creativesRes] = await Promise.all([
externalChannelsApi.list(selectedChannel.id),
creativesApi.list({ target_channel_id: selectedChannel.id }),
]);
setExternalChannels(externalChannelsRes.external_channels);
setCreatives(
creativesRes.creatives.filter((c) => c.status === "active")
);
} catch (err) {
console.error("Error loading data:", err);
}
};
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation
if (!selectedChannel) {
setError("Выберите целевой канал в верхнем меню");
return;
}
if (!formData.external_channel_id) {
setError("Выберите внешний канал");
return;
}
if (!formData.creative_id) {
setError("Выберите креатив");
return;
}
if (!formData.placement_date) {
setError("Укажите дату размещения");
return;
}
if (!formData.cost || parseFloat(formData.cost) <= 0) {
setError("Укажите корректную стоимость");
return;
}
try {
setIsLoading(true);
const result = await purchasesApi.create({
target_channel_id: selectedChannel.id,
external_channel_id: formData.external_channel_id,
creative_id: formData.creative_id,
placement_date: formData.placement_date,
cost: parseFloat(formData.cost),
comment: formData.comment || undefined,
ad_post_url: 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({
external_channel_id: "",
creative_id: "",
placement_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">
{selectedChannel && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Размещение будет создано для канала: <strong>{selectedChannel.title}</strong>
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2">
<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 className="space-y-2">
<Label htmlFor="creative_id">Креатив *</Label>
<Select
value={formData.creative_id}
onValueChange={(value) =>
setFormData({ ...formData, creative_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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="placement_date">Дата размещения *</Label>
<Input
id="placement_date"
type="datetime-local"
value={formData.placement_date}
onChange={(e) =>
setFormData({
...formData,
placement_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" | "approval") =>
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="approval" id="approval" />
<Label htmlFor="approval" 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

@@ -1,964 +0,0 @@
"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,
TableFooter,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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,
ArrowUpDown,
ArrowUp,
ArrowDown,
Download,
} from "lucide-react";
import type { Purchase } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import * as XLSX from "xlsx";
type SortField =
| "status"
| "placement_date"
| "cost"
| "subscriptions_count"
| "views_count"
| "cpm"
| "cpl"
| null;
type SortDirection = "asc" | "desc" | null;
type AggregateFunction = "sum" | "avg" | "median" | "max" | "min";
type AggregateConfig = {
cost: AggregateFunction;
subscriptions_count: AggregateFunction;
views_count: AggregateFunction;
cpm: AggregateFunction;
cpl: AggregateFunction;
};
export default function PurchasesPage() {
const { selectedChannel } = useTargetChannel();
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
const [aggregateConfig, setAggregateConfig] = useState<AggregateConfig>({
cost: "sum",
subscriptions_count: "sum",
views_count: "sum",
cpm: "avg",
cpl: "avg",
});
useEffect(() => {
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const purchasesRes = await purchasesApi.list({
target_channel_id: selectedChannel.id,
});
setPurchases(purchasesRes.placements);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки данных");
} finally {
setLoading(false);
}
};
// Вычисление метрик
const calculateCPM = (purchase: Purchase): number | null => {
if (!purchase.cost || !purchase.views_count || purchase.views_count === 0)
return null;
return (purchase.cost / purchase.views_count) * 1000;
};
const calculateCPL = (purchase: Purchase): number | null => {
if (
!purchase.cost ||
!purchase.subscriptions_count ||
purchase.subscriptions_count === 0
)
return null;
return purchase.cost / purchase.subscriptions_count;
};
// Функции агрегации
const calculateAggregate = (
values: (number | null)[],
func: AggregateFunction
): number | null => {
const filtered = values.filter((v) => v !== null) as number[];
if (filtered.length === 0) return null;
switch (func) {
case "sum":
return filtered.reduce((sum, val) => sum + val, 0);
case "avg":
return filtered.reduce((sum, val) => sum + val, 0) / filtered.length;
case "median":
const sorted = [...filtered].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
case "max":
return Math.max(...filtered);
case "min":
return Math.min(...filtered);
default:
return null;
}
};
const getAggregateFunctionLabel = (func: AggregateFunction): string => {
switch (func) {
case "sum":
return "Сумма";
case "avg":
return "Среднее";
case "median":
return "Медиана";
case "max":
return "Наибольшее";
case "min":
return "Наименьшее";
}
};
// Функции экспорта
const getExportData = () => {
return filteredPurchases.map((purchase) => {
const cpm = calculateCPM(purchase);
const cpl = calculateCPL(purchase);
return {
Статус: purchase.status === "archived" ? "Архив" : "Активно",
"Целевой канал": purchase.target_channel_title,
"Внешний канал": purchase.external_channel_title,
Креатив: purchase.creative_name,
Дата: formatDate(purchase.placement_date),
Стоимость: purchase.cost,
Подписки: purchase.subscriptions_count,
Просмотры: purchase.views_count || 0,
CPM: cpm !== null ? Number(cpm.toFixed(2)) : null,
CPL: cpl !== null ? Number(cpl.toFixed(2)) : null,
};
});
};
const getExportTotals = () => {
return {
Статус: "",
"Целевой канал": "",
"Внешний канал": "",
Креатив: "",
Дата: "Итого",
Стоимость:
calculateAggregate(
filteredPurchases.map((p) => p.cost),
aggregateConfig.cost
) || 0,
Подписки:
calculateAggregate(
filteredPurchases.map((p) => p.subscriptions_count),
aggregateConfig.subscriptions_count
) || 0,
Просмотры:
calculateAggregate(
filteredPurchases.map((p) => p.views_count),
aggregateConfig.views_count
) || 0,
CPM:
calculateAggregate(
filteredPurchases.map((p) => calculateCPM(p)),
aggregateConfig.cpm
) || 0,
CPL:
calculateAggregate(
filteredPurchases.map((p) => calculateCPL(p)),
aggregateConfig.cpl
) || 0,
};
};
const exportToExcel = () => {
const data = getExportData();
const totals = getExportTotals();
const exportData = [...data, totals];
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Закупы");
// Форматирование ширины столбцов
const colWidths = [
{ wch: 10 }, // Статус
{ wch: 25 }, // Целевой канал
{ wch: 25 }, // Внешний канал
{ wch: 30 }, // Креатив
{ wch: 12 }, // Дата
{ wch: 12 }, // Стоимость
{ wch: 10 }, // Подписки
{ wch: 12 }, // Просмотры
{ wch: 10 }, // CPM
{ wch: 10 }, // CPL
];
ws["!cols"] = colWidths;
XLSX.writeFile(wb, `Закупы_${new Date().toISOString().split("T")[0]}.xlsx`);
};
const exportToCSV = () => {
const data = getExportData();
const totals = getExportTotals();
const exportData = [...data, totals];
// Заголовки
const headers = Object.keys(exportData[0]);
const csvContent = [
headers.join(","),
...exportData.map((row) =>
headers
.map((header) => {
const value = row[header as keyof typeof row];
// Обработка значений с запятыми или кавычками
if (
value === null ||
value === undefined ||
value === "" ||
value === 0
) {
return value === null ? "" : value;
}
const stringValue = String(value);
if (stringValue.includes(",") || stringValue.includes('"')) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
})
.join(",")
),
].join("\n");
const blob = new Blob(["\ufeff" + csvContent], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `Закупы_${new Date().toISOString().split("T")[0]}.csv`;
link.click();
URL.revokeObjectURL(link.href);
};
const exportToTXT = () => {
const data = getExportData();
const totals = getExportTotals();
const exportData = [...data, totals];
const headers = Object.keys(exportData[0]);
const columnWidths = headers.map((header) => {
const maxLength = Math.max(
header.length,
...exportData.map((row) => {
const value = row[header as keyof typeof row];
return String(value || "").length;
})
);
return Math.min(maxLength + 2, 30);
});
const txtContent = [
// Заголовки
headers.map((header, i) => header.padEnd(columnWidths[i])).join(" | "),
// Разделитель
columnWidths.map((width) => "-".repeat(width)).join("-+-"),
// Данные
...exportData.map((row) =>
headers
.map((header, i) => {
const value = row[header as keyof typeof row];
return String(value || "").padEnd(columnWidths[i]);
})
.join(" | ")
),
].join("\n");
const blob = new Blob([txtContent], { type: "text/plain;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `Закупы_${new Date().toISOString().split("T")[0]}.txt`;
link.click();
URL.revokeObjectURL(link.href);
};
// Обработка сортировки
const handleSort = (field: SortField) => {
if (sortField === field) {
// Переключение: asc -> desc -> null -> asc
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortField(null);
setSortDirection(null);
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
}
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
};
const filteredPurchases = purchases
.filter((purchase) => {
// Поиск
const matchesSearch =
purchase.external_channel_title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.creative_name
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Фильтр по статусу
let matchesStatus = true;
if (filterStatus === "archived") {
matchesStatus = purchase.status === "archived";
} else if (filterStatus === "active") {
matchesStatus = purchase.status === "active";
}
return matchesSearch && matchesStatus;
})
.sort((a, b) => {
if (!sortField || !sortDirection) return 0;
let aValue: number | string | null = 0;
let bValue: number | string | null = 0;
switch (sortField) {
case "status":
aValue = a.status;
bValue = b.status;
// Сравниваем строки
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
return 0;
case "placement_date":
aValue = new Date(a.placement_date).getTime();
bValue = new Date(b.placement_date).getTime();
break;
case "cost":
aValue = a.cost;
bValue = b.cost;
break;
case "subscriptions_count":
aValue = a.subscriptions_count;
bValue = b.subscriptions_count;
break;
case "views_count":
aValue = a.views_count || 0;
bValue = b.views_count || 0;
break;
case "cpm":
aValue = calculateCPM(a);
bValue = calculateCPM(b);
break;
case "cpl":
aValue = calculateCPL(a);
bValue = calculateCPL(b);
break;
}
// Обработка null значений для чисел
if (typeof aValue === "number" && typeof bValue === "number") {
if (aValue === null && bValue === null) return 0;
if (aValue === null) return 1;
if (bValue === null) return -1;
if (sortDirection === "asc") {
return aValue - bValue;
} else {
return bValue - aValue;
}
}
return 0;
});
// Статистика
const stats = {
total: purchases.length,
active: purchases.filter((p) => p.status === "active").length,
archived: purchases.filter((p) => p.status === "archived").length,
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
totalSubscriptions: purchases.reduce(
(sum, p) => sum + p.subscriptions_count,
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>
<div className="flex items-center gap-2">
<Button asChild>
<Link href="/purchases/new">
<Plus className="mr-2 h-4 w-4" />
Создать закуп
</Link>
</Button>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardContent className="justify-between flex flex-row">
<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={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 className="flex flex-row justify-between">
<div className="">
<CardTitle>Список закупов</CardTitle>
<CardDescription>Все рекламные размещения</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Экспорт
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportToExcel}>
Экспорт в Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToCSV}>
Экспорт в CSV (.csv)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToTXT}>
Экспорт в TXT (.txt)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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 || filterStatus !== "all"
? "Закупы не найдены"
: "Нет закупов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery || filterStatus !== "all"
? "Попробуйте изменить фильтры"
: "Создайте первый закуп"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("status")}
>
Статус
{getSortIcon("status")}
</TableHead>
<TableHead>Целевой канал</TableHead>
<TableHead>Внешний канал</TableHead>
<TableHead>Креатив</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("placement_date")}
>
Дата
{getSortIcon("placement_date")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("cost")}
>
Стоимость
{getSortIcon("cost")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("subscriptions_count")}
>
Подписки
{getSortIcon("subscriptions_count")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("views_count")}
>
Просмотры
{getSortIcon("views_count")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("cpm")}
>
CPM
{getSortIcon("cpm")}
</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("cpl")}
>
CPL
{getSortIcon("cpl")}
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPurchases.map((purchase) => {
const cpm = calculateCPM(purchase);
const cpl = calculateCPL(purchase);
return (
<TableRow key={purchase.id}>
<TableCell>
{purchase.status === "archived" ? (
<Badge variant="secondary">
<Archive className="h-3 w-3 mr-1" />
Архив
</Badge>
) : (
<Badge variant="default">
<CheckCircle 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.placement_date)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(purchase.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(purchase.subscriptions_count)}
</TableCell>
<TableCell className="text-right">
{purchase.views_count
? formatNumber(purchase.views_count)
: "—"}
</TableCell>
<TableCell className="text-right">
{cpm !== null ? formatCurrency(cpm) : "—"}
</TableCell>
<TableCell className="text-right">
{cpl !== null ? formatCurrency(cpl) : "—"}
</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>
<TableFooter>
<TableRow>
<TableCell colSpan={5} className="font-semibold">
Итого
</TableCell>
<TableCell className="text-right font-semibold">
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<span className="text-xs">ƒ</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(
[
"sum",
"avg",
"median",
"max",
"min",
] as AggregateFunction[]
).map((func) => (
<DropdownMenuItem
key={func}
onClick={() =>
setAggregateConfig({
...aggregateConfig,
cost: func,
})
}
className={
aggregateConfig.cost === func
? "bg-accent"
: ""
}
>
{getAggregateFunctionLabel(func)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{formatCurrency(
calculateAggregate(
filteredPurchases.map((p) => p.cost),
aggregateConfig.cost
) || 0
)}
</div>
</TableCell>
<TableCell className="text-right font-semibold">
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<span className="text-xs">ƒ</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(
[
"sum",
"avg",
"median",
"max",
"min",
] as AggregateFunction[]
).map((func) => (
<DropdownMenuItem
key={func}
onClick={() =>
setAggregateConfig({
...aggregateConfig,
subscriptions_count: func,
})
}
className={
aggregateConfig.subscriptions_count === func
? "bg-accent"
: ""
}
>
{getAggregateFunctionLabel(func)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{formatNumber(
calculateAggregate(
filteredPurchases.map((p) => p.subscriptions_count),
aggregateConfig.subscriptions_count
) || 0
)}
</div>
</TableCell>
<TableCell className="text-right font-semibold">
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<span className="text-xs">ƒ</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(
[
"sum",
"avg",
"median",
"max",
"min",
] as AggregateFunction[]
).map((func) => (
<DropdownMenuItem
key={func}
onClick={() =>
setAggregateConfig({
...aggregateConfig,
views_count: func,
})
}
className={
aggregateConfig.views_count === func
? "bg-accent"
: ""
}
>
{getAggregateFunctionLabel(func)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{formatNumber(
calculateAggregate(
filteredPurchases.map((p) => p.views_count),
aggregateConfig.views_count
) || 0
)}
</div>
</TableCell>
<TableCell className="text-right font-semibold">
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<span className="text-xs">ƒ</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(
[
"sum",
"avg",
"median",
"max",
"min",
] as AggregateFunction[]
).map((func) => (
<DropdownMenuItem
key={func}
onClick={() =>
setAggregateConfig({
...aggregateConfig,
cpm: func,
})
}
className={
aggregateConfig.cpm === func
? "bg-accent"
: ""
}
>
{getAggregateFunctionLabel(func)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{formatCurrency(
calculateAggregate(
filteredPurchases.map((p) => calculateCPM(p)),
aggregateConfig.cpm
) || 0
)}
</div>
</TableCell>
<TableCell className="text-right font-semibold">
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<span className="text-xs">ƒ</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(
[
"sum",
"avg",
"median",
"max",
"min",
] as AggregateFunction[]
).map((func) => (
<DropdownMenuItem
key={func}
onClick={() =>
setAggregateConfig({
...aggregateConfig,
cpl: func,
})
}
className={
aggregateConfig.cpl === func
? "bg-accent"
: ""
}
>
{getAggregateFunctionLabel(func)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{formatCurrency(
calculateAggregate(
filteredPurchases.map((p) => calculateCPL(p)),
aggregateConfig.cpl
) || 0
)}
</div>
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,302 @@
"use client";
// ============================================================================
// Channels Analytics Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2,
Building2,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { ChannelAnalyticsItem } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Types
// ============================================================================
type SortField =
| "placements"
| "cost"
| "subscriptions"
| "views"
| "cps"
| "cpm";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Component
// ============================================================================
export default function ChannelsAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { getProjectFilterId } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true);
const [sortField, setSortField] = useState<SortField | null>("cost");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
useEffect(() => {
loadData();
}, [workspaceId, projectFilterId]);
const loadData = async () => {
try {
setLoading(true);
const response = await demoAwareAnalyticsApi.channels(workspaceId, {
project_id: projectFilterId,
size: 100,
});
setChannels(response.items);
} catch (err) {
console.error("Failed to load channels analytics:", err);
} finally {
setLoading(false);
}
};
// Sort handler
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") setSortOrder("desc");
else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
} else setSortOrder("asc");
} else {
setSortField(field);
setSortOrder("desc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
return <ArrowUpDown className="h-4 w-4 ml-1" />;
};
// Sort data
const sortedChannels = [...channels].sort((a, b) => {
if (!sortField || !sortOrder) return 0;
let aVal = 0;
let bVal = 0;
switch (sortField) {
case "placements":
aVal = a.placements_count;
bVal = b.placements_count;
break;
case "cost":
aVal = a.total_cost;
bVal = b.total_cost;
break;
case "subscriptions":
aVal = a.total_subscriptions;
bVal = b.total_subscriptions;
break;
case "views":
aVal = a.total_views;
bVal = b.total_views;
break;
case "cps":
aVal = a.avg_cps ?? 0;
bVal = b.avg_cps ?? 0;
break;
case "cpm":
aVal = a.avg_cpm ?? 0;
bVal = b.avg_cpm ?? 0;
break;
}
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
{ label: "Каналы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Аналитика по каналам
</h1>
<p className="text-muted-foreground">
Эффективность каналов размещения
</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : sortedChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
<p className="text-muted-foreground text-center">
Создайте размещения для отображения статистики по каналам
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Каналы размещения</CardTitle>
<CardDescription>{sortedChannels.length} каналов</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Канал</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("placements")}
>
Размещений
{getSortIcon("placements")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
Расходы
{getSortIcon("cost")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("subscriptions")}
>
Подписчики
{getSortIcon("subscriptions")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("views")}
>
Просмотры
{getSortIcon("views")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cps")}
>
Ср. CPS
{getSortIcon("cps")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cpm")}
>
Ср. CPM
{getSortIcon("cpm")}
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedChannels.map((channel) => (
<TableRow key={channel.channel_id}>
<TableCell>
<div className="font-medium">{channel.channel_title}</div>
{channel.channel_username && (
<div className="text-xs text-muted-foreground">
@{channel.channel_username}
</div>
)}
</TableCell>
<TableCell>{channel.placements_count}</TableCell>
<TableCell>{formatCurrency(channel.total_cost)}</TableCell>
<TableCell>
{formatNumber(channel.total_subscriptions)}
</TableCell>
<TableCell>{formatNumber(channel.total_views)}</TableCell>
<TableCell>{formatCurrency(channel.avg_cps)}</TableCell>
<TableCell>{formatCurrency(channel.avg_cpm)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
// ============================================================================
// Creatives Analytics Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Folder,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { CreativeAnalyticsItem } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Types
// ============================================================================
type SortField =
| "placements"
| "cost"
| "subscriptions"
| "views"
| "cps"
| "cpm";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Component
// ============================================================================
export default function CreativesAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { getProjectFilterId } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [creatives, setCreatives] = useState<CreativeAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true);
const [sortField, setSortField] = useState<SortField | null>("cost");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
useEffect(() => {
loadData();
}, [workspaceId, projectFilterId]);
const loadData = async () => {
try {
setLoading(true);
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
project_id: projectFilterId,
size: 100,
});
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives analytics:", err);
} finally {
setLoading(false);
}
};
// Sort handler
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") setSortOrder("desc");
else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
} else setSortOrder("asc");
} else {
setSortField(field);
setSortOrder("desc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
return <ArrowUpDown className="h-4 w-4 ml-1" />;
};
// Sort data
const sortedCreatives = [...creatives].sort((a, b) => {
if (!sortField || !sortOrder) return 0;
let aVal = 0;
let bVal = 0;
switch (sortField) {
case "placements":
aVal = a.placements_count;
bVal = b.placements_count;
break;
case "cost":
aVal = a.total_cost;
bVal = b.total_cost;
break;
case "subscriptions":
aVal = a.total_subscriptions;
bVal = b.total_subscriptions;
break;
case "views":
aVal = a.total_views;
bVal = b.total_views;
break;
case "cps":
aVal = a.avg_cpf ?? 0;
bVal = b.avg_cpf ?? 0;
break;
case "cpm":
aVal = a.avg_cpm ?? 0;
bVal = b.avg_cpm ?? 0;
break;
}
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
{ label: "Креативы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Аналитика по креативам
</h1>
<p className="text-muted-foreground">
Сравнение эффективности рекламных материалов
</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : sortedCreatives.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
<p className="text-muted-foreground text-center">
Создайте размещения для отображения статистики по креативам
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Креативы</CardTitle>
<CardDescription>
{sortedCreatives.length} креативов с размещениями
</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Креатив</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("placements")}
>
Размещений
{getSortIcon("placements")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
Расходы
{getSortIcon("cost")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("subscriptions")}
>
Подписчики
{getSortIcon("subscriptions")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("views")}
>
Просмотры
{getSortIcon("views")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cps")}
>
Ср. CPS
{getSortIcon("cps")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cpm")}
>
Ср. CPM
{getSortIcon("cpm")}
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="font-medium hover:underline"
>
{creative.name}
</Link>
</TableCell>
<TableCell>{creative.placements_count}</TableCell>
<TableCell>{formatCurrency(creative.total_cost)}</TableCell>
<TableCell>
{formatNumber(creative.total_subscriptions)}
</TableCell>
<TableCell>{formatNumber(creative.total_views)}</TableCell>
<TableCell>{formatCurrency(creative.avg_cpf)}</TableCell>
<TableCell>{formatCurrency(creative.avg_cpm)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
// ============================================================================
// Analytics Overview Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Users,
Eye,
DollarSign,
ArrowRight,
BarChart3,
Info,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import type { SpendingAnalytics } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function AnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const {
selectedProjects,
getProjectFilterId,
allProjectsSelected,
projects
} = useWorkspace();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const projectFilterId = getProjectFilterId();
useEffect(() => {
loadAnalytics();
}, [workspaceId, projectFilterId]);
const loadAnalytics = async () => {
try {
setLoading(true);
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: projectFilterId,
grouping: "month",
});
setSpending(data);
} catch (err) {
console.error("Failed to load analytics:", err);
} finally {
setLoading(false);
}
};
// Calculate averages
const avgCps =
spending && spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions
: null;
const avgCpm =
spending && spending.total_views > 0
? (spending.total_cost / spending.total_views) * 1000
: null;
// Selection info text
const selectionText = allProjectsSelected
? "Все проекты"
: selectedProjects.length === 1
? selectedProjects[0].title
: `${selectedProjects.length} проекта`;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ 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-2xl font-bold tracking-tight">Аналитика</h1>
<p className="text-muted-foreground">
Обзор эффективности рекламных размещений
</p>
</div>
<Badge variant="outline" className="text-sm">
{selectionText}
</Badge>
</div>
{selectedProjects.length > 1 && !allProjectsSelected && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Выбрано {selectedProjects.length} проекта: {selectedProjects.map(p => p.title).join(", ")}.
Показана суммарная аналитика по всем выбранным проектам.
</AlertDescription>
</Alert>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего потрачено
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(spending?.total_cost ?? 0)}
</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(spending?.total_subscriptions ?? 0)}
</div>
<p className="text-xs text-muted-foreground">
Средний CPS: {avgCps ? formatCurrency(avgCps) : "—"}
</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>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(spending?.total_views ?? 0)}
</div>
<p className="text-xs text-muted-foreground">
Средний CPM: {avgCpm ? formatCurrency(avgCpm) : "—"}
</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">
{spending?.chart_data?.length ?? 0}
</div>
<p className="text-xs text-muted-foreground">месяцев</p>
</CardContent>
</Card>
</div>
{/* Quick Links */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="hover:bg-muted/50 transition-colors">
<Link href={`/dashboard/${workspaceId}/analytics/spending`}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Динамика расходов
<ArrowRight className="h-4 w-4" />
</CardTitle>
<CardDescription>
График расходов по времени с группировкой
</CardDescription>
</CardHeader>
</Link>
</Card>
<Card className="hover:bg-muted/50 transition-colors">
<Link href={`/dashboard/${workspaceId}/analytics/channels`}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Аналитика по каналам
<ArrowRight className="h-4 w-4" />
</CardTitle>
<CardDescription>
Эффективность каналов размещения
</CardDescription>
</CardHeader>
</Link>
</Card>
<Card className="hover:bg-muted/50 transition-colors">
<Link href={`/dashboard/${workspaceId}/analytics/creatives`}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Аналитика по креативам
<ArrowRight className="h-4 w-4" />
</CardTitle>
<CardDescription>
Сравнение эффективности креативов
</CardDescription>
</CardHeader>
</Link>
</Card>
</div>
{/* Recent Activity */}
{spending && spending.chart_data && spending.chart_data.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Последние периоды</CardTitle>
<CardDescription>Динамика по периодам</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{spending.chart_data.slice(0, 6).map((item, index) => (
<div
key={index}
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
>
<div>
<div className="font-medium">{item.period}</div>
<div className="text-sm text-muted-foreground">
{formatNumber(item.subscriptions)} подписчиков {" "}
{formatNumber(item.views)} просмотров
</div>
</div>
<div className="text-right">
<div className="font-medium">
{formatCurrency(item.cost)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,501 @@
"use client";
// ============================================================================
// Spending Analytics Page - Enhanced with Charts
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation";
import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info } from "lucide-react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
LabelList,
} from "recharts";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatShortCurrency = (value: number) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
return value.toString();
};
const formatDateLabel = (date: string, grouping: DateGrouping) => {
const d = new Date(date);
const g = grouping.toLowerCase();
if (g === "day") {
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
} else if (g === "week") {
return `Нед. ${d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`;
} else {
return d.toLocaleDateString("ru-RU", { month: "short", year: "2-digit" });
}
};
// ============================================================================
// Chart Types
// ============================================================================
type ChartType = "line" | "bar";
type MetricKey = "cost" | "subscriptions" | "views";
// ============================================================================
// Chart Configurations
// ============================================================================
const chartConfig: ChartConfig = {
cost: {
label: "Расходы",
color: "hsl(var(--chart-1))",
},
subscriptions: {
label: "Подписчики",
color: "hsl(var(--chart-2))",
},
views: {
label: "Просмотры",
color: "hsl(var(--chart-3))",
},
};
// ============================================================================
// Component
// ============================================================================
export default function SpendingAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [grouping, setGrouping] = useState<DateGrouping>("month");
// Chart settings
const [chartType, setChartType] = useState<ChartType>("line");
const [showLabels, setShowLabels] = useState(false);
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]);
useEffect(() => {
loadData();
}, [workspaceId, projectFilterId, grouping]);
const loadData = async () => {
try {
setLoading(true);
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: projectFilterId,
grouping,
});
setSpending(data);
} catch (err) {
console.error("Failed to load spending:", err);
} finally {
setLoading(false);
}
};
// Format chart data
const chartData = useMemo(() => {
if (!spending?.chart_data) return [];
return spending.chart_data.map((item) => ({
period: item.period,
dateLabel: formatDateLabel(item.period, grouping),
cost: item.cost,
subscriptions: item.subscriptions,
views: item.views,
}));
}, [spending, grouping]);
// Toggle metric
const handleToggleMetric = (metric: MetricKey) => {
if (selectedMetrics.includes(metric)) {
if (selectedMetrics.length > 1) {
setSelectedMetrics(selectedMetrics.filter((m) => m !== metric));
}
} else {
setSelectedMetrics([...selectedMetrics, metric]);
}
};
// Render chart
const renderChart = () => {
if (chartData.length === 0) return null;
const ChartComponent = chartType === "line" ? LineChart : BarChart;
return (
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
<ChartComponent
data={chartData}
margin={{ left: 12, right: 12, top: showLabels ? 20 : 12, bottom: 12 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={formatShortCurrency}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
return payload[0].payload.period;
}
return "";
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{selectedMetrics.map((metric) =>
chartType === "line" ? (
<Line
key={metric}
dataKey={metric}
type="monotone"
stroke={`var(--color-${metric})`}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
>
{showLabels && (
<LabelList
dataKey={metric}
position="top"
formatter={(value: number) =>
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
}
className="fill-foreground text-[10px]"
/>
)}
</Line>
) : (
<Bar
key={metric}
dataKey={metric}
fill={`var(--color-${metric})`}
radius={[4, 4, 0, 0]}
>
{showLabels && (
<LabelList
dataKey={metric}
position="top"
formatter={(value: number) =>
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
}
className="fill-foreground text-[10px]"
/>
)}
</Bar>
)
)}
</ChartComponent>
</ChartContainer>
);
};
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/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-2xl font-bold tracking-tight">Динамика расходов</h1>
<p className="text-muted-foreground">Анализ расходов по периодам</p>
</div>
<Select value={grouping} onValueChange={(v) => setGrouping(v as DateGrouping)}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
</SelectContent>
</Select>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : spending ? (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(spending.total_subscriptions)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(spending.total_views)}</div>
</CardContent>
</Card>
</div>
{/* Chart with Settings */}
{chartData.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>График динамики</CardTitle>
<CardDescription>
Визуализация данных по выбранным метрикам
</CardDescription>
</div>
{/* Chart Settings */}
<div className="flex items-center gap-4">
{/* Chart Type Toggle */}
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={chartType === "line" ? "secondary" : "ghost"}
size="sm"
onClick={() => setChartType("line")}
aria-label="Линейный график"
>
<LineChartIcon className="h-4 w-4" />
</Button>
<Button
variant={chartType === "bar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setChartType("bar")}
aria-label="Столбчатая диаграмма"
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
{/* Labels Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="show-labels"
checked={showLabels}
onCheckedChange={(checked) => setShowLabels(checked === true)}
/>
<Label htmlFor="show-labels" className="text-sm cursor-pointer">
Подписи
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Показывать значения на точках графика
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* Metrics Selection */}
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
<span className="text-sm text-muted-foreground">Метрики:</span>
{(["cost", "subscriptions", "views"] as MetricKey[]).map((metric) => (
<div key={metric} className="flex items-center gap-2">
<Checkbox
id={`metric-${metric}`}
checked={selectedMetrics.includes(metric)}
onCheckedChange={() => handleToggleMetric(metric)}
/>
<Label
htmlFor={`metric-${metric}`}
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: `var(--color-${metric})` }}
/>
{chartConfig[metric].label}
</Label>
</div>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">{renderChart()}</CardContent>
</Card>
)}
{/* Data Table */}
{spending.chart_data && spending.chart_data.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Данные по периодам</CardTitle>
<CardDescription>{spending.chart_data.length} периодов</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Subscription</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPM
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
</Tooltip>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spending.chart_data.map((item, index) => {
const cps = item.subscriptions > 0 ? item.cost / item.subscriptions : null;
const cpm = item.views > 0 ? (item.cost / item.views) * 1000 : null;
return (
<TableRow key={index}>
<TableCell className="font-medium">{item.period}</TableCell>
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
<TableCell className="text-right">
{formatNumber(item.subscriptions)}
</TableCell>
<TableCell className="text-right">{formatNumber(item.views)}</TableCell>
<TableCell className="text-right">
{cps ? formatCurrency(cps) : "—"}
</TableCell>
<TableCell className="text-right">
{cpm ? formatCurrency(cpm) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
<p className="text-muted-foreground text-center">
Создайте размещения для отображения статистики
</p>
</CardContent>
</Card>
)}
</>
) : null}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,384 @@
"use client";
// ============================================================================
// Channels Catalog Page - Enhanced with Sorting
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation";
import {
Loader2,
Search,
Building2,
Users,
ExternalLink,
Grid,
List,
ArrowUpDown,
ArrowUp,
ArrowDown,
Info,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { channelsApi } from "@/lib/api";
import { isDemoWorkspace, demoChannels } from "@/lib/demo";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { Channel } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Sort Types
// ============================================================================
type SortField = "title" | "username" | "subscribers";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Component
// ============================================================================
export default function ChannelsCatalogPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
useEffect(() => {
const loadChannels = async () => {
try {
setLoading(true);
// Demo mode - use mock data
const isDemo = isDemoWorkspace(workspaceId);
const response = isDemo
? { items: demoChannels.filter(c => !search || c.username?.includes(search) || c.title.includes(search)), total: demoChannels.length, page: 1, size: 100, pages: 1 }
: await channelsApi.list({
username: search || undefined,
size: 100,
});
setChannels(response.items);
} catch (err) {
console.error("Failed to load channels:", err);
} finally {
setLoading(false);
}
};
const debounce = setTimeout(loadChannels, 300);
return () => clearTimeout(debounce);
}, [search]);
// Handle sort
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") {
setSortOrder("desc");
} else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
} else {
setSortOrder("asc");
}
} else {
setSortField(field);
setSortOrder("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
return <ArrowUpDown className="h-4 w-4 ml-1" />;
};
// Filter and sort channels
const filteredChannels = useMemo(() => {
let result = [...channels];
// Sort
if (sortField && sortOrder) {
result.sort((a, b) => {
let aVal: string | number = "";
let bVal: string | number = "";
switch (sortField) {
case "title":
aVal = a.title.toLowerCase();
bVal = b.title.toLowerCase();
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
case "username":
aVal = (a.username || "").toLowerCase();
bVal = (b.username || "").toLowerCase();
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
case "subscribers":
aVal = a.subscribers_count || 0;
bVal = b.subscribers_count || 0;
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
}
return 0;
});
}
return result;
}, [channels, sortField, sortOrder]);
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Каталог каналов" },
]}
showProjectSelector={false}
/>
<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-2xl font-bold tracking-tight">Каталог каналов</h1>
<p className="text-muted-foreground">
{filteredChannels.length} каналов для размещения рекламы
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск по username..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : channels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Каналы не найдены</h3>
<p className="text-muted-foreground text-center">
{search
? "Попробуйте изменить поисковый запрос"
: "В каталоге пока нет каналов"}
</p>
</CardContent>
</Card>
) : viewMode === "table" ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("title")}
>
Канал
{getSortIcon("title")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("username")}
>
Username
{getSortIcon("username")}
</Button>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => handleSort("subscribers")}
>
Подписчики
{getSortIcon("subscribers")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help ml-1" />
</TooltipTrigger>
<TooltipContent>
Количество подписчиков на момент последнего обновления
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<Building2 className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="font-medium">{channel.title}</div>
{channel.description && (
<div className="text-xs text-muted-foreground line-clamp-1 max-w-[300px]">
{channel.description}
</div>
)}
</div>
</div>
</TableCell>
<TableCell>
{channel.username ? (
<a
href={`https://t.me/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1"
>
@{channel.username}
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{formatNumber(channel.subscribers_count)}
</div>
</TableCell>
<TableCell>
<Button variant="outline" size="sm" asChild>
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
Разместить
</a>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredChannels.map((channel) => (
<Card key={channel.id}>
<CardHeader>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">{channel.title}</CardTitle>
{channel.username && (
<CardDescription>
<a
href={`https://t.me/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-1"
>
@{channel.username}
<ExternalLink className="h-3 w-3" />
</a>
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent>
{channel.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{channel.description}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
{formatNumber(channel.subscribers_count)}
</div>
<Button variant="outline" size="sm" asChild>
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
Разместить
</a>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,366 @@
"use client";
// ============================================================================
// Creative Detail Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ArrowLeft,
Pencil,
Archive,
Trash2,
Copy,
Check,
ExternalLink,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { creativesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { Creative } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function CreativeDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const creativeId = params?.creativeId as string;
const { hasPermission } = useWorkspace();
const [creative, setCreative] = useState<Creative | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "true");
const [isSaving, setIsSaving] = useState(false);
const [copied, setCopied] = useState(false);
// Edit form state
const [name, setName] = useState("");
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadCreative();
}, [workspaceId, creativeId]);
const loadCreative = async () => {
try {
setLoading(true);
const data = await creativesApi.get(workspaceId, creativeId);
setCreative(data);
setName(data.name);
setText(data.text);
} catch (err) {
console.error("Failed to load creative:", err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!name.trim() || !text.trim()) {
setError("Заполните все поля");
return;
}
if (!text.includes("{invite_link}")) {
setError("Текст должен содержать {invite_link}");
return;
}
try {
setIsSaving(true);
setError(null);
const updated = await creativesApi.update(workspaceId, creativeId, {
name: name.trim(),
text: text.trim(),
});
setCreative(updated);
setIsEditing(false);
} catch (err: any) {
setError(err?.detail || "Не удалось сохранить");
} finally {
setIsSaving(false);
}
};
const handleArchive = async () => {
if (!creative) return;
try {
// Toggle archive status by updating via delete endpoint (soft delete)
await creativesApi.delete(workspaceId, creativeId);
router.push(`/dashboard/${workspaceId}/creatives`);
} catch (err) {
console.error("Failed to archive:", err);
}
};
const handleCopy = () => {
if (creative) {
navigator.clipboard.writeText(creative.text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCancel = () => {
if (creative) {
setName(creative.name);
setText(creative.text);
}
setIsEditing(false);
setError(null);
};
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (!creative) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
<div className="flex flex-col items-center justify-center py-12">
<h2 className="text-xl font-semibold mb-2">Креатив не найден</h2>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/creatives`}>
Вернуться к списку
</Link>
</Button>
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
{ label: creative.name },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-3xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/creatives`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">
{creative.name}
</h1>
<Badge
variant={
creative.status === "active" ? "default" : "secondary"
}
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</div>
<p className="text-muted-foreground">
{creative.placements_count} размещений
</p>
</div>
</div>
{canWrite && !isEditing && (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setIsEditing(true)}>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">
<Archive className="h-4 w-4 mr-2" />
В архив
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Архивировать креатив?</AlertDialogTitle>
<AlertDialogDescription>
Креатив будет перемещён в архив. Существующие размещения
останутся без изменений.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>
Архивировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
{isEditing ? (
<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={name}
onChange={(e) => setName(e.target.value)}
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="text">Текст креатива</Label>
<Textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isSaving}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Не забудьте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>
</p>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSaving}
>
Отмена
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Текст креатива</CardTitle>
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Скопировано
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Копировать
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
<pre className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-lg">
{creative.text.replace(
"{invite_link}",
"https://t.me/+AbCdEfGhIjK"
)}
</pre>
<p className="text-xs text-muted-foreground mt-2">
<code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
заменяется уникальной ссылкой при создании размещения
</p>
</CardContent>
</Card>
)}
{/* Quick Actions */}
{canWrite && creative.status === "active" && (
<Card>
<CardHeader>
<CardTitle>Действия</CardTitle>
</CardHeader>
<CardContent>
<Button asChild>
<Link
href={`/dashboard/${workspaceId}/placements/new?creative_id=${creative.id}`}
>
<ExternalLink className="h-4 w-4 mr-2" />
Создать размещение с этим креативом
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
// ============================================================================
// Create Creative Page
// ============================================================================
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2, ArrowLeft, Info } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { creativesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
// ============================================================================
// Component
// ============================================================================
export default function NewCreativePage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { currentProject } = useWorkspace();
const [name, setName] = useState("");
const [text, setText] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !text.trim()) {
setError("Заполните все обязательные поля");
return;
}
if (!text.includes("{invite_link}")) {
setError("Текст должен содержать {invite_link} для вставки ссылки");
return;
}
if (!currentProject) {
setError("Выберите проект в верхнем меню");
return;
}
try {
setIsSubmitting(true);
setError(null);
const creative = await creativesApi.create(
workspaceId,
currentProject.id,
{
name: name.trim(),
text: text.trim(),
}
);
router.push(`/dashboard/${workspaceId}/creatives/${creative.id}`);
} catch (err: any) {
setError(err?.detail || "Не удалось создать креатив");
} finally {
setIsSubmitting(false);
}
};
const insertInviteLink = () => {
if (!text.includes("{invite_link}")) {
setText((prev) => prev + "{invite_link}");
}
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
{ label: "Новый креатив" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">Новый креатив</h1>
<p className="text-muted-foreground">
Создайте рекламный материал для размещений
</p>
</div>
</div>
{!currentProject && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Выберите проект в верхнем меню для создания креатива
</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Информация о креативе</CardTitle>
<CardDescription>
Креатив это текст рекламного сообщения с пригласительной ссылкой
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Название <span className="text-destructive">*</span>
</Label>
<Input
id="name"
placeholder="Например: Летняя акция 2025"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Внутреннее название для удобства поиска
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="text">
Текст креатива <span className="text-destructive">*</span>
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={insertInviteLink}
disabled={text.includes("{invite_link}")}
>
Вставить {"{invite_link}"}
</Button>
</div>
<Textarea
id="text"
placeholder={`Пример:
🔥 Подпишись на наш канал!
Здесь ты найдёшь:
• Полезный контент
• Эксклюзивные материалы
• Актуальные новости
👉 {invite_link}`}
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isSubmitting}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Используйте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
в том месте, где должна быть пригласительная ссылка
</p>
</div>
{text.includes("{invite_link}") && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<span className="font-medium">Предпросмотр:</span>
<pre className="mt-2 whitespace-pre-wrap text-sm">
{text.replace(
"{invite_link}",
"https://t.me/+AbCdEfGhIjK"
)}
</pre>
</AlertDescription>
</Alert>
)}
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
Отмена
</Button>
<Button
type="submit"
disabled={
isSubmitting ||
!name.trim() ||
!text.trim() ||
!currentProject
}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
"Создать креатив"
)}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</>
);
}

View File

@@ -0,0 +1,569 @@
"use client";
// ============================================================================
// Creatives List Page - Enhanced with Statistics
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Search,
Plus,
Folder,
MoreHorizontal,
Archive,
Pencil,
Trash2,
Eye,
Copy,
TrendingUp,
Users,
ShoppingCart,
BarChart3,
Info,
Grid,
List,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { creativesApi } from "@/lib/api";
import { demoAwareCreativesApi, demoAwareAnalyticsApi } from "@/lib/demo";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
// ============================================================================
// Types
// ============================================================================
interface CreativeWithStats extends Creative {
analytics?: CreativeAnalyticsItem;
}
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function CreativesPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [creatives, setCreatives] = useState<Creative[]>([]);
const [analyticsData, setAnalyticsData] = useState<CreativeAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingAnalytics, setLoadingAnalytics] = useState(false);
const [search, setSearch] = useState("");
const [includeArchived, setIncludeArchived] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadCreatives();
}, [workspaceId, projectFilterId, includeArchived]);
useEffect(() => {
if (creatives.length > 0) {
loadAnalytics();
}
}, [creatives, workspaceId, projectFilterId]);
const loadCreatives = async () => {
try {
setLoading(true);
const response = await demoAwareCreativesApi.list(workspaceId, {
project_id: projectFilterId,
include_archived: includeArchived,
size: 100,
});
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives:", err);
} finally {
setLoading(false);
}
};
const loadAnalytics = async () => {
try {
setLoadingAnalytics(true);
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
project_id: projectFilterId,
size: 100,
});
setAnalyticsData(response.items);
} catch (err) {
console.error("Failed to load analytics:", err);
} finally {
setLoadingAnalytics(false);
}
};
// Merge creatives with analytics
const creativesWithStats: CreativeWithStats[] = useMemo(() => {
return creatives.map((creative) => ({
...creative,
analytics: analyticsData.find((a) => a.id === creative.id),
}));
}, [creatives, analyticsData]);
const handleArchive = async (creative: Creative) => {
try {
await creativesApi.update(workspaceId, creative.id, {});
loadCreatives();
} catch (err) {
console.error("Failed to archive creative:", err);
}
};
const handleDelete = async (creativeId: string) => {
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
try {
await creativesApi.delete(workspaceId, creativeId);
loadCreatives();
} catch (err) {
console.error("Failed to delete creative:", err);
}
};
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text);
};
// Filter by search
const filteredCreatives = creativesWithStats.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.text.toLowerCase().includes(search.toLowerCase())
);
// Totals
const totals = useMemo(() => {
const data = filteredCreatives.filter((c) => c.analytics);
return {
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0),
subscriptions: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0),
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
};
}, [filteredCreatives]);
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ 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-2xl font-bold tracking-tight">Креативы</h1>
<p className="text-muted-foreground">
{filteredCreatives.length} креативов {formatCurrency(totals.cost)} {" "}
{formatNumber(totals.subscriptions)} подписчиков
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
</div>
{canWrite && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Link>
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск по названию или тексту..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-archived"
checked={includeArchived}
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
/>
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
Показать архивные
</Label>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-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 py-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет креативов</h3>
<p className="text-muted-foreground text-center mb-4">
{search
? "Ничего не найдено по вашему запросу"
: "Создайте первый креатив для начала работы"}
</p>
{canWrite && !search && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Link>
</Button>
)}
</CardContent>
</Card>
) : viewMode === "table" ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Текст</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1">
Размещений
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Количество размещений с этим креативом
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Потрачено
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Сумма всех размещений с этим креативом
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Сред. CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Subscription средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="font-medium hover:underline"
>
{creative.name}
</Link>
</TableCell>
<TableCell>
<div className="max-w-[300px] truncate text-sm text-muted-foreground">
{creative.text.replace("{invite_link}", "[ссылка]")}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{creative.analytics?.placements_count ?? creative.placements_count}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.total_cost)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatNumber(creative.analytics?.total_subscriptions)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
)}
</TableCell>
<TableCell>
<Badge
variant={creative.status === "active" ? "default" : "secondary"}
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
>
<Eye className="h-4 w-4 mr-2" />
Просмотр
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4 mr-2" />
Аналитика
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
<Copy className="h-4 w-4 mr-2" />
Копировать текст
</DropdownMenuItem>
{canWrite && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArchive(creative)}>
<Archive className="h-4 w-4 mr-2" />
{creative.status === "active" ? "В архив" : "Восстановить"}
</DropdownMenuItem>
{creative.placements_count === 0 && (
<DropdownMenuItem
onClick={() => handleDelete(creative.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCreatives.map((creative) => (
<Card key={creative.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="hover:underline"
>
{creative.name}
</Link>
</CardTitle>
<CardDescription className="line-clamp-2 mt-1">
{creative.text.replace("{invite_link}", "[ссылка]")}
</CardDescription>
</div>
<Badge
variant={creative.status === "active" ? "default" : "secondary"}
className="ml-2 shrink-0"
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats */}
{creative.analytics && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(creative.analytics.total_cost)}
</div>
<div className="text-xs text-muted-foreground">Потрачено</div>
</div>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_views)}
</div>
<div className="text-xs text-muted-foreground">Просмотров</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(creative.analytics.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">Сред. CPS</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
<Eye className="h-4 w-4 mr-1" />
Подробнее
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,29 @@
// ============================================================================
// Workspace Dashboard Layout
// ============================================================================
import { AppSidebar } from "@/components/layout/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { WorkspaceProvider } from "@/components/providers/workspace-provider";
import { DashboardLayoutContent } from "@/components/layout/dashboard-layout-content";
export default function WorkspaceDashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProtectedRoute>
<WorkspaceProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<DashboardLayoutContent>{children}</DashboardLayoutContent>
</SidebarInset>
</SidebarProvider>
</WorkspaceProvider>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
// ============================================================================
// Dashboard Home Page
// ============================================================================
import { useEffect, useState } from "react";
import { Loader2, ShoppingCart, Users, TrendingUp, BarChart3 } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { SpendingAnalytics } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined, showSign = false): string => {
if (value === null || value === undefined) return "—";
const formatted = new Intl.NumberFormat("ru-RU").format(value);
if (showSign && value > 0) return `+${formatted}`;
return formatted;
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function DashboardPage() {
const { currentWorkspace, currentProject, isLoading: workspaceLoading, projects } = useWorkspace();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!currentWorkspace) {
setLoading(false);
return;
}
const loadData = async () => {
try {
setLoading(true);
const data = await demoAwareAnalyticsApi.spending(currentWorkspace.id, {
project_id: currentProject?.id,
});
setSpending(data);
} catch (err) {
console.error("Failed to load spending:", err);
} finally {
setLoading(false);
}
};
loadData();
}, [currentWorkspace?.id, currentProject?.id]);
if (workspaceLoading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (!currentWorkspace) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Нет доступных воркспейсов</h2>
<p className="text-muted-foreground">
Создайте новый воркспейс для начала работы
</p>
</div>
</div>
</>
);
}
// Calculate totals and averages
const totalCost = spending?.total_cost ?? 0;
const totalSubscriptions = spending?.total_subscriptions ?? 0;
const totalViews = spending?.total_views ?? 0;
const avgCps = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null;
const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null;
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<div className="text-2xl font-bold">{formatCurrency(totalCost)}</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>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<div className="text-2xl font-bold">{formatNumber(totalSubscriptions)}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPS</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<div className="text-2xl font-bold">{formatCurrency(avgCps)}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<div className="text-2xl font-bold">{formatCurrency(avgCpm)}</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Быстрый старт</CardTitle>
<CardDescription>Начните с основных действий</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<a
href={`/dashboard/${currentWorkspace.id}/placements/new`}
className="block text-sm text-primary hover:underline"
>
Создать размещение
</a>
<a
href={`/dashboard/${currentWorkspace.id}/creatives/new`}
className="block text-sm text-primary hover:underline"
>
Создать креатив
</a>
<a
href={`/dashboard/${currentWorkspace.id}/analytics`}
className="block text-sm text-primary hover:underline"
>
Посмотреть аналитику
</a>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Проекты</CardTitle>
<CardDescription>
{projects.length > 0
? `${projects.length} активных проектов`
: "Нет активных проектов"}
</CardDescription>
</CardHeader>
<CardContent>
{projects.length > 0 ? (
<div className="space-y-2">
{projects.slice(0, 5).map((project) => (
<div
key={project.id}
className="flex items-center justify-between text-sm"
>
<span className="font-medium">{project.title}</span>
{project.username && (
<span className="text-muted-foreground">@{project.username}</span>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Добавьте бота в канал через Telegram для создания проекта
</p>
)}
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,431 @@
"use client";
// ============================================================================
// Placement Detail Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ArrowLeft,
ExternalLink,
Copy,
Check,
Archive,
Eye,
Users,
TrendingUp,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { placementsApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
};
const formatDateTime = (dateString: string | null | undefined) => {
if (!dateString) return "—";
return new Date(dateString).toLocaleString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCurrency = (value: number | null | undefined) => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null | undefined) => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function PlacementDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const placementId = params?.placementId as string;
const { hasPermission } = useWorkspace();
const [placement, setPlacement] = useState<Placement | null>(null);
const [loading, setLoading] = useState(true);
const [copiedLink, setCopiedLink] = useState(false);
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadPlacement();
}, [workspaceId, placementId]);
const loadPlacement = async () => {
try {
setLoading(true);
const data = await placementsApi.get(workspaceId, placementId);
setPlacement(data);
} catch (err) {
console.error("Failed to load placement:", err);
} finally {
setLoading(false);
}
};
const handleArchive = async () => {
try {
await placementsApi.delete(workspaceId, placementId);
router.push(`/dashboard/${workspaceId}/placements`);
} catch (err) {
console.error("Failed to archive:", err);
}
};
const handleCopyLink = () => {
if (placement?.invite_link) {
navigator.clipboard.writeText(placement.invite_link);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
}
};
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (!placement) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
<div className="flex flex-col items-center justify-center py-12">
<h2 className="text-xl font-semibold mb-2">Размещение не найдено</h2>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/placements`}>
Вернуться к списку
</Link>
</Button>
</div>
</>
);
}
// Calculate metrics
const cps =
placement.cost && placement.subscriptions_count > 0
? placement.cost / placement.subscriptions_count
: null;
const cpm =
placement.cost && placement.views_count
? (placement.cost / placement.views_count) * 1000
: null;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ label: placement.placement_channel_title },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/placements`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">
{placement.placement_channel_title}
</h1>
<Badge
variant={
placement.status === "active" ? "default" : "secondary"
}
>
{placement.status === "active" ? "Активно" : "Архив"}
</Badge>
</div>
<p className="text-muted-foreground">
{formatDate(placement.placement_date)}
</p>
</div>
</div>
{canWrite && placement.status === "active" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">
<Archive className="h-4 w-4 mr-2" />
В архив
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Архивировать размещение?</AlertDialogTitle>
<AlertDialogDescription>
Размещение будет перемещено в архив. Статистика сохранится.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>
Архивировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(placement.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(placement.subscriptions_count)}
</div>
<p className="text-xs text-muted-foreground">
CPS: {cps ? formatCurrency(cps) : "—"}
</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>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(placement.views_count)}
</div>
<p className="text-xs text-muted-foreground">
CPM: {cpm ? formatCurrency(cpm) : "—"}
</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">
{placement.invite_link_type === "approval"
? "С заявками"
: "Публичная"}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Invite Link */}
<Card>
<CardHeader>
<CardTitle>Пригласительная ссылка</CardTitle>
<CardDescription>
{placement.invite_link_type === "approval"
? "С одобрением — бот отслеживает подписчиков"
: "Публичная ссылка"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
{placement.invite_link}
</code>
<Button variant="outline" size="icon" onClick={handleCopyLink}>
{copiedLink ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button variant="outline" size="icon" asChild>
<a
href={placement.invite_link}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</CardContent>
</Card>
{/* Creative */}
<Card>
<CardHeader>
<CardTitle>Креатив</CardTitle>
<CardDescription>{placement.creative_name}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
>
Просмотреть креатив
</Link>
</Button>
</CardContent>
</Card>
</div>
{/* Details */}
<Card>
<CardHeader>
<CardTitle>Детали размещения</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Проект
</dt>
<dd className="text-sm">{placement.project_channel_title}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Дата размещения
</dt>
<dd className="text-sm">{formatDate(placement.placement_date)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Статус просмотров
</dt>
<dd className="text-sm">
<Badge variant="outline">
{placement.views_availability === "available"
? "Автообновление"
: placement.views_availability === "manual"
? "Ручной ввод"
: placement.views_availability === "unavailable"
? "Недоступно"
: "Не проверено"}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Последнее обновление просмотров
</dt>
<dd className="text-sm">
{formatDateTime(placement.last_views_fetch_at)}
</dd>
</div>
{placement.ad_post_url && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground">
Ссылка на пост
</dt>
<dd className="text-sm">
<a
href={placement.ad_post_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{placement.ad_post_url}
<ExternalLink className="h-3 w-3" />
</a>
</dd>
</div>
)}
{placement.comment && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground">
Комментарий
</dt>
<dd className="text-sm">{placement.comment}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,393 @@
"use client";
// ============================================================================
// Create Placement Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Loader2, ArrowLeft, Info, Calendar as CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { placementsApi, creativesApi, channelsApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { Creative, Channel, InviteLinkType } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function NewPlacementPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const { currentProject } = useWorkspace();
// Pre-filled from URL params
const prefilledCreativeId = searchParams.get("creative_id");
const prefilledChannelId = searchParams.get("channel_id");
// Form state
const [channelId, setChannelId] = useState(prefilledChannelId || "");
const [creativeId, setCreativeId] = useState(prefilledCreativeId || "");
const [placementDate, setPlacementDate] = useState<Date>(new Date());
const [cost, setCost] = useState("");
const [comment, setComment] = useState("");
const [adPostUrl, setAdPostUrl] = useState("");
const [inviteLinkType, setInviteLinkType] = useState<InviteLinkType>("approval");
// Data
const [channels, setChannels] = useState<Channel[]>([]);
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loadingData, setLoadingData] = useState(true);
// Submit state
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadData();
}, [workspaceId, currentProject?.id]);
const loadData = async () => {
try {
setLoadingData(true);
const [channelsRes, creativesRes] = await Promise.all([
channelsApi.list({ size: 100 }),
currentProject
? creativesApi.list(workspaceId, {
project_id: currentProject.id,
include_archived: false,
size: 100,
})
: Promise.resolve({ items: [] }),
]);
setChannels(channelsRes.items);
setCreatives(creativesRes.items);
} catch (err) {
console.error("Failed to load data:", err);
} finally {
setLoadingData(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentProject) {
setError("Выберите проект в верхнем меню");
return;
}
if (!channelId || !creativeId) {
setError("Выберите канал и креатив");
return;
}
try {
setIsSubmitting(true);
setError(null);
const placement = await placementsApi.create(workspaceId, {
project_id: currentProject.id,
placement_channel_id: channelId,
creative_id: creativeId,
placement_date: placementDate.toISOString(),
cost: cost ? parseFloat(cost) : undefined,
comment: comment || undefined,
ad_post_url: adPostUrl || undefined,
invite_link_type: inviteLinkType,
});
router.push(`/dashboard/${workspaceId}/placements/${placement.id}`);
} catch (err: any) {
setError(err?.detail || "Не удалось создать размещение");
} finally {
setIsSubmitting(false);
}
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ label: "Новое размещение" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">Новое размещение</h1>
<p className="text-muted-foreground">
Создайте размещение рекламы в Telegram канале
</p>
</div>
</div>
{!currentProject && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Выберите проект в верхнем меню для создания размещения
</AlertDescription>
</Alert>
)}
{loadingData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Параметры размещения</CardTitle>
<CardDescription>
Укажите канал, креатив и условия размещения
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Channel */}
<div className="space-y-2">
<Label>
Канал для размещения <span className="text-destructive">*</span>
</Label>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex flex-col">
<span>{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">
@{channel.username}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Creative */}
<div className="space-y-2">
<Label>
Креатив <span className="text-destructive">*</span>
</Label>
<Select value={creativeId} onValueChange={setCreativeId}>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
Нет доступных креативов
</div>
) : (
creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{creatives.length === 0 && currentProject && (
<p className="text-xs text-muted-foreground">
Сначала{" "}
<a
href={`/dashboard/${workspaceId}/creatives/new`}
className="text-primary hover:underline"
>
создайте креатив
</a>
</p>
)}
</div>
{/* Placement Date */}
<div className="space-y-2">
<Label>
Дата размещения <span className="text-destructive">*</span>
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!placementDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{placementDate
? format(placementDate, "PPP", { locale: ru })
: "Выберите дату"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={placementDate}
onSelect={(date) => date && setPlacementDate(date)}
locale={ru}
/>
</PopoverContent>
</Popover>
</div>
{/* Cost */}
<div className="space-y-2">
<Label htmlFor="cost">Стоимость ()</Label>
<Input
id="cost"
type="number"
placeholder="0"
value={cost}
onChange={(e) => setCost(e.target.value)}
disabled={isSubmitting}
/>
</div>
{/* Invite Link Type */}
<div className="space-y-2">
<Label>Тип пригласительной ссылки</Label>
<Select
value={inviteLinkType}
onValueChange={(v) => setInviteLinkType(v as InviteLinkType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approval">
<div className="flex flex-col">
<span>С одобрением (рекомендуется)</span>
<span className="text-xs text-muted-foreground">
Бот одобряет заявки и отслеживает подписчиков
</span>
</div>
</SelectItem>
<SelectItem value="public">
<div className="flex flex-col">
<span>Публичная ссылка</span>
<span className="text-xs text-muted-foreground">
Без отслеживания подписчиков
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Ad Post URL */}
<div className="space-y-2">
<Label htmlFor="adPostUrl">Ссылка на рекламный пост</Label>
<Input
id="adPostUrl"
placeholder="https://t.me/channel/123"
value={adPostUrl}
onChange={(e) => setAdPostUrl(e.target.value)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Ссылка на пост после публикации (можно добавить позже)
</p>
</div>
{/* Comment */}
<div className="space-y-2">
<Label htmlFor="comment">Комментарий</Label>
<Textarea
id="comment"
placeholder="Дополнительная информация..."
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={isSubmitting}
rows={3}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
Отмена
</Button>
<Button
type="submit"
disabled={
isSubmitting ||
!channelId ||
!creativeId ||
!currentProject
}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
"Создать размещение"
)}
</Button>
</div>
</CardContent>
</Card>
</form>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,742 @@
"use client";
// ============================================================================
// Placements List Page - Enhanced with Totals Row and Export
// ============================================================================
import { useEffect, useState, useMemo, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Search,
Plus,
ShoppingCart,
MoreHorizontal,
Eye,
ExternalLink,
ArrowUpDown,
ArrowUp,
ArrowDown,
Download,
FileSpreadsheet,
FileText,
Info,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableFooter,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
});
};
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const calculateCPS = (cost: number | null, subscriptions: number) => {
if (!cost || subscriptions === 0) return null;
return cost / subscriptions;
};
const calculateCPM = (cost: number | null | undefined, views: number | null | undefined) => {
if (!cost || !views || views === 0) return null;
return (cost / views) * 1000;
};
// ============================================================================
// Sort Types
// ============================================================================
type SortField = "date" | "cost" | "subscriptions" | "views" | "cps" | "cpm" | "status";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Aggregation Functions
// ============================================================================
type AggFunc = "sum" | "avg" | "median" | "max" | "min";
const aggregationFunctions: Record<AggFunc, { label: string; fn: (values: number[]) => number | null }> = {
sum: {
label: "Сумма",
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) : null),
},
avg: {
label: "Среднее",
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : null),
},
median: {
label: "Медиана",
fn: (values) => {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
},
},
max: {
label: "Макс.",
fn: (values) => (values.length > 0 ? Math.max(...values) : null),
},
min: {
label: "Мин.",
fn: (values) => (values.length > 0 ? Math.min(...values) : null),
},
};
// ============================================================================
// Export Functions
// ============================================================================
const exportToCSV = (placements: Placement[], filename: string) => {
const headers = [
"Канал",
"Username",
"Креатив",
"Дата",
"Стоимость",
"Подписки",
"Просмотры",
"CPS",
"CPM",
"Статус",
"Ссылка на пост",
"Пригласительная ссылка",
];
const rows = placements.map((p) => [
p.placement_channel_title,
"",
p.creative_name,
new Date(p.placement_date).toISOString().split("T")[0],
p.cost?.toString() || "",
p.subscriptions_count.toString(),
p.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
p.status,
p.ad_post_url || "",
p.invite_link,
]);
const csv = [headers.join(";"), ...rows.map((r) => r.join(";"))].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
downloadBlob(blob, `${filename}.csv`);
};
const exportToExcel = (placements: Placement[], filename: string) => {
// Simple XLSX-like TSV format (opens in Excel)
const headers = [
"Канал",
"Username",
"Креатив",
"Дата",
"Стоимость",
"Подписки",
"Просмотры",
"CPS",
"CPM",
"Статус",
];
const rows = placements.map((p) => [
p.placement_channel_title,
"",
p.creative_name,
new Date(p.placement_date).toISOString().split("T")[0],
p.cost?.toString() || "",
p.subscriptions_count.toString(),
p.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
p.status,
]);
const tsv = [headers.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
const blob = new Blob(["\uFEFF" + tsv], {
type: "application/vnd.ms-excel;charset=utf-8;",
});
downloadBlob(blob, `${filename}.xls`);
};
const exportToTxt = (placements: Placement[], filename: string) => {
const lines = placements.map((p) => {
const cps = calculateCPS(p.cost, p.subscriptions_count);
const cpm = calculateCPM(p.cost, p.views_count);
return [
`Канал: ${p.placement_channel_title}`,
`Креатив: ${p.creative_name}`,
`Дата: ${formatDate(p.placement_date)}`,
`Стоимость: ${formatCurrency(p.cost)}`,
`Подписки: ${p.subscriptions_count}`,
`Просмотры: ${p.views_count || "—"}`,
`CPS: ${cps ? formatCurrency(cps) : "—"}`,
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
`Ссылка: ${p.invite_link}`,
"---",
].join("\n");
});
const text = lines.join("\n");
const blob = new Blob([text], { type: "text/plain;charset=utf-8;" });
downloadBlob(blob, `${filename}.txt`);
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// ============================================================================
// Component
// ============================================================================
export default function PlacementsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [includeArchived, setIncludeArchived] = useState(false);
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
// Aggregation settings
const [aggCost, setAggCost] = useState<AggFunc>("sum");
const [aggSubs, setAggSubs] = useState<AggFunc>("sum");
const [aggViews, setAggViews] = useState<AggFunc>("sum");
const [aggCps, setAggCps] = useState<AggFunc>("avg");
const [aggCpm, setAggCpm] = useState<AggFunc>("avg");
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadPlacements();
}, [workspaceId, projectFilterId, includeArchived]);
const loadPlacements = async () => {
try {
setLoading(true);
const response = await demoAwarePlacementsApi.list(workspaceId, {
project_id: projectFilterId,
include_archived: includeArchived,
size: 100,
});
setPlacements(response.items);
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
// Handle sort
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") {
setSortOrder("desc");
} else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
} else {
setSortOrder("asc");
}
} else {
setSortField(field);
setSortOrder("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
return <ArrowUpDown className="h-4 w-4 ml-1" />;
};
// Filter and sort
const filteredPlacements = useMemo(() => {
return placements
.filter(
(p) =>
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) ||
p.creative_name.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => {
if (!sortField || !sortOrder) return 0;
let aVal: number | string = 0;
let bVal: number | string = 0;
switch (sortField) {
case "date":
return sortOrder === "asc"
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime()
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime();
case "cost":
aVal = a.cost ?? 0;
bVal = b.cost ?? 0;
break;
case "subscriptions":
aVal = a.subscriptions_count;
bVal = b.subscriptions_count;
break;
case "views":
aVal = a.views_count ?? 0;
bVal = b.views_count ?? 0;
break;
case "cps":
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0;
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0;
break;
case "cpm":
aVal = calculateCPM(a.cost, a.views_count) ?? 0;
bVal = calculateCPM(b.cost, b.views_count) ?? 0;
break;
case "status":
aVal = a.status;
bVal = b.status;
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return sortOrder === "asc"
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number);
});
}, [placements, search, sortField, sortOrder]);
// Calculate totals with aggregation functions
const totals = useMemo(() => {
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null);
const subs = filteredPlacements.map((p) => p.subscriptions_count);
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null);
const cpsValues = filteredPlacements
.map((p) => calculateCPS(p.cost, p.subscriptions_count))
.filter((c): c is number => c !== null);
const cpmValues = filteredPlacements
.map((p) => calculateCPM(p.cost, p.views_count))
.filter((c): c is number => c !== null);
return {
cost: aggregationFunctions[aggCost].fn(costs),
subscriptions: aggregationFunctions[aggSubs].fn(subs),
views: aggregationFunctions[aggViews].fn(views),
cps: aggregationFunctions[aggCps].fn(cpsValues),
cpm: aggregationFunctions[aggCpm].fn(cpmValues),
};
}, [filteredPlacements, aggCost, aggSubs, aggViews, aggCps, aggCpm]);
// Export handlers
const handleExport = useCallback(
(format: "csv" | "excel" | "txt") => {
const filename = `placements_${new Date().toISOString().split("T")[0]}`;
switch (format) {
case "csv":
exportToCSV(filteredPlacements, filename);
break;
case "excel":
exportToExcel(filteredPlacements, filename);
break;
case "txt":
exportToTxt(filteredPlacements, filename);
break;
}
},
[filteredPlacements]
);
// Aggregation selector component
const AggSelector = ({
value,
onChange,
}: {
value: AggFunc;
onChange: (v: AggFunc) => void;
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5 ml-1 opacity-50 hover:opacity-100">
<span className="text-[10px] font-mono">f</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Функция</DropdownMenuLabel>
{(Object.keys(aggregationFunctions) as AggFunc[]).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => onChange(key)}
className={value === key ? "bg-accent" : ""}
>
{aggregationFunctions[key].label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ 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-2xl font-bold tracking-tight">Размещения</h1>
<p className="text-muted-foreground">
{filteredPlacements.length} размещений
</p>
</div>
<div className="flex items-center gap-2">
{/* Export Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Экспорт
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("excel")}>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Excel (.xls)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("csv")}>
<FileText className="h-4 w-4 mr-2" />
CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("txt")}>
<FileText className="h-4 w-4 mr-2" />
Текст (.txt)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canWrite && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/placements/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск по каналу или креативу..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-archived"
checked={includeArchived}
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
/>
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
Показать архивные
</Label>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredPlacements.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ShoppingCart className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет размещений</h3>
<p className="text-muted-foreground text-center mb-4">
{search ? "Ничего не найдено по вашему запросу" : "Создайте первое размещение"}
</p>
{canWrite && !search && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/placements/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Канал</TableHead>
<TableHead>Креатив</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("date")}
>
Дата
{getSortIcon("date")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
Стоимость
{getSortIcon("cost")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("subscriptions")}
>
Подписки
{getSortIcon("subscriptions")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("views")}
>
Просмотры
{getSortIcon("views")}
</Button>
</TableHead>
<TableHead>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cps")}
>
CPS
{getSortIcon("cps")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Subscription</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cpm")}
>
CPM
{getSortIcon("cpm")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("status")}
>
Статус
{getSortIcon("status")}
</Button>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlacements.map((placement) => {
const cps = calculateCPS(placement.cost, placement.subscriptions_count);
const cpm = calculateCPM(placement.cost, placement.views_count);
return (
<TableRow key={placement.id}>
<TableCell>
<div className="font-medium">{placement.placement_channel_title}</div>
</TableCell>
<TableCell>
<div className="text-sm">{placement.creative_name}</div>
</TableCell>
<TableCell>{formatDate(placement.placement_date)}</TableCell>
<TableCell>{formatCurrency(placement.cost)}</TableCell>
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell>
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell>
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
<TableCell>
<Badge
variant={placement.status === "active" ? "default" : "secondary"}
>
{placement.status === "active" ? "Активен" : "Архив"}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/${workspaceId}/placements/${placement.id}`}>
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Link>
</DropdownMenuItem>
{placement.ad_post_url && (
<DropdownMenuItem asChild>
<a
href={placement.ad_post_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-2" />
Открыть пост
</a>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
{/* Totals Row */}
<TableFooter>
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={3}>
<span className="text-muted-foreground">Итого</span>
</TableCell>
<TableCell>
<div className="flex items-center">
{formatCurrency(totals.cost)}
<AggSelector value={aggCost} onChange={setAggCost} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{formatNumber(totals.subscriptions)}
<AggSelector value={aggSubs} onChange={setAggSubs} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{formatNumber(totals.views)}
<AggSelector value={aggViews} onChange={setAggViews} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{totals.cps ? formatCurrency(totals.cps) : "—"}
<AggSelector value={aggCps} onChange={setAggCps} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{totals.cpm ? formatCurrency(totals.cpm) : "—"}
<AggSelector value={aggCpm} onChange={setAggCpm} />
</div>
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,458 @@
"use client";
// ============================================================================
// Projects Page (Target Channels) - Enhanced with Statistics
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Tv,
ExternalLink,
Grid,
List,
TrendingUp,
Users,
Eye,
ShoppingCart,
Info,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api";
// ============================================================================
// Types
// ============================================================================
interface ProjectWithStats extends Project {
stats?: {
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cps: number | null;
};
}
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function ProjectsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
// Load statistics for each project
useEffect(() => {
if (projects.length === 0) {
setProjectsWithStats([]);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
const statsPromises = projects.map(async (project) => {
try {
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: project.id,
});
return {
...project,
stats: {
total_cost: spending.total_cost,
total_subscriptions: spending.total_subscriptions,
total_views: spending.total_views,
avg_cps:
spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions
: null,
},
};
} catch {
return { ...project, stats: undefined };
}
});
const results = await Promise.all(statsPromises);
setProjectsWithStats(results);
} catch (err) {
console.error("Failed to load project stats:", err);
setProjectsWithStats(projects);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [projects, workspaceId]);
const isLoading = isLoadingProjects || loadingStats;
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${currentWorkspace?.id}` },
{ label: "Проекты" },
]}
showProjectSelector={false}
/>
<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-2xl font-bold tracking-tight">Проекты</h1>
<p className="text-muted-foreground">
Telegram каналы, которые вы продвигаете
</p>
</div>
<div className="flex items-center gap-2 border rounded-lg p-1">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
<Alert>
<Tv className="h-4 w-4" />
<AlertDescription>
Чтобы добавить новый проект, добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
администратором в ваш Telegram канал.
</AlertDescription>
</Alert>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projectsWithStats.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Tv className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center max-w-md">
Добавьте бота администратором в ваш Telegram канал, и он
автоматически появится здесь как проект.
</p>
</CardContent>
</Card>
) : viewMode === "table" ? (
// Table View
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Канал</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Потрачено
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Сумма всех размещений по проекту
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики по пригласительным ссылкам
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Просмотры
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Суммарные просмотры рекламных постов
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Сред. CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Subscription средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="w-[140px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectsWithStats.map((project) => (
<TableRow key={project.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Tv className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium">{project.title}</div>
{project.username && (
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:underline inline-flex items-center gap-0.5"
>
@{project.username}
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
>
{project.status === "active"
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(project.stats?.total_cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_views)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.stats?.avg_cps)}
</TableCell>
<TableCell>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
>
План закупа
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => (
<Card key={project.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
{project.username && (
<CardDescription>
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-0.5"
>
@{project.username}
<ExternalLink className="h-2.5 w-2.5" />
</a>
</CardDescription>
)}
</div>
</div>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
>
{project.status === "active"
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
{project.stats && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.total_cost)}
</div>
<div className="text-xs text-muted-foreground">
Потрачено
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Подписчиков
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_views)}
</div>
<div className="text-xs text-muted-foreground">
Просмотров
</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.avg_cps)}
</div>
<div className="text-xs text-muted-foreground">
Сред. CPS
</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
{project.username && (
<Button variant="outline" size="sm" asChild>
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-1" />
Канал
</a>
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
>
План закупа
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,742 @@
"use client";
// ============================================================================
// Purchase Plan Detail Page
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Plus,
ArrowLeft,
MoreHorizontal,
Pencil,
Trash2,
ExternalLink,
Clock,
CheckCircle,
XCircle,
ArrowUpDown,
ShoppingCart,
RefreshCw,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { purchasePlanApi, channelsApi } from "@/lib/api";
import { demoAwarePurchasePlanApi, isDemoWorkspace, demoChannels } from "@/lib/demo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableFooter,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { Project, PurchasePlanChannel, Channel } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const getStatusBadge = (status: string) => {
switch (status) {
case "planned":
return (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Запланировано
</Badge>
);
case "completed":
return (
<Badge variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
Завершено
</Badge>
);
case "cancelled":
return (
<Badge variant="secondary" className="gap-1">
<XCircle className="h-3 w-3" />
Отменено
</Badge>
);
default:
return <Badge variant="outline">{status}</Badge>;
}
};
type SortField = "channel" | "status" | "cost";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Component
// ============================================================================
export default function PurchasePlanDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string;
const { projects, hasPermission } = useWorkspace();
const [planChannels, setPlanChannels] = useState<PurchasePlanChannel[]>([]);
const [loading, setLoading] = useState(true);
const [project, setProject] = useState<Project | null>(null);
// Add channel dialog
const [showAddDialog, setShowAddDialog] = useState(false);
const [availableChannels, setAvailableChannels] = useState<Channel[]>([]);
const [loadingChannels, setLoadingChannels] = useState(false);
const [selectedChannelId, setSelectedChannelId] = useState("");
const [plannedCost, setPlannedCost] = useState("");
const [comment, setComment] = useState("");
const [isAdding, setIsAdding] = useState(false);
// Edit dialog
const [editingChannel, setEditingChannel] = useState<PurchasePlanChannel | null>(null);
const [editPlannedCost, setEditPlannedCost] = useState("");
const [editComment, setEditComment] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
// Delete confirmation
const [deletingChannel, setDeletingChannel] = useState<PurchasePlanChannel | null>(null);
// Sorting
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
const canWrite = hasPermission("placements_write");
useEffect(() => {
const proj = projects.find((p) => p.id === projectId);
if (proj) {
setProject(proj);
}
}, [projects, projectId]);
useEffect(() => {
loadPlanChannels();
}, [workspaceId, projectId]);
const loadPlanChannels = async () => {
try {
setLoading(true);
const response = await demoAwarePurchasePlanApi.list(workspaceId, projectId, { size: 100 });
setPlanChannels(response.items);
} catch (err) {
console.error("Failed to load purchase plan:", err);
} finally {
setLoading(false);
}
};
const loadAvailableChannels = async () => {
try {
setLoadingChannels(true);
// In demo mode use mock channels
const isDemo = isDemoWorkspace(workspaceId);
const response = isDemo
? { items: demoChannels, total: demoChannels.length, page: 1, size: 100, pages: 1 }
: await channelsApi.list({ size: 100 });
// Filter out channels already in plan
const existingIds = new Set(planChannels.map((pc) => pc.channel.id));
setAvailableChannels(response.items.filter((c) => !existingIds.has(c.id)));
} catch (err) {
console.error("Failed to load channels:", err);
} finally {
setLoadingChannels(false);
}
};
const handleOpenAddDialog = () => {
loadAvailableChannels();
setSelectedChannelId("");
setPlannedCost("");
setComment("");
setShowAddDialog(true);
};
const handleAddChannel = async () => {
if (!selectedChannelId) return;
try {
setIsAdding(true);
await purchasePlanApi.create(workspaceId, projectId, {
channel_id: selectedChannelId,
planned_cost: plannedCost ? parseFloat(plannedCost) : undefined,
comment: comment || undefined,
});
setShowAddDialog(false);
loadPlanChannels();
} catch (err) {
console.error("Failed to add channel:", err);
} finally {
setIsAdding(false);
}
};
const handleOpenEditDialog = (channel: PurchasePlanChannel) => {
setEditingChannel(channel);
setEditPlannedCost(channel.planned_cost?.toString() || "");
setEditComment(channel.comment || "");
};
const handleUpdateChannel = async () => {
if (!editingChannel) return;
try {
setIsUpdating(true);
await purchasePlanApi.update(workspaceId, projectId, editingChannel.id, {
planned_cost: editPlannedCost ? parseFloat(editPlannedCost) : undefined,
comment: editComment || undefined,
});
setEditingChannel(null);
loadPlanChannels();
} catch (err) {
console.error("Failed to update channel:", err);
} finally {
setIsUpdating(false);
}
};
const handleDeleteChannel = async () => {
if (!deletingChannel) return;
try {
await purchasePlanApi.delete(workspaceId, projectId, deletingChannel.id);
setDeletingChannel(null);
loadPlanChannels();
} catch (err) {
console.error("Failed to delete channel:", err);
}
};
// Sorting
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") setSortOrder("desc");
else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
}
} else {
setSortField(field);
setSortOrder("asc");
}
};
const sortedChannels = useMemo(() => {
if (!sortField || !sortOrder) return planChannels;
return [...planChannels].sort((a, b) => {
let aVal: string | number = 0;
let bVal: string | number = 0;
switch (sortField) {
case "channel":
aVal = a.channel.title.toLowerCase();
bVal = b.channel.title.toLowerCase();
break;
case "status":
aVal = a.status;
bVal = b.status;
break;
case "cost":
aVal = a.planned_cost ?? 0;
bVal = b.planned_cost ?? 0;
break;
}
if (typeof aVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(bVal as string)
: (bVal as string).localeCompare(aVal);
}
return sortOrder === "asc" ? aVal - (bVal as number) : (bVal as number) - aVal;
});
}, [planChannels, sortField, sortOrder]);
// Stats
const stats = useMemo(() => {
const planned = planChannels.filter((c) => c.status === "planned");
return {
totalChannels: planChannels.length,
plannedCount: planned.length,
completedCount: planChannels.filter((c) => c.status === "completed").length,
totalPlannedCost: planned.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
};
}, [planChannels]);
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} showProjectSelector={false} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Планы закупок", href: `/dashboard/${workspaceId}/purchase-plans` },
{ label: project?.title || "План закупок" },
]}
showProjectSelector={false}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/purchase-plans`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
План закупок: {project?.title}
</h1>
<p className="text-muted-foreground">
{stats.totalChannels} каналов Планируемый бюджет:{" "}
{formatCurrency(stats.totalPlannedCost)}
</p>
</div>
</div>
{canWrite && (
<div className="flex gap-2">
<Button variant="outline" onClick={loadPlanChannels}>
<RefreshCw className="h-4 w-4 mr-2" />
Обновить
</Button>
<Button onClick={handleOpenAddDialog}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
</div>
)}
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Всего каналов
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalChannels}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Запланировано
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.plannedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Завершено
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Планируемый бюджет
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(stats.totalPlannedCost)}
</div>
</CardContent>
</Card>
</div>
{/* Table */}
{planChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ShoppingCart className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">План пуст</h3>
<p className="text-muted-foreground text-center mb-4">
Добавьте каналы для размещения рекламы
</p>
{canWrite && (
<Button onClick={handleOpenAddDialog}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("channel")}
>
Канал
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("status")}
>
Статус
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead className="text-right">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
План. стоимость
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedChannels.map((planChannel) => (
<TableRow key={planChannel.id}>
<TableCell>
<div>
<div className="font-medium">{planChannel.channel.title}</div>
{planChannel.channel.username && (
<a
href={`https://t.me/${planChannel.channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
@{planChannel.channel.username}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</TableCell>
<TableCell>{getStatusBadge(planChannel.status)}</TableCell>
<TableCell className="text-right">
{formatCurrency(planChannel.planned_cost)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{planChannel.comment || "—"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/placements/new?channel_id=${planChannel.channel.id}`}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</DropdownMenuItem>
{canWrite && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleOpenEditDialog(planChannel)}
>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeletingChannel(planChannel)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} className="font-bold">
Итого запланировано:
</TableCell>
<TableCell className="text-right font-bold">
{formatCurrency(stats.totalPlannedCost)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
)}
</div>
{/* Add Channel Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Добавить канал в план</DialogTitle>
<DialogDescription>
Выберите канал из каталога для добавления в план закупок
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Канал</Label>
{loadingChannels ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка каналов...
</div>
) : (
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{availableChannels.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
Все каналы уже добавлены
</div>
) : (
availableChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex flex-col">
<span>{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">
@{channel.username}
</span>
)}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label htmlFor="planned-cost">Планируемая стоимость ()</Label>
<Input
id="planned-cost"
type="number"
placeholder="0"
value={plannedCost}
onChange={(e) => setPlannedCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="comment">Комментарий</Label>
<Textarea
id="comment"
placeholder="Дополнительная информация..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
Отмена
</Button>
<Button
onClick={handleAddChannel}
disabled={!selectedChannelId || isAdding}
>
{isAdding ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Добавление...
</>
) : (
"Добавить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={!!editingChannel} onOpenChange={(open) => !open && setEditingChannel(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать канал</DialogTitle>
<DialogDescription>
{editingChannel?.channel.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-planned-cost">Планируемая стоимость ()</Label>
<Input
id="edit-planned-cost"
type="number"
placeholder="0"
value={editPlannedCost}
onChange={(e) => setEditPlannedCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-comment">Комментарий</Label>
<Textarea
id="edit-comment"
placeholder="Дополнительная информация..."
value={editComment}
onChange={(e) => setEditComment(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingChannel(null)}>
Отмена
</Button>
<Button onClick={handleUpdateChannel} disabled={isUpdating}>
{isUpdating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={!!deletingChannel}
onOpenChange={(open) => !open && setDeletingChannel(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить канал из плана?</AlertDialogTitle>
<AlertDialogDescription>
Канал "{deletingChannel?.channel.title}" будет удалён из плана закупок.
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteChannel}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
// ============================================================================
// Purchase Plans Overview Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ClipboardList,
ChevronRight,
Tv,
Calendar,
DollarSign,
CheckCircle,
Clock,
XCircle,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePurchasePlanApi } from "@/lib/demo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { Project, PurchasePlanChannel } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
interface ProjectPlanStats {
totalChannels: number;
plannedChannels: number;
completedChannels: number;
cancelledChannels: number;
totalPlannedCost: number;
}
// ============================================================================
// Component
// ============================================================================
export default function PurchasePlansPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects } = useWorkspace();
const [projectStats, setProjectStats] = useState<Record<string, ProjectPlanStats>>({});
const [loadingStats, setLoadingStats] = useState(true);
useEffect(() => {
if (projects.length > 0) {
loadAllStats();
} else {
setLoadingStats(false);
}
}, [projects, workspaceId]);
const loadAllStats = async () => {
setLoadingStats(true);
const stats: Record<string, ProjectPlanStats> = {};
for (const project of projects) {
try {
const response = await demoAwarePurchasePlanApi.list(workspaceId, project.id, { size: 100 });
const channels = response.items;
stats[project.id] = {
totalChannels: channels.length,
plannedChannels: channels.filter((c) => c.status === "planned").length,
completedChannels: channels.filter((c) => c.status === "completed").length,
cancelledChannels: channels.filter((c) => c.status === "cancelled").length,
totalPlannedCost: channels
.filter((c) => c.status === "planned")
.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
};
} catch (err) {
console.error(`Failed to load plan for project ${project.id}:`, err);
stats[project.id] = {
totalChannels: 0,
plannedChannels: 0,
completedChannels: 0,
cancelledChannels: 0,
totalPlannedCost: 0,
};
}
}
setProjectStats(stats);
setLoadingStats(false);
};
const isLoading = isLoadingProjects || loadingStats;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Планы закупок" },
]}
showProjectSelector={false}
/>
<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-2xl font-bold tracking-tight">Планы закупок</h1>
<p className="text-muted-foreground">
Управление планами закупок по проектам
</p>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center mb-4">
Добавьте проект через Telegram бота для создания плана закупок
</p>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/projects`}>
Перейти к проектам
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const stats = projectStats[project.id];
return (
<Card
key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() =>
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
{project.username && (
<CardDescription>@{project.username}</CardDescription>
)}
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{stats ? (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Всего каналов:</span>
<span className="font-medium">{stats.totalChannels}</span>
</div>
<div className="flex gap-2 flex-wrap">
{stats.plannedChannels > 0 && (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
{stats.plannedChannels} план.
</Badge>
)}
{stats.completedChannels > 0 && (
<Badge variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
{stats.completedChannels} заверш.
</Badge>
)}
{stats.cancelledChannels > 0 && (
<Badge variant="secondary" className="gap-1">
<XCircle className="h-3 w-3" />
{stats.cancelledChannels} отмен.
</Badge>
)}
</div>
{stats.totalPlannedCost > 0 && (
<div className="flex items-center gap-2 text-sm pt-2 border-t">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">План. бюджет:</span>
<span className="font-medium ml-auto">
{formatCurrency(stats.totalPlannedCost)}
</span>
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">Загрузка...</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
// ============================================================================
// Workspace Invites Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Loader2, UserPlus, Mail, Check, X, Clock } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { invitesApi } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { WorkspaceInvite } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function InvitesPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const [invites, setInvites] = useState<WorkspaceInvite[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [username, setUsername] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadInvites();
}, [workspaceId]);
const loadInvites = async () => {
try {
setLoading(true);
const response = await invitesApi.list(workspaceId, { size: 100 });
setInvites(response.items);
} catch (err) {
console.error("Failed to load invites:", err);
} finally {
setLoading(false);
}
};
const handleCreateInvite = async () => {
if (!username.trim()) return;
try {
setIsCreating(true);
setError(null);
// Remove @ if present
const cleanUsername = username.trim().replace(/^@/, "");
const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
});
setInvites((prev) => [invite, ...prev]);
setShowDialog(false);
setUsername("");
} catch (err: any) {
setError(err?.error?.message || "Не удалось отправить приглашение");
} finally {
setIsCreating(false);
}
};
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
switch (status) {
case "pending":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "accepted":
return <Check className="h-4 w-4 text-green-500" />;
case "rejected":
return <X className="h-4 w-4 text-red-500" />;
}
};
const getStatusText = (status: WorkspaceInvite["status"]) => {
switch (status) {
case "pending":
return "Ожидает";
case "accepted":
return "Принято";
case "rejected":
return "Отклонено";
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
});
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
{ label: "Приглашения" },
]}
showProjectSelector={false}
/>
<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-2xl font-bold tracking-tight">Приглашения</h1>
<p className="text-muted-foreground">
Приглашения в воркспейс через Telegram
</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
Пригласить
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Пригласить участника</DialogTitle>
<DialogDescription>
Введите username Telegram пользователя для отправки
приглашения через бота
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="username">Telegram username</Label>
<Input
id="username"
placeholder="@username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateInvite();
}
}}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDialog(false)}
>
Отмена
</Button>
<Button
onClick={handleCreateInvite}
disabled={!username.trim() || isCreating}
>
{isCreating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Mail className="h-4 w-4 mr-2" />
)}
Отправить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : invites.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет приглашений</h3>
<p className="text-muted-foreground text-center">
Пригласите участников в воркспейс через Telegram
</p>
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Дата</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((invite) => (
<TableRow key={invite.id}>
<TableCell>
<div className="flex items-center gap-2">
<UserPlus className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">@{invite.username}</span>
</div>
</TableCell>
<TableCell>
<Badge
variant={
invite.status === "pending"
? "outline"
: invite.status === "accepted"
? "default"
: "destructive"
}
className="gap-1"
>
{getStatusIcon(invite.status)}
{getStatusText(invite.status)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(invite.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
// ============================================================================
// Workspace Members Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Loader2, Users, Shield, ChevronDown } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { membersApi } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { WorkspaceMember, PermissionKey } from "@/lib/types/api";
// ============================================================================
// Constants
// ============================================================================
const PERMISSION_LABELS: Record<PermissionKey, string> = {
admin_full: "Полный доступ",
projects_read: "Просмотр проектов",
projects_write: "Редактирование проектов",
placements_read: "Просмотр размещений",
placements_write: "Редактирование размещений",
analytics_read: "Просмотр аналитики",
};
const ALL_PERMISSIONS: PermissionKey[] = [
"admin_full",
"projects_read",
"projects_write",
"placements_read",
"placements_write",
"analytics_read",
];
// ============================================================================
// Component
// ============================================================================
export default function MembersPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const [members, setMembers] = useState<WorkspaceMember[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
useEffect(() => {
const loadMembers = async () => {
try {
setLoading(true);
const response = await membersApi.list(workspaceId, { size: 100 });
setMembers(response.items);
} catch (err) {
console.error("Failed to load members:", err);
} finally {
setLoading(false);
}
};
loadMembers();
}, [workspaceId]);
const handlePermissionChange = async (
memberId: string,
userId: string,
permission: PermissionKey,
enabled: boolean
) => {
try {
setUpdating(memberId);
const member = members.find((m) => m.id === memberId);
if (!member) return;
let newPermissions = [...member.permissions];
if (enabled) {
// Add permission
if (!newPermissions.some((p) => p.key === permission)) {
newPermissions.push({ key: permission });
}
} else {
// Remove permission
newPermissions = newPermissions.filter((p) => p.key !== permission);
}
await membersApi.updatePermissions(workspaceId, userId, {
permissions: newPermissions,
});
// Update local state
setMembers((prev) =>
prev.map((m) =>
m.id === memberId ? { ...m, permissions: newPermissions } : m
)
);
} catch (err) {
console.error("Failed to update permissions:", err);
} finally {
setUpdating(null);
}
};
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
return member.permissions.some((p) => p.key === key);
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
{ label: "Участники" },
]}
showProjectSelector={false}
/>
<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-2xl font-bold tracking-tight">Участники</h1>
<p className="text-muted-foreground">
Управление участниками и правами доступа
</p>
</div>
<Button asChild>
<a href={`/dashboard/${workspaceId}/settings/invites`}>
Пригласить участника
</a>
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : members.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет участников</h3>
<p className="text-muted-foreground text-center">
Пригласите участников в воркспейс
</p>
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Права доступа</TableHead>
<TableHead className="w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium">
{member.user.username || `User ${member.user.telegram_id}`}
</div>
{member.user.username && (
<div className="text-xs text-muted-foreground">
@{member.user.username}
</div>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
member.status === "active" ? "default" : "secondary"
}
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{hasPermission(member, "admin_full") ? (
<Badge variant="destructive" className="gap-1">
<Shield className="h-3 w-3" />
Админ
</Badge>
) : (
member.permissions.map((p) => (
<Badge key={p.key} variant="outline">
{PERMISSION_LABELS[p.key]}
</Badge>
))
)}
{member.permissions.length === 0 && (
<span className="text-sm text-muted-foreground">
Нет прав
</span>
)}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={updating === member.id}
>
{updating === member.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Права
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Права доступа</DropdownMenuLabel>
<DropdownMenuSeparator />
{ALL_PERMISSIONS.map((perm) => (
<DropdownMenuCheckboxItem
key={perm}
checked={hasPermission(member, perm)}
onCheckedChange={(checked) =>
handlePermissionChange(
member.id,
member.user.id,
perm,
checked
)
}
>
{PERMISSION_LABELS[perm]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
// ============================================================================
// Workspace Settings Page
// ============================================================================
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2, Settings, Pencil, Trash2 } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { workspacesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
export default function SettingsPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
const [name, setName] = useState(currentWorkspace?.name || "");
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleUpdate = async () => {
if (!name.trim() || name === currentWorkspace?.name) return;
try {
setIsUpdating(true);
await workspacesApi.update(workspaceId, { name: name.trim() });
await refreshWorkspaces();
} catch (err) {
console.error("Failed to update workspace:", err);
} finally {
setIsUpdating(false);
}
};
const handleDelete = async () => {
try {
setIsDeleting(true);
await workspacesApi.delete(workspaceId);
router.replace("/");
} catch (err) {
console.error("Failed to delete workspace:", err);
} finally {
setIsDeleting(false);
}
};
if (!currentWorkspace) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Настройки" }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Настройки" },
]}
showProjectSelector={false}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Настройки воркспейса</h1>
<p className="text-muted-foreground">
Управление настройками воркспейса
</p>
</div>
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>Основные настройки</CardTitle>
<CardDescription>
Название и основные параметры воркспейса
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Название</Label>
<div className="flex gap-2">
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Название воркспейса"
/>
<Button
onClick={handleUpdate}
disabled={
!name.trim() ||
name === currentWorkspace.name ||
isUpdating
}
>
{isUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Pencil className="h-4 w-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Team Management Links */}
<Card>
<CardHeader>
<CardTitle>Команда</CardTitle>
<CardDescription>
Управление участниками и приглашениями
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start" asChild>
<a href={`/dashboard/${workspaceId}/settings/members`}>
Участники воркспейса
</a>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<a href={`/dashboard/${workspaceId}/settings/invites`}>
Приглашения
</a>
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Опасная зона</CardTitle>
<CardDescription>
Необратимые действия с воркспейсом
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" />
Удалить воркспейс
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить воркспейс?</AlertDialogTitle>
<AlertDialogDescription>
Это действие необратимо. Все данные воркспейса будут
удалены, включая проекты, креативы, размещения и аналитику.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
// ============================================================================
// Onboarding Page - First Workspace Creation
// ============================================================================
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Building2, Loader2, Megaphone } from "lucide-react";
import { workspacesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { STORAGE_KEYS } from "@/lib/utils/constants";
export default function OnboardingPage() {
const router = useRouter();
const [name, setName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!name.trim()) return;
try {
setIsCreating(true);
setError(null);
const workspace = await workspacesApi.create({ name: name.trim() });
// Save as selected workspace
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
// Redirect to dashboard
router.replace(`/dashboard/${workspace.id}`);
} catch (err: any) {
setError(err?.error?.message || "Не удалось создать воркспейс");
} finally {
setIsCreating(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Megaphone className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl">Добро пожаловать в TGEX!</CardTitle>
<CardDescription>
Создайте свой первый воркспейс для начала работы
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Название воркспейса</Label>
<Input
id="workspace-name"
placeholder="Например: Моя команда"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreate();
}
}}
disabled={isCreating}
/>
<p className="text-xs text-muted-foreground">
Воркспейс это изолированное пространство для вашей команды. Вы
сможете пригласить участников и настроить права доступа.
</p>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={handleCreate}
disabled={!name.trim() || isCreating}
>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание...
</>
) : (
<>
<Building2 className="mr-2 h-4 w-4" />
Создать воркспейс
</>
)}
</Button>
</CardFooter>
</Card>
</div>
);
}

59
app/page.tsx Normal file
View File

@@ -0,0 +1,59 @@
"use client";
// ============================================================================
// Root Page - Redirect to Dashboard
// ============================================================================
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { authApi, workspacesApi } from "@/lib/api";
import { STORAGE_KEYS } from "@/lib/utils/constants";
export default function RootPage() {
const router = useRouter();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkAuthAndRedirect = async () => {
// Check if user is authenticated
const token = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
if (!token) {
router.replace("/login");
return;
}
try {
// Verify token is valid
await authApi.me();
// Get workspaces
const { items: workspaces } = await workspacesApi.list({ size: 1 });
if (workspaces.length > 0) {
// Check for saved workspace
const savedId = localStorage.getItem(STORAGE_KEYS.SELECTED_WORKSPACE);
const workspaceId = savedId || workspaces[0].id;
router.replace(`/dashboard/${workspaceId}`);
} else {
// No workspaces - show onboarding or create first workspace
router.replace("/dashboard/onboarding");
}
} catch (err) {
// Token invalid - redirect to login
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
router.replace("/login");
}
};
checkAuthAndRedirect();
}, [router]);
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -7,7 +7,6 @@
import React from "react";
import { AuthProvider } from "@/components/auth/auth-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { TargetChannelProvider } from "@/components/providers/target-channel-provider";
interface ProvidersProps {
children: React.ReactNode;
@@ -21,9 +20,7 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<TargetChannelProvider>{children}</TargetChannelProvider>
</AuthProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
);
};