From 790fd5cd5b33adbdb4de7a841d777f6683238f94 Mon Sep 17 00:00:00 2001
From: ivannoskov
Date: Mon, 1 Dec 2025 06:27:37 +0300
Subject: [PATCH] feat: update purchcases page
---
app/(dashboard)/page.tsx | 16 +-
app/(dashboard)/purchases/page.tsx | 889 +++++++++++++++++++++----
components/target-channel-selector.tsx | 33 +-
3 files changed, 784 insertions(+), 154 deletions(-)
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index 6ec05cf..983e3d8 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -65,7 +65,9 @@ export default function DashboardPage() {
-
Выберите целевой канал
+
+ Выберите целевой канал
+
Для начала работы выберите целевой канал в верхнем меню
@@ -96,8 +98,7 @@ export default function DashboardPage() {
{formatNumber(spending?.total_cost)}
- {formatNumber(0 || 0, true)} за
- прошлый месяц
+ {formatNumber(0 || 0, true)} за прошлый месяц
>
)}
@@ -120,8 +121,7 @@ export default function DashboardPage() {
{formatNumber(spending?.total_subscriptions)}
- {formatNumber(0 || 0, true)} за
- прошлый месяц
+ {formatNumber(0 || 0, true)} за прошлый месяц
>
)}
@@ -142,8 +142,7 @@ export default function DashboardPage() {
₽{formatMetric(spending?.avg_cpf)}
- {formatPercent(0 || 0, true)} от прошлого
- месяца
+ {formatPercent(0 || 0, true)} от прошлого месяца
>
)}
@@ -164,8 +163,7 @@ export default function DashboardPage() {
₽{formatMetric(spending?.avg_cpm)}
- {formatPercent(0 || 0, true)} от прошлого
- месяца
+ {formatPercent(0 || 0, true)} от прошлого месяца
>
)}
diff --git a/app/(dashboard)/purchases/page.tsx b/app/(dashboard)/purchases/page.tsx
index 99612c4..896d22b 100644
--- a/app/(dashboard)/purchases/page.tsx
+++ b/app/(dashboard)/purchases/page.tsx
@@ -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(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState("all");
+ const [sortField, setSortField] = useState(null);
+ const [sortDirection, setSortDirection] = useState(null);
+ const [aggregateConfig, setAggregateConfig] = useState({
+ 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 ;
+ }
+ if (sortDirection === "asc") {
+ return ;
+ }
+ return ;
+ };
+
+ 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() {
Рекламные размещения во внешних каналах
-
+
+
+
{error && (
@@ -147,60 +504,8 @@ export default function PurchasesPage() {
)}
-
-
-
-
- Всего закупов
-
-
-
-
-
- {formatNumber(stats.total)}
-
-
- {stats.active} активных, {stats.archived} в архиве
-
-
-
-
-
-
-
- Общие затраты
-
-
-
-
- {formatCurrency(stats.totalCost)}
-
-
- За все закупы
-
-
-
-
-
-
-
- Подписчиков привлечено
-
-
-
-
- {formatNumber(stats.totalSubscriptions)}
-
- Всего
-
-
-
-
-
- Фильтры
-
-
+
@@ -234,9 +539,31 @@ export default function PurchasesPage() {
-
- Список закупов
- Все рекламные размещения
+
+
+ Список закупов
+ Все рекламные размещения
+
+
+
+
+
+
+
+
+ Экспорт в Excel (.xlsx)
+
+
+ Экспорт в CSV (.csv)
+
+
+ Экспорт в TXT (.txt)
+
+
+
{loading ? (
@@ -261,64 +588,372 @@ export default function PurchasesPage() {
- Статус
+ handleSort("status")}
+ >
+ Статус
+ {getSortIcon("status")}
+
Целевой канал
Внешний канал
Креатив
- Дата
- Стоимость
- Подписки
- Просмотры
+ handleSort("placement_date")}
+ >
+ Дата
+ {getSortIcon("placement_date")}
+
+ handleSort("cost")}
+ >
+ Стоимость
+ {getSortIcon("cost")}
+
+ handleSort("subscriptions_count")}
+ >
+ Подписки
+ {getSortIcon("subscriptions_count")}
+
+ handleSort("views_count")}
+ >
+ Просмотры
+ {getSortIcon("views_count")}
+
+ handleSort("cpm")}
+ >
+ CPM
+ {getSortIcon("cpm")}
+
+ handleSort("cpl")}
+ >
+ CPL
+ {getSortIcon("cpl")}
+
- {filteredPurchases.map((purchase) => (
-
-
- {purchase.status === "archived" ? (
-
-
- Архив
-
- ) : (
-
-
- Активно
-
- )}
-
-
- {purchase.target_channel_title}
-
- {purchase.external_channel_title}
-
- {purchase.creative_name}
-
-
- {formatDate(purchase.placement_date)}
-
-
- {formatCurrency(purchase.cost)}
-
-
- {formatNumber(purchase.subscriptions_count)}
-
-
- {purchase.views_count
- ? formatNumber(purchase.views_count)
- : "—"}
-
-
-
-
-
- ))}
+ {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)}
+
+
+ {formatCurrency(purchase.cost)}
+
+
+ {formatNumber(purchase.subscriptions_count)}
+
+
+ {purchase.views_count
+ ? formatNumber(purchase.views_count)
+ : "—"}
+
+
+ {cpm !== null ? formatCurrency(cpm) : "—"}
+
+
+ {cpl !== null ? formatCurrency(cpl) : "—"}
+
+
+
+
+
+ );
+ })}
+
+
+
+ Итого
+
+
+
+
+
+
+
+
+ {(
+ [
+ "sum",
+ "avg",
+ "median",
+ "max",
+ "min",
+ ] as AggregateFunction[]
+ ).map((func) => (
+
+ setAggregateConfig({
+ ...aggregateConfig,
+ cost: func,
+ })
+ }
+ className={
+ aggregateConfig.cost === func
+ ? "bg-accent"
+ : ""
+ }
+ >
+ {getAggregateFunctionLabel(func)}
+
+ ))}
+
+
+ {formatCurrency(
+ calculateAggregate(
+ filteredPurchases.map((p) => p.cost),
+ aggregateConfig.cost
+ ) || 0
+ )}
+
+
+
+
+
+
+
+
+
+ {(
+ [
+ "sum",
+ "avg",
+ "median",
+ "max",
+ "min",
+ ] as AggregateFunction[]
+ ).map((func) => (
+
+ setAggregateConfig({
+ ...aggregateConfig,
+ subscriptions_count: func,
+ })
+ }
+ className={
+ aggregateConfig.subscriptions_count === func
+ ? "bg-accent"
+ : ""
+ }
+ >
+ {getAggregateFunctionLabel(func)}
+
+ ))}
+
+
+ {formatNumber(
+ calculateAggregate(
+ filteredPurchases.map((p) => p.subscriptions_count),
+ aggregateConfig.subscriptions_count
+ ) || 0
+ )}
+
+
+
+
+
+
+
+
+
+ {(
+ [
+ "sum",
+ "avg",
+ "median",
+ "max",
+ "min",
+ ] as AggregateFunction[]
+ ).map((func) => (
+
+ setAggregateConfig({
+ ...aggregateConfig,
+ views_count: func,
+ })
+ }
+ className={
+ aggregateConfig.views_count === func
+ ? "bg-accent"
+ : ""
+ }
+ >
+ {getAggregateFunctionLabel(func)}
+
+ ))}
+
+
+ {formatNumber(
+ calculateAggregate(
+ filteredPurchases.map((p) => p.views_count),
+ aggregateConfig.views_count
+ ) || 0
+ )}
+
+
+
+
+
+
+
+
+
+ {(
+ [
+ "sum",
+ "avg",
+ "median",
+ "max",
+ "min",
+ ] as AggregateFunction[]
+ ).map((func) => (
+
+ setAggregateConfig({
+ ...aggregateConfig,
+ cpm: func,
+ })
+ }
+ className={
+ aggregateConfig.cpm === func
+ ? "bg-accent"
+ : ""
+ }
+ >
+ {getAggregateFunctionLabel(func)}
+
+ ))}
+
+
+ {formatCurrency(
+ calculateAggregate(
+ filteredPurchases.map((p) => calculateCPM(p)),
+ aggregateConfig.cpm
+ ) || 0
+ )}
+
+
+
+
+
+
+
+
+
+ {(
+ [
+ "sum",
+ "avg",
+ "median",
+ "max",
+ "min",
+ ] as AggregateFunction[]
+ ).map((func) => (
+
+ setAggregateConfig({
+ ...aggregateConfig,
+ cpl: func,
+ })
+ }
+ className={
+ aggregateConfig.cpl === func
+ ? "bg-accent"
+ : ""
+ }
+ >
+ {getAggregateFunctionLabel(func)}
+
+ ))}
+
+
+ {formatCurrency(
+ calculateAggregate(
+ filteredPurchases.map((p) => calculateCPL(p)),
+ aggregateConfig.cpl
+ ) || 0
+ )}
+
+
+
+
+
)}
diff --git a/components/target-channel-selector.tsx b/components/target-channel-selector.tsx
index 60df783..556b4b7 100644
--- a/components/target-channel-selector.tsx
+++ b/components/target-channel-selector.tsx
@@ -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 = () => {
-