"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.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([]); 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.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; }) => ( Функция {(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 cps = calculateCPS(placement.cost, placement.subscriptions_count); const cpm = calculateCPM(placement.cost, placement.views_count); return (
{placement.placement_channel_title}
{placement.creative_name}
{formatDate(placement.placement_date)} {formatCurrency(placement.cost)} {formatNumber(placement.subscriptions_count)} {formatNumber(placement.views_count ?? null)} {cps ? formatCurrency(cps) : "—"} {cpm ? formatCurrency(cpm) : "—"} {placement.status === "active" ? "Активен" : "Архив"} Подробнее {placement.ad_post_url && ( Открыть пост )}
); })}
{/* Totals Row */} Итого
{formatCurrency(totals.cost)}
{formatNumber(totals.subscriptions)}
{formatNumber(totals.views)}
{totals.cps ? formatCurrency(totals.cps) : "—"}
{totals.cpm ? formatCurrency(totals.cpm) : "—"}
)}
); }