feat: global platform v2 update
This commit is contained in:
742
app/dashboard/[workspaceId]/placements/page.tsx
Normal file
742
app/dashboard/[workspaceId]/placements/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user