639 lines
24 KiB
TypeScript
639 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|