feat: update analytics
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -471,7 +471,7 @@ function CellRenderer({
|
||||
<td key={column.id} className="py-2 px-3">
|
||||
{placement.invite_link ? (
|
||||
<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>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
@@ -498,8 +498,16 @@ function CellRenderer({
|
||||
);
|
||||
|
||||
case "cost":
|
||||
const isFixedFormat = placement.details?.cost?.type === "fixed";
|
||||
const canEditCost = canEdit && isFixedFormat;
|
||||
const costType = placement.details?.cost?.type || "fixed";
|
||||
const costValue = placement.details?.cost?.value || 0;
|
||||
const viewsCount = placement.placement_post?.views_count || 0;
|
||||
const subsCount = placement.placement_post?.subscriptions_count || 0;
|
||||
|
||||
// Если тип CPM - показываем рассчитанную стоимость = CPM * views / 1000
|
||||
// Если тип Fixed - показываем фиксированную стоимость
|
||||
const calculatedCost = costType === "cpm" ? (viewsCount > 0 ? (costValue * viewsCount) / 1000 : null) : costValue;
|
||||
|
||||
const canEditCost = canEdit && costType === "fixed";
|
||||
return (
|
||||
<td key={column.id} className="py-2 px-3">
|
||||
{isEditing && editingCell?.field === "cost" ? (
|
||||
@@ -509,7 +517,7 @@ function CellRenderer({
|
||||
step="0.01"
|
||||
autoFocus
|
||||
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
||||
defaultValue={effectiveCost}
|
||||
defaultValue={calculatedCost || 0}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (!raw) {
|
||||
@@ -533,8 +541,55 @@ function CellRenderer({
|
||||
canEditCost && setEditingCell({ placementId: placement.id, field: "cost" })
|
||||
}
|
||||
>
|
||||
<span>{effectiveCost ? formatCurrency(effectiveCost) : "—"}</span>
|
||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||
<span className="font-medium">{calculatedCost ? formatCurrency(calculatedCost) : "—"}</span>
|
||||
{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>
|
||||
)}
|
||||
</td>
|
||||
@@ -750,48 +805,6 @@ function CellRenderer({
|
||||
</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":
|
||||
return (
|
||||
<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,
|
||||
AnalyticsQueryParams,
|
||||
SpendingAnalyticsQueryParams,
|
||||
PaginatedResponse,
|
||||
OverviewAnalytics,
|
||||
OverviewAnalyticsQueryParams,
|
||||
ProjectsAnalyticsResponse,
|
||||
@@ -20,19 +19,22 @@ import type {
|
||||
|
||||
export const analyticsApi = {
|
||||
/**
|
||||
* Get placements analytics
|
||||
* Get placements analytics (paginated with all fields)
|
||||
*/
|
||||
placements: (workspaceId: string, params?: PlacementsAnalyticsQueryParams) =>
|
||||
api.get<PaginatedResponse<PlacementAnalyticsItem>>(
|
||||
`/workspaces/${workspaceId}/analytics/placements`,
|
||||
{ params }
|
||||
),
|
||||
api.get<{
|
||||
items: PlacementAnalyticsItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
pages: number;
|
||||
}>(`/workspaces/${workspaceId}/analytics/placements`, { params }),
|
||||
|
||||
/**
|
||||
* Get creatives analytics
|
||||
*/
|
||||
creatives: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||
api.get<PaginatedResponse<CreativeAnalyticsItem>>(
|
||||
api.get<{ items: CreativeAnalyticsItem[]; total: number; page: number; size: number; pages: number }>(
|
||||
`/workspaces/${workspaceId}/analytics/creatives`,
|
||||
{ params }
|
||||
),
|
||||
@@ -41,7 +43,7 @@ export const analyticsApi = {
|
||||
* Get channels analytics
|
||||
*/
|
||||
channels: (workspaceId: string, params?: AnalyticsQueryParams) =>
|
||||
api.get<PaginatedResponse<ChannelAnalyticsItem>>(
|
||||
api.get<{ items: ChannelAnalyticsItem[]; total: number; page: number; size: number; pages: number }>(
|
||||
`/workspaces/${workspaceId}/analytics/channels`,
|
||||
{ params }
|
||||
),
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ALL_COLUMNS: ColumnConfig[] = [
|
||||
|
||||
// Content
|
||||
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
||||
{ id: "invite_link", label: "Пригласительная ссылка", group: "content", editable: false },
|
||||
{ id: "invite_link", label: "Пригл. ссылка", group: "content", editable: false },
|
||||
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
||||
|
||||
// Finance
|
||||
|
||||
@@ -396,9 +396,10 @@ export const demoAwareAnalyticsApi = {
|
||||
placements: async (
|
||||
workspaceId: string,
|
||||
params?: {
|
||||
project_id?: string;
|
||||
placement_channel_id?: string;
|
||||
creative_id?: string;
|
||||
project_ids?: string;
|
||||
status_list?: string;
|
||||
placement_channel_ids?: string;
|
||||
creative_ids?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
@@ -407,22 +408,22 @@ export const demoAwareAnalyticsApi = {
|
||||
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
|
||||
if (isDemoWorkspace(workspaceId)) {
|
||||
let items = demoPlacementAnalytics;
|
||||
if (params?.project_id) {
|
||||
items = items.filter((p) => p.project_id === params.project_id);
|
||||
if (params?.project_ids) {
|
||||
items = items.filter((p) => p.project_id === params.project_ids);
|
||||
}
|
||||
if (params?.placement_channel_id) {
|
||||
items = items.filter((p) => p.placement_channel_id === params.placement_channel_id);
|
||||
if (params?.placement_channel_ids) {
|
||||
items = items.filter((p) => params.placement_channel_ids!.includes(p.channel_id));
|
||||
}
|
||||
if (params?.creative_id) {
|
||||
items = items.filter((p) => p.creative_id === params.creative_id);
|
||||
if (params?.creative_ids) {
|
||||
items = items.filter((p) => p.creative_id && params.creative_ids!.includes(p.creative_id));
|
||||
}
|
||||
if (params?.date_from) {
|
||||
const from = new Date(params.date_from);
|
||||
items = items.filter((p) => new Date(p.placement_date) >= from);
|
||||
items = items.filter((p) => p.placement_date && new Date(p.placement_date) >= from);
|
||||
}
|
||||
if (params?.date_to) {
|
||||
const to = new Date(params.date_to);
|
||||
items = items.filter((p) => new Date(p.placement_date) <= to);
|
||||
items = items.filter((p) => p.placement_date && new Date(p.placement_date) <= to);
|
||||
}
|
||||
return paginate(items, params?.page, params?.size);
|
||||
}
|
||||
@@ -452,12 +453,12 @@ export const demoAwareAnalyticsApi = {
|
||||
|
||||
if (params.date_from) {
|
||||
const from = new Date(params.date_from);
|
||||
filtered = filtered.filter((p) => new Date(p.placement_date) >= from);
|
||||
filtered = filtered.filter((p) => p.placement_date && new Date(p.placement_date) >= from);
|
||||
}
|
||||
|
||||
if (params.date_to) {
|
||||
const to = new Date(params.date_to);
|
||||
filtered = filtered.filter((p) => new Date(p.placement_date) <= to);
|
||||
filtered = filtered.filter((p) => p.placement_date && new Date(p.placement_date) <= to);
|
||||
}
|
||||
|
||||
// Helper to format period label
|
||||
@@ -483,6 +484,7 @@ export const demoAwareAnalyticsApi = {
|
||||
// Group by period
|
||||
const grouped: Record<string, typeof filtered> = {};
|
||||
filtered.forEach((item) => {
|
||||
if (!item.placement_date) return;
|
||||
const date = new Date(item.placement_date);
|
||||
let periodKey: string;
|
||||
|
||||
|
||||
@@ -766,87 +766,177 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Placements Analytics (Projects Analytics)
|
||||
// Placements Analytics (detailed view)
|
||||
export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
|
||||
{
|
||||
id: "plac-0001-0000-0000-000000000001",
|
||||
project_id: "proj-0001-0000-0000-000000000001",
|
||||
project_channel_title: "Крипто Новости",
|
||||
placement_channel_id: "chan-0001-0000-0000-000000000001",
|
||||
placement_channel_title: "Технологии Будущего",
|
||||
project_title: "Крипто Новости",
|
||||
channel_id: "chan-0001-0000-0000-000000000001",
|
||||
channel_title: "Технологии Будущего",
|
||||
creative_id: "crea-0001-0000-0000-000000000001",
|
||||
creative_name: "Крипто - Основной",
|
||||
placement_date: "2024-12-20T12:00:00Z",
|
||||
cost: 5500,
|
||||
cost_type: "fixed",
|
||||
cost_before_bargain: 6000,
|
||||
payment_at: "2024-12-19T10:00:00Z",
|
||||
placement_type: "standard",
|
||||
comment: null,
|
||||
format: null,
|
||||
invite_link_type: "public",
|
||||
placement_date: "2024-12-20T12:00:00Z",
|
||||
subscriptions_count: 156,
|
||||
views_count: 8500,
|
||||
cpf: 35.26,
|
||||
cpm: 647.06,
|
||||
time_on_top: 60,
|
||||
time_in_feed: null,
|
||||
invite_link: "https://t.me/+test1",
|
||||
invite_link_created_at: "2024-12-20T11:55:00Z",
|
||||
post_url: "https://t.me/tech_future/1001",
|
||||
post_deleted_at: null,
|
||||
conversion_24h: 1.5,
|
||||
conversion_48h: 2.1,
|
||||
conversion_total: 1.84,
|
||||
unsubscriptions_count: 5,
|
||||
unsub_percent: 3.11,
|
||||
total_active: 151,
|
||||
},
|
||||
{
|
||||
id: "plac-0002-0000-0000-000000000002",
|
||||
project_id: "proj-0001-0000-0000-000000000001",
|
||||
project_channel_title: "Крипто Новости",
|
||||
placement_channel_id: "chan-0002-0000-0000-000000000002",
|
||||
placement_channel_title: "Инвестиции Pro",
|
||||
project_title: "Крипто Новости",
|
||||
channel_id: "chan-0002-0000-0000-000000000002",
|
||||
channel_title: "Инвестиции Pro",
|
||||
creative_id: "crea-0001-0000-0000-000000000001",
|
||||
creative_name: "Крипто - Основной",
|
||||
placement_date: "2024-12-18T14:00:00Z",
|
||||
cost: 3200,
|
||||
cost_type: "fixed",
|
||||
cost_before_bargain: 3500,
|
||||
payment_at: "2024-12-17T14:00:00Z",
|
||||
placement_type: "standard",
|
||||
comment: null,
|
||||
format: null,
|
||||
invite_link_type: "public",
|
||||
placement_date: "2024-12-18T14:00:00Z",
|
||||
subscriptions_count: 89,
|
||||
views_count: 4200,
|
||||
cpf: 35.96,
|
||||
cpm: 761.9,
|
||||
time_on_top: 45,
|
||||
time_in_feed: null,
|
||||
invite_link: "https://t.me/+test2",
|
||||
invite_link_created_at: "2024-12-18T13:50:00Z",
|
||||
post_url: "https://t.me/invest_pro/502",
|
||||
post_deleted_at: null,
|
||||
conversion_24h: 1.8,
|
||||
conversion_48h: 2.2,
|
||||
conversion_total: 2.12,
|
||||
unsubscriptions_count: 3,
|
||||
unsub_percent: 3.26,
|
||||
total_active: 86,
|
||||
},
|
||||
{
|
||||
id: "plac-0003-0000-0000-000000000003",
|
||||
project_id: "proj-0001-0000-0000-000000000001",
|
||||
project_channel_title: "Крипто Новости",
|
||||
placement_channel_id: "chan-0003-0000-0000-000000000003",
|
||||
placement_channel_title: "Программисты",
|
||||
project_title: "Крипто Новости",
|
||||
channel_id: "chan-0003-0000-0000-000000000003",
|
||||
channel_title: "Программисты",
|
||||
creative_id: "crea-0001-0000-0000-000000000001",
|
||||
creative_name: "Крипто - Основной",
|
||||
placement_date: "2024-12-15T10:00:00Z",
|
||||
cost: 4800,
|
||||
cost_type: "cpm",
|
||||
cost_before_bargain: null,
|
||||
payment_at: "2024-12-14T10:00:00Z",
|
||||
placement_type: "standard",
|
||||
comment: "Хороший канал",
|
||||
format: null,
|
||||
invite_link_type: "approval",
|
||||
placement_date: "2024-12-15T10:00:00Z",
|
||||
subscriptions_count: 234,
|
||||
views_count: 12000,
|
||||
cpf: 20.51,
|
||||
cpm: 400.0,
|
||||
time_on_top: 120,
|
||||
time_in_feed: null,
|
||||
invite_link: "https://t.me/+test3",
|
||||
invite_link_created_at: "2024-12-15T09:55:00Z",
|
||||
post_url: "https://t.me/programmers_hub/2050",
|
||||
post_deleted_at: null,
|
||||
conversion_24h: 1.2,
|
||||
conversion_48h: 1.8,
|
||||
conversion_total: 1.95,
|
||||
unsubscriptions_count: 8,
|
||||
unsub_percent: 3.31,
|
||||
total_active: 226,
|
||||
},
|
||||
{
|
||||
id: "plac-0004-0000-0000-000000000004",
|
||||
project_id: "proj-0002-0000-0000-000000000002",
|
||||
project_channel_title: "IT Вакансии",
|
||||
placement_channel_id: "chan-0004-0000-0000-000000000004",
|
||||
placement_channel_title: "Маркетинг Pro",
|
||||
project_title: "IT Вакансии",
|
||||
channel_id: "chan-0004-0000-0000-000000000004",
|
||||
channel_title: "Маркетинг Pro",
|
||||
creative_id: "crea-0002-0000-0000-000000000002",
|
||||
creative_name: "Крипто - Агрессивный",
|
||||
placement_date: "2024-12-10T16:00:00Z",
|
||||
cost: 2500,
|
||||
cost_type: "fixed",
|
||||
cost_before_bargain: 3000,
|
||||
payment_at: "2024-12-09T16:00:00Z",
|
||||
placement_type: "self_promo",
|
||||
comment: null,
|
||||
format: null,
|
||||
invite_link_type: "public",
|
||||
placement_date: "2024-12-10T16:00:00Z",
|
||||
subscriptions_count: 67,
|
||||
views_count: 3100,
|
||||
cpf: 37.31,
|
||||
cpm: 806.45,
|
||||
time_on_top: 30,
|
||||
time_in_feed: null,
|
||||
invite_link: "https://t.me/+test4",
|
||||
invite_link_created_at: "2024-12-10T15:55:00Z",
|
||||
post_url: "https://t.me/marketing_pro/803",
|
||||
post_deleted_at: "2024-12-12T16:00:00Z",
|
||||
conversion_24h: 1.5,
|
||||
conversion_48h: 2.0,
|
||||
conversion_total: 2.16,
|
||||
unsubscriptions_count: 2,
|
||||
unsub_percent: 2.9,
|
||||
total_active: 65,
|
||||
},
|
||||
{
|
||||
id: "plac-0005-0000-0000-000000000005",
|
||||
project_id: "proj-0002-0000-0000-000000000002",
|
||||
project_channel_title: "IT Вакансии",
|
||||
placement_channel_id: "chan-0005-0000-0000-000000000005",
|
||||
placement_channel_title: "Финансовая грамотность",
|
||||
project_title: "IT Вакансии",
|
||||
channel_id: "chan-0005-0000-0000-000000000005",
|
||||
channel_title: "Финансовая грамотность",
|
||||
creative_id: "crea-0002-0000-0000-000000000002",
|
||||
creative_name: "Крипто - Агрессивный",
|
||||
placement_date: "2024-12-05T11:00:00Z",
|
||||
cost: 6200,
|
||||
cost_type: "fixed",
|
||||
cost_before_bargain: 7500,
|
||||
payment_at: "2024-12-04T11:00:00Z",
|
||||
placement_type: "standard",
|
||||
comment: null,
|
||||
format: null,
|
||||
invite_link_type: "approval",
|
||||
placement_date: "2024-12-05T11:00:00Z",
|
||||
subscriptions_count: 312,
|
||||
views_count: 15600,
|
||||
cpf: 19.87,
|
||||
cpm: 397.44,
|
||||
time_on_top: 90,
|
||||
time_in_feed: null,
|
||||
invite_link: "https://t.me/+test5",
|
||||
invite_link_created_at: "2024-12-05T10:55:00Z",
|
||||
post_url: "https://t.me/finance_edu/1204",
|
||||
post_deleted_at: null,
|
||||
conversion_24h: 1.3,
|
||||
conversion_48h: 1.9,
|
||||
conversion_total: 2.0,
|
||||
unsubscriptions_count: 12,
|
||||
unsub_percent: 3.7,
|
||||
total_active: 300,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
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";
|
||||
|
||||
// Placements Analytics (Projects Analytics)
|
||||
// Placements Analytics (detailed view with all fields)
|
||||
export interface PlacementAnalyticsItem {
|
||||
id: string; // UUID
|
||||
project_id: string; // UUID
|
||||
project_channel_title: string;
|
||||
placement_channel_id: string; // UUID
|
||||
placement_channel_title: string;
|
||||
creative_id: string; // UUID
|
||||
creative_name: string;
|
||||
placement_date: string; // ISO 8601
|
||||
cost: number;
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
channel_id: string;
|
||||
channel_title: string;
|
||||
creative_id: string | null;
|
||||
creative_name: string | null;
|
||||
cost: number | null;
|
||||
cost_type: CostType;
|
||||
cost_before_bargain: number | null;
|
||||
payment_at: string | null;
|
||||
placement_type: PlacementType | null;
|
||||
comment: string | null;
|
||||
format: string | null;
|
||||
invite_link_type: InviteLinkType | null;
|
||||
placement_date: string | null;
|
||||
subscriptions_count: number;
|
||||
views_count: number;
|
||||
cpf: number;
|
||||
cpm: number;
|
||||
post_url?: string | null;
|
||||
views_count: number | null;
|
||||
cpf: number | null;
|
||||
cpm: number | null;
|
||||
time_on_top: number | null;
|
||||
time_in_feed: number | null;
|
||||
invite_link: string | null;
|
||||
invite_link_created_at: string | null;
|
||||
post_url: string | null;
|
||||
post_deleted_at: string | null;
|
||||
conversion_24h: number | null;
|
||||
conversion_48h: number | null;
|
||||
conversion_total: number | null;
|
||||
unsubscriptions_count: number;
|
||||
unsub_percent: number | null;
|
||||
total_active: number;
|
||||
}
|
||||
|
||||
export interface PlacementsAnalyticsQueryParams extends PaginationParams {
|
||||
project_id?: string;
|
||||
placement_channel_id?: string;
|
||||
creative_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
project_ids?: string;
|
||||
status_list?: string;
|
||||
placement_channel_ids?: string;
|
||||
creative_ids?: string;
|
||||
cost_types?: string;
|
||||
placement_types?: string;
|
||||
invite_link_types?: string;
|
||||
cost_min?: number;
|
||||
cost_max?: number;
|
||||
views_min?: number;
|
||||
views_max?: number;
|
||||
subscriptions_min?: number;
|
||||
subscriptions_max?: number;
|
||||
cpm_min?: number;
|
||||
cpm_max?: number;
|
||||
channel_title_contains?: string;
|
||||
creative_name_contains?: string;
|
||||
comment_contains?: string;
|
||||
placement_date_from?: string;
|
||||
placement_date_to?: string;
|
||||
sort_by?: string;
|
||||
sort_direction?: "asc" | "desc";
|
||||
}
|
||||
|
||||
// Creatives Analytics (matches actual API response)
|
||||
@@ -615,6 +650,50 @@ export interface OverviewAnalyticsQueryParams {
|
||||
date_to?: string;
|
||||
}
|
||||
|
||||
// Placements Detail Analytics
|
||||
export interface PlacementAnalyticsDetail {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
channel_id: string;
|
||||
channel_title: string;
|
||||
creative_id: string | null;
|
||||
creative_name: string | null;
|
||||
cost: number | null;
|
||||
cost_type: CostType;
|
||||
cost_before_bargain: number | null;
|
||||
payment_at: string | null;
|
||||
placement_type: PlacementType | null;
|
||||
comment: string | null;
|
||||
format: string | null;
|
||||
invite_link_type: InviteLinkType | null;
|
||||
placement_date: string | null;
|
||||
subscriptions_count: number;
|
||||
views_count: number | null;
|
||||
cpf: number | null;
|
||||
cpm: number | null;
|
||||
time_on_top: number | null;
|
||||
time_in_feed: number | null;
|
||||
invite_link: string | null;
|
||||
invite_link_created_at: string | null;
|
||||
post_url: string | null;
|
||||
post_deleted_at: string | null;
|
||||
conversion_24h: number | null;
|
||||
conversion_48h: number | null;
|
||||
conversion_total: number | null;
|
||||
unsubscriptions_count: number;
|
||||
unsub_percent: number | null;
|
||||
total_active: number;
|
||||
}
|
||||
|
||||
export interface PlacementsDetailAnalyticsQueryParams extends PaginationParams {
|
||||
project_id?: string;
|
||||
channel_id?: string;
|
||||
status?: string;
|
||||
sort_by?: string;
|
||||
sort_direction?: "asc" | "desc";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Common Types
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user