2026-07-10 14:56:11 -07:00
|
|
|
import { Box, Text, Flex, IconButton, Spinner, Popover } from "@chakra-ui/react";
|
|
|
|
|
import { X, MoreVertical } from "lucide-react";
|
2026-06-09 14:06:01 -07:00
|
|
|
import { Responsive, WidthProvider } from "react-grid-layout";
|
|
|
|
|
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
2026-07-09 15:10:50 -07:00
|
|
|
import { Suspense, useEffect, useRef } from 'react';
|
2026-07-15 13:56:12 -07:00
|
|
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
2026-07-09 15:10:50 -07:00
|
|
|
// 1. Import database messenger
|
|
|
|
|
import useAuthStore from "@/store/authStore";
|
|
|
|
|
|
|
|
|
|
// 2. Import registry
|
2026-07-08 15:05:50 -07:00
|
|
|
import { WIDGET_REGISTRY } from '@/constants/widgetRegistry.jsx';
|
2025-07-21 14:27:53 -07:00
|
|
|
|
2026-06-09 14:06:01 -07:00
|
|
|
// React-Grid-Layout requires this wrapper to automatically calculate screen width
|
|
|
|
|
const ResponsiveGridLayout = WidthProvider(Responsive);
|
2025-07-22 11:58:10 -07:00
|
|
|
|
2026-06-09 14:06:01 -07:00
|
|
|
export default function MyDashboard() {
|
2026-07-15 13:56:12 -07:00
|
|
|
// 🌗 Let's adjust for our light and dark mode
|
2026-07-22 10:37:01 -07:00
|
|
|
const frameBg = useColorModeValue("gray.100", "#1E1E2B");
|
2026-07-15 13:56:12 -07:00
|
|
|
const titleColor = useColorModeValue("gray.800", "gray.100");
|
|
|
|
|
const iconColor = useColorModeValue("gray.600", "gray.400");
|
|
|
|
|
const iconHoverBg = useColorModeValue("gray.100", "gray.700");
|
|
|
|
|
|
2026-07-09 15:10:50 -07:00
|
|
|
// ☁️ Database Connection
|
|
|
|
|
const { user } = useAuthStore();
|
|
|
|
|
|
|
|
|
|
// ☁️ Local UI Connections
|
2026-07-08 15:05:50 -07:00
|
|
|
const widgets = useDashboardStore(state => state.widgets);
|
|
|
|
|
const isEditing = useDashboardStore(state => state.isEditing);
|
|
|
|
|
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
|
|
|
|
|
const removeWidget = useDashboardStore(state => state.removeWidget);
|
2026-07-09 15:10:50 -07:00
|
|
|
const setWidgets = useDashboardStore(state => state.setWidgets); // The injection action
|
2025-07-22 13:41:32 -07:00
|
|
|
|
2026-07-09 15:10:50 -07:00
|
|
|
// 🔒 Safety lock to prevent infinite re-renders
|
|
|
|
|
const hasHydrated = useRef(false);
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
// INITIAL LOAD: Grab data from Django
|
|
|
|
|
// ==========================================
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
|
|
|
|
|
const savedLayout = user.preferences.dashboard_layout;
|
|
|
|
|
|
|
|
|
|
// Safety check: Only inject if it's actually an array of widgets.
|
|
|
|
|
// (Prevents a crash if MariaDB returns a default empty object {})
|
|
|
|
|
if (Array.isArray(savedLayout)) {
|
|
|
|
|
setWidgets(savedLayout);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hasHydrated.current = true;
|
|
|
|
|
}
|
|
|
|
|
}, [user, setWidgets]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 🛠️ Format the data for RGL
|
2026-07-08 15:05:50 -07:00
|
|
|
const rglLayout = widgets.map(w => ({
|
|
|
|
|
i: w.id,
|
|
|
|
|
x: w.x,
|
|
|
|
|
y: w.y,
|
|
|
|
|
w: w.w,
|
|
|
|
|
h: w.h,
|
|
|
|
|
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 3. THE COMPONENT MAPPER
|
|
|
|
|
const renderWidget = (widget) => {
|
|
|
|
|
const registryEntry = WIDGET_REGISTRY[widget.type];
|
|
|
|
|
|
|
|
|
|
if (registryEntry && registryEntry.component) {
|
|
|
|
|
const Component = registryEntry.component;
|
|
|
|
|
|
2026-07-10 14:56:11 -07:00
|
|
|
// 1. Check if this widget type has a settings component registered
|
|
|
|
|
const SettingsUI = registryEntry.settingsComponent;
|
|
|
|
|
|
2026-07-08 15:05:50 -07:00
|
|
|
return (
|
|
|
|
|
// The "Frame" that holds both the Title and the Chart
|
2026-07-15 13:56:12 -07:00
|
|
|
<Flex direction="column" height="100%" width="100%" bg={frameBg}>
|
2026-07-08 15:05:50 -07:00
|
|
|
|
2026-07-10 14:56:11 -07:00
|
|
|
{/* THE TITLE BAR */}
|
2026-07-08 15:05:50 -07:00
|
|
|
<Flex
|
|
|
|
|
justify="space-between"
|
|
|
|
|
align="center"
|
|
|
|
|
px={3}
|
|
|
|
|
py={2}
|
|
|
|
|
>
|
2026-07-15 13:56:12 -07:00
|
|
|
<Text color={titleColor} fontWeight="semibold" fontSize="md" isTruncated>
|
2026-07-08 15:05:50 -07:00
|
|
|
{registryEntry.name}
|
|
|
|
|
</Text>
|
|
|
|
|
|
2026-07-10 14:56:11 -07:00
|
|
|
{/* THE SETTINGS ELLIPSIS */}
|
|
|
|
|
{SettingsUI ? (
|
|
|
|
|
<Popover.Root positioning={{ placement: "bottom-end" }}>
|
|
|
|
|
<Popover.Trigger asChild>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
color="gray.400"
|
|
|
|
|
_hover={{ color: "white", bg: "gray.700" }}
|
|
|
|
|
aria-label="Widget Settings"
|
|
|
|
|
>
|
|
|
|
|
<MoreVertical size={16} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Popover.Trigger>
|
|
|
|
|
|
|
|
|
|
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
|
|
|
|
|
<Popover.Positioner zIndex={10}>
|
|
|
|
|
<Popover.Content bg="gray.50" p={4} boxShadow="xl" borderRadius="md">
|
|
|
|
|
<Suspense fallback={<Spinner size="sm" />}>
|
|
|
|
|
<SettingsUI
|
|
|
|
|
widgetId={widget.id}
|
|
|
|
|
currentSettings={widget.settings || {}}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
</Popover.Content>
|
|
|
|
|
</Popover.Positioner>
|
|
|
|
|
</Popover.Root>
|
|
|
|
|
) : (
|
|
|
|
|
<Box w="20px" h="20px" />
|
|
|
|
|
)}
|
2026-07-08 15:05:50 -07:00
|
|
|
</Flex>
|
|
|
|
|
|
2026-07-10 14:56:11 -07:00
|
|
|
{/* THE CHART CANVAS */}
|
2026-07-08 15:05:50 -07:00
|
|
|
<Box flex="1" overflow="hidden" position="relative" p={1}>
|
2026-07-10 14:56:11 -07:00
|
|
|
{/* Pass the settings object into the actual chart component */}
|
|
|
|
|
<Component settings={widget.settings || {}} />
|
2026-07-08 15:05:50 -07:00
|
|
|
</Box>
|
2025-08-08 13:00:33 -07:00
|
|
|
|
2026-06-09 14:06:01 -07:00
|
|
|
</Flex>
|
2026-07-08 15:05:50 -07:00
|
|
|
);
|
|
|
|
|
}
|
2026-06-09 14:06:01 -07:00
|
|
|
|
2026-07-08 15:05:50 -07:00
|
|
|
// Fallback if someone asks for a widget that doesn't exist
|
|
|
|
|
return (
|
|
|
|
|
<Flex width="100%" height="100%" align="center" justify="center" direction="column" bg="gray.800">
|
|
|
|
|
<Text fontWeight="bold" color="red.400">Error</Text>
|
|
|
|
|
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
|
2026-06-04 15:30:01 -07:00
|
|
|
</Flex>
|
2026-07-08 15:05:50 -07:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box width="100%" height="100%">
|
|
|
|
|
<ResponsiveGridLayout
|
|
|
|
|
className="layout"
|
|
|
|
|
layouts={{ lg: rglLayout }} // We feed it the formatted layout map
|
|
|
|
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
|
|
|
|
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} // Our 12-column master grid
|
|
|
|
|
rowHeight={30}
|
2026-07-09 15:10:50 -07:00
|
|
|
isDraggable={isEditing} // Only allows dragging when Settings tools toggle this
|
2026-07-08 15:05:50 -07:00
|
|
|
isResizable={false} // Double lock-down
|
|
|
|
|
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
|
|
|
|
|
compactType="vertical" // Auto-packs widgets to the top
|
|
|
|
|
>
|
|
|
|
|
{/* 3. 🎨 Paint the Widgets */}
|
|
|
|
|
{widgets.map(widget => (
|
|
|
|
|
<Box
|
|
|
|
|
key={widget.id}
|
|
|
|
|
bg="white"
|
|
|
|
|
borderRadius="md"
|
|
|
|
|
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
|
|
|
|
|
border={isEditing ? "2px dashed gray" : "1px solid"}
|
|
|
|
|
borderColor={isEditing ? "gray.400" : "gray.200"}
|
|
|
|
|
overflow="hidden"
|
|
|
|
|
cursor={isEditing ? "grab" : "default"}
|
|
|
|
|
position="relative" // 3. REQUIRED so the absolute button stays inside this box
|
|
|
|
|
>
|
|
|
|
|
{/* 4. THE DELETE BUTTON */}
|
|
|
|
|
{isEditing && (
|
|
|
|
|
<IconButton
|
|
|
|
|
aria-label="Remove widget"
|
|
|
|
|
position="absolute"
|
|
|
|
|
top={2}
|
|
|
|
|
right={2}
|
|
|
|
|
size="xs"
|
|
|
|
|
bg="white"
|
2026-07-15 13:56:12 -07:00
|
|
|
color={iconColor}
|
2026-07-08 15:05:50 -07:00
|
|
|
border="1px solid"
|
|
|
|
|
borderColor="gray.200"
|
|
|
|
|
_hover={{ bg: "red.50", color: "red.500", borderColor: "red.200" }}
|
|
|
|
|
zIndex={2}
|
|
|
|
|
onClick={() => removeWidget(widget.id)}
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
|
|
|
|
|
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
|
|
|
|
|
>
|
|
|
|
|
<X size={14} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
|
|
|
|
|
<Suspense fallback={
|
|
|
|
|
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
|
|
|
|
|
<Spinner size="md" color="blue.400" thickness="3px" />
|
|
|
|
|
</Flex>
|
|
|
|
|
}>
|
|
|
|
|
{renderWidget(widget)}
|
|
|
|
|
</Suspense>
|
|
|
|
|
|
|
|
|
|
</Box>
|
|
|
|
|
))}
|
|
|
|
|
</ResponsiveGridLayout>
|
|
|
|
|
|
|
|
|
|
{/* Empty State Helper */}
|
|
|
|
|
{widgets.length === 0 && (
|
|
|
|
|
<Flex width="100%" height="200px" align="center" justify="center">
|
|
|
|
|
<Text color="gray.400">Your dashboard is empty. Click Settings to add widgets.</Text>
|
|
|
|
|
</Flex>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
2026-06-09 14:06:01 -07:00
|
|
|
}
|