Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-02-03 18:55:09 +03:00

1994 lines
73 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 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 { AddChannelDialog } from "@/components/dialog-add-channel";
import { CreatePlacementsDialog } from "@/components/dialog-create-placements";
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { ColumnSelectorDialog } from "@/components/column-selector-dialog";
import { CreativeSelectDialog } from "@/components/creative-select-dialog";
import { cn } from "@/lib/utils";
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,
} from "@/lib/config/placement-columns";
type PlacementInlineDraft = {
status?: PlacementStatus;
comment?: string | null;
creative_id?: string | null;
creative_name?: string | null;
placement_at?: string | null;
payment_at?: string | null;
cost?: { type: CostType; value: number } | null;
cost_before_bargain?: { type: CostType; value: number } | null;
placement_type?: PlacementType | null;
format?: string | null;
cpm?: number | null;
};
type EditableField = "status" | "placement_at" | "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 {
column: (typeof ALL_COLUMNS)[number];
placement: PlacementWithStats;
idx: number;
channelNumber: number;
canEdit: boolean;
editingCell: EditingCell | null;
setEditingCell: (cell: EditingCell | null) => void;
handleDraftChange: (placementId: string, patch: PlacementInlineDraft) => void;
effectiveCost: number;
cpf: number | null;
cpm: number | null;
selectedPlacementIds: Set<string>;
onTogglePlacement: (placementId: string) => void;
onOpenCreativeSelect?: (placement: PlacementWithStats) => void;
onClearCreative?: (placementId: string) => void;
onOpenCreativeSend?: (placement: PlacementWithStats) => void;
}
function CellRenderer({
column,
placement,
idx,
channelNumber,
canEdit,
editingCell,
setEditingCell,
handleDraftChange,
effectiveCost,
cpf,
cpm,
selectedPlacementIds,
onTogglePlacement,
onOpenCreativeSelect,
onClearCreative,
onOpenCreativeSend,
}: CellRendererProps) {
const isEditing = editingCell?.placementId === placement.id;
const getStatusColor = (status: PlacementStatus): string => {
switch (status) {
case "Оплачено":
return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
case "Согласование условий":
case "Ждём ответа":
return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
case "Отмена":
case "Не подходит цена":
case "Неактуально":
case "Не отвечает":
return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
case "Оплатить":
return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const toIsoFromDateInput = (value: string): string | null => {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
};
const toDateInputValue = (iso: string | null | undefined): string => {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatTime = (seconds: number | null | undefined): string => {
if (!seconds) return "—";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}д ${hours % 24}ч`;
}
return `${hours}ч ${minutes}м`;
};
// Render specific columns
switch (column.id) {
case "checkbox":
return (
<td key={column.id} className="py-2 px-3">
<Checkbox
checked={selectedPlacementIds.has(placement.id)}
onCheckedChange={() => onTogglePlacement(placement.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
);
case "number":
return (
<td 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>
</td>
);
case "status_deal":
return (
<td key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "status" ? (
<select
aria-label="Статус размещения"
className={cn(
"h-7 w-full rounded-md border border-input bg-background px-2 text-xs font-medium shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
getStatusColor(placement.status)
)}
value={placement.status}
onChange={(e) =>
handleDraftChange(placement.id, {
status: e.target.value as PlacementStatus,
})
}
onBlur={() => setEditingCell(null)}
>
{PLACEMENT_DEAL_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
) : (
<button
type="button"
disabled={!canEdit}
className={cn(
"group h-7 w-full rounded-md border px-2 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default flex items-center justify-between gap-2",
getStatusColor(placement.status)
)}
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "status" })
}
>
<span>{placement.status}</span>
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-70 transition-opacity" />
</button>
)}
</td>
);
case "status_post":
const postStatus = placement.placement_post?.status;
return (
<td key={column.id} className="py-2 px-3">
{postStatus ? (
<span className="text-xs px-2 py-1 rounded border inline-block">
{postStatus}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
);
case "creative":
const viewCreativeId = placement.creative_id;
const hasCreative = !!viewCreativeId;
return (
<td 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>
)}
</td>
);
case "invite_link":
return (
<td 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>
)}
</td>
);
case "post_link":
return (
<td 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>
)}
</td>
);
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 (
<td 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>
)}
</td>
);
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 (
<td 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>
)}
</td>
);
case "cost_before":
const costBefore = placement.details?.cost_before_bargain?.value;
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}
</td>
);
case "cost_format":
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
return (
<td 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>
)}
</td>
);
case "placement_type":
const placementType = placement.details?.placement_type;
return (
<td key={column.id} className="py-2 px-3">
{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}
</td>
);
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 (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{discount ? `${discount.toFixed(1)}%` : "—"}
</td>
);
case "payment_date":
return (
<td 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>
)}
</td>
);
case "planned_date":
return (
<td 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>
)}
</td>
);
case "actual_date":
return (
<td 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)
: "—"}
</td>
);
case "format":
return (
<td 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>
)}
</td>
);
case "time_top":
const timeOnTop = placement.placement_post?.time_on_top;
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatTime(timeOnTop)}
</td>
);
case "link_created":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatDate(placement.invite_link_created_at)}
</td>
);
case "post_deleted":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
</td>
);
case "subscriptions":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatNumber(placement.placement_post?.subscriptions_count)}
</td>
);
case "views":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatNumber(placement.placement_post?.views_count)}
</td>
);
case "cpf":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{cpf ? formatCurrency(cpf) : "—"}
</td>
);
case "conversion_24h":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"}
</td>
);
case "conversion_48h":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"}
</td>
);
case "conversion_total":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"}
</td>
);
case "total_unsubs":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"}
</td>
);
case "unsub_percent":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"}
</td>
);
case "total_active":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"}
</td>
);
case "time_in_feed":
return (
<td 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 : "—"}
</td>
);
case "invite_type":
return (
<td key={column.id} className="py-2 px-3">
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
</td>
);
case "comment":
return (
<td 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>
)}
</td>
);
default:
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs italic">
Недоступно
</td>
);
}
}
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/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()}
>
<img src="/icons/telemetr.jpg" 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()}
>
<img src="/icons/tgstat.jpg" 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">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
<th
key={column.id}
className={cn(
"py-2 px-3 font-medium text-muted-foreground",
column.editable && "cursor-help",
column.width || "w-auto"
)}
>
<div className="flex items-center gap-1.5">
{column.editable && (
<span
className="flex items-center gap-0.5"
title={column.partialEdit ? "Частично редактируемое" : "Редактируемое"}
>
<p className="flex items-center whitespace-nowrap">
{column.partialEdit ? (
<span className="text-[10px] opacity-40 flex items-center gap-0.5">(
<Pencil className="h-3 w-3 " />)</span>
) : (
<Pencil className="h-3 w-3 opacity-40" />)}
</p>
</span>
)}
<span>{column.label}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{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 (
<tr
key={placement.id}
data-placement-id={placement.id}
className="border-b last:border-0 hover:bg-muted/30"
>
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
<CellRenderer
key={column.id}
column={column}
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}
/>
))}
</tr>
);
})}
</tbody>
</table>
</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 } = 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);
// Create placements dialog
const prefilledCreativeId = searchParams.get("creative_id");
const [prefilledChannelIds, setPrefilledChannelIds] = useState<string[] | null>(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [expandedAll, setExpandedAll] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [selectedPlacementIds, setSelectedPlacementIds] = useState<Set<string>>(new Set());
const [showCopyDialog, setShowCopyDialog] = useState(false);
const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
const [copyingChannels, setCopyingChannels] = useState(false);
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(
new Set(DEFAULT_VISIBLE_COLUMNS)
);
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 }>>([]);
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]);
// Auto-open create dialog if creative_id is provided
useEffect(() => {
if (prefilledCreativeId && project) {
setShowCreateDialog(true);
}
}, [prefilledCreativeId, project]);
// Auto-open create dialog if channel_ids are provided
useEffect(() => {
if (prefilledChannelIds && project) {
setShowCreateDialog(true);
}
}, [prefilledChannelIds, project]);
useEffect(() => {
loadPlacements();
}, [workspaceId, projectId]);
const loadPlacements = async () => {
try {
setLoading(true);
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
const data = await api.getByProject(workspaceId, projectId);
const placementsWithCreativeNames = await Promise.all(
data.map(async (placement) => {
if (placement.creative_id && !placement.creative_name && !isDemoMode) {
try {
const creative = await creativesApi.get(workspaceId, placement.creative_id);
return { ...placement, creative_name: creative.name };
} catch (err) {
console.error("Failed to fetch creative name:", err);
return placement;
}
}
return placement;
})
);
setPlacements(placementsWithCreativeNames);
setDraftsByPlacementId({});
setSaveError(null);
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
// Creative selection handlers
const handleOpenCreativeSelect = (placement: PlacementWithStats) => {
setSelectedPlacementForCreative(placement);
setShowCreativeDialog(true);
};
const handleClearCreative = (placementId: string) => {
handleDraftChange(placementId, { creative_id: null, creative_name: null });
};
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
if (!placement.creative_id || isDemoMode) return;
try {
await placementsApi.sendCreative(workspaceId, projectId, placement.id);
alert("Креатив успешно отправлен");
} catch (err) {
console.error("Failed to send creative:", err);
alert("Не удалось отправить креатив");
}
};
const handleSelectCreative = (creative: { id: string; name: string }) => {
if (selectedPlacementForCreative) {
handleDraftChange(selectedPlacementForCreative.id, {
creative_id: creative.id,
creative_name: creative.name,
});
}
};
const canEditPlacements = hasPermission("placements_write") && !isDemoMode;
const hasUnsavedChanges = Object.keys(draftsByPlacementId).length > 0;
const handleDraftChange = (placementId: string, patch: PlacementInlineDraft) => {
if (!canEditPlacements) return;
setDraftsByPlacementId((prev) => ({
...prev,
[placementId]: {
...(prev[placementId] || {}),
...patch,
},
}));
};
const getPlacementView = (placement: PlacementWithStats): PlacementWithStats => {
const draft = draftsByPlacementId[placement.id];
if (!draft) return placement;
const nextStatus = draft.status ?? placement.status;
const nextComment = draft.comment !== undefined ? draft.comment : placement.comment;
const 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,
comment: nextComment,
creative_id: draft.creative_id ?? placement.creative_id,
creative_name: draft.creative_name ?? placement.creative_name,
details: nextDetails,
};
};
const handleCancelEdits = () => {
setDraftsByPlacementId({});
setSaveError(null);
setEditingCell(null);
};
const handleSaveEdits = async () => {
if (!canEditPlacements) return;
const entries = Object.entries(draftsByPlacementId);
if (entries.length === 0) return;
try {
setSavingEdits(true);
setSaveError(null);
for (const [placementId, draft] of entries) {
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest);
}
await loadPlacements();
setSavingEdits(false);
} catch (err: unknown) {
console.error("Failed to save placement edits:", err);
const message =
typeof err === "object" && err && "detail" in err
? String((err as any).detail)
: "Не удалось сохранить изменения";
setSaveError(message);
setSavingEdits(false);
}
};
const toggleChannelSelection = (channelId: string, placementIds: string[]) => {
const allSelected = placementIds.length > 0 && placementIds.every((id) => selectedPlacementIds.has(id));
setSelectedPlacementIds((prev) => {
const next = new Set(prev);
if (allSelected) {
placementIds.forEach((id) => next.delete(id));
} else {
placementIds.forEach((id) => next.add(id));
}
return next;
});
};
const toggleAllChannels = () => {
const allPlacementIds = new Set(filteredGroups.flatMap((g) => g.placements.map((p) => p.id)));
if (selectedPlacementIds.size === allPlacementIds.size) {
setSelectedPlacementIds(new Set());
} else {
setSelectedPlacementIds(allPlacementIds);
}
};
const clearSelection = () => {
setSelectedPlacementIds(new Set());
};
const handleDeletePlacements = async () => {
if (!canEditPlacements) return;
const selectedIds = Array.from(selectedPlacementIds);
if (selectedIds.length === 0) return;
try {
for (const placementId of selectedIds) {
await placementsApi.deleteInProject(workspaceId, projectId, placementId);
}
await loadPlacements();
clearSelection();
} catch (err) {
console.error("Failed to delete placements:", err);
}
};
const handleCopyPlacements = async () => {
if (!targetProjectId) return;
const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id));
if (selectedPlacements.length === 0) return;
try {
setCopyingChannels(true);
await placementsApi.createBulk(workspaceId, targetProjectId, {
creative_id: undefined,
channels: selectedPlacements.map((p) => ({
channel_id: p.channel.id,
status: String(p.status),
comment: p.comment || undefined,
details: p.details ? {
placement_at: p.details.placement_at || undefined,
payment_at: p.details.payment_at || undefined,
cost: p.details.cost || undefined,
cost_before_bargain: p.details.cost_before_bargain?.value || undefined,
placement_type: p.details.placement_type || undefined,
format: p.details.format || undefined,
creative_id: p.details.creative_id || undefined,
} : undefined,
})),
});
setShowCopyDialog(false);
setTargetProjectId(null);
clearSelection();
} catch (err) {
console.error("Failed to copy placements:", err);
} finally {
setCopyingChannels(false);
}
};
const handleAddPlacement = async (channel: Placement["channel"]) => {
if (!canEditPlacements) return;
try {
const result = await placementsApi.createInProject(workspaceId, projectId, {
creative_id: undefined,
channels: [{
channel_id: channel.id,
status: "Без статуса",
comment: undefined,
details: {},
}],
details: {},
});
// Extract the new placement from the response
const newPlacement = result.placements[0];
// Add the new placement to the list and switch to edit mode
setPlacements((prev) => [...prev, newPlacement]);
// Scroll to and focus the new placement after a short delay
setTimeout(() => {
const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`);
if (rowElement) {
rowElement.scrollIntoView({ behavior: "smooth", block: "center" });
// Set editing state for the first editable field
setEditingCell({ placementId: newPlacement.id, field: "status" });
}
}, 100);
} catch (err) {
console.error("Failed to add placement:", err);
}
};
const groupedPlacements = useMemo(() => {
const groups: Record<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">
<AddChannelDialog
workspaceId={workspaceId}
projectId={projectId}
existingPlacements={placements}
onSuccess={loadPlacements}
>
<Button variant="outline" disabled={isDemoMode}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
</AddChannelDialog>
<CreatePlacementsDialog
workspaceId={workspaceId}
projectId={projectId}
existingPlacements={placements}
prefilledCreativeId={prefilledCreativeId}
prefilledChannelIds={prefilledChannelIds}
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSuccess={loadPlacements}
>
<Button disabled={isDemoMode}>
<Plus className="h-4 w-4 mr-2" />
Создать размещения
</Button>
</CreatePlacementsDialog>
</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>
) : (
<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}
/>
</>
);
}