feat: took header out

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

View File

@@ -38,6 +38,7 @@ import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi, creativesApi } from "@/lib/api";
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
import { PlacementsTableHeader } from "@/components/placements-table-header";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -103,6 +104,7 @@ import {
PLACEMENT_POST_STATUSES,
canEditPostStatus,
getPlacementStatusColor,
getColumnStyle,
} from "@/lib/config/placement-columns";
type PlacementInlineDraft = {
@@ -230,7 +232,8 @@ interface ChannelGroupProps {
// ============================================================================
interface CellRendererProps {
column: (typeof ALL_COLUMNS)[number];
placementId: string;
columnConfigs: (typeof ALL_COLUMNS)[number][];
placement: PlacementWithStats;
idx: number;
channelNumber: number;
@@ -249,7 +252,8 @@ interface CellRendererProps {
}
function CellRenderer({
column,
placementId,
columnConfigs,
placement,
idx,
channelNumber,
@@ -320,62 +324,62 @@ function CellRenderer({
return `${hours}ч ${minutes}м`;
};
// Render specific columns
switch (column.id) {
case "checkbox":
return (
<td key={column.id} className="py-2 px-3">
<Checkbox
checked={selectedPlacementIds.has(placement.id)}
onCheckedChange={() => onTogglePlacement(placement.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
);
const renderCell = (column: (typeof ALL_COLUMNS)[number]) => {
switch (column.id) {
case "checkbox":
return (
<div key={column.id} className="py-2 px-3">
<Checkbox
checked={selectedPlacementIds.has(placement.id)}
onCheckedChange={() => onTogglePlacement(placement.id)}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
case "number":
return (
<td 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>
);
case "number":
return (
<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>
</div>
);
case "status_deal":
return (
<td key={column.id} className="py-2 px-3">
<PlacementStatusSelector
status={placement.status}
isEditing={isEditing && editingCell?.field === "status"}
canEdit={canEdit}
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
onChange={(s) => handleDraftChange(placement.id, { status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</td>
);
case "status_deal":
return (
<div key={column.id} className="py-2 px-3">
<PlacementStatusSelector
status={placement.status}
isEditing={isEditing && editingCell?.field === "status"}
canEdit={canEdit}
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
onChange={(s) => handleDraftChange(placement.id, { status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</div>
);
case "status_post":
const postStatus = placement.placement_post?.status;
return (
<td key={column.id} className="py-2 px-3">
<PlacementPostStatusSelector
status={postStatus ?? null}
isEditing={isEditing && editingCell?.field === "post_status"}
canEdit={canEdit}
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</td>
);
case "status_post":
const postStatus = placement.placement_post?.status;
return (
<div key={column.id} className="py-2 px-3">
<PlacementPostStatusSelector
status={postStatus ?? null}
isEditing={isEditing && editingCell?.field === "post_status"}
canEdit={canEdit}
onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
onCloseEdit={() => setEditingCell(null)}
/>
</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,41 +1005,8 @@ 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>
{placements.map((placement, idx) => {
<div className="w-full text-sm">
{placements.map((placement, idx) => {
const viewPlacement = getPlacementView(placement);
const effectiveCost = viewPlacement.details?.cost?.value || 0;
@@ -1033,37 +1018,29 @@ 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}
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}
/>
))}
</tr>
);
})}
</tbody>
</table>
<CellRenderer
key={placement.id}
placementId={placement.id}
columnConfigs={ALL_COLUMNS.filter((c) => 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}
/>
);
})}
</div>
</div>
</CollapsibleContent>
</Collapsible>
@@ -1843,8 +1820,25 @@ export default function PurchasePlanDetailPage() {
</CardContent>
</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
key={group.channel.id}
channel={group.channel}
@@ -1866,7 +1860,8 @@ export default function PurchasePlanDetailPage() {
onAddPlacement={handleAddPlacement}
/>
))}
</div>
</div>
</>
)}
</div>