feat: took header out
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
126
components/placements-table-header.tsx
Normal file
126
components/placements-table-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user