Files
tgex-frontend/components/filters-modal.tsx
2026-02-02 21:29:06 +03:00

664 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "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";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { Placement, PlacementStatus, PlacementPostStatus, CostType, PlacementType } from "@/lib/types/api";
type FilterCondition = "contains" | "not_contains" | "equals";
interface FilterField {
key: string;
label: string;
type: "text" | "number" | "date";
}
interface SortField {
key: string;
label: string;
}
interface SortOption {
field: string;
direction: "asc" | "desc";
}
interface FiltersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filters: PlacementFilters) => void;
existingPlacements: Placement[];
initialFilters: PlacementFilters;
}
export interface PlacementFilters {
channelFilters: {
name: { value: string; condition: FilterCondition };
username: { value: string; condition: FilterCondition };
subscribersMin: number | null;
subscribersMax: number | null;
};
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;
};
sort: SortOption | null;
}
const CHANNEL_FILTER_FIELDS: FilterField[] = [
{ key: "name", label: "Название канала", type: "text" },
{ key: "username", label: "Username", type: "text" },
];
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 }[] = [
{ value: "contains", label: "содержит" },
{ value: "not_contains", label: "не содержит" },
{ value: "equals", label: "равно" },
];
const STATUS_OPTIONS: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
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,
initialFilters,
}: FiltersModalProps) {
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
const [activeTab, setActiveTab] = useState<"channels" | "placements">("channels");
const handleApply = () => {
onApply(filters);
onOpenChange(false);
};
const handleReset = () => {
const defaultFilters: PlacementFilters = {
channelFilters: {
name: { value: "", condition: "contains" },
username: { value: "", condition: "contains" },
subscribersMin: null,
subscribersMax: null,
},
placementFilters: {
statuses: [],
postStatuses: [],
hasCreative: null,
costMin: null,
costMax: null,
costFormat: null,
placementType: null,
dateFrom: null,
dateTo: null,
},
sort: null,
};
setFilters(defaultFilters);
};
const toggleStatus = (status: PlacementStatus) => {
const currentStatuses = filters.placementFilters.statuses;
const newStatuses = currentStatuses.includes(status)
? currentStatuses.filter((s) => s !== status)
: [...currentStatuses, status];
setFilters({
...filters,
placementFilters: { ...filters.placementFilters, statuses: newStatuses },
});
};
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 }
) => {
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
[key]: { ...filters.channelFilters[key], ...updates },
},
});
};
const hasActiveFilters =
filters.channelFilters.name.value ||
filters.channelFilters.username.value ||
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;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Фильтры и сортировка
</DialogTitle>
<DialogDescription>
Настройте фильтры и сортировку для каналов и размещений
</DialogDescription>
</DialogHeader>
<div className="flex border-b">
<button
onClick={() => setActiveTab("channels")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "channels"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Каналы
</button>
<button
onClick={() => setActiveTab("placements")}
className={cn(
"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>
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "channels" && (
<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-8 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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="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 w-28"
value={filters.channelFilters.subscribersMin || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMin: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="до"
className="h-8 text-sm w-28"
value={filters.channelFilters.subscribersMax || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMax: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</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-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-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"
)}
>
<Checkbox
checked={filters.placementFilters.statuses.includes(status)}
onCheckedChange={() => toggleStatus(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 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-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-3">
<Label className="text-sm font-medium">Формат оплаты</Label>
<Select
value={filters.placementFilters.costFormat || "any"}
onValueChange={(value: CostType | "any") =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
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-3">
<Label className="text-sm font-medium">Тип закупа</Label>
<Select
value={filters.placementFilters.placementType || "any"}
onValueChange={(value: PlacementType | "any") =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
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-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 className="pt-4 mt-4 border-t">
<div className="flex items-center gap-2 mb-3">
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Сортировка</h3>
</div>
<div className="space-y-3">
<Label className="text-xs text-muted-foreground">Сортировать размещения внутри каналов</Label>
<div className="flex gap-2 items-center">
<Select
value={filters.sort?.field || ""}
onValueChange={(field) =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, field }
: { field, direction: "asc" },
})
}
>
<SelectTrigger className="h-8 text-sm w-48">
<SelectValue placeholder="Без сортировки" />
</SelectTrigger>
<SelectContent>
{PLACEMENT_SORT_FIELDS.map((field) => (
<SelectItem key={field.key} value={field.key}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
{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: null })
}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
Сбросить
</button>
)}
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex justify-between">
<Button variant="outline" onClick={handleReset}>
Сбросить
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button onClick={handleApply}>
Применить
{hasActiveFilters && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
</span>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}