feat(ui): Implement AuraUI Bento Grid Dashboard
- Added BentoGrid and BentoCard components for modular widget layout - Refactored DashboardPage to use the new command center grid - Improved visual hierarchy with large KPI cards and operational activity feeds - Maintained responsive layout for mobile and desktop
This commit is contained in:
parent
d51c5c162b
commit
e1ea974b18
2 changed files with 216 additions and 152 deletions
129
frontend/src/components/aura/BentoGrid.tsx
Normal file
129
frontend/src/components/aura/BentoGrid.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { ReactNode } from "react";
|
||||
import { ArrowRight, LucideIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
/**
|
||||
* Bento Grid Components - Feature 14 from AuraUI
|
||||
*
|
||||
* A responsive, modular grid system for dashboard widgets.
|
||||
*/
|
||||
|
||||
// --- Grid Container ---
|
||||
export const BentoGrid = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full auto-rows-[22rem] grid-cols-1 md:grid-cols-3 gap-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Grid Item (Card) ---
|
||||
interface BentoCardProps {
|
||||
name: string;
|
||||
className: string;
|
||||
background?: ReactNode;
|
||||
Icon: LucideIcon;
|
||||
description: string;
|
||||
href: string;
|
||||
cta?: string;
|
||||
children?: ReactNode; // For custom content inside the card
|
||||
value?: string | number; // For metric displays
|
||||
}
|
||||
|
||||
export const BentoCard = ({
|
||||
name,
|
||||
className,
|
||||
background,
|
||||
Icon,
|
||||
description,
|
||||
href,
|
||||
cta = "View Details",
|
||||
children,
|
||||
value
|
||||
}: BentoCardProps) => {
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
"group relative col-span-3 flex flex-col justify-between overflow-hidden rounded-xl",
|
||||
// Light mode styles
|
||||
"bg-white border border-slate-200", // shadow-sm
|
||||
// Dark mode styles
|
||||
"dark:bg-slate-950 dark:border-slate-800",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Background Graphic (optional) */}
|
||||
<div className="absolute inset-0 z-0 opacity-20 group-hover:opacity-40 transition-opacity duration-500">
|
||||
{background}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative z-10 p-6 flex flex-col h-full pointer-events-none">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-900 group-hover:bg-cyan-50 dark:group-hover:bg-cyan-950/30 transition-colors duration-300">
|
||||
<Icon className="h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-50 text-lg">
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Metric Value (if provided) - Large display */}
|
||||
{value && (
|
||||
<div className="mt-4 mb-2">
|
||||
<span className="text-4xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description / Subtext */}
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm leading-relaxed max-w-[90%]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Custom Children (Charts, Lists, etc) */}
|
||||
{children && (
|
||||
<div className="flex-1 mt-4 min-h-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Footer / CTA */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto",
|
||||
"absolute bottom-0 flex w-full translate-y-10 flex-row items-center p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100",
|
||||
"bg-gradient-to-t from-white via-white to-transparent dark:from-slate-950 dark:via-slate-950"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={href}
|
||||
className="flex items-center gap-2 text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300"
|
||||
>
|
||||
{cta}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Hover Effect Details */}
|
||||
<div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/[.03] group-hover:dark:bg-neutral-800/10" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { Plus, Leaf, ClipboardCheck, AlertTriangle, Activity, Droplet, Salad, Bug, Eye, ArrowRight } from 'lucide-react';
|
||||
import { Leaf, ClipboardCheck, Activity, Droplet, Salad, Bug, Eye, CalendarDays, Zap } from 'lucide-react';
|
||||
import TouchPointModal from '../components/touchpoints/TouchPointModal';
|
||||
import TasksDueTodayWidget from '../components/tasks/TasksDueTodayWidget';
|
||||
import { SmartAlerts } from '../components/dashboard/SmartAlerts';
|
||||
import { touchPointsApi } from '../lib/touchPointsApi';
|
||||
import { analyticsApi, AnalyticsOverview } from '../lib/analyticsApi';
|
||||
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
||||
import { BentoGrid, BentoCard } from '../components/aura/BentoGrid';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
|
|
@ -17,7 +16,6 @@ export default function DashboardPage() {
|
|||
const [recentActivity, setRecentActivity] = useState<any[]>([]);
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activityLoading, setActivityLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -25,7 +23,6 @@ export default function DashboardPage() {
|
|||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setActivityLoading(true);
|
||||
try {
|
||||
const [overviewData, activityData] = await Promise.all([
|
||||
analyticsApi.getOverview(),
|
||||
|
|
@ -38,7 +35,6 @@ export default function DashboardPage() {
|
|||
addToast('Failed to load dashboard data', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setActivityLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -47,6 +43,10 @@ export default function DashboardPage() {
|
|||
addToast('Activity logged successfully', 'success');
|
||||
};
|
||||
|
||||
const handlePullRefresh = async () => {
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
const iconClass = "transition-transform duration-fast";
|
||||
switch (type) {
|
||||
|
|
@ -58,176 +58,111 @@ export default function DashboardPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const metrics = overview ? [
|
||||
{ label: 'Active Batches', value: overview.activeBatches.toString(), icon: Leaf, accent: 'accent', link: '/batches' },
|
||||
{ label: 'Total Plants', value: overview.totalPlants.toString(), icon: Leaf, accent: 'success', link: '/batches' },
|
||||
{ label: 'Tasks Completed', value: overview.tasksCompletedThisWeek.toString(), icon: ClipboardCheck, accent: 'accent', link: '/tasks' },
|
||||
{ label: 'Tasks Pending', value: overview.tasksPending.toString(), icon: AlertTriangle, accent: 'warning', link: '/tasks' },
|
||||
{ label: 'Touch Points', value: overview.touchPointsToday.toString(), icon: Activity, accent: 'accent', link: '/touch-points' },
|
||||
{ label: 'Rooms', value: overview.totalRooms.toString(), icon: Leaf, accent: 'neutral', link: '/rooms' },
|
||||
] : [];
|
||||
|
||||
const handlePullRefresh = async () => {
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const getAccentClasses = (accent: string) => {
|
||||
switch (accent) {
|
||||
case 'success': return 'bg-success-muted text-success';
|
||||
case 'warning': return 'bg-warning-muted text-warning';
|
||||
case 'destructive': return 'bg-destructive-muted text-destructive';
|
||||
case 'accent': return 'bg-accent-muted text-accent';
|
||||
default: return 'bg-tertiary text-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
|
||||
<div className="space-y-6 pb-20 animate-in">
|
||||
<div className="space-y-8 pb-20 animate-in fade-in duration-500">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-start">
|
||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
||||
Welcome back, {user?.name || user?.email?.split('@')[0]}
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
Command Center
|
||||
</h1>
|
||||
<p className="text-secondary text-sm mt-1">
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-success-muted text-success rounded-md text-xs font-medium">
|
||||
<div className="w-1.5 h-1.5 bg-success rounded-full animate-pulse" />
|
||||
Online
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded-lg font-medium shadow-lg shadow-cyan-500/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Zap size={18} />
|
||||
Log Action
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Smart Alerts */}
|
||||
<BentoGrid>
|
||||
{/* 1. Smart Alerts & Overview (Top Left - Large) */}
|
||||
<BentoCard
|
||||
name="System Status"
|
||||
className="md:col-span-2"
|
||||
Icon={Activity}
|
||||
description="Real-time alerts and facility health monitoring."
|
||||
href="/alerts"
|
||||
cta="View All Alerts"
|
||||
background={<div className="absolute right-0 top-0 h-full w-1/2 bg-gradient-to-l from-red-50 to-transparent dark:from-red-900/10 pointer-events-none" />}
|
||||
>
|
||||
<div className="mt-2">
|
||||
<SmartAlerts />
|
||||
</div>
|
||||
</BentoCard>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{/* 2. Active Batches (Top Right - Small) */}
|
||||
<BentoCard
|
||||
name="Active Batches"
|
||||
className="md:col-span-1"
|
||||
Icon={Leaf}
|
||||
value={overview?.activeBatches.toString() || "0"}
|
||||
description={`${overview?.totalPlants || 0} total plants in production.`}
|
||||
href="/batches"
|
||||
cta="Manage Batches"
|
||||
background={<Leaf className="absolute -right-4 -top-4 text-emerald-100 dark:text-emerald-900/20 w-32 h-32 rotate-12" />}
|
||||
/>
|
||||
|
||||
{/* 3. Tasks (Bottom Left - Small) */}
|
||||
<BentoCard
|
||||
name="Tasks Due"
|
||||
className="md:col-span-1"
|
||||
Icon={ClipboardCheck}
|
||||
value={overview?.tasksPending.toString() || "0"}
|
||||
description={`${overview?.tasksCompletedThisWeek || 0} completed this week.`}
|
||||
href="/tasks"
|
||||
cta="View Tasks"
|
||||
background={<CalendarDays className="absolute -right-4 -bottom-4 text-blue-100 dark:text-blue-900/20 w-32 h-32 -rotate-12" />}
|
||||
/>
|
||||
|
||||
{/* 4. Recent Activity (Bottom Right - Large) */}
|
||||
<BentoCard
|
||||
name="Recent Activity"
|
||||
className="md:col-span-2"
|
||||
Icon={Activity}
|
||||
description="Latest operational touchpoints and logs."
|
||||
href="/touch-points"
|
||||
cta="View Full Log"
|
||||
>
|
||||
<div className="space-y-2 mt-2">
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="card p-4 space-y-3">
|
||||
<div className="skeleton w-8 h-8 rounded-md" />
|
||||
<div className="skeleton w-12 h-6" />
|
||||
<div className="skeleton w-20 h-3" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
metrics.map((m, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={m.link}
|
||||
className="card card-interactive p-4 group"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`w-8 h-8 rounded-md flex items-center justify-center ${getAccentClasses(m.accent)}`}>
|
||||
<m.icon size={16} />
|
||||
</div>
|
||||
<ArrowRight size={12} className="text-tertiary opacity-0 group-hover:opacity-100 transition-opacity duration-fast" />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary tracking-tight">
|
||||
{m.value}
|
||||
</p>
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{m.label}
|
||||
</p>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Tasks Widget */}
|
||||
<TasksDueTodayWidget userId={user?.id || ''} />
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="card p-5">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-sm font-medium text-primary">Recent Activity</h3>
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="btn btn-ghost text-xs h-8 px-3 gap-1 text-accent hover:text-accent"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activityLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3 p-3 rounded-md">
|
||||
<div className="skeleton w-8 h-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton w-3/4 h-4" />
|
||||
<div className="skeleton w-1/2 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<div className="h-12 bg-slate-100 dark:bg-slate-800 rounded-lg animate-pulse" />
|
||||
<div className="h-12 bg-slate-100 dark:bg-slate-800 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Activity size={32} className="mx-auto text-quaternary mb-3" />
|
||||
<p className="text-tertiary text-sm">No recent activity</p>
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="btn btn-secondary mt-4 h-9"
|
||||
>
|
||||
Log First Activity
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center py-6 text-slate-400 text-sm">No recent activity logged.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{recentActivity.map((activity: any) => (
|
||||
recentActivity.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start gap-3 p-3 rounded-md hover:bg-tertiary transition-colors duration-fast group"
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800/50 group hover:border-cyan-200 dark:hover:border-cyan-800 transition-colors"
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
|
||||
${activity.type === 'WATER' ? 'bg-blue-500/10' :
|
||||
activity.type === 'FEED' ? 'bg-success-muted' :
|
||||
activity.type === 'IPM' ? 'bg-destructive-muted' :
|
||||
activity.type === 'INSPECT' ? 'bg-accent-muted' :
|
||||
'bg-tertiary'
|
||||
}
|
||||
`}>
|
||||
<div className="p-2 bg-white dark:bg-slate-800 rounded-lg shadow-sm">
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">
|
||||
<span className="font-medium">{activity.type}</span>
|
||||
<span className="text-tertiary"> — </span>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-200 truncate">
|
||||
{activity.batch?.name || 'Unknown Batch'}
|
||||
<span className="text-slate-400 font-normal"> — {activity.type}</span>
|
||||
</p>
|
||||
<p className="text-xs text-tertiary mt-0.5">
|
||||
{activity.user?.name || 'User'} · {new Date(activity.createdAt).toLocaleString()}
|
||||
</p>
|
||||
{activity.notes && (
|
||||
<p className="text-xs text-secondary mt-1 truncate">
|
||||
"{activity.notes}"
|
||||
<p className="text-xs text-slate-500 truncate">
|
||||
{activity.user?.name} · {new Date(activity.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
<button
|
||||
onClick={() => setIsTouchModalOpen(true)}
|
||||
className="md:hidden fixed bottom-20 right-4 w-12 h-12 btn-primary rounded-full shadow-lg flex items-center justify-center z-30"
|
||||
style={{ backgroundColor: 'var(--color-accent)' }}
|
||||
aria-label="Log Activity"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</BentoCard>
|
||||
</BentoGrid>
|
||||
|
||||
<TouchPointModal
|
||||
isOpen={isTouchModalOpen}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue