Files
tgex-frontend/app/dashboard/[workspaceId]/analytics/placements/page.tsx
2026-01-21 22:43:54 +03:00

518 lines
21 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";
// ============================================================================
// 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<PlacementAnalyticsItem[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
// Filters
const [selectedProjectId, setSelectedProjectId] = useState<string>("all");
const [selectedChannelId, setSelectedChannelId] = useState<string>("all");
const [selectedCreativeId, setSelectedCreativeId] = useState<string>("all");
const [dateRange, setDateRange] = useState<DateRange>({ from: undefined, to: undefined });
// Sorting
const [sortConfig, setSortConfig] = useState<SortConfig>({
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<string, string>();
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 (
<>
<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>
<h1 className="text-2xl font-bold tracking-tight">Все размещения</h1>
<p className="text-muted-foreground">
Сводная таблица по всем завершённым размещениям
</p>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Всего размещений</CardDescription>
<CardTitle className="text-2xl">{formatNumber(placements.length)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Сумма расходов</CardDescription>
<CardTitle className="text-2xl">{formatCurrency(totals.cost)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Подписчиков</CardDescription>
<CardTitle className="text-2xl">{formatNumber(totals.subscriptions_count)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Просмотров</CardDescription>
<CardTitle className="text-2xl">{formatNumber(totals.views_count)}</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Фильтры</CardTitle>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={resetFilters}>
<X className="h-4 w-4 mr-2" />
Сбросить
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-5">
<div className="space-y-2">
<label className="text-sm font-medium">Проект</label>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger>
<SelectValue placeholder="Все проекты" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все проекты</SelectItem>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Канал</label>
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
<SelectTrigger>
<SelectValue placeholder="Все каналы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Креатив</label>
<Select value={selectedCreativeId} onValueChange={setSelectedCreativeId}>
<SelectTrigger>
<SelectValue placeholder="Все креативы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все креативы</SelectItem>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Период</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateRange.from && !dateRange.to && "text-muted-foreground"
)}
>
<Calendar className="mr-2 h-4 w-4" />
{dateRange.from && dateRange.to
? `${format(dateRange.from, "d MMM", { locale: ru })} - ${format(dateRange.to, "d MMM yyyy", { locale: ru })}`
: dateRange.from
? `${format(dateRange.from, "d MMM yyyy", { locale: ru })} - ...`
: "Выберите период"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="range"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selected={dateRange as any}
onSelect={(range: DateRange | undefined) => setDateRange({ from: range?.from, to: range?.to })}
initialFocus
numberOfMonths={2}
locale={ru}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-end">
<Button
variant="outline"
className="w-full"
onClick={loadPlacements}
>
<Filter className="h-4 w-4 mr-2" />
Применить
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<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>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("placement_date")}
>
<div className="flex items-center gap-1">
Дата
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("cost")}
>
<div className="flex items-center gap-1">
Стоимость
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead>Канал размещения</TableHead>
<TableHead>Проект</TableHead>
<TableHead>Креатив</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("subscriptions_count")}
>
<div className="flex items-center justify-end gap-1">
Подписчики
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("views_count")}
>
<div className="flex items-center justify-end gap-1">
Просмотры
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("cpf")}
>
<div className="flex items-center justify-end gap-1">
CPF
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("cpm")}
>
<div className="flex items-center justify-end gap-1">
CPM
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead className="text-center">Пост</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedPlacements.map((placement) => (
<TableRow key={placement.id}>
<TableCell className="whitespace-nowrap">
{formatDate(placement.placement_date)}
</TableCell>
<TableCell className="whitespace-nowrap font-medium">
{formatCurrency(placement.cost)}
</TableCell>
<TableCell className="whitespace-nowrap">
{placement.placement_channel_title}
</TableCell>
<TableCell className="whitespace-nowrap">
{placement.project_channel_title}
</TableCell>
<TableCell className="whitespace-nowrap">
{placement.creative_name}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatNumber(placement.subscriptions_count)}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatNumber(placement.views_count)}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatCurrency(placement.cpf)}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatCurrency(placement.cpm)}
</TableCell>
<TableCell className="text-center">
{placement.post_url ? (
<a
href={placement.post_url}
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>
)}
</TableCell>
</TableRow>
))}
{/* Totals Row */}
<TableRow className="bg-muted/50 font-medium">
<TableCell>ИТОГО</TableCell>
<TableCell>{formatCurrency(totals.cost)}</TableCell>
<TableCell colSpan={2}></TableCell>
<TableCell className="text-right">{formatNumber(totals.subscriptions_count)}</TableCell>
<TableCell className="text-right">{formatNumber(totals.views_count)}</TableCell>
<TableCell className="text-right">{formatCurrency(totals.cpf)}</TableCell>
<TableCell className="text-right">{formatCurrency(totals.cpm)}</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}