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 { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useToast } from '../context/ToastContext';
|
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 TouchPointModal from '../components/touchpoints/TouchPointModal';
|
||||||
import TasksDueTodayWidget from '../components/tasks/TasksDueTodayWidget';
|
|
||||||
import { SmartAlerts } from '../components/dashboard/SmartAlerts';
|
import { SmartAlerts } from '../components/dashboard/SmartAlerts';
|
||||||
import { touchPointsApi } from '../lib/touchPointsApi';
|
import { touchPointsApi } from '../lib/touchPointsApi';
|
||||||
import { analyticsApi, AnalyticsOverview } from '../lib/analyticsApi';
|
import { analyticsApi, AnalyticsOverview } from '../lib/analyticsApi';
|
||||||
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
import { PullToRefresh } from '../components/ui/PullToRefresh';
|
||||||
|
import { BentoGrid, BentoCard } from '../components/aura/BentoGrid';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
@ -17,7 +16,6 @@ export default function DashboardPage() {
|
||||||
const [recentActivity, setRecentActivity] = useState<any[]>([]);
|
const [recentActivity, setRecentActivity] = useState<any[]>([]);
|
||||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activityLoading, setActivityLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
|
@ -25,7 +23,6 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setActivityLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const [overviewData, activityData] = await Promise.all([
|
const [overviewData, activityData] = await Promise.all([
|
||||||
analyticsApi.getOverview(),
|
analyticsApi.getOverview(),
|
||||||
|
|
@ -38,7 +35,6 @@ export default function DashboardPage() {
|
||||||
addToast('Failed to load dashboard data', 'error');
|
addToast('Failed to load dashboard data', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setActivityLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,6 +43,10 @@ export default function DashboardPage() {
|
||||||
addToast('Activity logged successfully', 'success');
|
addToast('Activity logged successfully', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePullRefresh = async () => {
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
const getActivityIcon = (type: string) => {
|
const getActivityIcon = (type: string) => {
|
||||||
const iconClass = "transition-transform duration-fast";
|
const iconClass = "transition-transform duration-fast";
|
||||||
switch (type) {
|
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 (
|
return (
|
||||||
<PullToRefresh onRefresh={handlePullRefresh} disabled={loading}>
|
<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 */}
|
||||||
<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>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-primary tracking-tight">
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||||
Welcome back, {user?.name || user?.email?.split('@')[0]}
|
Command Center
|
||||||
</h1>
|
</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' })}
|
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="flex items-center gap-3">
|
||||||
<div className="w-1.5 h-1.5 bg-success rounded-full animate-pulse" />
|
<button
|
||||||
Online
|
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>
|
</div>
|
||||||
</header>
|
</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 />
|
<SmartAlerts />
|
||||||
|
</div>
|
||||||
|
</BentoCard>
|
||||||
|
|
||||||
{/* Metrics Grid */}
|
{/* 2. Active Batches (Top Right - Small) */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
<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 ? (
|
{loading ? (
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
<div className="space-y-4">
|
||||||
<div key={i} className="card p-4 space-y-3">
|
<div className="h-12 bg-slate-100 dark:bg-slate-800 rounded-lg animate-pulse" />
|
||||||
<div className="skeleton w-8 h-8 rounded-md" />
|
<div className="h-12 bg-slate-100 dark:bg-slate-800 rounded-lg animate-pulse" />
|
||||||
<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>
|
</div>
|
||||||
) : recentActivity.length === 0 ? (
|
) : recentActivity.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-6 text-slate-400 text-sm">No recent activity logged.</div>
|
||||||
<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="space-y-1">
|
recentActivity.map((activity) => (
|
||||||
{recentActivity.map((activity: any) => (
|
|
||||||
<div
|
<div
|
||||||
key={activity.id}
|
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={`
|
<div className="p-2 bg-white dark:bg-slate-800 rounded-lg shadow-sm">
|
||||||
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'
|
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{getActivityIcon(activity.type)}
|
{getActivityIcon(activity.type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-primary truncate">
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-200 truncate">
|
||||||
<span className="font-medium">{activity.type}</span>
|
|
||||||
<span className="text-tertiary"> — </span>
|
|
||||||
{activity.batch?.name || 'Unknown Batch'}
|
{activity.batch?.name || 'Unknown Batch'}
|
||||||
|
<span className="text-slate-400 font-normal"> — {activity.type}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-tertiary mt-0.5">
|
<p className="text-xs text-slate-500 truncate">
|
||||||
{activity.user?.name || 'User'} · {new Date(activity.createdAt).toLocaleString()}
|
{activity.user?.name} · {new Date(activity.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</p>
|
|
||||||
{activity.notes && (
|
|
||||||
<p className="text-xs text-secondary mt-1 truncate">
|
|
||||||
"{activity.notes}"
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BentoCard>
|
||||||
))}
|
</BentoGrid>
|
||||||
</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>
|
|
||||||
|
|
||||||
<TouchPointModal
|
<TouchPointModal
|
||||||
isOpen={isTouchModalOpen}
|
isOpen={isTouchModalOpen}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue