feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View 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>
);
}