"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 { ProjectSelector } from "@/components/project-selector"; 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 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.channel.title, "", "—", // creative_name not available in new structure p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "", p.details?.cost?.value?.toString() || "", p.placement_post?.subscriptions_count.toString() || "0", p.placement_post?.views_count?.toString() || "", calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "", calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "", p.status, p.placement_post?.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.channel.title, "", "—", p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "", p.details?.cost?.value?.toString() || "", p.placement_post?.subscriptions_count.toString() || "0", p.placement_post?.views_count?.toString() || "", calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "", calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.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 cost = p.details?.cost?.value ?? null; const subscriptions = p.placement_post?.subscriptions_count ?? 0; const views = p.placement_post?.views_count ?? null; const cps = calculateCPS(cost, subscriptions); const cpm = calculateCPM(cost, views); return [ `Канал: ${p.channel.title}`, `Креатив: —`, `Дата: ${p.details?.placement_at ? formatDate(p.details.placement_at) : "—"}`, `Стоимость: ${formatCurrency(cost)}`, `Подписки: ${p.placement_post?.subscriptions_count ?? "—"}`, `Просмотры: ${p.placement_post?.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([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [includeArchived, setIncludeArchived] = useState(false); const [sortField, setSortField] = useState(null); const [sortOrder, setSortOrder] = useState(null); // Aggregation settings const [aggCost, setAggCost] = useState("sum"); const [aggSubs, setAggSubs] = useState("sum"); const [aggViews, setAggViews] = useState("sum"); const [aggCps, setAggCps] = useState("avg"); const [aggCpm, setAggCpm] = useState("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 ; if (sortOrder === "asc") return ; if (sortOrder === "desc") return ; return ; }; // Filter and sort const filteredPlacements = useMemo(() => { return placements .filter( (p) => p.channel.title.toLowerCase().includes(search.toLowerCase()) || (p.creative_id && p.creative_id.toString().toLowerCase().includes(search.toLowerCase())) ) .sort((a, b) => { if (!sortField || !sortOrder) return 0; let aVal: number | string = 0; let bVal: number | string = 0; const aCost = a.details?.cost?.value ?? null; const bCost = b.details?.cost?.value ?? null; const aSubs = a.placement_post?.subscriptions_count ?? 0; const bSubs = b.placement_post?.subscriptions_count ?? 0; const aViews = a.placement_post?.views_count ?? null; const bViews = b.placement_post?.views_count ?? null; switch (sortField) { case "date": const aDate = a.details?.placement_at || ""; const bDate = b.details?.placement_at || ""; return sortOrder === "asc" ? aDate.localeCompare(bDate) : bDate.localeCompare(aDate); case "cost": aVal = aCost ?? 0; bVal = bCost ?? 0; break; case "subscriptions": aVal = aSubs; bVal = bSubs; break; case "views": aVal = aViews ?? 0; bVal = bViews ?? 0; break; case "cps": aVal = calculateCPS(aCost, aSubs) ?? 0; bVal = calculateCPS(bCost, bSubs) ?? 0; break; case "cpm": aVal = calculateCPM(aCost, aViews) ?? 0; bVal = calculateCPM(bCost, bViews) ?? 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.details?.cost?.value).filter((c): c is number => c !== null); const subs = filteredPlacements.map((p) => p.placement_post?.subscriptions_count ?? 0); const views = filteredPlacements.map((p) => p.placement_post?.views_count).filter((v): v is number => v !== null); const cpsValues = filteredPlacements .map((p) => calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)) .filter((c): c is number => c !== null); const cpmValues = filteredPlacements .map((p) => calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)) .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; }) => ( Функция {(Object.keys(aggregationFunctions) as AggFunc[]).map((key) => ( onChange(key)} className={value === key ? "bg-accent" : ""} > {aggregationFunctions[key].label} ))} ); return (

Размещения

{filteredPlacements.length} размещений

{/* Export Button */} handleExport("excel")}> Excel (.xls) handleExport("csv")}> CSV handleExport("txt")}> Текст (.txt) {canWrite && ( )}
setSearch(e.target.value)} className="pl-10" />
setIncludeArchived(checked === true)} />
{loading ? (
) : filteredPlacements.length === 0 ? (

Нет размещений

{search ? "Ничего не найдено по вашему запросу" : "Создайте первое размещение"}

{canWrite && !search && ( )}
) : ( Канал Креатив
Cost Per Subscription
Cost Per Mille (1000 views)
{filteredPlacements.map((placement) => { const cost = placement.details?.cost?.value ?? null; const subscriptions = placement.placement_post?.subscriptions_count ?? 0; const views = placement.placement_post?.views_count ?? null; const cps = calculateCPS(cost, subscriptions); const cpm = calculateCPM(cost, views); return (
{placement.channel.title}
{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"} {formatCurrency(cost)} {formatNumber(subscriptions)} {formatNumber(views)} {cps ? formatCurrency(cps) : "—"} {cpm ? formatCurrency(cpm) : "—"} {placement.status} Подробнее {placement.placement_post?.post?.url && ( Открыть пост )}
); })}
{/* Totals Row */} Итого
{formatCurrency(totals.cost)}
{formatNumber(totals.subscriptions)}
{formatNumber(totals.views)}
{totals.cps ? formatCurrency(totals.cps) : "—"}
{totals.cpm ? formatCurrency(totals.cpm) : "—"}
)}
); }