"use client"; // ============================================================================ // All Placements Analytics Page // ============================================================================ import { useEffect, useState, useMemo, useCallback } from "react"; import { useParams } from "next/navigation"; import { Loader2, ArrowUpDown, ArrowUpRight, Calendar, Filter, X } from "lucide-react"; import { format } from "date-fns"; import { ru } from "date-fns/locale"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { analyticsApi, channelsApi } from "@/lib/api"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Calendar as CalendarComponent } from "@/components/ui/calendar"; import { cn } from "@/lib/utils"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api"; // ============================================================================ // Types // ============================================================================ type SortField = "placement_date" | "cost" | "subscriptions_count" | "views_count" | "cpf" | "cpm"; type SortDirection = "asc" | "desc"; interface SortConfig { field: SortField; direction: SortDirection; } interface DateRange { from: Date | undefined; to?: Date | undefined; } // ============================================================================ // Helpers // ============================================================================ const formatCurrency = (value: number | null | undefined) => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value); }; const formatNumber = (value: number | null | undefined) => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU").format(value); }; const formatDate = (dateStr: string | undefined) => { if (!dateStr) return "—"; try { return format(new Date(dateStr), "d MMM yyyy, HH:mm", { locale: ru }); } catch { return "—"; } }; // ============================================================================ // Main Component // ============================================================================ export default function PlacementsAnalyticsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; const { projects } = useWorkspace(); const [placements, setPlacements] = useState([]); const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(true); // Filters const [selectedProjectId, setSelectedProjectId] = useState("all"); const [selectedChannelId, setSelectedChannelId] = useState("all"); const [selectedCreativeId, setSelectedCreativeId] = useState("all"); const [dateRange, setDateRange] = useState({ from: undefined, to: undefined }); // Sorting const [sortConfig, setSortConfig] = useState({ field: "placement_date", direction: "desc", }); const loadChannels = useCallback(async () => { try { const response = await channelsApi.list({ size: 1000 }); setChannels(response.items); } catch (err) { console.error("Failed to load channels:", err); setChannels([]); } }, []); const loadPlacements = useCallback(async () => { try { setLoading(true); const response = await analyticsApi.placements(workspaceId, { project_id: selectedProjectId !== "all" ? selectedProjectId : undefined, placement_channel_id: selectedChannelId !== "all" ? selectedChannelId : undefined, creative_id: selectedCreativeId !== "all" ? selectedCreativeId : undefined, date_from: dateRange.from ? dateRange.from.toISOString() : undefined, date_to: dateRange.to ? dateRange.to.toISOString() : undefined, }); setPlacements(response.items); } catch (err) { console.error("Failed to load placements:", err); setPlacements([]); } finally { setLoading(false); } }, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange]); // Load data useEffect(() => { loadChannels(); loadPlacements(); }, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange, loadPlacements, loadChannels]); // Get unique creatives from placements const creatives = useMemo(() => { const creativeMap = new Map(); placements.forEach((p) => { if (p.creative_id && p.creative_name) { creativeMap.set(p.creative_id, p.creative_name); } }); return Array.from(creativeMap.entries()).map(([id, name]) => ({ id, name })); }, [placements]); // Sort placements const sortedPlacements = useMemo(() => { const sorted = [...placements].sort((a, b) => { let aVal: number | string | null | undefined = a[sortConfig.field]; let bVal: number | string | null | undefined = b[sortConfig.field]; if (sortConfig.field === "placement_date") { aVal = aVal ? new Date(aVal as string).getTime() : 0; bVal = bVal ? new Date(bVal as string).getTime() : 0; } if (aVal === null || aVal === undefined) aVal = sortConfig.direction === "asc" ? Infinity : -Infinity; if (bVal === null || bVal === undefined) bVal = sortConfig.direction === "asc" ? Infinity : -Infinity; if (sortConfig.direction === "asc") { return aVal > bVal ? 1 : -1; } else { return aVal < bVal ? 1 : -1; } }); return sorted; }, [placements, sortConfig]); // Calculate totals const totals = useMemo(() => { return { cost: placements.reduce((sum, p) => sum + (p.cost || 0), 0), subscriptions_count: placements.reduce((sum, p) => sum + p.subscriptions_count, 0), views_count: placements.reduce((sum, p) => sum + (p.views_count || 0), 0), cpf: placements.length > 0 ? placements.reduce((sum, p) => sum + (p.cpf || 0), 0) / placements.length : null, cpm: placements.length > 0 ? placements.reduce((sum, p) => sum + (p.cpm || 0), 0) / placements.length : null, }; }, [placements]); // Handle sort click const handleSort = (field: SortField) => { setSortConfig((prev) => ({ field, direction: prev.field === field && prev.direction === "desc" ? "asc" : "desc", })); }; // Reset filters const resetFilters = () => { setSelectedProjectId("all"); setSelectedChannelId("all"); setSelectedCreativeId("all"); setDateRange({ from: undefined, to: undefined }); }; // Has active filters const hasActiveFilters = selectedProjectId !== "all" || selectedChannelId !== "all" || selectedCreativeId !== "all" || dateRange.from || dateRange.to; return ( <>

Все размещения

Сводная таблица по всем завершённым размещениям

{/* Stats Cards */}
Всего размещений {formatNumber(placements.length)} Сумма расходов {formatCurrency(totals.cost)} Подписчиков {formatNumber(totals.subscriptions_count)} Просмотров {formatNumber(totals.views_count)}
{/* Filters */}
Фильтры {hasActiveFilters && ( )}
setDateRange({ from: range?.from, to: range?.to })} initialFocus numberOfMonths={2} locale={ru} />
{/* Table */} {loading ? (
) : placements.length === 0 ? (

Нет завершённых размещений

) : (
handleSort("placement_date")} >
Дата
handleSort("cost")} >
Стоимость
Канал размещения Проект Креатив handleSort("subscriptions_count")} >
Подписчики
handleSort("views_count")} >
Просмотры
handleSort("cpf")} >
CPF
handleSort("cpm")} >
CPM
Пост
{sortedPlacements.map((placement) => ( {formatDate(placement.placement_date)} {formatCurrency(placement.cost)} {placement.placement_channel_title} {placement.project_channel_title} {placement.creative_name} {formatNumber(placement.subscriptions_count)} {formatNumber(placement.views_count)} {formatCurrency(placement.cpf)} {formatCurrency(placement.cpm)} {placement.post_url ? ( ) : ( )} ))} {/* Totals Row */} ИТОГО {formatCurrency(totals.cost)} {formatNumber(totals.subscriptions_count)} {formatNumber(totals.views_count)} {formatCurrency(totals.cpf)} {formatCurrency(totals.cpm)}
)}
); }