feat: global ui & functional update
This commit is contained in:
523
components/filters-modal.tsx
Normal file
523
components/filters-modal.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user