Files
tgex-frontend/app/(dashboard)/purchases/page.tsx
2025-12-01 06:27:37 +03:00

965 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
</>
);
}