@vendure/dashboard 3.4.1-master-202508070243 → 3.4.1-master-202508120233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -6
- package/src/app/routes/_authenticated/index.tsx +100 -82
- package/src/app/styles.css +0 -4
- package/src/lib/components/ui/grid-layout.tsx +376 -0
- package/src/lib/framework/dashboard-widget/base-widget.tsx +1 -1
- package/src/lib/framework/dashboard-widget/orders-summary/index.tsx +5 -7
- package/src/lib/framework/defaults.ts +1 -0
- package/src/lib/framework/extension-api/types/widgets.ts +4 -0
- package/src/lib/providers/user-settings.tsx +17 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.4.1-master-
|
|
4
|
+
"version": "3.4.1-master-202508120233",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -99,10 +99,9 @@
|
|
|
99
99
|
"@tiptap/starter-kit": "^2.11.5",
|
|
100
100
|
"@types/react": "^19.0.10",
|
|
101
101
|
"@types/react-dom": "^19.0.4",
|
|
102
|
-
"@types/react-grid-layout": "^1.3.5",
|
|
103
102
|
"@uidotdev/usehooks": "^2.4.1",
|
|
104
|
-
"@vendure/common": "^3.4.1-master-
|
|
105
|
-
"@vendure/core": "^3.4.1-master-
|
|
103
|
+
"@vendure/common": "^3.4.1-master-202508120233",
|
|
104
|
+
"@vendure/core": "^3.4.1-master-202508120233",
|
|
106
105
|
"@vitejs/plugin-react": "^4.3.4",
|
|
107
106
|
"acorn": "^8.11.3",
|
|
108
107
|
"acorn-walk": "^8.3.2",
|
|
@@ -126,7 +125,6 @@
|
|
|
126
125
|
"react-day-picker": "^9.8.0",
|
|
127
126
|
"react-dom": "^19.0.0",
|
|
128
127
|
"react-dropzone": "^14.3.8",
|
|
129
|
-
"react-grid-layout": "^1.5.1",
|
|
130
128
|
"react-hook-form": "^7.60.0",
|
|
131
129
|
"react-resizable-panels": "^3.0.3",
|
|
132
130
|
"recharts": "^2.15.4",
|
|
@@ -154,5 +152,5 @@
|
|
|
154
152
|
"lightningcss-linux-arm64-musl": "^1.29.3",
|
|
155
153
|
"lightningcss-linux-x64-musl": "^1.29.1"
|
|
156
154
|
},
|
|
157
|
-
"gitHead": "
|
|
155
|
+
"gitHead": "050ab3e98d6054ea65ea01924f69dfd1a9c85306"
|
|
158
156
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Button } from '@/vdb/components/ui/button.js';
|
|
2
|
+
import type { GridLayout as GridLayoutType } from '@/vdb/components/ui/grid-layout.js';
|
|
3
|
+
import { GridLayout } from '@/vdb/components/ui/grid-layout.js';
|
|
2
4
|
import {
|
|
3
5
|
getDashboardWidget,
|
|
4
6
|
getDashboardWidgetRegistry,
|
|
@@ -12,12 +14,9 @@ import {
|
|
|
12
14
|
PageLayout,
|
|
13
15
|
PageTitle,
|
|
14
16
|
} from '@/vdb/framework/layout-engine/page-layout.js';
|
|
17
|
+
import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
|
|
15
18
|
import { createFileRoute } from '@tanstack/react-router';
|
|
16
|
-
import { useEffect,
|
|
17
|
-
import { Responsive as ResponsiveGridLayout } from 'react-grid-layout';
|
|
18
|
-
|
|
19
|
-
import 'react-grid-layout/css/styles.css';
|
|
20
|
-
import 'react-resizable/css/styles.css';
|
|
19
|
+
import { useEffect, useState, useRef } from 'react';
|
|
21
20
|
|
|
22
21
|
export const Route = createFileRoute('/_authenticated/')({
|
|
23
22
|
component: DashboardPage,
|
|
@@ -27,13 +26,11 @@ const findNextPosition = (
|
|
|
27
26
|
existingWidgets: DashboardWidgetInstance[],
|
|
28
27
|
newWidgetSize: { w: number; h: number },
|
|
29
28
|
) => {
|
|
30
|
-
// Create a set of all occupied cells
|
|
31
29
|
const occupied = new Set();
|
|
32
30
|
let maxExistingRow = 0;
|
|
33
31
|
|
|
34
32
|
existingWidgets.forEach(widget => {
|
|
35
33
|
const { x, y, w, h } = widget.layout;
|
|
36
|
-
// Track the maximum row used by existing widgets
|
|
37
34
|
maxExistingRow = Math.max(maxExistingRow, y + h);
|
|
38
35
|
|
|
39
36
|
for (let i = x; i < x + w; i++) {
|
|
@@ -43,16 +40,13 @@ const findNextPosition = (
|
|
|
43
40
|
}
|
|
44
41
|
});
|
|
45
42
|
|
|
46
|
-
// Search up to 3 rows past the last occupied row
|
|
47
43
|
const maxSearchRows = maxExistingRow + 3;
|
|
48
44
|
|
|
49
|
-
// Find first position where the widget fits
|
|
50
45
|
for (let y = 0; y < maxSearchRows; y++) {
|
|
51
|
-
for (let x = 0; x
|
|
46
|
+
for (let x = 0; x <= 12 - newWidgetSize.w; x++) {
|
|
52
47
|
let fits = true;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for (let j = y; j < y + (newWidgetSize.h || 1); j++) {
|
|
48
|
+
for (let i = x; i < x + newWidgetSize.w; i++) {
|
|
49
|
+
for (let j = y; j < y + newWidgetSize.h; j++) {
|
|
56
50
|
if (occupied.has(`${i},${j}`)) {
|
|
57
51
|
fits = false;
|
|
58
52
|
break;
|
|
@@ -65,46 +59,52 @@ const findNextPosition = (
|
|
|
65
59
|
}
|
|
66
60
|
}
|
|
67
61
|
}
|
|
68
|
-
// If no space found, place it in the next row after all existing widgets
|
|
69
62
|
return { x: 0, y: maxExistingRow };
|
|
70
63
|
};
|
|
71
64
|
|
|
72
65
|
function DashboardPage() {
|
|
73
66
|
const [widgets, setWidgets] = useState<DashboardWidgetInstance[]>([]);
|
|
74
67
|
const [editMode, setEditMode] = useState(false);
|
|
75
|
-
const [
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (!layoutRef.current) return;
|
|
80
|
-
|
|
81
|
-
const resizeObserver = new ResizeObserver(entries => {
|
|
82
|
-
for (const entry of entries) {
|
|
83
|
-
setLayoutWidth(entry.contentRect.width);
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
resizeObserver.observe(layoutRef.current);
|
|
68
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
69
|
+
const prevEditModeRef = useRef(editMode);
|
|
88
70
|
|
|
89
|
-
|
|
90
|
-
resizeObserver.disconnect();
|
|
91
|
-
};
|
|
92
|
-
}, []);
|
|
71
|
+
const { settings, setWidgetLayout } = useUserSettings();
|
|
93
72
|
|
|
94
73
|
useEffect(() => {
|
|
74
|
+
const savedLayouts = settings.widgetLayout || {};
|
|
75
|
+
|
|
95
76
|
const initialWidgets = Array.from(getDashboardWidgetRegistry().entries()).reduce(
|
|
96
77
|
(acc: DashboardWidgetInstance[], [id, widget]) => {
|
|
78
|
+
const defaultSize = {
|
|
79
|
+
w: widget.defaultSize.w ?? 4, // Default 4 columns
|
|
80
|
+
h: widget.defaultSize.h ?? 3, // Default 3 rows
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Use minSize if specified, otherwise fall back to defaultSize
|
|
84
|
+
const minSize = {
|
|
85
|
+
w: widget.minSize?.w ?? defaultSize.w,
|
|
86
|
+
h: widget.minSize?.h ?? defaultSize.h,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Check if we have a saved layout for this widget
|
|
90
|
+
const savedLayout = savedLayouts[id];
|
|
91
|
+
|
|
97
92
|
const layout = {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
w: savedLayout?.w ?? defaultSize.w,
|
|
94
|
+
h: savedLayout?.h ?? defaultSize.h,
|
|
95
|
+
x: savedLayout?.x ?? widget.defaultSize.x ?? 0,
|
|
96
|
+
y: savedLayout?.y ?? widget.defaultSize.y ?? 0,
|
|
97
|
+
minW: minSize.w,
|
|
98
|
+
minH: minSize.h,
|
|
99
|
+
maxW: widget.maxSize?.w,
|
|
100
|
+
maxH: widget.maxSize?.h,
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
//
|
|
104
|
-
if (
|
|
103
|
+
// Only find next position if we don't have a saved layout
|
|
104
|
+
if (!savedLayout) {
|
|
105
105
|
const pos = findNextPosition(acc, {
|
|
106
|
-
w:
|
|
107
|
-
h:
|
|
106
|
+
w: layout.w,
|
|
107
|
+
h: layout.h,
|
|
108
108
|
});
|
|
109
109
|
layout.x = pos.x;
|
|
110
110
|
layout.y = pos.y;
|
|
@@ -123,69 +123,87 @@ function DashboardPage() {
|
|
|
123
123
|
);
|
|
124
124
|
|
|
125
125
|
setWidgets(initialWidgets);
|
|
126
|
-
|
|
126
|
+
setIsInitialized(true);
|
|
127
|
+
}, [settings.widgetLayout]);
|
|
128
|
+
|
|
129
|
+
// Save layout when edit mode is turned off
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
// Only save when transitioning from edit mode ON to OFF
|
|
132
|
+
if (prevEditModeRef.current && !editMode && isInitialized && widgets.length > 0) {
|
|
133
|
+
const layoutConfig: Record<string, { x: number; y: number; w: number; h: number }> = {};
|
|
134
|
+
widgets.forEach(widget => {
|
|
135
|
+
layoutConfig[widget.widgetId] = {
|
|
136
|
+
x: widget.layout.x,
|
|
137
|
+
y: widget.layout.y,
|
|
138
|
+
w: widget.layout.w,
|
|
139
|
+
h: widget.layout.h,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
setWidgetLayout(layoutConfig);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Update the ref for next render
|
|
146
|
+
prevEditModeRef.current = editMode;
|
|
147
|
+
}, [editMode, isInitialized, widgets, setWidgetLayout]);
|
|
127
148
|
|
|
128
|
-
const handleLayoutChange = (
|
|
149
|
+
const handleLayoutChange = (layouts: GridLayoutType[]) => {
|
|
129
150
|
setWidgets(prev =>
|
|
130
151
|
prev.map((widget, i) => ({
|
|
131
152
|
...widget,
|
|
132
|
-
layout:
|
|
153
|
+
layout: layouts[i] || widget.layout,
|
|
133
154
|
})),
|
|
134
155
|
);
|
|
135
156
|
};
|
|
136
157
|
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
layouts={{ lg: widgets.map(w => ({ ...w.layout, i: w.id })) }}
|
|
145
|
-
onLayoutChange={handleLayoutChange}
|
|
146
|
-
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
|
147
|
-
rowHeight={100}
|
|
148
|
-
isDraggable={editMode}
|
|
149
|
-
isResizable={editMode}
|
|
150
|
-
autoSize={true}
|
|
151
|
-
innerRef={layoutRef}
|
|
152
|
-
transformScale={0.9}
|
|
153
|
-
>
|
|
154
|
-
{widgets.map(widget => {
|
|
155
|
-
const definition = getDashboardWidget(widget.widgetId);
|
|
156
|
-
if (!definition) return null;
|
|
157
|
-
const WidgetComponent = definition.component;
|
|
158
|
-
|
|
159
|
-
return (
|
|
160
|
-
<div key={widget.id}>
|
|
161
|
-
<WidgetComponent id={widget.id} config={widget.config} />
|
|
162
|
-
</div>
|
|
163
|
-
);
|
|
164
|
-
})}
|
|
165
|
-
</ResponsiveGridLayout>
|
|
166
|
-
)
|
|
167
|
-
);
|
|
168
|
-
}, [layoutWidth, editMode, widgets]);
|
|
158
|
+
const renderWidget = (widget: DashboardWidgetInstance) => {
|
|
159
|
+
const definition = getDashboardWidget(widget.widgetId);
|
|
160
|
+
if (!definition) return null;
|
|
161
|
+
const WidgetComponent = definition.component;
|
|
162
|
+
|
|
163
|
+
return <WidgetComponent key={widget.id} id={widget.id} config={widget.config} />;
|
|
164
|
+
};
|
|
169
165
|
|
|
170
166
|
return (
|
|
171
167
|
<Page pageId="insights">
|
|
172
168
|
<PageTitle>Insights</PageTitle>
|
|
173
169
|
<PageActionBar>
|
|
174
170
|
<PageActionBarRight>
|
|
175
|
-
<Button
|
|
176
|
-
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<span className="text-xs text-muted-foreground">OFF</span>
|
|
181
|
-
)}
|
|
171
|
+
<Button
|
|
172
|
+
variant={editMode ? "default" : "outline"}
|
|
173
|
+
onClick={() => setEditMode(prev => !prev)}
|
|
174
|
+
>
|
|
175
|
+
{editMode ? "Save Layout" : "Edit Layout"}
|
|
182
176
|
</Button>
|
|
183
177
|
</PageActionBarRight>
|
|
184
178
|
</PageActionBar>
|
|
185
179
|
<PageLayout>
|
|
186
180
|
<FullWidthPageBlock blockId="widgets">
|
|
187
|
-
<div
|
|
188
|
-
{
|
|
181
|
+
<div className="w-full">
|
|
182
|
+
{widgets.length > 0 ? (
|
|
183
|
+
<GridLayout
|
|
184
|
+
layouts={widgets.map(w => ({ ...w.layout, i: w.id }))}
|
|
185
|
+
onLayoutChange={handleLayoutChange}
|
|
186
|
+
cols={12}
|
|
187
|
+
rowHeight={100}
|
|
188
|
+
isDraggable={editMode}
|
|
189
|
+
isResizable={editMode}
|
|
190
|
+
className="min-h-[400px]"
|
|
191
|
+
gutter={10}
|
|
192
|
+
>
|
|
193
|
+
{
|
|
194
|
+
widgets
|
|
195
|
+
.map(widget => renderWidget(widget))
|
|
196
|
+
.filter(Boolean) as React.ReactElement[]
|
|
197
|
+
}
|
|
198
|
+
</GridLayout>
|
|
199
|
+
) : (
|
|
200
|
+
<div
|
|
201
|
+
className="flex items-center justify-center text-muted-foreground"
|
|
202
|
+
style={{ height: '400px' }}
|
|
203
|
+
>
|
|
204
|
+
No widgets available
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
189
207
|
</div>
|
|
190
208
|
</FullWidthPageBlock>
|
|
191
209
|
</PageLayout>
|
package/src/app/styles.css
CHANGED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
3
|
+
import { cn } from "@/vdb/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface GridLayout {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
i: string;
|
|
11
|
+
minW?: number;
|
|
12
|
+
minH?: number;
|
|
13
|
+
maxW?: number;
|
|
14
|
+
maxH?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GridLayoutProps {
|
|
18
|
+
children: React.ReactElement[];
|
|
19
|
+
layouts: GridLayout[];
|
|
20
|
+
onLayoutChange?: (layouts: GridLayout[]) => void;
|
|
21
|
+
cols?: number;
|
|
22
|
+
rowHeight?: number;
|
|
23
|
+
isDraggable?: boolean;
|
|
24
|
+
isResizable?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
gutter?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GridItemProps {
|
|
30
|
+
layout: GridLayout;
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
isDraggable?: boolean;
|
|
33
|
+
isResizable?: boolean;
|
|
34
|
+
onLayoutChange?: (layout: GridLayout) => void;
|
|
35
|
+
onInteractionStart?: () => void;
|
|
36
|
+
onInteractionEnd?: () => void;
|
|
37
|
+
cols?: number;
|
|
38
|
+
rowHeight?: number;
|
|
39
|
+
gutter?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function GridItem({
|
|
43
|
+
layout,
|
|
44
|
+
children,
|
|
45
|
+
isDraggable = false,
|
|
46
|
+
isResizable = false,
|
|
47
|
+
onLayoutChange,
|
|
48
|
+
onInteractionStart,
|
|
49
|
+
onInteractionEnd,
|
|
50
|
+
cols = 12,
|
|
51
|
+
rowHeight = 100,
|
|
52
|
+
gutter = 10,
|
|
53
|
+
}: GridItemProps) {
|
|
54
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
55
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
56
|
+
const [dragStart, setDragStart] = useState({ x: 0, y: 0, mouseX: 0, mouseY: 0 });
|
|
57
|
+
const itemRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
|
|
59
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
60
|
+
if (!isDraggable || isResizing) return;
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
|
|
64
|
+
const rect = itemRef.current?.getBoundingClientRect();
|
|
65
|
+
if (!rect) return;
|
|
66
|
+
|
|
67
|
+
setIsDragging(true);
|
|
68
|
+
onInteractionStart?.();
|
|
69
|
+
setDragStart({
|
|
70
|
+
x: layout.x,
|
|
71
|
+
y: layout.y,
|
|
72
|
+
mouseX: e.clientX,
|
|
73
|
+
mouseY: e.clientY,
|
|
74
|
+
});
|
|
75
|
+
}, [isDraggable, isResizing, layout.x, layout.y, onInteractionStart]);
|
|
76
|
+
|
|
77
|
+
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
78
|
+
if (!isResizable) return;
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
setIsResizing(true);
|
|
82
|
+
onInteractionStart?.();
|
|
83
|
+
setDragStart({
|
|
84
|
+
x: layout.w,
|
|
85
|
+
y: layout.h,
|
|
86
|
+
mouseX: e.clientX,
|
|
87
|
+
mouseY: e.clientY,
|
|
88
|
+
});
|
|
89
|
+
}, [isResizable, layout.w, layout.h, onInteractionStart]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
93
|
+
if (!itemRef.current) return;
|
|
94
|
+
|
|
95
|
+
const containerRect = itemRef.current.parentElement?.getBoundingClientRect();
|
|
96
|
+
if (!containerRect) return;
|
|
97
|
+
|
|
98
|
+
const colWidth = (containerRect.width - gutter * (cols - 1)) / cols;
|
|
99
|
+
|
|
100
|
+
if (isDragging && onLayoutChange) {
|
|
101
|
+
const deltaX = e.clientX - dragStart.mouseX;
|
|
102
|
+
const deltaY = e.clientY - dragStart.mouseY;
|
|
103
|
+
|
|
104
|
+
const newX = Math.round(dragStart.x + deltaX / colWidth);
|
|
105
|
+
const newY = Math.round(dragStart.y + deltaY / rowHeight);
|
|
106
|
+
|
|
107
|
+
onLayoutChange({
|
|
108
|
+
...layout,
|
|
109
|
+
x: Math.max(0, Math.min(cols - layout.w, newX)),
|
|
110
|
+
y: Math.max(0, newY),
|
|
111
|
+
});
|
|
112
|
+
} else if (isResizing && onLayoutChange) {
|
|
113
|
+
const deltaX = e.clientX - dragStart.mouseX;
|
|
114
|
+
const deltaY = e.clientY - dragStart.mouseY;
|
|
115
|
+
|
|
116
|
+
const newW = Math.round(dragStart.x + deltaX / colWidth);
|
|
117
|
+
const newH = Math.round(dragStart.y + deltaY / rowHeight);
|
|
118
|
+
|
|
119
|
+
// Apply min/max constraints
|
|
120
|
+
const minW = layout.minW ?? 1;
|
|
121
|
+
const minH = layout.minH ?? 1;
|
|
122
|
+
const maxW = layout.maxW ?? (cols - layout.x);
|
|
123
|
+
const maxH = layout.maxH ?? 999;
|
|
124
|
+
|
|
125
|
+
onLayoutChange({
|
|
126
|
+
...layout,
|
|
127
|
+
w: Math.max(minW, Math.min(maxW, Math.min(cols - layout.x, newW))),
|
|
128
|
+
h: Math.max(minH, Math.min(maxH, newH)),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleMouseUp = () => {
|
|
134
|
+
if (isDragging || isResizing) {
|
|
135
|
+
onInteractionEnd?.();
|
|
136
|
+
}
|
|
137
|
+
setIsDragging(false);
|
|
138
|
+
setIsResizing(false);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (isDragging || isResizing) {
|
|
142
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
143
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
144
|
+
return () => {
|
|
145
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
146
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}, [isDragging, isResizing, dragStart, layout, onLayoutChange, onInteractionEnd, cols, rowHeight]);
|
|
150
|
+
|
|
151
|
+
const colWidth = `calc((100% - ${gutter * (cols - 1)}px) / ${cols})`;
|
|
152
|
+
const style: React.CSSProperties = {
|
|
153
|
+
position: 'absolute',
|
|
154
|
+
left: `calc(${layout.x} * (${colWidth} + ${gutter}px))`,
|
|
155
|
+
top: `calc(${layout.y} * (${rowHeight}px + ${gutter}px))`,
|
|
156
|
+
width: `calc(${layout.w} * ${colWidth} + ${(layout.w - 1) * gutter}px)`,
|
|
157
|
+
height: `calc(${layout.h} * ${rowHeight}px + ${(layout.h - 1) * gutter}px)`,
|
|
158
|
+
zIndex: isDragging || isResizing ? 1000 : 10, // Normal widgets above grid (10), active widget much higher (1000)
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
ref={itemRef}
|
|
164
|
+
style={style}
|
|
165
|
+
className={cn(
|
|
166
|
+
"transition-shadow",
|
|
167
|
+
isDraggable && !isResizing && "cursor-move",
|
|
168
|
+
(isDragging || isResizing) && "shadow-lg",
|
|
169
|
+
)}
|
|
170
|
+
onMouseDown={handleMouseDown}
|
|
171
|
+
>
|
|
172
|
+
<div className="h-full w-full">
|
|
173
|
+
{children}
|
|
174
|
+
</div>
|
|
175
|
+
{isResizable && (
|
|
176
|
+
<div
|
|
177
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize bg-muted-foreground/20 hover:bg-muted-foreground/40 transition-colors"
|
|
178
|
+
style={{
|
|
179
|
+
clipPath: "polygon(100% 0%, 0% 100%, 100% 100%)",
|
|
180
|
+
}}
|
|
181
|
+
onMouseDown={handleResizeStart}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function GridLayout({
|
|
189
|
+
children,
|
|
190
|
+
layouts,
|
|
191
|
+
onLayoutChange,
|
|
192
|
+
cols = 12,
|
|
193
|
+
rowHeight = 100,
|
|
194
|
+
isDraggable = false,
|
|
195
|
+
isResizable = false,
|
|
196
|
+
className,
|
|
197
|
+
gutter = 10,
|
|
198
|
+
}: GridLayoutProps) {
|
|
199
|
+
const [showGrid, setShowGrid] = useState(false);
|
|
200
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
201
|
+
|
|
202
|
+
// Detect mobile screen size
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const checkMobile = () => {
|
|
205
|
+
setIsMobile(window.innerWidth < 768); // Tailwind's md breakpoint
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
checkMobile();
|
|
209
|
+
window.addEventListener('resize', checkMobile);
|
|
210
|
+
return () => window.removeEventListener('resize', checkMobile);
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
// Transform layouts for mobile - stack widgets vertically in full width
|
|
214
|
+
const mobileLayouts = React.useMemo(() => {
|
|
215
|
+
if (!isMobile) return layouts;
|
|
216
|
+
|
|
217
|
+
return layouts.map((layout, index) => ({
|
|
218
|
+
...layout,
|
|
219
|
+
x: 0,
|
|
220
|
+
y: layouts.slice(0, index).reduce((sum, l) => sum + l.h, 0),
|
|
221
|
+
w: cols, // Full width
|
|
222
|
+
}));
|
|
223
|
+
}, [layouts, isMobile, cols]);
|
|
224
|
+
|
|
225
|
+
const effectiveLayouts = isMobile ? mobileLayouts : layouts;
|
|
226
|
+
const effectiveGutter = isMobile ? 5 : gutter; // Smaller gutter on mobile
|
|
227
|
+
const maxRow = Math.max(...effectiveLayouts.map(l => l.y + l.h), 4); // Minimum 4 rows
|
|
228
|
+
const containerHeight = maxRow * rowHeight + (maxRow - 1) * effectiveGutter;
|
|
229
|
+
|
|
230
|
+
// Helper function to check if two layouts overlap
|
|
231
|
+
const layoutsOverlap = (a: GridLayout, b: GridLayout): boolean => {
|
|
232
|
+
return !(
|
|
233
|
+
a.x + a.w <= b.x || // a is left of b
|
|
234
|
+
b.x + b.w <= a.x || // b is left of a
|
|
235
|
+
a.y + a.h <= b.y || // a is above b
|
|
236
|
+
b.y + b.h <= a.y // b is above a
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Helper function to find the next available position for a widget
|
|
241
|
+
const findNextAvailablePosition = (
|
|
242
|
+
widget: GridLayout,
|
|
243
|
+
occupiedLayouts: GridLayout[],
|
|
244
|
+
draggedWidget?: GridLayout
|
|
245
|
+
): GridLayout => {
|
|
246
|
+
const sortedLayouts = [...occupiedLayouts]
|
|
247
|
+
.filter(l => l.i !== widget.i && (!draggedWidget || l.i !== draggedWidget.i))
|
|
248
|
+
.sort((a, b) => a.y === b.y ? a.x - b.x : a.y - b.y);
|
|
249
|
+
|
|
250
|
+
// Try to place widget in rows, starting from its current position
|
|
251
|
+
for (let y = widget.y; y < 100; y++) { // Max 100 rows
|
|
252
|
+
for (let x = 0; x <= cols - widget.w; x++) {
|
|
253
|
+
const testLayout = { ...widget, x, y };
|
|
254
|
+
|
|
255
|
+
// Check if this position overlaps with any other widget
|
|
256
|
+
const hasOverlap = sortedLayouts.some(layout => layoutsOverlap(testLayout, layout));
|
|
257
|
+
|
|
258
|
+
// Also check overlap with dragged widget if provided
|
|
259
|
+
if (!hasOverlap && (!draggedWidget || !layoutsOverlap(testLayout, draggedWidget))) {
|
|
260
|
+
return testLayout;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Fallback: place at the bottom
|
|
266
|
+
const maxY = Math.max(...sortedLayouts.map(l => l.y + l.h), 0);
|
|
267
|
+
return { ...widget, x: 0, y: maxY };
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handleItemLayoutChange = useCallback((newLayout: GridLayout) => {
|
|
271
|
+
if (onLayoutChange && !isMobile) { // Disable layout changes on mobile
|
|
272
|
+
const newLayouts = [...layouts];
|
|
273
|
+
const draggedIndex = layouts.findIndex(l => l.i === newLayout.i);
|
|
274
|
+
|
|
275
|
+
if (draggedIndex === -1) return;
|
|
276
|
+
|
|
277
|
+
// Update the dragged widget's position
|
|
278
|
+
newLayouts[draggedIndex] = newLayout;
|
|
279
|
+
|
|
280
|
+
// Find widgets that overlap with the new position
|
|
281
|
+
const overlappingWidgets: number[] = [];
|
|
282
|
+
|
|
283
|
+
for (let i = 0; i < newLayouts.length; i++) {
|
|
284
|
+
if (i !== draggedIndex && layoutsOverlap(newLayout, newLayouts[i])) {
|
|
285
|
+
overlappingWidgets.push(i);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Move overlapping widgets to new positions
|
|
290
|
+
for (const index of overlappingWidgets) {
|
|
291
|
+
const widgetToMove = newLayouts[index];
|
|
292
|
+
const newPosition = findNextAvailablePosition(widgetToMove, newLayouts, newLayout);
|
|
293
|
+
newLayouts[index] = newPosition;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
onLayoutChange(newLayouts);
|
|
297
|
+
}
|
|
298
|
+
}, [layouts, onLayoutChange, cols, isMobile]);
|
|
299
|
+
|
|
300
|
+
const handleInteractionStart = useCallback(() => {
|
|
301
|
+
setShowGrid(true);
|
|
302
|
+
}, []);
|
|
303
|
+
|
|
304
|
+
const handleInteractionEnd = useCallback(() => {
|
|
305
|
+
setShowGrid(false);
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
// Create grid overlay
|
|
309
|
+
const renderGridOverlay = () => {
|
|
310
|
+
if (!showGrid) return null;
|
|
311
|
+
|
|
312
|
+
const gridCells = [];
|
|
313
|
+
for (let row = 0; row < maxRow; row++) {
|
|
314
|
+
for (let col = 0; col < cols; col++) {
|
|
315
|
+
const colWidth = `calc((100% - ${effectiveGutter * (cols - 1)}px) / ${cols})`;
|
|
316
|
+
const cellStyle: React.CSSProperties = {
|
|
317
|
+
position: 'absolute',
|
|
318
|
+
left: `calc(${col} * (${colWidth} + ${effectiveGutter}px))`,
|
|
319
|
+
top: `calc(${row} * (${rowHeight}px + ${effectiveGutter}px))`,
|
|
320
|
+
width: colWidth,
|
|
321
|
+
height: `${rowHeight}px`,
|
|
322
|
+
pointerEvents: 'none',
|
|
323
|
+
zIndex: 0, // Behind widgets but above background
|
|
324
|
+
boxSizing: 'border-box',
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
gridCells.push(
|
|
328
|
+
<div
|
|
329
|
+
key={`grid-${row}-${col}`}
|
|
330
|
+
style={cellStyle}
|
|
331
|
+
className="transition-opacity duration-200 border-2 border-dashed border-primary bg-primary/10"
|
|
332
|
+
/>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return gridCells;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div
|
|
342
|
+
className={cn("relative w-full bg-muted/10", className)}
|
|
343
|
+
style={{
|
|
344
|
+
height: `${containerHeight}px`,
|
|
345
|
+
backgroundImage: `
|
|
346
|
+
linear-gradient(to right, hsl(var(--border)) 1px, transparent 1px),
|
|
347
|
+
linear-gradient(to bottom, hsl(var(--border)) 1px, transparent 1px)
|
|
348
|
+
`,
|
|
349
|
+
backgroundSize: `${100 / cols}% ${rowHeight}px`,
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
{children.map((child, index) => {
|
|
353
|
+
const layout = effectiveLayouts[index];
|
|
354
|
+
if (!layout) return null;
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<GridItem
|
|
358
|
+
key={layout.i}
|
|
359
|
+
layout={layout}
|
|
360
|
+
isDraggable={isDraggable && !isMobile} // Disable dragging on mobile
|
|
361
|
+
isResizable={isResizable && !isMobile} // Disable resizing on mobile
|
|
362
|
+
onLayoutChange={handleItemLayoutChange}
|
|
363
|
+
onInteractionStart={handleInteractionStart}
|
|
364
|
+
onInteractionEnd={handleInteractionEnd}
|
|
365
|
+
cols={cols}
|
|
366
|
+
rowHeight={rowHeight}
|
|
367
|
+
gutter={effectiveGutter}
|
|
368
|
+
>
|
|
369
|
+
{child}
|
|
370
|
+
</GridItem>
|
|
371
|
+
);
|
|
372
|
+
})}
|
|
373
|
+
{renderGridOverlay()}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { AnimatedCurrency, AnimatedNumber } from '@/vdb/components/shared/animated-number.js';
|
|
2
2
|
import { Tabs, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
|
|
3
3
|
import { api } from '@/vdb/graphql/api.js';
|
|
4
|
-
import { useChannel } from '@/vdb/hooks/use-channel.js';
|
|
5
|
-
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
6
4
|
import { useQuery } from '@tanstack/react-query';
|
|
7
5
|
import { endOfDay, endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from 'date-fns';
|
|
8
6
|
import { useMemo, useState } from 'react';
|
|
@@ -139,9 +137,9 @@ export function OrdersSummaryWidget() {
|
|
|
139
137
|
</Tabs>
|
|
140
138
|
}
|
|
141
139
|
>
|
|
142
|
-
<div className="flex flex-col gap-4 items-center justify-center text-center">
|
|
143
|
-
<div className="flex flex-col gap-2">
|
|
144
|
-
<p className="text-lg text-muted-foreground">Total Orders</p>
|
|
140
|
+
<div className="flex flex-col lg:gap-4 items-center justify-center text-center">
|
|
141
|
+
<div className="flex flex-col lg:gap-2">
|
|
142
|
+
<p className="lg:text-lg text-muted-foreground">Total Orders</p>
|
|
145
143
|
<p className="text-3xl font-semibold">
|
|
146
144
|
<AnimatedNumber
|
|
147
145
|
animationConfig={{ mass: 0.5, stiffness: 90, damping: 10 }}
|
|
@@ -150,8 +148,8 @@ export function OrdersSummaryWidget() {
|
|
|
150
148
|
</p>
|
|
151
149
|
<PercentageChange value={orderChange} />
|
|
152
150
|
</div>
|
|
153
|
-
<div className="flex flex-col gap-2">
|
|
154
|
-
<p className="text-lg text-muted-foreground">Total Revenue</p>
|
|
151
|
+
<div className="flex flex-col lg:gap-2">
|
|
152
|
+
<p className="lg:text-lg text-muted-foreground">Total Revenue</p>
|
|
155
153
|
<p className="text-3xl font-semibold">
|
|
156
154
|
<AnimatedCurrency
|
|
157
155
|
animationConfig={{ mass: 0.2, stiffness: 90, damping: 10 }}
|
|
@@ -26,6 +26,7 @@ export interface UserSettings {
|
|
|
26
26
|
devMode: boolean;
|
|
27
27
|
hasSeenOnboarding: boolean;
|
|
28
28
|
tableSettings?: Record<string, TableSettings>;
|
|
29
|
+
widgetLayout?: Record<string, { x: number; y: number; w: number; h: number }>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const defaultSettings: UserSettings = {
|
|
@@ -57,6 +58,7 @@ export interface UserSettingsContextType {
|
|
|
57
58
|
key: K,
|
|
58
59
|
value: TableSettings[K],
|
|
59
60
|
) => void;
|
|
61
|
+
setWidgetLayout: (layoutConfig: Record<string, { x: number; y: number; w: number; h: number }>) => void;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
|
|
@@ -87,6 +89,7 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
87
89
|
const [serverSettings, setServerSettings] = useState<UserSettings | null>(null);
|
|
88
90
|
const [isReady, setIsReady] = useState(false);
|
|
89
91
|
const previousContentLanguage = useRef(settings.contentLanguage);
|
|
92
|
+
const saveInProgressRef = useRef(false);
|
|
90
93
|
|
|
91
94
|
// Load settings from server on mount
|
|
92
95
|
const { data: serverSettingsResponse, isSuccess: serverSettingsLoaded } = useQuery({
|
|
@@ -102,8 +105,14 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
102
105
|
api.mutate(setSettingsStoreValueDocument, {
|
|
103
106
|
input: { key: SETTINGS_STORE_KEY, value: settingsToSave },
|
|
104
107
|
}),
|
|
108
|
+
onSuccess: (_, settingsSaved) => {
|
|
109
|
+
// Only update serverSettings after successful save
|
|
110
|
+
setServerSettings(settingsSaved);
|
|
111
|
+
saveInProgressRef.current = false;
|
|
112
|
+
},
|
|
105
113
|
onError: error => {
|
|
106
114
|
console.error('Failed to save user settings to server:', error);
|
|
115
|
+
saveInProgressRef.current = false;
|
|
107
116
|
},
|
|
108
117
|
});
|
|
109
118
|
|
|
@@ -119,6 +128,10 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
119
128
|
setSettings(mergedSettings);
|
|
120
129
|
setServerSettings(mergedSettings);
|
|
121
130
|
setIsReady(true);
|
|
131
|
+
} else {
|
|
132
|
+
// Server has no settings, use local settings
|
|
133
|
+
setServerSettings(settings);
|
|
134
|
+
setIsReady(true);
|
|
122
135
|
}
|
|
123
136
|
} catch (e) {
|
|
124
137
|
console.error('Failed to parse server settings:', e);
|
|
@@ -139,12 +152,13 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
139
152
|
|
|
140
153
|
// Save to server when settings differ from server state
|
|
141
154
|
useEffect(() => {
|
|
142
|
-
if (isReady && serverSettings) {
|
|
155
|
+
if (isReady && serverSettings && !saveInProgressRef.current) {
|
|
143
156
|
const serverDiffers = JSON.stringify(serverSettings) !== JSON.stringify(settings);
|
|
144
157
|
|
|
145
158
|
if (serverDiffers) {
|
|
159
|
+
saveInProgressRef.current = true;
|
|
146
160
|
saveToServerMutation.mutate(settings);
|
|
147
|
-
|
|
161
|
+
// Don't update serverSettings here - wait for mutation success
|
|
148
162
|
}
|
|
149
163
|
}
|
|
150
164
|
}, [settings, serverSettings, isReady, saveToServerMutation]);
|
|
@@ -182,6 +196,7 @@ export const UserSettingsProvider: React.FC<UserSettingsProviderProps> = ({ quer
|
|
|
182
196
|
},
|
|
183
197
|
}));
|
|
184
198
|
},
|
|
199
|
+
setWidgetLayout: layoutConfig => updateSetting('widgetLayout', layoutConfig),
|
|
185
200
|
};
|
|
186
201
|
|
|
187
202
|
return <UserSettingsContext.Provider value={contextValue}>{children}</UserSettingsContext.Provider>;
|