refactor: Code splitting, page templates, and PageHeader consistency
- Lazy load all pages for 24% bundle size reduction (575KB → 436KB) - Created templates: PageTemplate, ComponentTemplate, WidgetTemplate - Updated RoomsPage and IPMDashboardPage with PageHeader - All routes wrapped in Suspense with PageLoader component - Fixed malformed router.tsx structure
This commit is contained in:
parent
010ed94b31
commit
35162d565d
6 changed files with 447 additions and 95 deletions
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||
import { batchesApi, Batch } from '../lib/batchesApi';
|
||||
import { touchPointsApi, IPMSchedule } from '../lib/touchPointsApi';
|
||||
import { Loader2, AlertTriangle, CheckCircle, Calendar, Shield } from 'lucide-react';
|
||||
import { PageHeader } from '../components/layout/PageHeader';
|
||||
|
||||
export default function IPMDashboardPage() {
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
|
|
@ -64,12 +65,12 @@ export default function IPMDashboardPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Shield className="text-emerald-600" /> IPM Dashboard
|
||||
</h1>
|
||||
<p className="text-slate-500">Root Drench Schedule (Veg Room)</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="IPM Dashboard"
|
||||
description="Root drench schedule and integrated pest management tracking"
|
||||
icon={Shield}
|
||||
iconColor="text-emerald-600"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{batches.map(batch => {
|
||||
|
|
|
|||
|
|
@ -1,58 +1,114 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Home, Plus } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { PageHeader, PageHeaderButton } from '../components/layout/PageHeader';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
export default function RoomsPage() {
|
||||
const { isManager } = usePermissions();
|
||||
const [rooms, setRooms] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
const fetchRooms = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/rooms');
|
||||
setRooms(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoomTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
VEG: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
FLOWER: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
DRY: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
CURE: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
MOTHER: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
TRIM: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
};
|
||||
return colors[type] || 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neutral-800">Cultivation Rooms</h2>
|
||||
<button className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors">
|
||||
+ New Room
|
||||
</button>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="Cultivation Rooms"
|
||||
description="Manage grow rooms and monitor their status"
|
||||
icon={Home}
|
||||
iconColor="text-green-600"
|
||||
actions={
|
||||
isManager && (
|
||||
<PageHeaderButton variant="primary">
|
||||
<Plus size={18} />
|
||||
Add Room
|
||||
</PageHeaderButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{rooms.map(room => (
|
||||
<div key={room.id} className="bg-white p-6 rounded-xl shadow-sm border border-neutral-200">
|
||||
<div
|
||||
key={room.id}
|
||||
className="bg-white dark:bg-slate-800 p-5 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-bold text-lg text-emerald-950">{room.name}</h3>
|
||||
<span className="px-2 py-1 bg-neutral-100 text-neutral-600 text-xs rounded uppercase font-bold tracking-wider">
|
||||
<h3 className="font-semibold text-lg text-slate-900 dark:text-white">
|
||||
{room.name?.replace('[DEMO] ', '')}
|
||||
</h3>
|
||||
<span className={`px-2 py-1 text-[10px] rounded font-bold uppercase tracking-wider ${getRoomTypeColor(room.type)}`}>
|
||||
{room.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-neutral-600">
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||
<span>Size:</span>
|
||||
<span className="font-medium">{room.sqft} sqft</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">{room.sqft?.toLocaleString()} sqft</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||
<span>Capacity:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">{room.capacity || '—'} plants</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||
<span>Active Batches:</span>
|
||||
<span className="font-medium">{room.batches?.length || 0}</span>
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">{room.batches?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rooms.length === 0 && (
|
||||
<div className="col-span-full text-center py-20 bg-neutral-50 rounded-xl border border-dashed border-neutral-300">
|
||||
<p className="text-neutral-500">No rooms found. Seed the database or create one.</p>
|
||||
{room.targetTemp && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 dark:border-slate-700 flex gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>🌡️ {room.targetTemp}°F</span>
|
||||
<span>💧 {room.targetHumidity}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{rooms.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-16 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-dashed border-slate-300 dark:border-slate-600">
|
||||
<Home size={48} className="text-slate-300 dark:text-slate-600 mb-4" />
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-4">No rooms found</p>
|
||||
{isManager && (
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700">
|
||||
<Plus size={18} />
|
||||
Create First Room
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,26 @@ import Layout from './components/Layout';
|
|||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { RouterErrorPage, NotFoundPage } from './pages/ErrorPages';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import DailyWalkthroughPage from './pages/DailyWalkthroughPage';
|
||||
import RoomsPage from './pages/RoomsPage';
|
||||
import BatchesPage from './pages/BatchesPage';
|
||||
import TimeclockPage from './pages/TimeclockPage';
|
||||
import SuppliesPage from './pages/SuppliesPage';
|
||||
import TasksPage from './pages/TasksPage';
|
||||
import TaskTemplatesPage from './pages/TaskTemplatesPage';
|
||||
import WalkthroughSettingsPage from './pages/WalkthroughSettingsPage';
|
||||
import RolesPage from './pages/RolesPage';
|
||||
import TouchPointPage from './pages/TouchPointPage';
|
||||
import IPMDashboardPage from './pages/IPMDashboardPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import ReportsPage from './pages/ReportsPage';
|
||||
|
||||
// Lazy load heavy components
|
||||
// Core pages - loaded immediately
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
|
||||
// Lazy load all other pages to reduce initial bundle
|
||||
const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage'));
|
||||
const RoomsPage = lazy(() => import('./pages/RoomsPage'));
|
||||
const BatchesPage = lazy(() => import('./pages/BatchesPage'));
|
||||
const TimeclockPage = lazy(() => import('./pages/TimeclockPage'));
|
||||
const SuppliesPage = lazy(() => import('./pages/SuppliesPage'));
|
||||
const TasksPage = lazy(() => import('./pages/TasksPage'));
|
||||
const TaskTemplatesPage = lazy(() => import('./pages/TaskTemplatesPage'));
|
||||
const WalkthroughSettingsPage = lazy(() => import('./pages/WalkthroughSettingsPage'));
|
||||
const RolesPage = lazy(() => import('./pages/RolesPage'));
|
||||
const TouchPointPage = lazy(() => import('./pages/TouchPointPage'));
|
||||
const IPMDashboardPage = lazy(() => import('./pages/IPMDashboardPage'));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
|
||||
|
||||
// Heavy components
|
||||
const LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage'));
|
||||
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
|
||||
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
|
||||
|
|
@ -33,6 +37,13 @@ const EnvironmentDashboard = lazy(() => import('./pages/EnvironmentDashboard'));
|
|||
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
|
||||
const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard'));
|
||||
|
||||
// Loading spinner component for Suspense fallbacks
|
||||
const PageLoader = () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
|
|
@ -67,105 +78,81 @@ export const router = createBrowserRouter([
|
|||
},
|
||||
{
|
||||
path: 'walkthrough',
|
||||
element: <DailyWalkthroughPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><DailyWalkthroughPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'touch-points',
|
||||
element: <TouchPointPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><TouchPointPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'ipm',
|
||||
element: <IPMDashboardPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><IPMDashboardPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'rooms',
|
||||
element: <RoomsPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><RoomsPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'batches',
|
||||
element: <BatchesPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'timeclock',
|
||||
element: <TimeclockPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><TimeclockPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'supplies',
|
||||
element: <SuppliesPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><SuppliesPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
element: <TasksPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><TasksPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'tasks/templates',
|
||||
element: <TaskTemplatesPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><TaskTemplatesPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
element: <ReportsPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><ReportsPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
element: <RolesPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><RolesPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'visitors',
|
||||
element: (
|
||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
||||
<VisitorManagementPage />
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<PageLoader />}><VisitorManagementPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><SettingsPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'settings/walkthrough',
|
||||
element: <WalkthroughSettingsPage />,
|
||||
element: <Suspense fallback={<PageLoader />}><WalkthroughSettingsPage /></Suspense>,
|
||||
},
|
||||
// Phase 10: Compliance & Audit
|
||||
{
|
||||
path: 'compliance/audit',
|
||||
element: (
|
||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
||||
<AuditLogPage />
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<PageLoader />}><AuditLogPage /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'compliance/documents',
|
||||
element: (
|
||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
||||
<DocumentsPage />
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<PageLoader />}><DocumentsPage /></Suspense>,
|
||||
},
|
||||
// Phase 13: Advanced Features
|
||||
{
|
||||
path: 'environment',
|
||||
element: (
|
||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
||||
<EnvironmentDashboard />
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<PageLoader />}><EnvironmentDashboard /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'financial',
|
||||
element: (
|
||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
||||
<FinancialDashboard />
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<PageLoader />}><FinancialDashboard /></Suspense>,
|
||||
},
|
||||
{
|
||||
path: 'insights',
|
||||
element: (
|
||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
||||
<InsightsDashboard />
|
||||
</Suspense>
|
||||
),
|
||||
element: <Suspense fallback={<PageLoader />}><InsightsDashboard /></Suspense>,
|
||||
},
|
||||
// 404 catch-all
|
||||
{
|
||||
|
|
|
|||
62
frontend/src/templates/ComponentTemplate.tsx
Normal file
62
frontend/src/templates/ComponentTemplate.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Component Template
|
||||
*
|
||||
* Copy this file to create a new component:
|
||||
* 1. cp src/templates/ComponentTemplate.tsx src/components/MyComponent.tsx
|
||||
* 2. Rename the component and update props interface
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ComponentTemplateProps {
|
||||
/** Required: Main content or children */
|
||||
children?: ReactNode;
|
||||
/** Optional: Custom CSS classes */
|
||||
className?: string;
|
||||
/** Optional: Variant style */
|
||||
variant?: 'default' | 'outlined' | 'ghost';
|
||||
/** Optional: Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Optional: Click handler */
|
||||
onClick?: () => void;
|
||||
/** Optional: Disabled state */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-emerald-600 text-white hover:bg-emerald-700',
|
||||
outlined: 'border border-emerald-600 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20',
|
||||
ghost: 'text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
export function ComponentTemplate({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: ComponentTemplateProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2
|
||||
rounded-lg font-medium transition-all
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
122
frontend/src/templates/PageTemplate.tsx
Normal file
122
frontend/src/templates/PageTemplate.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Page Template
|
||||
*
|
||||
* Copy this file to create a new page:
|
||||
* 1. cp src/templates/PageTemplate.tsx src/pages/MyNewPage.tsx
|
||||
* 2. Replace "PageTemplate" with your page name
|
||||
* 3. Update the icon, title, description
|
||||
* 4. Add route in router.tsx
|
||||
* 5. Add navigation item in lib/navigation.ts
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileText, Plus, Filter, Search } from 'lucide-react';
|
||||
import { PageHeader, PageHeaderButton } from '../components/layout/PageHeader';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
// Optional: Add API import
|
||||
// import api from '../lib/api';
|
||||
|
||||
export default function PageTemplate() {
|
||||
const { canAccess, isAdmin } = usePermissions();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Example data - replace with API call
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
|
||||
// Optional: Fetch data on mount
|
||||
// useEffect(() => {
|
||||
// const fetchData = async () => {
|
||||
// setIsLoading(true);
|
||||
// try {
|
||||
// const response = await api.get('/your-endpoint');
|
||||
// setItems(response.data);
|
||||
// } catch (error) {
|
||||
// console.error('Failed to load data:', error);
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
// fetchData();
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title="Page Title"
|
||||
description="Brief description of what this page does"
|
||||
icon={FileText}
|
||||
iconColor="text-emerald-600"
|
||||
actions={
|
||||
isAdmin && (
|
||||
<PageHeaderButton variant="primary" onClick={() => console.log('Add new')}>
|
||||
<Plus size={18} />
|
||||
Add New
|
||||
</PageHeaderButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters & Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
|
||||
<Filter size={16} />
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
/* Empty State */
|
||||
<div className="flex flex-col items-center justify-center p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-4">
|
||||
<FileText size={32} className="text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
||||
No Items Yet
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm mb-6">
|
||||
Get started by creating your first item. Items will appear here once created.
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium">
|
||||
<Plus size={18} />
|
||||
Create First Item
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Data List/Table */
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id || index}
|
||||
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
{/* Item content here */}
|
||||
<p className="text-sm text-slate-900 dark:text-white">{item.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
frontend/src/templates/WidgetTemplate.tsx
Normal file
124
frontend/src/templates/WidgetTemplate.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Widget Template
|
||||
*
|
||||
* Copy this file to create a new dashboard widget:
|
||||
* 1. cp src/templates/WidgetTemplate.tsx src/components/widgets/MyWidget.tsx
|
||||
* 2. Rename the widget and update data fetching
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, RefreshCw } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface WidgetTemplateProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
iconColor?: string;
|
||||
refreshInterval?: number; // ms, 0 = no auto-refresh
|
||||
}
|
||||
|
||||
interface WidgetData {
|
||||
value: number | string;
|
||||
label: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
trendValue?: string;
|
||||
}
|
||||
|
||||
export function WidgetTemplate({
|
||||
title,
|
||||
icon: Icon,
|
||||
iconColor = 'text-emerald-600',
|
||||
refreshInterval = 0,
|
||||
}: WidgetTemplateProps) {
|
||||
const [data, setData] = useState<WidgetData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Replace with actual API call
|
||||
// const response = await api.get('/widget-data');
|
||||
// setData(response.data);
|
||||
|
||||
// Demo data
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setData({
|
||||
value: Math.floor(Math.random() * 100),
|
||||
label: 'Items',
|
||||
trend: Math.random() > 0.5 ? 'up' : 'down',
|
||||
trendValue: `${Math.floor(Math.random() * 20)}%`,
|
||||
});
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Widget fetch failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(fetchData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [refreshInterval]);
|
||||
|
||||
const TrendIcon = data?.trend === 'up' ? TrendingUp : data?.trend === 'down' ? TrendingDown : Minus;
|
||||
const trendColor = data?.trend === 'up' ? 'text-emerald-500' : data?.trend === 'down' ? 'text-red-500' : 'text-slate-400';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg bg-slate-100 dark:bg-slate-700 ${iconColor}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-slate-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading && !data ? (
|
||||
<div className="h-16 flex items-center justify-center">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-emerald-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{data.value}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 mb-1">{data.label}</span>
|
||||
</div>
|
||||
{data.trend && (
|
||||
<div className={`flex items-center gap-1 text-sm ${trendColor}`}>
|
||||
<TrendIcon size={14} />
|
||||
<span>{data.trendValue} from last week</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">No data available</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue