"use client"; // ============================================================================ // Placements Detail Page - All placements grouped by channel // ============================================================================ import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; import { Loader2, Plus, ArrowLeft, ChevronDown, ChevronRight, Search, Filter, Eye, Users, ExternalLink, CircleDollarSign, TrendingUp, RefreshCw, Loader2 as LoaderIcon, BarChart3, ArrowUpDown, X, MoreVertical, Trash2, Copy, Pencil, Send, Check, } from "lucide-react"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { demoAwarePlacementsApi } from "@/lib/demo"; import { placementsApi, creativesApi } from "@/lib/api"; import { FiltersModal, PlacementFilters } from "@/components/filters-modal"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { ColumnSelectorDialog } from "@/components/column-selector-dialog"; import { CreativeSelectDialog } from "@/components/creative-select-dialog"; import { PlacementStatusSelector } from "@/components/placement-status-selector"; import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector"; import { PlacementWizard } from "@/components/placement-wizard"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import type { Project, Placement, PlacementWithStats, PlacementsGroupedByChannel, PlacementStatus, PlacementPostStatus, PlacementType, CostType, UpdatePlacementInProjectRequest, } from "@/lib/types/api"; import { ALL_COLUMNS, DEFAULT_VISIBLE_COLUMNS, PLACEMENT_DEAL_STATUSES, PLACEMENT_POST_STATUSES, canEditPostStatus, getPlacementStatusColor, } from "@/lib/config/placement-columns"; type PlacementInlineDraft = { status?: PlacementStatus; post_status?: PlacementPostStatus | null; 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; cpm?: number | null; }; type EditableField = "status" | "post_status" | "placement_at" | "payment_at" | "cost" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name"; const FIXED_COST_TYPE: CostType = "fixed"; interface EditingCell { placementId: string; field: EditableField; } // ============================================================================ // Helpers // ============================================================================ const formatNumber = (value: number | null | undefined): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU").format(value); }; const formatCurrency = (value: number | null | undefined): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value); }; const formatCompact = (value: number | null | undefined): string => { if (value === null || value === undefined) return "—"; if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; return value.toString(); }; const formatDate = (dateStr: string | null | undefined): string => { if (!dateStr) return "—"; const date = new Date(dateStr); return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "numeric", }); }; const toDateInputValue = (iso: string | null | undefined): 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(); }; const getStatusColor = (status: PlacementStatus): string => { switch (status) { 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"; } }; // ============================================================================ // Components // ============================================================================ interface ChannelGroupProps { channel: Placement["channel"]; placements: PlacementWithStats[]; channelNumber: number; canEdit: boolean; getPlacementView: (placement: PlacementWithStats) => PlacementWithStats; handleDraftChange: (placementId: string, patch: PlacementInlineDraft) => void; editingCell: EditingCell | null; setEditingCell: (cell: EditingCell | null) => void; expanded: boolean; selectedPlacementIds: Set; onToggle: (channelId: string, placementIds: string[]) => void; onTogglePlacement: (placementId: string) => void; visibleColumns: Set; onOpenCreativeSelect?: (placement: PlacementWithStats) => void; onClearCreative?: (placementId: string) => void; onOpenCreativeSend?: (placement: PlacementWithStats) => void; onAddPlacement?: (channel: Placement["channel"]) => void; } // ============================================================================ // Cell Renderer Component // ============================================================================ interface CellRendererProps { column: (typeof ALL_COLUMNS)[number]; placement: PlacementWithStats; idx: number; channelNumber: number; canEdit: boolean; editingCell: EditingCell | null; setEditingCell: (cell: EditingCell | null) => void; handleDraftChange: (placementId: string, patch: PlacementInlineDraft) => void; effectiveCost: number; cpf: number | null; cpm: number | null; selectedPlacementIds: Set; onTogglePlacement: (placementId: string) => void; onOpenCreativeSelect?: (placement: PlacementWithStats) => void; onClearCreative?: (placementId: string) => void; onOpenCreativeSend?: (placement: PlacementWithStats) => void; } function CellRenderer({ column, placement, idx, channelNumber, canEdit, editingCell, setEditingCell, handleDraftChange, effectiveCost, cpf, cpm, selectedPlacementIds, onTogglePlacement, onOpenCreativeSelect, onClearCreative, onOpenCreativeSend, }: CellRendererProps) { const isEditing = editingCell?.placementId === placement.id; const formatCurrency = (value: number): string => { return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value); }; const formatDate = (dateStr: string | null | undefined): string => { if (!dateStr) return "—"; const date = new Date(dateStr); return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "numeric", }); }; const formatNumber = (value: number | null | undefined): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU").format(value); }; 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(); }; const toDateInputValue = (iso: string | null | undefined): 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 formatTime = (seconds: number | null | undefined): string => { if (!seconds) return "—"; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours > 24) { const days = Math.floor(hours / 24); return `${days}д ${hours % 24}ч`; } return `${hours}ч ${minutes}м`; }; // Render specific columns switch (column.id) { case "checkbox": return ( onTogglePlacement(placement.id)} onClick={(e) => e.stopPropagation()} /> ); case "number": return ( {channelNumber}.{idx + 1} ); case "status_deal": return ( canEdit && setEditingCell({ placementId: placement.id, field: "status" })} onChange={(s) => handleDraftChange(placement.id, { status: s })} onCloseEdit={() => setEditingCell(null)} /> ); case "status_post": const postStatus = placement.placement_post?.status; return ( canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })} onChange={(s) => handleDraftChange(placement.id, { post_status: s })} onCloseEdit={() => setEditingCell(null)} /> ); case "creative": const viewCreativeId = placement.creative_id; const hasCreative = !!viewCreativeId; return ( {hasCreative ? (
{placement.creative_name || "Без названия"}
) : ( )} ); case "invite_link": return ( {placement.invite_link ? ( {placement.invite_link.slice(8, 20)}… ) : ( )} ); case "post_link": return ( {placement.placement_post?.post?.url ? ( Пост ) : ( )} ); case "cost": const costType = placement.details?.cost?.type || "fixed"; const costValue = placement.details?.cost?.value || 0; const viewsCount = placement.placement_post?.views_count || 0; const subsCount = placement.placement_post?.subscriptions_count || 0; // Если тип CPM - показываем рассчитанную стоимость = CPM * views / 1000 // Если тип Fixed - показываем фиксированную стоимость const calculatedCost = costType === "cpm" ? (viewsCount > 0 ? (costValue * viewsCount) / 1000 : null) : costValue; const canEditCost = canEdit && costType === "fixed"; return ( {isEditing && editingCell?.field === "cost" ? ( { const raw = e.target.value; if (!raw) { handleDraftChange(placement.id, { cost: null }); return; } const value = Number(raw); if (Number.isNaN(value)) return; handleDraftChange(placement.id, { cost: { type: "fixed", value }, }); }} onBlur={() => setEditingCell(null)} /> ) : ( )} ); case "cpm": // Если тип CPM - показываем CPM значение // Если тип Fixed - рассчитываем CPM = cost / views * 1000 const cpmType = placement.details?.cost?.type || "fixed"; const cpmValue = placement.details?.cost?.value || 0; const cpmViewsCount = placement.placement_post?.views_count || 0; const calculatedCpm = cpmType === "cpm" ? cpmValue : (cpmViewsCount > 0 ? (cpmValue / cpmViewsCount) * 1000 : null); const canEditCpm = canEdit && cpmType === "cpm"; return ( {isEditing && editingCell?.field === "cpm" ? ( { const raw = e.target.value; if (!raw) return; const value = Number(raw); if (Number.isNaN(value)) return; handleDraftChange(placement.id, { cost: { type: "cpm", value }, }); }} onBlur={() => setEditingCell(null)} /> ) : ( )} ); case "cost_before": const costBefore = placement.details?.cost_before_bargain?.value; return ( {costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"} ); case "cost_format": const costFormat = placement.details?.cost?.type || "fixed" as CostType; return ( {isEditing && editingCell?.field === "cost_format" ? ( ) : ( )} ); case "placement_type": const placementType = placement.details?.placement_type; return ( {placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"} ); case "discount": const costBeforeBargain = placement.details?.cost_before_bargain?.value; const cost = placement.details?.cost?.value; const discount = costBeforeBargain && cost && costBeforeBargain > 0 ? ((costBeforeBargain - cost) / costBeforeBargain) * 100 : null; return ( {discount ? `${discount.toFixed(1)}%` : "—"} ); case "payment_date": return ( {isEditing && editingCell?.field === "payment_at" ? ( handleDraftChange(placement.id, { payment_at: toIsoFromDateInput(e.target.value), }) } onBlur={() => setEditingCell(null)} /> ) : ( )} ); case "planned_date": return ( {isEditing && editingCell?.field === "placement_at" ? ( handleDraftChange(placement.id, { placement_at: toIsoFromDateInput(e.target.value), }) } onBlur={() => setEditingCell(null)} /> ) : ( )} ); case "actual_date": return ( {placement.placement_post?.post?.published_at ? formatDate(placement.placement_post.post.published_at) : "—"} ); case "format": return ( {isEditing && editingCell?.field === "format" ? ( handleDraftChange(placement.id, { format: e.target.value ? e.target.value : null, }) } onBlur={() => setEditingCell(null)} /> ) : ( )} ); case "time_top": const timeOnTop = placement.placement_post?.time_on_top; return ( {formatTime(timeOnTop)} ); case "link_created": return ( {formatDate(placement.invite_link_created_at)} ); case "post_deleted": return ( {formatDate(placement.placement_post?.post?.deleted_from_channel_at)} ); case "subscriptions": const subsCountValue = placement.placement_post?.subscriptions_count; // Show "—" if subscriptions are hidden due to permissions (0 means hidden) const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue); return ( {showSubs} ); case "views": return ( {formatNumber(placement.placement_post?.views_count)} ); case "cpf": return ( {cpf ? formatCurrency(cpf) : "—"} ); case "conversion_24h": return ( {(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"} ); case "conversion_48h": return ( {(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"} ); case "conversion_total": return ( {(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"} ); case "total_unsubs": return ( {(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"} ); case "unsub_percent": return ( {(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"} ); case "total_active": return ( {(placement as any).total_active !== undefined ? (placement as any).total_active : "—"} ); case "time_in_feed": return ( {(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"} ); case "invite_type": return ( {placement.invite_link_type === "approval" ? "С заявками" : "Открытая"} ); case "comment": return ( {isEditing && editingCell?.field === "comment" ? ( handleDraftChange(placement.id, { comment: e.target.value ? e.target.value : null, }) } onBlur={() => setEditingCell(null)} /> ) : ( )} ); default: return ( Недоступно ); } } function ChannelGroup({ channel, placements, channelNumber, canEdit, getPlacementView, handleDraftChange, editingCell, setEditingCell, expanded, selectedPlacementIds, onToggle, onTogglePlacement, visibleColumns, onOpenCreativeSelect, onClearCreative, onOpenCreativeSend, onAddPlacement, }: ChannelGroupProps) { const [isOpen, setIsOpen] = useState(true); useEffect(() => { setIsOpen(expanded); }, [expanded]); const placementIds = placements.map((p) => p.id); const allSelected = placementIds.length > 0 && placementIds.every((id) => selectedPlacementIds.has(id)); const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0); const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0); const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0); const avgCpf = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null; const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null; if (placements.length === 0) return null; return (
onToggle(channel.id, placementIds)} onClick={(e) => e.stopPropagation()} /> {channelNumber} {channel.title[0]}
{channel.title} {channel.username && ( @{channel.username} )}
{channel.username && ( )} {placements.length}
{formatCompact(channel.subscribers_count)} {formatCompact(totalViews)}
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( ))} {placements.map((placement, idx) => { const viewPlacement = getPlacementView(placement); const effectiveCost = viewPlacement.details?.cost?.value || 0; const cpf = viewPlacement.placement_post?.subscriptions_count ? effectiveCost / viewPlacement.placement_post.subscriptions_count : null; const cpm = viewPlacement.placement_post?.views_count ? (effectiveCost / viewPlacement.placement_post.views_count) * 1000 : null; return ( {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( ))} ); })}
{column.editable && (

{column.partialEdit ? ( ( ) ) : ( )}

)} {column.label}
); } // ============================================================================ // Main Component // ============================================================================ export default function PurchasePlanDetailPage() { const params = useParams(); const router = useRouter(); const searchParams = useSearchParams(); const workspaceId = params?.workspaceId as string; const projectId = params?.projectId as string; const { projects, hasPermission, canViewAnalytics, canViewSubscriptions } = useWorkspace(); const [placements, setPlacements] = useState([]); const [loading, setLoading] = useState(true); const [project, setProject] = useState(null); const [draftsByPlacementId, setDraftsByPlacementId] = useState>({}); const [savingEdits, setSavingEdits] = useState(false); const [saveError, setSaveError] = useState(null); const [editingCell, setEditingCell] = useState(null); // Placement wizard state const prefilledCreativeId = searchParams.get("creative_id"); const [showPlacementWizard, setShowPlacementWizard] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [expandedAll, setExpandedAll] = useState(true); const [filtersOpen, setFiltersOpen] = useState(false); const [selectedPlacementIds, setSelectedPlacementIds] = useState>(new Set()); const [showCopyDialog, setShowCopyDialog] = useState(false); const [targetProjectId, setTargetProjectId] = useState(null); const [copyingChannels, setCopyingChannels] = useState(false); // Initialize visible columns based on permissions const initialVisibleColumns = useMemo(() => { const columns = new Set(DEFAULT_VISIBLE_COLUMNS); // Hide subscription-related columns if user doesn't have analytics_read permission if (!canViewSubscriptions) { columns.delete("subscriptions"); columns.delete("cpf"); columns.delete("conversion_24h"); columns.delete("conversion_48h"); columns.delete("conversion_total"); columns.delete("total_unsubs"); columns.delete("unsub_percent"); columns.delete("total_active"); } // Hide views and cpm if user has no analytics permissions at all if (!canViewAnalytics) { columns.delete("views"); columns.delete("cpm"); } return columns; }, [canViewSubscriptions, canViewAnalytics]); const [visibleColumns, setVisibleColumns] = useState>(initialVisibleColumns); const [showColumnsDialog, setShowColumnsDialog] = useState(false); // Creative selection dialog const [showCreativeDialog, setShowCreativeDialog] = useState(false); const [selectedPlacementForCreative, setSelectedPlacementForCreative] = useState(null); const [creatives, setCreatives] = useState>([]); // Placement wizard const [wizardInitialCreative, setWizardInitialCreative] = useState(null); const loadCreatives = async () => { try { const response = await creativesApi.list(workspaceId, { project_id: projectId, include_archived: false, size: 100, }); setCreatives(response.items); } catch (err) { console.error("Failed to load creatives:", err); } }; useEffect(() => { if (showCreativeDialog && creatives.length === 0) { loadCreatives(); } }, [showCreativeDialog]); const togglePlacementSelection = (placementId: string) => { setSelectedPlacementIds((prev) => { const next = new Set(prev); if (next.has(placementId)) { next.delete(placementId); } else { next.add(placementId); } return next; }); }; const [filters, setFilters] = useState({ channelFilters: { name: { value: "", condition: "contains" }, username: { value: "", condition: "contains" }, subscribersMin: null, subscribersMax: null, }, placementFilters: { statuses: [], postStatuses: [], hasCreative: null, costMin: null, costMax: null, costFormat: null, placementType: null, dateFrom: null, dateTo: null, }, sort: null, }); const { isDemoMode } = useWorkspace(); useEffect(() => { const proj = projects.find((p) => p.id === projectId); if (proj) { setProject(proj); } }, [projects, projectId]); // Handle URL params for placement wizard useEffect(() => { const openPlacement = searchParams.get("openPlacement"); if (openPlacement === "true") { const creativeId = searchParams.get("creative_id"); setWizardInitialCreative(creativeId); setShowPlacementWizard(true); } }, [searchParams]); useEffect(() => { loadPlacements(); }, [workspaceId, projectId]); const loadPlacements = async () => { try { setLoading(true); const api = isDemoMode ? demoAwarePlacementsApi : placementsApi; const data = await api.getByProject(workspaceId, projectId); const placementsWithCreativeNames = await Promise.all( data.map(async (placement) => { if (placement.creative_id && !placement.creative_name && !isDemoMode) { try { const creative = await creativesApi.get(workspaceId, placement.creative_id); return { ...placement, creative_name: creative.name }; } catch (err) { console.error("Failed to fetch creative name:", err); return placement; } } return placement; }) ); setPlacements(placementsWithCreativeNames); setDraftsByPlacementId({}); setSaveError(null); } catch (err) { console.error("Failed to load placements:", err); } finally { setLoading(false); } }; // Creative selection handlers const handleOpenCreativeSelect = (placement: PlacementWithStats) => { setSelectedPlacementForCreative(placement); setShowCreativeDialog(true); }; const handleClearCreative = (placementId: string) => { handleDraftChange(placementId, { creative_id: null, creative_name: null }); }; const handleOpenCreativeSend = async (placement: PlacementWithStats) => { if (!placement.creative_id || isDemoMode) return; await toast.promise( placementsApi.sendCreative(workspaceId, projectId, placement.id), { loading: "Отправка креатива...", success: "Креатив успешно отправлен", error: "Не удалось отправить креатив", } ); }; const handleSelectCreative = (creative: { id: string; name: string }) => { if (selectedPlacementForCreative) { handleDraftChange(selectedPlacementForCreative.id, { creative_id: creative.id, creative_name: creative.name, }); } }; const canEditPlacements = hasPermission("placements_write") && !isDemoMode; const hasUnsavedChanges = Object.keys(draftsByPlacementId).length > 0; const handleDraftChange = (placementId: string, patch: PlacementInlineDraft) => { if (!canEditPlacements) return; setDraftsByPlacementId((prev) => ({ ...prev, [placementId]: { ...(prev[placementId] || {}), ...patch, }, })); }; const getPlacementView = (placement: PlacementWithStats): PlacementWithStats => { const draft = draftsByPlacementId[placement.id]; if (!draft) return placement; const nextStatus = draft.status ?? placement.status; const nextComment = draft.comment !== undefined ? draft.comment : placement.comment; const nextPlacementPost = draft.post_status !== null && draft.post_status !== undefined && placement.placement_post ? { ...placement.placement_post, status: draft.post_status } : placement.placement_post; const nextDetails = { ...(placement.details || { placement_at: null, payment_at: null, cost: null, cost_before_bargain: null, placement_type: null, format: null, comment: null, creative_id: null, }), placement_at: draft.placement_at !== undefined ? draft.placement_at : placement.details?.placement_at ?? null, payment_at: draft.payment_at !== undefined ? draft.payment_at : placement.details?.payment_at ?? null, cost: draft.cost !== undefined ? draft.cost : placement.details?.cost ?? null, cost_before_bargain: draft.cost_before_bargain !== undefined ? draft.cost_before_bargain : placement.details?.cost_before_bargain ?? null, placement_type: draft.placement_type !== undefined ? draft.placement_type : placement.details?.placement_type ?? null, format: draft.format !== undefined ? draft.format : placement.details?.format ?? null, creative_id: draft.creative_id !== undefined ? draft.creative_id : placement.details?.creative_id ?? null, }; return { ...placement, status: nextStatus, placement_post: nextPlacementPost, comment: nextComment, creative_id: draft.creative_id ?? placement.creative_id, creative_name: draft.creative_name ?? placement.creative_name, details: nextDetails, }; }; const handleCancelEdits = () => { setDraftsByPlacementId({}); setSaveError(null); setEditingCell(null); }; const handleSaveEdits = async () => { if (!canEditPlacements) return; const entries = Object.entries(draftsByPlacementId); if (entries.length === 0) return; try { setSavingEdits(true); setSaveError(null); for (const [placementId, draft] of entries) { await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest); } await loadPlacements(); setSavingEdits(false); } catch (err: unknown) { console.error("Failed to save placement edits:", err); const message = typeof err === "object" && err && "detail" in err ? String((err as any).detail) : "Не удалось сохранить изменения"; setSaveError(message); setSavingEdits(false); } }; const toggleChannelSelection = (channelId: string, placementIds: string[]) => { const allSelected = placementIds.length > 0 && placementIds.every((id) => selectedPlacementIds.has(id)); setSelectedPlacementIds((prev) => { const next = new Set(prev); if (allSelected) { placementIds.forEach((id) => next.delete(id)); } else { placementIds.forEach((id) => next.add(id)); } return next; }); }; const toggleAllChannels = () => { const allPlacementIds = new Set(filteredGroups.flatMap((g) => g.placements.map((p) => p.id))); if (selectedPlacementIds.size === allPlacementIds.size) { setSelectedPlacementIds(new Set()); } else { setSelectedPlacementIds(allPlacementIds); } }; const clearSelection = () => { setSelectedPlacementIds(new Set()); }; const handleDeletePlacements = async () => { if (!canEditPlacements) return; const selectedIds = Array.from(selectedPlacementIds); if (selectedIds.length === 0) return; try { for (const placementId of selectedIds) { await placementsApi.deleteInProject(workspaceId, projectId, placementId); } await loadPlacements(); clearSelection(); } catch (err) { console.error("Failed to delete placements:", err); } }; const handleCopyPlacements = async () => { if (!targetProjectId) return; const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id)); if (selectedPlacements.length === 0) return; try { setCopyingChannels(true); await placementsApi.createBulk(workspaceId, targetProjectId, { creative_id: undefined, channels: selectedPlacements.map((p) => ({ channel_id: p.channel.id, status: String(p.status), comment: p.comment || undefined, details: p.details ? { placement_at: p.details.placement_at || undefined, payment_at: p.details.payment_at || undefined, cost: p.details.cost || undefined, cost_before_bargain: p.details.cost_before_bargain?.value || undefined, placement_type: p.details.placement_type || undefined, format: p.details.format || undefined, creative_id: p.details.creative_id || undefined, } : undefined, })), }); setShowCopyDialog(false); setTargetProjectId(null); clearSelection(); } catch (err) { console.error("Failed to copy placements:", err); } finally { setCopyingChannels(false); } }; const handleAddPlacement = async (channel: Placement["channel"]) => { if (!canEditPlacements) return; try { const result = await placementsApi.createInProject(workspaceId, projectId, { creative_id: undefined, channels: [{ channel_id: channel.id, status: "Без статуса", comment: undefined, details: {}, }], details: {}, }); // Extract the new placement from the response const newPlacement = result.placements[0]; // Add the new placement to the list and switch to edit mode setPlacements((prev) => [...prev, newPlacement]); // Scroll to and focus the new placement after a short delay setTimeout(() => { const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`); if (rowElement) { rowElement.scrollIntoView({ behavior: "smooth", block: "center" }); // Set editing state for the first editable field setEditingCell({ placementId: newPlacement.id, field: "status" }); } }, 100); } catch (err) { console.error("Failed to add placement:", err); } }; const groupedPlacements = useMemo(() => { const groups: Record = {}; placements.forEach((placement) => { const channelId = placement.channel.id; if (!groups[channelId]) { groups[channelId] = { channel: placement.channel, placements: [], }; } const cpf = placement.placement_post?.subscriptions_count ? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count : null; const cpm = placement.placement_post?.views_count ? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000 : null; groups[channelId].placements.push({ ...placement, cpf, cpm, }); }); Object.values(groups).forEach((group) => { group.placements.sort((a, b) => { const dateA = new Date(a.created_at).getTime(); const dateB = new Date(b.created_at).getTime(); return dateA - dateB; }); }); return Object.entries(groups).map(([_, group]) => ({ channel: group.channel, placements: group.placements, })).sort((a, b) => a.channel.title.localeCompare(b.channel.title)); }, [placements]); const summary = useMemo(() => { const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0); const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0); const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0); return { totalPlacements: placements.length, totalChannels: groupedPlacements.length, totalCost, totalSubscriptions, totalViews, avgCpf: totalSubscriptions > 0 ? totalCost / totalSubscriptions : null, avgCpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, }; }, [placements, groupedPlacements]); const filteredGroups = useMemo(() => { let filtered = groupedPlacements.filter((group) => { // Channel filters const nameFilter = filters.channelFilters.name; const channelNameMatch = !nameFilter.value || (nameFilter.condition === "contains" ? group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase()) : nameFilter.condition === "not_contains" ? !group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase()) : group.channel.title.toLowerCase() === nameFilter.value.toLowerCase()); const usernameFilter = filters.channelFilters.username; const channelUsernameMatch = !usernameFilter.value || (usernameFilter.condition === "contains" ? group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase()) : usernameFilter.condition === "not_contains" ? !group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase()) : group.channel.username?.toLowerCase() === usernameFilter.value.toLowerCase()); const subscribersMinMatch = filters.channelFilters.subscribersMin === null || (group.channel.subscribers_count || 0) >= filters.channelFilters.subscribersMin; const subscribersMaxMatch = filters.channelFilters.subscribersMax === null || (group.channel.subscribers_count || 0) <= filters.channelFilters.subscribersMax; // Placement filters const placementsWithStatus = filters.placementFilters.statuses.length === 0 || group.placements.some((p) => filters.placementFilters.statuses.includes(p.status)); const costMinMatch = filters.placementFilters.costMin === null || group.placements.some((p) => (p.details?.cost?.value || 0) >= filters.placementFilters.costMin!); const costMaxMatch = filters.placementFilters.costMax === null || group.placements.some((p) => (p.details?.cost?.value || 0) <= filters.placementFilters.costMax!); let dateMatch = true; if (filters.placementFilters.dateFrom || filters.placementFilters.dateTo) { dateMatch = group.placements.some((p) => { if (!p.details?.placement_at) return false; const placementDate = new Date(p.details.placement_at); const fromMatch = !filters.placementFilters.dateFrom || placementDate >= new Date(filters.placementFilters.dateFrom); const toMatch = !filters.placementFilters.dateTo || placementDate <= new Date(filters.placementFilters.dateTo); return fromMatch && toMatch; }); } // New filters const postStatusMatch = filters.placementFilters.postStatuses.length === 0 || group.placements.some((p) => filters.placementFilters.postStatuses.includes(p.placement_post?.status as PlacementPostStatus)); const hasCreativeMatch = filters.placementFilters.hasCreative === null || group.placements.some((p) => (filters.placementFilters.hasCreative === true && p.creative_id !== null) || (filters.placementFilters.hasCreative === false && p.creative_id === null) ); const costFormatMatch = filters.placementFilters.costFormat === null || group.placements.some((p) => p.details?.cost?.type === filters.placementFilters.costFormat); const placementTypeMatch = filters.placementFilters.placementType === null || group.placements.some((p) => p.details?.placement_type === filters.placementFilters.placementType); return channelNameMatch && channelUsernameMatch && subscribersMinMatch && subscribersMaxMatch && placementsWithStatus && costMinMatch && costMaxMatch && dateMatch && postStatusMatch && hasCreativeMatch && costFormatMatch && placementTypeMatch; }); // Sort placements inside each channel if (filters.sort) { filtered = filtered.map((group) => ({ ...group, placements: [...group.placements].sort((a, b) => { let aValue = 0; let bValue = 0; switch (filters.sort!.field) { case "cost": aValue = a.details?.cost?.value || 0; bValue = b.details?.cost?.value || 0; break; case "date": aValue = a.details?.placement_at ? new Date(a.details.placement_at).getTime() : 0; bValue = b.details?.placement_at ? new Date(b.details.placement_at).getTime() : 0; break; case "cpm": aValue = a.cpm || 0; bValue = b.cpm || 0; break; case "cpf": aValue = a.cpf || 0; bValue = b.cpf || 0; break; case "subscribers": aValue = a.placement_post?.subscriptions_count || 0; bValue = b.placement_post?.subscriptions_count || 0; break; case "views": aValue = a.placement_post?.views_count || 0; bValue = b.placement_post?.views_count || 0; break; case "created": aValue = new Date(a.created_at).getTime(); bValue = new Date(b.created_at).getTime(); break; } return filters.sort!.direction === "asc" ? aValue - bValue : bValue - aValue; }), })); } return filtered; }, [groupedPlacements, filters]); const canWrite = hasPermission("placements_write"); const hasActiveFilters = filters.channelFilters.name.value || filters.channelFilters.username.value || filters.channelFilters.subscribersMin !== null || filters.channelFilters.subscribersMax !== null || filters.placementFilters.statuses.length > 0 || filters.placementFilters.postStatuses.length > 0 || filters.placementFilters.hasCreative !== null || filters.placementFilters.costMin !== null || filters.placementFilters.costMax !== null || filters.placementFilters.costFormat !== null || filters.placementFilters.placementType !== null || filters.placementFilters.dateFrom || filters.placementFilters.dateTo || filters.sort; if (loading) { return ( <>
); } return ( <>
{/* Header */}

План закупов проекта «{project?.title || "Проект"}»

Список каналов, в которых планируем размещения для проекта

{/* Filters */}
0 && filteredGroups.every((g) => g.placements.every((p) => selectedPlacementIds.has(p.id)))} onCheckedChange={toggleAllChannels} />
setSearchQuery(e.target.value)} className="pl-9" />
{selectedPlacementIds.size > 0 && ( setShowCopyDialog(true)}> Добавить в другой проект Удалить Отменить выбор )}
{/* Placements List */} {filteredGroups.length === 0 ? (

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

{placements.length === 0 ? "Добавьте первое размещение для этого проекта" : "Попробуйте изменить параметры поиска"}

) : (
{filteredGroups.map((group, idx) => ( toggleChannelSelection(channelId, placementIds)} onTogglePlacement={togglePlacementSelection} visibleColumns={visibleColumns} onOpenCreativeSelect={handleOpenCreativeSelect} onClearCreative={handleClearCreative} onOpenCreativeSend={handleOpenCreativeSend} onAddPlacement={handleAddPlacement} /> ))}
)}
{hasUnsavedChanges && (
{saveError && (
{saveError}
)}
)} Добавить в другой проект Выберите проект, в который хотите добавить {selectedPlacementIds.size} размещений
handleClearCreative(selectedPlacementForCreative.id) : null} currentCreativeId={selectedPlacementForCreative?.creative_id} /> ); }