Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-02-10 11:42:17 +03:00

1370 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Placements Detail Page - All placements grouped by channel
// ============================================================================
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import {
Loader2,
Plus,
ArrowLeft,
ChevronDown,
ChevronRight,
Search,
Filter,
Eye,
Users,
ExternalLink,
CircleDollarSign,
TrendingUp,
RefreshCw,
Loader2 as LoaderIcon,
BarChart3,
ArrowUpDown,
X,
MoreVertical,
Trash2,
Copy,
Pencil,
Send,
Check,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi, creativesApi } from "@/lib/api";
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
import { 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}
/>
</>
);
}