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:
165
hooks/useFilterState.ts
Normal file
165
hooks/useFilterState.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { usePathname, useSearchParams, useRouter } from "next/navigation"
|
||||
import { DateRange } from "react-day-picker"
|
||||
|
||||
interface FilterState {
|
||||
searchQuery?: string
|
||||
statusFilter?: string
|
||||
dateRange?: DateRange
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
sortColumn?: string
|
||||
sortDirection?: "asc" | "desc"
|
||||
}
|
||||
|
||||
interface UseFilterStateOptions {
|
||||
/** Unique key for this page's filter state */
|
||||
storageKey: string
|
||||
/** Initialize from URL params on mount */
|
||||
syncWithUrl?: boolean
|
||||
/** Default values */
|
||||
defaults?: Partial<FilterState>
|
||||
}
|
||||
|
||||
/**
|
||||
* useFilterState - Persist filter state across navigation
|
||||
* Uses sessionStorage to remember filters per page
|
||||
*/
|
||||
export function useFilterState({
|
||||
storageKey,
|
||||
syncWithUrl = false,
|
||||
defaults = {}
|
||||
}: UseFilterStateOptions) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const fullKey = `filterState:${storageKey}`
|
||||
|
||||
// Initialize state from sessionStorage or URL params
|
||||
const getInitialState = (): FilterState => {
|
||||
if (typeof window === "undefined") return defaults
|
||||
|
||||
// First try URL params if syncWithUrl is enabled
|
||||
if (syncWithUrl && searchParams) {
|
||||
const urlState: FilterState = {}
|
||||
const status = searchParams.get("status")
|
||||
const search = searchParams.get("search")
|
||||
const page = searchParams.get("page")
|
||||
|
||||
if (status) urlState.statusFilter = status
|
||||
if (search) urlState.searchQuery = search
|
||||
if (page) urlState.page = parseInt(page)
|
||||
|
||||
if (Object.keys(urlState).length > 0) {
|
||||
return { ...defaults, ...urlState }
|
||||
}
|
||||
}
|
||||
|
||||
// Then try sessionStorage
|
||||
try {
|
||||
const stored = sessionStorage.getItem(fullKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Restore dateRange as Date objects
|
||||
if (parsed.dateRange) {
|
||||
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
|
||||
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
|
||||
}
|
||||
return { ...defaults, ...parsed }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load filter state from storage:", e)
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
|
||||
|
||||
// Save to sessionStorage whenever state changes
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save filter state to storage:", e)
|
||||
}
|
||||
}, [filterState, fullKey])
|
||||
|
||||
// Update URL if syncWithUrl is enabled
|
||||
useEffect(() => {
|
||||
if (!syncWithUrl) return
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (filterState.statusFilter && filterState.statusFilter !== "all") {
|
||||
params.set("status", filterState.statusFilter)
|
||||
}
|
||||
if (filterState.searchQuery) {
|
||||
params.set("search", filterState.searchQuery)
|
||||
}
|
||||
if (filterState.page && filterState.page > 1) {
|
||||
params.set("page", filterState.page.toString())
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
|
||||
|
||||
// Only update if URL would change
|
||||
const currentQuery = searchParams?.toString() || ""
|
||||
if (queryString !== currentQuery) {
|
||||
router.replace(newUrl, { scroll: false })
|
||||
}
|
||||
}, [filterState, syncWithUrl, pathname, router, searchParams])
|
||||
|
||||
// Convenience setters
|
||||
const setSearchQuery = useCallback((query: string) => {
|
||||
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setStatusFilter = useCallback((status: string) => {
|
||||
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setDateRange = useCallback((range: DateRange | undefined) => {
|
||||
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setPage = useCallback((page: number) => {
|
||||
setFilterState(prev => ({ ...prev, page }))
|
||||
}, [])
|
||||
|
||||
const setItemsPerPage = useCallback((count: number) => {
|
||||
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
|
||||
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilterState(defaults)
|
||||
}, [defaults])
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
filterState.searchQuery ||
|
||||
(filterState.statusFilter && filterState.statusFilter !== "all") ||
|
||||
filterState.dateRange?.from
|
||||
)
|
||||
|
||||
return {
|
||||
...filterState,
|
||||
setFilterState,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setDateRange,
|
||||
setPage,
|
||||
setItemsPerPage,
|
||||
setSort,
|
||||
clearFilters,
|
||||
hasActiveFilters
|
||||
}
|
||||
}
|
||||
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