Files
tgex-frontend/components/analytics/chart-config-panel.tsx
2025-12-01 07:43:47 +03:00

392 lines
13 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 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>
);
};