feat: update purchcases page

This commit is contained in:
ivannoskov
2025-12-01 06:27:37 +03:00
parent d2cc1c39b3
commit 790fd5cd5b
3 changed files with 784 additions and 154 deletions

View File

@@ -65,7 +65,9 @@ export default function DashboardPage() {
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} /> <DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div className="text-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 className="text-muted-foreground">
Для начала работы выберите целевой канал в верхнем меню Для начала работы выберите целевой канал в верхнем меню
</p> </p>
@@ -96,8 +98,7 @@ export default function DashboardPage() {
{formatNumber(spending?.total_cost)} {formatNumber(spending?.total_cost)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatNumber(0 || 0, true)} за {formatNumber(0 || 0, true)} за прошлый месяц
прошлый месяц
</p> </p>
</> </>
)} )}
@@ -120,8 +121,7 @@ export default function DashboardPage() {
{formatNumber(spending?.total_subscriptions)} {formatNumber(spending?.total_subscriptions)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatNumber(0 || 0, true)} за {formatNumber(0 || 0, true)} за прошлый месяц
прошлый месяц
</p> </p>
</> </>
)} )}
@@ -142,8 +142,7 @@ export default function DashboardPage() {
{formatMetric(spending?.avg_cpf)} {formatMetric(spending?.avg_cpf)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatPercent(0 || 0, true)} от прошлого {formatPercent(0 || 0, true)} от прошлого месяца
месяца
</p> </p>
</> </>
)} )}
@@ -164,8 +163,7 @@ export default function DashboardPage() {
{formatMetric(spending?.avg_cpm)} {formatMetric(spending?.avg_cpm)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatPercent(0 || 0, true)} от прошлого {formatPercent(0 || 0, true)} от прошлого месяца
месяца
</p> </p>
</> </>
)} )}

View File

@@ -31,7 +31,14 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
TableFooter,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { purchasesApi, channelsApi } from "@/lib/api"; import { purchasesApi, channelsApi } from "@/lib/api";
import { import {
formatNumber, formatNumber,
@@ -51,10 +58,36 @@ import {
CheckCircle, CheckCircle,
Clock, Clock,
Archive, Archive,
ArrowUpDown,
ArrowUp,
ArrowDown,
Download,
} from "lucide-react"; } from "lucide-react";
import type { Purchase } from "@/lib/types/api"; import type { Purchase } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTargetChannel } from "@/components/providers/target-channel-provider"; 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() { export default function PurchasesPage() {
const { selectedChannel } = useTargetChannel(); const { selectedChannel } = useTargetChannel();
@@ -63,6 +96,15 @@ export default function PurchasesPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState<string>("all"); 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(() => { useEffect(() => {
if (selectedChannel) { if (selectedChannel) {
@@ -88,13 +130,268 @@ export default function PurchasesPage() {
} }
}; };
const filteredPurchases = purchases.filter((purchase) => { // Вычисление метрик
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 = const matchesSearch =
purchase.external_channel_title purchase.external_channel_title
.toLowerCase() .toLowerCase()
.includes(searchQuery.toLowerCase()) || .includes(searchQuery.toLowerCase()) ||
purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase()); purchase.creative_name
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Фильтр по статусу // Фильтр по статусу
let matchesStatus = true; let matchesStatus = true;
@@ -105,6 +402,64 @@ export default function PurchasesPage() {
} }
return matchesSearch && matchesStatus; 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;
}); });
// Статистика // Статистика
@@ -132,6 +487,7 @@ export default function PurchasesPage() {
Рекламные размещения во внешних каналах Рекламные размещения во внешних каналах
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Button asChild> <Button asChild>
<Link href="/purchases/new"> <Link href="/purchases/new">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@@ -139,6 +495,7 @@ export default function PurchasesPage() {
</Link> </Link>
</Button> </Button>
</div> </div>
</div>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -147,60 +504,8 @@ export default function PurchasesPage() {
</Alert> </Alert>
)} )}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardContent className="justify-between flex flex-row">
<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">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <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>
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row justify-between">
<div className="">
<CardTitle>Список закупов</CardTitle> <CardTitle>Список закупов</CardTitle>
<CardDescription>Все рекламные размещения</CardDescription> <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> </CardHeader>
<CardContent> <CardContent>
{loading ? ( {loading ? (
@@ -261,19 +588,67 @@ export default function PurchasesPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <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>Креатив</TableHead> <TableHead>Креатив</TableHead>
<TableHead>Дата</TableHead> <TableHead
<TableHead className="text-right">Стоимость</TableHead> className="cursor-pointer hover:bg-muted/50"
<TableHead className="text-right">Подписки</TableHead> onClick={() => handleSort("placement_date")}
<TableHead className="text-right">Просмотры</TableHead> >
Дата
{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> <TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredPurchases.map((purchase) => ( {filteredPurchases.map((purchase) => {
const cpm = calculateCPM(purchase);
const cpl = calculateCPL(purchase);
return (
<TableRow key={purchase.id}> <TableRow key={purchase.id}>
<TableCell> <TableCell>
{purchase.status === "archived" ? ( {purchase.status === "archived" ? (
@@ -309,6 +684,12 @@ export default function PurchasesPage() {
? formatNumber(purchase.views_count) ? formatNumber(purchase.views_count)
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell className="text-right">
{cpm !== null ? formatCurrency(cpm) : "—"}
</TableCell>
<TableCell className="text-right">
{cpl !== null ? formatCurrency(cpl) : "—"}
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link href={`/purchases/${purchase.id}`}> <Link href={`/purchases/${purchase.id}`}>
@@ -317,8 +698,262 @@ export default function PurchasesPage() {
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </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> </Table>
)} )}
</CardContent> </CardContent>

View File

@@ -23,7 +23,6 @@ import {
Plus, Plus,
ExternalLink, ExternalLink,
CheckCircle2, CheckCircle2,
ArrowUpRightIcon,
} from "lucide-react"; } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { BOT_USERNAME } from "@/lib/utils/constants"; import { BOT_USERNAME } from "@/lib/utils/constants";
@@ -49,7 +48,6 @@ export const TargetChannelSelector: React.FC = () => {
// Автоматический запуск polling при открытии диалога // Автоматический запуск polling при открытии диалога
useEffect(() => { useEffect(() => {
if (isAddDialogOpen && !isSuccess && initialChannelCount > 0) { if (isAddDialogOpen && !isSuccess && initialChannelCount > 0) {
// Polling каждую секунду
pollingIntervalRef.current = setInterval(() => { pollingIntervalRef.current = setInterval(() => {
checkForNewChannels(); checkForNewChannels();
}, 1000); }, 1000);
@@ -71,20 +69,15 @@ export const TargetChannelSelector: React.FC = () => {
); );
if (activeChannels.length > initialChannelCount) { if (activeChannels.length > initialChannelCount) {
// Новый канал добавлен!
setIsSuccess(true); setIsSuccess(true);
// Останавливаем polling
if (pollingIntervalRef.current) { if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current); clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null; pollingIntervalRef.current = null;
} }
// Закрываем диалог через 2 секунды
setTimeout(() => { setTimeout(() => {
setIsAddDialogOpen(false); setIsAddDialogOpen(false);
setIsSuccess(false); setIsSuccess(false);
// Обновляем список каналов через провайдер
window.location.reload(); window.location.reload();
}, 2000); }, 2000);
} }
@@ -99,17 +92,16 @@ export const TargetChannelSelector: React.FC = () => {
setIsAddDialogOpen(true); setIsAddDialogOpen(true);
}; };
const handleDialogClose = () => { const handleDialogOpenChange = (open: boolean) => {
if (!isSuccess) { if (!open) {
// Не даем закрыть диалог во время ожидания // Разрешаем закрытие всегда (в том числе через кнопку крестик)
return;
}
setIsAddDialogOpen(false); setIsAddDialogOpen(false);
setIsSuccess(false); setIsSuccess(false);
if (pollingIntervalRef.current) { if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current); clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null; pollingIntervalRef.current = null;
} }
}
}; };
if (isLoading) { if (isLoading) {
@@ -203,10 +195,11 @@ export const TargetChannelSelector: React.FC = () => {
</SelectContent> </SelectContent>
</Select> </Select>
<Dialog open={isAddDialogOpen} onOpenChange={handleDialogClose}> <Dialog open={isAddDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent <DialogContent
className="sm:max-w-md" className="sm:max-w-md"
onInteractOutside={(e) => !isSuccess && e.preventDefault()} onInteractOutside={(e) => !isSuccess && e.preventDefault()}
onEscapeKeyDown={(e) => !isSuccess && e.preventDefault()}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>Добавить целевой канал</DialogTitle> <DialogTitle>Добавить целевой канал</DialogTitle>
@@ -244,6 +237,10 @@ export const TargetChannelSelector: React.FC = () => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1" 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} @{BOT_USERNAME}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />