"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")}
>
)}
{visibleColumns.has("cpm") && (
handleSort("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")}
)}
))}
)}
>
);
}