feat: update purchcases page
This commit is contained in:
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,24 +130,337 @@ export default function PurchasesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPurchases = purchases.filter((purchase) => {
|
// Вычисление метрик
|
||||||
// Поиск
|
const calculateCPM = (purchase: Purchase): number | null => {
|
||||||
const matchesSearch =
|
if (!purchase.cost || !purchase.views_count || purchase.views_count === 0)
|
||||||
purchase.external_channel_title
|
return null;
|
||||||
.toLowerCase()
|
return (purchase.cost / purchase.views_count) * 1000;
|
||||||
.includes(searchQuery.toLowerCase()) ||
|
};
|
||||||
purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
// Фильтр по статусу
|
const calculateCPL = (purchase: Purchase): number | null => {
|
||||||
let matchesStatus = true;
|
if (
|
||||||
if (filterStatus === "archived") {
|
!purchase.cost ||
|
||||||
matchesStatus = purchase.status === "archived";
|
!purchase.subscriptions_count ||
|
||||||
} else if (filterStatus === "active") {
|
purchase.subscriptions_count === 0
|
||||||
matchesStatus = purchase.status === "active";
|
)
|
||||||
|
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 <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 = {
|
const stats = {
|
||||||
@@ -132,12 +487,14 @@ export default function PurchasesPage() {
|
|||||||
Рекламные размещения во внешних каналах
|
Рекламные размещения во внешних каналах
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/purchases/new">
|
<Button asChild>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Link href="/purchases/new">
|
||||||
Создать закуп
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Создать закуп
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -147,60 +504,8 @@ export default function PurchasesPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="justify-between flex flex-row">
|
||||||
<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">
|
||||||
<CardTitle>Список закупов</CardTitle>
|
<div className="">
|
||||||
<CardDescription>Все рекламные размещения</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -261,64 +588,372 @@ 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) => {
|
||||||
<TableRow key={purchase.id}>
|
const cpm = calculateCPM(purchase);
|
||||||
<TableCell>
|
const cpl = calculateCPL(purchase);
|
||||||
{purchase.status === "archived" ? (
|
|
||||||
<Badge variant="secondary">
|
return (
|
||||||
<Archive className="h-3 w-3 mr-1" />
|
<TableRow key={purchase.id}>
|
||||||
Архив
|
<TableCell>
|
||||||
</Badge>
|
{purchase.status === "archived" ? (
|
||||||
) : (
|
<Badge variant="secondary">
|
||||||
<Badge variant="default">
|
<Archive className="h-3 w-3 mr-1" />
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
Архив
|
||||||
Активно
|
</Badge>
|
||||||
</Badge>
|
) : (
|
||||||
)}
|
<Badge variant="default">
|
||||||
</TableCell>
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
<TableCell className="font-medium">
|
Активно
|
||||||
{purchase.target_channel_title}
|
</Badge>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell>{purchase.external_channel_title}</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[200px] truncate">
|
<TableCell className="font-medium">
|
||||||
{purchase.creative_name}
|
{purchase.target_channel_title}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>{purchase.external_channel_title}</TableCell>
|
||||||
{formatDate(purchase.placement_date)}
|
<TableCell className="max-w-[200px] truncate">
|
||||||
</TableCell>
|
{purchase.creative_name}
|
||||||
<TableCell className="text-right">
|
</TableCell>
|
||||||
{formatCurrency(purchase.cost)}
|
<TableCell>
|
||||||
</TableCell>
|
{formatDate(purchase.placement_date)}
|
||||||
<TableCell className="text-right">
|
</TableCell>
|
||||||
{formatNumber(purchase.subscriptions_count)}
|
<TableCell className="text-right">
|
||||||
</TableCell>
|
{formatCurrency(purchase.cost)}
|
||||||
<TableCell className="text-right">
|
</TableCell>
|
||||||
{purchase.views_count
|
<TableCell className="text-right">
|
||||||
? formatNumber(purchase.views_count)
|
{formatNumber(purchase.subscriptions_count)}
|
||||||
: "—"}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-right">
|
||||||
<TableCell className="text-right">
|
{purchase.views_count
|
||||||
<Button asChild variant="ghost" size="sm">
|
? formatNumber(purchase.views_count)
|
||||||
<Link href={`/purchases/${purchase.id}`}>
|
: "—"}
|
||||||
<Eye className="h-4 w-4" />
|
</TableCell>
|
||||||
</Link>
|
<TableCell className="text-right">
|
||||||
</Button>
|
{cpm !== null ? formatCurrency(cpm) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -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,16 +92,15 @@ export const TargetChannelSelector: React.FC = () => {
|
|||||||
setIsAddDialogOpen(true);
|
setIsAddDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
const handleDialogOpenChange = (open: boolean) => {
|
||||||
if (!isSuccess) {
|
if (!open) {
|
||||||
// Не даем закрыть диалог во время ожидания
|
// Разрешаем закрытие всегда (в том числе через кнопку крестик)
|
||||||
return;
|
setIsAddDialogOpen(false);
|
||||||
}
|
setIsSuccess(false);
|
||||||
setIsAddDialogOpen(false);
|
if (pollingIntervalRef.current) {
|
||||||
setIsSuccess(false);
|
clearInterval(pollingIntervalRef.current);
|
||||||
if (pollingIntervalRef.current) {
|
pollingIntervalRef.current = null;
|
||||||
clearInterval(pollingIntervalRef.current);
|
}
|
||||||
pollingIntervalRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user