392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState } from "react";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Calendar } from "@/components/ui/calendar";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { cn } from "@/lib/utils";
|
||
import { format } from "date-fns";
|
||
import { ru } from "date-fns/locale";
|
||
import { CalendarIcon, X, TrendingUp, BarChart3, LineChart } from "lucide-react";
|
||
import type {
|
||
TargetChannel,
|
||
AnalyticsMetric,
|
||
DateGrouping,
|
||
} from "@/lib/types/api";
|
||
import { AnalyticsChart } from "./analytics-chart";
|
||
import type { SpendingAnalytics } from "@/lib/types/api";
|
||
|
||
interface ChartConfigPanelProps {
|
||
channels: TargetChannel[];
|
||
data: SpendingAnalytics | null;
|
||
loading: boolean;
|
||
onBuild: (config: {
|
||
targetChannelIds: string[];
|
||
metrics: AnalyticsMetric[];
|
||
dateFrom: Date | null;
|
||
dateTo: Date | null;
|
||
grouping: DateGrouping;
|
||
}) => void;
|
||
onRemove?: () => void;
|
||
showRemoveButton?: boolean;
|
||
chartNumber?: number;
|
||
}
|
||
|
||
const METRICS: { value: AnalyticsMetric; label: string }[] = [
|
||
{ value: "cost", label: "Расходы" },
|
||
{ value: "subscriptions", label: "Подписки" },
|
||
{ value: "views", label: "Просмотры" },
|
||
{ value: "cpf", label: "CPF (Cost Per Follow)" },
|
||
{ value: "cpm", label: "CPM (Cost Per Mille)" },
|
||
];
|
||
|
||
const GROUPINGS: { value: DateGrouping; label: string }[] = [
|
||
{ value: "day", label: "По дням" },
|
||
{ value: "week", label: "По неделям" },
|
||
{ value: "month", label: "По месяцам" },
|
||
{ value: "quarter", label: "По кварталам" },
|
||
{ value: "year", label: "По годам" },
|
||
];
|
||
|
||
export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||
channels,
|
||
data,
|
||
loading,
|
||
onBuild,
|
||
onRemove,
|
||
showRemoveButton = false,
|
||
chartNumber = 1,
|
||
}) => {
|
||
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
|
||
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
|
||
const [dateFrom, setDateFrom] = useState<Date | undefined>();
|
||
const [dateTo, setDateTo] = useState<Date | undefined>();
|
||
const [grouping, setGrouping] = useState<DateGrouping>("day");
|
||
const [chartType, setChartType] = useState<"line" | "bar">("line");
|
||
const [showDataLabels, setShowDataLabels] = useState(false);
|
||
|
||
// Отслеживание изменений настроек
|
||
const [lastBuiltConfig, setLastBuiltConfig] = useState<string | null>(null);
|
||
const [isBuilt, setIsBuilt] = useState(false);
|
||
|
||
// Текущая конфигурация как строка для сравнения
|
||
const currentConfigString = JSON.stringify({
|
||
selectedChannelIds,
|
||
selectedMetrics,
|
||
dateFrom: dateFrom?.toISOString(),
|
||
dateTo: dateTo?.toISOString(),
|
||
grouping,
|
||
});
|
||
|
||
// Проверяем, изменились ли настройки
|
||
const hasChanges = isBuilt && lastBuiltConfig !== currentConfigString;
|
||
|
||
const toggleChannel = (channelId: string) => {
|
||
setSelectedChannelIds((prev) =>
|
||
prev.includes(channelId)
|
||
? prev.filter((id) => id !== channelId)
|
||
: [...prev, channelId]
|
||
);
|
||
};
|
||
|
||
const toggleMetric = (metric: AnalyticsMetric) => {
|
||
setSelectedMetrics((prev) =>
|
||
prev.includes(metric)
|
||
? prev.filter((m) => m !== metric)
|
||
: [...prev, metric]
|
||
);
|
||
};
|
||
|
||
const handleBuild = () => {
|
||
if (selectedChannelIds.length === 0 || selectedMetrics.length === 0) {
|
||
alert("Выберите хотя бы один канал и одну метрику");
|
||
return;
|
||
}
|
||
|
||
onBuild({
|
||
targetChannelIds: selectedChannelIds,
|
||
metrics: selectedMetrics,
|
||
dateFrom: dateFrom || null,
|
||
dateTo: dateTo || null,
|
||
grouping,
|
||
});
|
||
|
||
setLastBuiltConfig(currentConfigString);
|
||
setIsBuilt(true);
|
||
};
|
||
|
||
const getButtonText = () => {
|
||
if (loading) return "Загрузка...";
|
||
if (!isBuilt) return "Построить график";
|
||
if (hasChanges) return "Обновить график";
|
||
return "График построен";
|
||
};
|
||
|
||
const isButtonDisabled = loading || (isBuilt && !hasChanges);
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>График #{chartNumber}</CardTitle>
|
||
<CardDescription className="mt-1">
|
||
Настройте параметры и постройте график
|
||
</CardDescription>
|
||
</div>
|
||
{showRemoveButton && onRemove && (
|
||
<Button variant="ghost" size="sm" onClick={onRemove}>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* Настройки */}
|
||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||
{/* Выбор каналов */}
|
||
<div className="space-y-2">
|
||
<Label>Целевые каналы</Label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full justify-start"
|
||
>
|
||
{selectedChannelIds.length === 0
|
||
? "Выберите каналы"
|
||
: `Выбрано: ${selectedChannelIds.length}`}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-64 p-3" align="start">
|
||
<div className="space-y-2">
|
||
{channels.map((channel) => (
|
||
<div
|
||
key={channel.id}
|
||
className="flex items-center space-x-2"
|
||
>
|
||
<Checkbox
|
||
id={`channel-${channel.id}`}
|
||
checked={selectedChannelIds.includes(channel.id)}
|
||
onCheckedChange={() => toggleChannel(channel.id)}
|
||
/>
|
||
<label
|
||
htmlFor={`channel-${channel.id}`}
|
||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||
>
|
||
{channel.title}
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Выбор метрик */}
|
||
<div className="space-y-2">
|
||
<Label>Метрики</Label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full justify-start"
|
||
>
|
||
{selectedMetrics.length === 0
|
||
? "Выберите метрики"
|
||
: `Выбрано: ${selectedMetrics.length}`}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-64 p-3" align="start">
|
||
<div className="space-y-2">
|
||
{METRICS.map((metric) => (
|
||
<div
|
||
key={metric.value}
|
||
className="flex items-center space-x-2"
|
||
>
|
||
<Checkbox
|
||
id={`metric-${metric.value}`}
|
||
checked={selectedMetrics.includes(metric.value)}
|
||
onCheckedChange={() => toggleMetric(metric.value)}
|
||
/>
|
||
<label
|
||
htmlFor={`metric-${metric.value}`}
|
||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||
>
|
||
{metric.label}
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Период от */}
|
||
<div className="space-y-2">
|
||
<Label>Дата от</Label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
className={cn(
|
||
"w-full justify-start text-left font-normal",
|
||
!dateFrom && "text-muted-foreground"
|
||
)}
|
||
>
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{dateFrom ? (
|
||
format(dateFrom, "d MMM yyyy", { locale: ru })
|
||
) : (
|
||
<span>Выберите</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={dateFrom}
|
||
onSelect={setDateFrom}
|
||
initialFocus
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Период до */}
|
||
<div className="space-y-2">
|
||
<Label>Дата до</Label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
className={cn(
|
||
"w-full justify-start text-left font-normal",
|
||
!dateTo && "text-muted-foreground"
|
||
)}
|
||
>
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{dateTo ? (
|
||
format(dateTo, "d MMM yyyy", { locale: ru })
|
||
) : (
|
||
<span>Выберите</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={dateTo}
|
||
onSelect={setDateTo}
|
||
initialFocus
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4">
|
||
{/* Группировка */}
|
||
<div className="flex-1 space-y-2">
|
||
<Label>Группировка</Label>
|
||
<Select
|
||
value={grouping}
|
||
onValueChange={(v) => setGrouping(v as DateGrouping)}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{GROUPINGS.map((g) => (
|
||
<SelectItem key={g.value} value={g.value}>
|
||
{g.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Тип графика */}
|
||
<div className="space-y-2">
|
||
<Label>Тип</Label>
|
||
<div className="flex gap-1 border rounded-md">
|
||
<Button
|
||
variant={chartType === "line" ? "default" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setChartType("line")}
|
||
className="rounded-r-none"
|
||
>
|
||
<LineChart className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant={chartType === "bar" ? "default" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setChartType("bar")}
|
||
className="rounded-l-none"
|
||
>
|
||
<BarChart3 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Подписи значений */}
|
||
<div className="space-y-2">
|
||
<Label>Подписи</Label>
|
||
<div className="flex items-center space-x-2 h-9">
|
||
<Switch
|
||
id="show-data-labels"
|
||
checked={showDataLabels}
|
||
onCheckedChange={setShowDataLabels}
|
||
/>
|
||
<Label htmlFor="show-data-labels" className="cursor-pointer">
|
||
{showDataLabels ? "Вкл" : "Выкл"}
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Кнопка построения */}
|
||
<div className="space-y-2">
|
||
<Label className="invisible">Построить</Label>
|
||
<Button onClick={handleBuild} disabled={isButtonDisabled}>
|
||
<TrendingUp className={cn("mr-2 h-4 w-4", loading && "animate-pulse")} />
|
||
{getButtonText()}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* График */}
|
||
{data && (
|
||
<AnalyticsChart
|
||
data={data.chart_data}
|
||
metrics={selectedMetrics}
|
||
chartType={chartType}
|
||
showDataLabels={showDataLabels}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|