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 0000000..660122c Binary files /dev/null and b/public/logo_full_dark.png differ diff --git a/public/logo_full_dark.webp b/public/logo_full_dark.webp deleted file mode 100644 index 826c96d..0000000 Binary files a/public/logo_full_dark.webp and /dev/null differ 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 79a1590..0000000 Binary files a/public/logo_full_white.webp and /dev/null differ