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:
@@ -4,6 +4,14 @@ import { useState, useEffect } from "react"
|
||||
import OrderStats from "./order-stats"
|
||||
import QuickActions from "./quick-actions"
|
||||
import RecentActivity from "./recent-activity"
|
||||
import { WidgetSettings } from "./widget-settings"
|
||||
import { WidgetSettingsModal } from "./widget-settings-modal"
|
||||
import { DashboardEditor, EditDashboardButton } from "./dashboard-editor"
|
||||
import { DraggableWidget } from "./draggable-widget"
|
||||
import RevenueWidget from "./revenue-widget"
|
||||
import LowStockWidget from "./low-stock-widget"
|
||||
import RecentCustomersWidget from "./recent-customers-widget"
|
||||
import PendingChatsWidget from "./pending-chats-widget"
|
||||
import { getGreeting } from "@/lib/utils/general"
|
||||
import { statsConfig } from "@/config/dashboard"
|
||||
import { getRandomQuote } from "@/config/quotes"
|
||||
@@ -16,6 +24,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import { motion } from "framer-motion"
|
||||
import Link from "next/link"
|
||||
import { useWidgetLayout, WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
|
||||
interface ContentProps {
|
||||
username: string
|
||||
@@ -37,6 +46,9 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings, updateWidgetColSpan } = useWidgetLayout();
|
||||
const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(null);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// Initialize with a default quote to match server-side rendering, then randomize on client
|
||||
const [randomQuote, setRandomQuote] = useState({ text: "Loading wisdom...", author: "..." });
|
||||
@@ -59,15 +71,151 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchTopProducts();
|
||||
};
|
||||
|
||||
const renderWidget = (widget: WidgetConfig) => {
|
||||
switch (widget.id) {
|
||||
case "quick-actions":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||
<QuickActions />
|
||||
</section>
|
||||
);
|
||||
case "overview":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsConfig.map((stat, index) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||
icon={stat.icon}
|
||||
index={index}
|
||||
filterStatus={stat.filterStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case "recent-activity":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Recent Activity</h2>
|
||||
<RecentActivity />
|
||||
</section>
|
||||
);
|
||||
case "top-products":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Top Performing Listings</h2>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Top Performing Listings</CardTitle>
|
||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCcw className="h-3 w-3" />
|
||||
<span>Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||
Your top performing listings will materialize here as you receive orders.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{topProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold">{product.count}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
|
||||
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
case "revenue-chart":
|
||||
return <RevenueWidget settings={widget.settings} />;
|
||||
case "low-stock":
|
||||
return <LowStockWidget settings={widget.settings} />;
|
||||
case "recent-customers":
|
||||
return <RecentCustomersWidget settings={widget.settings} />;
|
||||
case "pending-chats":
|
||||
return <PendingChatsWidget settings={widget.settings} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setGreeting(getGreeting());
|
||||
fetchTopProducts();
|
||||
}, []);
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchTopProducts();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-10">
|
||||
<motion.div
|
||||
@@ -84,125 +232,62 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EditDashboardButton
|
||||
isEditMode={isEditMode}
|
||||
onToggle={() => setIsEditMode(!isEditMode)}
|
||||
/>
|
||||
<WidgetSettings
|
||||
widgets={widgets}
|
||||
onToggle={toggleWidget}
|
||||
onMove={moveWidget}
|
||||
onReset={resetLayout}
|
||||
onConfigure={(widget) => setConfiguredWidget(widget)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick ActionsSection */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||
<QuickActions />
|
||||
</section>
|
||||
<DashboardEditor
|
||||
widgets={widgets}
|
||||
isEditMode={isEditMode}
|
||||
onToggleEditMode={() => setIsEditMode(false)}
|
||||
onReorder={reorderWidgets}
|
||||
onReset={resetLayout}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 auto-rows-min">
|
||||
{widgets.map((widget) => {
|
||||
if (!widget.visible && !isEditMode) return null;
|
||||
|
||||
{/* Order Statistics */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsConfig.map((stat, index) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||
icon={stat.icon}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<DraggableWidget
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onConfigure={() => setConfiguredWidget(widget)}
|
||||
onToggleVisibility={() => toggleWidget(widget.id)}
|
||||
>
|
||||
{!widget.visible && isEditMode ? (
|
||||
<div className="opacity-40 grayscale pointer-events-none h-full">
|
||||
{renderWidget(widget)}
|
||||
</div>
|
||||
) : (
|
||||
renderWidget(widget)
|
||||
)}
|
||||
</DraggableWidget>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</DashboardEditor>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
{/* Recent Activity Section */}
|
||||
<div className="xl:col-span-1">
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
{/* Best Selling Products Section */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Top Performing Listings</CardTitle>
|
||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCcw className="h-3 w-3" />
|
||||
<span>Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||
Your top performing listings will materialize here as you receive orders.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{topProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold">{product.count}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
|
||||
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* Widget Settings Modal */}
|
||||
<WidgetSettingsModal
|
||||
widget={configuredWidget}
|
||||
open={!!configuredWidget}
|
||||
onOpenChange={(open) => !open && setConfiguredWidget(null)}
|
||||
onSave={(widgetId, settings, colSpan) => {
|
||||
updateWidgetSettings(widgetId, settings);
|
||||
if (colSpan !== undefined) updateWidgetColSpan(widgetId, colSpan);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user