786 lines
33 KiB
TypeScript
786 lines
33 KiB
TypeScript
"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 (
|
||
<>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||
{ label: "Все размещения" },
|
||
]}
|
||
/>
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||
<Card className="flex flex-col items-center justify-center py-16">
|
||
<CardContent className="text-center">
|
||
<h2 className="text-xl font-semibold mb-2">Доступ запрещён</h2>
|
||
<p className="text-muted-foreground">
|
||
У вас нет прав для просмотра аналитики. Обратитесь к администратору workspace.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const [placements, setPlacements] = useState<PlacementAnalyticsItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
|
||
const [filters, setFilters] = useState<PlacementsAnalyticsFilters>(DEFAULT_FILTERS);
|
||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(DEFAULT_VISIBLE_COLUMNS);
|
||
const [columnsOpen, setColumnsOpen] = useState(false);
|
||
|
||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||
field: "placement_date",
|
||
direction: "desc",
|
||
});
|
||
|
||
const [editingDrafts, setEditingDrafts] = useState<Record<string, Record<string, unknown>>>({});
|
||
|
||
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<string, unknown>)[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 ? (
|
||
<Input
|
||
type="number"
|
||
className="h-8 w-24"
|
||
value={draftValue as number ?? ""}
|
||
onChange={(e) => startEdit(placement.id, columnId, parseFloat(e.target.value) || null)}
|
||
/>
|
||
) : (
|
||
formatCurrency(value as number | undefined)
|
||
);
|
||
case "cost_type":
|
||
return isEditing ? (
|
||
<select
|
||
className="h-8 w-24 rounded border px-2"
|
||
value={draftValue as string}
|
||
onChange={(e) => startEdit(placement.id, columnId, e.target.value)}
|
||
>
|
||
{EDITABLE_COST_TYPES.map((t) => (
|
||
<option key={t} value={t}>{t === "fixed" ? "Фикс" : "CPM"}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
value === "fixed" ? "Фикс" : value === "cpm" ? "CPM" : "—"
|
||
);
|
||
case "placement_type":
|
||
return isEditing ? (
|
||
<select
|
||
className="h-8 w-32 rounded border px-2"
|
||
value={draftValue as string ?? ""}
|
||
onChange={(e) => startEdit(placement.id, columnId, e.target.value || null)}
|
||
>
|
||
<option value="">—</option>
|
||
{EDITABLE_PLACEMENT_TYPES.map((t) => (
|
||
<option key={t} value={t}>{t === "standard" ? "Стандартный" : "Взаимный пиар"}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
value === "standard" ? "Стандартный" : value === "self_promo" ? "Взаимный пиар" : "—"
|
||
);
|
||
case "invite_link_type":
|
||
return isEditing ? (
|
||
<select
|
||
className="h-8 w-28 rounded border px-2"
|
||
value={draftValue as string ?? ""}
|
||
onChange={(e) => startEdit(placement.id, columnId, e.target.value || null)}
|
||
>
|
||
<option value="">—</option>
|
||
{EDITABLE_INVITE_TYPES.map((t) => (
|
||
<option key={t} value={t}>{t === "public" ? "Открытая" : "С заявками"}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
value === "public" ? "Открытая" : value === "approval" ? "С заявками" : "—"
|
||
);
|
||
case "comment":
|
||
return isEditing ? (
|
||
<Input
|
||
className="h-8 w-40"
|
||
value={draftValue as string ?? ""}
|
||
onChange={(e) => startEdit(placement.id, columnId, e.target.value)}
|
||
/>
|
||
) : (
|
||
<span className="truncate max-w-[200px] block">{value as string || "—"}</span>
|
||
);
|
||
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 ? (
|
||
<a
|
||
href={value as string}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center justify-center p-1 rounded hover:bg-muted"
|
||
>
|
||
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
|
||
</a>
|
||
) : (
|
||
<span className="text-muted-foreground/50">—</span>
|
||
);
|
||
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 (
|
||
<>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||
{ label: "Все размещения" },
|
||
]}
|
||
/>
|
||
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">Все размещения</h1>
|
||
<p className="text-muted-foreground">
|
||
{totalCount > 0 ? `${totalCount} размещений` : "Нет данных"}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant={hasActiveFilters ? "default" : "outline"}
|
||
onClick={() => setFiltersOpen(true)}
|
||
className="gap-2"
|
||
>
|
||
<Filter className="h-4 w-4" />
|
||
Фильтры
|
||
{hasActiveFilters && <span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">✓</span>}
|
||
</Button>
|
||
<Popover open={columnsOpen} onOpenChange={setColumnsOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="outline" className="gap-2">
|
||
<Columns className="h-4 w-4" />
|
||
Колонки
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-56" align="end">
|
||
<div className="space-y-2">
|
||
<p className="text-sm font-medium">Видимые колонки</p>
|
||
{ALL_COLUMNS.map((col) => (
|
||
<label
|
||
key={col.id}
|
||
className="flex items-center gap-2 text-sm cursor-pointer"
|
||
>
|
||
<Checkbox
|
||
checked={visibleColumns.has(col.id)}
|
||
onCheckedChange={() => toggleColumn(col.id)}
|
||
/>
|
||
{col.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
<AnalyticsFiltersModal
|
||
open={filtersOpen}
|
||
onOpenChange={setFiltersOpen}
|
||
onApply={(newFilters) => {
|
||
setFilters(newFilters);
|
||
setCurrentPage(1);
|
||
}}
|
||
projects={projects}
|
||
channels={[]}
|
||
creatives={[]}
|
||
initialFilters={DEFAULT_FILTERS}
|
||
/>
|
||
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : placements.length === 0 ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<p className="text-muted-foreground">Нет размещений</p>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-md border overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{visibleColumns.has("placement_date") && (
|
||
<TableHead
|
||
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap"
|
||
onClick={() => handleSort("placement_date")}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
Дата
|
||
<ArrowUpDown className="h-4 w-4" />
|
||
</div>
|
||
</TableHead>
|
||
)}
|
||
{visibleColumns.has("cost") && (
|
||
<TableHead
|
||
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap"
|
||
onClick={() => handleSort("cost")}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
Стоимость
|
||
<ArrowUpDown className="h-4 w-4" />
|
||
</div>
|
||
</TableHead>
|
||
)}
|
||
{visibleColumns.has("project_title") && (
|
||
<TableHead className="whitespace-nowrap">Проект</TableHead>
|
||
)}
|
||
{visibleColumns.has("channel_title") && (
|
||
<TableHead className="whitespace-nowrap">Канал</TableHead>
|
||
)}
|
||
{visibleColumns.has("creative_name") && (
|
||
<TableHead className="whitespace-nowrap">Креатив</TableHead>
|
||
)}
|
||
{visibleColumns.has("subscriptions_count") && (
|
||
<TableHead
|
||
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||
onClick={() => handleSort("subscriptions_count")}
|
||
>
|
||
<div className="flex items-center justify-end gap-1">
|
||
Подписки
|
||
<ArrowUpDown className="h-4 w-4" />
|
||
</div>
|
||
</TableHead>
|
||
)}
|
||
{visibleColumns.has("views_count") && (
|
||
<TableHead
|
||
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||
onClick={() => handleSort("views_count")}
|
||
>
|
||
<div className="flex items-center justify-end gap-1">
|
||
Просмотры
|
||
<ArrowUpDown className="h-4 w-4" />
|
||
</div>
|
||
</TableHead>
|
||
)}
|
||
{visibleColumns.has("cpf") && (
|
||
<TableHead
|
||
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||
onClick={() => handleSort("cpf")}
|
||
>
|
||
<div className="flex items-center justify-end gap-1">
|
||
CPF
|
||
<ArrowUpDown className="h-4 w-4" />
|
||
</div>
|
||
</TableHead>
|
||
)}
|
||
{visibleColumns.has("cpm") && (
|
||
<TableHead
|
||
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||
onClick={() => handleSort("cpm")}
|
||
>
|
||
<div className="flex items-center justify-end gap-1">
|
||
CPM
|
||
<ArrowUpDown className="h-4 w-4" />
|
||
</div>
|
||
</TableHead>
|
||
)}
|
||
{visibleColumns.has("post_url") && (
|
||
<TableHead className="whitespace-nowrap text-center">Пост</TableHead>
|
||
)}
|
||
{visibleColumns.has("placement_type") && (
|
||
<TableHead className="whitespace-nowrap">Тип закупа</TableHead>
|
||
)}
|
||
{visibleColumns.has("cost_type") && (
|
||
<TableHead className="whitespace-nowrap">Формат</TableHead>
|
||
)}
|
||
{visibleColumns.has("cost_before_bargain") && (
|
||
<TableHead className="whitespace-nowrap">До торга</TableHead>
|
||
)}
|
||
{visibleColumns.has("payment_at") && (
|
||
<TableHead className="whitespace-nowrap">Оплата</TableHead>
|
||
)}
|
||
{visibleColumns.has("comment") && (
|
||
<TableHead className="whitespace-nowrap">Комментарий</TableHead>
|
||
)}
|
||
{visibleColumns.has("invite_link") && (
|
||
<TableHead className="whitespace-nowrap">Ссылка</TableHead>
|
||
)}
|
||
{visibleColumns.has("invite_link_type") && (
|
||
<TableHead className="whitespace-nowrap">Тип ссылки</TableHead>
|
||
)}
|
||
{visibleColumns.has("format") && (
|
||
<TableHead className="whitespace-nowrap">Формат</TableHead>
|
||
)}
|
||
{visibleColumns.has("time_on_top") && (
|
||
<TableHead className="whitespace-nowrap text-right">В топе</TableHead>
|
||
)}
|
||
{visibleColumns.has("time_in_feed") && (
|
||
<TableHead className="whitespace-nowrap text-right">В ленте</TableHead>
|
||
)}
|
||
{visibleColumns.has("discount_percent") && (
|
||
<TableHead className="whitespace-nowrap text-right">% скидки</TableHead>
|
||
)}
|
||
{visibleColumns.has("conversion_24h") && (
|
||
<TableHead className="whitespace-nowrap text-right">Конв. 24ч</TableHead>
|
||
)}
|
||
{visibleColumns.has("conversion_48h") && (
|
||
<TableHead className="whitespace-nowrap text-right">Конв. 48ч</TableHead>
|
||
)}
|
||
{visibleColumns.has("conversion_total") && (
|
||
<TableHead className="whitespace-nowrap text-right">Конв. общ.</TableHead>
|
||
)}
|
||
{visibleColumns.has("unsubscriptions_count") && (
|
||
<TableHead className="whitespace-nowrap text-right">Отписки</TableHead>
|
||
)}
|
||
{visibleColumns.has("unsub_percent") && (
|
||
<TableHead className="whitespace-nowrap text-right">% отписок</TableHead>
|
||
)}
|
||
{visibleColumns.has("total_active") && (
|
||
<TableHead className="whitespace-nowrap text-right">Итого</TableHead>
|
||
)}
|
||
{visibleColumns.has("post_deleted_at") && (
|
||
<TableHead className="whitespace-nowrap">Удалён</TableHead>
|
||
)}
|
||
{visibleColumns.has("invite_link_created_at") && (
|
||
<TableHead className="whitespace-nowrap">Дата ссылки</TableHead>
|
||
)}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{sortedPlacements.map((placement) => (
|
||
<TableRow key={placement.id}>
|
||
{visibleColumns.has("placement_date") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "placement_date")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("cost") && (
|
||
<TableCell className="whitespace-nowrap font-medium">
|
||
{renderCell(placement, "cost")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("project_title") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{placement.project_title}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("channel_title") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{placement.channel_title}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("creative_name") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{placement.creative_name || "—"}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("subscriptions_count") && (
|
||
<TableCell className="text-right whitespace-nowrap">
|
||
{renderCell(placement, "subscriptions_count")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("views_count") && (
|
||
<TableCell className="text-right whitespace-nowrap">
|
||
{renderCell(placement, "views_count")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("cpf") && (
|
||
<TableCell className="text-right whitespace-nowrap">
|
||
{renderCell(placement, "cpf")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("cpm") && (
|
||
<TableCell className="text-right whitespace-nowrap">
|
||
{renderCell(placement, "cpm")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("post_url") && (
|
||
<TableCell className="whitespace-nowrap text-center">
|
||
{renderCell(placement, "post_url")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("placement_type") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "placement_type")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("cost_type") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "cost_type")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("cost_before_bargain") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "cost_before_bargain")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("payment_at") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "payment_at")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("comment") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "comment")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("invite_link") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{placement.invite_link ? (
|
||
<a href={placement.invite_link} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||
Ссылка
|
||
</a>
|
||
) : "—"}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("invite_link_type") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "invite_link_type")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("format") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{placement.format || "—"}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("time_on_top") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{placement.time_on_top ? `${placement.time_on_top} мин` : "—"}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("time_in_feed") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{placement.time_in_feed ? `${placement.time_in_feed} мин` : "—"}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("discount_percent") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{renderCell(placement, "discount_percent")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("conversion_24h") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{renderCell(placement, "conversion_24h")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("conversion_48h") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{renderCell(placement, "conversion_48h")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("conversion_total") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{renderCell(placement, "conversion_total")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("unsubscriptions_count") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{renderCell(placement, "unsubscriptions_count")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("unsub_percent") && (
|
||
<TableCell className="whitespace-nowrap text-right">
|
||
{renderCell(placement, "unsub_percent")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("total_active") && (
|
||
<TableCell className="whitespace-nowrap text-right font-medium">
|
||
{renderCell(placement, "total_active")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("post_deleted_at") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "post_deleted_at")}
|
||
</TableCell>
|
||
)}
|
||
{visibleColumns.has("invite_link_created_at") && (
|
||
<TableCell className="whitespace-nowrap">
|
||
{renderCell(placement, "invite_link_created_at")}
|
||
</TableCell>
|
||
)}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|