1370 lines
51 KiB
TypeScript
1370 lines
51 KiB
TypeScript
"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 { 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 type {
|
||
Project,
|
||
Placement,
|
||
PlacementWithStats,
|
||
PlacementsGroupedByChannel,
|
||
PlacementStatus,
|
||
PlacementPostStatus,
|
||
PlacementType,
|
||
CostType,
|
||
InviteLinkType,
|
||
UpdatePlacementInProjectRequest,
|
||
} from "@/lib/types/api";
|
||
import {
|
||
ALL_COLUMNS,
|
||
DEFAULT_VISIBLE_COLUMNS,
|
||
PLACEMENT_DEAL_STATUSES,
|
||
PLACEMENT_POST_STATUSES,
|
||
canEditPostStatus,
|
||
getPlacementStatusColor,
|
||
} 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>;
|
||
draftsByPlacementId: Record<string, PlacementInlineDraft>;
|
||
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;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Column Width Helper
|
||
// ============================================================================
|
||
|
||
function getGridStyle(visibleColumns: Set<string>) {
|
||
const columns = ALL_COLUMNS.filter((c) => visibleColumns.has(c.id));
|
||
const template = columns.map((c) => c.width || "1fr").join(" ");
|
||
return { gridTemplateColumns: template };
|
||
}
|
||
|
||
// ============================================================================
|
||
// Table Header Component
|
||
// ============================================================================
|
||
|
||
function TableHeader({ visibleColumns, isScrolled }: { visibleColumns: Set<string>; isScrolled: boolean }) {
|
||
const { gridTemplateColumns } = getGridStyle(visibleColumns);
|
||
const visibleList = ALL_COLUMNS.filter((c) => visibleColumns.has(c.id));
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"w-full grid overflow-hidden border bg-muted/30 transition-shadow",
|
||
isScrolled && "shadow-md"
|
||
)}
|
||
style={{ gridTemplateColumns }}
|
||
>
|
||
{visibleList.map((column) => (
|
||
<div
|
||
key={column.id}
|
||
className={cn(
|
||
"py-2 px-3 font-medium text-muted-foreground text-xs",
|
||
column.editable && "cursor-help"
|
||
)}
|
||
>
|
||
<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>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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>;
|
||
draftsByPlacementId: Record<string, PlacementInlineDraft>;
|
||
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,
|
||
draftsByPlacementId,
|
||
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 getCellClassName = () => {
|
||
return "py-2 px-3 flex items-center";
|
||
};
|
||
|
||
const getCellTextClassName = () => {
|
||
return "text-muted-foreground text-xs";
|
||
};
|
||
|
||
// Render specific columns
|
||
switch (column.id) {
|
||
case "checkbox":
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
<div className="flex items-center justify-center">
|
||
<Checkbox
|
||
checked={selectedPlacementIds.has(placement.id)}
|
||
onCheckedChange={() => onTogglePlacement(placement.id)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
case "number":
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
<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={getCellClassName()}>
|
||
<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={getCellClassName()}>
|
||
<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={cn(getCellClassName(), "flex items-center")}>
|
||
{hasCreative ? (
|
||
<div className="flex items-center gap-1 w-full">
|
||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded flex-1 min-w-0">
|
||
<span className="text-xs font-medium truncate">{placement.creative_name || "Без названия"}</span>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary rounded shrink-0"
|
||
title="Отправить готовый креатив"
|
||
onClick={() => {
|
||
if (viewCreativeId) {
|
||
onOpenCreativeSend?.(placement);
|
||
}
|
||
}}
|
||
>
|
||
<Send className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive rounded shrink-0"
|
||
title="Очистить креатив"
|
||
onClick={() => {
|
||
if (viewCreativeId) {
|
||
onClearCreative?.(placement.id);
|
||
}
|
||
}}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground hover: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={cn(getCellClassName(), "flex items-center")}>
|
||
{placement.invite_link ? (
|
||
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate block w-full">
|
||
{placement.invite_link.slice(8, 20)}…
|
||
</code>
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case "post_link":
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
{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;
|
||
|
||
const calculatedCost = costType === "cpm" ? (viewsCount > 0 ? (costValue * viewsCount) / 1000 : null) : costValue;
|
||
|
||
const canEditCost = canEdit && costType === "fixed";
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
{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":
|
||
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={getCellClassName()}>
|
||
{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={cn(getCellClassName(), getCellTextClassName())}>
|
||
{isEditing && editingCell?.field === "cost_before" ? (
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
autoFocus
|
||
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
||
defaultValue={costBefore || 0}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
if (!raw) {
|
||
handleDraftChange(placement.id, { cost_before_bargain: null });
|
||
return;
|
||
}
|
||
const value = Number(raw);
|
||
if (Number.isNaN(value)) return;
|
||
handleDraftChange(placement.id, {
|
||
cost_before_bargain: { type: "fixed", 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: "cost_before" })
|
||
}
|
||
>
|
||
<span className="font-medium">{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}</span>
|
||
{canEdit && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case "cost_format":
|
||
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
{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={getCellClassName()}>
|
||
{isEditing && editingCell?.field === "placement_type" ? (
|
||
<Select
|
||
value={placementType || "standard"}
|
||
onValueChange={(value: PlacementType) => {
|
||
handleDraftChange(placement.id, {
|
||
placement_type: value,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 w-full bg-background px-2 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="standard">Стандартный</SelectItem>
|
||
<SelectItem value="self_promo">Взаимный пиар</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className="group inline-flex min-h-[1.75rem] w-full items-center justify-between rounded px-2 text-xs text-left hover:bg-muted/50 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_type" })
|
||
}
|
||
>
|
||
<span>{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}</span>
|
||
{canEdit && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case "discount":
|
||
const costBeforeBargain = placement.details?.cost_before_bargain?.value;
|
||
const cost = placement.details?.cost?.value;
|
||
const discount = costBeforeBargain && cost && costBeforeBargain > 0 ? ((costBeforeBargain - cost) / costBeforeBargain) * 100 : null;
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{discount ? `${discount.toFixed(1)}%` : "—"}
|
||
</div>
|
||
);
|
||
|
||
case "payment_date":
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
{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={getCellClassName()}>
|
||
{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={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{placement.placement_post?.post?.published_at
|
||
? formatDate(placement.placement_post.post.published_at)
|
||
: "—"}
|
||
</div>
|
||
);
|
||
|
||
case "format":
|
||
const formatOptions = [
|
||
{ value: "1/24", label: "24 часа" },
|
||
{ value: "1/48", label: "48 часов" },
|
||
{ value: "1/72", label: "72 часа" },
|
||
{ value: "1/(7 дней)", label: "7 дней" },
|
||
{ value: "1/(30 дней)", label: "30 дней" },
|
||
{ value: "1/(без удаления)", label: "Без удаления" },
|
||
{ value: "2/24", label: "2×24 часа" },
|
||
{ value: "2/48", label: "2×48 часов" },
|
||
{ value: "2/72", label: "2×72 часа" },
|
||
];
|
||
const currentFormat = placement.details?.format || "";
|
||
const customFormatDraft = draftsByPlacementId[placement.id]?.format;
|
||
const formatValue = customFormatDraft !== undefined ? customFormatDraft : currentFormat;
|
||
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "flex items-center")}>
|
||
<div className="flex items-center gap-1 w-full min-w-0">
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className={cn(
|
||
"flex-1 min-w-0 text-left text-xs px-2 py-1 rounded bg-muted/30 hover:bg-muted/50 truncate block",
|
||
!formatValue && "text-muted-foreground"
|
||
)}
|
||
onClick={() =>
|
||
canEdit && setEditingCell({ placementId: placement.id, field: "format" })
|
||
}
|
||
>
|
||
{formatValue || "—"}
|
||
</button>
|
||
{canEdit && (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0 shrink-0 rounded"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Формат размещения</DialogTitle>
|
||
<DialogDescription>
|
||
Срок, на который пост останется на канале до удаления
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid gap-2 py-4">
|
||
{formatOptions.map((opt) => (
|
||
<Button
|
||
key={opt.value}
|
||
variant={formatValue === opt.value ? "default" : "outline"}
|
||
className="justify-start h-10"
|
||
onClick={() => {
|
||
handleDraftChange(placement.id, { format: opt.value });
|
||
}}
|
||
>
|
||
<span className="font-mono mr-2 text-muted-foreground">{opt.value}</span>
|
||
{opt.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div className="border-t pt-4">
|
||
<label className="text-sm font-medium mb-2 block">
|
||
Свой формат
|
||
</label>
|
||
<Input
|
||
className="h-9"
|
||
placeholder="напр: 3/24 или 1/(14 дней)"
|
||
value={formatValue}
|
||
onChange={(e) =>
|
||
handleDraftChange(placement.id, {
|
||
format: e.target.value ? e.target.value : null,
|
||
})
|
||
}
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Укажите количество постов и срок через слеш, например: X/24, X/48, X/(7 дней)
|
||
</p>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
case "time_top":
|
||
const timeOnTop = placement.placement_post?.time_on_top;
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{formatTime(timeOnTop)}
|
||
</div>
|
||
);
|
||
|
||
case "link_created":
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{formatDate(placement.invite_link_created_at)}
|
||
</div>
|
||
);
|
||
|
||
case "post_deleted":
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
|
||
</div>
|
||
);
|
||
|
||
case "subscriptions":
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{formatNumber(placement.placement_post?.subscriptions_count)}
|
||
</div>
|
||
);
|
||
|
||
case "views":
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{formatNumber(placement.placement_post?.views_count)}
|
||
</div>
|
||
);
|
||
|
||
case "cpf":
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{cpf ? formatCurrency(cpf) : "—"}
|
||
</div>
|
||
);
|
||
|
||
case "conversion_24h":
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "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={cn(getCellClassName(), "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={cn(getCellClassName(), "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={cn(getCellClassName(), "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={cn(getCellClassName(), "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={cn(getCellClassName(), "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={cn(getCellClassName(), "text-muted-foreground text-xs")}>
|
||
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"}
|
||
</div>
|
||
);
|
||
|
||
case "invite_type":
|
||
const inviteType = placement.invite_link_type;
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
{isEditing && editingCell?.field === "invite_type" ? (
|
||
<Select
|
||
value={inviteType || "public"}
|
||
onValueChange={(value: InviteLinkType) => {
|
||
handleDraftChange(placement.id, {
|
||
invite_link_type: value,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 w-full bg-background px-2 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="public">Открытая</SelectItem>
|
||
<SelectItem value="approval">С заявками</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: "invite_type" })
|
||
}
|
||
>
|
||
<span>{inviteType === "approval" ? "С заявками" : "Открытая"}</span>
|
||
{canEdit && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case "comment":
|
||
return (
|
||
<div key={column.id} className={getCellClassName()}>
|
||
{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] w-full items-center justify-between rounded px-2 py-1 text-left text-xs 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: "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 text-muted-foreground" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
default:
|
||
return (
|
||
<div key={column.id} className={cn(getCellClassName(), "text-muted-foreground text-xs italic")}>
|
||
Недоступно
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
function ChannelGroup({
|
||
channel,
|
||
placements,
|
||
channelNumber,
|
||
canEdit,
|
||
getPlacementView,
|
||
handleDraftChange,
|
||
editingCell,
|
||
setEditingCell,
|
||
expanded,
|
||
selectedPlacementIds,
|
||
draftsByPlacementId,
|
||
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-1.5 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="w-full overflow-hidden rounded-lg border border-t-0 bg-muted/10"
|
||
style={{ display: "grid", ...getGridStyle(visibleColumns) }}
|
||
>
|
||
{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 (
|
||
<div
|
||
key={placement.id}
|
||
data-placement-id={placement.id}
|
||
className="contents"
|
||
>
|
||
{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}
|
||
draftsByPlacementId={draftsByPlacementId}
|
||
onTogglePlacement={onTogglePlacement}
|
||
onOpenCreativeSelect={onOpenCreativeSelect}
|
||
onClearCreative={onClearCreative}
|
||
onOpenCreativeSend={onOpenCreativeSend}
|
||
/>
|
||
))}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
);
|
||
}
|
||
<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}
|
||
/>
|
||
</>
|
||
);
|
||
}
|