"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 type { Project, Placement, PlacementWithStats, PlacementsGroupedByChannel, PlacementStatus, PlacementPostStatus, PlacementType, CostType, InviteLinkType, 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; draftsByPlacementId: Record; 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; } // ============================================================================ // Column Width Helper // ============================================================================ function getGridStyle(visibleColumns: Set) { const columns = ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)); const template = columns.map((c) => c.width || "1fr").join(" "); return { gridTemplateColumns: template }; } // ============================================================================ // Table Header Component // ============================================================================ function TableHeader({ visibleColumns, isScrolled }: { visibleColumns: Set; isScrolled: boolean }) { const { gridTemplateColumns } = getGridStyle(visibleColumns); const visibleList = ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)); return (
{visibleList.map((column) => (
{column.editable && (

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

)} {column.label}
))}
); } // ============================================================================ // 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; draftsByPlacementId: Record; 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, draftsByPlacementId, 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}м`; }; const getCellClassName = () => { return "py-2 px-3 flex items-center"; }; const getCellTextClassName = () => { return "text-muted-foreground text-xs"; }; // 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; 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": 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 (
{isEditing && editingCell?.field === "cost_before" ? ( { const raw = e.target.value; if (!raw) { handleDraftChange(placement.id, { cost_before_bargain: null }); return; } const value = Number(raw); if (Number.isNaN(value)) return; handleDraftChange(placement.id, { cost_before_bargain: { type: "fixed", value }, }); }} onBlur={() => setEditingCell(null)} /> ) : ( )}
); 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 (
{isEditing && editingCell?.field === "placement_type" ? ( ) : ( )}
); 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": const formatOptions = [ { value: "1/24", label: "24 часа" }, { value: "1/48", label: "48 часов" }, { value: "1/72", label: "72 часа" }, { value: "1/(7 дней)", label: "7 дней" }, { value: "1/(30 дней)", label: "30 дней" }, { value: "1/(без удаления)", label: "Без удаления" }, { value: "2/24", label: "2×24 часа" }, { value: "2/48", label: "2×48 часов" }, { value: "2/72", label: "2×72 часа" }, ]; const currentFormat = placement.details?.format || ""; const customFormatDraft = draftsByPlacementId[placement.id]?.format; const formatValue = customFormatDraft !== undefined ? customFormatDraft : currentFormat; return (
{canEdit && ( Формат размещения Срок, на который пост останется на канале до удаления
{formatOptions.map((opt) => ( ))}
handleDraftChange(placement.id, { format: e.target.value ? e.target.value : null, }) } />

Укажите количество постов и срок через слеш, например: X/24, X/48, X/(7 дней)

)}
); 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": return (
{formatNumber(placement.placement_post?.subscriptions_count)}
); 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": const inviteType = placement.invite_link_type; return (
{isEditing && editingCell?.field === "invite_type" ? ( ) : ( )}
); 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, draftsByPlacementId, 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)}
{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) => ( ))}
); }
{saveError && (
{saveError}
)}
)} Добавить в другой проект Выберите проект, в который хотите добавить {selectedPlacementIds.size} размещений
handleClearCreative(selectedPlacementForCreative.id) : null} currentCreativeId={selectedPlacementForCreative?.creative_id} /> ); }