feat: purchase-plans global update

This commit is contained in:
ivannoskov
2026-02-02 21:01:11 +03:00
parent f9c334ba22
commit 735077e707
11 changed files with 2575 additions and 534 deletions

View File

@@ -0,0 +1,148 @@
import { X, Check } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ALL_COLUMNS, COLUMN_GROUPS, DEFAULT_VISIBLE_COLUMNS } from "@/lib/config/placement-columns";
interface ColumnSelectorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
visibleColumns: Set<string>;
onVisibleColumnsChange: (columns: Set<string>) => void;
}
export function ColumnSelectorDialog({
open,
onOpenChange,
visibleColumns,
onVisibleColumnsChange,
}: ColumnSelectorDialogProps) {
const handleToggleColumn = (columnId: string) => {
const next = new Set(visibleColumns);
if (next.has(columnId)) {
// Don't allow hiding required columns
const column = ALL_COLUMNS.find((c) => c.id === columnId);
if (column?.required) return;
next.delete(columnId);
} else {
next.add(columnId);
}
onVisibleColumnsChange(next);
};
const handleToggleGroup = (groupId: string, select: boolean) => {
const groupColumns = ALL_COLUMNS.filter((c) => c.group === groupId);
const next = new Set(visibleColumns);
groupColumns.forEach((column) => {
if (column.required) return;
if (select) {
next.add(column.id);
} else {
next.delete(column.id);
}
});
onVisibleColumnsChange(next);
};
const isGroupFullySelected = (groupId: string): boolean => {
const groupColumns = ALL_COLUMNS.filter((c) => c.group === groupId);
return groupColumns.every((c) => c.required || visibleColumns.has(c.id));
};
const isGroupPartiallySelected = (groupId: string): boolean => {
const groupColumns = ALL_COLUMNS.filter((c) => c.group === groupId);
return groupColumns.some((c) => visibleColumns.has(c.id)) && !isGroupFullySelected(groupId);
};
const handleReset = () => {
onVisibleColumnsChange(new Set(DEFAULT_VISIBLE_COLUMNS));
};
const handleSelectAll = () => {
onVisibleColumnsChange(new Set(ALL_COLUMNS.map((c) => c.id)));
};
const handleSelectNone = () => {
onVisibleColumnsChange(
new Set(ALL_COLUMNS.filter((c) => c.required).map((c) => c.id))
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Выбор колонок</DialogTitle>
<DialogDescription>
Выберите колонки для отображения в таблице размещений
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
По умолчанию
</Button>
<Button variant="outline" size="sm" onClick={handleSelectAll}>
Выбрать все
</Button>
<Button variant="outline" size="sm" onClick={handleSelectNone}>
Снять выбор
</Button>
</div>
{/* Column groups */}
{COLUMN_GROUPS.map((group) => {
const groupColumns = ALL_COLUMNS.filter((c) => c.group === group.id);
return (
<div key={group.id} className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox
id={`group-${group.id}`}
checked={isGroupFullySelected(group.id)}
onCheckedChange={(checked) => handleToggleGroup(group.id, !!checked)}
/>
<label
htmlFor={`group-${group.id}`}
className="text-sm font-medium cursor-pointer"
>
{group.label}
</label>
{isGroupPartiallySelected(group.id) && (
<span className="text-xs text-muted-foreground">(частично выбрано)</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 ml-6">
{groupColumns.map((column) => (
<div key={column.id} className="flex items-center gap-2">
<Checkbox
id={`col-${column.id}`}
checked={visibleColumns.has(column.id)}
onCheckedChange={() => handleToggleColumn(column.id)}
disabled={column.required}
/>
<label
htmlFor={`col-${column.id}`}
className={cn(
"text-xs cursor-pointer truncate",
column.required && "font-medium"
)}
>
{column.label}
</label>
</div>
))}
</div>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,90 @@
import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface Creative {
id: string;
name: string;
thumbnail_url?: string | null;
}
interface CreativeSelectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
creatives: Creative[];
onSelect: (creative: Creative) => void;
onClear: (() => void) | null;
currentCreativeId: string | null | undefined;
}
export function CreativeSelectDialog({
open,
onOpenChange,
creatives,
onSelect,
onClear,
currentCreativeId,
}: CreativeSelectDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Выберите креатив</DialogTitle>
</DialogHeader>
{creatives.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Нет доступных креативов
</div>
) : (
<div className="space-y-2">
{creatives.map((creative) => (
<div
key={creative.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
currentCreativeId === creative.id
? "border-primary bg-primary/5"
: "hover:bg-muted"
)}
onClick={() => {
onSelect(creative);
onOpenChange(false);
}}
>
<div className="w-12 h-12 rounded bg-muted flex items-center justify-center overflow-hidden shrink-0">
{creative.thumbnail_url ? (
<img
src={creative.thumbnail_url}
alt={creative.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-xs">📷</span>
)}
</div>
<span className="text-sm font-medium truncate">{creative.name}</span>
</div>
))}
</div>
)}
{currentCreativeId && onClear && (
<div className="pt-4 border-t mt-4">
<Button
variant="outline"
className="w-full"
onClick={() => {
onClear();
onOpenChange(false);
}}
>
Отменить выбор креатива
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -38,6 +38,7 @@ interface CreatePlacementsDialogProps {
projectId: string;
existingPlacements: Placement[];
prefilledCreativeId?: string | null;
prefilledChannelIds?: string[] | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess: () => void;
@@ -52,6 +53,7 @@ export function CreatePlacementsDialog({
projectId,
existingPlacements,
prefilledCreativeId = null,
prefilledChannelIds = null,
open: controlledOpen,
onOpenChange,
onSuccess,
@@ -120,8 +122,9 @@ export function CreatePlacementsDialog({
if (isOpen) {
loadCreatives();
loadProjectChannels();
loadPrefilledChannels();
}
}, [isOpen, workspaceId, projectId, prefilledCreativeId]);
}, [isOpen, workspaceId, projectId, prefilledCreativeId, prefilledChannelIds]);
const loadCreatives = async () => {
try {
@@ -153,6 +156,24 @@ export function CreatePlacementsDialog({
}
};
const loadPrefilledChannels = async () => {
if (!prefilledChannelIds || prefilledChannelIds.length === 0) return;
try {
const projectPlacements = await placementsApi.getByProject(workspaceId, projectId);
const prefilledChannels = projectPlacements
.filter((p) => prefilledChannelIds.includes(p.channel.id))
.map((p) => ({
username: p.channel.username || p.channel.title,
channelId: p.channel.id,
}));
setSelectedChannels(prefilledChannels);
} catch (err) {
console.error("Failed to load prefilled channels:", err);
}
};
const addChannel = async () => {
const username = parseChannelInput(newChannelInput);
if (!username) {

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Loader2, Filter, ArrowUpDown, X, Check, ArrowUp, ArrowDown } from "lucide-react";
import { Filter, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -21,9 +21,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { Placement, PlacementStatus } from "@/lib/types/api";
import type { Placement, PlacementStatus, PlacementPostStatus, CostType, PlacementType } from "@/lib/types/api";
type FilterCondition = "contains" | "not_contains" | "equals";
@@ -60,11 +59,14 @@ export interface PlacementFilters {
};
placementFilters: {
statuses: PlacementStatus[];
postStatuses: PlacementPostStatus[];
hasCreative: boolean | null;
costMin: number | null;
costMax: number | null;
costFormat: CostType | null;
placementType: PlacementType | null;
dateFrom: string | null;
dateTo: string | null;
hasPlacement: boolean | null;
};
sort: SortOption | null;
}
@@ -74,13 +76,14 @@ const CHANNEL_FILTER_FIELDS: FilterField[] = [
{ key: "username", label: "Username", type: "text" },
];
const SORT_FIELDS: SortField[] = [
{ key: "title", label: "Название канала" },
{ key: "subscribers", label: "Подписчики" },
const PLACEMENT_SORT_FIELDS: SortField[] = [
{ key: "cost", label: "Стоимость" },
{ key: "date", label: "Дата размещения" },
{ key: "cpm", label: "CPM" },
{ key: "cpf", label: "CPF" },
{ key: "subscribers", label: "Подписчики" },
{ key: "views", label: "Просмотры" },
{ key: "created", label: "Дата создания" },
];
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
@@ -102,21 +105,40 @@ const STATUS_OPTIONS: PlacementStatus[] = [
"Не отвечает",
];
const CONDITION_LABELS: Record<FilterCondition, string> = {
contains: "содержит",
not_contains: "не содержит",
equals: "равно",
};
const POST_STATUS_OPTIONS: PlacementPostStatus[] = [
"Без статуса",
"Отправить пост",
"Согласование поста",
"Ожидание отложки",
"Запланирован",
"Пост вышел",
"Размещение отработало - пост удалён",
"Размещение отработало - пост не удалён",
"Проверить - пост удалён раньше срока",
"Проверить - пост не вышел",
"Размещение отработало",
];
const COST_FORMAT_OPTIONS: { value: CostType | "any"; label: string }[] = [
{ value: "any", label: "Любой" },
{ value: "fixed", label: "Фикс" },
{ value: "cpm", label: "CPM" },
];
const PLACEMENT_TYPE_OPTIONS: { value: PlacementType | "any"; label: string }[] = [
{ value: "any", label: "Любой" },
{ value: "standard", label: "Стандартный" },
{ value: "self_promo", label: "Взаимный пиар" },
];
export function FiltersModal({
open,
onOpenChange,
onApply,
existingPlacements,
initialFilters,
}: FiltersModalProps) {
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("channels");
const [activeTab, setActiveTab] = useState<"channels" | "placements">("channels");
const handleApply = () => {
onApply(filters);
@@ -133,11 +155,14 @@ export function FiltersModal({
},
placementFilters: {
statuses: [],
postStatuses: [],
hasCreative: null,
costMin: null,
costMax: null,
costFormat: null,
placementType: null,
dateFrom: null,
dateTo: null,
hasPlacement: null,
},
sort: null,
};
@@ -155,6 +180,17 @@ export function FiltersModal({
});
};
const togglePostStatus = (status: PlacementPostStatus) => {
const currentStatuses = filters.placementFilters.postStatuses;
const newStatuses = currentStatuses.includes(status)
? currentStatuses.filter((s) => s !== status)
: [...currentStatuses, status];
setFilters({
...filters,
placementFilters: { ...filters.placementFilters, postStatuses: newStatuses },
});
};
const updateChannelFilter = (
key: "name" | "username",
updates: { value?: string; condition?: FilterCondition }
@@ -174,8 +210,12 @@ export function FiltersModal({
filters.channelFilters.subscribersMin !== null ||
filters.channelFilters.subscribersMax !== null ||
filters.placementFilters.statuses.length > 0 ||
filters.placementFilters.postStatuses.length > 0 ||
filters.placementFilters.hasCreative !== null ||
filters.placementFilters.costMin !== null ||
filters.placementFilters.costMax !== null ||
filters.placementFilters.costFormat !== null ||
filters.placementFilters.placementType !== null ||
filters.placementFilters.dateFrom ||
filters.placementFilters.dateTo ||
filters.sort;
@@ -186,7 +226,7 @@ export function FiltersModal({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Фильтры и сортировка
Фильтры
</DialogTitle>
<DialogDescription>
Настройте фильтры для каналов и размещений
@@ -208,42 +248,31 @@ export function FiltersModal({
<button
onClick={() => setActiveTab("placements")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
activeTab === "placements"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Размещения
</button>
<button
onClick={() => setActiveTab("sort")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
activeTab === "sort"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<ArrowUpDown className="h-4 w-4" />
Сортировка
Размещения
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "channels" && (
<div className="space-y-4">
{CHANNEL_FILTER_FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label className="flex items-center gap-2">
{field.label}
<div className="space-y-5">
<div className="space-y-3">
<Label className="text-sm font-medium">Поиск канала</Label>
{CHANNEL_FILTER_FIELDS.map((field) => (
<div key={field.key} className="flex gap-2 items-center">
<Select
value={filters.channelFilters[field.key as "name" | "username"].condition}
onValueChange={(value: FilterCondition) =>
updateChannelFilter(field.key as "name" | "username", { condition: value })
}
>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -254,27 +283,25 @@ export function FiltersModal({
))}
</SelectContent>
</Select>
</Label>
<Input
placeholder={
filters.channelFilters[field.key as "name" | "username"].condition === "equals"
? "Точное значение"
: "Например: новости"
}
value={filters.channelFilters[field.key as "name" | "username"].value}
onChange={(e) =>
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
}
/>
</div>
))}
<Input
placeholder={field.key === "name" ? "Название канала" : "@username"}
className="h-8 text-sm"
value={filters.channelFilters[field.key as "name" | "username"].value}
onChange={(e) =>
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
}
/>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. подписчиков</Label>
<div className="space-y-3">
<Label className="text-sm font-medium">Подписчики</Label>
<div className="flex gap-2 items-center">
<Input
type="number"
placeholder="0"
placeholder="от"
className="h-8 text-sm w-28"
value={filters.channelFilters.subscribersMin || ""}
onChange={(e) =>
setFilters({
@@ -286,12 +313,11 @@ export function FiltersModal({
})
}
/>
</div>
<div className="space-y-2">
<Label>Макс. подписчиков</Label>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="1000000"
placeholder="до"
className="h-8 text-sm w-28"
value={filters.channelFilters.subscribersMax || ""}
onChange={(e) =>
setFilters({
@@ -305,19 +331,25 @@ export function FiltersModal({
/>
</div>
</div>
<div className="p-3 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">
Каналы всегда сортируются по алфавиту
</p>
</div>
</div>
)}
{activeTab === "placements" && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Статусы размещений</Label>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
<div className="space-y-5">
<div className="space-y-3">
<Label className="text-sm font-medium">Статус сделки</Label>
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
{STATUS_OPTIONS.map((status) => (
<label
key={status}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
"flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-colors text-xs",
filters.placementFilters.statuses.includes(status)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
@@ -326,92 +358,207 @@ export function FiltersModal({
<Checkbox
checked={filters.placementFilters.statuses.includes(status)}
onCheckedChange={() => toggleStatus(status)}
className="h-3 w-3"
/>
{status}
<span className="truncate max-w-[150px]">{status}</span>
</label>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-medium">Статус поста</Label>
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
{POST_STATUS_OPTIONS.map((status) => (
<label
key={status}
className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-colors text-xs",
filters.placementFilters.postStatuses.includes(status)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.placementFilters.postStatuses.includes(status)}
onCheckedChange={() => togglePostStatus(status)}
className="h-3 w-3"
/>
<span className="truncate max-w-[150px]">{status}</span>
</label>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-medium">Креатив</Label>
<div className="flex gap-4">
{[
{ value: null, label: "Любой" },
{ value: true, label: "С креативом" },
{ value: false, label: "Без креатива" },
].map((opt) => (
<label
key={String(opt.value)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.placementFilters.hasCreative === opt.value
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.placementFilters.hasCreative === opt.value}
onCheckedChange={() =>
setFilters({
...filters,
placementFilters: { ...filters.placementFilters, hasCreative: opt.value },
})
}
className="h-3 w-3"
/>
{opt.label}
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. стоимость ()</Label>
<Input
type="number"
placeholder="0"
value={filters.placementFilters.costMin || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMin: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
<div className="space-y-3">
<Label className="text-sm font-medium">Стоимость ()</Label>
<div className="flex gap-2 items-center">
<Input
type="number"
placeholder="от"
className="h-8 text-sm"
value={filters.placementFilters.costMin || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMin: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="до"
className="h-8 text-sm"
value={filters.placementFilters.costMax || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMax: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label>Макс. стоимость ()</Label>
<Input
type="number"
placeholder="100000"
value={filters.placementFilters.costMax || ""}
onChange={(e) =>
<div className="space-y-3">
<Label className="text-sm font-medium">Формат оплаты</Label>
<Select
value={filters.placementFilters.costFormat || "any"}
onValueChange={(value: CostType | "any") =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMax: e.target.value ? parseFloat(e.target.value) : null,
costFormat: value === "any" ? null : value,
},
})
}
/>
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Любой" />
</SelectTrigger>
<SelectContent>
{COST_FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Дата размещения от</Label>
<Input
type="date"
value={filters.placementFilters.dateFrom || ""}
onChange={(e) =>
<div className="space-y-3">
<Label className="text-sm font-medium">Тип закупа</Label>
<Select
value={filters.placementFilters.placementType || "any"}
onValueChange={(value: PlacementType | "any") =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateFrom: e.target.value || null,
placementType: value === "any" ? null : value,
},
})
}
/>
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Любой" />
</SelectTrigger>
<SelectContent>
{PLACEMENT_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Дата размещения до</Label>
<Input
type="date"
value={filters.placementFilters.dateTo || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateTo: e.target.value || null,
},
})
}
/>
<div className="space-y-3">
<Label className="text-sm font-medium">Дата размещения</Label>
<div className="flex gap-2 items-center">
<Input
type="date"
className="h-8 text-sm"
value={filters.placementFilters.dateFrom || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateFrom: e.target.value || null,
},
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="date"
className="h-8 text-sm"
value={filters.placementFilters.dateTo || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateTo: e.target.value || null,
},
})
}
/>
</div>
</div>
</div>
</div>
)}
{activeTab === "sort" && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2">
<Label>Сортировать по</Label>
<div className="space-y-3 pt-2 border-t">
<Label className="text-sm font-medium flex items-center gap-2">
<ArrowUpDown className="h-4 w-4" />
Сортировка внутри каналов
</Label>
<div className="flex gap-2 items-center">
<Select
value={filters.sort?.field || ""}
onValueChange={(field) =>
@@ -419,82 +566,73 @@ export function FiltersModal({
...filters,
sort: filters.sort
? { ...filters.sort, field }
: { field, direction: "desc" },
: { field, direction: "asc" },
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите поле" />
<SelectTrigger className="h-8 text-sm w-48">
<SelectValue placeholder="Без сортировки" />
</SelectTrigger>
<SelectContent>
{SORT_FIELDS.map((field) => (
{PLACEMENT_SORT_FIELDS.map((field) => (
<SelectItem key={field.key} value={field.key}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Направление</Label>
<div className="flex gap-1 p-1 border rounded-md">
{filters.sort && (
<div className="flex gap-1 p-0.5 border rounded-md">
<button
onClick={() =>
setFilters({
...filters,
sort: { ...filters.sort!, direction: "asc" },
})
}
className={cn(
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
filters.sort.direction === "asc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По возрастанию"
>
<ArrowUp className="h-3 w-3 mr-1" />
А-Я
</button>
<button
onClick={() =>
setFilters({
...filters,
sort: { ...filters.sort!, direction: "desc" },
})
}
className={cn(
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
filters.sort.direction === "desc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По убыванию"
>
<ArrowDown className="h-3 w-3 mr-1" />
Я-А
</button>
</div>
)}
{filters.sort && (
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "asc" }
: { field: "title", direction: "asc" },
})
setFilters({ ...filters, sort: null })
}
className={cn(
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
filters.sort?.direction === "asc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По возрастанию"
className="text-xs text-muted-foreground hover:text-foreground underline"
>
<ArrowUp className="h-4 w-4" />
<span className="ml-1 text-xs">A-Z</span>
Сбросить
</button>
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "desc" }
: { field: "title", direction: "desc" },
})
}
className={cn(
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
filters.sort?.direction === "desc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По убыванию"
>
<ArrowDown className="h-4 w-4" />
<span className="ml-1 text-xs">Z-A</span>
</button>
</div>
)}
</div>
</div>
{filters.sort && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Текущая сортировка:</span>
<span className="font-medium">
{SORT_FIELDS.find((f) => f.key === filters.sort?.field)?.label ||
filters.sort?.field}
</span>
<span>
{filters.sort.direction === "asc" ? "↑" : "↓"} (
{filters.sort.direction === "asc" ? "A-Z" : "Z-A"})
</span>
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,484 @@
import { Check, X, Copy, Send, ExternalLink, Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { PlacementWithStats, PlacementStatus, PlacementPostStatus, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
import { PLACEMENT_DEAL_STATUSES, PLACEMENT_POST_STATUSES, COST_FORMATS, PLACEMENT_TYPES, INVITE_TYPES, canEditPostStatus } from "@/lib/config/placement-columns";
// ============================================================================
// Cell Renderers
// ============================================================================
interface CellProps {
placement: PlacementWithStats;
idx: number;
channelNumber: number;
canEdit: boolean;
isEditing?: boolean;
onEdit?: () => void;
onSave?: (value: any) => void;
}
// Checkbox column
export const CheckboxCell = ({ checked, onCheckedChange, onClick }: { checked: boolean; onCheckedChange: () => void; onClick?: (e: React.MouseEvent) => void }) => (
<td className="py-2 px-3">
<Checkbox checked={checked} onCheckedChange={onCheckedChange} onClick={onClick} />
</td>
);
// Number column
export const NumberCell = ({ channelNumber, idx }: { channelNumber: number; idx: number }) => (
<td 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>
);
// Status deal column
export const StatusDealCell = ({
status,
canEdit,
isEditing,
onEdit,
onSave,
}: {
status: PlacementStatus;
canEdit: boolean;
isEditing: boolean;
onEdit: () => void;
onSave: (value: PlacementStatus) => void;
}) => {
const getStatusColor = (s: PlacementStatus): string => {
switch (s) {
case "Оплачено":
return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
case "Согласование условий":
case "Ждём ответа":
return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
case "Отмена":
case "Не подходит цена":
case "Неактуально":
case "Не отвечает":
return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
case "Оплатить":
return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
return (
<td className="py-2 px-3">
{isEditing ? (
<Select value={status} onValueChange={(v) => onSave(v as PlacementStatus)}>
<SelectTrigger className="h-7 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLACEMENT_DEAL_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<button
type="button"
disabled={!canEdit}
className={cn(
"h-7 w-full rounded border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default",
getStatusColor(status)
)}
onClick={onEdit}
>
{status}
</button>
)}
</td>
);
};
// Status post column
export const StatusPostCell = ({
status,
canEdit,
isEditing,
onEdit,
onSave,
}: {
status: PlacementPostStatus;
canEdit: boolean;
isEditing: boolean;
onEdit: () => void;
onSave: (value: PlacementPostStatus) => void;
}) => {
const canEditStatus = canEdit && canEditPostStatus(status);
const isAuto = !canEditPostStatus(status);
return (
<td className="py-2 px-3">
{isEditing && canEditStatus ? (
<Select value={status} onValueChange={(v) => onSave(v as PlacementPostStatus)}>
<SelectTrigger className="h-7 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLACEMENT_POST_STATUSES.filter(s => canEditPostStatus(s as PlacementPostStatus)).map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className={cn(
"h-7 w-full rounded border px-2 text-xs font-medium flex items-center",
isAuto ? "bg-muted text-muted-foreground" : "bg-background",
canEditStatus && canEdit ? "cursor-pointer hover:bg-muted/50" : "cursor-default"
)} onClick={canEditStatus && canEdit ? onEdit : undefined}>
{status}
</div>
)}
</td>
);
};
// Creative column
export const CreativeCell = ({
creativeId,
creativeName,
canEdit,
onToggleCreative,
}: {
creativeId: string | null;
creativeName: string | null;
canEdit: boolean;
onToggleCreative: () => void;
}) => (
<td className="py-2 px-3">
{creativeId ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded p-1" onClick={onToggleCreative}>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center overflow-hidden">
<span className="text-xs">📷</span>
</div>
<span className="text-xs truncate max-w-[140px]">{creativeName || "Без названия"}</span>
</div>
<Button variant="outline" size="sm" className="h-6 px-2 text-xs">
<Send className="h-3 w-3 mr-1" />
Отправить
</Button>
</div>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={onToggleCreative}
>
Выбрать креатив +
</button>
)}
</td>
);
// Invite link column
export const InviteLinkCell = ({ inviteLink, onCopy }: { inviteLink: string | null; onCopy: () => void }) => (
<td className="py-2 px-3">
{inviteLink ? (
<div className="flex items-center gap-1">
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[200px]">{inviteLink}</code>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onCopy}>
<Copy className="h-3 w-3" />
</Button>
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
);
// Cost column
export const CostCell = ({
cost,
costType,
canEdit,
isEditing,
onEdit,
onSave,
}: {
cost: number | null;
costType: CostType | null;
canEdit: boolean;
isEditing: boolean;
onEdit: () => void;
onSave: (value: { type: CostType; value: number } | null) => void;
}) => (
<td className="py-2 px-3">
{isEditing ? (
<Input
type="number"
min="0"
step="0.01"
autoFocus
className="h-7 min-w-[80px] bg-background px-2 text-xs"
defaultValue={cost ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (!raw) {
onSave(null);
return;
}
const value = Number(raw);
if (Number.isNaN(value)) return;
onSave({ type: costType || "fixed", value });
}}
onBlur={(e) => {
e.currentTarget.blur();
}}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={onEdit}
>
{cost ? new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(cost) : "—"}
</button>
)}
</td>
);
// Date column
export const DateCell = ({
date,
canEdit,
isEditing,
onEdit,
onSave,
}: {
date: string | null;
canEdit: boolean;
isEditing: boolean;
onEdit: () => void;
onSave: (value: string | null) => void;
}) => {
const formatDate = (d: string | null): string => {
if (!d) return "—";
return new Date(d).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const toDateInputValue = (iso: string | null): string => {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const toIsoFromDateInput = (value: string): string | null => {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
};
return (
<td className="py-2 px-3">
{isEditing ? (
<Input
type="date"
autoFocus
className="h-7 min-w-[100px] bg-background px-2 text-xs"
defaultValue={toDateInputValue(date)}
onChange={(e) => onSave(toIsoFromDateInput(e.target.value))}
onBlur={(e) => e.currentTarget.blur()}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={onEdit}
>
{formatDate(date)}
</button>
)}
</td>
);
};
// Text column (for format, comment)
export const TextCell = ({
value,
placeholder = "—",
canEdit,
isEditing,
onEdit,
onSave,
className = "",
}: {
value: string | null;
placeholder?: string;
canEdit: boolean;
isEditing: boolean;
onEdit: () => void;
onSave: (value: string | null) => void;
className?: string;
}) => (
<td className={cn("py-2 px-3", className)}>
{isEditing ? (
<Input
autoFocus
className="h-7 min-w-[120px] bg-background px-2 text-xs"
defaultValue={value ?? ""}
onChange={(e) => onSave(e.target.value || null)}
onBlur={(e) => e.currentTarget.blur()}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-0 text-left text-xs hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={onEdit}
>
<span className="truncate w-full">{value ?? placeholder}</span>
</button>
)}
</td>
);
// Metrics column
export const MetricCell = ({ value, format = "number" }: { value: number | null; format?: "number" | "currency" | "percent" }) => {
const formatValue = () => {
if (value === null || value === undefined) return "—";
switch (format) {
case "currency":
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
case "percent":
return `${value.toFixed(1)}%`;
case "number":
default:
return new Intl.NumberFormat("ru-RU").format(value);
}
};
return (
<td className="py-2 px-3 text-muted-foreground">
{formatValue()}
</td>
);
};
// Select column (for cost_format, placement_type, invite_type)
export const SelectCell = ({
value,
options,
canEdit,
isEditing,
onEdit,
onSave,
displayMap,
}: {
value: string | null;
options: string[];
canEdit: boolean;
isEditing: boolean;
onEdit: () => void;
onSave: (value: string) => void;
displayMap?: Record<string, string>;
}) => {
const getDisplayValue = (v: string) => {
if (displayMap) return displayMap[v] || v;
switch (v) {
case "fixed":
return "Фикс";
case "cpm":
return "CPM";
case "self_promo":
return "Взаимный пиар";
case "standard":
return "Стандартный";
case "public":
return "Открытая";
case "approval":
return "С заявками";
default:
return v;
}
};
return (
<td className="py-2 px-3">
{isEditing ? (
<Select value={value ?? ""} onValueChange={onSave}>
<SelectTrigger className="h-7 w-full">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{getDisplayValue(option)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={onEdit}
>
{value ? getDisplayValue(value) : "—"}
</button>
)}
</td>
);
};
// Link column
export const LinkCell = ({ url, external = false }: { url: string | null; external?: boolean }) => (
<td className="py-2 px-3">
{url ? (
<a
href={url}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
{url.substring(0, 50)}
{url.length > 50 ? "..." : ""}
{external && <ExternalLink className="h-3 w-3" />}
</a>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
);
// Empty column (for future columns not yet implemented)
export const EmptyCell = ({ label }: { label?: string }) => (
<td className="py-2 px-3 text-muted-foreground text-xs italic">
{label || "Недоступно"}
</td>
);