feat: took header out

This commit is contained in:
ivannoskov
2026-03-03 21:35:10 +03:00
parent 9b509df12f
commit ef903f1b23
3 changed files with 348 additions and 210 deletions

View File

@@ -38,6 +38,7 @@ import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi, creativesApi } from "@/lib/api";
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
import { PlacementsTableHeader } from "@/components/placements-table-header";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -103,6 +104,7 @@ import {
PLACEMENT_POST_STATUSES,
canEditPostStatus,
getPlacementStatusColor,
getColumnStyle,
} from "@/lib/config/placement-columns";
type PlacementInlineDraft = {
@@ -230,7 +232,8 @@ interface ChannelGroupProps {
// ============================================================================
interface CellRendererProps {
column: (typeof ALL_COLUMNS)[number];
placementId: string;
columnConfigs: (typeof ALL_COLUMNS)[number][];
placement: PlacementWithStats;
idx: number;
channelNumber: number;
@@ -249,7 +252,8 @@ interface CellRendererProps {
}
function CellRenderer({
column,
placementId,
columnConfigs,
placement,
idx,
channelNumber,
@@ -320,31 +324,31 @@ function CellRenderer({
return `${hours}ч ${minutes}м`;
};
// Render specific columns
const renderCell = (column: (typeof ALL_COLUMNS)[number]) => {
switch (column.id) {
case "checkbox":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
<Checkbox
checked={selectedPlacementIds.has(placement.id)}
onCheckedChange={() => onTogglePlacement(placement.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
</div>
);
case "number":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{channelNumber}.{idx + 1}
</span>
</td>
</div>
);
case "status_deal":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
<PlacementStatusSelector
status={placement.status}
isEditing={isEditing && editingCell?.field === "status"}
@@ -353,13 +357,13 @@ function CellRenderer({
onChange={(s) => handleDraftChange(placement.id, { status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</td>
</div>
);
case "status_post":
const postStatus = placement.placement_post?.status;
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
<PlacementPostStatusSelector
status={postStatus ?? null}
isEditing={isEditing && editingCell?.field === "post_status"}
@@ -368,14 +372,14 @@ function CellRenderer({
onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</td>
</div>
);
case "creative":
const viewCreativeId = placement.creative_id;
const hasCreative = !!viewCreativeId;
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{hasCreative ? (
<div className="flex items-center gap-1">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded ">
@@ -422,12 +426,12 @@ function CellRenderer({
<span className="text-primary font-medium">+</span>
</button>
)}
</td>
</div>
);
case "invite_link":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{placement.invite_link ? (
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
{placement.invite_link.slice(8, 20)}
@@ -435,12 +439,12 @@ function CellRenderer({
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</div>
);
case "post_link":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{placement.placement_post?.post?.url ? (
<a
href={placement.placement_post.post.url}
@@ -453,7 +457,7 @@ function CellRenderer({
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</div>
);
case "cost":
@@ -468,7 +472,7 @@ function CellRenderer({
const canEditCost = canEdit && costType === "fixed";
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cost" ? (
<Input
type="number"
@@ -504,7 +508,7 @@ function CellRenderer({
{costType === "fixed" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</td>
</div>
);
case "cpm":
@@ -518,7 +522,7 @@ function CellRenderer({
const canEditCpm = canEdit && cpmType === "cpm";
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cpm" ? (
<Input
type="number"
@@ -551,21 +555,21 @@ function CellRenderer({
{cpmType === "cpm" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
</button>
)}
</td>
</div>
);
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">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}
</td>
</div>
);
case "cost_format":
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "cost_format" ? (
<Select
value={costFormat}
@@ -596,15 +600,15 @@ function CellRenderer({
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</td>
</div>
);
case "placement_type":
const placementType = placement.details?.placement_type;
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}
</td>
</div>
);
case "discount":
@@ -612,14 +616,14 @@ function CellRenderer({
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">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{discount ? `${discount.toFixed(1)}%` : "—"}
</td>
</div>
);
case "payment_date":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "payment_at" ? (
<Input
type="date"
@@ -646,12 +650,12 @@ function CellRenderer({
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</td>
</div>
);
case "planned_date":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "placement_at" ? (
<Input
type="date"
@@ -678,21 +682,21 @@ function CellRenderer({
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</td>
</div>
);
case "actual_date":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{placement.placement_post?.post?.published_at
? formatDate(placement.placement_post.post.published_at)
: "—"}
</td>
</div>
);
case "format":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "format" ? (
<Input
autoFocus
@@ -718,29 +722,29 @@ function CellRenderer({
<Pencil className="h-3 w-3 shrink-0 ml-1 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</td>
</div>
);
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">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatTime(timeOnTop)}
</td>
</div>
);
case "link_created":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatDate(placement.invite_link_created_at)}
</td>
</div>
);
case "post_deleted":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
</td>
</div>
);
case "subscriptions":
@@ -748,84 +752,84 @@ function CellRenderer({
// Show "—" if subscriptions are hidden due to permissions (0 means hidden)
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{showSubs}
</td>
</div>
);
case "views":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatNumber(placement.placement_post?.views_count)}
</td>
</div>
);
case "cpf":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{cpf ? formatCurrency(cpf) : "—"}
</td>
</div>
);
case "conversion_24h":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"}
</td>
</div>
);
case "conversion_48h":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"}
</td>
</div>
);
case "conversion_total":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"}
</td>
</div>
);
case "total_unsubs":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"}
</td>
</div>
);
case "unsub_percent":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"}
</td>
</div>
);
case "total_active":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"}
</td>
</div>
);
case "time_in_feed":
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"}
</td>
</div>
);
case "invite_type":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
</td>
</div>
);
case "comment":
return (
<td key={column.id} className="py-2 px-3">
<div key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "comment" ? (
<Input
aria-label="Комментарий"
@@ -852,16 +856,30 @@ function CellRenderer({
<Pencil className="h-3 w-3 shrink-0 ml-1 opacity-0 group-hover:opacity-50 transition-opacity" />
</button>
)}
</td>
</div>
);
default:
return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs italic">
<div key={column.id} className="py-2 px-3 text-muted-foreground text-xs italic">
Недоступно
</td>
</div>
);
}
};
return (
<div
key={placementId}
data-placement-id={placementId}
className="grid border-b last:border-0 hover:bg-muted/30"
style={{
gridTemplateColumns: columnConfigs.map((c) => getColumnStyle(c)).join(" "),
}}
>
{columnConfigs.map((column) => renderCell(column))}
</div>
);
}
function ChannelGroup({
@@ -987,40 +1005,7 @@ function ChannelGroup({
</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>
<div className="w-full text-sm">
{placements.map((placement, idx) => {
const viewPlacement = getPlacementView(placement);
const effectiveCost = viewPlacement.details?.cost?.value || 0;
@@ -1033,15 +1018,10 @@ function ChannelGroup({
: 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}
key={placement.id}
placementId={placement.id}
columnConfigs={ALL_COLUMNS.filter((c) => visibleColumns.has(c.id))}
placement={viewPlacement}
idx={idx}
channelNumber={channelNumber}
@@ -1058,12 +1038,9 @@ function ChannelGroup({
onClearCreative={onClearCreative}
onOpenCreativeSend={onOpenCreativeSend}
/>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</CollapsibleContent>
</Collapsible>
@@ -1843,6 +1820,23 @@ export default function PurchasePlanDetailPage() {
</CardContent>
</Card>
) : (
<>
<PlacementsTableHeader
visibleColumns={visibleColumns}
sort={filters.sort || undefined}
onSort={(field) => {
setFilters((prev) => ({
...prev,
sort: {
field,
direction:
prev.sort?.field === field && prev.sort?.direction === "asc"
? "desc"
: "asc",
},
}));
}}
/>
<div className="space-y-1">
{filteredGroups.map((group, idx) => (
<ChannelGroup
@@ -1867,6 +1861,7 @@ export default function PurchasePlanDetailPage() {
/>
))}
</div>
</>
)}
</div>

View File

@@ -0,0 +1,126 @@
"use client";
import { ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { ALL_COLUMNS, getColumnStyle, type ColumnConfig } from "@/lib/config/placement-columns";
interface SortOption {
field: string;
direction: "asc" | "desc";
}
interface PlacementsTableHeaderProps {
visibleColumns: Set<string>;
sort?: SortOption | null;
onSort?: (field: string) => void;
}
const SORT_FIELD_TO_COLUMN: Record<string, string> = {
cost: "cost",
date: "planned_date",
cpm: "cpm",
cpf: "cpf",
subscribers: "subscriptions",
views: "views",
created: "created",
};
export function PlacementsTableHeader({
visibleColumns,
sort,
onSort,
}: PlacementsTableHeaderProps) {
const visibleColumnConfigs = ALL_COLUMNS.filter((c) =>
visibleColumns.has(c.id)
);
const handleSort = (column: ColumnConfig) => {
if (!column.sortable || !onSort) return;
const sortField = Object.entries(SORT_FIELD_TO_COLUMN).find(
([, colId]) => colId === column.id
)?.[0];
if (sortField) {
onSort(sortField);
}
};
const getSortDirection = (column: ColumnConfig): "asc" | "desc" | null => {
if (!sort || !column.sortable) return null;
const sortField = Object.entries(SORT_FIELD_TO_COLUMN).find(
([, colId]) => colId === column.id
)?.[0];
if (sortField && sort.field === sortField) {
return sort.direction;
}
return null;
};
return (
<div
className="sticky top-0 z-10 bg-background border-b"
>
<div
className="grid text-sm"
style={{
gridTemplateColumns: visibleColumnConfigs
.map((c) => getColumnStyle(c))
.join(" "),
}}
>
{visibleColumnConfigs.map((column) => {
const sortDir = getSortDirection(column);
return (
<div
key={column.id}
className={cn(
"py-2 px-3 font-medium text-muted-foreground flex items-center gap-1.5",
column.sortable && "cursor-pointer hover:text-foreground transition-colors"
)}
onClick={() => handleSort(column)}
title={column.sortable ? "Нажмите для сортировки" : undefined}
>
{column.editable && (
<span
className="flex items-center gap-0.5"
title={column.partialEdit ? "Частично редактируемое" : "Редактируемое"}
>
<span className="text-[10px] opacity-40 flex items-center gap-0.5">
(
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
</span>
</span>
)}
<span className="whitespace-nowrap">{column.label}</span>
{column.sortable && (
<ArrowUpDown
className={cn(
"h-3 w-3 opacity-40",
sortDir && "opacity-100 text-primary"
)}
/>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,12 +1,29 @@
import type { PlacementStatus, PlacementPostStatus, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
export type ColumnSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
export const COLUMN_SIZE_STYLES: Record<ColumnSize, string> = {
'xxs': 'minmax(28px, 0.25fr)',
'xs': 'minmax(36px, 0.35fr)',
'sm': 'minmax(50px, 0.5fr)',
'md': 'minmax(70px, 0.7fr)',
'lg': 'minmax(100px, 1fr)',
'xl': 'minmax(130px, 1.3fr)',
'2xl': 'minmax(160px, 1.6fr)',
};
export const getColumnStyle = (column: ColumnConfig): string => {
return COLUMN_SIZE_STYLES[column.size];
};
export interface ColumnConfig {
id: string;
label: string;
group: string;
editable: boolean;
partialEdit?: boolean;
width?: string;
size: ColumnSize;
sortable?: boolean;
required?: boolean;
}
@@ -21,51 +38,51 @@ export const COLUMN_GROUPS = [
] as const;
export const ALL_COLUMNS: ColumnConfig[] = [
// Basic - узкие колонки
{ id: "checkbox", label: "", group: "basic", editable: false, required: true, width: "32px" },
{ id: "number", label: "№", group: "basic", editable: false, required: true, width: "40px" },
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
// Basic
{ id: "checkbox", label: "", group: "basic", editable: false, required: true, size: "xxs", sortable: false },
{ id: "number", label: "№", group: "basic", editable: false, required: true, size: "xs", sortable: false },
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, size: "lg", sortable: true },
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, size: "lg", sortable: true },
// Content
{ id: "creative", label: "Креатив", group: "content", editable: true, width: "minmax(140px, 1.5fr)" },
{ id: "invite_link", label: "Ссылка", group: "content", editable: false, width: "minmax(80px, 0.8fr)" },
{ id: "post_link", label: "Пост", group: "content", editable: false, width: "minmax(50px, 0.5fr)" },
{ id: "creative", label: "Креатив", group: "content", editable: true, size: "xl", sortable: true },
{ id: "invite_link", label: "Ссылка", group: "content", editable: false, size: "sm", sortable: false },
{ id: "post_link", label: "Пост", group: "content", editable: false, size: "sm", sortable: false },
// Finance
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
{ id: "cost_before", label: "До торга", group: "finance", editable: true, width: "minmax(80px, 0.8fr)" },
{ id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true, width: "minmax(100px, 1fr)" },
{ id: "discount", label: "Скидка", group: "finance", editable: false, width: "minmax(50px, 0.5fr)" },
{ id: "payment_date", label: "Оплата", group: "finance", editable: true, width: "minmax(90px, 0.8fr)" },
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, size: "md", sortable: true },
{ id: "cost_before", label: "До торга", group: "finance", editable: true, size: "md", sortable: true },
{ id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, size: "sm", sortable: true },
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true, size: "lg", sortable: true },
{ id: "discount", label: "Скидка", group: "finance", editable: false, size: "sm", sortable: true },
{ id: "payment_date", label: "Оплата", group: "finance", editable: true, size: "md", sortable: true },
// Dates
{ id: "planned_date", label: "План. выход", group: "dates", editable: true, width: "minmax(90px, 0.8fr)" },
{ id: "actual_date", label: "Факт", group: "dates", editable: false, width: "minmax(90px, 0.8fr)" },
{ id: "format", label: "Формат", group: "dates", editable: true, width: "minmax(100px, 0.8fr)" },
{ id: "time_top", label: "В топе", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "time_in_feed", label: "В ленте", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "link_created", label: "Ссылка", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
{ id: "post_deleted", label: "Удалён", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
{ id: "planned_date", label: "План. выход", group: "dates", editable: true, size: "md", sortable: true },
{ id: "actual_date", label: "Факт", group: "dates", editable: false, size: "md", sortable: true },
{ id: "format", label: "Формат", group: "dates", editable: true, size: "md", sortable: true },
{ id: "time_top", label: "В топе", group: "dates", editable: false, size: "sm", sortable: true },
{ id: "time_in_feed", label: "В ленте", group: "dates", editable: false, size: "sm", sortable: true },
{ id: "link_created", label: "Ссылка", group: "dates", editable: false, size: "md", sortable: true },
{ id: "post_deleted", label: "Удалён", group: "dates", editable: false, size: "md", sortable: true },
// Metrics
{ id: "subscriptions", label: "Подписки", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
{ id: "views", label: "Просмотры", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
{ id: "subscriptions", label: "Подписки", group: "metrics", editable: false, size: "md", sortable: true },
{ id: "views", label: "Просмотры", group: "metrics", editable: false, size: "md", sortable: true },
// Calculated
{ id: "cpf", label: "CPF", group: "calculated", editable: false, width: "minmax(50px, 0.5fr)" },
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, width: "minmax(50px, 0.5fr)" },
{ id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, width: "minmax(70px, 0.6fr)" },
{ id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "total_active", label: "Итого", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "cpf", label: "CPF", group: "calculated", editable: false, size: "sm", sortable: true },
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, size: "sm", sortable: true },
{ id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, size: "sm", sortable: true },
{ id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, size: "sm", sortable: true },
{ id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, size: "sm", sortable: true },
{ id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, size: "sm", sortable: true },
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, size: "sm", sortable: true },
{ id: "total_active", label: "Итого", group: "calculated", editable: false, size: "sm", sortable: true },
// Technical
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, width: "minmax(100px, 1fr)" },
{ id: "comment", label: "Комментарий", group: "technical", editable: true, width: "minmax(150px, 2fr)" },
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, size: "md", sortable: true },
{ id: "comment", label: "Комментарий", group: "technical", editable: true, size: "2xl", sortable: true },
] as const;
// Default visible columns