diff --git a/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx b/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx index 73ee635..30c91ca 100644 --- a/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx +++ b/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx @@ -83,6 +83,7 @@ export default function PlacementDetailPage() { const [placement, setPlacement] = useState(null); const [loading, setLoading] = useState(true); const [copiedLink, setCopiedLink] = useState(false); + const [projectId, setProjectId] = useState(null); const canWrite = hasPermission("placements_write"); @@ -93,8 +94,14 @@ export default function PlacementDetailPage() { const loadPlacement = async () => { try { 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); setPlacement(data); + // Store projectId from placement for future operations + if (data.project?.id) { + setProjectId(data.project.id); + } } catch (err) { console.error("Failed to load placement:", err); } finally { diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index 7acfe15..e0353b1 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -25,11 +25,15 @@ import { BarChart3, ArrowUpDown, X, + MoreVertical, + Trash2, + Copy, + Pencil, } from "lucide-react"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { demoAwarePlacementsApi } from "@/lib/demo"; -import { placementsApi } from "@/lib/api"; +import { placementsApi, creativesApi } from "@/lib/api"; import { AddChannelDialog } from "@/components/dialog-add-channel"; import { CreatePlacementsDialog } from "@/components/dialog-create-placements"; import { FiltersModal, PlacementFilters } from "@/components/filters-modal"; @@ -56,6 +60,24 @@ import { 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 { cn } from "@/lib/utils"; import type { Project, @@ -63,21 +85,36 @@ import type { PlacementWithStats, PlacementsGroupedByChannel, PlacementStatus, + PlacementPostStatus, + PlacementType, + CostType, + UpdatePlacementInProjectRequest, } from "@/lib/types/api"; +import { + ALL_COLUMNS, + DEFAULT_VISIBLE_COLUMNS, + PLACEMENT_DEAL_STATUSES, + PLACEMENT_POST_STATUSES, + canEditPostStatus, +} from "@/lib/config/placement-columns"; -type PlacementInlineDraft = Partial<{ - status: PlacementStatus; - 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; -}>; +type PlacementInlineDraft = { + status?: PlacementStatus; + 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" | "placement_at" | "cost" | "format" | "comment"; +type EditableField = "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; @@ -110,7 +147,7 @@ const formatCompact = (value: number | null | undefined): string => { return value.toString(); }; -const formatDate = (dateStr: string | null): string => { +const formatDate = (dateStr: string | null | undefined): string => { if (!dateStr) return "—"; const date = new Date(dateStr); return date.toLocaleDateString("ru-RU", { @@ -137,19 +174,6 @@ const toIsoFromDateInput = (value: string): string | null => { return date.toISOString(); }; -const PLACEMENT_STATUSES: PlacementStatus[] = [ - "Без статуса", - "Написать", - "Ждём ответа", - "Согласование условий", - "Оплатить", - "Оплачено", - "Отмена", - "Не подходит цена", - "Неактуально", - "Не отвечает", -]; - const getStatusColor = (status: PlacementStatus): string => { switch (status) { case "Оплачено": @@ -183,6 +207,681 @@ interface ChannelGroupProps { 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; +} + +// ============================================================================ +// 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 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"; + } + }; + + 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 ( + + {isEditing && editingCell?.field === "status" ? ( + + ) : ( + + )} + + ); + + case "status_post": + const postStatus = placement.placement_post?.status; + return ( + + {postStatus ? ( + + {postStatus} + + ) : ( + + )} + + ); + + case "creative": + const hasCreative = !!placement.creative_id; + return ( + + {hasCreative ? ( +
+
+ 📷 +
+ {placement.creative_name || "Без названия"} + + +
+ ) : ( + + )} + + ); + + case "invite_link": + return ( + + {placement.invite_link ? ( + + {placement.invite_link} + + ) : ( + + )} + + ); + + case "post_link": + return ( + + {placement.placement_post?.post?.url ? ( + + Пост + + ) : ( + + )} + + ); + + case "cost": + const isFixedFormat = placement.details?.cost?.type === "fixed"; + const canEditCost = canEdit && isFixedFormat; + 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 "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": + return ( + + {formatNumber(placement.placement_post?.subscriptions_count)} + + ); + + case "views": + return ( + + {formatNumber(placement.placement_post?.views_count)} + + ); + + case "cpf": + return ( + + {cpf ? formatCurrency(cpf) : "—"} + + ); + + case "cpm": + const isCpmFormat = placement.details?.cost?.type === "cpm"; + const canEditCpm = canEdit && isCpmFormat; + return ( + + {isEditing && editingCell?.field === "cpm" ? ( + { + const raw = e.target.value; + if (!raw) return; + const value = Number(raw); + if (Number.isNaN(value)) return; + const currentCost = placement.details?.cost; + handleDraftChange(placement.id, { + cost: { type: "cpm", value: (currentCost?.type === "cpm" ? currentCost.value : 0) }, + cpm: value, + }); + }} + onBlur={() => setEditingCell(null)} + /> + ) : ( + + )} + + ); + + 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({ @@ -195,6 +894,13 @@ function ChannelGroup({ editingCell, setEditingCell, expanded, + selectedPlacementIds, + onToggle, + onTogglePlacement, + visibleColumns, + onOpenCreativeSelect, + onClearCreative, + onOpenCreativeSend, }: ChannelGroupProps) { const [isOpen, setIsOpen] = useState(true); @@ -202,6 +908,9 @@ function ChannelGroup({ 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); @@ -216,6 +925,11 @@ function ChannelGroup({
+ onToggle(channel.id, placementIds)} + onClick={(e) => e.stopPropagation()} + /> - - № - Статус - Дата - Стоимость - Охват - Подписки - CPF - CPM - Формат - Комментарий + {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( + + {column.label} + + ))} @@ -300,27 +1014,6 @@ function ChannelGroup({ const viewPlacement = getPlacementView(placement); const effectiveCost = viewPlacement.details?.cost?.value || 0; - const isEditingStatus = - canEdit && - editingCell?.placementId === placement.id && - editingCell.field === "status"; - const isEditingDate = - canEdit && - editingCell?.placementId === placement.id && - editingCell.field === "placement_at"; - const isEditingCost = - canEdit && - editingCell?.placementId === placement.id && - editingCell.field === "cost"; - const isEditingFormat = - canEdit && - editingCell?.placementId === placement.id && - editingCell.field === "format"; - const isEditingComment = - canEdit && - editingCell?.placementId === placement.id && - editingCell.field === "comment"; - const cpf = viewPlacement.placement_post?.subscriptions_count ? effectiveCost / viewPlacement.placement_post.subscriptions_count : null; @@ -330,194 +1023,27 @@ function ChannelGroup({ return ( - - - - {channelNumber}.{idx + 1} - - - - {isEditingStatus ? ( - - ) : ( - - )} - - - {isEditingDate ? ( - - handleDraftChange(placement.id, { - placement_at: toIsoFromDateInput(e.target.value), - }) - } - onBlur={() => setEditingCell(null)} - /> - ) : ( - - )} - - - {isEditingCost ? ( - { - 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)} - /> - ) : ( - - )} - - - {formatCompact(viewPlacement.placement_post?.views_count || null)} - - - {formatNumber(viewPlacement.placement_post?.subscriptions_count || null)} - - - {formatCurrency(cpf)} - - - {formatCurrency(cpm)} - - - {isEditingFormat ? ( - - handleDraftChange(placement.id, { - format: e.target.value ? e.target.value : null, - }) - } - onBlur={() => setEditingCell(null)} - /> - ) : ( - - )} - - - {isEditingComment ? ( - - handleDraftChange(placement.id, { - comment: e.target.value ? e.target.value : null, - }) - } - onBlur={() => setEditingCell(null)} - /> - ) : ( - - )} - + {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( + + ))} ); })} @@ -552,12 +1078,57 @@ export default function PurchasePlanDetailPage() { // Create placements dialog const prefilledCreativeId = searchParams.get("creative_id"); + const [prefilledChannelIds, setPrefilledChannelIds] = useState(null); const [showCreateDialog, setShowCreateDialog] = 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>([]); + + 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" }, @@ -567,11 +1138,14 @@ export default function PurchasePlanDetailPage() { }, placementFilters: { statuses: [], + postStatuses: [], + hasCreative: null, costMin: null, costMax: null, + costFormat: null, + placementType: null, dateFrom: null, dateTo: null, - hasPlacement: null, }, sort: null, }); @@ -592,6 +1166,13 @@ export default function PurchasePlanDetailPage() { } }, [prefilledCreativeId, project]); + // Auto-open create dialog if channel_ids are provided + useEffect(() => { + if (prefilledChannelIds && project) { + setShowCreateDialog(true); + } + }, [prefilledChannelIds, project]); + useEffect(() => { loadPlacements(); }, [workspaceId, projectId]); @@ -601,7 +1182,23 @@ export default function PurchasePlanDetailPage() { setLoading(true); const api = isDemoMode ? demoAwarePlacementsApi : placementsApi; const data = await api.getByProject(workspaceId, projectId); - setPlacements(data); + + 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) { @@ -611,6 +1208,29 @@ export default function PurchasePlanDetailPage() { } }; + // 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 = (placement: PlacementWithStats) => { + console.log("Sending creative for placement:", placement.id); + }; + + 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; @@ -677,6 +1297,8 @@ export default function PurchasePlanDetailPage() { ...placement, status: nextStatus, comment: nextComment, + creative_id: draft.creative_id ?? placement.creative_id, + creative_name: draft.creative_name ?? placement.creative_name, details: nextDetails, }; }; @@ -697,7 +1319,7 @@ export default function PurchasePlanDetailPage() { setSaveError(null); for (const [placementId, draft] of entries) { - await placementsApi.updateInProject(workspaceId, projectId, placementId, draft); + await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest); } await loadPlacements(); @@ -712,6 +1334,82 @@ export default function PurchasePlanDetailPage() { } }; + 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 groupedPlacements = useMemo(() => { const groups: Record = {}; @@ -738,10 +1436,18 @@ export default function PurchasePlanDetailPage() { }); }); + 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(() => { @@ -806,56 +1512,71 @@ export default function PurchasePlanDetailPage() { }); } + // 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; + subscribersMaxMatch && placementsWithStatus && costMinMatch && costMaxMatch && dateMatch && + postStatusMatch && hasCreativeMatch && costFormatMatch && placementTypeMatch; }); - // Apply sorting + // Sort placements inside each channel if (filters.sort) { - filtered = [...filtered].sort((a, b) => { - let aValue: number | string = 0; - let bValue: number | string = 0; + filtered = filtered.map((group) => ({ + ...group, + placements: [...group.placements].sort((a, b) => { + let aValue = 0; + let bValue = 0; - switch (filters.sort!.field) { - case "title": - aValue = a.channel.title.toLowerCase(); - bValue = b.channel.title.toLowerCase(); - break; - case "subscribers": - aValue = a.channel.subscribers_count || 0; - bValue = b.channel.subscribers_count || 0; - break; - case "cost": - aValue = Math.max(...a.placements.map((p) => p.details?.cost?.value || 0)); - bValue = Math.max(...b.placements.map((p) => p.details?.cost?.value || 0)); - break; - case "date": - aValue = Math.max( - ...a.placements.map((p) => p.details?.placement_at ? new Date(p.details.placement_at).getTime() : 0) - ); - bValue = Math.max( - ...b.placements.map((p) => p.details?.placement_at ? new Date(p.details.placement_at).getTime() : 0) - ); - break; - case "cpm": - aValue = a.placements[0]?.cpm || 0; - bValue = b.placements[0]?.cpm || 0; - break; - case "cpf": - aValue = a.placements[0]?.cpf || 0; - bValue = b.placements[0]?.cpf || 0; - break; - } + 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; + } - if (typeof aValue === "string") { return filters.sort!.direction === "asc" - ? aValue.localeCompare(bValue as string) - : (bValue as string).localeCompare(aValue); - } - return filters.sort!.direction === "asc" - ? (aValue as number) - (bValue as number) - : (bValue as number) - (aValue as number); - }); + ? aValue - bValue + : bValue - aValue; + }), + })); } return filtered; @@ -863,13 +1584,17 @@ export default function PurchasePlanDetailPage() { const canWrite = hasPermission("placements_write"); - const hasActiveFilters = filters.channelFilters.name || - filters.channelFilters.username || + 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; @@ -932,6 +1657,7 @@ export default function PurchasePlanDetailPage() { projectId={projectId} existingPlacements={placements} prefilledCreativeId={prefilledCreativeId} + prefilledChannelIds={prefilledChannelIds} open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={loadPlacements} @@ -946,6 +1672,18 @@ export default function PurchasePlanDetailPage() { {/* Filters */}
+
+ 0 && filteredGroups.every((g) => g.placements.every((p) => selectedPlacementIds.has(p.id)))} + onCheckedChange={toggleAllChannels} + /> + +
)} + + {selectedPlacementIds.size > 0 && ( + + + + + + setShowCopyDialog(true)}> + + Добавить в другой проект + + + + + Удалить + + + + Отменить выбор + + + + )}
{/* Placements List */} @@ -1004,6 +1774,13 @@ export default function PurchasePlanDetailPage() { editingCell={editingCell} setEditingCell={setEditingCell} expanded={expandedAll} + selectedPlacementIds={selectedPlacementIds} + onToggle={(channelId, placementIds) => toggleChannelSelection(channelId, placementIds)} + onTogglePlacement={togglePlacementSelection} + visibleColumns={visibleColumns} + onOpenCreativeSelect={handleOpenCreativeSelect} + onClearCreative={handleClearCreative} + onOpenCreativeSend={handleOpenCreativeSend} /> ))}
@@ -1046,6 +1823,57 @@ export default function PurchasePlanDetailPage() {
)} + + + + Добавить в другой проект + + Выберите проект, в который хотите добавить {selectedPlacementIds.size} размещений + + +
+ + +
+
+ + +
+
+
+ + + + + handleClearCreative(selectedPlacementForCreative.id) : null} + currentCreativeId={selectedPlacementForCreative?.creative_id} + /> ); } diff --git a/components/column-selector-dialog.tsx b/components/column-selector-dialog.tsx new file mode 100644 index 0000000..b49562f --- /dev/null +++ b/components/column-selector-dialog.tsx @@ -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; + onVisibleColumnsChange: (columns: Set) => 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 ( + + + + Выбор колонок + + Выберите колонки для отображения в таблице размещений + + + +
+ {/* Quick actions */} +
+ + + +
+ + {/* Column groups */} + {COLUMN_GROUPS.map((group) => { + const groupColumns = ALL_COLUMNS.filter((c) => c.group === group.id); + return ( +
+
+ handleToggleGroup(group.id, !!checked)} + /> + + {isGroupPartiallySelected(group.id) && ( + (частично выбрано) + )} +
+ +
+ {groupColumns.map((column) => ( +
+ handleToggleColumn(column.id)} + disabled={column.required} + /> + +
+ ))} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/components/creative-select-dialog.tsx b/components/creative-select-dialog.tsx new file mode 100644 index 0000000..946b9cb --- /dev/null +++ b/components/creative-select-dialog.tsx @@ -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 ( + + + + Выберите креатив + + {creatives.length === 0 ? ( +
+ Нет доступных креативов +
+ ) : ( +
+ {creatives.map((creative) => ( +
{ + onSelect(creative); + onOpenChange(false); + }} + > +
+ {creative.thumbnail_url ? ( + {creative.name} + ) : ( + 📷 + )} +
+ {creative.name} +
+ ))} +
+ )} + {currentCreativeId && onClear && ( +
+ +
+ )} +
+
+ ); +} + diff --git a/components/dialog-create-placements.tsx b/components/dialog-create-placements.tsx index 7de55d7..bc6828d 100644 --- a/components/dialog-create-placements.tsx +++ b/components/dialog-create-placements.tsx @@ -38,6 +38,7 @@ interface CreatePlacementsDialogProps { projectId: string; existingPlacements: Placement[]; prefilledCreativeId?: string | null; + prefilledChannelIds?: string[] | null; open?: boolean; onOpenChange?: (open: boolean) => void; onSuccess: () => void; @@ -52,6 +53,7 @@ export function CreatePlacementsDialog({ projectId, existingPlacements, prefilledCreativeId = null, + prefilledChannelIds = null, open: controlledOpen, onOpenChange, onSuccess, @@ -120,8 +122,9 @@ export function CreatePlacementsDialog({ if (isOpen) { loadCreatives(); loadProjectChannels(); + loadPrefilledChannels(); } - }, [isOpen, workspaceId, projectId, prefilledCreativeId]); + }, [isOpen, workspaceId, projectId, prefilledCreativeId, prefilledChannelIds]); const loadCreatives = async () => { 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 username = parseChannelInput(newChannelInput); if (!username) { diff --git a/components/filters-modal.tsx b/components/filters-modal.tsx index 7a04430..4499661 100644 --- a/components/filters-modal.tsx +++ b/components/filters-modal.tsx @@ -1,7 +1,7 @@ "use client"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -21,9 +21,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; 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"; @@ -60,11 +59,14 @@ export interface PlacementFilters { }; placementFilters: { statuses: PlacementStatus[]; + postStatuses: PlacementPostStatus[]; + hasCreative: boolean | null; costMin: number | null; costMax: number | null; + costFormat: CostType | null; + placementType: PlacementType | null; dateFrom: string | null; dateTo: string | null; - hasPlacement: boolean | null; }; sort: SortOption | null; } @@ -74,13 +76,14 @@ const CHANNEL_FILTER_FIELDS: FilterField[] = [ { key: "username", label: "Username", type: "text" }, ]; -const SORT_FIELDS: SortField[] = [ - { key: "title", label: "Название канала" }, - { key: "subscribers", label: "Подписчики" }, +const PLACEMENT_SORT_FIELDS: SortField[] = [ { key: "cost", label: "Стоимость" }, { key: "date", label: "Дата размещения" }, { key: "cpm", label: "CPM" }, { key: "cpf", label: "CPF" }, + { key: "subscribers", label: "Подписчики" }, + { key: "views", label: "Просмотры" }, + { key: "created", label: "Дата создания" }, ]; const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [ @@ -102,21 +105,40 @@ const STATUS_OPTIONS: PlacementStatus[] = [ "Не отвечает", ]; -const CONDITION_LABELS: Record = { - contains: "содержит", - not_contains: "не содержит", - equals: "равно", -}; +const POST_STATUS_OPTIONS: PlacementPostStatus[] = [ + "Без статуса", + "Отправить пост", + "Согласование поста", + "Ожидание отложки", + "Запланирован", + "Пост вышел", + "Размещение отработало - пост удалён", + "Размещение отработало - пост не удалён", + "Проверить - пост удалён раньше срока", + "Проверить - пост не вышел", + "Размещение отработало", +]; + +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({ open, onOpenChange, onApply, - existingPlacements, initialFilters, }: FiltersModalProps) { const [filters, setFilters] = useState(initialFilters); - const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("channels"); + const [activeTab, setActiveTab] = useState<"channels" | "placements">("channels"); const handleApply = () => { onApply(filters); @@ -133,11 +155,14 @@ export function FiltersModal({ }, placementFilters: { statuses: [], + postStatuses: [], + hasCreative: null, costMin: null, costMax: null, + costFormat: null, + placementType: null, dateFrom: null, dateTo: null, - hasPlacement: 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 = ( key: "name" | "username", updates: { value?: string; condition?: FilterCondition } @@ -174,8 +210,12 @@ export function FiltersModal({ 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; @@ -186,7 +226,7 @@ export function FiltersModal({ - Фильтры и сортировка + Фильтры Настройте фильтры для каналов и размещений @@ -208,42 +248,31 @@ export function FiltersModal({ -
{activeTab === "channels" && ( -
- {CHANNEL_FILTER_FIELDS.map((field) => ( -
-