feat: add frag&drop
This commit is contained in:
@@ -5,6 +5,23 @@ import { DashboardHeader } from "@/components/layout/dashboard-header";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { ChartConfigPanel } from "@/components/analytics/chart-config-panel";
|
import { ChartConfigPanel } from "@/components/analytics/chart-config-panel";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -37,8 +54,62 @@ interface ChartData {
|
|||||||
};
|
};
|
||||||
data: SpendingAnalytics | null;
|
data: SpendingAnalytics | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
width: "full" | "half";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Компонент для sortable item
|
||||||
|
interface SortableChartItemProps {
|
||||||
|
chart: ChartData;
|
||||||
|
index: number;
|
||||||
|
channels: TargetChannel[];
|
||||||
|
chartsLength: number;
|
||||||
|
onBuild: (chartId: string, config: ChartData["config"]) => void;
|
||||||
|
onRemove: (chartId: string) => void;
|
||||||
|
onWidthChange: (chartId: string, width: "full" | "half") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableChartItem: React.FC<SortableChartItemProps> = ({
|
||||||
|
chart,
|
||||||
|
index,
|
||||||
|
channels,
|
||||||
|
chartsLength,
|
||||||
|
onBuild,
|
||||||
|
onRemove,
|
||||||
|
onWidthChange,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: chart.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className={chart.width === "half" ? "" : "col-span-full"}>
|
||||||
|
<ChartConfigPanel
|
||||||
|
channels={channels}
|
||||||
|
data={chart.data}
|
||||||
|
loading={chart.loading}
|
||||||
|
width={chart.width}
|
||||||
|
onBuild={(config) => onBuild(chart.id, config)}
|
||||||
|
onRemove={chartsLength > 1 ? () => onRemove(chart.id) : undefined}
|
||||||
|
onWidthChange={(width) => onWidthChange(chart.id, width)}
|
||||||
|
showRemoveButton={chartsLength > 1}
|
||||||
|
chartNumber={index + 1}
|
||||||
|
dragHandleProps={{ ...attributes, ...listeners }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function TargetChannelsAnalyticsPage() {
|
export default function TargetChannelsAnalyticsPage() {
|
||||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||||
const [charts, setCharts] = useState<ChartData[]>([
|
const [charts, setCharts] = useState<ChartData[]>([
|
||||||
@@ -53,8 +124,16 @@ export default function TargetChannelsAnalyticsPage() {
|
|||||||
},
|
},
|
||||||
data: null,
|
data: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
width: "full",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -191,6 +270,7 @@ export default function TargetChannelsAnalyticsPage() {
|
|||||||
},
|
},
|
||||||
data: null,
|
data: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
width: "full",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -203,6 +283,27 @@ export default function TargetChannelsAnalyticsPage() {
|
|||||||
setCharts(charts.filter((c) => c.id !== chartId));
|
setCharts(charts.filter((c) => c.id !== chartId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWidthChange = (chartId: string, width: "full" | "half") => {
|
||||||
|
setCharts(
|
||||||
|
charts.map((chart) =>
|
||||||
|
chart.id === chartId ? { ...chart, width } : chart
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setCharts((items) => {
|
||||||
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||||
|
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||||
|
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Собираем все данные для таблицы
|
// Собираем все данные для таблицы
|
||||||
const getAllTableData = (): SpendingDataPoint[] => {
|
const getAllTableData = (): SpendingDataPoint[] => {
|
||||||
const allData: SpendingDataPoint[] = [];
|
const allData: SpendingDataPoint[] = [];
|
||||||
@@ -294,24 +395,31 @@ export default function TargetChannelsAnalyticsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Графики */}
|
{/* Графики */}
|
||||||
<div className="space-y-6">
|
<DndContext
|
||||||
{charts.map((chart, index) => (
|
sensors={sensors}
|
||||||
<ChartConfigPanel
|
collisionDetection={closestCenter}
|
||||||
key={chart.id}
|
onDragEnd={handleDragEnd}
|
||||||
channels={channels}
|
>
|
||||||
data={chart.data}
|
<SortableContext
|
||||||
loading={chart.loading}
|
items={charts.map((c) => c.id)}
|
||||||
onBuild={(config) => handleBuildChart(chart.id, config)}
|
strategy={verticalListSortingStrategy}
|
||||||
onRemove={
|
>
|
||||||
charts.length > 1
|
<div className="grid grid-cols-2 gap-6">
|
||||||
? () => handleRemoveChart(chart.id)
|
{charts.map((chart, index) => (
|
||||||
: undefined
|
<SortableChartItem
|
||||||
}
|
key={chart.id}
|
||||||
showRemoveButton={charts.length > 1}
|
chart={chart}
|
||||||
chartNumber={index + 1}
|
index={index}
|
||||||
/>
|
channels={channels}
|
||||||
))}
|
chartsLength={charts.length}
|
||||||
</div>
|
onBuild={handleBuildChart}
|
||||||
|
onRemove={handleRemoveChart}
|
||||||
|
onWidthChange={handleWidthChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{/* Таблица */}
|
{/* Таблица */}
|
||||||
{tableData.length > 0 && (
|
{tableData.length > 0 && (
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ru } from "date-fns/locale";
|
import { ru } from "date-fns/locale";
|
||||||
import { CalendarIcon, X, TrendingUp, BarChart3, LineChart } from "lucide-react";
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
X,
|
||||||
|
TrendingUp,
|
||||||
|
BarChart3,
|
||||||
|
LineChart,
|
||||||
|
GripVertical,
|
||||||
|
Maximize2,
|
||||||
|
Columns2,
|
||||||
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
TargetChannel,
|
TargetChannel,
|
||||||
AnalyticsMetric,
|
AnalyticsMetric,
|
||||||
@@ -41,6 +50,7 @@ interface ChartConfigPanelProps {
|
|||||||
channels: TargetChannel[];
|
channels: TargetChannel[];
|
||||||
data: SpendingAnalytics | null;
|
data: SpendingAnalytics | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
width: "full" | "half";
|
||||||
onBuild: (config: {
|
onBuild: (config: {
|
||||||
targetChannelIds: string[];
|
targetChannelIds: string[];
|
||||||
metrics: AnalyticsMetric[];
|
metrics: AnalyticsMetric[];
|
||||||
@@ -49,8 +59,10 @@ interface ChartConfigPanelProps {
|
|||||||
grouping: DateGrouping;
|
grouping: DateGrouping;
|
||||||
}) => void;
|
}) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
|
onWidthChange?: (width: "full" | "half") => void;
|
||||||
showRemoveButton?: boolean;
|
showRemoveButton?: boolean;
|
||||||
chartNumber?: number;
|
chartNumber?: number;
|
||||||
|
dragHandleProps?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const METRICS: { value: AnalyticsMetric; label: string }[] = [
|
const METRICS: { value: AnalyticsMetric; label: string }[] = [
|
||||||
@@ -73,10 +85,13 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
channels,
|
channels,
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
|
width,
|
||||||
onBuild,
|
onBuild,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onWidthChange,
|
||||||
showRemoveButton = false,
|
showRemoveButton = false,
|
||||||
chartNumber = 1,
|
chartNumber = 1,
|
||||||
|
dragHandleProps,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
|
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
|
||||||
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
|
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
|
||||||
@@ -85,11 +100,11 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
const [grouping, setGrouping] = useState<DateGrouping>("day");
|
const [grouping, setGrouping] = useState<DateGrouping>("day");
|
||||||
const [chartType, setChartType] = useState<"line" | "bar">("line");
|
const [chartType, setChartType] = useState<"line" | "bar">("line");
|
||||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
const [showDataLabels, setShowDataLabels] = useState(false);
|
||||||
|
|
||||||
// Отслеживание изменений настроек
|
// Отслеживание изменений настроек
|
||||||
const [lastBuiltConfig, setLastBuiltConfig] = useState<string | null>(null);
|
const [lastBuiltConfig, setLastBuiltConfig] = useState<string | null>(null);
|
||||||
const [isBuilt, setIsBuilt] = useState(false);
|
const [isBuilt, setIsBuilt] = useState(false);
|
||||||
|
|
||||||
// Текущая конфигурация как строка для сравнения
|
// Текущая конфигурация как строка для сравнения
|
||||||
const currentConfigString = JSON.stringify({
|
const currentConfigString = JSON.stringify({
|
||||||
selectedChannelIds,
|
selectedChannelIds,
|
||||||
@@ -98,7 +113,7 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
dateTo: dateTo?.toISOString(),
|
dateTo: dateTo?.toISOString(),
|
||||||
grouping,
|
grouping,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем, изменились ли настройки
|
// Проверяем, изменились ли настройки
|
||||||
const hasChanges = isBuilt && lastBuiltConfig !== currentConfigString;
|
const hasChanges = isBuilt && lastBuiltConfig !== currentConfigString;
|
||||||
|
|
||||||
@@ -131,35 +146,72 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
dateTo: dateTo || null,
|
dateTo: dateTo || null,
|
||||||
grouping,
|
grouping,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLastBuiltConfig(currentConfigString);
|
setLastBuiltConfig(currentConfigString);
|
||||||
setIsBuilt(true);
|
setIsBuilt(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getButtonText = () => {
|
const getButtonText = () => {
|
||||||
if (loading) return "Загрузка...";
|
if (loading) return "Загрузка...";
|
||||||
if (!isBuilt) return "Построить график";
|
if (!isBuilt) return "Построить график";
|
||||||
if (hasChanges) return "Обновить график";
|
if (hasChanges) return "Обновить график";
|
||||||
return "График построен";
|
return "График построен";
|
||||||
};
|
};
|
||||||
|
|
||||||
const isButtonDisabled = loading || (isBuilt && !hasChanges);
|
const isButtonDisabled = loading || (isBuilt && !hasChanges);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>График #{chartNumber}</CardTitle>
|
{/* Drag handle */}
|
||||||
<CardDescription className="mt-1">
|
<div
|
||||||
Настройте параметры и постройте график
|
{...dragHandleProps}
|
||||||
</CardDescription>
|
className="cursor-grab active:cursor-grabbing touch-none"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5 text-muted-foreground hover:text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>График #{chartNumber}</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Настройте параметры и постройте график
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Width toggle */}
|
||||||
|
{onWidthChange && (
|
||||||
|
<div className="flex gap-1 border rounded-md mr-2">
|
||||||
|
<Button
|
||||||
|
variant={width === "full" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onWidthChange("full")}
|
||||||
|
className="rounded-r-none"
|
||||||
|
title="Полная ширина"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={width === "half" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onWidthChange("half")}
|
||||||
|
className="rounded-l-none"
|
||||||
|
title="Половина ширины"
|
||||||
|
>
|
||||||
|
<Columns2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
{showRemoveButton && onRemove && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onRemove}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showRemoveButton && onRemove && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onRemove}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -170,10 +222,7 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
<Label>Целевые каналы</Label>
|
<Label>Целевые каналы</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className="w-full justify-start">
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start"
|
|
||||||
>
|
|
||||||
{selectedChannelIds.length === 0
|
{selectedChannelIds.length === 0
|
||||||
? "Выберите каналы"
|
? "Выберите каналы"
|
||||||
: `Выбрано: ${selectedChannelIds.length}`}
|
: `Выбрано: ${selectedChannelIds.length}`}
|
||||||
@@ -209,10 +258,7 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
<Label>Метрики</Label>
|
<Label>Метрики</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className="w-full justify-start">
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start"
|
|
||||||
>
|
|
||||||
{selectedMetrics.length === 0
|
{selectedMetrics.length === 0
|
||||||
? "Выберите метрики"
|
? "Выберите метрики"
|
||||||
: `Выбрано: ${selectedMetrics.length}`}
|
: `Выбрано: ${selectedMetrics.length}`}
|
||||||
@@ -369,7 +415,9 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="invisible">Построить</Label>
|
<Label className="invisible">Построить</Label>
|
||||||
<Button onClick={handleBuild} disabled={isButtonDisabled}>
|
<Button onClick={handleBuild} disabled={isButtonDisabled}>
|
||||||
<TrendingUp className={cn("mr-2 h-4 w-4", loading && "animate-pulse")} />
|
<TrendingUp
|
||||||
|
className={cn("mr-2 h-4 w-4", loading && "animate-pulse")}
|
||||||
|
/>
|
||||||
{getButtonText()}
|
{getButtonText()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,4 +436,3 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user