Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-03-04 08:29:11 +03:00

2068 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
ImageIcon,
MousePointerClick,
} 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 { PlacementsTableHeader } from "@/components/placements-table-header";
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 { CostFormatSelector } from "@/components/cost-format-selector";
import { FormatSelector } from "@/components/format-selector";
import { PlacementWizard } from "@/components/placement-wizard";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { isPermissionError, showSuccessToast } from "@/lib/utils/error-handler";
import type {
Project,
Placement,
PlacementWithStats,
PlacementsGroupedByChannel,
PlacementStatus,
PlacementPostStatus,
PlacementType,
CostType,
UpdatePlacementInProjectRequest,
Creative,
} from "@/lib/types/api";
import {
ALL_COLUMNS,
DEFAULT_VISIBLE_COLUMNS,
PLACEMENT_DEAL_STATUSES,
PLACEMENT_POST_STATUSES,
canEditPostStatus,
getPlacementStatusColor,
getColumnStyle,
} 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;
originalFormat?: string | null;
cpm?: number | null;
};
type EditableField = "status" | "post_status" | "placement_at" | "payment_at" | "cost" | "cost_before" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name" | "placement_type";
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<string>;
onToggle: (channelId: string, placementIds: string[]) => void;
onTogglePlacement: (placementId: string) => void;
visibleColumns: Set<string>;
onOpenCreativeSelect?: (placement: PlacementWithStats) => void;
onClearCreative?: (placementId: string) => void;
onOpenCreativeSend?: (placement: PlacementWithStats) => void;
onAddPlacement?: (channel: Placement["channel"]) => void;
creatives: Creative[];
}
// ============================================================================
// Cell Renderer Component
// ============================================================================
interface CellRendererProps {
placementId: string;
columnConfigs: (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<string>;
onTogglePlacement: (placementId: string) => void;
onOpenCreativeSelect?: (placement: PlacementWithStats) => void;
onClearCreative?: (placementId: string) => void;
onOpenCreativeSend?: (placement: PlacementWithStats) => void;
creatives: Creative[];
}
function CellRenderer({
placementId,
columnConfigs,
placement,
idx,
channelNumber,
canEdit,
editingCell,
setEditingCell,
handleDraftChange,
effectiveCost,
cpf,
cpm,
selectedPlacementIds,
onTogglePlacement,
onOpenCreativeSelect,
onClearCreative,
onOpenCreativeSend,
creatives,
}: 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 renderCell = (column: (typeof ALL_COLUMNS)[number]) => {
switch (column.id) {
case "checkbox":
return (
<div key={column.id} className="py-2 px-3">
<Checkbox
checked={selectedPlacementIds.has(placement.id)}
onCheckedChange={() => onTogglePlacement(placement.id)}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
case "number":
return (
<div key={column.id} className="py-2 px-3">
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{channelNumber}.{idx + 1}
</span>
</div>
);
case "status_deal":
return (
<div key={column.id} className="py-2 px-3">
<PlacementStatusSelector
status={placement.status}
isEditing={isEditing && editingCell?.field === "status"}
canEdit={canEdit}
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
onChange={(s) => handleDraftChange(placement.id, { status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</div>
);
case "status_post":
const postStatus = placement.placement_post?.status;
return (
<div key={column.id} className="py-2 px-3">
<PlacementPostStatusSelector
status={postStatus ?? null}
isEditing={isEditing && editingCell?.field === "post_status"}
canEdit={canEdit}
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</div>
);
case "creative":
const viewCreativeId = placement.creative_id;
const hasCreative = !!viewCreativeId;
const creative = hasCreative ? creatives.find((c) => c.id === viewCreativeId) : null;
const thumbnailUrl = creative?.media_items?.[0]?.s3_url;
const imagesCount = creative?.media_items?.length || 0;
const buttonsCount = creative?.buttons?.length || 0;
return (
<div key={column.id} className="py-2 px-3">
{hasCreative && creative ? (
<div className="flex items-center gap-1">
<div className="flex items-center px-2 py-1 bg-muted/50 rounded flex-1 min-w-0 gap-1">
{imagesCount > 0 && (
<div className="flex items-center gap-0.5 text-xs text-muted-foreground shrink-0" title={`${imagesCount} изображений`}>
<ImageIcon className="size-3"/>
<span>{imagesCount}</span>
</div>
)}
{buttonsCount > 0 && (
<div className="flex items-center gap-0.5 text-xs text-muted-foreground shrink-0" title={`${buttonsCount} кнопок`}>
<MousePointerClick className="size-3"/>
<span>{buttonsCount}</span>
</div>
)}
<span className="text-xs font-medium truncate">
{creative.text?.replace(/<[^>]+>/g, '').substring(0, 50) || "Без текста"}
{creative.text && creative.text.replace(/<[^>]+>/g, '').length > 50 && '...'}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary rounded shrink-0"
title="Отправить готовый креатив"
onClick={() => {
if (viewCreativeId) {
onOpenCreativeSend?.(placement);
}
}}
>
<Send className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive rounded shrink-0"
title="Очистить креатив"
onClick={() => {
if (viewCreativeId) {
onClearCreative?.(placement.id);
}
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<button
type="button"
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded px-2 py-1 w-full min-h-[1.75rem]"
onClick={() => {
if (canEdit) {
onOpenCreativeSelect?.(placement);
}
}}
>
<span>Выбрать креатив</span>
<span className="text-primary font-medium">+</span>
</button>
)}
</div>
);
case "cost":
const costType = placement.details?.cost?.type || "fixed";
const costValue = placement.details?.cost?.value || 0;
const viewsCount = placement.placement_post?.views_count || 0;
const subsCount = placement.placement_post?.subscriptions_count || 0;
// Если тип CPM - показываем рассчитанную стоимость = CPM * views / 1000
// Если тип Fixed - показываем фиксированную стоимость
const calculatedCost = costType === "cpm" ? (viewsCount > 0 ? (costValue * viewsCount) / 1000 : null) : costValue;
const canEditCost = canEdit && costType === "fixed";
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cost" ? (
<Input
type="number"
min="0"
step="0.01"
autoFocus
className="h-7 min-w-[80px] bg-background px-2 text-xs"
defaultValue={calculatedCost || 0}
onChange={(e) => {
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)}
/>
) : (
<button
type="button"
disabled={!canEditCost}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEditCost && setEditingCell({ placementId: placement.id, field: "cost" })
}
>
<span className="font-medium">{calculatedCost ? formatCurrency(calculatedCost) : "—"}</span>
{costType === "fixed" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</div>
);
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 (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cpm" ? (
<Input
type="number"
min="0"
step="0.01"
autoFocus
className="h-7 min-w-[80px] bg-background px-2 text-xs"
defaultValue={calculatedCpm || 0}
onChange={(e) => {
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)}
/>
) : (
<button
type="button"
disabled={!canEditCpm}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEditCpm && setEditingCell({ placementId: placement.id, field: "cpm" })
}
>
<span>{calculatedCpm ? formatCurrency(calculatedCpm) : "—"}</span>
{cpmType === "cpm" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</div>
);
case "cost_before":
const costBefore = placement.details?.cost_before_bargain?.value;
const costBeforeType = placement.details?.cost_before_bargain?.type || "fixed" as CostType;
const canEditCostBefore = canEdit;
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cost_before" ? (
<Input
type="number"
min="0"
step="0.01"
autoFocus
className="h-7 min-w-[80px] bg-background px-2 text-xs"
defaultValue={costBefore || 0}
onChange={(e) => {
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: costBeforeType, value },
});
}}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEditCostBefore}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEditCostBefore && setEditingCell({ placementId: placement.id, field: "cost_before" })
}
>
<span>{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}</span>
{canEditCostBefore && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</div>
);
case "cost_format":
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
return (
<div key={column.id} className="py-2 px-3">
<CostFormatSelector
value={costFormat}
isEditing={isEditing && editingCell?.field === "cost_format"}
canEdit={canEdit}
onEdit={() => setEditingCell({ placementId: placement.id, field: "cost_format" })}
onChange={(value: CostType) => {
handleDraftChange(placement.id, {
cost: { type: value, value: placement.details?.cost?.value || 0 },
});
}}
onCloseEdit={() => setEditingCell(null)}
/>
</div>
);
case "placement_type":
const placementType = placement.details?.placement_type;
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "placement_type" ? (
<Select
value={placementType || ""}
onValueChange={(value: PlacementType) => {
handleDraftChange(placement.id, {
placement_type: value as PlacementType,
});
}}
>
<SelectTrigger className="h-7 w-[120px] bg-background px-2 text-xs">
<SelectValue placeholder="Выберите..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Стандартный</SelectItem>
<SelectItem value="self_promo">Взаимный пиар</SelectItem>
</SelectContent>
</Select>
) : (
<button
type="button"
disabled={!canEdit}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "placement_type" })
}
>
<span>{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}</span>
{canEdit && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</div>
);
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 (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{discount ? `${discount.toFixed(1)}%` : "—"}
</div>
);
case "payment_date":
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "payment_at" ? (
<Input
type="date"
autoFocus
className="h-7 min-w-[100px] bg-background px-2 text-xs"
defaultValue={toDateInputValue(placement.details?.payment_at)}
onChange={(e) =>
handleDraftChange(placement.id, {
payment_at: toIsoFromDateInput(e.target.value),
})
}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "payment_at" })
}
>
<span>{formatDate(placement.details?.payment_at)}</span>
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</div>
);
case "planned_date":
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "placement_at" ? (
<Input
type="date"
autoFocus
className="h-7 min-w-[100px] bg-background px-2 text-xs"
defaultValue={toDateInputValue(placement.details?.placement_at)}
onChange={(e) =>
handleDraftChange(placement.id, {
placement_at: toIsoFromDateInput(e.target.value),
})
}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "placement_at" })
}
>
<span>{formatDate(placement.details?.placement_at)}</span>
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</div>
);
case "actual_date":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{placement.placement_post?.post?.published_at
? formatDate(placement.placement_post.post.published_at)
: "—"}
</div>
);
case "format":
return (
<div key={column.id} className="py-2 px-3">
<FormatSelector
value={placement.details?.format}
isEditing={isEditing && editingCell?.field === "format"}
canEdit={canEdit}
onEdit={() => setEditingCell({ placementId: placement.id, field: "format" })}
onChange={(value) => handleDraftChange(placement.id, { format: value, originalFormat: value })}
onCloseEdit={() => setEditingCell(null)}
/>
</div>
);
case "time_top":
const timeOnTop = placement.placement_post?.time_on_top;
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{formatTime(timeOnTop)}
</div>
);
case "link_created":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{formatDate(placement.invite_link_created_at)}
</div>
);
case "post_deleted":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
</div>
);
case "subscriptions":
const subsCountValue = placement.placement_post?.subscriptions_count;
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{showSubs}
</div>
);
case "views":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{formatNumber(placement.placement_post?.views_count)}
</div>
);
case "cpf":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{cpf ? formatCurrency(cpf) : "—"}
</div>
);
case "conversion_24h":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"}
</div>
);
case "conversion_48h":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"}
</div>
);
case "conversion_total":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"}
</div>
);
case "total_unsubs":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"}
</div>
);
case "unsub_percent":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"}
</div>
);
case "total_active":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"}
</div>
);
case "time_in_feed":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs text-muted-foreground">
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"}
</div>
);
case "invite_type":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem] text-xs">
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
</div>
);
case "invite_link":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem]">
{placement.invite_link ? (
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
{placement.invite_link.slice(8, 20)}
</code>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
);
case "post_link":
return (
<div key={column.id} className="py-2 px-3 flex items-center min-h-[1.75rem]">
{placement.placement_post?.post?.url ? (
<a
href={placement.placement_post.post.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Пост
</a>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
);
case "comment":
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "comment" ? (
<Input
aria-label="Комментарий"
autoFocus
className="h-7 w-full bg-background px-2 text-xs"
value={placement.comment || ""}
onChange={(e) =>
handleDraftChange(placement.id, {
comment: e.target.value ? e.target.value : null,
})
}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "comment" })
}
>
<span className="truncate flex-1">{placement.comment || "—"}</span>
{canEdit && <Pencil className="h-3 w-3 shrink-0 ml-1 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</div>
);
default:
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs italic">
Недоступно
</div>
);
}
};
return (
<div
key={placementId}
data-placement-id={placementId}
className="grid border-b last:border-0 hover:bg-muted/30"
style={{
gridTemplateColumns: columnConfigs.map((c) => getColumnStyle(c)).join(" "),
}}
>
{columnConfigs.map((column) => renderCell(column))}
</div>
);
}
function ChannelGroup({
channel,
placements,
channelNumber,
canEdit,
getPlacementView,
handleDraftChange,
editingCell,
setEditingCell,
expanded,
selectedPlacementIds,
onToggle,
onTogglePlacement,
visibleColumns,
onOpenCreativeSelect,
onClearCreative,
onOpenCreativeSend,
onAddPlacement,
creatives,
}: 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 (
<div className="mb-3">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg cursor-pointer hover:bg-muted transition-colors">
<Checkbox
checked={allSelected}
onCheckedChange={() => onToggle(channel.id, placementIds)}
onClick={(e) => e.stopPropagation()}
/>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
!isOpen && "-rotate-90"
)}
/>
<span className="text-xs font-mono text-muted-foreground bg-background px-1.5 py-0.5 rounded w-6 text-center">
{channelNumber}
</span>
<Avatar className="h-8 w-8">
<AvatarImage
src={channel.username ? `https://t.me/i/userpic/160/${channel.username}.jpg` : undefined}
alt={channel.title}
/>
<AvatarFallback className="text-sm">
{channel.title[0]}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium text-sm">{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">@{channel.username}</span>
)}
</div>
{channel.username && (
<div className="flex items-center gap-1 ml-2">
<a
href={`https://telemetr.me/content/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:opacity-75 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Image src="/icons/telemetr.jpg" width={20} height={20} alt="TgStat" className="h-5 w-5 rounded" />
</a>
<a
href={`https://tgstat.ru/channel/@${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:opacity-75 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Image src="/icons/tgstat.jpg" width={20} height={20} alt="TgStat" className="h-5 w-5 rounded" />
</a>
</div>
)}
<Badge variant="outline" className="ml-auto text-xs">
{placements.length}
</Badge>
<div className="flex items-center gap-3 text-xs text-muted-foreground ml-2">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{formatCompact(channel.subscribers_count)}
</span>
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{formatCompact(totalViews)}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 px-3 text-xs ml-4"
onClick={(e) => {
e.stopPropagation();
onAddPlacement?.(channel);
}}
title="Добавить размещение"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Добавить
</Button>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="overflow-x-auto">
<div className="w-full text-sm">
{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 (
<CellRenderer
key={placement.id}
placementId={placement.id}
columnConfigs={ALL_COLUMNS.filter((c) => visibleColumns.has(c.id))}
placement={viewPlacement}
idx={idx}
channelNumber={channelNumber}
canEdit={canEdit}
editingCell={editingCell}
setEditingCell={setEditingCell}
handleDraftChange={handleDraftChange}
effectiveCost={effectiveCost}
cpf={cpf}
cpm={cpm}
selectedPlacementIds={selectedPlacementIds}
onTogglePlacement={onTogglePlacement}
onOpenCreativeSelect={onOpenCreativeSelect}
onClearCreative={onClearCreative}
onOpenCreativeSend={onOpenCreativeSend}
creatives={creatives}
/>
);
})}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function PurchasePlanDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string;
const { projects, hasPermission, canViewAnalytics, canViewSubscriptions } = useWorkspace();
const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true);
const [project, setProject] = useState<Project | null>(null);
const [draftsByPlacementId, setDraftsByPlacementId] = useState<Record<string, PlacementInlineDraft>>({});
const [savingEdits, setSavingEdits] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingCell, setEditingCell] = useState<EditingCell | null>(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<Set<string>>(new Set());
const [showCopyDialog, setShowCopyDialog] = useState(false);
const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
const [copyingChannels, setCopyingChannels] = useState(false);
// Initialize visible columns based on permissions
const initialVisibleColumns = useMemo(() => {
const columns = new Set(DEFAULT_VISIBLE_COLUMNS);
// Hide subscription-related columns if user doesn't have analytics_read permission
if (!canViewSubscriptions) {
columns.delete("subscriptions");
columns.delete("cpf");
columns.delete("conversion_24h");
columns.delete("conversion_48h");
columns.delete("conversion_total");
columns.delete("total_unsubs");
columns.delete("unsub_percent");
columns.delete("total_active");
}
// Hide views and cpm if user has no analytics permissions at all
if (!canViewAnalytics) {
columns.delete("views");
columns.delete("cpm");
}
return columns;
}, [canViewSubscriptions, canViewAnalytics]);
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(initialVisibleColumns);
const [showColumnsDialog, setShowColumnsDialog] = useState(false);
// Creative selection dialog
const [showCreativeDialog, setShowCreativeDialog] = useState(false);
const [selectedPlacementForCreative, setSelectedPlacementForCreative] = useState<PlacementWithStats | null>(null);
const [creatives, setCreatives] = useState<Creative[]>([]);
// Placement wizard
const [wizardInitialCreative, setWizardInitialCreative] = useState<string | null>(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<PlacementFilters>({
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);
// Load creatives for the project
if (!isDemoMode) {
try {
const creativesResponse = await creativesApi.list(workspaceId, {
project_id: projectId,
include_archived: false,
size: 100,
});
setCreatives(creativesResponse.items);
} catch (err) {
console.error("Failed to load creatives:", err);
}
}
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
// Creative selection handlers
const handleOpenCreativeSelect = (placement: PlacementWithStats) => {
setSelectedPlacementForCreative(placement);
setShowCreativeDialog(true);
};
const handleClearCreative = (placementId: string) => {
handleDraftChange(placementId, { creative_id: null, creative_name: null });
};
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
if (!placement.creative_id || isDemoMode) return;
await toast.promise(
placementsApi.sendCreative(workspaceId, projectId, placement.id),
{
loading: "Отправка креатива...",
success: "Креатив успешно отправлен",
error: "Не удалось отправить креатив",
}
);
};
const handleSelectCreative = (creative: { id: string; name: string }) => {
if (selectedPlacementForCreative) {
handleDraftChange(selectedPlacementForCreative.id, {
creative_id: creative.id,
creative_name: creative.name,
});
}
};
const canEditPlacements = hasPermission("placements_write") && !isDemoMode;
const hasUnsavedChanges = Object.keys(draftsByPlacementId).length > 0;
const handleDraftChange = (placementId: string, patch: PlacementInlineDraft) => {
if (!canEditPlacements) return;
setDraftsByPlacementId((prev) => ({
...prev,
[placementId]: {
...(prev[placementId] || {}),
...patch,
},
}));
};
const getPlacementView = (placement: PlacementWithStats): PlacementWithStats => {
const draft = draftsByPlacementId[placement.id];
if (!draft) return placement;
const nextStatus = draft.status ?? placement.status;
const nextComment = draft.comment !== undefined ? draft.comment : placement.comment;
const nextPlacementPost = draft.post_status !== null && draft.post_status !== undefined && placement.placement_post
? { ...placement.placement_post, status: draft.post_status }
: placement.placement_post;
const nextDetails = {
...(placement.details || {
placement_at: null,
payment_at: null,
cost: null,
cost_before_bargain: null,
placement_type: null,
format: null,
comment: null,
creative_id: null,
}),
placement_at:
draft.placement_at !== undefined
? draft.placement_at
: placement.details?.placement_at ?? null,
payment_at:
draft.payment_at !== undefined
? draft.payment_at
: placement.details?.payment_at ?? null,
cost:
draft.cost !== undefined
? draft.cost
: placement.details?.cost ?? null,
cost_before_bargain:
draft.cost_before_bargain !== undefined
? draft.cost_before_bargain
: placement.details?.cost_before_bargain ?? null,
placement_type:
draft.placement_type !== undefined
? draft.placement_type
: placement.details?.placement_type ?? null,
format:
draft.originalFormat !== undefined
? draft.originalFormat
: 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;
await toast.promise(
(async () => {
for (const [placementId, draft] of entries) {
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest);
}
await loadPlacements();
})(),
{
loading: "Сохранение изменений...",
success: "Изменения сохранены",
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось сохранить изменения";
},
}
);
};
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;
await toast.promise(
(async () => {
for (const placementId of selectedIds) {
await placementsApi.deleteInProject(workspaceId, projectId, placementId);
}
await loadPlacements();
clearSelection();
})(),
{
loading: "Удаление размещений...",
success: () => `Удалено ${selectedIds.length} размещений`,
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось удалить размещения";
},
}
);
};
const handleCopyPlacements = async () => {
if (!targetProjectId) return;
const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id));
if (selectedPlacements.length === 0) return;
await toast.promise(
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,
})),
}),
{
loading: "Копирование размещений...",
success: () => {
setShowCopyDialog(false);
setTargetProjectId(null);
clearSelection();
return `Скопировано ${selectedPlacements.length} размещений`;
},
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось скопировать размещения";
},
}
);
};
const handleAddPlacement = async (channel: Placement["channel"]) => {
if (!canEditPlacements) return;
const result = await placementsApi.createInProject(workspaceId, projectId, {
creative_id: undefined,
channels: [{
channel_id: channel.id,
status: "Без статуса",
comment: undefined,
details: {},
}],
details: {},
});
toast.promise(Promise.resolve(result), {
loading: "Добавление размещения...",
success: () => {
const newPlacement = result.placements[0];
setPlacements((prev) => [...prev, newPlacement]);
setTimeout(() => {
const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`);
if (rowElement) {
rowElement.scrollIntoView({ behavior: "smooth", block: "center" });
setEditingCell({ placementId: newPlacement.id, field: "status" });
}
}, 100);
return `Размещение добавлено: ${channel.title}`;
},
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось добавить размещение";
},
});
};
const groupedPlacements = useMemo(() => {
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
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: number | null = null;
let bValue: number | null = null;
switch (filters.sort!.field) {
case "cost":
aValue = a.details?.cost?.value ?? null;
bValue = b.details?.cost?.value ?? null;
break;
case "date":
aValue = a.details?.placement_at ? new Date(a.details.placement_at).getTime() : null;
bValue = b.details?.placement_at ? new Date(b.details.placement_at).getTime() : null;
break;
case "cpm":
aValue = a.cpm ?? null;
bValue = b.cpm ?? null;
break;
case "cpf":
aValue = a.cpf ?? null;
bValue = b.cpf ?? null;
break;
case "subscribers":
aValue = a.placement_post?.subscriptions_count ?? null;
bValue = b.placement_post?.subscriptions_count ?? null;
break;
case "views":
aValue = a.placement_post?.views_count ?? null;
bValue = b.placement_post?.views_count ?? null;
break;
case "created":
aValue = new Date(a.created_at).getTime();
bValue = new Date(b.created_at).getTime();
break;
}
// Null values always at the end
if (aValue === null && bValue === null) return 0;
if (aValue === null) return 1;
if (bValue === null) return -1;
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 (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/purchase-plans` },
{ label: project?.title || "Проект" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/purchase-plans`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
План закупов проекта «{project?.title || "Проект"}»
</h1>
<p className="text-muted-foreground">
Список каналов, в которых планируем размещения для проекта
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button disabled={isDemoMode} onClick={() => setShowPlacementWizard(true)}>
<Plus className="h-4 w-4 mr-2" />
Добавить размещения
</Button>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Checkbox
id="select-all-channels"
checked={filteredGroups.length > 0 && filteredGroups.every((g) => g.placements.every((p) => selectedPlacementIds.has(p.id)))}
onCheckedChange={toggleAllChannels}
/>
<label htmlFor="select-all-channels" className="text-sm cursor-pointer">
{selectedPlacementIds.size > 0
? `Размещений: ${selectedPlacementIds.size}`
: "Выбрать все"}
</label>
</div>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск канала..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button
variant={hasActiveFilters ? "default" : "outline"}
size="sm"
onClick={() => setFiltersOpen(true)}
>
<Filter className="h-4 w-4 mr-1" />
Фильтры и сортировка
{hasActiveFilters && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowColumnsDialog(true)}
>
<BarChart3 className="h-4 w-4 mr-1" />
Колонки
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExpandedAll(!expandedAll)}
>
{expandedAll ? "Свернуть все" : "Развернуть все"}
</Button>
{selectedPlacementIds.size > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowCopyDialog(true)}>
<Copy className="h-4 w-4 mr-2" />
Добавить в другой проект
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDeletePlacements} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
<DropdownMenuItem onClick={clearSelection}>
<X className="h-4 w-4 mr-2" />
Отменить выбор
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Placements List */}
{filteredGroups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет размещений</h3>
<p className="text-muted-foreground text-center">
{placements.length === 0
? "Добавьте первое размещение для этого проекта"
: "Попробуйте изменить параметры поиска"}
</p>
</CardContent>
</Card>
) : (
<>
<PlacementsTableHeader
visibleColumns={visibleColumns}
sort={filters.sort || undefined}
onSort={(field) => {
setFilters((prev) => ({
...prev,
sort: {
field,
direction:
prev.sort?.field === field && prev.sort?.direction === "asc"
? "desc"
: "asc",
},
}));
}}
/>
<div className="space-y-1">
{filteredGroups.map((group, idx) => (
<ChannelGroup
key={group.channel.id}
channel={group.channel}
placements={group.placements}
channelNumber={idx + 1}
canEdit={canEditPlacements}
getPlacementView={getPlacementView}
handleDraftChange={handleDraftChange}
editingCell={editingCell}
setEditingCell={setEditingCell}
expanded={expandedAll}
selectedPlacementIds={selectedPlacementIds}
onToggle={(channelId, placementIds) => toggleChannelSelection(channelId, placementIds)}
onTogglePlacement={togglePlacementSelection}
visibleColumns={visibleColumns}
onOpenCreativeSelect={handleOpenCreativeSelect}
onClearCreative={handleClearCreative}
onOpenCreativeSend={handleOpenCreativeSend}
onAddPlacement={handleAddPlacement}
creatives={creatives}
/>
))}
</div>
</>
)}
</div>
{hasUnsavedChanges && (
<div className="fixed bottom-4 right-4 z-50">
<div className="rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleCancelEdits}
disabled={savingEdits}
>
Отменить
</Button>
<Button
type="button"
onClick={handleSaveEdits}
disabled={savingEdits}
>
{savingEdits ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</div>
{saveError && (
<div className="mt-2 text-xs text-destructive">
{saveError}
</div>
)}
</div>
</div>
)}
<Dialog open={showCopyDialog} onOpenChange={setShowCopyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Добавить в другой проект</DialogTitle>
<DialogDescription>
Выберите проект, в который хотите добавить {selectedPlacementIds.size} размещений
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium mb-2 block">Проект</label>
<Select
value={targetProjectId || ""}
onValueChange={(value) => setTargetProjectId(value)}
>
<SelectTrigger>
<SelectValue placeholder="Выберите проект" />
</SelectTrigger>
<SelectContent>
{projects.filter((p) => p.id !== projectId).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowCopyDialog(false)}>
Отмена
</Button>
<Button onClick={handleCopyPlacements} disabled={!targetProjectId || copyingChannels}>
{copyingChannels ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Копирование...
</>
) : (
"Добавить"
)}
</Button>
</div>
</DialogContent>
</Dialog>
<ColumnSelectorDialog
open={showColumnsDialog}
onOpenChange={setShowColumnsDialog}
visibleColumns={visibleColumns}
onVisibleColumnsChange={setVisibleColumns}
/>
<FiltersModal
open={filtersOpen}
onOpenChange={setFiltersOpen}
onApply={setFilters}
existingPlacements={placements}
initialFilters={filters}
/>
<CreativeSelectDialog
open={showCreativeDialog}
onOpenChange={setShowCreativeDialog}
creatives={creatives}
onSelect={handleSelectCreative}
onClear={selectedPlacementForCreative ? () => handleClearCreative(selectedPlacementForCreative.id) : null}
currentCreativeId={selectedPlacementForCreative?.creative_id}
/>
<PlacementWizard
open={showPlacementWizard}
onOpenChange={setShowPlacementWizard}
workspaceId={workspaceId}
projectId={projectId}
initialCreativeId={wizardInitialCreative}
onSuccess={() => {
loadPlacements();
showSuccessToast("Размещения созданы", "Успешно добавлено");
}}
/>
</>
);
}