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 = () => { - + !isSuccess && e.preventDefault()} + onEscapeKeyDown={(e) => !isSuccess && e.preventDefault()} > Добавить целевой канал @@ -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}