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 { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -37,8 +54,62 @@ interface ChartData {
|
||||
};
|
||||
data: SpendingAnalytics | null;
|
||||
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() {
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const [charts, setCharts] = useState<ChartData[]>([
|
||||
@@ -53,8 +124,16 @@ export default function TargetChannelsAnalyticsPage() {
|
||||
},
|
||||
data: null,
|
||||
loading: false,
|
||||
width: "full",
|
||||
},
|
||||
]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -191,6 +270,7 @@ export default function TargetChannelsAnalyticsPage() {
|
||||
},
|
||||
data: null,
|
||||
loading: false,
|
||||
width: "full",
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -203,6 +283,27 @@ export default function TargetChannelsAnalyticsPage() {
|
||||
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 allData: SpendingDataPoint[] = [];
|
||||
@@ -294,24 +395,31 @@ export default function TargetChannelsAnalyticsPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Графики */}
|
||||
<div className="space-y-6">
|
||||
{charts.map((chart, index) => (
|
||||
<ChartConfigPanel
|
||||
key={chart.id}
|
||||
channels={channels}
|
||||
data={chart.data}
|
||||
loading={chart.loading}
|
||||
onBuild={(config) => handleBuildChart(chart.id, config)}
|
||||
onRemove={
|
||||
charts.length > 1
|
||||
? () => handleRemoveChart(chart.id)
|
||||
: undefined
|
||||
}
|
||||
showRemoveButton={charts.length > 1}
|
||||
chartNumber={index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={charts.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{charts.map((chart, index) => (
|
||||
<SortableChartItem
|
||||
key={chart.id}
|
||||
chart={chart}
|
||||
index={index}
|
||||
channels={channels}
|
||||
chartsLength={charts.length}
|
||||
onBuild={handleBuildChart}
|
||||
onRemove={handleRemoveChart}
|
||||
onWidthChange={handleWidthChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Таблица */}
|
||||
{tableData.length > 0 && (
|
||||
|
||||
@@ -28,7 +28,16 @@ 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 {
|
||||
CalendarIcon,
|
||||
X,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
LineChart,
|
||||
GripVertical,
|
||||
Maximize2,
|
||||
Columns2,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
TargetChannel,
|
||||
AnalyticsMetric,
|
||||
@@ -41,6 +50,7 @@ interface ChartConfigPanelProps {
|
||||
channels: TargetChannel[];
|
||||
data: SpendingAnalytics | null;
|
||||
loading: boolean;
|
||||
width: "full" | "half";
|
||||
onBuild: (config: {
|
||||
targetChannelIds: string[];
|
||||
metrics: AnalyticsMetric[];
|
||||
@@ -49,8 +59,10 @@ interface ChartConfigPanelProps {
|
||||
grouping: DateGrouping;
|
||||
}) => void;
|
||||
onRemove?: () => void;
|
||||
onWidthChange?: (width: "full" | "half") => void;
|
||||
showRemoveButton?: boolean;
|
||||
chartNumber?: number;
|
||||
dragHandleProps?: any;
|
||||
}
|
||||
|
||||
const METRICS: { value: AnalyticsMetric; label: string }[] = [
|
||||
@@ -73,10 +85,13 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||||
channels,
|
||||
data,
|
||||
loading,
|
||||
width,
|
||||
onBuild,
|
||||
onRemove,
|
||||
onWidthChange,
|
||||
showRemoveButton = false,
|
||||
chartNumber = 1,
|
||||
dragHandleProps,
|
||||
}) => {
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
|
||||
@@ -148,18 +163,55 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>График #{chartNumber}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Настройте параметры и постройте график
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
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>
|
||||
{showRemoveButton && onRemove && (
|
||||
<Button variant="ghost" size="sm" onClick={onRemove}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -170,10 +222,7 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||||
<Label>Целевые каналы</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
{selectedChannelIds.length === 0
|
||||
? "Выберите каналы"
|
||||
: `Выбрано: ${selectedChannelIds.length}`}
|
||||
@@ -209,10 +258,7 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||||
<Label>Метрики</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
{selectedMetrics.length === 0
|
||||
? "Выберите метрики"
|
||||
: `Выбрано: ${selectedMetrics.length}`}
|
||||
@@ -369,7 +415,9 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||||
<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")} />
|
||||
<TrendingUp
|
||||
className={cn("mr-2 h-4 w-4", loading && "animate-pulse")}
|
||||
/>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -388,4 +436,3 @@ export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user