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 { demoAwarePlacementsApi } from "@/lib/demo";
|
||||||
import { placementsApi, creativesApi } from "@/lib/api";
|
import { placementsApi, creativesApi } from "@/lib/api";
|
||||||
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
|
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
|
||||||
|
import { PlacementsTableHeader } from "@/components/placements-table-header";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -103,6 +104,7 @@ import {
|
|||||||
PLACEMENT_POST_STATUSES,
|
PLACEMENT_POST_STATUSES,
|
||||||
canEditPostStatus,
|
canEditPostStatus,
|
||||||
getPlacementStatusColor,
|
getPlacementStatusColor,
|
||||||
|
getColumnStyle,
|
||||||
} from "@/lib/config/placement-columns";
|
} from "@/lib/config/placement-columns";
|
||||||
|
|
||||||
type PlacementInlineDraft = {
|
type PlacementInlineDraft = {
|
||||||
@@ -230,7 +232,8 @@ interface ChannelGroupProps {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface CellRendererProps {
|
interface CellRendererProps {
|
||||||
column: (typeof ALL_COLUMNS)[number];
|
placementId: string;
|
||||||
|
columnConfigs: (typeof ALL_COLUMNS)[number][];
|
||||||
placement: PlacementWithStats;
|
placement: PlacementWithStats;
|
||||||
idx: number;
|
idx: number;
|
||||||
channelNumber: number;
|
channelNumber: number;
|
||||||
@@ -249,7 +252,8 @@ interface CellRendererProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CellRenderer({
|
function CellRenderer({
|
||||||
column,
|
placementId,
|
||||||
|
columnConfigs,
|
||||||
placement,
|
placement,
|
||||||
idx,
|
idx,
|
||||||
channelNumber,
|
channelNumber,
|
||||||
@@ -320,62 +324,62 @@ function CellRenderer({
|
|||||||
return `${hours}ч ${minutes}м`;
|
return `${hours}ч ${minutes}м`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render specific columns
|
const renderCell = (column: (typeof ALL_COLUMNS)[number]) => {
|
||||||
switch (column.id) {
|
switch (column.id) {
|
||||||
case "checkbox":
|
case "checkbox":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedPlacementIds.has(placement.id)}
|
checked={selectedPlacementIds.has(placement.id)}
|
||||||
onCheckedChange={() => onTogglePlacement(placement.id)}
|
onCheckedChange={() => onTogglePlacement(placement.id)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
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">
|
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
{channelNumber}.{idx + 1}
|
{channelNumber}.{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "status_deal":
|
case "status_deal":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
<PlacementStatusSelector
|
<PlacementStatusSelector
|
||||||
status={placement.status}
|
status={placement.status}
|
||||||
isEditing={isEditing && editingCell?.field === "status"}
|
isEditing={isEditing && editingCell?.field === "status"}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
|
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
|
||||||
onChange={(s) => handleDraftChange(placement.id, { status: s })}
|
onChange={(s) => handleDraftChange(placement.id, { status: s })}
|
||||||
onCloseEdit={() => setEditingCell(null)}
|
onCloseEdit={() => setEditingCell(null)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "status_post":
|
case "status_post":
|
||||||
const postStatus = placement.placement_post?.status;
|
const postStatus = placement.placement_post?.status;
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
<PlacementPostStatusSelector
|
<PlacementPostStatusSelector
|
||||||
status={postStatus ?? null}
|
status={postStatus ?? null}
|
||||||
isEditing={isEditing && editingCell?.field === "post_status"}
|
isEditing={isEditing && editingCell?.field === "post_status"}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
|
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
|
||||||
onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
|
onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
|
||||||
onCloseEdit={() => setEditingCell(null)}
|
onCloseEdit={() => setEditingCell(null)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "creative":
|
case "creative":
|
||||||
const viewCreativeId = placement.creative_id;
|
const viewCreativeId = placement.creative_id;
|
||||||
const hasCreative = !!viewCreativeId;
|
const hasCreative = !!viewCreativeId;
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{hasCreative ? (
|
{hasCreative ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded ">
|
<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>
|
<span className="text-primary font-medium">+</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "invite_link":
|
case "invite_link":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{placement.invite_link ? (
|
{placement.invite_link ? (
|
||||||
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
|
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
|
||||||
{placement.invite_link.slice(8, 20)}…
|
{placement.invite_link.slice(8, 20)}…
|
||||||
@@ -435,12 +439,12 @@ function CellRenderer({
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "post_link":
|
case "post_link":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{placement.placement_post?.post?.url ? (
|
{placement.placement_post?.post?.url ? (
|
||||||
<a
|
<a
|
||||||
href={placement.placement_post.post.url}
|
href={placement.placement_post.post.url}
|
||||||
@@ -453,7 +457,7 @@ function CellRenderer({
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "cost":
|
case "cost":
|
||||||
@@ -468,7 +472,7 @@ function CellRenderer({
|
|||||||
|
|
||||||
const canEditCost = canEdit && costType === "fixed";
|
const canEditCost = canEdit && costType === "fixed";
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "cost" ? (
|
{isEditing && editingCell?.field === "cost" ? (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -504,7 +508,7 @@ function CellRenderer({
|
|||||||
{costType === "fixed" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
{costType === "fixed" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "cpm":
|
case "cpm":
|
||||||
@@ -518,7 +522,7 @@ function CellRenderer({
|
|||||||
|
|
||||||
const canEditCpm = canEdit && cpmType === "cpm";
|
const canEditCpm = canEdit && cpmType === "cpm";
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "cpm" ? (
|
{isEditing && editingCell?.field === "cpm" ? (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -551,21 +555,21 @@ function CellRenderer({
|
|||||||
{cpmType === "cpm" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
{cpmType === "cpm" && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
const costBefore = placement.details?.cost_before_bargain?.value;
|
const costBefore = placement.details?.cost_before_bargain?.value;
|
||||||
return (
|
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) : "—"}
|
{costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "cost_format":
|
case "cost_format":
|
||||||
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
|
const costFormat = placement.details?.cost?.type || "fixed" as CostType;
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "cost_format" ? (
|
{isEditing && editingCell?.field === "cost_format" ? (
|
||||||
<Select
|
<Select
|
||||||
value={costFormat}
|
value={costFormat}
|
||||||
@@ -596,15 +600,15 @@ function CellRenderer({
|
|||||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "placement_type":
|
case "placement_type":
|
||||||
const placementType = placement.details?.placement_type;
|
const placementType = placement.details?.placement_type;
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}
|
{placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "discount":
|
case "discount":
|
||||||
@@ -612,14 +616,14 @@ function CellRenderer({
|
|||||||
const cost = placement.details?.cost?.value;
|
const cost = placement.details?.cost?.value;
|
||||||
const discount = costBeforeBargain && cost && costBeforeBargain > 0 ? ((costBeforeBargain - cost) / costBeforeBargain) * 100 : null;
|
const discount = costBeforeBargain && cost && costBeforeBargain > 0 ? ((costBeforeBargain - cost) / costBeforeBargain) * 100 : null;
|
||||||
return (
|
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)}%` : "—"}
|
{discount ? `${discount.toFixed(1)}%` : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "payment_date":
|
case "payment_date":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "payment_at" ? (
|
{isEditing && editingCell?.field === "payment_at" ? (
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -646,12 +650,12 @@ function CellRenderer({
|
|||||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "planned_date":
|
case "planned_date":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "placement_at" ? (
|
{isEditing && editingCell?.field === "placement_at" ? (
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -678,21 +682,21 @@ function CellRenderer({
|
|||||||
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "actual_date":
|
case "actual_date":
|
||||||
return (
|
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
|
{placement.placement_post?.post?.published_at
|
||||||
? formatDate(placement.placement_post.post.published_at)
|
? formatDate(placement.placement_post.post.published_at)
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "format":
|
case "format":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "format" ? (
|
{isEditing && editingCell?.field === "format" ? (
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
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" />
|
<Pencil className="h-3 w-3 shrink-0 ml-1 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "time_top":
|
case "time_top":
|
||||||
const timeOnTop = placement.placement_post?.time_on_top;
|
const timeOnTop = placement.placement_post?.time_on_top;
|
||||||
return (
|
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)}
|
{formatTime(timeOnTop)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "link_created":
|
case "link_created":
|
||||||
return (
|
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)}
|
{formatDate(placement.invite_link_created_at)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "post_deleted":
|
case "post_deleted":
|
||||||
return (
|
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)}
|
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "subscriptions":
|
case "subscriptions":
|
||||||
@@ -748,84 +752,84 @@ function CellRenderer({
|
|||||||
// Show "—" if subscriptions are hidden due to permissions (0 means hidden)
|
// Show "—" if subscriptions are hidden due to permissions (0 means hidden)
|
||||||
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
|
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
|
||||||
return (
|
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}
|
{showSubs}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "views":
|
case "views":
|
||||||
return (
|
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)}
|
{formatNumber(placement.placement_post?.views_count)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "cpf":
|
case "cpf":
|
||||||
return (
|
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) : "—"}
|
{cpf ? formatCurrency(cpf) : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "conversion_24h":
|
case "conversion_24h":
|
||||||
return (
|
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}%` : "—"}
|
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "conversion_48h":
|
case "conversion_48h":
|
||||||
return (
|
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}%` : "—"}
|
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "conversion_total":
|
case "conversion_total":
|
||||||
return (
|
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}%` : "—"}
|
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "total_unsubs":
|
case "total_unsubs":
|
||||||
return (
|
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 : "—"}
|
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "unsub_percent":
|
case "unsub_percent":
|
||||||
return (
|
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}%` : "—"}
|
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "total_active":
|
case "total_active":
|
||||||
return (
|
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 : "—"}
|
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "time_in_feed":
|
case "time_in_feed":
|
||||||
return (
|
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 : "—"}
|
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "invite_type":
|
case "invite_type":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
|
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "comment":
|
case "comment":
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<div key={column.id} className="py-2 px-3">
|
||||||
{isEditing && editingCell?.field === "comment" ? (
|
{isEditing && editingCell?.field === "comment" ? (
|
||||||
<Input
|
<Input
|
||||||
aria-label="Комментарий"
|
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" />
|
<Pencil className="h-3 w-3 shrink-0 ml-1 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
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({
|
function ChannelGroup({
|
||||||
@@ -987,41 +1005,8 @@ function ChannelGroup({
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<div className="w-full text-sm">
|
||||||
<thead>
|
{placements.map((placement, idx) => {
|
||||||
<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>
|
|
||||||
{placements.map((placement, idx) => {
|
|
||||||
const viewPlacement = getPlacementView(placement);
|
const viewPlacement = getPlacementView(placement);
|
||||||
const effectiveCost = viewPlacement.details?.cost?.value || 0;
|
const effectiveCost = viewPlacement.details?.cost?.value || 0;
|
||||||
|
|
||||||
@@ -1033,37 +1018,29 @@ function ChannelGroup({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<CellRenderer
|
||||||
key={placement.id}
|
key={placement.id}
|
||||||
data-placement-id={placement.id}
|
placementId={placement.id}
|
||||||
className="border-b last:border-0 hover:bg-muted/30"
|
columnConfigs={ALL_COLUMNS.filter((c) => visibleColumns.has(c.id))}
|
||||||
>
|
placement={viewPlacement}
|
||||||
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
|
idx={idx}
|
||||||
<CellRenderer
|
channelNumber={channelNumber}
|
||||||
key={column.id}
|
canEdit={canEdit}
|
||||||
column={column}
|
editingCell={editingCell}
|
||||||
placement={viewPlacement}
|
setEditingCell={setEditingCell}
|
||||||
idx={idx}
|
handleDraftChange={handleDraftChange}
|
||||||
channelNumber={channelNumber}
|
effectiveCost={effectiveCost}
|
||||||
canEdit={canEdit}
|
cpf={cpf}
|
||||||
editingCell={editingCell}
|
cpm={cpm}
|
||||||
setEditingCell={setEditingCell}
|
selectedPlacementIds={selectedPlacementIds}
|
||||||
handleDraftChange={handleDraftChange}
|
onTogglePlacement={onTogglePlacement}
|
||||||
effectiveCost={effectiveCost}
|
onOpenCreativeSelect={onOpenCreativeSelect}
|
||||||
cpf={cpf}
|
onClearCreative={onClearCreative}
|
||||||
cpm={cpm}
|
onOpenCreativeSend={onOpenCreativeSend}
|
||||||
selectedPlacementIds={selectedPlacementIds}
|
/>
|
||||||
onTogglePlacement={onTogglePlacement}
|
);
|
||||||
onOpenCreativeSelect={onOpenCreativeSelect}
|
})}
|
||||||
onClearCreative={onClearCreative}
|
</div>
|
||||||
onOpenCreativeSend={onOpenCreativeSend}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
@@ -1843,8 +1820,25 @@ export default function PurchasePlanDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<>
|
||||||
{filteredGroups.map((group, idx) => (
|
<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
|
<ChannelGroup
|
||||||
key={group.channel.id}
|
key={group.channel.id}
|
||||||
channel={group.channel}
|
channel={group.channel}
|
||||||
@@ -1866,7 +1860,8 @@ export default function PurchasePlanDetailPage() {
|
|||||||
onAddPlacement={handleAddPlacement}
|
onAddPlacement={handleAddPlacement}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</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";
|
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 {
|
export interface ColumnConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
partialEdit?: boolean;
|
partialEdit?: boolean;
|
||||||
width?: string;
|
size: ColumnSize;
|
||||||
|
sortable?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,51 +38,51 @@ export const COLUMN_GROUPS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const ALL_COLUMNS: ColumnConfig[] = [
|
export const ALL_COLUMNS: ColumnConfig[] = [
|
||||||
// Basic - узкие колонки
|
// Basic
|
||||||
{ id: "checkbox", label: "", group: "basic", editable: false, required: true, width: "32px" },
|
{ id: "checkbox", label: "", group: "basic", editable: false, required: true, size: "xxs", sortable: false },
|
||||||
{ id: "number", label: "№", group: "basic", editable: false, required: true, width: "40px" },
|
{ id: "number", label: "№", group: "basic", editable: false, required: true, size: "xs", sortable: false },
|
||||||
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
|
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, size: "lg", sortable: true },
|
||||||
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
|
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, size: "lg", sortable: true },
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
{ id: "creative", label: "Креатив", group: "content", editable: true, width: "minmax(140px, 1.5fr)" },
|
{ id: "creative", label: "Креатив", group: "content", editable: true, size: "xl", sortable: true },
|
||||||
{ id: "invite_link", label: "Ссылка", group: "content", editable: false, width: "minmax(80px, 0.8fr)" },
|
{ id: "invite_link", label: "Ссылка", group: "content", editable: false, size: "sm", sortable: false },
|
||||||
{ id: "post_link", label: "Пост", group: "content", editable: false, width: "minmax(50px, 0.5fr)" },
|
{ id: "post_link", label: "Пост", group: "content", editable: false, size: "sm", sortable: false },
|
||||||
|
|
||||||
// Finance
|
// Finance
|
||||||
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
|
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, size: "md", sortable: true },
|
||||||
{ id: "cost_before", label: "До торга", group: "finance", editable: true, width: "minmax(80px, 0.8fr)" },
|
{ id: "cost_before", label: "До торга", group: "finance", editable: true, size: "md", sortable: true },
|
||||||
{ id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
|
{ id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, size: "sm", sortable: true },
|
||||||
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true, width: "minmax(100px, 1fr)" },
|
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true, size: "lg", sortable: true },
|
||||||
{ id: "discount", label: "Скидка", group: "finance", editable: false, width: "minmax(50px, 0.5fr)" },
|
{ id: "discount", label: "Скидка", group: "finance", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "payment_date", label: "Оплата", group: "finance", editable: true, width: "minmax(90px, 0.8fr)" },
|
{ id: "payment_date", label: "Оплата", group: "finance", editable: true, size: "md", sortable: true },
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
{ id: "planned_date", label: "План. выход", group: "dates", editable: true, width: "minmax(90px, 0.8fr)" },
|
{ id: "planned_date", label: "План. выход", group: "dates", editable: true, size: "md", sortable: true },
|
||||||
{ id: "actual_date", label: "Факт", group: "dates", editable: false, width: "minmax(90px, 0.8fr)" },
|
{ id: "actual_date", label: "Факт", group: "dates", editable: false, size: "md", sortable: true },
|
||||||
{ id: "format", label: "Формат", group: "dates", editable: true, width: "minmax(100px, 0.8fr)" },
|
{ id: "format", label: "Формат", group: "dates", editable: true, size: "md", sortable: true },
|
||||||
{ id: "time_top", label: "В топе", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "time_top", label: "В топе", group: "dates", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "time_in_feed", label: "В ленте", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "time_in_feed", label: "В ленте", group: "dates", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "link_created", label: "Ссылка", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
|
{ id: "link_created", label: "Ссылка", group: "dates", editable: false, size: "md", sortable: true },
|
||||||
{ id: "post_deleted", label: "Удалён", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
|
{ id: "post_deleted", label: "Удалён", group: "dates", editable: false, size: "md", sortable: true },
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
{ id: "subscriptions", 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, width: "minmax(70px, 0.6fr)" },
|
{ id: "views", label: "Просмотры", group: "metrics", editable: false, size: "md", sortable: true },
|
||||||
|
|
||||||
// Calculated
|
// Calculated
|
||||||
{ id: "cpf", label: "CPF", group: "calculated", editable: false, width: "minmax(50px, 0.5fr)" },
|
{ id: "cpf", label: "CPF", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, width: "minmax(50px, 0.5fr)" },
|
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, size: "sm", sortable: true },
|
||||||
{ id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, width: "minmax(70px, 0.6fr)" },
|
{ id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
{ id: "total_active", label: "Итого", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
{ id: "total_active", label: "Итого", group: "calculated", editable: false, size: "sm", sortable: true },
|
||||||
|
|
||||||
// Technical
|
// Technical
|
||||||
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, width: "minmax(100px, 1fr)" },
|
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, size: "md", sortable: true },
|
||||||
{ id: "comment", label: "Комментарий", group: "technical", editable: true, width: "minmax(150px, 2fr)" },
|
{ id: "comment", label: "Комментарий", group: "technical", editable: true, size: "2xl", sortable: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Default visible columns
|
// Default visible columns
|
||||||
|
|||||||
Reference in New Issue
Block a user