"use client"; import { useEffect, useState, useMemo, useCallback } from "react"; import { useParams } from "next/navigation"; import { Loader2, ArrowUpDown, ArrowUpRight, Filter, Columns } 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, creativesApi } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api"; import { AnalyticsFiltersModal, type PlacementsAnalyticsFilters } from "@/components/analytics-filters-modal"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; type SortField = "placement_date" | "cost" | "subscriptions_count" | "views_count" | "cpf" | "cpm"; type SortDirection = "asc" | "desc"; interface SortConfig { field: SortField; direction: SortDirection; } const DEFAULT_FILTERS: PlacementsAnalyticsFilters = { projectIds: [], statusList: ["Оплачено"], placementChannelIds: [], creativeIds: [], costTypes: [], placementTypes: [], inviteLinkTypes: [], costMin: null, costMax: null, viewsMin: null, viewsMax: null, subscriptionsMin: null, subscriptionsMax: null, cpmMin: null, cpmMax: null, channelTitleContains: "", creativeNameContains: "", commentContains: "", placementDateFrom: "", placementDateTo: "", }; const ALL_COLUMNS = [ { id: "placement_date", label: "Дата размещения" }, { id: "cost", label: "Стоимость" }, { id: "project_title", label: "Проект" }, { id: "channel_title", label: "Канал" }, { id: "creative_name", label: "Креатив" }, { id: "subscriptions_count", label: "Подписки" }, { id: "views_count", label: "Просмотры" }, { id: "cpf", label: "CPF" }, { id: "cpm", label: "CPM" }, { id: "post_url", label: "Пост" }, { id: "placement_type", label: "Тип закупа" }, { id: "cost_type", label: "Формат оплаты" }, { id: "cost_before_bargain", label: "Цена до торга" }, { id: "payment_at", label: "Дата оплаты" }, { id: "comment", label: "Комментарий" }, { id: "invite_link", label: "Ссылка" }, { id: "invite_link_type", label: "Тип ссылки" }, { id: "format", label: "Формат" }, { id: "time_on_top", label: "Время в топе" }, { id: "time_in_feed", label: "Время в ленте" }, { id: "discount_percent", label: "% скидки" }, { id: "conversion_24h", label: "Конв. 24ч" }, { id: "conversion_48h", label: "Конв. 48ч" }, { id: "conversion_total", label: "Конв. общ." }, { id: "unsubscriptions_count", label: "Отписки" }, { id: "unsub_percent", label: "% отписок" }, { id: "total_active", label: "Итого активных" }, { id: "post_deleted_at", label: "Дата удаления" }, { id: "invite_link_created_at", label: "Дата ссылки" }, ] as const; const DEFAULT_VISIBLE_COLUMNS = new Set([ "placement_date", "cost", "project_title", "channel_title", "creative_name", "subscriptions_count", "views_count", "cpf", "cpm", "post_url", ]); const EDITABLE_COST_TYPES = ["fixed", "cpm"] as const; const EDITABLE_PLACEMENT_TYPES = ["standard", "self_promo"] as const; const EDITABLE_INVITE_TYPES = ["public", "approval"] as const; 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 "—"; } }; export default function PlacementsAnalyticsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; const { projects, hasAnyAnalyticsPermission } = useWorkspace(); if (!hasAnyAnalyticsPermission) { return ( <>

Доступ запрещён

У вас нет прав для просмотра аналитики. Обратитесь к администратору workspace.

); } const [placements, setPlacements] = useState([]); const [loading, setLoading] = useState(true); const [totalCount, setTotalCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [filters, setFilters] = useState(DEFAULT_FILTERS); const [filtersOpen, setFiltersOpen] = useState(false); const [visibleColumns, setVisibleColumns] = useState>(DEFAULT_VISIBLE_COLUMNS); const [columnsOpen, setColumnsOpen] = useState(false); const [sortConfig, setSortConfig] = useState({ field: "placement_date", direction: "desc", }); const [editingDrafts, setEditingDrafts] = useState>>({}); const loadChannels = useCallback(async () => { try { const response = await channelsApi.list({ size: 100 }); return response.items; } catch (err) { console.error("Failed to load channels:", err); return []; } }, []); const loadCreatives = useCallback(async () => { try { const response = await creativesApi.list(workspaceId, { size: 100 }); return response.items.map((c) => ({ id: c.id, name: c.name })); } catch (err) { console.error("Failed to load creatives:", err); return []; } }, [workspaceId]); const loadPlacements = useCallback(async () => { try { setLoading(true); const params = { status_list: filters.statusList.length > 0 ? filters.statusList.join(',') : undefined, sort_by: sortConfig.field, sort_direction: sortConfig.direction, page: currentPage, size: 50, }; const response = await analyticsApi.placements(workspaceId, params); setPlacements(response.items); setTotalCount(response.total); setEditingDrafts({}); } catch (err) { console.error("Failed to load placements:", err); setPlacements([]); setTotalCount(0); } finally { setLoading(false); } }, [workspaceId, filters, sortConfig, currentPage]); useEffect(() => { loadChannels(); loadCreatives(); }, [workspaceId, loadChannels, loadCreatives]); useEffect(() => { loadPlacements(); }, [workspaceId, filters, sortConfig, currentPage, loadPlacements]); const sortedPlacements = useMemo(() => { return [...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; } }); }, [placements, sortConfig]); const handleSort = (field: SortField) => { setSortConfig((prev) => ({ field, direction: prev.field === field && prev.direction === "desc" ? "asc" : "desc", })); }; const hasActiveFilters = Object.values(filters).some((v) => { if (Array.isArray(v)) return v.length > 0; if (typeof v === "string") return v !== ""; if (typeof v === "number") return v !== null; return false; }); const toggleColumn = (colId: string) => { const newSet = new Set(visibleColumns); if (newSet.has(colId)) { newSet.delete(colId); } else { newSet.add(colId); } setVisibleColumns(newSet); }; const startEdit = (id: string, field: string, value: unknown) => { setEditingDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [field]: value }, })); }; const getDraftValue = (id: string, field: string, originalValue: unknown) => { return editingDrafts[id]?.[field] ?? originalValue; }; const renderCell = (placement: PlacementAnalyticsItem, columnId: string) => { const value = (placement as unknown as Record)[columnId]; const isEditing = !!editingDrafts[placement.id]?.[columnId]; const draftValue = getDraftValue(placement.id, columnId, value); switch (columnId) { case "placement_date": return formatDate(value as string | undefined); case "cost": case "cost_before_bargain": return isEditing ? ( startEdit(placement.id, columnId, parseFloat(e.target.value) || null)} /> ) : ( formatCurrency(value as number | undefined) ); case "cost_type": return isEditing ? ( ) : ( value === "fixed" ? "Фикс" : value === "cpm" ? "CPM" : "—" ); case "placement_type": return isEditing ? ( ) : ( value === "standard" ? "Стандартный" : value === "self_promo" ? "Взаимный пиар" : "—" ); case "invite_link_type": return isEditing ? ( ) : ( value === "public" ? "Открытая" : value === "approval" ? "С заявками" : "—" ); case "comment": return isEditing ? ( startEdit(placement.id, columnId, e.target.value)} /> ) : ( {value as string || "—"} ); case "cpf": case "cpm": case "conversion_24h": case "conversion_48h": case "conversion_total": case "unsub_percent": case "discount_percent": return value !== null && value !== undefined ? `${formatNumber(value as number)}%` : "—"; case "unsubscriptions_count": case "total_active": case "subscriptions_count": case "views_count": case "time_on_top": case "time_in_feed": return formatNumber(value as number | undefined); case "post_url": return value ? ( ) : ( ); default: if (value instanceof Date || (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}/))) { return formatDate(value as string); } return String(value ?? "—"); } }; return ( <>

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

{totalCount > 0 ? `${totalCount} размещений` : "Нет данных"}

Видимые колонки

{ALL_COLUMNS.map((col) => ( ))}
{ setFilters(newFilters); setCurrentPage(1); }} projects={projects} channels={[]} creatives={[]} initialFilters={DEFAULT_FILTERS} /> {loading ? (
) : placements.length === 0 ? (

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

) : (
{visibleColumns.has("placement_date") && ( handleSort("placement_date")} >
Дата
)} {visibleColumns.has("cost") && ( handleSort("cost")} >
Стоимость
)} {visibleColumns.has("project_title") && ( Проект )} {visibleColumns.has("channel_title") && ( Канал )} {visibleColumns.has("creative_name") && ( Креатив )} {visibleColumns.has("subscriptions_count") && ( handleSort("subscriptions_count")} >
Подписки
)} {visibleColumns.has("views_count") && ( handleSort("views_count")} >
Просмотры
)} {visibleColumns.has("cpf") && ( handleSort("cpf")} >
CPF
)} {visibleColumns.has("cpm") && ( handleSort("cpm")} >
CPM
)} {visibleColumns.has("post_url") && ( Пост )} {visibleColumns.has("placement_type") && ( Тип закупа )} {visibleColumns.has("cost_type") && ( Формат )} {visibleColumns.has("cost_before_bargain") && ( До торга )} {visibleColumns.has("payment_at") && ( Оплата )} {visibleColumns.has("comment") && ( Комментарий )} {visibleColumns.has("invite_link") && ( Ссылка )} {visibleColumns.has("invite_link_type") && ( Тип ссылки )} {visibleColumns.has("format") && ( Формат )} {visibleColumns.has("time_on_top") && ( В топе )} {visibleColumns.has("time_in_feed") && ( В ленте )} {visibleColumns.has("discount_percent") && ( % скидки )} {visibleColumns.has("conversion_24h") && ( Конв. 24ч )} {visibleColumns.has("conversion_48h") && ( Конв. 48ч )} {visibleColumns.has("conversion_total") && ( Конв. общ. )} {visibleColumns.has("unsubscriptions_count") && ( Отписки )} {visibleColumns.has("unsub_percent") && ( % отписок )} {visibleColumns.has("total_active") && ( Итого )} {visibleColumns.has("post_deleted_at") && ( Удалён )} {visibleColumns.has("invite_link_created_at") && ( Дата ссылки )}
{sortedPlacements.map((placement) => ( {visibleColumns.has("placement_date") && ( {renderCell(placement, "placement_date")} )} {visibleColumns.has("cost") && ( {renderCell(placement, "cost")} )} {visibleColumns.has("project_title") && ( {placement.project_title} )} {visibleColumns.has("channel_title") && ( {placement.channel_title} )} {visibleColumns.has("creative_name") && ( {placement.creative_name || "—"} )} {visibleColumns.has("subscriptions_count") && ( {renderCell(placement, "subscriptions_count")} )} {visibleColumns.has("views_count") && ( {renderCell(placement, "views_count")} )} {visibleColumns.has("cpf") && ( {renderCell(placement, "cpf")} )} {visibleColumns.has("cpm") && ( {renderCell(placement, "cpm")} )} {visibleColumns.has("post_url") && ( {renderCell(placement, "post_url")} )} {visibleColumns.has("placement_type") && ( {renderCell(placement, "placement_type")} )} {visibleColumns.has("cost_type") && ( {renderCell(placement, "cost_type")} )} {visibleColumns.has("cost_before_bargain") && ( {renderCell(placement, "cost_before_bargain")} )} {visibleColumns.has("payment_at") && ( {renderCell(placement, "payment_at")} )} {visibleColumns.has("comment") && ( {renderCell(placement, "comment")} )} {visibleColumns.has("invite_link") && ( {placement.invite_link ? ( Ссылка ) : "—"} )} {visibleColumns.has("invite_link_type") && ( {renderCell(placement, "invite_link_type")} )} {visibleColumns.has("format") && ( {placement.format || "—"} )} {visibleColumns.has("time_on_top") && ( {placement.time_on_top ? `${placement.time_on_top} мин` : "—"} )} {visibleColumns.has("time_in_feed") && ( {placement.time_in_feed ? `${placement.time_in_feed} мин` : "—"} )} {visibleColumns.has("discount_percent") && ( {renderCell(placement, "discount_percent")} )} {visibleColumns.has("conversion_24h") && ( {renderCell(placement, "conversion_24h")} )} {visibleColumns.has("conversion_48h") && ( {renderCell(placement, "conversion_48h")} )} {visibleColumns.has("conversion_total") && ( {renderCell(placement, "conversion_total")} )} {visibleColumns.has("unsubscriptions_count") && ( {renderCell(placement, "unsubscriptions_count")} )} {visibleColumns.has("unsub_percent") && ( {renderCell(placement, "unsub_percent")} )} {visibleColumns.has("total_active") && ( {renderCell(placement, "total_active")} )} {visibleColumns.has("post_deleted_at") && ( {renderCell(placement, "post_deleted_at")} )} {visibleColumns.has("invite_link_created_at") && ( {renderCell(placement, "invite_link_created_at")} )} ))}
)}
); }