feat: add frag&drop

This commit is contained in:
ivannoskov
2025-12-01 07:48:35 +03:00
parent e06b53d133
commit 2ea90d1b55
2 changed files with 201 additions and 46 deletions

View File

@@ -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 && (

View File

@@ -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[]>([]);
@@ -148,18 +163,55 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
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>
); );
}; };