diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index 5a687db..d35b29f 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -92,7 +92,6 @@ import type { PlacementPostStatus, PlacementType, CostType, - InviteLinkType, UpdatePlacementInProjectRequest, } from "@/lib/types/api"; import { @@ -215,7 +214,6 @@ interface ChannelGroupProps { setEditingCell: (cell: EditingCell | null) => void; expanded: boolean; selectedPlacementIds: Set; - draftsByPlacementId: Record; onToggle: (channelId: string, placementIds: string[]) => void; onTogglePlacement: (placementId: string) => void; visibleColumns: Set; @@ -225,63 +223,6 @@ interface ChannelGroupProps { 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 // ============================================================================ @@ -299,7 +240,6 @@ interface CellRendererProps { cpf: number | null; cpm: number | null; selectedPlacementIds: Set; - draftsByPlacementId: Record; onTogglePlacement: (placementId: string) => void; onOpenCreativeSelect?: (placement: PlacementWithStats) => void; onClearCreative?: (placementId: string) => void; @@ -319,7 +259,6 @@ function CellRenderer({ cpf, cpm, selectedPlacementIds, - draftsByPlacementId, onTogglePlacement, onOpenCreativeSelect, onClearCreative, @@ -379,41 +318,31 @@ function CellRenderer({ 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()} - /> -
-
+ + onTogglePlacement(placement.id)} + onClick={(e) => e.stopPropagation()} + /> + ); case "number": return ( -
+ {channelNumber}.{idx + 1} -
+ ); case "status_deal": return ( -
+ handleDraftChange(placement.id, { status: s })} onCloseEdit={() => setEditingCell(null)} /> -
+ ); case "status_post": const postStatus = placement.placement_post?.status; return ( -
+ handleDraftChange(placement.id, { post_status: s })} onCloseEdit={() => setEditingCell(null)} /> -
+ ); case "creative": const viewCreativeId = placement.creative_id; const hasCreative = !!viewCreativeId; return ( -
+ {hasCreative ? ( -
-
- {placement.creative_name || "Без названия"} +
+
+ {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": @@ -530,12 +459,14 @@ function CellRenderer({ 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" ? ( } )} -
+ ); 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" ? ( } )} -
+ ); 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)} - /> - ) : ( - - )} -
+ + {costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"} + ); case "cost_format": const costFormat = placement.details?.cost?.type || "fixed" as CostType; return ( -
+ {isEditing && editingCell?.field === "cost_format" ? ( { - handleDraftChange(placement.id, { - placement_type: value, - }); - }} - > - - - - - Стандартный - Взаимный пиар - - - ) : ( - - )} -
+ + {placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"} + ); case "discount": @@ -740,14 +610,14 @@ function CellRenderer({ 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" ? ( )} -
+ ); case "planned_date": return ( -
+ {isEditing && editingCell?.field === "placement_at" ? ( )} -
+ ); 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 ( -
-
+ + {isEditing && editingCell?.field === "format" ? ( + + handleDraftChange(placement.id, { + format: e.target.value ? e.target.value : null, + }) + } + onBlur={() => setEditingCell(null)} + /> + ) : ( - {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" ? ( - - ) : ( - - )} -
+ + {placement.invite_link_type === "approval" ? "С заявками" : "Открытая"} + ); case "comment": return ( -
+ {isEditing && editingCell?.field === "comment" ? ( canEdit && setEditingCell({ placementId: placement.id, field: "comment" }) } > - {placement.comment || "—"} - + {placement.comment || "—"} + )} -
+ ); default: return ( -
+ Недоступно -
+ ); } } @@ -1092,7 +870,6 @@ function ChannelGroup({ setEditingCell, expanded, selectedPlacementIds, - draftsByPlacementId, onToggle, onTogglePlacement, visibleColumns, @@ -1123,7 +900,7 @@ function ChannelGroup({
-
+
onToggle(channel.id, placementIds)} @@ -1204,55 +981,849 @@ function ChannelGroup({
-
- {placements.map((placement, idx) => { - const viewPlacement = getPlacementView(placement); - const effectiveCost = viewPlacement.details?.cost?.value || 0; +
+ + + + {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; + 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) => ( - - ))} + 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 } = 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); + const [visibleColumns, setVisibleColumns] = useState>( + new Set(DEFAULT_VISIBLE_COLUMNS) + ); + 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; + + try { + await placementsApi.sendCreative(workspaceId, projectId, placement.id); + alert("Креатив успешно отправлен"); + } catch (err) { + console.error("Failed to send creative:", err); + alert("Не удалось отправить креатив"); + } + }; + + 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 && (