From e27a171c63eade2fc3d9880da2dbbf4132ec53ab Mon Sep 17 00:00:00 2001 From: ivannoskov Date: Tue, 3 Feb 2026 18:55:09 +0300 Subject: [PATCH] feat: update analytics --- .../analytics/placements/page.tsx | 946 +++++++++++------- .../purchase-plans/[projectId]/page.tsx | 109 +- components/analytics-filters-modal.tsx | 638 ++++++++++++ lib/api/analytics.ts | 18 +- lib/config/placement-columns.ts | 2 +- lib/demo/demo-aware-api.ts | 28 +- lib/demo/mock-data.ts | 132 ++- lib/types/api.ts | 117 ++- 8 files changed, 1529 insertions(+), 461 deletions(-) create mode 100644 components/analytics-filters-modal.tsx diff --git a/app/dashboard/[workspaceId]/analytics/placements/page.tsx b/app/dashboard/[workspaceId]/analytics/placements/page.tsx index c97037b..37562ab 100644 --- a/app/dashboard/[workspaceId]/analytics/placements/page.tsx +++ b/app/dashboard/[workspaceId]/analytics/placements/page.tsx @@ -1,34 +1,15 @@ "use client"; -// ============================================================================ -// All Placements Analytics Page -// ============================================================================ - import { useEffect, useState, useMemo, useCallback } from "react"; import { useParams } from "next/navigation"; -import { Loader2, ArrowUpDown, ArrowUpRight, Calendar, Filter, X } from "lucide-react"; +import { Loader2, ArrowUpDown, ArrowUpRight, Filter, Columns } from "lucide-react"; import { format } from "date-fns"; import { ru } from "date-fns/locale"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; -import { analyticsApi, channelsApi } from "@/lib/api"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { analyticsApi, channelsApi, creativesApi } from "@/lib/api"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { Table, @@ -39,10 +20,14 @@ import { TableRow, } from "@/components/ui/table"; import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api"; - -// ============================================================================ -// Types -// ============================================================================ +import { AnalyticsFiltersModal, type PlacementsAnalyticsFilters } from "@/components/analytics-filters-modal"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; type SortField = "placement_date" | "cost" | "subscriptions_count" | "views_count" | "cpf" | "cpm"; type SortDirection = "asc" | "desc"; @@ -52,14 +37,77 @@ interface SortConfig { direction: SortDirection; } -interface DateRange { - from: Date | undefined; - to?: Date | undefined; -} +const DEFAULT_FILTERS: PlacementsAnalyticsFilters = { + projectIds: [], + statusList: ["Оплачено"], + placementChannelIds: [], + creativeIds: [], + costTypes: [], + placementTypes: [], + inviteLinkTypes: [], + costMin: null, + costMax: null, + viewsMin: null, + viewsMax: null, + subscriptionsMin: null, + subscriptionsMax: null, + cpmMin: null, + cpmMax: null, + channelTitleContains: "", + creativeNameContains: "", + commentContains: "", + placementDateFrom: "", + placementDateTo: "", +}; -// ============================================================================ -// Helpers -// ============================================================================ +const ALL_COLUMNS = [ + { id: "placement_date", label: "Дата размещения" }, + { id: "cost", label: "Стоимость" }, + { id: "project_title", label: "Проект" }, + { id: "channel_title", label: "Канал" }, + { id: "creative_name", label: "Креатив" }, + { id: "subscriptions_count", label: "Подписки" }, + { id: "views_count", label: "Просмотры" }, + { id: "cpf", label: "CPF" }, + { id: "cpm", label: "CPM" }, + { id: "post_url", label: "Пост" }, + { id: "placement_type", label: "Тип закупа" }, + { id: "cost_type", label: "Формат оплаты" }, + { id: "cost_before_bargain", label: "Цена до торга" }, + { id: "payment_at", label: "Дата оплаты" }, + { id: "comment", label: "Комментарий" }, + { id: "invite_link", label: "Ссылка" }, + { id: "invite_link_type", label: "Тип ссылки" }, + { id: "format", label: "Формат" }, + { id: "time_on_top", label: "Время в топе" }, + { id: "time_in_feed", label: "Время в ленте" }, + { id: "discount_percent", label: "% скидки" }, + { id: "conversion_24h", label: "Конв. 24ч" }, + { id: "conversion_48h", label: "Конв. 48ч" }, + { id: "conversion_total", label: "Конв. общ." }, + { id: "unsubscriptions_count", label: "Отписки" }, + { id: "unsub_percent", label: "% отписок" }, + { id: "total_active", label: "Итого активных" }, + { id: "post_deleted_at", label: "Дата удаления" }, + { id: "invite_link_created_at", label: "Дата ссылки" }, +] as const; + +const DEFAULT_VISIBLE_COLUMNS = new Set([ + "placement_date", + "cost", + "project_title", + "channel_title", + "creative_name", + "subscriptions_count", + "views_count", + "cpf", + "cpm", + "post_url", +]); + +const EDITABLE_COST_TYPES = ["fixed", "cpm"] as const; +const EDITABLE_PLACEMENT_TYPES = ["standard", "self_promo"] as const; +const EDITABLE_INVITE_TYPES = ["public", "approval"] as const; const formatCurrency = (value: number | null | undefined) => { if (value === null || value === undefined) return "—"; @@ -85,80 +133,83 @@ const formatDate = (dateStr: string | undefined) => { } }; -// ============================================================================ -// Main Component -// ============================================================================ - export default function PlacementsAnalyticsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; const { projects } = useWorkspace(); const [placements, setPlacements] = useState([]); - const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); - // Filters - const [selectedProjectId, setSelectedProjectId] = useState("all"); - const [selectedChannelId, setSelectedChannelId] = useState("all"); - const [selectedCreativeId, setSelectedCreativeId] = useState("all"); - const [dateRange, setDateRange] = useState({ from: undefined, to: undefined }); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [filtersOpen, setFiltersOpen] = useState(false); + const [visibleColumns, setVisibleColumns] = useState>(DEFAULT_VISIBLE_COLUMNS); + const [columnsOpen, setColumnsOpen] = useState(false); - // Sorting const [sortConfig, setSortConfig] = useState({ field: "placement_date", direction: "desc", }); + const [editingDrafts, setEditingDrafts] = useState>>({}); + const loadChannels = useCallback(async () => { try { - const response = await channelsApi.list({ size: 1000 }); - setChannels(response.items); + const response = await channelsApi.list({ size: 100 }); + return response.items; } catch (err) { console.error("Failed to load channels:", err); - setChannels([]); + return []; } }, []); + const loadCreatives = useCallback(async () => { + try { + const response = await creativesApi.list(workspaceId, { size: 100 }); + return response.items.map((c) => ({ id: c.id, name: c.name })); + } catch (err) { + console.error("Failed to load creatives:", err); + return []; + } + }, [workspaceId]); + const loadPlacements = useCallback(async () => { try { setLoading(true); - const response = await analyticsApi.placements(workspaceId, { - project_id: selectedProjectId !== "all" ? selectedProjectId : undefined, - placement_channel_id: selectedChannelId !== "all" ? selectedChannelId : undefined, - creative_id: selectedCreativeId !== "all" ? selectedCreativeId : undefined, - date_from: dateRange.from ? dateRange.from.toISOString() : undefined, - date_to: dateRange.to ? dateRange.to.toISOString() : undefined, - }); + const params = { + status_list: filters.statusList.length > 0 ? filters.statusList.join(',') : undefined, + sort_by: sortConfig.field, + sort_direction: sortConfig.direction, + page: currentPage, + size: 50, + }; + + const response = await analyticsApi.placements(workspaceId, params); setPlacements(response.items); + setTotalCount(response.total); + setEditingDrafts({}); } catch (err) { console.error("Failed to load placements:", err); setPlacements([]); + setTotalCount(0); } finally { setLoading(false); } - }, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange]); + }, [workspaceId, filters, sortConfig, currentPage]); - // Load data useEffect(() => { loadChannels(); + loadCreatives(); + }, [workspaceId, loadChannels, loadCreatives]); + + useEffect(() => { loadPlacements(); - }, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange, loadPlacements, loadChannels]); + }, [workspaceId, filters, sortConfig, currentPage, loadPlacements]); - // Get unique creatives from placements - const creatives = useMemo(() => { - const creativeMap = new Map(); - placements.forEach((p) => { - if (p.creative_id && p.creative_name) { - creativeMap.set(p.creative_id, p.creative_name); - } - }); - return Array.from(creativeMap.entries()).map(([id, name]) => ({ id, name })); - }, [placements]); - - // Sort placements const sortedPlacements = useMemo(() => { - const sorted = [...placements].sort((a, b) => { + return [...placements].sort((a, b) => { let aVal: number | string | null | undefined = a[sortConfig.field]; let bVal: number | string | null | undefined = b[sortConfig.field]; @@ -176,25 +227,8 @@ export default function PlacementsAnalyticsPage() { return aVal < bVal ? 1 : -1; } }); - return sorted; }, [placements, sortConfig]); - // Calculate totals - const totals = useMemo(() => { - return { - cost: placements.reduce((sum, p) => sum + (p.cost || 0), 0), - subscriptions_count: placements.reduce((sum, p) => sum + p.subscriptions_count, 0), - views_count: placements.reduce((sum, p) => sum + (p.views_count || 0), 0), - cpf: placements.length > 0 - ? placements.reduce((sum, p) => sum + (p.cpf || 0), 0) / placements.length - : null, - cpm: placements.length > 0 - ? placements.reduce((sum, p) => sum + (p.cpm || 0), 0) / placements.length - : null, - }; - }, [placements]); - - // Handle sort click const handleSort = (field: SortField) => { setSortConfig((prev) => ({ field, @@ -202,16 +236,143 @@ export default function PlacementsAnalyticsPage() { })); }; - // Reset filters - const resetFilters = () => { - setSelectedProjectId("all"); - setSelectedChannelId("all"); - setSelectedCreativeId("all"); - setDateRange({ from: undefined, to: undefined }); + const hasActiveFilters = Object.values(filters).some((v) => { + if (Array.isArray(v)) return v.length > 0; + if (typeof v === "string") return v !== ""; + if (typeof v === "number") return v !== null; + return false; + }); + + const toggleColumn = (colId: string) => { + const newSet = new Set(visibleColumns); + if (newSet.has(colId)) { + newSet.delete(colId); + } else { + newSet.add(colId); + } + setVisibleColumns(newSet); }; - // Has active filters - const hasActiveFilters = selectedProjectId !== "all" || selectedChannelId !== "all" || selectedCreativeId !== "all" || dateRange.from || dateRange.to; + const startEdit = (id: string, field: string, value: unknown) => { + setEditingDrafts((prev) => ({ + ...prev, + [id]: { ...prev[id], [field]: value }, + })); + }; + + const getDraftValue = (id: string, field: string, originalValue: unknown) => { + return editingDrafts[id]?.[field] ?? originalValue; + }; + + const renderCell = (placement: PlacementAnalyticsItem, columnId: string) => { + const value = (placement as unknown as Record)[columnId]; + const isEditing = !!editingDrafts[placement.id]?.[columnId]; + const draftValue = getDraftValue(placement.id, columnId, value); + + switch (columnId) { + case "placement_date": + return formatDate(value as string | undefined); + case "cost": + case "cost_before_bargain": + return isEditing ? ( + startEdit(placement.id, columnId, parseFloat(e.target.value) || null)} + /> + ) : ( + formatCurrency(value as number | undefined) + ); + case "cost_type": + return isEditing ? ( + + ) : ( + value === "fixed" ? "Фикс" : value === "cpm" ? "CPM" : "—" + ); + case "placement_type": + return isEditing ? ( + + ) : ( + value === "standard" ? "Стандартный" : value === "self_promo" ? "Взаимный пиар" : "—" + ); + case "invite_link_type": + return isEditing ? ( + + ) : ( + value === "public" ? "Открытая" : value === "approval" ? "С заявками" : "—" + ); + case "comment": + return isEditing ? ( + startEdit(placement.id, columnId, e.target.value)} + /> + ) : ( + {value as string || "—"} + ); + case "cpf": + case "cpm": + case "conversion_24h": + case "conversion_48h": + case "conversion_total": + case "unsub_percent": + case "discount_percent": + return value !== null && value !== undefined ? `${formatNumber(value as number)}%` : "—"; + case "unsubscriptions_count": + case "total_active": + case "subscriptions_count": + case "views_count": + case "time_on_top": + case "time_in_feed": + return formatNumber(value as number | undefined); + case "post_url": + return value ? ( + + + + ) : ( + + ); + default: + if (value instanceof Date || (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}/))) { + return formatDate(value as string); + } + return String(value ?? "—"); + } + }; return ( <> @@ -224,155 +385,64 @@ export default function PlacementsAnalyticsPage() { />
-
-

Все размещения

-

- Сводная таблица по всем завершённым размещениям -

-
- - {/* Stats Cards */} -
- - - Всего размещений - {formatNumber(placements.length)} - - - - - Сумма расходов - {formatCurrency(totals.cost)} - - - - - Подписчиков - {formatNumber(totals.subscriptions_count)} - - - - - Просмотров - {formatNumber(totals.views_count)} - - -
- - {/* Filters */} - - -
- Фильтры - {hasActiveFilters && ( - + + + - )} -
-
- -
-
- - -
- -
- - -
- -
- - -
- -
- - - - - - - setDateRange({ from: range?.from, to: range?.to })} - initialFocus - numberOfMonths={2} - locale={ru} - /> - - -
+ toggleColumn(col.id)} + /> + {col.label} + + ))} +
+ + +
+ -
- -
- - - + { + setFilters(newFilters); + setCurrentPage(1); + }} + projects={projects} + channels={[]} + creatives={[]} + initialFilters={DEFAULT_FILTERS} + /> - {/* Table */} {loading ? ( @@ -381,130 +451,304 @@ export default function PlacementsAnalyticsPage() { ) : placements.length === 0 ? (
-

Нет завершённых размещений

+

Нет размещений

) : (
- handleSort("placement_date")} - > -
- Дата - -
-
- handleSort("cost")} - > -
- Стоимость - -
-
- Канал размещения - Проект - Креатив - handleSort("subscriptions_count")} - > -
- Подписчики - -
-
- handleSort("views_count")} - > -
- Просмотры - -
-
- handleSort("cpf")} - > -
- CPF - -
-
- handleSort("cpm")} - > -
- CPM - -
-
- Пост + {visibleColumns.has("placement_date") && ( + handleSort("placement_date")} + > +
+ Дата + +
+
+ )} + {visibleColumns.has("cost") && ( + handleSort("cost")} + > +
+ Стоимость + +
+
+ )} + {visibleColumns.has("project_title") && ( + Проект + )} + {visibleColumns.has("channel_title") && ( + Канал + )} + {visibleColumns.has("creative_name") && ( + Креатив + )} + {visibleColumns.has("subscriptions_count") && ( + handleSort("subscriptions_count")} + > +
+ Подписки + +
+
+ )} + {visibleColumns.has("views_count") && ( + handleSort("views_count")} + > +
+ Просмотры + +
+
+ )} + {visibleColumns.has("cpf") && ( + handleSort("cpf")} + > +
+ CPF + +
+
+ )} + {visibleColumns.has("cpm") && ( + handleSort("cpm")} + > +
+ CPM + +
+
+ )} + {visibleColumns.has("post_url") && ( + Пост + )} + {visibleColumns.has("placement_type") && ( + Тип закупа + )} + {visibleColumns.has("cost_type") && ( + Формат + )} + {visibleColumns.has("cost_before_bargain") && ( + До торга + )} + {visibleColumns.has("payment_at") && ( + Оплата + )} + {visibleColumns.has("comment") && ( + Комментарий + )} + {visibleColumns.has("invite_link") && ( + Ссылка + )} + {visibleColumns.has("invite_link_type") && ( + Тип ссылки + )} + {visibleColumns.has("format") && ( + Формат + )} + {visibleColumns.has("time_on_top") && ( + В топе + )} + {visibleColumns.has("time_in_feed") && ( + В ленте + )} + {visibleColumns.has("discount_percent") && ( + % скидки + )} + {visibleColumns.has("conversion_24h") && ( + Конв. 24ч + )} + {visibleColumns.has("conversion_48h") && ( + Конв. 48ч + )} + {visibleColumns.has("conversion_total") && ( + Конв. общ. + )} + {visibleColumns.has("unsubscriptions_count") && ( + Отписки + )} + {visibleColumns.has("unsub_percent") && ( + % отписок + )} + {visibleColumns.has("total_active") && ( + Итого + )} + {visibleColumns.has("post_deleted_at") && ( + Удалён + )} + {visibleColumns.has("invite_link_created_at") && ( + Дата ссылки + )}
{sortedPlacements.map((placement) => ( - - {formatDate(placement.placement_date)} - - - {formatCurrency(placement.cost)} - - - {placement.placement_channel_title} - - - {placement.project_channel_title} - - - {placement.creative_name} - - - {formatNumber(placement.subscriptions_count)} - - - {formatNumber(placement.views_count)} - - - {formatCurrency(placement.cpf)} - - - {formatCurrency(placement.cpm)} - - - {placement.post_url ? ( - - - - ) : ( - - )} - + {visibleColumns.has("placement_date") && ( + + {renderCell(placement, "placement_date")} + + )} + {visibleColumns.has("cost") && ( + + {renderCell(placement, "cost")} + + )} + {visibleColumns.has("project_title") && ( + + {placement.project_title} + + )} + {visibleColumns.has("channel_title") && ( + + {placement.channel_title} + + )} + {visibleColumns.has("creative_name") && ( + + {placement.creative_name || "—"} + + )} + {visibleColumns.has("subscriptions_count") && ( + + {renderCell(placement, "subscriptions_count")} + + )} + {visibleColumns.has("views_count") && ( + + {renderCell(placement, "views_count")} + + )} + {visibleColumns.has("cpf") && ( + + {renderCell(placement, "cpf")} + + )} + {visibleColumns.has("cpm") && ( + + {renderCell(placement, "cpm")} + + )} + {visibleColumns.has("post_url") && ( + + {renderCell(placement, "post_url")} + + )} + {visibleColumns.has("placement_type") && ( + + {renderCell(placement, "placement_type")} + + )} + {visibleColumns.has("cost_type") && ( + + {renderCell(placement, "cost_type")} + + )} + {visibleColumns.has("cost_before_bargain") && ( + + {renderCell(placement, "cost_before_bargain")} + + )} + {visibleColumns.has("payment_at") && ( + + {renderCell(placement, "payment_at")} + + )} + {visibleColumns.has("comment") && ( + + {renderCell(placement, "comment")} + + )} + {visibleColumns.has("invite_link") && ( + + {placement.invite_link ? ( + + Ссылка + + ) : "—"} + + )} + {visibleColumns.has("invite_link_type") && ( + + {renderCell(placement, "invite_link_type")} + + )} + {visibleColumns.has("format") && ( + + {placement.format || "—"} + + )} + {visibleColumns.has("time_on_top") && ( + + {placement.time_on_top ? `${placement.time_on_top} мин` : "—"} + + )} + {visibleColumns.has("time_in_feed") && ( + + {placement.time_in_feed ? `${placement.time_in_feed} мин` : "—"} + + )} + {visibleColumns.has("discount_percent") && ( + + {renderCell(placement, "discount_percent")} + + )} + {visibleColumns.has("conversion_24h") && ( + + {renderCell(placement, "conversion_24h")} + + )} + {visibleColumns.has("conversion_48h") && ( + + {renderCell(placement, "conversion_48h")} + + )} + {visibleColumns.has("conversion_total") && ( + + {renderCell(placement, "conversion_total")} + + )} + {visibleColumns.has("unsubscriptions_count") && ( + + {renderCell(placement, "unsubscriptions_count")} + + )} + {visibleColumns.has("unsub_percent") && ( + + {renderCell(placement, "unsub_percent")} + + )} + {visibleColumns.has("total_active") && ( + + {renderCell(placement, "total_active")} + + )} + {visibleColumns.has("post_deleted_at") && ( + + {renderCell(placement, "post_deleted_at")} + + )} + {visibleColumns.has("invite_link_created_at") && ( + + {renderCell(placement, "invite_link_created_at")} + + )} ))} - {/* Totals Row */} - - ИТОГО - {formatCurrency(totals.cost)} - - {formatNumber(totals.subscriptions_count)} - {formatNumber(totals.views_count)} - {formatCurrency(totals.cpf)} - {formatCurrency(totals.cpm)} - -
diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index 588b563..2dd961a 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -471,7 +471,7 @@ function CellRenderer({ {placement.invite_link ? ( - {placement.invite_link} + {placement.invite_link.slice(8, 20)}… ) : ( @@ -498,8 +498,16 @@ function CellRenderer({ ); case "cost": - const isFixedFormat = placement.details?.cost?.type === "fixed"; - const canEditCost = canEdit && isFixedFormat; + const costType = placement.details?.cost?.type || "fixed"; + const costValue = placement.details?.cost?.value || 0; + const viewsCount = placement.placement_post?.views_count || 0; + const subsCount = placement.placement_post?.subscriptions_count || 0; + + // Если тип CPM - показываем рассчитанную стоимость = CPM * views / 1000 + // Если тип Fixed - показываем фиксированную стоимость + const calculatedCost = costType === "cpm" ? (viewsCount > 0 ? (costValue * viewsCount) / 1000 : null) : costValue; + + const canEditCost = canEdit && costType === "fixed"; return ( {isEditing && editingCell?.field === "cost" ? ( @@ -509,7 +517,7 @@ function CellRenderer({ step="0.01" autoFocus className="h-7 min-w-[80px] bg-background px-2 text-xs" - defaultValue={effectiveCost} + defaultValue={calculatedCost || 0} onChange={(e) => { const raw = e.target.value; if (!raw) { @@ -533,8 +541,55 @@ function CellRenderer({ canEditCost && setEditingCell({ placementId: placement.id, field: "cost" }) } > - {effectiveCost ? formatCurrency(effectiveCost) : "—"} - + {calculatedCost ? formatCurrency(calculatedCost) : "—"} + {costType === "fixed" && } + + )} + + ); + + case "cpm": + // Если тип CPM - показываем CPM значение + // Если тип Fixed - рассчитываем CPM = cost / views * 1000 + const cpmType = placement.details?.cost?.type || "fixed"; + const cpmValue = placement.details?.cost?.value || 0; + const cpmViewsCount = placement.placement_post?.views_count || 0; + + const calculatedCpm = cpmType === "cpm" ? cpmValue : (cpmViewsCount > 0 ? (cpmValue / cpmViewsCount) * 1000 : null); + + const canEditCpm = canEdit && cpmType === "cpm"; + return ( + + {isEditing && editingCell?.field === "cpm" ? ( + { + const raw = e.target.value; + if (!raw) return; + const value = Number(raw); + if (Number.isNaN(value)) return; + handleDraftChange(placement.id, { + cost: { type: "cpm", value }, + }); + }} + onBlur={() => setEditingCell(null)} + /> + ) : ( + )} @@ -750,48 +805,6 @@ function CellRenderer({ ); - case "cpm": - const isCpmFormat = placement.details?.cost?.type === "cpm"; - const canEditCpm = canEdit && isCpmFormat; - return ( - - {isEditing && editingCell?.field === "cpm" ? ( - { - const raw = e.target.value; - if (!raw) return; - const value = Number(raw); - if (Number.isNaN(value)) return; - const currentCost = placement.details?.cost; - handleDraftChange(placement.id, { - cost: { type: "cpm", value: (currentCost?.type === "cpm" ? currentCost.value : 0) }, - cpm: value, - }); - }} - onBlur={() => setEditingCell(null)} - /> - ) : ( - - )} - - ); - case "conversion_24h": return ( diff --git a/components/analytics-filters-modal.tsx b/components/analytics-filters-modal.tsx new file mode 100644 index 0000000..41216be --- /dev/null +++ b/components/analytics-filters-modal.tsx @@ -0,0 +1,638 @@ +"use client"; + +import { useState } from "react"; +import { Filter, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { Project, Channel, Creative, CostType, PlacementType, InviteLinkType } from "@/lib/types/api"; + +interface AnalyticsFiltersModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onApply: (filters: PlacementsAnalyticsFilters) => void; + projects: Project[]; + channels: Channel[]; + creatives: Creative[]; + initialFilters: PlacementsAnalyticsFilters; +} + +export interface PlacementsAnalyticsFilters { + projectIds: string[]; + statusList: string[]; + placementChannelIds: string[]; + creativeIds: string[]; + costTypes: CostType[]; + placementTypes: PlacementType[]; + inviteLinkTypes: InviteLinkType[]; + costMin: number | null; + costMax: number | null; + viewsMin: number | null; + viewsMax: number | null; + subscriptionsMin: number | null; + subscriptionsMax: number | null; + cpmMin: number | null; + cpmMax: number | null; + channelTitleContains: string; + creativeNameContains: string; + commentContains: string; + placementDateFrom: string; + placementDateTo: string; +} + +const STATUS_OPTIONS = [ + "Без статуса", + "Написать", + "Ждём ответа", + "Согласование условий", + "Оплатить", + "Оплачено", + "Отмена", + "Не подходит цена", + "Неактуально", + "Не отвечает", +]; + +const COST_TYPE_OPTIONS: { value: CostType; label: string }[] = [ + { value: "fixed", label: "Фикс" }, + { value: "cpm", label: "CPM" }, +]; + +const PLACEMENT_TYPE_OPTIONS: { value: PlacementType; label: string }[] = [ + { value: "standard", label: "Стандартный" }, + { value: "self_promo", label: "Взаимный пиар" }, +]; + +const INVITE_LINK_TYPE_OPTIONS: { value: InviteLinkType; label: string }[] = [ + { value: "public", label: "Открытая" }, + { value: "approval", label: "С заявками" }, +]; + +export function AnalyticsFiltersModal({ + open, + onOpenChange, + onApply, + projects, + channels, + creatives, + initialFilters, +}: AnalyticsFiltersModalProps) { + const [filters, setFilters] = useState(initialFilters); + const [activeTab, setActiveTab] = useState<"categories" | "numbers" | "text" | "dates">("categories"); + + const handleApply = () => { + onApply(filters); + onOpenChange(false); + }; + + const handleReset = () => { + const defaultFilters: PlacementsAnalyticsFilters = { + projectIds: [], + statusList: [], + placementChannelIds: [], + creativeIds: [], + costTypes: [], + placementTypes: [], + inviteLinkTypes: [], + costMin: null, + costMax: null, + viewsMin: null, + viewsMax: null, + subscriptionsMin: null, + subscriptionsMax: null, + cpmMin: null, + cpmMax: null, + channelTitleContains: "", + creativeNameContains: "", + commentContains: "", + placementDateFrom: "", + placementDateTo: "", + }; + setFilters(defaultFilters); + }; + + const toggleArrayItem = (arr: T[], item: T): T[] => { + return arr.includes(item) ? arr.filter((i) => i !== item) : [...arr, item]; + }; + + const hasActiveFilters = + filters.projectIds.length > 0 || + filters.statusList.length > 0 || + filters.placementChannelIds.length > 0 || + filters.creativeIds.length > 0 || + filters.costTypes.length > 0 || + filters.placementTypes.length > 0 || + filters.inviteLinkTypes.length > 0 || + filters.costMin !== null || + filters.costMax !== null || + filters.viewsMin !== null || + filters.viewsMax !== null || + filters.subscriptionsMin !== null || + filters.subscriptionsMax !== null || + filters.cpmMin !== null || + filters.cpmMax !== null || + filters.channelTitleContains || + filters.creativeNameContains || + filters.commentContains || + filters.placementDateFrom || + filters.placementDateTo; + + return ( + + + + + + Фильтры + + + Настройте гибкие фильтры для таблицы размещений + + + +
+ {[ + { id: "categories", label: "Категории" }, + { id: "numbers", label: "Числовые" }, + { id: "text", label: "Текст" }, + { id: "dates", label: "Даты" }, + ].map((tab) => ( + + ))} +
+ +
+ {activeTab === "categories" && ( +
+ {/* Projects - Multi-select */} +
+ +
+ {projects.map((project) => ( + + ))} + {projects.length === 0 && ( +

Нет доступных проектов

+ )} +
+
+ + {/* Status - Multi-select */} +
+ +
+ {STATUS_OPTIONS.map((status) => ( + + ))} +
+
+ + {/* Channels - Multi-select */} +
+ +
+ {channels.map((channel) => ( + + ))} + {channels.length === 0 && ( +

Нет доступных каналов

+ )} +
+
+ + {/* Creatives - Multi-select */} +
+ +
+ {creatives.map((creative) => ( + + ))} + {creatives.length === 0 && ( +

Нет доступных креативов

+ )} +
+
+ + {/* Cost Type - Multi-select */} +
+ +
+ {COST_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Placement Type - Multi-select */} +
+ +
+ {PLACEMENT_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Invite Link Type - Multi-select */} +
+ +
+ {INVITE_LINK_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ )} + + {activeTab === "numbers" && ( +
+ {/* Cost range */} +
+ +
+ + setFilters({ + ...filters, + costMin: e.target.value ? parseFloat(e.target.value) : null, + }) + } + /> + + + setFilters({ + ...filters, + costMax: e.target.value ? parseFloat(e.target.value) : null, + }) + } + /> +
+
+ + {/* Views range */} +
+ +
+ + setFilters({ + ...filters, + viewsMin: e.target.value ? parseInt(e.target.value) : null, + }) + } + /> + + + setFilters({ + ...filters, + viewsMax: e.target.value ? parseInt(e.target.value) : null, + }) + } + /> +
+
+ + {/* Subscriptions range */} +
+ +
+ + setFilters({ + ...filters, + subscriptionsMin: e.target.value ? parseInt(e.target.value) : null, + }) + } + /> + + + setFilters({ + ...filters, + subscriptionsMax: e.target.value ? parseInt(e.target.value) : null, + }) + } + /> +
+
+ + {/* CPM range */} +
+ +
+ + setFilters({ + ...filters, + cpmMin: e.target.value ? parseFloat(e.target.value) : null, + }) + } + /> + + + setFilters({ + ...filters, + cpmMax: e.target.value ? parseFloat(e.target.value) : null, + }) + } + /> +
+
+
+ )} + + {activeTab === "text" && ( +
+ {/* Channel title */} +
+ + + setFilters({ ...filters, channelTitleContains: e.target.value }) + } + /> +
+ + {/* Creative name */} +
+ + + setFilters({ ...filters, creativeNameContains: e.target.value }) + } + /> +
+ + {/* Comment */} +
+ + + setFilters({ ...filters, commentContains: e.target.value }) + } + /> +
+
+ )} + + {activeTab === "dates" && ( +
+ {/* Placement date range */} +
+ +
+ + setFilters({ ...filters, placementDateFrom: e.target.value }) + } + /> + + + setFilters({ ...filters, placementDateTo: e.target.value }) + } + /> +
+
+
+ )} +
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/lib/api/analytics.ts b/lib/api/analytics.ts index 3de4cde..092e3d6 100644 --- a/lib/api/analytics.ts +++ b/lib/api/analytics.ts @@ -10,7 +10,6 @@ import type { SpendingAnalytics, AnalyticsQueryParams, SpendingAnalyticsQueryParams, - PaginatedResponse, OverviewAnalytics, OverviewAnalyticsQueryParams, ProjectsAnalyticsResponse, @@ -20,19 +19,22 @@ import type { export const analyticsApi = { /** - * Get placements analytics + * Get placements analytics (paginated with all fields) */ placements: (workspaceId: string, params?: PlacementsAnalyticsQueryParams) => - api.get>( - `/workspaces/${workspaceId}/analytics/placements`, - { params } - ), + api.get<{ + items: PlacementAnalyticsItem[]; + total: number; + page: number; + size: number; + pages: number; + }>(`/workspaces/${workspaceId}/analytics/placements`, { params }), /** * Get creatives analytics */ creatives: (workspaceId: string, params?: AnalyticsQueryParams) => - api.get>( + api.get<{ items: CreativeAnalyticsItem[]; total: number; page: number; size: number; pages: number }>( `/workspaces/${workspaceId}/analytics/creatives`, { params } ), @@ -41,7 +43,7 @@ export const analyticsApi = { * Get channels analytics */ channels: (workspaceId: string, params?: AnalyticsQueryParams) => - api.get>( + api.get<{ items: ChannelAnalyticsItem[]; total: number; page: number; size: number; pages: number }>( `/workspaces/${workspaceId}/analytics/channels`, { params } ), diff --git a/lib/config/placement-columns.ts b/lib/config/placement-columns.ts index c35e740..8abcd59 100644 --- a/lib/config/placement-columns.ts +++ b/lib/config/placement-columns.ts @@ -29,7 +29,7 @@ export const ALL_COLUMNS: ColumnConfig[] = [ // Content { id: "creative", label: "Креатив", group: "content", editable: true }, - { id: "invite_link", label: "Пригласительная ссылка", group: "content", editable: false }, + { id: "invite_link", label: "Пригл. ссылка", group: "content", editable: false }, { id: "post_link", label: "Ссылка на пост", group: "content", editable: false }, // Finance diff --git a/lib/demo/demo-aware-api.ts b/lib/demo/demo-aware-api.ts index 94c12d8..29d4381 100644 --- a/lib/demo/demo-aware-api.ts +++ b/lib/demo/demo-aware-api.ts @@ -396,9 +396,10 @@ export const demoAwareAnalyticsApi = { placements: async ( workspaceId: string, params?: { - project_id?: string; - placement_channel_id?: string; - creative_id?: string; + project_ids?: string; + status_list?: string; + placement_channel_ids?: string; + creative_ids?: string; date_from?: string; date_to?: string; page?: number; @@ -407,22 +408,22 @@ export const demoAwareAnalyticsApi = { ): Promise> => { if (isDemoWorkspace(workspaceId)) { let items = demoPlacementAnalytics; - if (params?.project_id) { - items = items.filter((p) => p.project_id === params.project_id); + if (params?.project_ids) { + items = items.filter((p) => p.project_id === params.project_ids); } - if (params?.placement_channel_id) { - items = items.filter((p) => p.placement_channel_id === params.placement_channel_id); + if (params?.placement_channel_ids) { + items = items.filter((p) => params.placement_channel_ids!.includes(p.channel_id)); } - if (params?.creative_id) { - items = items.filter((p) => p.creative_id === params.creative_id); + if (params?.creative_ids) { + items = items.filter((p) => p.creative_id && params.creative_ids!.includes(p.creative_id)); } if (params?.date_from) { const from = new Date(params.date_from); - items = items.filter((p) => new Date(p.placement_date) >= from); + items = items.filter((p) => p.placement_date && new Date(p.placement_date) >= from); } if (params?.date_to) { const to = new Date(params.date_to); - items = items.filter((p) => new Date(p.placement_date) <= to); + items = items.filter((p) => p.placement_date && new Date(p.placement_date) <= to); } return paginate(items, params?.page, params?.size); } @@ -452,12 +453,12 @@ export const demoAwareAnalyticsApi = { if (params.date_from) { const from = new Date(params.date_from); - filtered = filtered.filter((p) => new Date(p.placement_date) >= from); + filtered = filtered.filter((p) => p.placement_date && 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); + filtered = filtered.filter((p) => p.placement_date && new Date(p.placement_date) <= to); } // Helper to format period label @@ -483,6 +484,7 @@ export const demoAwareAnalyticsApi = { // Group by period const grouped: Record = {}; filtered.forEach((item) => { + if (!item.placement_date) return; const date = new Date(item.placement_date); let periodKey: string; diff --git a/lib/demo/mock-data.ts b/lib/demo/mock-data.ts index 82cea9c..40cd79c 100644 --- a/lib/demo/mock-data.ts +++ b/lib/demo/mock-data.ts @@ -766,87 +766,177 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [ }, ]; -// Placements Analytics (Projects Analytics) +// Placements Analytics (detailed view) 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: "Технологии Будущего", + project_title: "Крипто Новости", + channel_id: "chan-0001-0000-0000-000000000001", + channel_title: "Технологии Будущего", creative_id: "crea-0001-0000-0000-000000000001", creative_name: "Крипто - Основной", - placement_date: "2024-12-20T12:00:00Z", cost: 5500, + cost_type: "fixed", + cost_before_bargain: 6000, + payment_at: "2024-12-19T10:00:00Z", + placement_type: "standard", + comment: null, + format: null, + invite_link_type: "public", + placement_date: "2024-12-20T12:00:00Z", subscriptions_count: 156, views_count: 8500, cpf: 35.26, cpm: 647.06, + time_on_top: 60, + time_in_feed: null, + invite_link: "https://t.me/+test1", + invite_link_created_at: "2024-12-20T11:55:00Z", post_url: "https://t.me/tech_future/1001", + post_deleted_at: null, + conversion_24h: 1.5, + conversion_48h: 2.1, + conversion_total: 1.84, + unsubscriptions_count: 5, + unsub_percent: 3.11, + total_active: 151, }, { 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", + project_title: "Крипто Новости", + channel_id: "chan-0002-0000-0000-000000000002", + channel_title: "Инвестиции Pro", creative_id: "crea-0001-0000-0000-000000000001", creative_name: "Крипто - Основной", - placement_date: "2024-12-18T14:00:00Z", cost: 3200, + cost_type: "fixed", + cost_before_bargain: 3500, + payment_at: "2024-12-17T14:00:00Z", + placement_type: "standard", + comment: null, + format: null, + invite_link_type: "public", + placement_date: "2024-12-18T14:00:00Z", subscriptions_count: 89, views_count: 4200, cpf: 35.96, cpm: 761.9, + time_on_top: 45, + time_in_feed: null, + invite_link: "https://t.me/+test2", + invite_link_created_at: "2024-12-18T13:50:00Z", post_url: "https://t.me/invest_pro/502", + post_deleted_at: null, + conversion_24h: 1.8, + conversion_48h: 2.2, + conversion_total: 2.12, + unsubscriptions_count: 3, + unsub_percent: 3.26, + total_active: 86, }, { 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: "Программисты", + project_title: "Крипто Новости", + channel_id: "chan-0003-0000-0000-000000000003", + channel_title: "Программисты", creative_id: "crea-0001-0000-0000-000000000001", creative_name: "Крипто - Основной", - placement_date: "2024-12-15T10:00:00Z", cost: 4800, + cost_type: "cpm", + cost_before_bargain: null, + payment_at: "2024-12-14T10:00:00Z", + placement_type: "standard", + comment: "Хороший канал", + format: null, + invite_link_type: "approval", + placement_date: "2024-12-15T10:00:00Z", subscriptions_count: 234, views_count: 12000, cpf: 20.51, cpm: 400.0, + time_on_top: 120, + time_in_feed: null, + invite_link: "https://t.me/+test3", + invite_link_created_at: "2024-12-15T09:55:00Z", post_url: "https://t.me/programmers_hub/2050", + post_deleted_at: null, + conversion_24h: 1.2, + conversion_48h: 1.8, + conversion_total: 1.95, + unsubscriptions_count: 8, + unsub_percent: 3.31, + total_active: 226, }, { 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", + project_title: "IT Вакансии", + channel_id: "chan-0004-0000-0000-000000000004", + channel_title: "Маркетинг Pro", creative_id: "crea-0002-0000-0000-000000000002", creative_name: "Крипто - Агрессивный", - placement_date: "2024-12-10T16:00:00Z", cost: 2500, + cost_type: "fixed", + cost_before_bargain: 3000, + payment_at: "2024-12-09T16:00:00Z", + placement_type: "self_promo", + comment: null, + format: null, + invite_link_type: "public", + placement_date: "2024-12-10T16:00:00Z", subscriptions_count: 67, views_count: 3100, cpf: 37.31, cpm: 806.45, + time_on_top: 30, + time_in_feed: null, + invite_link: "https://t.me/+test4", + invite_link_created_at: "2024-12-10T15:55:00Z", post_url: "https://t.me/marketing_pro/803", + post_deleted_at: "2024-12-12T16:00:00Z", + conversion_24h: 1.5, + conversion_48h: 2.0, + conversion_total: 2.16, + unsubscriptions_count: 2, + unsub_percent: 2.9, + total_active: 65, }, { 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: "Финансовая грамотность", + project_title: "IT Вакансии", + channel_id: "chan-0005-0000-0000-000000000005", + channel_title: "Финансовая грамотность", creative_id: "crea-0002-0000-0000-000000000002", creative_name: "Крипто - Агрессивный", - placement_date: "2024-12-05T11:00:00Z", cost: 6200, + cost_type: "fixed", + cost_before_bargain: 7500, + payment_at: "2024-12-04T11:00:00Z", + placement_type: "standard", + comment: null, + format: null, + invite_link_type: "approval", + placement_date: "2024-12-05T11:00:00Z", subscriptions_count: 312, views_count: 15600, cpf: 19.87, cpm: 397.44, + time_on_top: 90, + time_in_feed: null, + invite_link: "https://t.me/+test5", + invite_link_created_at: "2024-12-05T10:55:00Z", post_url: "https://t.me/finance_edu/1204", + post_deleted_at: null, + conversion_24h: 1.3, + conversion_48h: 1.9, + conversion_total: 2.0, + unsubscriptions_count: 12, + unsub_percent: 3.7, + total_active: 300, }, ]; diff --git a/lib/types/api.ts b/lib/types/api.ts index 12f1cbc..8a9fdcf 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -448,30 +448,65 @@ export interface ViewsHistoryQueryParams extends PaginationParams { export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH"; -// Placements Analytics (Projects Analytics) +// Placements Analytics (detailed view with all fields) export interface PlacementAnalyticsItem { - id: string; // UUID - project_id: string; // UUID - project_channel_title: string; - placement_channel_id: string; // UUID - placement_channel_title: string; - creative_id: string; // UUID - creative_name: string; - placement_date: string; // ISO 8601 - cost: number; + id: string; + project_id: string; + project_title: string; + channel_id: string; + channel_title: string; + creative_id: string | null; + creative_name: string | null; + cost: number | null; + cost_type: CostType; + cost_before_bargain: number | null; + payment_at: string | null; + placement_type: PlacementType | null; + comment: string | null; + format: string | null; + invite_link_type: InviteLinkType | null; + placement_date: string | null; subscriptions_count: number; - views_count: number; - cpf: number; - cpm: number; - post_url?: string | null; + views_count: number | null; + cpf: number | null; + cpm: number | null; + time_on_top: number | null; + time_in_feed: number | null; + invite_link: string | null; + invite_link_created_at: string | null; + post_url: string | null; + post_deleted_at: string | null; + conversion_24h: number | null; + conversion_48h: number | null; + conversion_total: number | null; + unsubscriptions_count: number; + unsub_percent: number | null; + total_active: number; } export interface PlacementsAnalyticsQueryParams extends PaginationParams { - project_id?: string; - placement_channel_id?: string; - creative_id?: string; - date_from?: string; - date_to?: string; + project_ids?: string; + status_list?: string; + placement_channel_ids?: string; + creative_ids?: string; + cost_types?: string; + placement_types?: string; + invite_link_types?: string; + cost_min?: number; + cost_max?: number; + views_min?: number; + views_max?: number; + subscriptions_min?: number; + subscriptions_max?: number; + cpm_min?: number; + cpm_max?: number; + channel_title_contains?: string; + creative_name_contains?: string; + comment_contains?: string; + placement_date_from?: string; + placement_date_to?: string; + sort_by?: string; + sort_direction?: "asc" | "desc"; } // Creatives Analytics (matches actual API response) @@ -615,6 +650,50 @@ export interface OverviewAnalyticsQueryParams { date_to?: string; } +// Placements Detail Analytics +export interface PlacementAnalyticsDetail { + id: string; + project_id: string; + project_title: string; + channel_id: string; + channel_title: string; + creative_id: string | null; + creative_name: string | null; + cost: number | null; + cost_type: CostType; + cost_before_bargain: number | null; + payment_at: string | null; + placement_type: PlacementType | null; + comment: string | null; + format: string | null; + invite_link_type: InviteLinkType | null; + placement_date: string | null; + subscriptions_count: number; + views_count: number | null; + cpf: number | null; + cpm: number | null; + time_on_top: number | null; + time_in_feed: number | null; + invite_link: string | null; + invite_link_created_at: string | null; + post_url: string | null; + post_deleted_at: string | null; + conversion_24h: number | null; + conversion_48h: number | null; + conversion_total: number | null; + unsubscriptions_count: number; + unsub_percent: number | null; + total_active: number; +} + +export interface PlacementsDetailAnalyticsQueryParams extends PaginationParams { + project_id?: string; + channel_id?: string; + status?: string; + sort_by?: string; + sort_direction?: "asc" | "desc"; +} + // ---------------------------------------------------------------------------- // Common Types // ----------------------------------------------------------------------------