"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([]); const [loading, setLoading] = useState(true); 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) { 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 ; } 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 = { 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 ( <>

Закупы

Рекламные размещения во внешних каналах

{error && ( {error} )}
setSearchQuery(e.target.value)} className="pl-8" />
Найдено: {formatNumber(filteredPurchases.length)}
Список закупов Все рекламные размещения
Экспорт в Excel (.xlsx) Экспорт в CSV (.csv) Экспорт в TXT (.txt)
{loading ? (
) : filteredPurchases.length === 0 ? (

{searchQuery || filterStatus !== "all" ? "Закупы не найдены" : "Нет закупов"}

{searchQuery || filterStatus !== "all" ? "Попробуйте изменить фильтры" : "Создайте первый закуп"}

) : ( 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) => { 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 )}
)}
); }