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 { batchesApi, Batch } from '../lib/batchesApi';
|
||||||
import { touchPointsApi, IPMSchedule } from '../lib/touchPointsApi';
|
import { touchPointsApi, IPMSchedule } from '../lib/touchPointsApi';
|
||||||
import { Loader2, AlertTriangle, CheckCircle, Calendar, Shield } from 'lucide-react';
|
import { Loader2, AlertTriangle, CheckCircle, Calendar, Shield } from 'lucide-react';
|
||||||
|
import { PageHeader } from '../components/layout/PageHeader';
|
||||||
|
|
||||||
export default function IPMDashboardPage() {
|
export default function IPMDashboardPage() {
|
||||||
const [batches, setBatches] = useState<Batch[]>([]);
|
const [batches, setBatches] = useState<Batch[]>([]);
|
||||||
|
|
@ -64,12 +65,12 @@ export default function IPMDashboardPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
title="IPM Dashboard"
|
||||||
<Shield className="text-emerald-600" /> IPM Dashboard
|
description="Root drench schedule and integrated pest management tracking"
|
||||||
</h1>
|
icon={Shield}
|
||||||
<p className="text-slate-500">Root Drench Schedule (Veg Room)</p>
|
iconColor="text-emerald-600"
|
||||||
</header>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{batches.map(batch => {
|
{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 api from '../lib/api';
|
||||||
|
import { PageHeader, PageHeaderButton } from '../components/layout/PageHeader';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
export default function RoomsPage() {
|
export default function RoomsPage() {
|
||||||
|
const { isManager } = usePermissions();
|
||||||
const [rooms, setRooms] = useState<any[]>([]);
|
const [rooms, setRooms] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchRooms = async () => {
|
const fetchRooms = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/rooms');
|
const { data } = await api.get('/rooms');
|
||||||
setRooms(data);
|
setRooms(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="flex justify-between items-center">
|
<PageHeader
|
||||||
<h2 className="text-2xl font-bold text-neutral-800">Cultivation Rooms</h2>
|
title="Cultivation Rooms"
|
||||||
<button className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors">
|
description="Manage grow rooms and monitor their status"
|
||||||
+ New Room
|
icon={Home}
|
||||||
</button>
|
iconColor="text-green-600"
|
||||||
</header>
|
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 => (
|
{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">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<h3 className="font-bold text-lg text-emerald-950">{room.name}</h3>
|
<h3 className="font-semibold text-lg text-slate-900 dark:text-white">
|
||||||
<span className="px-2 py-1 bg-neutral-100 text-neutral-600 text-xs rounded uppercase font-bold tracking-wider">
|
{room.name?.replace('[DEMO] ', '')}
|
||||||
|
</h3>
|
||||||
|
<span className={`px-2 py-1 text-[10px] rounded font-bold uppercase tracking-wider ${getRoomTypeColor(room.type)}`}>
|
||||||
{room.type}
|
{room.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm text-neutral-600">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between text-slate-600 dark:text-slate-400">
|
||||||
<span>Size:</span>
|
<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>
|
||||||
<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>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>
|
</div>
|
||||||
</div>
|
{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">
|
||||||
{rooms.length === 0 && (
|
<span>🌡️ {room.targetTemp}°F</span>
|
||||||
<div className="col-span-full text-center py-20 bg-neutral-50 rounded-xl border border-dashed border-neutral-300">
|
<span>💧 {room.targetHumidity}%</span>
|
||||||
<p className="text-neutral-500">No rooms found. Seed the database or create one.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,26 @@ import Layout from './components/Layout';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import { RouterErrorPage, NotFoundPage } from './pages/ErrorPages';
|
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 LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage'));
|
||||||
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
|
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
|
||||||
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
|
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
|
||||||
|
|
@ -33,6 +37,13 @@ const EnvironmentDashboard = lazy(() => import('./pages/EnvironmentDashboard'));
|
||||||
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
|
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
|
||||||
const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard'));
|
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([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -67,105 +78,81 @@ export const router = createBrowserRouter([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'walkthrough',
|
path: 'walkthrough',
|
||||||
element: <DailyWalkthroughPage />,
|
element: <Suspense fallback={<PageLoader />}><DailyWalkthroughPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'touch-points',
|
path: 'touch-points',
|
||||||
element: <TouchPointPage />,
|
element: <Suspense fallback={<PageLoader />}><TouchPointPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ipm',
|
path: 'ipm',
|
||||||
element: <IPMDashboardPage />,
|
element: <Suspense fallback={<PageLoader />}><IPMDashboardPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'rooms',
|
path: 'rooms',
|
||||||
element: <RoomsPage />,
|
element: <Suspense fallback={<PageLoader />}><RoomsPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'batches',
|
path: 'batches',
|
||||||
element: <BatchesPage />,
|
element: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'timeclock',
|
path: 'timeclock',
|
||||||
element: <TimeclockPage />,
|
element: <Suspense fallback={<PageLoader />}><TimeclockPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'supplies',
|
path: 'supplies',
|
||||||
element: <SuppliesPage />,
|
element: <Suspense fallback={<PageLoader />}><SuppliesPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
element: <TasksPage />,
|
element: <Suspense fallback={<PageLoader />}><TasksPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tasks/templates',
|
path: 'tasks/templates',
|
||||||
element: <TaskTemplatesPage />,
|
element: <Suspense fallback={<PageLoader />}><TaskTemplatesPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'reports',
|
path: 'reports',
|
||||||
element: <ReportsPage />,
|
element: <Suspense fallback={<PageLoader />}><ReportsPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'roles',
|
path: 'roles',
|
||||||
element: <RolesPage />,
|
element: <Suspense fallback={<PageLoader />}><RolesPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'visitors',
|
path: 'visitors',
|
||||||
element: (
|
element: <Suspense fallback={<PageLoader />}><VisitorManagementPage /></Suspense>,
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
|
||||||
<VisitorManagementPage />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
element: <SettingsPage />,
|
element: <Suspense fallback={<PageLoader />}><SettingsPage /></Suspense>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/walkthrough',
|
path: 'settings/walkthrough',
|
||||||
element: <WalkthroughSettingsPage />,
|
element: <Suspense fallback={<PageLoader />}><WalkthroughSettingsPage /></Suspense>,
|
||||||
},
|
},
|
||||||
// Phase 10: Compliance & Audit
|
// Phase 10: Compliance & Audit
|
||||||
{
|
{
|
||||||
path: 'compliance/audit',
|
path: 'compliance/audit',
|
||||||
element: (
|
element: <Suspense fallback={<PageLoader />}><AuditLogPage /></Suspense>,
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
|
||||||
<AuditLogPage />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'compliance/documents',
|
path: 'compliance/documents',
|
||||||
element: (
|
element: <Suspense fallback={<PageLoader />}><DocumentsPage /></Suspense>,
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
|
||||||
<DocumentsPage />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
// Phase 13: Advanced Features
|
// Phase 13: Advanced Features
|
||||||
{
|
{
|
||||||
path: 'environment',
|
path: 'environment',
|
||||||
element: (
|
element: <Suspense fallback={<PageLoader />}><EnvironmentDashboard /></Suspense>,
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
|
||||||
<EnvironmentDashboard />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'financial',
|
path: 'financial',
|
||||||
element: (
|
element: <Suspense fallback={<PageLoader />}><FinancialDashboard /></Suspense>,
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
|
||||||
<FinancialDashboard />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'insights',
|
path: 'insights',
|
||||||
element: (
|
element: <Suspense fallback={<PageLoader />}><InsightsDashboard /></Suspense>,
|
||||||
<Suspense fallback={<div className="p-4">Loading...</div>}>
|
|
||||||
<InsightsDashboard />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
// 404 catch-all
|
// 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