feat: purchase-plans global update
This commit is contained in:
@@ -83,6 +83,7 @@ export default function PlacementDetailPage() {
|
|||||||
const [placement, setPlacement] = useState<Placement | null>(null);
|
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canWrite = hasPermission("placements_write");
|
const canWrite = hasPermission("placements_write");
|
||||||
|
|
||||||
@@ -93,8 +94,14 @@ export default function PlacementDetailPage() {
|
|||||||
const loadPlacement = async () => {
|
const loadPlacement = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
// First, try to load placement without project_id (deprecated endpoint)
|
||||||
|
// If that fails, we'd need projectId
|
||||||
const data = await placementsApi.get(workspaceId, placementId);
|
const data = await placementsApi.get(workspaceId, placementId);
|
||||||
setPlacement(data);
|
setPlacement(data);
|
||||||
|
// Store projectId from placement for future operations
|
||||||
|
if (data.project?.id) {
|
||||||
|
setProjectId(data.project.id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load placement:", err);
|
console.error("Failed to load placement:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
148
components/column-selector-dialog.tsx
Normal file
148
components/column-selector-dialog.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { X, Check } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ALL_COLUMNS, COLUMN_GROUPS, DEFAULT_VISIBLE_COLUMNS } from "@/lib/config/placement-columns";
|
||||||
|
|
||||||
|
interface ColumnSelectorDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
visibleColumns: Set<string>;
|
||||||
|
onVisibleColumnsChange: (columns: Set<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnSelectorDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
visibleColumns,
|
||||||
|
onVisibleColumnsChange,
|
||||||
|
}: ColumnSelectorDialogProps) {
|
||||||
|
const handleToggleColumn = (columnId: string) => {
|
||||||
|
const next = new Set(visibleColumns);
|
||||||
|
if (next.has(columnId)) {
|
||||||
|
// Don't allow hiding required columns
|
||||||
|
const column = ALL_COLUMNS.find((c) => c.id === columnId);
|
||||||
|
if (column?.required) return;
|
||||||
|
next.delete(columnId);
|
||||||
|
} else {
|
||||||
|
next.add(columnId);
|
||||||
|
}
|
||||||
|
onVisibleColumnsChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleGroup = (groupId: string, select: boolean) => {
|
||||||
|
const groupColumns = ALL_COLUMNS.filter((c) => c.group === groupId);
|
||||||
|
const next = new Set(visibleColumns);
|
||||||
|
|
||||||
|
groupColumns.forEach((column) => {
|
||||||
|
if (column.required) return;
|
||||||
|
if (select) {
|
||||||
|
next.add(column.id);
|
||||||
|
} else {
|
||||||
|
next.delete(column.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onVisibleColumnsChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroupFullySelected = (groupId: string): boolean => {
|
||||||
|
const groupColumns = ALL_COLUMNS.filter((c) => c.group === groupId);
|
||||||
|
return groupColumns.every((c) => c.required || visibleColumns.has(c.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroupPartiallySelected = (groupId: string): boolean => {
|
||||||
|
const groupColumns = ALL_COLUMNS.filter((c) => c.group === groupId);
|
||||||
|
return groupColumns.some((c) => visibleColumns.has(c.id)) && !isGroupFullySelected(groupId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
onVisibleColumnsChange(new Set(DEFAULT_VISIBLE_COLUMNS));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
onVisibleColumnsChange(new Set(ALL_COLUMNS.map((c) => c.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectNone = () => {
|
||||||
|
onVisibleColumnsChange(
|
||||||
|
new Set(ALL_COLUMNS.filter((c) => c.required).map((c) => c.id))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Выбор колонок</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Выберите колонки для отображения в таблице размещений
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||||
|
По умолчанию
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
|
Выбрать все
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectNone}>
|
||||||
|
Снять выбор
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column groups */}
|
||||||
|
{COLUMN_GROUPS.map((group) => {
|
||||||
|
const groupColumns = ALL_COLUMNS.filter((c) => c.group === group.id);
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-${group.id}`}
|
||||||
|
checked={isGroupFullySelected(group.id)}
|
||||||
|
onCheckedChange={(checked) => handleToggleGroup(group.id, !!checked)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`group-${group.id}`}
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</label>
|
||||||
|
{isGroupPartiallySelected(group.id) && (
|
||||||
|
<span className="text-xs text-muted-foreground">(частично выбрано)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 ml-6">
|
||||||
|
{groupColumns.map((column) => (
|
||||||
|
<div key={column.id} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`col-${column.id}`}
|
||||||
|
checked={visibleColumns.has(column.id)}
|
||||||
|
onCheckedChange={() => handleToggleColumn(column.id)}
|
||||||
|
disabled={column.required}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`col-${column.id}`}
|
||||||
|
className={cn(
|
||||||
|
"text-xs cursor-pointer truncate",
|
||||||
|
column.required && "font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
components/creative-select-dialog.tsx
Normal file
90
components/creative-select-dialog.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Creative {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnail_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreativeSelectDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
creatives: Creative[];
|
||||||
|
onSelect: (creative: Creative) => void;
|
||||||
|
onClear: (() => void) | null;
|
||||||
|
currentCreativeId: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreativeSelectDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
creatives,
|
||||||
|
onSelect,
|
||||||
|
onClear,
|
||||||
|
currentCreativeId,
|
||||||
|
}: CreativeSelectDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Выберите креатив</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{creatives.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Нет доступных креативов
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{creatives.map((creative) => (
|
||||||
|
<div
|
||||||
|
key={creative.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
|
||||||
|
currentCreativeId === creative.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(creative);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{creative.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={creative.thumbnail_url}
|
||||||
|
alt={creative.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">📷</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium truncate">{creative.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentCreativeId && onClear && (
|
||||||
|
<div className="pt-4 border-t mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onClear();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отменить выбор креатива
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ interface CreatePlacementsDialogProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
existingPlacements: Placement[];
|
existingPlacements: Placement[];
|
||||||
prefilledCreativeId?: string | null;
|
prefilledCreativeId?: string | null;
|
||||||
|
prefilledChannelIds?: string[] | null;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
@@ -52,6 +53,7 @@ export function CreatePlacementsDialog({
|
|||||||
projectId,
|
projectId,
|
||||||
existingPlacements,
|
existingPlacements,
|
||||||
prefilledCreativeId = null,
|
prefilledCreativeId = null,
|
||||||
|
prefilledChannelIds = null,
|
||||||
open: controlledOpen,
|
open: controlledOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -120,8 +122,9 @@ export function CreatePlacementsDialog({
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadCreatives();
|
loadCreatives();
|
||||||
loadProjectChannels();
|
loadProjectChannels();
|
||||||
|
loadPrefilledChannels();
|
||||||
}
|
}
|
||||||
}, [isOpen, workspaceId, projectId, prefilledCreativeId]);
|
}, [isOpen, workspaceId, projectId, prefilledCreativeId, prefilledChannelIds]);
|
||||||
|
|
||||||
const loadCreatives = async () => {
|
const loadCreatives = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -153,6 +156,24 @@ export function CreatePlacementsDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadPrefilledChannels = async () => {
|
||||||
|
if (!prefilledChannelIds || prefilledChannelIds.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPlacements = await placementsApi.getByProject(workspaceId, projectId);
|
||||||
|
const prefilledChannels = projectPlacements
|
||||||
|
.filter((p) => prefilledChannelIds.includes(p.channel.id))
|
||||||
|
.map((p) => ({
|
||||||
|
username: p.channel.username || p.channel.title,
|
||||||
|
channelId: p.channel.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSelectedChannels(prefilledChannels);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load prefilled channels:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addChannel = async () => {
|
const addChannel = async () => {
|
||||||
const username = parseChannelInput(newChannelInput);
|
const username = parseChannelInput(newChannelInput);
|
||||||
if (!username) {
|
if (!username) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2, Filter, ArrowUpDown, X, Check, ArrowUp, ArrowDown } from "lucide-react";
|
import { Filter, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -21,9 +21,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Placement, PlacementStatus } from "@/lib/types/api";
|
import type { Placement, PlacementStatus, PlacementPostStatus, CostType, PlacementType } from "@/lib/types/api";
|
||||||
|
|
||||||
type FilterCondition = "contains" | "not_contains" | "equals";
|
type FilterCondition = "contains" | "not_contains" | "equals";
|
||||||
|
|
||||||
@@ -60,11 +59,14 @@ export interface PlacementFilters {
|
|||||||
};
|
};
|
||||||
placementFilters: {
|
placementFilters: {
|
||||||
statuses: PlacementStatus[];
|
statuses: PlacementStatus[];
|
||||||
|
postStatuses: PlacementPostStatus[];
|
||||||
|
hasCreative: boolean | null;
|
||||||
costMin: number | null;
|
costMin: number | null;
|
||||||
costMax: number | null;
|
costMax: number | null;
|
||||||
|
costFormat: CostType | null;
|
||||||
|
placementType: PlacementType | null;
|
||||||
dateFrom: string | null;
|
dateFrom: string | null;
|
||||||
dateTo: string | null;
|
dateTo: string | null;
|
||||||
hasPlacement: boolean | null;
|
|
||||||
};
|
};
|
||||||
sort: SortOption | null;
|
sort: SortOption | null;
|
||||||
}
|
}
|
||||||
@@ -74,13 +76,14 @@ const CHANNEL_FILTER_FIELDS: FilterField[] = [
|
|||||||
{ key: "username", label: "Username", type: "text" },
|
{ key: "username", label: "Username", type: "text" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SORT_FIELDS: SortField[] = [
|
const PLACEMENT_SORT_FIELDS: SortField[] = [
|
||||||
{ key: "title", label: "Название канала" },
|
|
||||||
{ key: "subscribers", label: "Подписчики" },
|
|
||||||
{ key: "cost", label: "Стоимость" },
|
{ key: "cost", label: "Стоимость" },
|
||||||
{ key: "date", label: "Дата размещения" },
|
{ key: "date", label: "Дата размещения" },
|
||||||
{ key: "cpm", label: "CPM" },
|
{ key: "cpm", label: "CPM" },
|
||||||
{ key: "cpf", label: "CPF" },
|
{ key: "cpf", label: "CPF" },
|
||||||
|
{ key: "subscribers", label: "Подписчики" },
|
||||||
|
{ key: "views", label: "Просмотры" },
|
||||||
|
{ key: "created", label: "Дата создания" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
|
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
|
||||||
@@ -102,21 +105,40 @@ const STATUS_OPTIONS: PlacementStatus[] = [
|
|||||||
"Не отвечает",
|
"Не отвечает",
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONDITION_LABELS: Record<FilterCondition, string> = {
|
const POST_STATUS_OPTIONS: PlacementPostStatus[] = [
|
||||||
contains: "содержит",
|
"Без статуса",
|
||||||
not_contains: "не содержит",
|
"Отправить пост",
|
||||||
equals: "равно",
|
"Согласование поста",
|
||||||
};
|
"Ожидание отложки",
|
||||||
|
"Запланирован",
|
||||||
|
"Пост вышел",
|
||||||
|
"Размещение отработало - пост удалён",
|
||||||
|
"Размещение отработало - пост не удалён",
|
||||||
|
"Проверить - пост удалён раньше срока",
|
||||||
|
"Проверить - пост не вышел",
|
||||||
|
"Размещение отработало",
|
||||||
|
];
|
||||||
|
|
||||||
|
const COST_FORMAT_OPTIONS: { value: CostType | "any"; label: string }[] = [
|
||||||
|
{ value: "any", label: "Любой" },
|
||||||
|
{ value: "fixed", label: "Фикс" },
|
||||||
|
{ value: "cpm", label: "CPM" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLACEMENT_TYPE_OPTIONS: { value: PlacementType | "any"; label: string }[] = [
|
||||||
|
{ value: "any", label: "Любой" },
|
||||||
|
{ value: "standard", label: "Стандартный" },
|
||||||
|
{ value: "self_promo", label: "Взаимный пиар" },
|
||||||
|
];
|
||||||
|
|
||||||
export function FiltersModal({
|
export function FiltersModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onApply,
|
onApply,
|
||||||
existingPlacements,
|
|
||||||
initialFilters,
|
initialFilters,
|
||||||
}: FiltersModalProps) {
|
}: FiltersModalProps) {
|
||||||
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
|
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
|
||||||
const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("channels");
|
const [activeTab, setActiveTab] = useState<"channels" | "placements">("channels");
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
onApply(filters);
|
onApply(filters);
|
||||||
@@ -133,11 +155,14 @@ export function FiltersModal({
|
|||||||
},
|
},
|
||||||
placementFilters: {
|
placementFilters: {
|
||||||
statuses: [],
|
statuses: [],
|
||||||
|
postStatuses: [],
|
||||||
|
hasCreative: null,
|
||||||
costMin: null,
|
costMin: null,
|
||||||
costMax: null,
|
costMax: null,
|
||||||
|
costFormat: null,
|
||||||
|
placementType: null,
|
||||||
dateFrom: null,
|
dateFrom: null,
|
||||||
dateTo: null,
|
dateTo: null,
|
||||||
hasPlacement: null,
|
|
||||||
},
|
},
|
||||||
sort: null,
|
sort: null,
|
||||||
};
|
};
|
||||||
@@ -155,6 +180,17 @@ export function FiltersModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePostStatus = (status: PlacementPostStatus) => {
|
||||||
|
const currentStatuses = filters.placementFilters.postStatuses;
|
||||||
|
const newStatuses = currentStatuses.includes(status)
|
||||||
|
? currentStatuses.filter((s) => s !== status)
|
||||||
|
: [...currentStatuses, status];
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
placementFilters: { ...filters.placementFilters, postStatuses: newStatuses },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateChannelFilter = (
|
const updateChannelFilter = (
|
||||||
key: "name" | "username",
|
key: "name" | "username",
|
||||||
updates: { value?: string; condition?: FilterCondition }
|
updates: { value?: string; condition?: FilterCondition }
|
||||||
@@ -174,8 +210,12 @@ export function FiltersModal({
|
|||||||
filters.channelFilters.subscribersMin !== null ||
|
filters.channelFilters.subscribersMin !== null ||
|
||||||
filters.channelFilters.subscribersMax !== null ||
|
filters.channelFilters.subscribersMax !== null ||
|
||||||
filters.placementFilters.statuses.length > 0 ||
|
filters.placementFilters.statuses.length > 0 ||
|
||||||
|
filters.placementFilters.postStatuses.length > 0 ||
|
||||||
|
filters.placementFilters.hasCreative !== null ||
|
||||||
filters.placementFilters.costMin !== null ||
|
filters.placementFilters.costMin !== null ||
|
||||||
filters.placementFilters.costMax !== null ||
|
filters.placementFilters.costMax !== null ||
|
||||||
|
filters.placementFilters.costFormat !== null ||
|
||||||
|
filters.placementFilters.placementType !== null ||
|
||||||
filters.placementFilters.dateFrom ||
|
filters.placementFilters.dateFrom ||
|
||||||
filters.placementFilters.dateTo ||
|
filters.placementFilters.dateTo ||
|
||||||
filters.sort;
|
filters.sort;
|
||||||
@@ -186,7 +226,7 @@ export function FiltersModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Filter className="h-5 w-5" />
|
<Filter className="h-5 w-5" />
|
||||||
Фильтры и сортировка
|
Фильтры
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Настройте фильтры для каналов и размещений
|
Настройте фильтры для каналов и размещений
|
||||||
@@ -208,42 +248,31 @@ export function FiltersModal({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("placements")}
|
onClick={() => setActiveTab("placements")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
|
||||||
activeTab === "placements"
|
activeTab === "placements"
|
||||||
? "border-primary text-primary"
|
? "border-primary text-primary"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
Размещения
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("sort")}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
|
|
||||||
activeTab === "sort"
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
Сортировка
|
Размещения
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{activeTab === "channels" && (
|
{activeTab === "channels" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Поиск канала</Label>
|
||||||
{CHANNEL_FILTER_FIELDS.map((field) => (
|
{CHANNEL_FILTER_FIELDS.map((field) => (
|
||||||
<div key={field.key} className="space-y-2">
|
<div key={field.key} className="flex gap-2 items-center">
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
{field.label}
|
|
||||||
<Select
|
<Select
|
||||||
value={filters.channelFilters[field.key as "name" | "username"].condition}
|
value={filters.channelFilters[field.key as "name" | "username"].condition}
|
||||||
onValueChange={(value: FilterCondition) =>
|
onValueChange={(value: FilterCondition) =>
|
||||||
updateChannelFilter(field.key as "name" | "username", { condition: value })
|
updateChannelFilter(field.key as "name" | "username", { condition: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-36 text-xs">
|
<SelectTrigger className="h-8 w-32 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -254,13 +283,9 @@ export function FiltersModal({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={
|
placeholder={field.key === "name" ? "Название канала" : "@username"}
|
||||||
filters.channelFilters[field.key as "name" | "username"].condition === "equals"
|
className="h-8 text-sm"
|
||||||
? "Точное значение"
|
|
||||||
: "Например: новости"
|
|
||||||
}
|
|
||||||
value={filters.channelFilters[field.key as "name" | "username"].value}
|
value={filters.channelFilters[field.key as "name" | "username"].value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
|
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
|
||||||
@@ -268,13 +293,15 @@ export function FiltersModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<Label className="text-sm font-medium">Подписчики</Label>
|
||||||
<Label>Мин. подписчиков</Label>
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="0"
|
placeholder="от"
|
||||||
|
className="h-8 text-sm w-28"
|
||||||
value={filters.channelFilters.subscribersMin || ""}
|
value={filters.channelFilters.subscribersMin || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -286,12 +313,11 @@ export function FiltersModal({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="text-muted-foreground">—</span>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Макс. подписчиков</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="1000000"
|
placeholder="до"
|
||||||
|
className="h-8 text-sm w-28"
|
||||||
value={filters.channelFilters.subscribersMax || ""}
|
value={filters.channelFilters.subscribersMax || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -305,19 +331,25 @@ export function FiltersModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Каналы всегда сортируются по алфавиту
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "placements" && (
|
{activeTab === "placements" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Label>Статусы размещений</Label>
|
<Label className="text-sm font-medium">Статус сделки</Label>
|
||||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
|
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
|
||||||
{STATUS_OPTIONS.map((status) => (
|
{STATUS_OPTIONS.map((status) => (
|
||||||
<label
|
<label
|
||||||
key={status}
|
key={status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
"flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-colors text-xs",
|
||||||
filters.placementFilters.statuses.includes(status)
|
filters.placementFilters.statuses.includes(status)
|
||||||
? "bg-primary/10 border-primary text-primary"
|
? "bg-primary/10 border-primary text-primary"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
@@ -326,19 +358,79 @@ export function FiltersModal({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={filters.placementFilters.statuses.includes(status)}
|
checked={filters.placementFilters.statuses.includes(status)}
|
||||||
onCheckedChange={() => toggleStatus(status)}
|
onCheckedChange={() => toggleStatus(status)}
|
||||||
|
className="h-3 w-3"
|
||||||
/>
|
/>
|
||||||
{status}
|
<span className="truncate max-w-[150px]">{status}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Статус поста</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
|
||||||
|
{POST_STATUS_OPTIONS.map((status) => (
|
||||||
|
<label
|
||||||
|
key={status}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-colors text-xs",
|
||||||
|
filters.placementFilters.postStatuses.includes(status)
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.placementFilters.postStatuses.includes(status)}
|
||||||
|
onCheckedChange={() => togglePostStatus(status)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[150px]">{status}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Креатив</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[
|
||||||
|
{ value: null, label: "Любой" },
|
||||||
|
{ value: true, label: "С креативом" },
|
||||||
|
{ value: false, label: "Без креатива" },
|
||||||
|
].map((opt) => (
|
||||||
|
<label
|
||||||
|
key={String(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||||
|
filters.placementFilters.hasCreative === opt.value
|
||||||
|
? "bg-primary/10 border-primary text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={filters.placementFilters.hasCreative === opt.value}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
placementFilters: { ...filters.placementFilters, hasCreative: opt.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Label>Мин. стоимость (₽)</Label>
|
<Label className="text-sm font-medium">Стоимость (₽)</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="0"
|
placeholder="от"
|
||||||
|
className="h-8 text-sm"
|
||||||
value={filters.placementFilters.costMin || ""}
|
value={filters.placementFilters.costMin || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -350,12 +442,11 @@ export function FiltersModal({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="text-muted-foreground">—</span>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Макс. стоимость (₽)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="100000"
|
placeholder="до"
|
||||||
|
className="h-8 text-sm"
|
||||||
value={filters.placementFilters.costMax || ""}
|
value={filters.placementFilters.costMax || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -370,11 +461,68 @@ export function FiltersModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Формат оплаты</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.placementFilters.costFormat || "any"}
|
||||||
|
onValueChange={(value: CostType | "any") =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
placementFilters: {
|
||||||
|
...filters.placementFilters,
|
||||||
|
costFormat: value === "any" ? null : value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Любой" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{COST_FORMAT_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Label>Дата размещения от</Label>
|
<Label className="text-sm font-medium">Тип закупа</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.placementFilters.placementType || "any"}
|
||||||
|
onValueChange={(value: PlacementType | "any") =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
placementFilters: {
|
||||||
|
...filters.placementFilters,
|
||||||
|
placementType: value === "any" ? null : value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Любой" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLACEMENT_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Дата размещения</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
className="h-8 text-sm"
|
||||||
value={filters.placementFilters.dateFrom || ""}
|
value={filters.placementFilters.dateFrom || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -386,11 +534,10 @@ export function FiltersModal({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="text-muted-foreground">—</span>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Дата размещения до</Label>
|
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
className="h-8 text-sm"
|
||||||
value={filters.placementFilters.dateTo || ""}
|
value={filters.placementFilters.dateTo || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -405,13 +552,13 @@ export function FiltersModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "sort" && (
|
<div className="space-y-3 pt-2 border-t">
|
||||||
<div className="space-y-4">
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
<div className="col-span-2 space-y-2">
|
Сортировка внутри каналов
|
||||||
<Label>Сортировать по</Label>
|
</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
<Select
|
<Select
|
||||||
value={filters.sort?.field || ""}
|
value={filters.sort?.field || ""}
|
||||||
onValueChange={(field) =>
|
onValueChange={(field) =>
|
||||||
@@ -419,82 +566,73 @@ export function FiltersModal({
|
|||||||
...filters,
|
...filters,
|
||||||
sort: filters.sort
|
sort: filters.sort
|
||||||
? { ...filters.sort, field }
|
? { ...filters.sort, field }
|
||||||
: { field, direction: "desc" },
|
: { field, direction: "asc" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-8 text-sm w-48">
|
||||||
<SelectValue placeholder="Выберите поле" />
|
<SelectValue placeholder="Без сортировки" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SORT_FIELDS.map((field) => (
|
{PLACEMENT_SORT_FIELDS.map((field) => (
|
||||||
<SelectItem key={field.key} value={field.key}>
|
<SelectItem key={field.key} value={field.key}>
|
||||||
{field.label}
|
{field.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
{filters.sort && (
|
||||||
<div className="space-y-2">
|
<div className="flex gap-1 p-0.5 border rounded-md">
|
||||||
<Label>Направление</Label>
|
|
||||||
<div className="flex gap-1 p-1 border rounded-md">
|
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
sort: filters.sort
|
sort: { ...filters.sort!, direction: "asc" },
|
||||||
? { ...filters.sort, direction: "asc" }
|
|
||||||
: { field: "title", direction: "asc" },
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
|
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
|
||||||
filters.sort?.direction === "asc"
|
filters.sort.direction === "asc"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title="По возрастанию"
|
title="По возрастанию"
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-3 w-3 mr-1" />
|
||||||
<span className="ml-1 text-xs">A-Z</span>
|
А-Я
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
sort: filters.sort
|
sort: { ...filters.sort!, direction: "desc" },
|
||||||
? { ...filters.sort, direction: "desc" }
|
|
||||||
: { field: "title", direction: "desc" },
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
|
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
|
||||||
filters.sort?.direction === "desc"
|
filters.sort.direction === "desc"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title="По убыванию"
|
title="По убыванию"
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-3 w-3 mr-1" />
|
||||||
<span className="ml-1 text-xs">Z-A</span>
|
Я-А
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filters.sort && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>Текущая сортировка:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{SORT_FIELDS.find((f) => f.key === filters.sort?.field)?.label ||
|
|
||||||
filters.sort?.field}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{filters.sort.direction === "asc" ? "↑" : "↓"} (
|
|
||||||
{filters.sort.direction === "asc" ? "A-Z" : "Z-A"})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{filters.sort && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ ...filters, sort: null })
|
||||||
|
}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
484
components/placement-cells.tsx
Normal file
484
components/placement-cells.tsx
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
import { Check, X, Copy, Send, ExternalLink, Play } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import type { PlacementWithStats, PlacementStatus, PlacementPostStatus, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
|
||||||
|
import { PLACEMENT_DEAL_STATUSES, PLACEMENT_POST_STATUSES, COST_FORMATS, PLACEMENT_TYPES, INVITE_TYPES, canEditPostStatus } from "@/lib/config/placement-columns";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cell Renderers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CellProps {
|
||||||
|
placement: PlacementWithStats;
|
||||||
|
idx: number;
|
||||||
|
channelNumber: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onSave?: (value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox column
|
||||||
|
export const CheckboxCell = ({ checked, onCheckedChange, onClick }: { checked: boolean; onCheckedChange: () => void; onClick?: (e: React.MouseEvent) => void }) => (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={onCheckedChange} onClick={onClick} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Number column
|
||||||
|
export const NumberCell = ({ channelNumber, idx }: { channelNumber: number; idx: number }) => (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{channelNumber}.{idx + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status deal column
|
||||||
|
export const StatusDealCell = ({
|
||||||
|
status,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
status: PlacementStatus;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: (value: PlacementStatus) => void;
|
||||||
|
}) => {
|
||||||
|
const getStatusColor = (s: PlacementStatus): string => {
|
||||||
|
switch (s) {
|
||||||
|
case "Оплачено":
|
||||||
|
return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
|
||||||
|
case "Согласование условий":
|
||||||
|
case "Ждём ответа":
|
||||||
|
return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
|
||||||
|
case "Отмена":
|
||||||
|
case "Не подходит цена":
|
||||||
|
case "Неактуально":
|
||||||
|
case "Не отвечает":
|
||||||
|
return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
|
||||||
|
case "Оплатить":
|
||||||
|
return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground border-border";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<Select value={status} onValueChange={(v) => onSave(v as PlacementStatus)}>
|
||||||
|
<SelectTrigger className="h-7 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLACEMENT_DEAL_STATUSES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-full rounded border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default",
|
||||||
|
getStatusColor(status)
|
||||||
|
)}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status post column
|
||||||
|
export const StatusPostCell = ({
|
||||||
|
status,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
status: PlacementPostStatus;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: (value: PlacementPostStatus) => void;
|
||||||
|
}) => {
|
||||||
|
const canEditStatus = canEdit && canEditPostStatus(status);
|
||||||
|
const isAuto = !canEditPostStatus(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isEditing && canEditStatus ? (
|
||||||
|
<Select value={status} onValueChange={(v) => onSave(v as PlacementPostStatus)}>
|
||||||
|
<SelectTrigger className="h-7 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLACEMENT_POST_STATUSES.filter(s => canEditPostStatus(s as PlacementPostStatus)).map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className={cn(
|
||||||
|
"h-7 w-full rounded border px-2 text-xs font-medium flex items-center",
|
||||||
|
isAuto ? "bg-muted text-muted-foreground" : "bg-background",
|
||||||
|
canEditStatus && canEdit ? "cursor-pointer hover:bg-muted/50" : "cursor-default"
|
||||||
|
)} onClick={canEditStatus && canEdit ? onEdit : undefined}>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creative column
|
||||||
|
export const CreativeCell = ({
|
||||||
|
creativeId,
|
||||||
|
creativeName,
|
||||||
|
canEdit,
|
||||||
|
onToggleCreative,
|
||||||
|
}: {
|
||||||
|
creativeId: string | null;
|
||||||
|
creativeName: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
onToggleCreative: () => void;
|
||||||
|
}) => (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{creativeId ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded p-1" onClick={onToggleCreative}>
|
||||||
|
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
<span className="text-xs">📷</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs truncate max-w-[140px]">{creativeName || "Без названия"}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 px-2 text-xs">
|
||||||
|
<Send className="h-3 w-3 mr-1" />
|
||||||
|
Отправить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||||||
|
onClick={onToggleCreative}
|
||||||
|
>
|
||||||
|
Выбрать креатив +
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invite link column
|
||||||
|
export const InviteLinkCell = ({ inviteLink, onCopy }: { inviteLink: string | null; onCopy: () => void }) => (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{inviteLink ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[200px]">{inviteLink}</code>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onCopy}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cost column
|
||||||
|
export const CostCell = ({
|
||||||
|
cost,
|
||||||
|
costType,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
cost: number | null;
|
||||||
|
costType: CostType | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: (value: { type: CostType; value: number } | null) => void;
|
||||||
|
}) => (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
autoFocus
|
||||||
|
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
||||||
|
defaultValue={cost ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (!raw) {
|
||||||
|
onSave(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number(raw);
|
||||||
|
if (Number.isNaN(value)) return;
|
||||||
|
onSave({ type: costType || "fixed", value });
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
{cost ? new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(cost) : "—"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Date column
|
||||||
|
export const DateCell = ({
|
||||||
|
date,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
date: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: (value: string | null) => void;
|
||||||
|
}) => {
|
||||||
|
const formatDate = (d: string | null): string => {
|
||||||
|
if (!d) return "—";
|
||||||
|
return new Date(d).toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDateInputValue = (iso: string | null): string => {
|
||||||
|
if (!iso) return "";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toIsoFromDateInput = (value: string): string | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(`${value}T00:00:00.000Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
autoFocus
|
||||||
|
className="h-7 min-w-[100px] bg-background px-2 text-xs"
|
||||||
|
defaultValue={toDateInputValue(date)}
|
||||||
|
onChange={(e) => onSave(toIsoFromDateInput(e.target.value))}
|
||||||
|
onBlur={(e) => e.currentTarget.blur()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
{formatDate(date)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text column (for format, comment)
|
||||||
|
export const TextCell = ({
|
||||||
|
value,
|
||||||
|
placeholder = "—",
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
placeholder?: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: (value: string | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<td className={cn("py-2 px-3", className)}>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-7 min-w-[120px] bg-background px-2 text-xs"
|
||||||
|
defaultValue={value ?? ""}
|
||||||
|
onChange={(e) => onSave(e.target.value || null)}
|
||||||
|
onBlur={(e) => e.currentTarget.blur()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-0 text-left text-xs hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<span className="truncate w-full">{value ?? placeholder}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Metrics column
|
||||||
|
export const MetricCell = ({ value, format = "number" }: { value: number | null; format?: "number" | "currency" | "percent" }) => {
|
||||||
|
const formatValue = () => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "currency":
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
case "percent":
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
case "number":
|
||||||
|
default:
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="py-2 px-3 text-muted-foreground">
|
||||||
|
{formatValue()}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select column (for cost_format, placement_type, invite_type)
|
||||||
|
export const SelectCell = ({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
displayMap,
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
options: string[];
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
displayMap?: Record<string, string>;
|
||||||
|
}) => {
|
||||||
|
const getDisplayValue = (v: string) => {
|
||||||
|
if (displayMap) return displayMap[v] || v;
|
||||||
|
|
||||||
|
switch (v) {
|
||||||
|
case "fixed":
|
||||||
|
return "Фикс";
|
||||||
|
case "cpm":
|
||||||
|
return "CPM";
|
||||||
|
case "self_promo":
|
||||||
|
return "Взаимный пиар";
|
||||||
|
case "standard":
|
||||||
|
return "Стандартный";
|
||||||
|
case "public":
|
||||||
|
return "Открытая";
|
||||||
|
case "approval":
|
||||||
|
return "С заявками";
|
||||||
|
default:
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<Select value={value ?? ""} onValueChange={onSave}>
|
||||||
|
<SelectTrigger className="h-7 w-full">
|
||||||
|
<SelectValue placeholder="—" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{getDisplayValue(option)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit}
|
||||||
|
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
{value ? getDisplayValue(value) : "—"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Link column
|
||||||
|
export const LinkCell = ({ url, external = false }: { url: string | null; external?: boolean }) => (
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{url ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target={external ? "_blank" : undefined}
|
||||||
|
rel={external ? "noopener noreferrer" : undefined}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{url.substring(0, 50)}
|
||||||
|
{url.length > 50 ? "..." : ""}
|
||||||
|
{external && <ExternalLink className="h-3 w-3" />}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty column (for future columns not yet implemented)
|
||||||
|
export const EmptyCell = ({ label }: { label?: string }) => (
|
||||||
|
<td className="py-2 px-3 text-muted-foreground text-xs italic">
|
||||||
|
{label || "Недоступно"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
@@ -12,20 +12,9 @@ import type {
|
|||||||
ViewsHistoryItem,
|
ViewsHistoryItem,
|
||||||
ViewsHistoryQueryParams,
|
ViewsHistoryQueryParams,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
UpdatePlacementInProjectRequest,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
type UpdatePlacementInProjectRequest = Partial<{
|
|
||||||
status: string;
|
|
||||||
comment: string | null;
|
|
||||||
creative_id: string | null;
|
|
||||||
placement_at: string | null;
|
|
||||||
payment_at: string | null;
|
|
||||||
cost: { type: "fixed"; value: number } | null;
|
|
||||||
cost_before_bargain: number | null;
|
|
||||||
placement_type: string | null;
|
|
||||||
format: string | null;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const placementsApi = {
|
export const placementsApi = {
|
||||||
/**
|
/**
|
||||||
* Get list of placements for workspace
|
* Get list of placements for workspace
|
||||||
@@ -40,15 +29,22 @@ export const placementsApi = {
|
|||||||
* Get placements for a specific project (grouped by channel UI)
|
* Get placements for a specific project (grouped by channel UI)
|
||||||
*/
|
*/
|
||||||
getByProject: (workspaceId: string, projectId: string) =>
|
getByProject: (workspaceId: string, projectId: string) =>
|
||||||
api.get<{ placements: Placement[] }>(`/workspaces/${workspaceId}/projects/${projectId}/placements`)
|
api.get<PaginatedResponse<Placement>>(`/workspaces/${workspaceId}/projects/${projectId}/placements`)
|
||||||
.then((response) => response.placements),
|
.then((response) => response.items),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get single placement
|
* Get single placement (deprecated - use getInProject for project-specific data)
|
||||||
*/
|
*/
|
||||||
get: (workspaceId: string, placementId: string) =>
|
get: (workspaceId: string, placementId: string) =>
|
||||||
api.get<Placement>(`/workspaces/${workspaceId}/placements/${placementId}`),
|
api.get<Placement>(`/workspaces/${workspaceId}/placements/${placementId}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single placement within project
|
||||||
|
* GET /workspaces/{workspace_id}/projects/{project_id}/placements/{placement_id}
|
||||||
|
*/
|
||||||
|
getInProject: (workspaceId: string, projectId: string, placementId: string) =>
|
||||||
|
api.get<PlacementWithStats>(`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new placement
|
* Create new placement
|
||||||
*/
|
*/
|
||||||
@@ -124,10 +120,13 @@ export const placementsApi = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete (archive) placement
|
* Delete placement within project
|
||||||
|
* DELETE /workspaces/{workspace_id}/projects/{project_id}/placements/{placement_id}
|
||||||
*/
|
*/
|
||||||
delete: (workspaceId: string, placementId: string) =>
|
deleteInProject: (workspaceId: string, projectId: string, placementId: string) =>
|
||||||
api.delete<void>(`/workspaces/${workspaceId}/placements/${placementId}`),
|
api.delete<void>(
|
||||||
|
`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get views history for placement
|
* Get views history for placement
|
||||||
|
|||||||
148
lib/config/placement-columns.ts
Normal file
148
lib/config/placement-columns.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { PlacementStatus, PlacementPostStatus, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
|
||||||
|
|
||||||
|
export interface ColumnConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
editable: boolean;
|
||||||
|
width?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLUMN_GROUPS = [
|
||||||
|
{ id: "basic", label: "Основная информация" },
|
||||||
|
{ id: "content", label: "Контент" },
|
||||||
|
{ id: "finance", label: "Финансы" },
|
||||||
|
{ id: "dates", label: "Даты и время" },
|
||||||
|
{ id: "metrics", label: "Метрики" },
|
||||||
|
{ id: "calculated", label: "Расчетные показатели" },
|
||||||
|
{ id: "technical", label: "Технические детали" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALL_COLUMNS: ColumnConfig[] = [
|
||||||
|
// Basic
|
||||||
|
{ id: "checkbox", label: "", group: "basic", editable: false, required: true },
|
||||||
|
{ id: "number", label: "№", group: "basic", editable: false, required: true },
|
||||||
|
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true },
|
||||||
|
{ id: "status_post", label: "Статус поста", group: "basic", editable: true },
|
||||||
|
|
||||||
|
// Content
|
||||||
|
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
||||||
|
{ id: "invite_link", label: "Пригласительная ссылка", group: "content", editable: false },
|
||||||
|
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
||||||
|
|
||||||
|
// Finance
|
||||||
|
{ id: "cost", label: "Стоимость", group: "finance", editable: true },
|
||||||
|
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true },
|
||||||
|
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true },
|
||||||
|
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true },
|
||||||
|
{ id: "discount", label: "% скидки", group: "finance", editable: false },
|
||||||
|
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
{ id: "planned_date", label: "Плановая дата", group: "dates", editable: true },
|
||||||
|
{ id: "actual_date", label: "Фактическая дата", group: "dates", editable: false },
|
||||||
|
{ id: "format", label: "Плановый формат", group: "dates", editable: true },
|
||||||
|
{ id: "time_top", label: "Время в топе", group: "dates", editable: false },
|
||||||
|
{ id: "time_in_feed", label: "Время в ленте", group: "dates", editable: false },
|
||||||
|
{ id: "link_created", label: "Дата ссылки", group: "dates", editable: false },
|
||||||
|
{ id: "post_deleted", label: "Дата удаления", group: "dates", editable: false },
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
{ id: "subscriptions", label: "Всего подписок", group: "metrics", editable: false },
|
||||||
|
{ id: "views", label: "Просмотры", group: "metrics", editable: false },
|
||||||
|
|
||||||
|
// Calculated
|
||||||
|
{ id: "cpf", label: "CPF", group: "calculated", editable: false },
|
||||||
|
{ id: "cpm", label: "CPM", group: "calculated", editable: false },
|
||||||
|
{ id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false },
|
||||||
|
{ id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false },
|
||||||
|
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false },
|
||||||
|
{ id: "total_unsubs", label: "Всего отписок", group: "calculated", editable: false },
|
||||||
|
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false },
|
||||||
|
{ id: "total_active", label: "Итого подписок", group: "calculated", editable: false },
|
||||||
|
|
||||||
|
// Technical
|
||||||
|
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true },
|
||||||
|
{ id: "comment", label: "Комментарий", group: "technical", editable: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Default visible columns
|
||||||
|
export const DEFAULT_VISIBLE_COLUMNS = new Set([
|
||||||
|
"checkbox",
|
||||||
|
"number",
|
||||||
|
"status_deal",
|
||||||
|
"status_post",
|
||||||
|
"creative",
|
||||||
|
"invite_link",
|
||||||
|
"cost",
|
||||||
|
"cost_before",
|
||||||
|
"cost_format",
|
||||||
|
"planned_date",
|
||||||
|
"format",
|
||||||
|
"subscriptions",
|
||||||
|
"views",
|
||||||
|
"cpf",
|
||||||
|
"cpm",
|
||||||
|
"comment",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Status lists
|
||||||
|
export const PLACEMENT_DEAL_STATUSES: PlacementStatus[] = [
|
||||||
|
"Без статуса",
|
||||||
|
"Написать",
|
||||||
|
"Ждём ответа",
|
||||||
|
"Согласование условий",
|
||||||
|
"Оплатить",
|
||||||
|
"Оплачено",
|
||||||
|
"Отмена",
|
||||||
|
"Не подходит цена",
|
||||||
|
"Неактуально",
|
||||||
|
"Не отвечает",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PLACEMENT_POST_STATUSES: PlacementPostStatus[] = [
|
||||||
|
"Без статуса",
|
||||||
|
"Отправить пост",
|
||||||
|
"Согласование поста",
|
||||||
|
"Ожидание отложки",
|
||||||
|
"Запланирован",
|
||||||
|
"Пост вышел",
|
||||||
|
"Размещение отработало - пост удалён",
|
||||||
|
"Размещение отработало - пост не удалён",
|
||||||
|
"Проверить - пост удалён раньше срока",
|
||||||
|
"Проверить - пост не вышел",
|
||||||
|
"Размещение отработало",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COST_FORMATS: CostType[] = ["fixed", "cpm"];
|
||||||
|
export const PLACEMENT_TYPES: PlacementType[] = ["self_promo", "standard"];
|
||||||
|
export const INVITE_TYPES: InviteLinkType[] = ["public", "approval"];
|
||||||
|
|
||||||
|
// Manual post statuses (user can select before post is published)
|
||||||
|
export const MANUAL_POST_STATUSES: Set<PlacementPostStatus> = new Set([
|
||||||
|
"Без статуса",
|
||||||
|
"Отправить пост",
|
||||||
|
"Согласование поста",
|
||||||
|
"Ожидание отложки",
|
||||||
|
"Запланирован",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Automatic post statuses (set by backend)
|
||||||
|
export const AUTOMATIC_POST_STATUSES: Set<PlacementPostStatus> = new Set([
|
||||||
|
"Пост вышел",
|
||||||
|
"Размещение отработало - пост удалён",
|
||||||
|
"Размещение отработало - пост не удалён",
|
||||||
|
"Проверить - пост удалён раньше срока",
|
||||||
|
"Проверить - пост не вышел",
|
||||||
|
"Размещение отработало",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
export const canEditPostStatus = (currentStatus: PlacementPostStatus): boolean => {
|
||||||
|
return MANUAL_POST_STATUSES.has(currentStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAutomaticPostStatus = (status: PlacementPostStatus): boolean => {
|
||||||
|
return AUTOMATIC_POST_STATUSES.has(status);
|
||||||
|
};
|
||||||
@@ -312,15 +312,19 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
status: "Оплачено",
|
status: "Оплачено",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
|
creative_name: "Креатив 1",
|
||||||
comment: "crypto_jan",
|
comment: "crypto_jan",
|
||||||
invite_link: "https://t.me/+abc123",
|
invite_link: "https://t.me/+abc123",
|
||||||
|
invite_link_created_at: "2024-12-19T10:00:00Z",
|
||||||
invite_link_type: "public",
|
invite_link_type: "public",
|
||||||
channel: demoPlacementChannels[0],
|
channel: demoPlacementChannels[0],
|
||||||
|
project: demoProjects[0],
|
||||||
|
short_id: "abc123",
|
||||||
details: {
|
details: {
|
||||||
placement_at: "2024-12-20T12:00:00Z",
|
placement_at: "2024-12-20T12:00:00Z",
|
||||||
payment_at: "2024-12-19T10:00:00Z",
|
payment_at: "2024-12-19T10:00:00Z",
|
||||||
cost: { type: "fixed", value: 5500 },
|
cost: { type: "fixed", value: 5500 },
|
||||||
cost_before_bargain: 6000,
|
cost_before_bargain: { type: "fixed", value: 6000 },
|
||||||
placement_type: "self_promo",
|
placement_type: "self_promo",
|
||||||
format: "Стандарт",
|
format: "Стандарт",
|
||||||
comment: null,
|
comment: null,
|
||||||
@@ -329,6 +333,8 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
placement_post: {
|
placement_post: {
|
||||||
subscriptions_count: 156,
|
subscriptions_count: 156,
|
||||||
views_count: 8500,
|
views_count: 8500,
|
||||||
|
status: "Пост вышел",
|
||||||
|
time_on_top: null,
|
||||||
created_at: "2024-12-20T12:00:00Z",
|
created_at: "2024-12-20T12:00:00Z",
|
||||||
post: {
|
post: {
|
||||||
id: "post-0001-0000-0000-000000000001",
|
id: "post-0001-0000-0000-000000000001",
|
||||||
@@ -336,6 +342,7 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
text: "Привет! Отличные новости...",
|
text: "Привет! Отличные новости...",
|
||||||
url: "https://t.me/tech_future/12345",
|
url: "https://t.me/tech_future/12345",
|
||||||
deleted_from_channel_at: null,
|
deleted_from_channel_at: null,
|
||||||
|
published_at: null,
|
||||||
created_at: "2024-12-20T12:00:00Z",
|
created_at: "2024-12-20T12:00:00Z",
|
||||||
updated_at: "2024-12-20T12:00:00Z",
|
updated_at: "2024-12-20T12:00:00Z",
|
||||||
},
|
},
|
||||||
@@ -347,15 +354,19 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
status: "Согласование условий",
|
status: "Согласование условий",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
|
creative_name: "Креатив 1",
|
||||||
comment: "crypto_feb",
|
comment: "crypto_feb",
|
||||||
invite_link: "https://t.me/+def456",
|
invite_link: "https://t.me/+def456",
|
||||||
|
invite_link_created_at: null,
|
||||||
invite_link_type: "approval",
|
invite_link_type: "approval",
|
||||||
channel: demoPlacementChannels[1],
|
channel: demoPlacementChannels[1],
|
||||||
|
project: demoProjects[0],
|
||||||
|
short_id: "def456",
|
||||||
details: {
|
details: {
|
||||||
placement_at: "2024-12-25T14:00:00Z",
|
placement_at: "2024-12-25T14:00:00Z",
|
||||||
payment_at: null,
|
payment_at: null,
|
||||||
cost: { type: "fixed", value: 3200 },
|
cost: { type: "fixed", value: 3200 },
|
||||||
cost_before_bargain: 3500,
|
cost_before_bargain: { type: "fixed", value: 3500 },
|
||||||
placement_type: "self_promo",
|
placement_type: "self_promo",
|
||||||
format: "Стандарт",
|
format: "Стандарт",
|
||||||
comment: null,
|
comment: null,
|
||||||
@@ -369,15 +380,19 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
status: "Оплачено",
|
status: "Оплачено",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
|
creative_name: "Креатив 1",
|
||||||
comment: null,
|
comment: null,
|
||||||
invite_link: "https://t.me/+ghi789",
|
invite_link: "https://t.me/+ghi789",
|
||||||
|
invite_link_created_at: "2024-12-14T16:00:00Z",
|
||||||
invite_link_type: "public",
|
invite_link_type: "public",
|
||||||
channel: demoPlacementChannels[2],
|
channel: demoPlacementChannels[2],
|
||||||
|
project: demoProjects[0],
|
||||||
|
short_id: "ghi789",
|
||||||
details: {
|
details: {
|
||||||
placement_at: "2024-12-15T10:00:00Z",
|
placement_at: "2024-12-15T10:00:00Z",
|
||||||
payment_at: "2024-12-14T16:00:00Z",
|
payment_at: "2024-12-14T16:00:00Z",
|
||||||
cost: { type: "fixed", value: 4800 },
|
cost: { type: "fixed", value: 4800 },
|
||||||
cost_before_bargain: 5000,
|
cost_before_bargain: { type: "fixed", value: 5000 },
|
||||||
placement_type: "self_promo",
|
placement_type: "self_promo",
|
||||||
format: "Стандарт",
|
format: "Стандарт",
|
||||||
comment: null,
|
comment: null,
|
||||||
@@ -386,6 +401,8 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
placement_post: {
|
placement_post: {
|
||||||
subscriptions_count: 234,
|
subscriptions_count: 234,
|
||||||
views_count: 12000,
|
views_count: 12000,
|
||||||
|
status: "Пост вышел",
|
||||||
|
time_on_top: null,
|
||||||
created_at: "2024-12-15T10:00:00Z",
|
created_at: "2024-12-15T10:00:00Z",
|
||||||
post: {
|
post: {
|
||||||
id: "post-0002-0000-0000-000000000002",
|
id: "post-0002-0000-0000-000000000002",
|
||||||
@@ -393,6 +410,7 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
text: "Криптовалюты продолжают расти...",
|
text: "Криптовалюты продолжают расти...",
|
||||||
url: "https://t.me/programmers/67890",
|
url: "https://t.me/programmers/67890",
|
||||||
deleted_from_channel_at: null,
|
deleted_from_channel_at: null,
|
||||||
|
published_at: null,
|
||||||
created_at: "2024-12-15T10:00:00Z",
|
created_at: "2024-12-15T10:00:00Z",
|
||||||
updated_at: "2024-12-15T10:00:00Z",
|
updated_at: "2024-12-15T10:00:00Z",
|
||||||
},
|
},
|
||||||
@@ -404,66 +422,172 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
|
|||||||
project_id: "proj-0001-0000-0000-000000000001",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
status: "Ждём ответа",
|
status: "Ждём ответа",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
|
creative_name: "Креатив 1",
|
||||||
comment: null,
|
comment: null,
|
||||||
invite_link: "https://t.me/+jkl012",
|
invite_link: null,
|
||||||
invite_link_type: "public",
|
invite_link_created_at: null,
|
||||||
|
invite_link_type: "approval",
|
||||||
channel: demoPlacementChannels[3],
|
channel: demoPlacementChannels[3],
|
||||||
|
project: demoProjects[0],
|
||||||
|
short_id: "jkl012",
|
||||||
details: {
|
details: {
|
||||||
placement_at: "2024-12-28T09:00:00Z",
|
placement_at: "2024-12-30T11:00:00Z",
|
||||||
payment_at: null,
|
payment_at: null,
|
||||||
cost: { type: "fixed", value: 2500 },
|
cost: { type: "fixed", value: 6100 },
|
||||||
cost_before_bargain: 2800,
|
cost_before_bargain: { type: "fixed", value: 6500 },
|
||||||
placement_type: "self_promo",
|
placement_type: "self_promo",
|
||||||
format: "Стандарт",
|
format: "Стандарт",
|
||||||
comment: null,
|
comment: null,
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
},
|
},
|
||||||
placement_post: null,
|
placement_post: null,
|
||||||
created_at: "2024-12-24T14:00:00Z",
|
created_at: "2024-12-27T08:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plac-0005-0000-0000-000000000005",
|
id: "plac-0005-0000-0000-000000000005",
|
||||||
project_id: "proj-0002-0000-0000-000000000002",
|
project_id: "proj-0001-0000-0000-000000000001",
|
||||||
status: "Написать",
|
status: "Неактуально",
|
||||||
creative_id: "crea-0002-0000-0000-000000000002",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
comment: "Новый оффер",
|
creative_name: "Креатив 1",
|
||||||
|
comment: "Разобрались без рекламы",
|
||||||
invite_link: null,
|
invite_link: null,
|
||||||
invite_link_type: "approval",
|
invite_link_created_at: null,
|
||||||
channel: demoPlacementChannels[0],
|
invite_link_type: "public",
|
||||||
|
channel: demoPlacementChannels[4],
|
||||||
|
project: demoProjects[0],
|
||||||
|
short_id: "mno345",
|
||||||
details: {
|
details: {
|
||||||
placement_at: null,
|
placement_at: null,
|
||||||
payment_at: null,
|
payment_at: null,
|
||||||
cost: { type: "fixed", value: 6000 },
|
cost: { type: "fixed", value: 7300 },
|
||||||
cost_before_bargain: null,
|
cost_before_bargain: { type: "fixed", value: 7800 },
|
||||||
placement_type: "self_promo",
|
placement_type: "self_promo",
|
||||||
format: "Спонсорский",
|
format: "Стандарт",
|
||||||
comment: null,
|
comment: null,
|
||||||
creative_id: "crea-0002-0000-0000-000000000002",
|
creative_id: "crea-0001-0000-0000-000000000001",
|
||||||
},
|
},
|
||||||
placement_post: null,
|
placement_post: null,
|
||||||
created_at: "2024-12-26T08:00:00Z",
|
created_at: "2024-12-25T14:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plac-0006-0000-0000-000000000006",
|
id: "plac-0006-0000-0000-000000000006",
|
||||||
project_id: "proj-0002-0000-0000-000000000002",
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
status: "Отмена",
|
status: "Согласование условий",
|
||||||
creative_id: "crea-0001-0000-0000-000000000001",
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
comment: "Неактуально",
|
creative_name: "Креатив 2",
|
||||||
|
comment: null,
|
||||||
invite_link: null,
|
invite_link: null,
|
||||||
invite_link_type: "public",
|
invite_link_created_at: null,
|
||||||
channel: demoPlacementChannels[1],
|
invite_link_type: "approval",
|
||||||
|
channel: demoPlacementChannels[5],
|
||||||
|
project: demoProjects[1],
|
||||||
|
short_id: "pqr678",
|
||||||
details: {
|
details: {
|
||||||
placement_at: null,
|
placement_at: "2025-01-05T15:00:00Z",
|
||||||
payment_at: null,
|
payment_at: null,
|
||||||
cost: null,
|
cost: { type: "fixed", value: 4100 },
|
||||||
cost_before_bargain: null,
|
cost_before_bargain: { type: "fixed", value: 4500 },
|
||||||
placement_type: null,
|
placement_type: "self_promo",
|
||||||
format: null,
|
format: "Стандарт",
|
||||||
comment: "Клиент отказался",
|
comment: null,
|
||||||
creative_id: null,
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
},
|
},
|
||||||
placement_post: null,
|
placement_post: null,
|
||||||
created_at: "2024-12-10T10:00:00Z",
|
created_at: "2025-01-02T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0007-0000-0000-000000000007",
|
||||||
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
|
status: "Оплачено",
|
||||||
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
|
creative_name: "Креатив 2",
|
||||||
|
comment: "finance_mar",
|
||||||
|
invite_link: "https://t.me/+stu901",
|
||||||
|
invite_link_created_at: "2025-01-06T12:00:00Z",
|
||||||
|
invite_link_type: "public",
|
||||||
|
channel: demoPlacementChannels[6],
|
||||||
|
project: demoProjects[1],
|
||||||
|
short_id: "stu901",
|
||||||
|
details: {
|
||||||
|
placement_at: "2025-01-07T09:00:00Z",
|
||||||
|
payment_at: "2025-01-06T12:00:00Z",
|
||||||
|
cost: { type: "fixed", value: 5700 },
|
||||||
|
cost_before_bargain: { type: "fixed", value: 6100 },
|
||||||
|
placement_type: "self_promo",
|
||||||
|
format: "Стандарт",
|
||||||
|
comment: null,
|
||||||
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
|
},
|
||||||
|
placement_post: {
|
||||||
|
subscriptions_count: 298,
|
||||||
|
views_count: 15500,
|
||||||
|
status: "Пост вышел",
|
||||||
|
time_on_top: null,
|
||||||
|
created_at: "2025-01-07T09:00:00Z",
|
||||||
|
post: {
|
||||||
|
id: "post-0003-0000-0000-000000000003",
|
||||||
|
message_id: 11111,
|
||||||
|
text: "Важное обновление в нашем продукте...",
|
||||||
|
url: "https://t.me/product_updates/11111",
|
||||||
|
deleted_from_channel_at: null,
|
||||||
|
published_at: null,
|
||||||
|
created_at: "2025-01-07T09:00:00Z",
|
||||||
|
updated_at: "2025-01-07T09:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created_at: "2025-01-04T11:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0008-0000-0000-000000000008",
|
||||||
|
project_id: "proj-0002-0000-0000-000000000002",
|
||||||
|
status: "Ждём ответа",
|
||||||
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
|
creative_name: "Креатив 2",
|
||||||
|
comment: null,
|
||||||
|
invite_link: null,
|
||||||
|
invite_link_created_at: null,
|
||||||
|
invite_link_type: "approval",
|
||||||
|
channel: demoPlacementChannels[7],
|
||||||
|
project: demoProjects[1],
|
||||||
|
short_id: "vwx234",
|
||||||
|
details: {
|
||||||
|
placement_at: "2025-01-12T16:00:00Z",
|
||||||
|
payment_at: null,
|
||||||
|
cost: { type: "fixed", value: 6900 },
|
||||||
|
cost_before_bargain: { type: "fixed", value: 7400 },
|
||||||
|
placement_type: "self_promo",
|
||||||
|
format: "Стандарт",
|
||||||
|
comment: null,
|
||||||
|
creative_id: "crea-0002-0000-0000-000000000002",
|
||||||
|
},
|
||||||
|
placement_post: null,
|
||||||
|
created_at: "2025-01-09T13:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plac-0009-0000-0000-000000000009",
|
||||||
|
project_id: "proj-0003-0000-0000-000000000003",
|
||||||
|
status: "Согласование условий",
|
||||||
|
creative_id: "crea-0003-0000-0000-000000000003",
|
||||||
|
creative_name: "Креатив 3",
|
||||||
|
comment: null,
|
||||||
|
invite_link: null,
|
||||||
|
invite_link_created_at: null,
|
||||||
|
invite_link_type: "approval",
|
||||||
|
channel: demoPlacementChannels[8],
|
||||||
|
project: demoProjects[2],
|
||||||
|
short_id: "yza567",
|
||||||
|
details: {
|
||||||
|
placement_at: "2025-01-18T11:00:00Z",
|
||||||
|
payment_at: null,
|
||||||
|
cost: { type: "fixed", value: 3500 },
|
||||||
|
cost_before_bargain: { type: "fixed", value: 3800 },
|
||||||
|
placement_type: "self_promo",
|
||||||
|
format: "Стандарт",
|
||||||
|
comment: null,
|
||||||
|
creative_id: "crea-0003-0000-0000-000000000003",
|
||||||
|
},
|
||||||
|
placement_post: null,
|
||||||
|
created_at: "2025-01-15T09:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,22 @@ export type PlacementStatus =
|
|||||||
| "Неактуально"
|
| "Неактуально"
|
||||||
| "Не отвечает";
|
| "Не отвечает";
|
||||||
|
|
||||||
|
export type PlacementPostStatus =
|
||||||
|
| "Без статуса"
|
||||||
|
| "Отправить пост"
|
||||||
|
| "Согласование поста"
|
||||||
|
| "Ожидание отложки"
|
||||||
|
| "Запланирован"
|
||||||
|
| "Пост вышел"
|
||||||
|
| "Размещение отработало - пост удалён"
|
||||||
|
| "Размещение отработало - пост не удалён"
|
||||||
|
| "Проверить - пост удалён раньше срока"
|
||||||
|
| "Проверить - пост не вышел"
|
||||||
|
| "Размещение отработало";
|
||||||
|
|
||||||
export type InviteLinkType = "public" | "approval";
|
export type InviteLinkType = "public" | "approval";
|
||||||
|
export type PlacementType = "self_promo" | "standard";
|
||||||
|
export type CostType = "fixed" | "cpm";
|
||||||
export type PostViewsAvailability =
|
export type PostViewsAvailability =
|
||||||
| "unknown"
|
| "unknown"
|
||||||
| "available"
|
| "available"
|
||||||
@@ -261,7 +276,7 @@ export interface PlacementChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlacementCostInfo {
|
export interface PlacementCostInfo {
|
||||||
type: "fixed" | "cpm";
|
type: CostType;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +284,8 @@ export interface PlacementDetails {
|
|||||||
placement_at: string | null; // ISO 8601
|
placement_at: string | null; // ISO 8601
|
||||||
payment_at: string | null; // ISO 8601
|
payment_at: string | null; // ISO 8601
|
||||||
cost: PlacementCostInfo | null;
|
cost: PlacementCostInfo | null;
|
||||||
cost_before_bargain: number | null;
|
cost_before_bargain: PlacementCostInfo | null;
|
||||||
placement_type: string | null;
|
placement_type: PlacementType | null;
|
||||||
format: string | null;
|
format: string | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
creative_id: string | null;
|
creative_id: string | null;
|
||||||
@@ -286,27 +301,44 @@ export interface PlacementPost {
|
|||||||
text: string;
|
text: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
deleted_from_channel_at: string | null;
|
deleted_from_channel_at: string | null;
|
||||||
|
published_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlacementPostWithStatus extends PlacementPost {
|
||||||
|
status: PlacementPostStatus;
|
||||||
|
time_on_top: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Placement {
|
export interface Placement {
|
||||||
id: string;
|
id: string;
|
||||||
status: PlacementStatus;
|
status: PlacementStatus;
|
||||||
creative_id: string | null;
|
creative_id: string | null;
|
||||||
|
creative_name: string | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
invite_link: string | null;
|
invite_link: string | null;
|
||||||
|
invite_link_created_at: string | null;
|
||||||
invite_link_type: InviteLinkType;
|
invite_link_type: InviteLinkType;
|
||||||
channel: PlacementChannel;
|
channel: PlacementChannel;
|
||||||
|
project: Project | null;
|
||||||
|
short_id: string;
|
||||||
details: PlacementDetails | null;
|
details: PlacementDetails | null;
|
||||||
placement_post: PlacementPost | null;
|
placement_post: PlacementPostWithStatus | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlacementWithStats extends Placement {
|
export interface PlacementWithStats extends Placement {
|
||||||
cpf: number | null;
|
cpf: number | null;
|
||||||
cpm: number | null;
|
cpm: number | null;
|
||||||
|
time_in_feed?: number | null;
|
||||||
|
conversion_24h?: number | null;
|
||||||
|
conversion_48h?: number | null;
|
||||||
|
conversion_total?: number | null;
|
||||||
|
total_unsubs?: number | null;
|
||||||
|
unsub_percent?: number | null;
|
||||||
|
total_active?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlacementsGroupedByChannel {
|
export interface PlacementsGroupedByChannel {
|
||||||
@@ -345,6 +377,19 @@ export interface PlacementUpdateRequest {
|
|||||||
ad_post_url?: string;
|
ad_post_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatePlacementInProjectRequest {
|
||||||
|
status?: string;
|
||||||
|
comment?: string | null;
|
||||||
|
creative_id?: string | null;
|
||||||
|
creative_name?: string | null;
|
||||||
|
placement_at?: string | null;
|
||||||
|
payment_at?: string | null;
|
||||||
|
cost?: { type: CostType; value: number } | null;
|
||||||
|
cost_before_bargain?: { type: CostType; value: number } | null;
|
||||||
|
placement_type?: PlacementType | null;
|
||||||
|
format?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlacementsQueryParams extends PaginationParams {
|
export interface PlacementsQueryParams extends PaginationParams {
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
placement_channel_id?: string;
|
placement_channel_id?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user