feat: update purchcases page
This commit is contained in:
@@ -65,7 +65,9 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<h2 className="text-2xl font-semibold mb-2">
|
||||
Выберите целевой канал
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Для начала работы выберите целевой канал в верхнем меню
|
||||
</p>
|
||||
@@ -96,8 +98,7 @@ export default function DashboardPage() {
|
||||
{formatNumber(spending?.total_cost)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(0 || 0, true)} за
|
||||
прошлый месяц
|
||||
{formatNumber(0 || 0, true)} за прошлый месяц
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -120,8 +121,7 @@ export default function DashboardPage() {
|
||||
{formatNumber(spending?.total_subscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(0 || 0, true)} за
|
||||
прошлый месяц
|
||||
{formatNumber(0 || 0, true)} за прошлый месяц
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -142,8 +142,7 @@ export default function DashboardPage() {
|
||||
₽{formatMetric(spending?.avg_cpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPercent(0 || 0, true)} от прошлого
|
||||
месяца
|
||||
{formatPercent(0 || 0, true)} от прошлого месяца
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -164,8 +163,7 @@ export default function DashboardPage() {
|
||||
₽{formatMetric(spending?.avg_cpm)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPercent(0 || 0, true)} от прошлого
|
||||
месяца
|
||||
{formatPercent(0 || 0, true)} от прошлого месяца
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,14 @@ import {
|
||||
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,
|
||||
@@ -51,10 +58,36 @@ import {
|
||||
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();
|
||||
@@ -63,6 +96,15 @@ export default function PurchasesPage() {
|
||||
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) {
|
||||
@@ -88,24 +130,337 @@ export default function PurchasesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPurchases = purchases.filter((purchase) => {
|
||||
// Поиск
|
||||
const matchesSearch =
|
||||
purchase.external_channel_title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
// Вычисление метрик
|
||||
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;
|
||||
};
|
||||
|
||||
// Фильтр по статусу
|
||||
let matchesStatus = true;
|
||||
if (filterStatus === "archived") {
|
||||
matchesStatus = purchase.status === "archived";
|
||||
} else if (filterStatus === "active") {
|
||||
matchesStatus = purchase.status === "active";
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
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 = {
|
||||
@@ -132,12 +487,14 @@ export default function PurchasesPage() {
|
||||
Рекламные размещения во внешних каналах
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/purchases/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать закуп
|
||||
</Link>
|
||||
</Button>
|
||||
<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 && (
|
||||
@@ -147,60 +504,8 @@ export default function PurchasesPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего закупов
|
||||
</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(stats.total)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.active} активных, {stats.archived} в архиве
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Общие затраты
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.totalCost)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
За все закупы
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Подписчиков привлечено
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(stats.totalSubscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Фильтры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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" />
|
||||
@@ -234,9 +539,31 @@ export default function PurchasesPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Список закупов</CardTitle>
|
||||
<CardDescription>Все рекламные размещения</CardDescription>
|
||||
<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 ? (
|
||||
@@ -261,64 +588,372 @@ export default function PurchasesPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Статус
|
||||
{getSortIcon("status")}
|
||||
</TableHead>
|
||||
<TableHead>Целевой канал</TableHead>
|
||||
<TableHead>Внешний канал</TableHead>
|
||||
<TableHead>Креатив</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Стоимость</TableHead>
|
||||
<TableHead className="text-right">Подписки</TableHead>
|
||||
<TableHead className="text-right">Просмотры</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) => (
|
||||
<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">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{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>
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Plus,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
ArrowUpRightIcon,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||
@@ -49,7 +48,6 @@ export const TargetChannelSelector: React.FC = () => {
|
||||
// Автоматический запуск polling при открытии диалога
|
||||
useEffect(() => {
|
||||
if (isAddDialogOpen && !isSuccess && initialChannelCount > 0) {
|
||||
// Polling каждую секунду
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
checkForNewChannels();
|
||||
}, 1000);
|
||||
@@ -71,20 +69,15 @@ export const TargetChannelSelector: React.FC = () => {
|
||||
);
|
||||
|
||||
if (activeChannels.length > initialChannelCount) {
|
||||
// Новый канал добавлен!
|
||||
setIsSuccess(true);
|
||||
|
||||
// Останавливаем polling
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Закрываем диалог через 2 секунды
|
||||
setTimeout(() => {
|
||||
setIsAddDialogOpen(false);
|
||||
setIsSuccess(false);
|
||||
// Обновляем список каналов через провайдер
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
@@ -99,16 +92,15 @@ export const TargetChannelSelector: React.FC = () => {
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
if (!isSuccess) {
|
||||
// Не даем закрыть диалог во время ожидания
|
||||
return;
|
||||
}
|
||||
setIsAddDialogOpen(false);
|
||||
setIsSuccess(false);
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
// Разрешаем закрытие всегда (в том числе через кнопку крестик)
|
||||
setIsAddDialogOpen(false);
|
||||
setIsSuccess(false);
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,10 +195,11 @@ export const TargetChannelSelector: React.FC = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={handleDialogClose}>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onInteractOutside={(e) => !isSuccess && e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => !isSuccess && e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Добавить целевой канал</DialogTitle>
|
||||
@@ -244,6 +237,10 @@ export const TargetChannelSelector: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
|
||||
tabIndex={0}
|
||||
aria-label={`Открыть бота @${BOT_USERNAME} в Telegram`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{BOT_USERNAME}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
|
||||
Reference in New Issue
Block a user