Files
tgex-frontend/app/dashboard/[workspaceId]/analytics/placements/page.tsx
2026-02-03 18:55:09 +03:00

762 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 } = useWorkspace();
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>
</>
);
}