feat: update analytics
This commit is contained in:
@@ -1,34 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// All Placements Analytics Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { useParams } from "next/navigation";
|
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 { format } from "date-fns";
|
||||||
import { ru } from "date-fns/locale";
|
import { ru } from "date-fns/locale";
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
import { analyticsApi, channelsApi } from "@/lib/api";
|
import { analyticsApi, channelsApi, creativesApi } from "@/lib/api";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -39,10 +20,14 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api";
|
import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api";
|
||||||
|
import { AnalyticsFiltersModal, type PlacementsAnalyticsFilters } from "@/components/analytics-filters-modal";
|
||||||
// ============================================================================
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
// Types
|
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 SortField = "placement_date" | "cost" | "subscriptions_count" | "views_count" | "cpf" | "cpm";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
@@ -52,14 +37,77 @@ interface SortConfig {
|
|||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DateRange {
|
const DEFAULT_FILTERS: PlacementsAnalyticsFilters = {
|
||||||
from: Date | undefined;
|
projectIds: [],
|
||||||
to?: Date | undefined;
|
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: "",
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
const ALL_COLUMNS = [
|
||||||
// Helpers
|
{ 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) => {
|
const formatCurrency = (value: number | null | undefined) => {
|
||||||
if (value === null || value === undefined) return "—";
|
if (value === null || value === undefined) return "—";
|
||||||
@@ -85,80 +133,83 @@ const formatDate = (dateStr: string | undefined) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Main Component
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default function PlacementsAnalyticsPage() {
|
export default function PlacementsAnalyticsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const workspaceId = params?.workspaceId as string;
|
const workspaceId = params?.workspaceId as string;
|
||||||
const { projects } = useWorkspace();
|
const { projects } = useWorkspace();
|
||||||
|
|
||||||
const [placements, setPlacements] = useState<PlacementAnalyticsItem[]>([]);
|
const [placements, setPlacements] = useState<PlacementAnalyticsItem[]>([]);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
// Filters
|
const [filters, setFilters] = useState<PlacementsAnalyticsFilters>(DEFAULT_FILTERS);
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("all");
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState<string>("all");
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(DEFAULT_VISIBLE_COLUMNS);
|
||||||
const [selectedCreativeId, setSelectedCreativeId] = useState<string>("all");
|
const [columnsOpen, setColumnsOpen] = useState(false);
|
||||||
const [dateRange, setDateRange] = useState<DateRange>({ from: undefined, to: undefined });
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
field: "placement_date",
|
field: "placement_date",
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [editingDrafts, setEditingDrafts] = useState<Record<string, Record<string, unknown>>>({});
|
||||||
|
|
||||||
const loadChannels = useCallback(async () => {
|
const loadChannels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await channelsApi.list({ size: 1000 });
|
const response = await channelsApi.list({ size: 100 });
|
||||||
setChannels(response.items);
|
return response.items;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load channels:", 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 () => {
|
const loadPlacements = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await analyticsApi.placements(workspaceId, {
|
const params = {
|
||||||
project_id: selectedProjectId !== "all" ? selectedProjectId : undefined,
|
status_list: filters.statusList.length > 0 ? filters.statusList.join(',') : undefined,
|
||||||
placement_channel_id: selectedChannelId !== "all" ? selectedChannelId : undefined,
|
sort_by: sortConfig.field,
|
||||||
creative_id: selectedCreativeId !== "all" ? selectedCreativeId : undefined,
|
sort_direction: sortConfig.direction,
|
||||||
date_from: dateRange.from ? dateRange.from.toISOString() : undefined,
|
page: currentPage,
|
||||||
date_to: dateRange.to ? dateRange.to.toISOString() : undefined,
|
size: 50,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const response = await analyticsApi.placements(workspaceId, params);
|
||||||
setPlacements(response.items);
|
setPlacements(response.items);
|
||||||
|
setTotalCount(response.total);
|
||||||
|
setEditingDrafts({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load placements:", err);
|
console.error("Failed to load placements:", err);
|
||||||
setPlacements([]);
|
setPlacements([]);
|
||||||
|
setTotalCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange]);
|
}, [workspaceId, filters, sortConfig, currentPage]);
|
||||||
|
|
||||||
// Load data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChannels();
|
loadChannels();
|
||||||
|
loadCreatives();
|
||||||
|
}, [workspaceId, loadChannels, loadCreatives]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
loadPlacements();
|
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<string, string>();
|
|
||||||
placements.forEach((p) => {
|
|
||||||
if (p.creative_id && p.creative_name) {
|
|
||||||
creativeMap.set(p.creative_id, p.creative_name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(creativeMap.entries()).map(([id, name]) => ({ id, name }));
|
|
||||||
}, [placements]);
|
|
||||||
|
|
||||||
// Sort placements
|
|
||||||
const sortedPlacements = useMemo(() => {
|
const sortedPlacements = useMemo(() => {
|
||||||
const sorted = [...placements].sort((a, b) => {
|
return [...placements].sort((a, b) => {
|
||||||
let aVal: number | string | null | undefined = a[sortConfig.field];
|
let aVal: number | string | null | undefined = a[sortConfig.field];
|
||||||
let bVal: number | string | null | undefined = b[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 aVal < bVal ? 1 : -1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return sorted;
|
|
||||||
}, [placements, sortConfig]);
|
}, [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) => {
|
const handleSort = (field: SortField) => {
|
||||||
setSortConfig((prev) => ({
|
setSortConfig((prev) => ({
|
||||||
field,
|
field,
|
||||||
@@ -202,16 +236,143 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset filters
|
const hasActiveFilters = Object.values(filters).some((v) => {
|
||||||
const resetFilters = () => {
|
if (Array.isArray(v)) return v.length > 0;
|
||||||
setSelectedProjectId("all");
|
if (typeof v === "string") return v !== "";
|
||||||
setSelectedChannelId("all");
|
if (typeof v === "number") return v !== null;
|
||||||
setSelectedCreativeId("all");
|
return false;
|
||||||
setDateRange({ from: undefined, to: undefined });
|
});
|
||||||
|
|
||||||
|
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 startEdit = (id: string, field: string, value: unknown) => {
|
||||||
const hasActiveFilters = selectedProjectId !== "all" || selectedChannelId !== "all" || selectedCreativeId !== "all" || dateRange.from || dateRange.to;
|
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<string, unknown>)[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 ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-8 w-24"
|
||||||
|
value={draftValue as number ?? ""}
|
||||||
|
onChange={(e) => startEdit(placement.id, columnId, parseFloat(e.target.value) || null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
formatCurrency(value as number | undefined)
|
||||||
|
);
|
||||||
|
case "cost_type":
|
||||||
|
return isEditing ? (
|
||||||
|
<select
|
||||||
|
className="h-8 w-24 rounded border px-2"
|
||||||
|
value={draftValue as string}
|
||||||
|
onChange={(e) => startEdit(placement.id, columnId, e.target.value)}
|
||||||
|
>
|
||||||
|
{EDITABLE_COST_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t === "fixed" ? "Фикс" : "CPM"}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
value === "fixed" ? "Фикс" : value === "cpm" ? "CPM" : "—"
|
||||||
|
);
|
||||||
|
case "placement_type":
|
||||||
|
return isEditing ? (
|
||||||
|
<select
|
||||||
|
className="h-8 w-32 rounded border px-2"
|
||||||
|
value={draftValue as string ?? ""}
|
||||||
|
onChange={(e) => startEdit(placement.id, columnId, e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{EDITABLE_PLACEMENT_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t === "standard" ? "Стандартный" : "Взаимный пиар"}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
value === "standard" ? "Стандартный" : value === "self_promo" ? "Взаимный пиар" : "—"
|
||||||
|
);
|
||||||
|
case "invite_link_type":
|
||||||
|
return isEditing ? (
|
||||||
|
<select
|
||||||
|
className="h-8 w-28 rounded border px-2"
|
||||||
|
value={draftValue as string ?? ""}
|
||||||
|
onChange={(e) => startEdit(placement.id, columnId, e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{EDITABLE_INVITE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t === "public" ? "Открытая" : "С заявками"}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
value === "public" ? "Открытая" : value === "approval" ? "С заявками" : "—"
|
||||||
|
);
|
||||||
|
case "comment":
|
||||||
|
return isEditing ? (
|
||||||
|
<Input
|
||||||
|
className="h-8 w-40"
|
||||||
|
value={draftValue as string ?? ""}
|
||||||
|
onChange={(e) => startEdit(placement.id, columnId, e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate max-w-[200px] block">{value as string || "—"}</span>
|
||||||
|
);
|
||||||
|
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 ? (
|
||||||
|
<a
|
||||||
|
href={value as string}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center p-1 rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50">—</span>
|
||||||
|
);
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -224,155 +385,64 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Все размещения</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Все размещения</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Сводная таблица по всем завершённым размещениям
|
{totalCount > 0 ? `${totalCount} размещений` : "Нет данных"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Всего размещений</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{formatNumber(placements.length)}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Сумма расходов</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{formatCurrency(totals.cost)}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Подписчиков</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{formatNumber(totals.subscriptions_count)}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardDescription>Просмотров</CardDescription>
|
|
||||||
<CardTitle className="text-2xl">{formatNumber(totals.views_count)}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Фильтры</CardTitle>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
|
||||||
<X className="h-4 w-4 mr-2" />
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 md:grid-cols-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Проект</label>
|
|
||||||
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Все проекты" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Все проекты</SelectItem>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<SelectItem key={project.id} value={project.id}>
|
|
||||||
{project.title}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Канал</label>
|
|
||||||
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Все каналы" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Все каналы</SelectItem>
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<SelectItem key={channel.id} value={channel.id}>
|
|
||||||
{channel.title}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Креатив</label>
|
|
||||||
<Select value={selectedCreativeId} onValueChange={setSelectedCreativeId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Все креативы" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Все креативы</SelectItem>
|
|
||||||
{creatives.map((creative) => (
|
|
||||||
<SelectItem key={creative.id} value={creative.id}>
|
|
||||||
{creative.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Период</label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant={hasActiveFilters ? "default" : "outline"}
|
||||||
className={cn(
|
onClick={() => setFiltersOpen(true)}
|
||||||
"w-full justify-start text-left font-normal",
|
className="gap-2"
|
||||||
!dateRange.from && !dateRange.to && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
{dateRange.from && dateRange.to
|
Фильтры
|
||||||
? `${format(dateRange.from, "d MMM", { locale: ru })} - ${format(dateRange.to, "d MMM yyyy", { locale: ru })}`
|
{hasActiveFilters && <span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">✓</span>}
|
||||||
: dateRange.from
|
</Button>
|
||||||
? `${format(dateRange.from, "d MMM yyyy", { locale: ru })} - ...`
|
<Popover open={columnsOpen} onOpenChange={setColumnsOpen}>
|
||||||
: "Выберите период"}
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Columns className="h-4 w-4" />
|
||||||
|
Колонки
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-56" align="end">
|
||||||
<CalendarComponent
|
<div className="space-y-2">
|
||||||
mode="range"
|
<p className="text-sm font-medium">Видимые колонки</p>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
{ALL_COLUMNS.map((col) => (
|
||||||
selected={dateRange as any}
|
<label
|
||||||
onSelect={(range: DateRange | undefined) => setDateRange({ from: range?.from, to: range?.to })}
|
key={col.id}
|
||||||
initialFocus
|
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||||
numberOfMonths={2}
|
>
|
||||||
locale={ru}
|
<Checkbox
|
||||||
|
checked={visibleColumns.has(col.id)}
|
||||||
|
onCheckedChange={() => toggleColumn(col.id)}
|
||||||
/>
|
/>
|
||||||
|
{col.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={loadPlacements}
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
|
||||||
Применить
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Table */}
|
<AnalyticsFiltersModal
|
||||||
|
open={filtersOpen}
|
||||||
|
onOpenChange={setFiltersOpen}
|
||||||
|
onApply={(newFilters) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
projects={projects}
|
||||||
|
channels={[]}
|
||||||
|
creatives={[]}
|
||||||
|
initialFilters={DEFAULT_FILTERS}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -381,15 +451,16 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : placements.length === 0 ? (
|
) : placements.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground">Нет завершённых размещений</p>
|
<p className="text-muted-foreground">Нет размещений</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border overflow-x-auto">
|
<div className="rounded-md border overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
{visibleColumns.has("placement_date") && (
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap"
|
||||||
onClick={() => handleSort("placement_date")}
|
onClick={() => handleSort("placement_date")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -397,8 +468,10 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cost") && (
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap"
|
||||||
onClick={() => handleSort("cost")}
|
onClick={() => handleSort("cost")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -406,20 +479,30 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Канал размещения</TableHead>
|
)}
|
||||||
<TableHead>Проект</TableHead>
|
{visibleColumns.has("project_title") && (
|
||||||
<TableHead>Креатив</TableHead>
|
<TableHead className="whitespace-nowrap">Проект</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("channel_title") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Канал</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("creative_name") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Креатив</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("subscriptions_count") && (
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 text-right"
|
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||||||
onClick={() => handleSort("subscriptions_count")}
|
onClick={() => handleSort("subscriptions_count")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
Подписчики
|
Подписки
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("views_count") && (
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 text-right"
|
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||||||
onClick={() => handleSort("views_count")}
|
onClick={() => handleSort("views_count")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
@@ -427,8 +510,10 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cpf") && (
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 text-right"
|
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||||||
onClick={() => handleSort("cpf")}
|
onClick={() => handleSort("cpf")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
@@ -436,8 +521,10 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cpm") && (
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 text-right"
|
className="cursor-pointer hover:bg-muted/50 whitespace-nowrap text-right"
|
||||||
onClick={() => handleSort("cpm")}
|
onClick={() => handleSort("cpm")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
@@ -445,66 +532,223 @@ export default function PlacementsAnalyticsPage() {
|
|||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center">Пост</TableHead>
|
)}
|
||||||
|
{visibleColumns.has("post_url") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-center">Пост</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("placement_type") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Тип закупа</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cost_type") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Формат</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cost_before_bargain") && (
|
||||||
|
<TableHead className="whitespace-nowrap">До торга</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("payment_at") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Оплата</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("comment") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Комментарий</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("invite_link") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Ссылка</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("invite_link_type") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Тип ссылки</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("format") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Формат</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("time_on_top") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">В топе</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("time_in_feed") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">В ленте</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("discount_percent") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">% скидки</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("conversion_24h") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">Конв. 24ч</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("conversion_48h") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">Конв. 48ч</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("conversion_total") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">Конв. общ.</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("unsubscriptions_count") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">Отписки</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("unsub_percent") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">% отписок</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("total_active") && (
|
||||||
|
<TableHead className="whitespace-nowrap text-right">Итого</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("post_deleted_at") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Удалён</TableHead>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("invite_link_created_at") && (
|
||||||
|
<TableHead className="whitespace-nowrap">Дата ссылки</TableHead>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedPlacements.map((placement) => (
|
{sortedPlacements.map((placement) => (
|
||||||
<TableRow key={placement.id}>
|
<TableRow key={placement.id}>
|
||||||
|
{visibleColumns.has("placement_date") && (
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
{formatDate(placement.placement_date)}
|
{renderCell(placement, "placement_date")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-medium">
|
|
||||||
{formatCurrency(placement.cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="whitespace-nowrap">
|
|
||||||
{placement.placement_channel_title}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="whitespace-nowrap">
|
|
||||||
{placement.project_channel_title}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="whitespace-nowrap">
|
|
||||||
{placement.creative_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right whitespace-nowrap">
|
|
||||||
{formatNumber(placement.subscriptions_count)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right whitespace-nowrap">
|
|
||||||
{formatNumber(placement.views_count)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right whitespace-nowrap">
|
|
||||||
{formatCurrency(placement.cpf)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right whitespace-nowrap">
|
|
||||||
{formatCurrency(placement.cpm)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
{placement.post_url ? (
|
|
||||||
<a
|
|
||||||
href={placement.post_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center justify-center p-1 rounded hover:bg-muted"
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/50">—</span>
|
|
||||||
)}
|
)}
|
||||||
|
{visibleColumns.has("cost") && (
|
||||||
|
<TableCell className="whitespace-nowrap font-medium">
|
||||||
|
{renderCell(placement, "cost")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("project_title") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{placement.project_title}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("channel_title") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{placement.channel_title}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("creative_name") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{placement.creative_name || "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("subscriptions_count") && (
|
||||||
|
<TableCell className="text-right whitespace-nowrap">
|
||||||
|
{renderCell(placement, "subscriptions_count")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("views_count") && (
|
||||||
|
<TableCell className="text-right whitespace-nowrap">
|
||||||
|
{renderCell(placement, "views_count")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cpf") && (
|
||||||
|
<TableCell className="text-right whitespace-nowrap">
|
||||||
|
{renderCell(placement, "cpf")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cpm") && (
|
||||||
|
<TableCell className="text-right whitespace-nowrap">
|
||||||
|
{renderCell(placement, "cpm")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("post_url") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-center">
|
||||||
|
{renderCell(placement, "post_url")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("placement_type") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "placement_type")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cost_type") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "cost_type")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("cost_before_bargain") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "cost_before_bargain")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("payment_at") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "payment_at")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("comment") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "comment")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("invite_link") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{placement.invite_link ? (
|
||||||
|
<a href={placement.invite_link} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
Ссылка
|
||||||
|
</a>
|
||||||
|
) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("invite_link_type") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "invite_link_type")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("format") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{placement.format || "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("time_on_top") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{placement.time_on_top ? `${placement.time_on_top} мин` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("time_in_feed") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{placement.time_in_feed ? `${placement.time_in_feed} мин` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("discount_percent") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{renderCell(placement, "discount_percent")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("conversion_24h") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{renderCell(placement, "conversion_24h")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("conversion_48h") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{renderCell(placement, "conversion_48h")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("conversion_total") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{renderCell(placement, "conversion_total")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("unsubscriptions_count") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{renderCell(placement, "unsubscriptions_count")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("unsub_percent") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
|
{renderCell(placement, "unsub_percent")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("total_active") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-right font-medium">
|
||||||
|
{renderCell(placement, "total_active")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("post_deleted_at") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "post_deleted_at")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("invite_link_created_at") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{renderCell(placement, "invite_link_created_at")}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{/* Totals Row */}
|
|
||||||
<TableRow className="bg-muted/50 font-medium">
|
|
||||||
<TableCell>ИТОГО</TableCell>
|
|
||||||
<TableCell>{formatCurrency(totals.cost)}</TableCell>
|
|
||||||
<TableCell colSpan={2}></TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(totals.subscriptions_count)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(totals.views_count)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatCurrency(totals.cpf)}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatCurrency(totals.cpm)}</TableCell>
|
|
||||||
<TableCell></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ function CellRenderer({
|
|||||||
<td key={column.id} className="py-2 px-3">
|
<td key={column.id} className="py-2 px-3">
|
||||||
{placement.invite_link ? (
|
{placement.invite_link ? (
|
||||||
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
|
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
|
||||||
{placement.invite_link}
|
{placement.invite_link.slice(8, 20)}…
|
||||||
</code>
|
</code>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
@@ -498,8 +498,16 @@ function CellRenderer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "cost":
|
case "cost":
|
||||||
const isFixedFormat = placement.details?.cost?.type === "fixed";
|
const costType = placement.details?.cost?.type || "fixed";
|
||||||
const canEditCost = canEdit && isFixedFormat;
|
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 (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<td key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "cost" ? (
|
{isEditing && editingCell?.field === "cost" ? (
|
||||||
@@ -509,7 +517,7 @@ function CellRenderer({
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
||||||
defaultValue={effectiveCost}
|
defaultValue={calculatedCost || 0}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value;
|
const raw = e.target.value;
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -533,8 +541,55 @@ function CellRenderer({
|
|||||||
canEditCost && setEditingCell({ placementId: placement.id, field: "cost" })
|
canEditCost && setEditingCell({ placementId: placement.id, field: "cost" })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>{effectiveCost ? formatCurrency(effectiveCost) : "—"}</span>
|
<span className="font-medium">{calculatedCost ? formatCurrency(calculatedCost) : "—"}</span>
|
||||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
{costType === "fixed" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<td key={column.id} className="py-2 px-3">
|
||||||
|
{isEditing && editingCell?.field === "cpm" ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
autoFocus
|
||||||
|
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
||||||
|
defaultValue={calculatedCpm || 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEditCpm}
|
||||||
|
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||||||
|
onClick={() =>
|
||||||
|
canEditCpm && setEditingCell({ placementId: placement.id, field: "cpm" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{calculatedCpm ? formatCurrency(calculatedCpm) : "—"}</span>
|
||||||
|
{cpmType === "cpm" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -750,48 +805,6 @@ function CellRenderer({
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "cpm":
|
|
||||||
const isCpmFormat = placement.details?.cost?.type === "cpm";
|
|
||||||
const canEditCpm = canEdit && isCpmFormat;
|
|
||||||
return (
|
|
||||||
<td key={column.id} className="py-2 px-3">
|
|
||||||
{isEditing && editingCell?.field === "cpm" ? (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
autoFocus
|
|
||||||
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
|
||||||
defaultValue={cpm || 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canEditCpm}
|
|
||||||
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
|
||||||
onClick={() =>
|
|
||||||
canEditCpm && setEditingCell({ placementId: placement.id, field: "cpm" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>{cpm ? formatCurrency(cpm) : "—"}</span>
|
|
||||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "conversion_24h":
|
case "conversion_24h":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
|
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
|
||||||
|
|||||||
638
components/analytics-filters-modal.tsx
Normal file
638
components/analytics-filters-modal.tsx
Normal file
@@ -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<PlacementsAnalyticsFilters>(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 = <T extends string>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
Фильтры
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Настройте гибкие фильтры для таблицы размещений
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex border-b">
|
||||||
|
{[
|
||||||
|
{ id: "categories", label: "Категории" },
|
||||||
|
{ id: "numbers", label: "Числовые" },
|
||||||
|
{ id: "text", label: "Текст" },
|
||||||
|
{ id: "dates", label: "Даты" },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{activeTab === "categories" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Projects - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Проекты</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<label
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.projectIds.includes(project.id)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.projectIds.includes(project.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
projectIds: toggleArrayItem(filters.projectIds, project.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[200px]">{project.title}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Нет доступных проектов</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Статусы</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||||
|
{STATUS_OPTIONS.map((status) => (
|
||||||
|
<label
|
||||||
|
key={status}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.statusList.includes(status)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.statusList.includes(status)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
statusList: toggleArrayItem(filters.statusList, status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[200px]">{status}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channels - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Каналы размещения</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<label
|
||||||
|
key={channel.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.placementChannelIds.includes(channel.id)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.placementChannelIds.includes(channel.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
placementChannelIds: toggleArrayItem(filters.placementChannelIds, channel.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[200px]">{channel.title}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{channels.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Нет доступных каналов</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Creatives - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Креативы</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||||
|
{creatives.map((creative) => (
|
||||||
|
<label
|
||||||
|
key={creative.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.creativeIds.includes(creative.id)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.creativeIds.includes(creative.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
creativeIds: toggleArrayItem(filters.creativeIds, creative.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[200px]">{creative.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{creatives.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Нет доступных креативов</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Type - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Формат оплаты</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COST_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.costTypes.includes(opt.value)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.costTypes.includes(opt.value)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
costTypes: toggleArrayItem(filters.costTypes, opt.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placement Type - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Тип закупа</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PLACEMENT_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.placementTypes.includes(opt.value)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.placementTypes.includes(opt.value)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
placementTypes: toggleArrayItem(filters.placementTypes, opt.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Link Type - Multi-select */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Тип ссылки</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{INVITE_LINK_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.inviteLinkTypes.includes(opt.value)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.inviteLinkTypes.includes(opt.value)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
inviteLinkTypes: toggleArrayItem(filters.inviteLinkTypes, opt.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "numbers" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Cost range */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Стоимость (₽)</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="от"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.costMin ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
costMin: e.target.value ? parseFloat(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="до"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.costMax ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
costMax: e.target.value ? parseFloat(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Views range */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Просмотры</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="от"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.viewsMin ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
viewsMin: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="до"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.viewsMax ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
viewsMax: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscriptions range */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Подписки</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="от"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.subscriptionsMin ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
subscriptionsMin: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="до"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.subscriptionsMax ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
subscriptionsMax: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CPM range */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">CPM (₽)</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="от"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.cpmMin ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
cpmMin: e.target.value ? parseFloat(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="до"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.cpmMax ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
cpmMax: e.target.value ? parseFloat(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "text" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Channel title */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Название канала (содержит)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Введите название канала"
|
||||||
|
value={filters.channelTitleContains}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, channelTitleContains: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Creative name */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Название креатива (содержит)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Введите название креатива"
|
||||||
|
value={filters.creativeNameContains}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, creativeNameContains: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Комментарий (содержит)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Введите текст комментария"
|
||||||
|
value={filters.commentContains}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, commentContains: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "dates" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Placement date range */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Дата размещения</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.placementDateFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, placementDateFrom: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-9"
|
||||||
|
value={filters.placementDateTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, placementDateTo: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApply}>
|
||||||
|
Применить
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
SpendingAnalytics,
|
SpendingAnalytics,
|
||||||
AnalyticsQueryParams,
|
AnalyticsQueryParams,
|
||||||
SpendingAnalyticsQueryParams,
|
SpendingAnalyticsQueryParams,
|
||||||
PaginatedResponse,
|
|
||||||
OverviewAnalytics,
|
OverviewAnalytics,
|
||||||
OverviewAnalyticsQueryParams,
|
OverviewAnalyticsQueryParams,
|
||||||
ProjectsAnalyticsResponse,
|
ProjectsAnalyticsResponse,
|
||||||
@@ -20,19 +19,22 @@ import type {
|
|||||||
|
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
/**
|
/**
|
||||||
* Get placements analytics
|
* Get placements analytics (paginated with all fields)
|
||||||
*/
|
*/
|
||||||
placements: (workspaceId: string, params?: PlacementsAnalyticsQueryParams) =>
|
placements: (workspaceId: string, params?: PlacementsAnalyticsQueryParams) =>
|
||||||
api.get<PaginatedResponse<PlacementAnalyticsItem>>(
|
api.get<{
|
||||||
`/workspaces/${workspaceId}/analytics/placements`,
|
items: PlacementAnalyticsItem[];
|
||||||
{ params }
|
total: number;
|
||||||
),
|
page: number;
|
||||||
|
size: number;
|
||||||
|
pages: number;
|
||||||
|
}>(`/workspaces/${workspaceId}/analytics/placements`, { params }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get creatives analytics
|
* Get creatives analytics
|
||||||
*/
|
*/
|
||||||
creatives: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
creatives: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||||
api.get<PaginatedResponse<CreativeAnalyticsItem>>(
|
api.get<{ items: CreativeAnalyticsItem[]; total: number; page: number; size: number; pages: number }>(
|
||||||
`/workspaces/${workspaceId}/analytics/creatives`,
|
`/workspaces/${workspaceId}/analytics/creatives`,
|
||||||
{ params }
|
{ params }
|
||||||
),
|
),
|
||||||
@@ -41,7 +43,7 @@ export const analyticsApi = {
|
|||||||
* Get channels analytics
|
* Get channels analytics
|
||||||
*/
|
*/
|
||||||
channels: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
channels: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||||
api.get<PaginatedResponse<ChannelAnalyticsItem>>(
|
api.get<{ items: ChannelAnalyticsItem[]; total: number; page: number; size: number; pages: number }>(
|
||||||
`/workspaces/${workspaceId}/analytics/channels`,
|
`/workspaces/${workspaceId}/analytics/channels`,
|
||||||
{ params }
|
{ params }
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ALL_COLUMNS: ColumnConfig[] = [
|
|||||||
|
|
||||||
// Content
|
// Content
|
||||||
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
{ 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 },
|
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
||||||
|
|
||||||
// Finance
|
// Finance
|
||||||
|
|||||||
@@ -396,9 +396,10 @@ export const demoAwareAnalyticsApi = {
|
|||||||
placements: async (
|
placements: async (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
params?: {
|
params?: {
|
||||||
project_id?: string;
|
project_ids?: string;
|
||||||
placement_channel_id?: string;
|
status_list?: string;
|
||||||
creative_id?: string;
|
placement_channel_ids?: string;
|
||||||
|
creative_ids?: string;
|
||||||
date_from?: string;
|
date_from?: string;
|
||||||
date_to?: string;
|
date_to?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -407,22 +408,22 @@ export const demoAwareAnalyticsApi = {
|
|||||||
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
|
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
|
||||||
if (isDemoWorkspace(workspaceId)) {
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
let items = demoPlacementAnalytics;
|
let items = demoPlacementAnalytics;
|
||||||
if (params?.project_id) {
|
if (params?.project_ids) {
|
||||||
items = items.filter((p) => p.project_id === params.project_id);
|
items = items.filter((p) => p.project_id === params.project_ids);
|
||||||
}
|
}
|
||||||
if (params?.placement_channel_id) {
|
if (params?.placement_channel_ids) {
|
||||||
items = items.filter((p) => p.placement_channel_id === params.placement_channel_id);
|
items = items.filter((p) => params.placement_channel_ids!.includes(p.channel_id));
|
||||||
}
|
}
|
||||||
if (params?.creative_id) {
|
if (params?.creative_ids) {
|
||||||
items = items.filter((p) => p.creative_id === params.creative_id);
|
items = items.filter((p) => p.creative_id && params.creative_ids!.includes(p.creative_id));
|
||||||
}
|
}
|
||||||
if (params?.date_from) {
|
if (params?.date_from) {
|
||||||
const from = new Date(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) {
|
if (params?.date_to) {
|
||||||
const to = new Date(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);
|
return paginate(items, params?.page, params?.size);
|
||||||
}
|
}
|
||||||
@@ -452,12 +453,12 @@ export const demoAwareAnalyticsApi = {
|
|||||||
|
|
||||||
if (params.date_from) {
|
if (params.date_from) {
|
||||||
const from = new Date(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) {
|
if (params.date_to) {
|
||||||
const to = new Date(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
|
// Helper to format period label
|
||||||
@@ -483,6 +484,7 @@ export const demoAwareAnalyticsApi = {
|
|||||||
// Group by period
|
// Group by period
|
||||||
const grouped: Record<string, typeof filtered> = {};
|
const grouped: Record<string, typeof filtered> = {};
|
||||||
filtered.forEach((item) => {
|
filtered.forEach((item) => {
|
||||||
|
if (!item.placement_date) return;
|
||||||
const date = new Date(item.placement_date);
|
const date = new Date(item.placement_date);
|
||||||
let periodKey: string;
|
let periodKey: string;
|
||||||
|
|
||||||
|
|||||||
@@ -766,87 +766,177 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Placements Analytics (Projects Analytics)
|
// Placements Analytics (detailed view)
|
||||||
export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
|
export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
|
||||||
{
|
{
|
||||||
id: "plac-0001-0000-0000-000000000001",
|
id: "plac-0001-0000-0000-000000000001",
|
||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
project_channel_title: "Крипто Новости",
|
project_title: "Крипто Новости",
|
||||||
placement_channel_id: "chan-0001-0000-0000-000000000001",
|
channel_id: "chan-0001-0000-0000-000000000001",
|
||||||
placement_channel_title: "Технологии Будущего",
|
channel_title: "Технологии Будущего",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
creative_name: "Крипто - Основной",
|
creative_name: "Крипто - Основной",
|
||||||
placement_date: "2024-12-20T12:00:00Z",
|
|
||||||
cost: 5500,
|
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,
|
subscriptions_count: 156,
|
||||||
views_count: 8500,
|
views_count: 8500,
|
||||||
cpf: 35.26,
|
cpf: 35.26,
|
||||||
cpm: 647.06,
|
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_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",
|
id: "plac-0002-0000-0000-000000000002",
|
||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
project_channel_title: "Крипто Новости",
|
project_title: "Крипто Новости",
|
||||||
placement_channel_id: "chan-0002-0000-0000-000000000002",
|
channel_id: "chan-0002-0000-0000-000000000002",
|
||||||
placement_channel_title: "Инвестиции Pro",
|
channel_title: "Инвестиции Pro",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
creative_name: "Крипто - Основной",
|
creative_name: "Крипто - Основной",
|
||||||
placement_date: "2024-12-18T14:00:00Z",
|
|
||||||
cost: 3200,
|
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,
|
subscriptions_count: 89,
|
||||||
views_count: 4200,
|
views_count: 4200,
|
||||||
cpf: 35.96,
|
cpf: 35.96,
|
||||||
cpm: 761.9,
|
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_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",
|
id: "plac-0003-0000-0000-000000000003",
|
||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
project_channel_title: "Крипто Новости",
|
project_title: "Крипто Новости",
|
||||||
placement_channel_id: "chan-0003-0000-0000-000000000003",
|
channel_id: "chan-0003-0000-0000-000000000003",
|
||||||
placement_channel_title: "Программисты",
|
channel_title: "Программисты",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
creative_name: "Крипто - Основной",
|
creative_name: "Крипто - Основной",
|
||||||
placement_date: "2024-12-15T10:00:00Z",
|
|
||||||
cost: 4800,
|
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,
|
subscriptions_count: 234,
|
||||||
views_count: 12000,
|
views_count: 12000,
|
||||||
cpf: 20.51,
|
cpf: 20.51,
|
||||||
cpm: 400.0,
|
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_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",
|
id: "plac-0004-0000-0000-000000000004",
|
||||||
project_id: "proj-0002-0000-0000-000000000002",
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
project_channel_title: "IT Вакансии",
|
project_title: "IT Вакансии",
|
||||||
placement_channel_id: "chan-0004-0000-0000-000000000004",
|
channel_id: "chan-0004-0000-0000-000000000004",
|
||||||
placement_channel_title: "Маркетинг Pro",
|
channel_title: "Маркетинг Pro",
|
||||||
creative_id: "crea-0002-0000-0000-000000000002",
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
creative_name: "Крипто - Агрессивный",
|
creative_name: "Крипто - Агрессивный",
|
||||||
placement_date: "2024-12-10T16:00:00Z",
|
|
||||||
cost: 2500,
|
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,
|
subscriptions_count: 67,
|
||||||
views_count: 3100,
|
views_count: 3100,
|
||||||
cpf: 37.31,
|
cpf: 37.31,
|
||||||
cpm: 806.45,
|
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_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",
|
id: "plac-0005-0000-0000-000000000005",
|
||||||
project_id: "proj-0002-0000-0000-000000000002",
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
project_channel_title: "IT Вакансии",
|
project_title: "IT Вакансии",
|
||||||
placement_channel_id: "chan-0005-0000-0000-000000000005",
|
channel_id: "chan-0005-0000-0000-000000000005",
|
||||||
placement_channel_title: "Финансовая грамотность",
|
channel_title: "Финансовая грамотность",
|
||||||
creative_id: "crea-0002-0000-0000-000000000002",
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
creative_name: "Крипто - Агрессивный",
|
creative_name: "Крипто - Агрессивный",
|
||||||
placement_date: "2024-12-05T11:00:00Z",
|
|
||||||
cost: 6200,
|
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,
|
subscriptions_count: 312,
|
||||||
views_count: 15600,
|
views_count: 15600,
|
||||||
cpf: 19.87,
|
cpf: 19.87,
|
||||||
cpm: 397.44,
|
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_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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
117
lib/types/api.ts
117
lib/types/api.ts
@@ -448,30 +448,65 @@ export interface ViewsHistoryQueryParams extends PaginationParams {
|
|||||||
|
|
||||||
export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH";
|
export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH";
|
||||||
|
|
||||||
// Placements Analytics (Projects Analytics)
|
// Placements Analytics (detailed view with all fields)
|
||||||
export interface PlacementAnalyticsItem {
|
export interface PlacementAnalyticsItem {
|
||||||
id: string; // UUID
|
id: string;
|
||||||
project_id: string; // UUID
|
project_id: string;
|
||||||
project_channel_title: string;
|
project_title: string;
|
||||||
placement_channel_id: string; // UUID
|
channel_id: string;
|
||||||
placement_channel_title: string;
|
channel_title: string;
|
||||||
creative_id: string; // UUID
|
creative_id: string | null;
|
||||||
creative_name: string;
|
creative_name: string | null;
|
||||||
placement_date: string; // ISO 8601
|
cost: number | null;
|
||||||
cost: number;
|
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;
|
subscriptions_count: number;
|
||||||
views_count: number;
|
views_count: number | null;
|
||||||
cpf: number;
|
cpf: number | null;
|
||||||
cpm: number;
|
cpm: number | null;
|
||||||
post_url?: string | 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 {
|
export interface PlacementsAnalyticsQueryParams extends PaginationParams {
|
||||||
project_id?: string;
|
project_ids?: string;
|
||||||
placement_channel_id?: string;
|
status_list?: string;
|
||||||
creative_id?: string;
|
placement_channel_ids?: string;
|
||||||
date_from?: string;
|
creative_ids?: string;
|
||||||
date_to?: 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)
|
// Creatives Analytics (matches actual API response)
|
||||||
@@ -615,6 +650,50 @@ export interface OverviewAnalyticsQueryParams {
|
|||||||
date_to?: string;
|
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
|
// Common Types
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user