From 1f36a074afc4adfa94161106d363bde7ab643b6d Mon Sep 17 00:00:00 2001 From: ivannoskov Date: Mon, 12 Jan 2026 12:46:29 +0300 Subject: [PATCH] feat: update analytics --- ANALYTICS_API_REQUIREMENTS.md | 183 +++ .../analytics/creatives/page.tsx | 317 +---- .../[workspaceId]/analytics/page.tsx | 282 ---- .../[workspaceId]/analytics/projects/page.tsx | 1180 +++++++++++++++++ components/layout/app-sidebar.tsx | 2 +- lib/api/analytics.ts | 11 + lib/demo/demo-aware-api.ts | 159 +++ lib/demo/mock-data.ts | 80 ++ lib/types/api.ts | 55 +- lib/utils/constants.ts | 8 +- public/logo_full_dark.png | Bin 0 -> 28603 bytes public/logo_full_dark.webp | Bin 9618 -> 0 bytes ...o_full_white_2.png => logo_full_white.png} | Bin public/logo_full_white.webp | Bin 10304 -> 0 bytes 14 files changed, 1683 insertions(+), 594 deletions(-) create mode 100644 ANALYTICS_API_REQUIREMENTS.md delete mode 100644 app/dashboard/[workspaceId]/analytics/page.tsx create mode 100644 app/dashboard/[workspaceId]/analytics/projects/page.tsx create mode 100644 public/logo_full_dark.png delete mode 100644 public/logo_full_dark.webp rename public/{logo_full_white_2.png => logo_full_white.png} (100%) delete mode 100644 public/logo_full_white.webp diff --git a/ANALYTICS_API_REQUIREMENTS.md b/ANALYTICS_API_REQUIREMENTS.md new file mode 100644 index 0000000..a455372 --- /dev/null +++ b/ANALYTICS_API_REQUIREMENTS.md @@ -0,0 +1,183 @@ +# Требования к API для аналитики по проектам + +## Endpoint + +``` +GET /api/v1/workspaces/{workspace_id}/analytics/projects +``` + +## Параметры запроса (Query Parameters) + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|-------------|----------| +| `project_ids` | `string[]` | Нет | Массив UUID проектов для фильтрации. Если не указан - все проекты workspace | +| `date_from` | `string (ISO 8601)` | Нет | Начало периода (например: `2024-12-01T00:00:00Z`) | +| `date_to` | `string (ISO 8601)` | Нет | Конец периода (например: `2024-12-31T23:59:59Z`) | +| `grouping` | `enum: "day" \| "week" \| "month" \| "quarter"` | Да | Группировка данных по периодам | +| `date_grouping` | `enum: "purchase_date" \| "link_date" \| "placement_date"` | Да | По какой дате группировать размещения | +| `metrics` | `string[]` | Нет | Массив метрик для расчета. Если не указан - возвращаются все доступные метрики | + +### Доступные метрики: +- `total_cost` - Сумма расходов +- `purchases_count` - Количество закупов (размещений) +- `total_subscriptions` - Общее количество подписок +- `total_views` - Общее количество просмотров +- `avg_cpf` - Средний CPF (cost per follower) +- `avg_cpm` - Средний CPM (cost per mille) +- `avg_post_cost` - Средняя стоимость поста +- `clicks_count` - Количество переходов (кликов) +- `reach_volume` - Объем охвата +- `total_discounts` - Сумма скидок +- `avg_discount_percent` - Средний процент скидки +- `avg_conversion` - Средняя конверсия закупов + +## Формат ответа + +```json +{ + "periods": [ + { + "period": "2024-12-01", + "period_label": "1 дек", + "metrics": { + "total_cost": 5500.0, + "purchases_count": 3, + "total_subscriptions": 156, + "total_views": 8500, + "avg_cpf": 35.26, + "avg_cpm": 647.06, + "avg_post_cost": 1833.33, + "clicks_count": 156, + "reach_volume": 8500, + "total_discounts": 0.0, + "avg_discount_percent": 0.0, + "avg_conversion": 0.0 + } + }, + { + "period": "2024-12-02", + "period_label": "2 дек", + "metrics": { + "total_cost": 3200.0, + "purchases_count": 2, + "total_subscriptions": 89, + "total_views": 4200, + "avg_cpf": 35.96, + "avg_cpm": 761.9, + "avg_post_cost": 1600.0, + "clicks_count": 89, + "reach_volume": 4200, + "total_discounts": 0.0, + "avg_discount_percent": 0.0, + "avg_conversion": 0.0 + } + } + ], + "totals": { + "total_cost": 8700.0, + "purchases_count": 5, + "total_subscriptions": 245, + "total_views": 12700, + "avg_cpf": 35.51, + "avg_cpm": 685.04, + "avg_post_cost": 1740.0, + "clicks_count": 245, + "reach_volume": 12700, + "total_discounts": 0.0, + "avg_discount_percent": 0.0, + "avg_conversion": 0.0 + } +} +``` + +## Примеры запросов + +### Пример 1: График расходов по дням за декабрь +```http +GET /api/v1/workspaces/123e4567-e89b-12d3-a456-426614174000/analytics/projects?grouping=day&date_grouping=placement_date&date_from=2024-12-01T00:00:00Z&date_to=2024-12-31T23:59:59Z&metrics[]=total_cost&project_ids[]=proj-001&project_ids[]=proj-002 +``` + +### Пример 2: Средний CPF по неделям +```http +GET /api/v1/workspaces/123e4567-e89b-12d3-a456-426614174000/analytics/projects?grouping=week&date_grouping=placement_date&metrics[]=avg_cpf&metrics[]=total_subscriptions +``` + +### Пример 3: Все метрики по месяцам +```http +GET /api/v1/workspaces/123e4567-e89b-12d3-a456-426614174000/analytics/projects?grouping=month&date_grouping=purchase_date&date_from=2024-01-01T00:00:00Z&date_to=2024-12-31T23:59:59Z +``` + +## Формат периода (period) + +В зависимости от `grouping`: +- `day`: `"YYYY-MM-DD"` (например: `"2024-12-01"`) +- `week`: `"YYYY-Www"` (например: `"2024-W49"`) - ISO week format +- `month`: `"YYYY-MM"` (например: `"2024-12"`) +- `quarter`: `"YYYY-QN"` (например: `"2024-Q4"`) + +## Формат period_label + +Человекочитаемая метка периода для отображения на графике: +- `day`: `"1 дек"` или `"1 дек 2024"` +- `week`: `"49 нед. 2024"` или `"1-7 дек"` +- `month`: `"дек 2024"` +- `quarter`: `"Q4 2024"` + +## Логика расчета метрик + +### Агрегации (SUM): +- `total_cost` - сумма всех `cost` +- `total_subscriptions` - сумма всех `subscriptions_count` +- `total_views` - сумма всех `views_count` +- `total_discounts` - сумма всех скидок +- `clicks_count` - сумма всех кликов +- `reach_volume` - сумма всех просмотров (аналогично `total_views`) + +### Счетчики (COUNT): +- `purchases_count` - количество размещений в периоде + +### Средние значения (AVG): +- `avg_cpf` = `total_cost / total_subscriptions` (если `total_subscriptions > 0`) +- `avg_cpm` = `(total_cost / total_views) * 1000` (если `total_views > 0`) +- `avg_post_cost` = `total_cost / purchases_count` (если `purchases_count > 0`) +- `avg_discount_percent` = средний процент скидки по размещениям +- `avg_conversion` = средняя конверсия закупов + +### Totals (итоговые значения): +Все метрики в `totals` рассчитываются как агрегация по всем периодам в ответе. + +## Обработка ошибок + +### 400 Bad Request +```json +{ + "detail": [ + { + "loc": ["query", "grouping"], + "msg": "Invalid grouping value. Must be one of: day, week, month, quarter", + "type": "value_error" + } + ] +} +``` + +### 422 Unprocessable Entity +```json +{ + "detail": [ + { + "loc": ["query", "date_from"], + "msg": "date_from must be before date_to", + "type": "value_error" + } + ] +} +``` + +## Примечания + +- Если для метрики нет данных (например, `total_discounts`), возвращается `0` или `null` +- Если знаменатель равен 0 при расчете средних значений, возвращается `null` +- Периоды сортируются по возрастанию (`period`) +- Если данных нет для какого-то периода, он не включается в ответ (или включается с нулевыми значениями - на усмотрение бэкенда) + diff --git a/app/dashboard/[workspaceId]/analytics/creatives/page.tsx b/app/dashboard/[workspaceId]/analytics/creatives/page.tsx index cd091c3..7b7f421 100644 --- a/app/dashboard/[workspaceId]/analytics/creatives/page.tsx +++ b/app/dashboard/[workspaceId]/analytics/creatives/page.tsx @@ -4,180 +4,13 @@ // Creatives Analytics Page // ============================================================================ -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import Link from "next/link"; -import { - Loader2, - Folder, - ArrowUpDown, - ArrowUp, - ArrowDown, -} from "lucide-react"; import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { useWorkspace } from "@/components/providers/workspace-provider"; -import { demoAwareAnalyticsApi } from "@/lib/demo"; -import { ProjectSelector } from "@/components/project-selector"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import type { CreativeAnalyticsItem } from "@/lib/types/api"; - -// ============================================================================ -// Helpers -// ============================================================================ - -const formatCurrency = (value: number | null) => { - if (value === null) return "—"; - return new Intl.NumberFormat("ru-RU", { - style: "currency", - currency: "RUB", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); -}; - -const formatNumber = (value: number | null) => { - if (value === null) return "—"; - return new Intl.NumberFormat("ru-RU").format(value); -}; - -const getCreativeWord = (count: number): string => { - const mod10 = count % 10; - const mod100 = count % 100; - - if (mod100 >= 11 && mod100 <= 14) { - return "креативов"; - } - - if (mod10 === 1) { - return "креатив"; - } - - if (mod10 >= 2 && mod10 <= 4) { - return "креатива"; - } - - return "креативов"; -}; - -// ============================================================================ -// Types -// ============================================================================ - -type SortField = - | "placements" - | "cost" - | "subscriptions" - | "views" - | "cps" - | "cpm"; -type SortOrder = "asc" | "desc" | null; - -// ============================================================================ -// Component -// ============================================================================ +import { useParams } from "next/navigation"; +import { Card, CardContent } from "@/components/ui/card"; export default function CreativesAnalyticsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; - const { getProjectFilterId } = useWorkspace(); - const projectFilterId = getProjectFilterId(); - - const [creatives, setCreatives] = useState([]); - const [loading, setLoading] = useState(true); - const [sortField, setSortField] = useState("cost"); - const [sortOrder, setSortOrder] = useState("desc"); - - useEffect(() => { - loadData(); - }, [workspaceId, projectFilterId]); - - const loadData = async () => { - try { - setLoading(true); - const response = await demoAwareAnalyticsApi.creatives(workspaceId, { - project_id: projectFilterId, - size: 100, - }); - setCreatives(response.items); - } catch (err) { - console.error("Failed to load creatives analytics:", err); - } finally { - setLoading(false); - } - }; - - // Sort handler - const handleSort = (field: SortField) => { - if (sortField === field) { - if (sortOrder === "asc") setSortOrder("desc"); - else if (sortOrder === "desc") { - setSortField(null); - setSortOrder(null); - } else setSortOrder("asc"); - } else { - setSortField(field); - setSortOrder("desc"); - } - }; - - const getSortIcon = (field: SortField) => { - if (sortField !== field) return ; - if (sortOrder === "asc") return ; - if (sortOrder === "desc") return ; - return ; - }; - - // Sort data - const sortedCreatives = [...creatives].sort((a, b) => { - if (!sortField || !sortOrder) return 0; - - let aVal = 0; - let bVal = 0; - - switch (sortField) { - case "placements": - aVal = a.placements_count; - bVal = b.placements_count; - break; - case "cost": - aVal = a.total_cost; - bVal = b.total_cost; - break; - case "subscriptions": - aVal = a.total_subscriptions; - bVal = b.total_subscriptions; - break; - case "views": - aVal = a.total_views; - bVal = b.total_views; - break; - case "cps": - aVal = a.avg_cpf ?? 0; - bVal = b.avg_cpf ?? 0; - break; - case "cpm": - aVal = a.avg_cpm ?? 0; - bVal = b.avg_cpm ?? 0; - break; - } - - return sortOrder === "asc" ? aVal - bVal : bVal - aVal; - }); return ( <> @@ -185,144 +18,28 @@ export default function CreativesAnalyticsPage() { breadcrumbs={[ { label: "Главная", href: `/dashboard/${workspaceId}` }, { label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` }, - { label: "Креативы" }, + { label: "По креативам" }, ]} />
-
-
-

- Аналитика по креативам -

-

- Сравнение эффективности рекламных материалов -

-
- +
+

+ Аналитика по креативам +

+

+ Раздел в разработке +

- {loading ? ( -
- -
- ) : sortedCreatives.length === 0 ? ( - - - -

Нет данных

-

- Создайте размещения для отображения статистики по креативам -

-
-
- ) : ( - - - Креативы - - {sortedCreatives.length} {getCreativeWord(sortedCreatives.length)} с размещениями - - - - - - Креатив - - - - - - - - - - - - - - - - - - - - - - {sortedCreatives.map((creative) => ( - - - - {creative.name} - - - {creative.placements_count} - {formatCurrency(creative.total_cost)} - - {formatNumber(creative.total_subscriptions)} - - {formatNumber(creative.total_views)} - {formatCurrency(creative.avg_cpf)} - {formatCurrency(creative.avg_cpm)} - - ))} - -
-
- )} + + +

+ Раздел будет реализован позже +

+
+
); } - diff --git a/app/dashboard/[workspaceId]/analytics/page.tsx b/app/dashboard/[workspaceId]/analytics/page.tsx deleted file mode 100644 index 8e9829a..0000000 --- a/app/dashboard/[workspaceId]/analytics/page.tsx +++ /dev/null @@ -1,282 +0,0 @@ -"use client"; - -// ============================================================================ -// Analytics Overview Page -// ============================================================================ - -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import Link from "next/link"; -import { - Loader2, - Users, - Eye, - DollarSign, - ArrowRight, - BarChart3, - Info, -} from "lucide-react"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { useWorkspace } from "@/components/providers/workspace-provider"; -import { demoAwareAnalyticsApi } from "@/lib/demo"; -import { ProjectSelector } from "@/components/project-selector"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import type { SpendingAnalytics } from "@/lib/types/api"; - -// ============================================================================ -// Helpers -// ============================================================================ - -const formatCurrency = (value: number | null) => { - if (value === null) return "—"; - return new Intl.NumberFormat("ru-RU", { - style: "currency", - currency: "RUB", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); -}; - -const formatNumber = (value: number | null) => { - if (value === null) return "—"; - return new Intl.NumberFormat("ru-RU").format(value); -}; - -// ============================================================================ -// Component -// ============================================================================ - -export default function AnalyticsPage() { - const params = useParams(); - const workspaceId = params?.workspaceId as string; - const { - selectedProjects, - getProjectFilterId, - allProjectsSelected, - projects - } = useWorkspace(); - - const [spending, setSpending] = useState(null); - const [loading, setLoading] = useState(true); - - const projectFilterId = getProjectFilterId(); - - useEffect(() => { - loadAnalytics(); - }, [workspaceId, projectFilterId]); - - const loadAnalytics = async () => { - try { - setLoading(true); - const data = await demoAwareAnalyticsApi.spending(workspaceId, { - project_id: projectFilterId, - grouping: "month", - }); - setSpending(data); - } catch (err) { - console.error("Failed to load analytics:", err); - } finally { - setLoading(false); - } - }; - - // Calculate averages - const avgCps = - spending && spending.total_subscriptions > 0 - ? spending.total_cost / spending.total_subscriptions - : null; - const avgCpm = - spending && spending.total_views > 0 - ? (spending.total_cost / spending.total_views) * 1000 - : null; - - return ( - <> - - -
-
-
-

Аналитика

-

- Обзор эффективности рекламных размещений -

-
- -
- - {selectedProjects.length > 1 && !allProjectsSelected && ( - - - - Выбрано {selectedProjects.length} проекта: {selectedProjects.map(p => p.title).join(", ")}. - Показана суммарная аналитика по всем выбранным проектам. - - - )} - - {loading ? ( -
- -
- ) : ( - <> - {/* Summary Cards */} -
- - - - Всего потрачено - - - - -
- {formatCurrency(spending?.total_cost ?? 0)} -
-
-
- - - - - Подписчиков - - - - -
- {formatNumber(spending?.total_subscriptions ?? 0)} -
-

- Средний CPS: {avgCps ? formatCurrency(avgCps) : "—"} -

-
-
- - - - Просмотров - - - -
- {formatNumber(spending?.total_views ?? 0)} -
-

- Средний CPM: {avgCpm ? formatCurrency(avgCpm) : "—"} -

-
-
- - - - - Периодов данных - - - - -
- {spending?.chart_data?.length ?? 0} -
-

месяцев

-
-
-
- - {/* Quick Links */} -
- - - - - Динамика расходов - - - - График расходов по времени с группировкой - - - - - - - - - - Аналитика по каналам - - - - Эффективность каналов размещения - - - - - - - - - - Аналитика по креативам - - - - Сравнение эффективности креативов - - - - -
- - {/* Recent Activity */} - {spending && spending.chart_data && spending.chart_data.length > 0 && ( - - - Последние периоды - Динамика по периодам - - -
- {spending.chart_data.slice(0, 6).map((item, index) => ( -
-
-
{item.period}
-
- {formatNumber(item.subscriptions)} подписчиков •{" "} - {formatNumber(item.views)} просмотров -
-
-
-
- {formatCurrency(item.cost)} -
-
-
- ))} -
-
-
- )} - - )} -
- - ); -} diff --git a/app/dashboard/[workspaceId]/analytics/projects/page.tsx b/app/dashboard/[workspaceId]/analytics/projects/page.tsx new file mode 100644 index 0000000..055b1cf --- /dev/null +++ b/app/dashboard/[workspaceId]/analytics/projects/page.tsx @@ -0,0 +1,1180 @@ +"use client"; + +// ============================================================================ +// Projects Analytics Page with Drag-and-Drop Panels +// ============================================================================ + +import { useEffect, useState, useMemo, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { + Loader2, + Plus, + X, + GripVertical, + Calendar as CalendarIcon, + Download, +} from "lucide-react"; +import { format, subDays, startOfDay, endOfDay } from "date-fns"; +import { ru } from "date-fns/locale"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + rectSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { DashboardHeader } from "@/components/layout/dashboard-header"; +import { useWorkspace } from "@/components/providers/workspace-provider"; +import { demoAwareAnalyticsApi } from "@/lib/demo"; +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 { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { + LineChart, + Line, + AreaChart, + Area, + BarChart, + Bar, + CartesianGrid, + XAxis, + YAxis, +} from "recharts"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import type { + Project, + ProjectsAnalyticsResponse, + ProjectsAnalyticsMetrics, +} from "@/lib/types/api"; + +// ============================================================================ +// Types +// ============================================================================ + +type DateRangeType = { + from?: Date; + to?: Date; +} | undefined; + +type MetricType = + | "avg_conversion" + | "total_cost" + | "total_discounts" + | "avg_discount_percent" + | "purchases_count" + | "avg_cpm" + | "avg_post_cost" + | "clicks_count" + | "reach_volume" + | "avg_cpf"; + +type GroupingType = "day" | "week" | "month" | "quarter"; +type ChartType = "area" | "bar" | "line"; + +interface ChartSeries { + id: string; + projectIds: string[]; + metric: MetricType; +} + +interface ChartPanelConfig { + id: string; + title: string; + series: ChartSeries[]; // Массив графиков (линий) + chartType: ChartType; // Тип графика + dateRange: DateRangeType; + grouping: GroupingType; + dateGrouping: "purchase_date" | "link_date" | "placement_date"; + colSpan: 1 | 2; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +const formatCurrency = (value: number | null) => { + if (value === null) return "—"; + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: "RUB", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); +}; + +const formatNumber = (value: number | null) => { + if (value === null) return "—"; + return new Intl.NumberFormat("ru-RU").format(value); +}; + +const formatPercent = (value: number | null) => { + if (value === null) return "—"; + return `${value.toFixed(1)}%`; +}; + +const getMetricLabel = (metric: MetricType): string => { + const labels: Record = { + avg_conversion: "Средняя конверсия закупов", + total_cost: "Сумма расходов", + total_discounts: "Сумма скидок", + avg_discount_percent: "Средний % скидки", + purchases_count: "Кол-во закупов", + avg_cpm: "Средний CPM", + avg_post_cost: "Средняя стоимость поста", + clicks_count: "Кол-во переходов", + reach_volume: "Объём охвата", + avg_cpf: "Средний CPF", + }; + return labels[metric]; +}; + +const getMetricFormatter = (metric: MetricType) => { + if (metric === "avg_discount_percent" || metric === "avg_conversion") { + return formatPercent; + } + if ( + metric === "total_cost" || + metric === "total_discounts" || + metric === "avg_cpm" || + metric === "avg_post_cost" || + metric === "avg_cpf" + ) { + return formatCurrency; + } + return formatNumber; +}; + +const getMetricValue = ( + metrics: ProjectsAnalyticsMetrics, + metric: MetricType +): number | null => { + switch (metric) { + case "total_cost": + return metrics.total_cost; + case "purchases_count": + return metrics.purchases_count; + case "avg_cpf": + return metrics.avg_cpf; + case "avg_cpm": + return metrics.avg_cpm; + case "avg_post_cost": + return metrics.avg_post_cost; + case "clicks_count": + return metrics.clicks_count; + case "reach_volume": + return metrics.reach_volume; + case "total_discounts": + return metrics.total_discounts; + case "avg_discount_percent": + return metrics.avg_discount_percent; + case "avg_conversion": + return metrics.avg_conversion; + default: + return null; + } +}; + +// Chart color keys for different series +const CHART_COLOR_KEYS = [ + "chart-1", + "chart-2", + "chart-3", + "chart-4", + "chart-5", +]; + +// ============================================================================ +// Sortable Panel Component +// ============================================================================ + +interface SortablePanelProps { + panel: ChartPanelConfig; + projects: Project[]; + onUpdate: (panel: ChartPanelConfig) => void; + onDelete: (id: string) => void; + workspaceId: string; +} + +const SortablePanel: React.FC = ({ + panel, + projects, + onUpdate, + onDelete, + workspaceId, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: panel.id }); + + const [loading, setLoading] = useState(false); + const [seriesData, setSeriesData] = useState< + Record + >({}); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + // Load chart data for all series + useEffect(() => { + loadAllSeriesData(); + }, [ + panel.series, + panel.dateRange, + panel.grouping, + panel.dateGrouping, + workspaceId, + ]); + + const loadAllSeriesData = async () => { + if (panel.series.length === 0) { + setSeriesData({}); + return; + } + + try { + setLoading(true); + const dataPromises = panel.series.map(async (series) => { + try { + const response = await demoAwareAnalyticsApi.projects(workspaceId, { + project_ids: + series.projectIds && series.projectIds.length > 0 + ? series.projectIds + : undefined, + date_from: panel.dateRange?.from + ? startOfDay(panel.dateRange.from).toISOString() + : undefined, + date_to: panel.dateRange?.to + ? endOfDay(panel.dateRange.to).toISOString() + : undefined, + grouping: panel.grouping, + date_grouping: panel.dateGrouping, + metrics: [series.metric], + }); + return { seriesId: series.id, data: response }; + } catch (err) { + console.error(`Failed to load data for series ${series.id}:`, err); + return { seriesId: series.id, data: null }; + } + }); + + const results = await Promise.all(dataPromises); + const newSeriesData: Record = {}; + results.forEach(({ seriesId, data }) => { + if (data) { + newSeriesData[seriesId] = data; + } + }); + setSeriesData(newSeriesData); + } catch (err) { + console.error("Failed to load chart data:", err); + } finally { + setLoading(false); + } + }; + + // Merge all series data into one chart dataset + const chartData = useMemo(() => { + if (panel.series.length === 0) return []; + + // Get all unique periods from all series + const allPeriods = new Set(); + Object.values(seriesData).forEach((data) => { + data.periods.forEach((period) => allPeriods.add(period.period)); + }); + + const sortedPeriods = Array.from(allPeriods).sort(); + + // Create merged data + return sortedPeriods.map((period) => { + const periodData: any = { + period, + periodLabel: "", + }; + + // Find period label from first available series + const firstSeriesData = Object.values(seriesData)[0]; + if (firstSeriesData) { + const periodInfo = firstSeriesData.periods.find((p) => p.period === period); + if (periodInfo) { + periodData.periodLabel = periodInfo.period_label; + } + } + + // Add value for each series + panel.series.forEach((series, index) => { + const data = seriesData[series.id]; + if (data) { + const periodInfo = data.periods.find((p) => p.period === period); + if (periodInfo) { + const value = getMetricValue(periodInfo.metrics, series.metric); + periodData[`series_${series.id}`] = value ?? null; + } else { + periodData[`series_${series.id}`] = null; + } + } else { + periodData[`series_${series.id}`] = null; + } + }); + + return periodData; + }); + }, [panel.series, seriesData]); + + // Build chart config for all series + const chartConfig = useMemo(() => { + const config: ChartConfig = {}; + panel.series.forEach((series, index) => { + const projectNames = + series.projectIds && series.projectIds.length > 0 + ? series.projectIds + .map((id) => projects.find((p) => p.id === id)?.title) + .filter(Boolean) + .join(", ") || "Все проекты" + : "Все проекты"; + const metricLabel = getMetricLabel(series.metric); + const colorIndex = (index % CHART_COLOR_KEYS.length) + 1; + config[`series_${series.id}`] = { + label: `${metricLabel} (${projectNames})`, + color: `var(--chart-${colorIndex})`, + }; + }); + return config; + }, [panel.series, projects]) satisfies ChartConfig; + + const dateRangeLabel = useMemo(() => { + if (panel.dateRange?.from && panel.dateRange?.to) { + return `${format(panel.dateRange.from, "d MMM", { locale: ru })} — ${format(panel.dateRange.to, "d MMM yyyy", { locale: ru })}`; + } + if (panel.dateRange?.from) { + return format(panel.dateRange.from, "d MMM yyyy", { locale: ru }); + } + return "За всё время"; + }, [panel.dateRange]); + + const handleAddSeries = () => { + const newSeries: ChartSeries = { + id: `series-${Date.now()}-${Math.random()}`, + projectIds: projects.length > 0 ? [projects[0].id] : [], + metric: "total_cost", + }; + onUpdate({ + ...panel, + series: [...panel.series, newSeries], + }); + }; + + const handleDeleteSeries = (seriesId: string) => { + onUpdate({ + ...panel, + series: panel.series.filter((s) => s.id !== seriesId), + }); + }; + + const handleUpdateSeries = (updatedSeries: ChartSeries) => { + onUpdate({ + ...panel, + series: panel.series.map((s) => + s.id === updatedSeries.id ? updatedSeries : s + ), + }); + }; + + return ( +
+ + +
+
+
+ +
+ {panel.title} +
+ +
+
+ + {/* Common Panel Settings */} +
+ {/* Chart Type */} +
+ + +
+ + {/* Date Range */} +
+ + + + + + + + onUpdate({ ...panel, dateRange: range }) + } + /> + + +
+ + {/* Grouping */} +
+ + +
+ + {/* Date Grouping */} +
+ + +
+
+ + {/* Series (Graphs) */} +
+ {panel.series.map((series, index) => ( +
+
+ {/* Projects Filter */} +
+ + + + + + +
+ {projects.map((project) => { + const isSelected = (series.projectIds || []).includes( + project.id + ); + return ( +
{ + const currentIds = series.projectIds || []; + const newIds = isSelected + ? currentIds.filter((id) => id !== project.id) + : [...currentIds, project.id]; + handleUpdateSeries({ ...series, projectIds: newIds }); + }} + > + + {project.title} +
+ ); + })} +
+
+
+
+ + {/* Metric Select */} +
+ + +
+
+ {panel.series.length > 1 && ( + + )} +
+ ))} + + +
+ + {/* Chart */} + {loading ? ( +
+ +
+ ) : panel.series.length === 0 ? ( +
+

+ Добавьте график для отображения данных +

+
+ ) : chartData.length === 0 ? ( +
+

+ Нет данных для отображения +

+
+ ) : ( + + + + + { + // Use formatter from first series + const firstSeries = panel.series[0]; + if (firstSeries) { + const formatter = getMetricFormatter(firstSeries.metric); + return formatter(value); + } + return value.toString(); + }} + /> + } + /> + } /> + {panel.series.map((series) => ( + + ))} + + + )} +
+
+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export default function ProjectsAnalyticsPage() { + const params = useParams(); + const workspaceId = params?.workspaceId as string; + const { projects } = useWorkspace(); + + const [panels, setPanels] = useState([]); + const [tableData, setTableData] = useState(null); + const [tableLoading, setTableLoading] = useState(false); + const [tableGrouping, setTableGrouping] = useState("month"); + const [tableDateGrouping, setTableDateGrouping] = useState<"purchase_date" | "link_date" | "placement_date">("placement_date"); + const [tableDateRange, setTableDateRange] = useState(() => ({ + from: subDays(new Date(), 29), + to: new Date(), + })); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Load panels from localStorage + useEffect(() => { + const saved = localStorage.getItem( + `analytics-projects-panels-${workspaceId}` + ); + if (saved) { + try { + const parsed = JSON.parse(saved); + const panelsWithDates = parsed.map((panel: any) => ({ + id: panel.id || `panel-${Date.now()}-${Math.random()}`, + title: panel.title || "Панель", + chartType: panel.chartType || "line", + series: Array.isArray(panel.series) + ? panel.series.map((s: any) => ({ + id: s.id || `series-${Date.now()}-${Math.random()}`, + projectIds: Array.isArray(s.projectIds) ? s.projectIds : [], + metric: s.metric || "total_cost", + })) + : // Migration from old format + panel.projectIds || panel.metric + ? [ + { + id: `series-${Date.now()}-${Math.random()}`, + projectIds: Array.isArray(panel.projectIds) + ? panel.projectIds + : [], + metric: panel.metric || "total_cost", + }, + ] + : [], + dateRange: panel.dateRange + ? { + from: panel.dateRange.from + ? new Date(panel.dateRange.from) + : undefined, + to: panel.dateRange.to ? new Date(panel.dateRange.to) : undefined, + } + : undefined, + grouping: panel.grouping || "day", + dateGrouping: panel.dateGrouping || "placement_date", + colSpan: panel.colSpan || 2, + })); + setPanels(panelsWithDates); + } catch { + // Ignore parse errors + } + } + }, [workspaceId]); + + // Save panels to localStorage + useEffect(() => { + if (panels.length > 0) { + localStorage.setItem( + `analytics-projects-panels-${workspaceId}`, + JSON.stringify(panels) + ); + } + }, [panels, workspaceId]); + + // Load table data + useEffect(() => { + loadTableData(); + }, [workspaceId, tableGrouping, tableDateGrouping, tableDateRange]); + + const loadTableData = async () => { + try { + setTableLoading(true); + const response = await demoAwareAnalyticsApi.projects(workspaceId, { + date_from: tableDateRange?.from + ? startOfDay(tableDateRange.from).toISOString() + : undefined, + date_to: tableDateRange?.to + ? endOfDay(tableDateRange.to).toISOString() + : undefined, + grouping: tableGrouping, + date_grouping: tableDateGrouping, + }); + setTableData(response); + } catch (err) { + console.error("Failed to load table data:", err); + } finally { + setTableLoading(false); + } + }; + + const handleAddPanel = () => { + const defaultProjectIds = + projects.length > 0 ? [projects[0].id] : []; + const newPanel: ChartPanelConfig = { + id: `panel-${Date.now()}`, + title: `Панель ${panels.length + 1}`, + chartType: "line", + series: [ + { + id: `series-${Date.now()}-${Math.random()}`, + projectIds: defaultProjectIds, + metric: "total_cost", + }, + ], + dateRange: { + from: subDays(new Date(), 29), + to: new Date(), + }, + grouping: "day", + dateGrouping: "placement_date", + colSpan: 2, + }; + setPanels([...panels, newPanel]); + }; + + const handleDeletePanel = (id: string) => { + setPanels(panels.filter((p) => p.id !== id)); + }; + + const handleUpdatePanel = (updatedPanel: ChartPanelConfig) => { + setPanels(panels.map((p) => (p.id === updatedPanel.id ? updatedPanel : p))); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setPanels((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + return arrayMove(items, oldIndex, newIndex); + }); + } + }; + + const handleExportTable = () => { + if (!tableData) return; + // TODO: Implement export to Excel/CSV + console.log("Export table", tableData); + }; + + const tableDateRangeLabel = useMemo(() => { + if (tableDateRange?.from && tableDateRange?.to) { + return `${format(tableDateRange.from, "d MMM", { locale: ru })} — ${format(tableDateRange.to, "d MMM yyyy", { locale: ru })}`; + } + if (tableDateRange?.from) { + return format(tableDateRange.from, "d MMM yyyy", { locale: ru }); + } + return "За всё время"; + }, [tableDateRange]); + + return ( + <> + + +
+
+
+

+ Аналитика по проектам +

+

+ Настраиваемые панели графиков с drag-and-drop +

+
+ +
+ + {panels.length === 0 ? ( + + +

+ Нет панелей. Создайте первую панель для начала работы. +

+ +
+
+ ) : ( + + p.id)} + strategy={rectSortingStrategy} + > +
+ {panels.map((panel) => ( + + ))} +
+
+
+ )} + + {/* Table */} + + +
+
+ Таблица показателей + + Периоды в столбцах, показатели в строках + +
+
+ + + + + + + + setTableDateRange(range)} + /> + + + +
+
+
+ + {tableLoading ? ( +
+ +
+ ) : !tableData || tableData.periods.length === 0 ? ( +
+

Нет данных для отображения

+
+ ) : ( +
+ + + + + Показатель + + {tableData.periods.map((period) => ( + + {period.period_label} + + ))} + + Итого + + + + + + + Сумма расходов + + {tableData.periods.map((period) => ( + + {formatCurrency(period.metrics.total_cost)} + + ))} + + {formatCurrency(tableData.totals.total_cost)} + + + + + Кол-во закупов + + {tableData.periods.map((period) => ( + + {formatNumber(period.metrics.purchases_count)} + + ))} + + {formatNumber(tableData.totals.purchases_count)} + + + + + Подписчиков + + {tableData.periods.map((period) => ( + + {formatNumber(period.metrics.total_subscriptions)} + + ))} + + {formatNumber(tableData.totals.total_subscriptions)} + + + + + Просмотров + + {tableData.periods.map((period) => ( + + {formatNumber(period.metrics.total_views)} + + ))} + + {formatNumber(tableData.totals.total_views)} + + + + + Средний CPF + + {tableData.periods.map((period) => ( + + {formatCurrency(period.metrics.avg_cpf)} + + ))} + + {formatCurrency(tableData.totals.avg_cpf)} + + + + + Средний CPM + + {tableData.periods.map((period) => ( + + {formatCurrency(period.metrics.avg_cpm)} + + ))} + + {formatCurrency(tableData.totals.avg_cpm)} + + + +
+
+ )} +
+
+
+ + ); +} diff --git a/components/layout/app-sidebar.tsx b/components/layout/app-sidebar.tsx index 53af49b..a32946f 100644 --- a/components/layout/app-sidebar.tsx +++ b/components/layout/app-sidebar.tsx @@ -424,7 +424,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { items: [ { title: "По проектам", - url: buildRoute.analytics(workspaceId), + url: buildRoute.analyticsProjects(workspaceId), icon: Tv, }, { diff --git a/lib/api/analytics.ts b/lib/api/analytics.ts index c2b5833..c08004a 100644 --- a/lib/api/analytics.ts +++ b/lib/api/analytics.ts @@ -13,6 +13,8 @@ import type { PaginatedResponse, OverviewAnalytics, OverviewAnalyticsQueryParams, + ProjectsAnalyticsResponse, + ProjectsAnalyticsQueryParams, } from "@/lib/types/api"; export const analyticsApi = { @@ -60,4 +62,13 @@ export const analyticsApi = { `/workspaces/${workspaceId}/analytics/overview`, { params } ), + + /** + * Get projects analytics (chart data with grouping) + */ + projects: (workspaceId: string, params: ProjectsAnalyticsQueryParams) => + api.get( + `/workspaces/${workspaceId}/analytics/projects`, + { params } + ), }; diff --git a/lib/demo/demo-aware-api.ts b/lib/demo/demo-aware-api.ts index f0b0325..dcd17e7 100644 --- a/lib/demo/demo-aware-api.ts +++ b/lib/demo/demo-aware-api.ts @@ -3,6 +3,8 @@ // ============================================================================ // This wrapper checks if the workspace is demo and returns mock data instead +import { format, getISOWeek } from "date-fns"; +import { ru } from "date-fns/locale"; import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants"; import { demoChannels, @@ -16,6 +18,7 @@ import { demoWorkspace, demoMember, demoOverviewAnalytics, + demoPlacementAnalytics, } from "./mock-data"; import type { PaginatedResponse, @@ -31,6 +34,9 @@ import type { WorkspaceMember, DateGrouping, OverviewAnalytics, + PlacementAnalyticsItem, + ProjectsAnalyticsResponse, + ProjectsAnalyticsQueryParams, } from "@/lib/types/api"; import { workspacesApi, @@ -349,6 +355,19 @@ export const demoAwareAnalyticsApi = { } return analyticsApi.channels(workspaceId, params); }, + placements: async ( + workspaceId: string, + params?: { project_id?: string; page?: number; size?: number } + ): Promise> => { + if (isDemoWorkspace(workspaceId)) { + let items = demoPlacementAnalytics; + if (params?.project_id) { + items = items.filter((p) => p.project_id === params.project_id); + } + return paginate(items, params?.page, params?.size); + } + return analyticsApi.placements(workspaceId, params); + }, overview: async ( workspaceId: string, params?: { project_id?: string; date_from?: string; date_to?: string } @@ -358,5 +377,145 @@ export const demoAwareAnalyticsApi = { } return analyticsApi.overview(workspaceId, params); }, + projects: async ( + workspaceId: string, + params: ProjectsAnalyticsQueryParams + ): Promise => { + if (isDemoWorkspace(workspaceId)) { + // Generate mock data based on demoPlacementAnalytics + const placements = demoPlacementAnalytics; + let filtered = placements; + + if (params.project_ids && params.project_ids.length > 0) { + filtered = filtered.filter((p) => params.project_ids!.includes(p.project_id)); + } + + if (params.date_from) { + const from = new Date(params.date_from); + filtered = filtered.filter((p) => new Date(p.placement_date) >= from); + } + + if (params.date_to) { + const to = new Date(params.date_to); + filtered = filtered.filter((p) => new Date(p.placement_date) <= to); + } + + // Helper to format period label + const formatPeriodLabel = (period: string, grouping: string): string => { + try { + if (grouping === "day") { + const date = new Date(period + "T00:00:00"); + return format(date, "d MMM", { locale: ru }); + } else if (grouping === "week") { + return period.replace("W", " нед. "); + } else if (grouping === "month") { + const date = new Date(period + "-01"); + return format(date, "MMM yyyy", { locale: ru }); + } else if (grouping === "quarter") { + return period.replace("Q", "Q"); + } + return period; + } catch { + return period; + } + }; + + // Group by period + const grouped: Record = {}; + filtered.forEach((item) => { + const date = new Date(item.placement_date); + let periodKey: string; + + switch (params.grouping) { + case "day": + periodKey = format(date, "yyyy-MM-dd"); + break; + case "week": + const weekNum = getISOWeek(date); + periodKey = `${date.getFullYear()}-W${weekNum.toString().padStart(2, "0")}`; + break; + case "month": + periodKey = format(date, "yyyy-MM"); + break; + case "quarter": + const quarter = Math.floor(date.getMonth() / 3) + 1; + periodKey = `${date.getFullYear()}-Q${quarter}`; + break; + default: + periodKey = format(date, "yyyy-MM-dd"); + } + + if (!grouped[periodKey]) { + grouped[periodKey] = []; + } + grouped[periodKey].push(item); + }); + + // Calculate metrics for each period + const periods = Object.entries(grouped) + .map(([period, items]) => { + const totalCost = items.reduce((sum, item) => sum + (item.cost || 0), 0); + const totalSubs = items.reduce((sum, item) => sum + (item.subscriptions_count || 0), 0); + const totalViews = items.reduce((sum, item) => sum + (item.views_count || 0), 0); + const purchasesCount = items.length; + + return { + period, + period_label: formatPeriodLabel(period, params.grouping), + metrics: { + total_cost: totalCost, + purchases_count: purchasesCount, + total_subscriptions: totalSubs, + total_views: totalViews, + avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null, + avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, + avg_post_cost: purchasesCount > 0 ? totalCost / purchasesCount : null, + clicks_count: totalSubs, // Using subscriptions as proxy + reach_volume: totalViews, + total_discounts: 0, + avg_discount_percent: 0, + avg_conversion: 0, + }, + }; + }) + .sort((a, b) => a.period.localeCompare(b.period)); + + // Calculate totals + const totals = periods.reduce( + (acc, period) => { + acc.total_cost += period.metrics.total_cost; + acc.purchases_count += period.metrics.purchases_count; + acc.total_subscriptions += period.metrics.total_subscriptions; + acc.total_views += period.metrics.total_views; + acc.clicks_count += period.metrics.clicks_count; + acc.reach_volume += period.metrics.reach_volume; + acc.total_discounts += period.metrics.total_discounts || 0; + return acc; + }, + { + total_cost: 0, + purchases_count: 0, + total_subscriptions: 0, + total_views: 0, + clicks_count: 0, + reach_volume: 0, + total_discounts: 0, + } + ); + + return { + periods, + totals: { + ...totals, + avg_cpf: totals.total_subscriptions > 0 ? totals.total_cost / totals.total_subscriptions : null, + avg_cpm: totals.total_views > 0 ? (totals.total_cost / totals.total_views) * 1000 : null, + avg_post_cost: totals.purchases_count > 0 ? totals.total_cost / totals.purchases_count : null, + avg_discount_percent: 0, + avg_conversion: 0, + }, + }; + } + return analyticsApi.projects(workspaceId, params); + }, }; diff --git a/lib/demo/mock-data.ts b/lib/demo/mock-data.ts index 093b095..deb7a5d 100644 --- a/lib/demo/mock-data.ts +++ b/lib/demo/mock-data.ts @@ -15,6 +15,7 @@ import type { CreativeAnalyticsItem, ChannelAnalyticsItem, OverviewAnalytics, + PlacementAnalyticsItem, } from "@/lib/types/api"; import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants"; @@ -590,6 +591,85 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [ }, ]; +// Placements Analytics (Projects Analytics) +export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [ + { + id: "plac-0001-0000-0000-000000000001", + project_id: "proj-0001-0000-0000-000000000001", + project_channel_title: "Крипто Новости", + placement_channel_id: "chan-0001-0000-0000-000000000001", + placement_channel_title: "Технологии Будущего", + creative_id: "crea-0001-0000-0000-000000000001", + creative_name: "Крипто - Основной", + placement_date: "2024-12-20T12:00:00Z", + cost: 5500, + subscriptions_count: 156, + views_count: 8500, + cpf: 35.26, + cpm: 647.06, + }, + { + id: "plac-0002-0000-0000-000000000002", + project_id: "proj-0001-0000-0000-000000000001", + project_channel_title: "Крипто Новости", + placement_channel_id: "chan-0002-0000-0000-000000000002", + placement_channel_title: "Инвестиции Pro", + creative_id: "crea-0001-0000-0000-000000000001", + creative_name: "Крипто - Основной", + placement_date: "2024-12-18T14:00:00Z", + cost: 3200, + subscriptions_count: 89, + views_count: 4200, + cpf: 35.96, + cpm: 761.9, + }, + { + id: "plac-0003-0000-0000-000000000003", + project_id: "proj-0001-0000-0000-000000000001", + project_channel_title: "Крипто Новости", + placement_channel_id: "chan-0003-0000-0000-000000000003", + placement_channel_title: "Программисты", + creative_id: "crea-0001-0000-0000-000000000001", + creative_name: "Крипто - Основной", + placement_date: "2024-12-15T10:00:00Z", + cost: 4800, + subscriptions_count: 234, + views_count: 12000, + cpf: 20.51, + cpm: 400.0, + }, + { + id: "plac-0004-0000-0000-000000000004", + project_id: "proj-0002-0000-0000-000000000002", + project_channel_title: "IT Вакансии", + placement_channel_id: "chan-0004-0000-0000-000000000004", + placement_channel_title: "Маркетинг Pro", + creative_id: "crea-0002-0000-0000-000000000002", + creative_name: "Крипто - Агрессивный", + placement_date: "2024-12-10T16:00:00Z", + cost: 2500, + subscriptions_count: 67, + views_count: 3100, + cpf: 37.31, + cpm: 806.45, + }, + { + id: "plac-0005-0000-0000-000000000005", + project_id: "proj-0002-0000-0000-000000000002", + project_channel_title: "IT Вакансии", + placement_channel_id: "chan-0005-0000-0000-000000000005", + placement_channel_title: "Финансовая грамотность", + creative_id: "crea-0002-0000-0000-000000000002", + creative_name: "Крипто - Агрессивный", + placement_date: "2024-12-05T11:00:00Z", + cost: 6200, + subscriptions_count: 312, + views_count: 15600, + cpf: 19.87, + cpm: 397.44, + }, +]; + export const demoOverviewAnalytics: OverviewAnalytics = { total_cost: { value: 523400.5, delta_percent: 12.4 }, total_reach: { value: 380000, delta_percent: -8.1 }, diff --git a/lib/types/api.ts b/lib/types/api.ts index a0ed7e4..ba4a88c 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -291,18 +291,21 @@ export interface ViewsHistoryQueryParams extends PaginationParams { export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH"; -// Placements Analytics +// Placements Analytics (Projects Analytics) export interface PlacementAnalyticsItem { - placement_id: string; + id: string; // UUID + project_id: string; // UUID + project_channel_title: string; + placement_channel_id: string; // UUID placement_channel_title: string; - placement_channel_username: string | null; + creative_id: string; // UUID creative_name: string; - placement_date: string; - cost: number | null; + placement_date: string; // ISO 8601 + cost: number; subscriptions_count: number; - views_count: number | null; - cps: number | null; // cost per subscription - cpm: number | null; // cost per mille views + views_count: number; + cpf: number; + cpm: number; } // Creatives Analytics (matches actual API response) @@ -353,6 +356,42 @@ export interface AnalyticsQueryParams extends PaginationParams { project_id?: string; } +// Projects Analytics Chart API +export interface ProjectsAnalyticsMetrics { + total_cost: number; + purchases_count: number; + total_subscriptions: number; + total_views: number; + avg_cpf: number | null; + avg_cpm: number | null; + avg_post_cost: number | null; + clicks_count: number; + reach_volume: number; + total_discounts: number | null; + avg_discount_percent: number | null; + avg_conversion: number | null; +} + +export interface ProjectsAnalyticsPeriod { + period: string; + period_label: string; + metrics: ProjectsAnalyticsMetrics; +} + +export interface ProjectsAnalyticsResponse { + periods: ProjectsAnalyticsPeriod[]; + totals: ProjectsAnalyticsMetrics; +} + +export interface ProjectsAnalyticsQueryParams { + project_ids?: string[]; + date_from?: string; + date_to?: string; + grouping: "day" | "week" | "month" | "quarter"; + date_grouping: "purchase_date" | "link_date" | "placement_date"; + metrics?: string[]; +} + export interface SpendingAnalyticsQueryParams { project_id?: string; date_from?: string; diff --git a/lib/utils/constants.ts b/lib/utils/constants.ts index b6d09f0..73b64c0 100644 --- a/lib/utils/constants.ts +++ b/lib/utils/constants.ts @@ -67,11 +67,13 @@ export const buildRoute = { `/dashboard/${workspaceId}/placements/${placementId}`, // Analytics - analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics`, - analyticsSpending: (workspaceId: string) => - `/dashboard/${workspaceId}/analytics/spending`, + analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics/projects`, + analyticsProjects: (workspaceId: string) => + `/dashboard/${workspaceId}/analytics/projects`, analyticsCreatives: (workspaceId: string) => `/dashboard/${workspaceId}/analytics/creatives`, + analyticsSpending: (workspaceId: string) => + `/dashboard/${workspaceId}/analytics/spending`, analyticsChannels: (workspaceId: string) => `/dashboard/${workspaceId}/analytics/channels`, analyticsPlacements: (workspaceId: string) => diff --git a/public/logo_full_dark.png b/public/logo_full_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..660122cf86806baf15d01c19e81875396c6c5932 GIT binary patch literal 28603 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

8@urf%Q9DrOuaU?LcxUU3@TQ=~FfOm2H} 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 J7LflF{s;3gb2$J2 diff --git a/public/logo_full_white_2.png b/public/logo_full_white.png similarity index 100% rename from public/logo_full_white_2.png rename to public/logo_full_white.png diff --git a/public/logo_full_white.webp b/public/logo_full_white.webp deleted file mode 100644 index 79a159027285b6d01a838ba18406d03405caac77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10304 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