diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
index 0db7bf9..6e9f56d 100644
--- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
+++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
@@ -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,62 +324,62 @@ function CellRenderer({
return `${hours}ч ${minutes}м`;
};
- // Render specific columns
- switch (column.id) {
- case "checkbox":
- return (
-
- onTogglePlacement(placement.id)}
- onClick={(e) => e.stopPropagation()}
- />
-
- );
+ const renderCell = (column: (typeof ALL_COLUMNS)[number]) => {
+ switch (column.id) {
+ case "checkbox":
+ return (
+
+ onTogglePlacement(placement.id)}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ );
- case "number":
- return (
-
-
- {channelNumber}.{idx + 1}
-
-
- );
+ case "number":
+ return (
+
+
+ {channelNumber}.{idx + 1}
+
+
+ );
- case "status_deal":
- return (
-
- canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
- onChange={(s) => handleDraftChange(placement.id, { status: s })}
- onCloseEdit={() => setEditingCell(null)}
- />
-
- );
+ case "status_deal":
+ return (
+
+
canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
+ onChange={(s) => handleDraftChange(placement.id, { status: s })}
+ onCloseEdit={() => setEditingCell(null)}
+ />
+
+ );
- case "status_post":
- const postStatus = placement.placement_post?.status;
- return (
-
- canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
- onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
- onCloseEdit={() => setEditingCell(null)}
- />
-
- );
+ case "status_post":
+ const postStatus = placement.placement_post?.status;
+ return (
+
+
canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
+ onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
+ onCloseEdit={() => setEditingCell(null)}
+ />
+
+ );
case "creative":
const viewCreativeId = placement.creative_id;
const hasCreative = !!viewCreativeId;
return (
-
+
{hasCreative ? (
@@ -422,12 +426,12 @@ function CellRenderer({
+
)}
-
+
);
case "invite_link":
return (
-
+
{placement.invite_link ? (
{placement.invite_link.slice(8, 20)}…
@@ -435,12 +439,12 @@ function CellRenderer({
) : (
—
)}
-
+
);
case "post_link":
return (
-
+
{placement.placement_post?.post?.url ? (
—
)}
-
+
);
case "cost":
@@ -468,7 +472,7 @@ function CellRenderer({
const canEditCost = canEdit && costType === "fixed";
return (
-
+
{isEditing && editingCell?.field === "cost" ? (
}
)}
-
+
);
case "cpm":
@@ -518,7 +522,7 @@ function CellRenderer({
const canEditCpm = canEdit && cpmType === "cpm";
return (
-
+
{isEditing && editingCell?.field === "cpm" ? (
}
)}
-
+
);
case "cost_before":
const costBefore = placement.details?.cost_before_bargain?.value;
return (
-
+
{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}
-
+
);
case "cost_format":
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
return (
-
+
{isEditing && editingCell?.field === "cost_format" ? (
)}
-
+
);
case "placement_type":
const placementType = placement.details?.placement_type;
return (
-
+
{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}
-
+
);
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 (
-
+
{discount ? `${discount.toFixed(1)}%` : "—"}
-
+
);
case "payment_date":
return (
-
+
{isEditing && editingCell?.field === "payment_at" ? (
)}
-
+
);
case "planned_date":
return (
-
+
{isEditing && editingCell?.field === "placement_at" ? (
)}
-
+
);
case "actual_date":
return (
-
+
{placement.placement_post?.post?.published_at
? formatDate(placement.placement_post.post.published_at)
: "—"}
-
+
);
case "format":
return (
-
+
{isEditing && editingCell?.field === "format" ? (
)}
-
+
);
case "time_top":
const timeOnTop = placement.placement_post?.time_on_top;
return (
-
+
{formatTime(timeOnTop)}
-
+
);
case "link_created":
return (
-
+
{formatDate(placement.invite_link_created_at)}
-
+
);
case "post_deleted":
return (
-
+
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
-
+
);
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 (
-
+
{showSubs}
-
+
);
case "views":
return (
-
+
{formatNumber(placement.placement_post?.views_count)}
-
+
);
case "cpf":
return (
-
+
{cpf ? formatCurrency(cpf) : "—"}
-
+
);
case "conversion_24h":
return (
-
+
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"}
-
+
);
case "conversion_48h":
return (
-
+
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"}
-
+
);
case "conversion_total":
return (
-
+
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"}
-
+
);
case "total_unsubs":
return (
-
+
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"}
-
+
);
case "unsub_percent":
return (
-
+
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"}
-
+
);
case "total_active":
return (
-
+
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"}
-
+
);
case "time_in_feed":
return (
-
+
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"}
-
+
);
case "invite_type":
return (
-
+
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
-
+
);
case "comment":
return (
-
+
{isEditing && editingCell?.field === "comment" ? (
)}
-
+
);
default:
return (
-
+
Недоступно
-
+
);
}
+ };
+
+ return (
+ getColumnStyle(c)).join(" "),
+ }}
+ >
+ {columnConfigs.map((column) => renderCell(column))}
+
+ );
}
function ChannelGroup({
@@ -987,41 +1005,8 @@ function ChannelGroup({
-
-
-
- {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
-
-
- {column.editable && (
-
-
- {column.partialEdit ? (
- (
- )
- ) : (
- )}
-
-
- )}
-
{column.label}
-
-
- ))}
-
-
-
- {placements.map((placement, idx) => {
+
+ {placements.map((placement, idx) => {
const viewPlacement = getPlacementView(placement);
const effectiveCost = viewPlacement.details?.cost?.value || 0;
@@ -1033,37 +1018,29 @@ function ChannelGroup({
: null;
return (
-
- {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
-
- ))}
-
- );
- })}
-
-
+
visibleColumns.has(c.id))}
+ placement={viewPlacement}
+ idx={idx}
+ channelNumber={channelNumber}
+ canEdit={canEdit}
+ editingCell={editingCell}
+ setEditingCell={setEditingCell}
+ handleDraftChange={handleDraftChange}
+ effectiveCost={effectiveCost}
+ cpf={cpf}
+ cpm={cpm}
+ selectedPlacementIds={selectedPlacementIds}
+ onTogglePlacement={onTogglePlacement}
+ onOpenCreativeSelect={onOpenCreativeSelect}
+ onClearCreative={onClearCreative}
+ onOpenCreativeSend={onOpenCreativeSend}
+ />
+ );
+ })}
+
@@ -1843,8 +1820,25 @@ export default function PurchasePlanDetailPage() {
) : (
-
- {filteredGroups.map((group, idx) => (
+ <>
+
{
+ setFilters((prev) => ({
+ ...prev,
+ sort: {
+ field,
+ direction:
+ prev.sort?.field === field && prev.sort?.direction === "asc"
+ ? "desc"
+ : "asc",
+ },
+ }));
+ }}
+ />
+
+ {filteredGroups.map((group, idx) => (
))}
-
+
+ >
)}
diff --git a/components/placements-table-header.tsx b/components/placements-table-header.tsx
new file mode 100644
index 0000000..38c3de7
--- /dev/null
+++ b/components/placements-table-header.tsx
@@ -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;
+ sort?: SortOption | null;
+ onSort?: (field: string) => void;
+}
+
+const SORT_FIELD_TO_COLUMN: Record = {
+ 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 (
+
+
getColumnStyle(c))
+ .join(" "),
+ }}
+ >
+ {visibleColumnConfigs.map((column) => {
+ const sortDir = getSortDirection(column);
+
+ return (
+
handleSort(column)}
+ title={column.sortable ? "Нажмите для сортировки" : undefined}
+ >
+ {column.editable && (
+
+
+ (
+
+
+
+
+ )
+
+
+ )}
+
{column.label}
+ {column.sortable && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/lib/config/placement-columns.ts b/lib/config/placement-columns.ts
index 48260f0..0253a24 100644
--- a/lib/config/placement-columns.ts
+++ b/lib/config/placement-columns.ts
@@ -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 = {
+ '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