965 lines
35 KiB
TypeScript
965 lines
35 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|