feat: global ui & functional update
This commit is contained in:
517
app/dashboard/[workspaceId]/analytics/placements/page.tsx
Normal file
517
app/dashboard/[workspaceId]/analytics/placements/page.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user