Files
tgex-frontend/components/analytics-filters-modal.tsx
2026-02-03 18:55:09 +03:00

639 lines
24 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, X } 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 { Project, Channel, Creative, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
interface AnalyticsFiltersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filters: PlacementsAnalyticsFilters) => void;
projects: Project[];
channels: Channel[];
creatives: Creative[];
initialFilters: PlacementsAnalyticsFilters;
}
export interface PlacementsAnalyticsFilters {
projectIds: string[];
statusList: string[];
placementChannelIds: string[];
creativeIds: string[];
costTypes: CostType[];
placementTypes: PlacementType[];
inviteLinkTypes: InviteLinkType[];
costMin: number | null;
costMax: number | null;
viewsMin: number | null;
viewsMax: number | null;
subscriptionsMin: number | null;
subscriptionsMax: number | null;
cpmMin: number | null;
cpmMax: number | null;
channelTitleContains: string;
creativeNameContains: string;
commentContains: string;
placementDateFrom: string;
placementDateTo: string;
}
const STATUS_OPTIONS = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
const COST_TYPE_OPTIONS: { value: CostType; label: string }[] = [
{ value: "fixed", label: "Фикс" },
{ value: "cpm", label: "CPM" },
];
const PLACEMENT_TYPE_OPTIONS: { value: PlacementType; label: string }[] = [
{ value: "standard", label: "Стандартный" },
{ value: "self_promo", label: "Взаимный пиар" },
];
const INVITE_LINK_TYPE_OPTIONS: { value: InviteLinkType; label: string }[] = [
{ value: "public", label: "Открытая" },
{ value: "approval", label: "С заявками" },
];
export function AnalyticsFiltersModal({
open,
onOpenChange,
onApply,
projects,
channels,
creatives,
initialFilters,
}: AnalyticsFiltersModalProps) {
const [filters, setFilters] = useState<PlacementsAnalyticsFilters>(initialFilters);
const [activeTab, setActiveTab] = useState<"categories" | "numbers" | "text" | "dates">("categories");
const handleApply = () => {
onApply(filters);
onOpenChange(false);
};
const handleReset = () => {
const defaultFilters: PlacementsAnalyticsFilters = {
projectIds: [],
statusList: [],
placementChannelIds: [],
creativeIds: [],
costTypes: [],
placementTypes: [],
inviteLinkTypes: [],
costMin: null,
costMax: null,
viewsMin: null,
viewsMax: null,
subscriptionsMin: null,
subscriptionsMax: null,
cpmMin: null,
cpmMax: null,
channelTitleContains: "",
creativeNameContains: "",
commentContains: "",
placementDateFrom: "",
placementDateTo: "",
};
setFilters(defaultFilters);
};
const toggleArrayItem = <T extends string>(arr: T[], item: T): T[] => {
return arr.includes(item) ? arr.filter((i) => i !== item) : [...arr, item];
};
const hasActiveFilters =
filters.projectIds.length > 0 ||
filters.statusList.length > 0 ||
filters.placementChannelIds.length > 0 ||
filters.creativeIds.length > 0 ||
filters.costTypes.length > 0 ||
filters.placementTypes.length > 0 ||
filters.inviteLinkTypes.length > 0 ||
filters.costMin !== null ||
filters.costMax !== null ||
filters.viewsMin !== null ||
filters.viewsMax !== null ||
filters.subscriptionsMin !== null ||
filters.subscriptionsMax !== null ||
filters.cpmMin !== null ||
filters.cpmMax !== null ||
filters.channelTitleContains ||
filters.creativeNameContains ||
filters.commentContains ||
filters.placementDateFrom ||
filters.placementDateTo;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl 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">
{[
{ id: "categories", label: "Категории" },
{ id: "numbers", label: "Числовые" },
{ id: "text", label: "Текст" },
{ id: "dates", label: "Даты" },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "categories" && (
<div className="space-y-6">
{/* Projects - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Проекты</Label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{projects.map((project) => (
<label
key={project.id}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.projectIds.includes(project.id)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.projectIds.includes(project.id)}
onCheckedChange={() =>
setFilters({
...filters,
projectIds: toggleArrayItem(filters.projectIds, project.id),
})
}
className="h-4 w-4"
/>
<span className="truncate max-w-[200px]">{project.title}</span>
</label>
))}
{projects.length === 0 && (
<p className="text-sm text-muted-foreground">Нет доступных проектов</p>
)}
</div>
</div>
{/* Status - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Статусы</Label>
<div className="flex flex-wrap gap-2 max-h-32 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.statusList.includes(status)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.statusList.includes(status)}
onCheckedChange={() =>
setFilters({
...filters,
statusList: toggleArrayItem(filters.statusList, status),
})
}
className="h-4 w-4"
/>
<span className="truncate max-w-[200px]">{status}</span>
</label>
))}
</div>
</div>
{/* Channels - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Каналы размещения</Label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{channels.map((channel) => (
<label
key={channel.id}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.placementChannelIds.includes(channel.id)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.placementChannelIds.includes(channel.id)}
onCheckedChange={() =>
setFilters({
...filters,
placementChannelIds: toggleArrayItem(filters.placementChannelIds, channel.id),
})
}
className="h-4 w-4"
/>
<span className="truncate max-w-[200px]">{channel.title}</span>
</label>
))}
{channels.length === 0 && (
<p className="text-sm text-muted-foreground">Нет доступных каналов</p>
)}
</div>
</div>
{/* Creatives - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Креативы</Label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{creatives.map((creative) => (
<label
key={creative.id}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.creativeIds.includes(creative.id)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.creativeIds.includes(creative.id)}
onCheckedChange={() =>
setFilters({
...filters,
creativeIds: toggleArrayItem(filters.creativeIds, creative.id),
})
}
className="h-4 w-4"
/>
<span className="truncate max-w-[200px]">{creative.name}</span>
</label>
))}
{creatives.length === 0 && (
<p className="text-sm text-muted-foreground">Нет доступных креативов</p>
)}
</div>
</div>
{/* Cost Type - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Формат оплаты</Label>
<div className="flex flex-wrap gap-2">
{COST_TYPE_OPTIONS.map((opt) => (
<label
key={opt.value}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.costTypes.includes(opt.value)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.costTypes.includes(opt.value)}
onCheckedChange={() =>
setFilters({
...filters,
costTypes: toggleArrayItem(filters.costTypes, opt.value),
})
}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
{/* Placement Type - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Тип закупа</Label>
<div className="flex flex-wrap gap-2">
{PLACEMENT_TYPE_OPTIONS.map((opt) => (
<label
key={opt.value}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.placementTypes.includes(opt.value)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.placementTypes.includes(opt.value)}
onCheckedChange={() =>
setFilters({
...filters,
placementTypes: toggleArrayItem(filters.placementTypes, opt.value),
})
}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
{/* Invite Link Type - Multi-select */}
<div className="space-y-3">
<Label className="text-sm font-medium">Тип ссылки</Label>
<div className="flex flex-wrap gap-2">
{INVITE_LINK_TYPE_OPTIONS.map((opt) => (
<label
key={opt.value}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.inviteLinkTypes.includes(opt.value)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.inviteLinkTypes.includes(opt.value)}
onCheckedChange={() =>
setFilters({
...filters,
inviteLinkTypes: toggleArrayItem(filters.inviteLinkTypes, opt.value),
})
}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
</div>
)}
{activeTab === "numbers" && (
<div className="space-y-6">
{/* Cost range */}
<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-9"
value={filters.costMin ?? ""}
onChange={(e) =>
setFilters({
...filters,
costMin: e.target.value ? parseFloat(e.target.value) : null,
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="до"
className="h-9"
value={filters.costMax ?? ""}
onChange={(e) =>
setFilters({
...filters,
costMax: e.target.value ? parseFloat(e.target.value) : null,
})
}
/>
</div>
</div>
{/* Views range */}
<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-9"
value={filters.viewsMin ?? ""}
onChange={(e) =>
setFilters({
...filters,
viewsMin: e.target.value ? parseInt(e.target.value) : null,
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="до"
className="h-9"
value={filters.viewsMax ?? ""}
onChange={(e) =>
setFilters({
...filters,
viewsMax: e.target.value ? parseInt(e.target.value) : null,
})
}
/>
</div>
</div>
{/* Subscriptions range */}
<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-9"
value={filters.subscriptionsMin ?? ""}
onChange={(e) =>
setFilters({
...filters,
subscriptionsMin: e.target.value ? parseInt(e.target.value) : null,
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="до"
className="h-9"
value={filters.subscriptionsMax ?? ""}
onChange={(e) =>
setFilters({
...filters,
subscriptionsMax: e.target.value ? parseInt(e.target.value) : null,
})
}
/>
</div>
</div>
{/* CPM range */}
<div className="space-y-3">
<Label className="text-sm font-medium">CPM ()</Label>
<div className="flex gap-2 items-center">
<Input
type="number"
placeholder="от"
className="h-9"
value={filters.cpmMin ?? ""}
onChange={(e) =>
setFilters({
...filters,
cpmMin: e.target.value ? parseFloat(e.target.value) : null,
})
}
/>
<span className="text-muted-foreground"></span>
<Input
type="number"
placeholder="до"
className="h-9"
value={filters.cpmMax ?? ""}
onChange={(e) =>
setFilters({
...filters,
cpmMax: e.target.value ? parseFloat(e.target.value) : null,
})
}
/>
</div>
</div>
</div>
)}
{activeTab === "text" && (
<div className="space-y-6">
{/* Channel title */}
<div className="space-y-3">
<Label className="text-sm font-medium">Название канала (содержит)</Label>
<Input
placeholder="Введите название канала"
value={filters.channelTitleContains}
onChange={(e) =>
setFilters({ ...filters, channelTitleContains: e.target.value })
}
/>
</div>
{/* Creative name */}
<div className="space-y-3">
<Label className="text-sm font-medium">Название креатива (содержит)</Label>
<Input
placeholder="Введите название креатива"
value={filters.creativeNameContains}
onChange={(e) =>
setFilters({ ...filters, creativeNameContains: e.target.value })
}
/>
</div>
{/* Comment */}
<div className="space-y-3">
<Label className="text-sm font-medium">Комментарий (содержит)</Label>
<Input
placeholder="Введите текст комментария"
value={filters.commentContains}
onChange={(e) =>
setFilters({ ...filters, commentContains: e.target.value })
}
/>
</div>
</div>
)}
{activeTab === "dates" && (
<div className="space-y-6">
{/* Placement date range */}
<div className="space-y-3">
<Label className="text-sm font-medium">Дата размещения</Label>
<div className="flex gap-2 items-center">
<Input
type="date"
className="h-9"
value={filters.placementDateFrom}
onChange={(e) =>
setFilters({ ...filters, placementDateFrom: e.target.value })
}
/>
<span className="text-muted-foreground"></span>
<Input
type="date"
className="h-9"
value={filters.placementDateTo}
onChange={(e) =>
setFilters({ ...filters, placementDateTo: e.target.value })
}
/>
</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>
);
}