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 "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) => ( - - ))} - - - - {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) => ( - - ))} - - ); - })} - -
-
- {column.editable && ( - -

- {column.partialEdit ? ( - ( - ) - ) : ( - )} -

-
- )} - {column.label} -
-
+ 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