Add modular dashboard widgets and layout editor
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:
g
2026-01-12 10:39:50 +00:00
parent a6b7286b45
commit 318927cd0c
23 changed files with 2435 additions and 317 deletions

165
hooks/useFilterState.ts Normal file
View 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
View 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
}
}