Files
tgex-frontend/app/dashboard/[workspaceId]/placements/page.tsx
2025-12-29 10:12:13 +03:00

743 lines
26 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";
// ============================================================================
// Placements List Page - Enhanced with Totals Row and Export
// ============================================================================
import { useEffect, useState, useMemo, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Search,
Plus,
ShoppingCart,
MoreHorizontal,
Eye,
ExternalLink,
ArrowUpDown,
ArrowUp,
ArrowDown,
Download,
FileSpreadsheet,
FileText,
Info,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableFooter,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
});
};
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const calculateCPS = (cost: number | null, subscriptions: number) => {
if (!cost || subscriptions === 0) return null;
return cost / subscriptions;
};
const calculateCPM = (cost: number | null | undefined, views: number | null | undefined) => {
if (!cost || !views || views === 0) return null;
return (cost / views) * 1000;
};
// ============================================================================
// Sort Types
// ============================================================================
type SortField = "date" | "cost" | "subscriptions" | "views" | "cps" | "cpm" | "status";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Aggregation Functions
// ============================================================================
type AggFunc = "sum" | "avg" | "median" | "max" | "min";
const aggregationFunctions: Record<AggFunc, { label: string; fn: (values: number[]) => number | null }> = {
sum: {
label: "Сумма",
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) : null),
},
avg: {
label: "Среднее",
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : null),
},
median: {
label: "Медиана",
fn: (values) => {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
},
},
max: {
label: "Макс.",
fn: (values) => (values.length > 0 ? Math.max(...values) : null),
},
min: {
label: "Мин.",
fn: (values) => (values.length > 0 ? Math.min(...values) : null),
},
};
// ============================================================================
// Export Functions
// ============================================================================
const exportToCSV = (placements: Placement[], filename: string) => {
const headers = [
"Канал",
"Username",
"Креатив",
"Дата",
"Стоимость",
"Подписки",
"Просмотры",
"CPS",
"CPM",
"Статус",
"Ссылка на пост",
"Пригласительная ссылка",
];
const rows = placements.map((p) => [
p.placement_channel_title,
"",
p.creative_name,
new Date(p.placement_date).toISOString().split("T")[0],
p.cost?.toString() || "",
p.subscriptions_count.toString(),
p.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
p.status,
p.ad_post_url || "",
p.invite_link,
]);
const csv = [headers.join(";"), ...rows.map((r) => r.join(";"))].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
downloadBlob(blob, `${filename}.csv`);
};
const exportToExcel = (placements: Placement[], filename: string) => {
// Simple XLSX-like TSV format (opens in Excel)
const headers = [
"Канал",
"Username",
"Креатив",
"Дата",
"Стоимость",
"Подписки",
"Просмотры",
"CPS",
"CPM",
"Статус",
];
const rows = placements.map((p) => [
p.placement_channel_title,
"",
p.creative_name,
new Date(p.placement_date).toISOString().split("T")[0],
p.cost?.toString() || "",
p.subscriptions_count.toString(),
p.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
p.status,
]);
const tsv = [headers.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
const blob = new Blob(["\uFEFF" + tsv], {
type: "application/vnd.ms-excel;charset=utf-8;",
});
downloadBlob(blob, `${filename}.xls`);
};
const exportToTxt = (placements: Placement[], filename: string) => {
const lines = placements.map((p) => {
const cps = calculateCPS(p.cost, p.subscriptions_count);
const cpm = calculateCPM(p.cost, p.views_count);
return [
`Канал: ${p.placement_channel_title}`,
`Креатив: ${p.creative_name}`,
`Дата: ${formatDate(p.placement_date)}`,
`Стоимость: ${formatCurrency(p.cost)}`,
`Подписки: ${p.subscriptions_count}`,
`Просмотры: ${p.views_count || "—"}`,
`CPS: ${cps ? formatCurrency(cps) : "—"}`,
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
`Ссылка: ${p.invite_link}`,
"---",
].join("\n");
});
const text = lines.join("\n");
const blob = new Blob([text], { type: "text/plain;charset=utf-8;" });
downloadBlob(blob, `${filename}.txt`);
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// ============================================================================
// Component
// ============================================================================
export default function PlacementsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [includeArchived, setIncludeArchived] = useState(false);
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
// Aggregation settings
const [aggCost, setAggCost] = useState<AggFunc>("sum");
const [aggSubs, setAggSubs] = useState<AggFunc>("sum");
const [aggViews, setAggViews] = useState<AggFunc>("sum");
const [aggCps, setAggCps] = useState<AggFunc>("avg");
const [aggCpm, setAggCpm] = useState<AggFunc>("avg");
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadPlacements();
}, [workspaceId, projectFilterId, includeArchived]);
const loadPlacements = async () => {
try {
setLoading(true);
const response = await demoAwarePlacementsApi.list(workspaceId, {
project_id: projectFilterId,
include_archived: includeArchived,
size: 100,
});
setPlacements(response.items);
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
// Handle sort
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") {
setSortOrder("desc");
} else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
} else {
setSortOrder("asc");
}
} else {
setSortField(field);
setSortOrder("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
return <ArrowUpDown className="h-4 w-4 ml-1" />;
};
// Filter and sort
const filteredPlacements = useMemo(() => {
return placements
.filter(
(p) =>
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) ||
p.creative_name.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => {
if (!sortField || !sortOrder) return 0;
let aVal: number | string = 0;
let bVal: number | string = 0;
switch (sortField) {
case "date":
return sortOrder === "asc"
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime()
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime();
case "cost":
aVal = a.cost ?? 0;
bVal = b.cost ?? 0;
break;
case "subscriptions":
aVal = a.subscriptions_count;
bVal = b.subscriptions_count;
break;
case "views":
aVal = a.views_count ?? 0;
bVal = b.views_count ?? 0;
break;
case "cps":
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0;
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0;
break;
case "cpm":
aVal = calculateCPM(a.cost, a.views_count) ?? 0;
bVal = calculateCPM(b.cost, b.views_count) ?? 0;
break;
case "status":
aVal = a.status;
bVal = b.status;
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return sortOrder === "asc"
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number);
});
}, [placements, search, sortField, sortOrder]);
// Calculate totals with aggregation functions
const totals = useMemo(() => {
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null);
const subs = filteredPlacements.map((p) => p.subscriptions_count);
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null);
const cpsValues = filteredPlacements
.map((p) => calculateCPS(p.cost, p.subscriptions_count))
.filter((c): c is number => c !== null);
const cpmValues = filteredPlacements
.map((p) => calculateCPM(p.cost, p.views_count))
.filter((c): c is number => c !== null);
return {
cost: aggregationFunctions[aggCost].fn(costs),
subscriptions: aggregationFunctions[aggSubs].fn(subs),
views: aggregationFunctions[aggViews].fn(views),
cps: aggregationFunctions[aggCps].fn(cpsValues),
cpm: aggregationFunctions[aggCpm].fn(cpmValues),
};
}, [filteredPlacements, aggCost, aggSubs, aggViews, aggCps, aggCpm]);
// Export handlers
const handleExport = useCallback(
(format: "csv" | "excel" | "txt") => {
const filename = `placements_${new Date().toISOString().split("T")[0]}`;
switch (format) {
case "csv":
exportToCSV(filteredPlacements, filename);
break;
case "excel":
exportToExcel(filteredPlacements, filename);
break;
case "txt":
exportToTxt(filteredPlacements, filename);
break;
}
},
[filteredPlacements]
);
// Aggregation selector component
const AggSelector = ({
value,
onChange,
}: {
value: AggFunc;
onChange: (v: AggFunc) => void;
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5 ml-1 opacity-50 hover:opacity-100">
<span className="text-[10px] font-mono">f</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Функция</DropdownMenuLabel>
{(Object.keys(aggregationFunctions) as AggFunc[]).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => onChange(key)}
className={value === key ? "bg-accent" : ""}
>
{aggregationFunctions[key].label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ 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-2xl font-bold tracking-tight">Размещения</h1>
<p className="text-muted-foreground">
{filteredPlacements.length} размещений
</p>
</div>
<div className="flex items-center gap-2">
{/* Export Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Экспорт
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("excel")}>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Excel (.xls)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("csv")}>
<FileText className="h-4 w-4 mr-2" />
CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("txt")}>
<FileText className="h-4 w-4 mr-2" />
Текст (.txt)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canWrite && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/placements/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск по каналу или креативу..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-archived"
checked={includeArchived}
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
/>
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
Показать архивные
</Label>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredPlacements.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ShoppingCart className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет размещений</h3>
<p className="text-muted-foreground text-center mb-4">
{search ? "Ничего не найдено по вашему запросу" : "Создайте первое размещение"}
</p>
{canWrite && !search && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/placements/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Канал</TableHead>
<TableHead>Креатив</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("date")}
>
Дата
{getSortIcon("date")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
Стоимость
{getSortIcon("cost")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("subscriptions")}
>
Подписки
{getSortIcon("subscriptions")}
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("views")}
>
Просмотры
{getSortIcon("views")}
</Button>
</TableHead>
<TableHead>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cps")}
>
CPS
{getSortIcon("cps")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Subscription</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cpm")}
>
CPM
{getSortIcon("cpm")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("status")}
>
Статус
{getSortIcon("status")}
</Button>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlacements.map((placement) => {
const cps = calculateCPS(placement.cost, placement.subscriptions_count);
const cpm = calculateCPM(placement.cost, placement.views_count);
return (
<TableRow key={placement.id}>
<TableCell>
<div className="font-medium">{placement.placement_channel_title}</div>
</TableCell>
<TableCell>
<div className="text-sm">{placement.creative_name}</div>
</TableCell>
<TableCell>{formatDate(placement.placement_date)}</TableCell>
<TableCell>{formatCurrency(placement.cost)}</TableCell>
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell>
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell>
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
<TableCell>
<Badge
variant={placement.status === "active" ? "default" : "secondary"}
>
{placement.status === "active" ? "Активен" : "Архив"}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/${workspaceId}/placements/${placement.id}`}>
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Link>
</DropdownMenuItem>
{placement.ad_post_url && (
<DropdownMenuItem asChild>
<a
href={placement.ad_post_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-2" />
Открыть пост
</a>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
{/* Totals Row */}
<TableFooter>
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={3}>
<span className="text-muted-foreground">Итого</span>
</TableCell>
<TableCell>
<div className="flex items-center">
{formatCurrency(totals.cost)}
<AggSelector value={aggCost} onChange={setAggCost} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{formatNumber(totals.subscriptions)}
<AggSelector value={aggSubs} onChange={setAggSubs} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{formatNumber(totals.views)}
<AggSelector value={aggViews} onChange={setAggViews} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{totals.cps ? formatCurrency(totals.cps) : "—"}
<AggSelector value={aggCps} onChange={setAggCps} />
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{totals.cpm ? formatCurrency(totals.cpm) : "—"}
<AggSelector value={aggCpm} onChange={setAggCpm} />
</div>
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
)}
</div>
</TooltipProvider>
);
}