Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-03-03 21:35:10 +03:00

1986 lines
72 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,
} 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 { 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,
} 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;
cpm?: number | null;
};
type EditableField = "status" | "post_status" | "placement_at" | "payment_at" | "cost" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name";
const FIXED_COST_TYPE: CostType = "fixed";
interface EditingCell {
placementId: string;
field: EditableField;
}
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatCompact = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toString();
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const toDateInputValue = (iso: string | null | undefined): string => {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const toIsoFromDateInput = (value: string): string | null => {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
};
const getStatusColor = (status: PlacementStatus): string => {
switch (status) {
case "Оплачено":
return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
case "Согласование условий":
case "Ждём ответа":
return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
case "Отмена":
case "Не подходит цена":
case "Неактуально":
case "Не отвечает":
return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
case "Оплатить":
return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
// ============================================================================
// Components
// ============================================================================
interface ChannelGroupProps {
channel: Placement["channel"];
placements: PlacementWithStats[];
channelNumber: number;
canEdit: boolean;
getPlacementView: (placement: PlacementWithStats) => PlacementWithStats;
handleDraftChange: (placementId: string, patch: PlacementInlineDraft) => void;
editingCell: EditingCell | null;
setEditingCell: (cell: EditingCell | null) => void;
expanded: boolean;
selectedPlacementIds: Set<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;
}
// ============================================================================
// 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;
}
function CellRenderer({
placementId,
columnConfigs,
placement,
idx,
channelNumber,
canEdit,
editingCell,
setEditingCell,
handleDraftChange,
effectiveCost,
cpf,
cpm,
selectedPlacementIds,
onTogglePlacement,
onOpenCreativeSelect,
onClearCreative,
onOpenCreativeSend,
}: CellRendererProps) {
const isEditing = editingCell?.placementId === placement.id;
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const toIsoFromDateInput = (value: string): string | null => {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
};
const toDateInputValue = (iso: string | null | undefined): string => {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatTime = (seconds: number | null | undefined): string => {
if (!seconds) return "—";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}д ${hours % 24}ч`;
}
return `${hours}ч ${minutes}м`;
};
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;
return (
<div key={column.id} className="py-2 px-3">
{hasCreative ? (
<div className="flex items-center gap-1">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded ">
<span className="text-xs font-medium truncate max-w-[100px]">{placement.creative_name || "Без названия"}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary rounded"
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"
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:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded px-2 py-1"
onClick={() => {
if (canEdit) {
onOpenCreativeSelect?.(placement);
}
}}
>
<span>Выбрать креатив</span>
<span className="text-primary font-medium">+</span>
</button>
)}
</div>
);
case "invite_link":
return (
<div key={column.id} className="py-2 px-3">
{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-muted-foreground"></span>
)}
</div>
);
case "post_link":
return (
<div key={column.id} className="py-2 px-3">
{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-muted-foreground"></span>
)}
</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 hover:underline 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 hover:underline 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;
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}
</div>
);
case "cost_format":
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cost_format" ? (
<Select
value={costFormat}
onValueChange={(value: CostType) => {
handleDraftChange(placement.id, {
cost: { type: value, value: placement.details?.cost?.value || 0 },
});
}}
>
<SelectTrigger className="h-7 w-[80px] bg-background px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed">Фикс</SelectItem>
<SelectItem value="cpm">CPM</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 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "cost_format" })
}
>
<span>{costFormat === "cpm" ? "CPM" : "Фикс"}</span>
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</div>
);
case "placement_type":
const placementType = placement.details?.placement_type;
return (
<div key={column.id} className="py-2 px-3">
{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}
</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 text-muted-foreground text-xs">
{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 hover:underline 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 hover:underline 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 text-muted-foreground text-xs">
{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">
{isEditing && editingCell?.field === "format" ? (
<Input
autoFocus
className="h-7 min-w-[140px] bg-background px-2 text-xs"
defaultValue={placement.details?.format || ""}
onChange={(e) =>
handleDraftChange(placement.id, {
format: 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-0 text-left text-xs text-muted-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "format" })
}
>
<span className="truncate w-full">{placement.details?.format || "—"}</span>
<Pencil className="h-3 w-3 shrink-0 ml-1 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</div>
);
case "time_top":
const timeOnTop = placement.placement_post?.time_on_top;
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatTime(timeOnTop)}
</div>
);
case "link_created":
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatDate(placement.invite_link_created_at)}
</div>
);
case "post_deleted":
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
</div>
);
case "subscriptions":
const subsCountValue = placement.placement_post?.subscriptions_count;
// Show "—" if subscriptions are hidden due to permissions (0 means hidden)
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{showSubs}
</div>
);
case "views":
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatNumber(placement.placement_post?.views_count)}
</div>
);
case "cpf":
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{cpf ? formatCurrency(cpf) : "—"}
</div>
);
case "conversion_24h":
return (
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(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 text-muted-foreground text-xs">
{(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 text-muted-foreground text-xs">
{(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 text-muted-foreground text-xs">
{(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 text-muted-foreground text-xs">
{(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 text-muted-foreground text-xs">
{(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 text-muted-foreground text-xs">
{(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">
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
</div>
);
case "comment":
return (
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "comment" ? (
<Input
aria-label="Комментарий"
autoFocus
className="h-7 min-w-[240px] 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] max-w-[240px] items-center justify-between rounded px-0 text-left text-xs text-muted-foreground hover:underline 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 w-full">{placement.comment || "—"}</span>
<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,
}: 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}
/>
);
})}
</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<Array<{ id: string; name: string; thumbnail_url?: string | null }>>([]);
// 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);
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
// Creative selection handlers
const handleOpenCreativeSelect = (placement: PlacementWithStats) => {
setSelectedPlacementForCreative(placement);
setShowCreativeDialog(true);
};
const handleClearCreative = (placementId: string) => {
handleDraftChange(placementId, { creative_id: null, creative_name: null });
};
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
if (!placement.creative_id || isDemoMode) return;
await toast.promise(
placementsApi.sendCreative(workspaceId, projectId, placement.id),
{
loading: "Отправка креатива...",
success: "Креатив успешно отправлен",
error: "Не удалось отправить креатив",
}
);
};
const handleSelectCreative = (creative: { id: string; name: string }) => {
if (selectedPlacementForCreative) {
handleDraftChange(selectedPlacementForCreative.id, {
creative_id: creative.id,
creative_name: creative.name,
});
}
};
const canEditPlacements = hasPermission("placements_write") && !isDemoMode;
const hasUnsavedChanges = Object.keys(draftsByPlacementId).length > 0;
const handleDraftChange = (placementId: string, patch: PlacementInlineDraft) => {
if (!canEditPlacements) return;
setDraftsByPlacementId((prev) => ({
...prev,
[placementId]: {
...(prev[placementId] || {}),
...patch,
},
}));
};
const getPlacementView = (placement: PlacementWithStats): PlacementWithStats => {
const draft = draftsByPlacementId[placement.id];
if (!draft) return placement;
const nextStatus = draft.status ?? placement.status;
const nextComment = draft.comment !== undefined ? draft.comment : placement.comment;
const nextPlacementPost = draft.post_status !== null && draft.post_status !== undefined && placement.placement_post
? { ...placement.placement_post, status: draft.post_status }
: placement.placement_post;
const nextDetails = {
...(placement.details || {
placement_at: null,
payment_at: null,
cost: null,
cost_before_bargain: null,
placement_type: null,
format: null,
comment: null,
creative_id: null,
}),
placement_at:
draft.placement_at !== undefined
? draft.placement_at
: placement.details?.placement_at ?? null,
payment_at:
draft.payment_at !== undefined
? draft.payment_at
: placement.details?.payment_at ?? null,
cost:
draft.cost !== undefined
? draft.cost
: placement.details?.cost ?? null,
cost_before_bargain:
draft.cost_before_bargain !== undefined
? draft.cost_before_bargain
: placement.details?.cost_before_bargain ?? null,
placement_type:
draft.placement_type !== undefined
? draft.placement_type
: placement.details?.placement_type ?? null,
format:
draft.format !== undefined
? draft.format
: placement.details?.format ?? null,
creative_id:
draft.creative_id !== undefined
? draft.creative_id
: placement.details?.creative_id ?? null,
};
return {
...placement,
status: nextStatus,
placement_post: nextPlacementPost,
comment: nextComment,
creative_id: draft.creative_id ?? placement.creative_id,
creative_name: draft.creative_name ?? placement.creative_name,
details: nextDetails,
};
};
const handleCancelEdits = () => {
setDraftsByPlacementId({});
setSaveError(null);
setEditingCell(null);
};
const handleSaveEdits = async () => {
if (!canEditPlacements) return;
const entries = Object.entries(draftsByPlacementId);
if (entries.length === 0) return;
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 = 0;
let bValue = 0;
switch (filters.sort!.field) {
case "cost":
aValue = a.details?.cost?.value || 0;
bValue = b.details?.cost?.value || 0;
break;
case "date":
aValue = a.details?.placement_at ? new Date(a.details.placement_at).getTime() : 0;
bValue = b.details?.placement_at ? new Date(b.details.placement_at).getTime() : 0;
break;
case "cpm":
aValue = a.cpm || 0;
bValue = b.cpm || 0;
break;
case "cpf":
aValue = a.cpf || 0;
bValue = b.cpf || 0;
break;
case "subscribers":
aValue = a.placement_post?.subscriptions_count || 0;
bValue = b.placement_post?.subscriptions_count || 0;
break;
case "views":
aValue = a.placement_post?.views_count || 0;
bValue = b.placement_post?.views_count || 0;
break;
case "created":
aValue = new Date(a.created_at).getTime();
bValue = new Date(b.created_at).getTime();
break;
}
return filters.sort!.direction === "asc"
? aValue - bValue
: bValue - aValue;
}),
}));
}
return filtered;
}, [groupedPlacements, filters]);
const canWrite = hasPermission("placements_write");
const hasActiveFilters = filters.channelFilters.name.value ||
filters.channelFilters.username.value ||
filters.channelFilters.subscribersMin !== null ||
filters.channelFilters.subscribersMax !== null ||
filters.placementFilters.statuses.length > 0 ||
filters.placementFilters.postStatuses.length > 0 ||
filters.placementFilters.hasCreative !== null ||
filters.placementFilters.costMin !== null ||
filters.placementFilters.costMax !== null ||
filters.placementFilters.costFormat !== null ||
filters.placementFilters.placementType !== null ||
filters.placementFilters.dateFrom ||
filters.placementFilters.dateTo ||
filters.sort;
if (loading) {
return (
<>
<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}
/>
))}
</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("Размещения созданы", "Успешно добавлено");
}}
/>
</>
);
}