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

@@ -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>