Files
tgex-frontend/components/filters-modal.tsx
2026-01-21 22:43:54 +03:00

524 lines
18 KiB
TypeScript
Raw 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 { Loader2, Filter, ArrowUpDown, X, Check, 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 { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { Placement, PlacementStatus } 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[];
costMin: number | null;
costMax: number | null;
dateFrom: string | null;
dateTo: string | null;
hasPlacement: boolean | null;
};
sort: SortOption | null;
}
const CHANNEL_FILTER_FIELDS: FilterField[] = [
{ key: "name", label: "Название канала", type: "text" },
{ key: "username", label: "Username", type: "text" },
];
const SORT_FIELDS: SortField[] = [
{ key: "title", label: "Название канала" },
{ key: "subscribers", label: "Подписчики" },
{ key: "cost", label: "Стоимость" },
{ key: "date", label: "Дата размещения" },
{ key: "cpm", label: "CPM" },
{ key: "cpf", label: "CPF" },
];
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
{ value: "contains", label: "содержит" },
{ value: "not_contains", label: "не содержит" },
{ value: "equals", label: "равно" },
];
const STATUS_OPTIONS: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
const CONDITION_LABELS: Record<FilterCondition, string> = {
contains: "содержит",
not_contains: "не содержит",
equals: "равно",
};
export function FiltersModal({
open,
onOpenChange,
onApply,
existingPlacements,
initialFilters,
}: FiltersModalProps) {
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("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: [],
costMin: null,
costMax: null,
dateFrom: null,
dateTo: null,
hasPlacement: 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 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.costMin !== null ||
filters.placementFilters.costMax !== 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",
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}
<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">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</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>
))}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. подписчиков</Label>
<Input
type="number"
placeholder="0"
value={filters.channelFilters.subscribersMin || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMin: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Макс. подписчиков</Label>
<Input
type="number"
placeholder="1000000"
value={filters.channelFilters.subscribersMax || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMax: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</div>
</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">
{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",
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)}
/>
{status}
</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>
<div className="space-y-2">
<Label>Макс. стоимость ()</Label>
<Input
type="number"
placeholder="100000"
value={filters.placementFilters.costMax || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMax: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
</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) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateFrom: e.target.value || null,
},
})
}
/>
</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>
</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>
<Select
value={filters.sort?.field || ""}
onValueChange={(field) =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, field }
: { field, direction: "desc" },
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите поле" />
</SelectTrigger>
<SelectContent>
{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">
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "asc" }
: { field: "title", direction: "asc" },
})
}
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="По возрастанию"
>
<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>
<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>
);
}