feat: update analitycs

This commit is contained in:
ivannoskov
2025-12-01 07:43:47 +03:00
parent 270e75e192
commit e06b53d133
9 changed files with 1631 additions and 3 deletions

View File

@@ -0,0 +1,181 @@
"use client";
import React from "react";
import { CartesianGrid, Line, LineChart, XAxis, Bar, BarChart } from "recharts";
import { CardContent } from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
} from "@/components/ui/chart";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import type { SpendingDataPoint, AnalyticsMetric } from "@/lib/types/api";
interface AnalyticsChartProps {
data: SpendingDataPoint[];
metrics: AnalyticsMetric[];
chartType: "line" | "bar";
showDataLabels?: boolean;
}
const METRIC_LABELS: Record<AnalyticsMetric, string> = {
cost: "Расходы",
subscriptions: "Подписки",
views: "Просмотры",
cpf: "CPF",
cpm: "CPM",
};
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
data,
metrics,
chartType,
showDataLabels = false,
}) => {
// Создаем конфигурацию для chart
const chartConfig: ChartConfig = metrics.reduce((acc, metric, index) => {
acc[metric] = {
label: METRIC_LABELS[metric],
color: `var(--chart-${index + 1})`,
};
return acc;
}, {} as ChartConfig);
const formatValue = (value: number | null, metric: AnalyticsMetric) => {
if (value === null) return "—";
if (metric === "cost" || metric === "cpf" || metric === "cpm") {
return formatCurrency(value);
}
return formatNumber(value);
};
if (chartType === "bar") {
return (
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[350px] w-full"
>
<BarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
top: showDataLabels ? 20 : 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) => `Период: ${value}`}
formatter={(value, name) => [
formatValue(
value as number,
name as AnalyticsMetric
),
chartConfig[name as AnalyticsMetric]?.label || name,
]}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{metrics.map((metric) => (
<Bar
key={metric}
dataKey={metric}
fill={chartConfig[metric]?.color}
radius={4}
label={
showDataLabels
? {
position: "top",
formatter: (value: number) =>
formatValue(value, metric),
fontSize: 10,
}
: undefined
}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
);
}
return (
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[350px] w-full"
>
<LineChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
top: showDataLabels ? 20 : 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) => `Период: ${value}`}
formatter={(value, name) => [
formatValue(
value as number,
name as AnalyticsMetric
),
chartConfig[name as AnalyticsMetric]?.label || name,
]}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{metrics.map((metric) => (
<Line
key={metric}
dataKey={metric}
type="monotone"
stroke={chartConfig[metric]?.color}
strokeWidth={2}
dot={showDataLabels}
label={
showDataLabels
? {
position: "top",
formatter: (value: number) =>
formatValue(value, metric),
fontSize: 10,
}
: undefined
}
/>
))}
</LineChart>
</ChartContainer>
</CardContent>
);
};

View File

@@ -0,0 +1,391 @@
"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>
);
};