Add modular dashboard widgets and layout editor
Some checks failed
Build Frontend / build (push) Failing after 7s
Some checks failed
Build Frontend / build (push) Failing after 7s
Introduces a modular dashboard system with draggable, configurable widgets including revenue, low stock, recent customers, and pending chats. Adds a dashboard editor for layout customization, widget visibility, and settings. Refactors dashboard content to use the new widget system and improves UI consistency and interactivity.
This commit is contained in:
195
hooks/useWidgetLayout.ts
Normal file
195
hooks/useWidgetLayout.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
|
||||
// Per-widget settings types
|
||||
export interface RecentActivitySettings {
|
||||
itemCount: number // 5, 10, 15
|
||||
}
|
||||
|
||||
export interface TopProductsSettings {
|
||||
itemCount: number // 3, 5, 10
|
||||
showRevenue: boolean
|
||||
}
|
||||
|
||||
export interface OverviewSettings {
|
||||
showChange: boolean // Show % change from previous period
|
||||
}
|
||||
|
||||
export interface RevenueChartSettings {
|
||||
days: number // 7, 14, 30
|
||||
showComparison: boolean
|
||||
}
|
||||
|
||||
export interface LowStockSettings {
|
||||
threshold: number // Show items with stock below this
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
export interface RecentCustomersSettings {
|
||||
itemCount: number
|
||||
showSpent: boolean
|
||||
}
|
||||
|
||||
export interface PendingChatsSettings {
|
||||
showPreview: boolean
|
||||
}
|
||||
|
||||
export type WidgetSettings =
|
||||
| { type: "quick-actions" }
|
||||
| { type: "overview"; settings: OverviewSettings }
|
||||
| { type: "recent-activity"; settings: RecentActivitySettings }
|
||||
| { type: "top-products"; settings: TopProductsSettings }
|
||||
| { type: "revenue-chart"; settings: RevenueChartSettings }
|
||||
| { type: "low-stock"; settings: LowStockSettings }
|
||||
| { type: "recent-customers"; settings: RecentCustomersSettings }
|
||||
| { type: "pending-chats"; settings: PendingChatsSettings }
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
title: string
|
||||
visible: boolean
|
||||
order: number
|
||||
colSpan: number // 1, 2, 3, 4 (full)
|
||||
settings?: Record<string, any>
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||
{ id: "quick-actions", title: "Quick Actions", visible: true, order: 0, colSpan: 4 },
|
||||
{ id: "overview", title: "Overview", visible: true, order: 1, colSpan: 4, settings: { showChange: false } },
|
||||
{ id: "recent-activity", title: "Recent Activity", visible: true, order: 2, colSpan: 2, settings: { itemCount: 10 } },
|
||||
{ id: "top-products", title: "Top Products", visible: true, order: 3, colSpan: 2, settings: { itemCount: 5, showRevenue: true } },
|
||||
{ id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, colSpan: 2, settings: { days: 7, showComparison: false } },
|
||||
{ id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, colSpan: 2, settings: { threshold: 5, itemCount: 5 } },
|
||||
{ id: "recent-customers", title: "Recent Customers", visible: false, order: 6, colSpan: 2, settings: { itemCount: 5, showSpent: true } },
|
||||
{ id: "pending-chats", title: "Pending Chats", visible: false, order: 7, colSpan: 2, settings: { showPreview: true } },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = "dashboard-widget-layout-v3"
|
||||
|
||||
/**
|
||||
* useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings
|
||||
*/
|
||||
export function useWidgetLayout() {
|
||||
const [widgets, setWidgets] = useState<WidgetConfig[]>(DEFAULT_WIDGETS)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as WidgetConfig[]
|
||||
// Merge with defaults to handle new widgets added in future
|
||||
const merged = DEFAULT_WIDGETS.map(defaultWidget => {
|
||||
const savedWidget = parsed.find(w => w.id === defaultWidget.id)
|
||||
return savedWidget
|
||||
? { ...defaultWidget, ...savedWidget, settings: { ...defaultWidget.settings, ...savedWidget.settings } }
|
||||
: defaultWidget
|
||||
})
|
||||
setWidgets(merged.sort((a, b) => a.order - b.order))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load widget layout:", e)
|
||||
}
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Save to localStorage whenever widgets change
|
||||
useEffect(() => {
|
||||
if (!isLoaded || typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(widgets))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save widget layout:", e)
|
||||
}
|
||||
}, [widgets, isLoaded])
|
||||
|
||||
const toggleWidget = useCallback((widgetId: string) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId ? { ...w, visible: !w.visible } : w)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const moveWidget = useCallback((widgetId: string, direction: "up" | "down") => {
|
||||
setWidgets(prev => {
|
||||
const index = prev.findIndex(w => w.id === widgetId)
|
||||
if (index === -1) return prev
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1
|
||||
if (newIndex < 0 || newIndex >= prev.length) return prev
|
||||
|
||||
const newWidgets = [...prev]
|
||||
const [widget] = newWidgets.splice(index, 1)
|
||||
newWidgets.splice(newIndex, 0, widget)
|
||||
|
||||
// Update order values
|
||||
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateWidgetSettings = useCallback((widgetId: string, newSettings: Record<string, any>) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId
|
||||
? { ...w, settings: { ...w.settings, ...newSettings } }
|
||||
: w
|
||||
)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const updateWidgetColSpan = useCallback((widgetId: string, colSpan: number) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId ? { ...w, colSpan } : w)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const getWidgetSettings = useCallback(<T extends Record<string, any>>(widgetId: string): T | undefined => {
|
||||
return widgets.find(w => w.id === widgetId)?.settings as T | undefined
|
||||
}, [widgets])
|
||||
|
||||
const resetLayout = useCallback(() => {
|
||||
setWidgets(DEFAULT_WIDGETS)
|
||||
}, [])
|
||||
|
||||
const getVisibleWidgets = useCallback(() => {
|
||||
return widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
|
||||
}, [widgets])
|
||||
|
||||
const isWidgetVisible = useCallback((widgetId: string) => {
|
||||
return widgets.find(w => w.id === widgetId)?.visible ?? true
|
||||
}, [widgets])
|
||||
|
||||
// Reorder widgets by moving activeId to overId's position
|
||||
const reorderWidgets = useCallback((activeId: string, overId: string) => {
|
||||
setWidgets(prev => {
|
||||
const oldIndex = prev.findIndex(w => w.id === activeId)
|
||||
const newIndex = prev.findIndex(w => w.id === overId)
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return prev
|
||||
|
||||
const newWidgets = [...prev]
|
||||
const [removed] = newWidgets.splice(oldIndex, 1)
|
||||
newWidgets.splice(newIndex, 0, removed)
|
||||
|
||||
// Update order values
|
||||
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
widgets,
|
||||
toggleWidget,
|
||||
moveWidget,
|
||||
reorderWidgets,
|
||||
updateWidgetSettings,
|
||||
updateWidgetColSpan,
|
||||
getWidgetSettings,
|
||||
resetLayout,
|
||||
getVisibleWidgets,
|
||||
isWidgetVisible,
|
||||
isLoaded
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user