Add scheduling-remote service to docker-compose and enhance camera error handling
- Introduced a new service for scheduling-remote in docker-compose.yml, allowing for better management of scheduling functionalities. - Enhanced error handling in CameraMonitor and CameraStreamer classes to improve robustness during camera initialization and streaming processes. - Updated various components in the management dashboard to support dark mode and improve user experience with consistent styling. - Implemented feature flags for enabling/disabling modules, including the new scheduling module.
This commit is contained in:
@@ -115,7 +115,7 @@ function App() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
@@ -127,7 +127,7 @@ function App() {
|
||||
// Handle signout route
|
||||
if (currentRoute === '/signout') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Signing out...</p>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ExperimentManagement } from './ExperimentManagement'
|
||||
import { DataEntry } from './DataEntry'
|
||||
// VisionSystem is now loaded as a microfrontend - see RemoteVisionSystem below
|
||||
// import { VisionSystem } from './VisionSystem'
|
||||
import { Scheduling } from './Scheduling'
|
||||
// Scheduling is now loaded as a microfrontend - see RemoteScheduling below
|
||||
// import { Scheduling } from './Scheduling'
|
||||
import React, { Suspense } from 'react'
|
||||
import { loadRemoteComponent } from '../lib/loadRemote'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
@@ -172,6 +173,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
LocalVisionSystemPlaceholder as any
|
||||
) as unknown as React.ComponentType
|
||||
|
||||
const LocalSchedulingPlaceholder = () => (<div className="p-6">Scheduling module not enabled.</div>)
|
||||
const RemoteScheduling = loadRemoteComponent(
|
||||
isFeatureEnabled('enableSchedulingModule'),
|
||||
() => import('schedulingRemote/App'),
|
||||
LocalSchedulingPlaceholder as any
|
||||
) as unknown as React.ComponentType<{ user: User; currentRoute: string }>
|
||||
|
||||
const renderCurrentView = () => {
|
||||
if (!user) return null
|
||||
|
||||
@@ -216,7 +224,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
</ErrorBoundary>
|
||||
)
|
||||
case 'scheduling':
|
||||
return <Scheduling user={user} currentRoute={currentRoute} />
|
||||
return (
|
||||
<ErrorBoundary fallback={<div className="p-6">Failed to load scheduling module. Please try again.</div>}>
|
||||
<Suspense fallback={<div className="p-6">Loading scheduling module...</div>}>
|
||||
<RemoteScheduling user={user} currentRoute={currentRoute} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
case 'video-library':
|
||||
return (
|
||||
<ErrorBoundary fallback={<div className="p-6">Failed to load video module. Please try again.</div>}>
|
||||
@@ -234,7 +248,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
@@ -245,7 +259,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="rounded-2xl bg-error-50 border border-error-200 p-4 dark:bg-error-500/15 dark:border-error-500/20">
|
||||
<div className="text-sm text-error-700 dark:text-error-500">{error}</div>
|
||||
@@ -263,7 +277,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">No user data available</div>
|
||||
<button
|
||||
@@ -298,7 +312,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
} ${isMobileOpen ? "ml-0" : ""}`}
|
||||
>
|
||||
<TopNavbar
|
||||
|
||||
@@ -98,7 +98,7 @@ export function DataEntry() {
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading experiments...</p>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading experiments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,8 +108,8 @@ export function DataEntry() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -129,8 +129,8 @@ export function DataEntry() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Data Entry</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Data Entry</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a repetition to enter measurement data
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,13 +142,13 @@ export function DataEntry() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Past/Completed Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<span className="w-4 h-4 bg-green-500 rounded-full mr-3"></span>
|
||||
Past/Completed ({pastRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Completed or past scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
@@ -164,7 +164,7 @@ export function DataEntry() {
|
||||
/>
|
||||
))}
|
||||
{pastRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
|
||||
No completed repetitions
|
||||
</p>
|
||||
)}
|
||||
@@ -173,13 +173,13 @@ export function DataEntry() {
|
||||
</div>
|
||||
|
||||
{/* In Progress Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3"></span>
|
||||
In Progress ({inProgressRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Currently scheduled or active repetitions
|
||||
</p>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ export function DataEntry() {
|
||||
/>
|
||||
))}
|
||||
{inProgressRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
|
||||
No repetitions in progress
|
||||
</p>
|
||||
)}
|
||||
@@ -204,13 +204,13 @@ export function DataEntry() {
|
||||
</div>
|
||||
|
||||
{/* Upcoming Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<span className="w-4 h-4 bg-yellow-500 rounded-full mr-3"></span>
|
||||
Upcoming ({upcomingRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Future scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@ export function DataEntry() {
|
||||
/>
|
||||
))}
|
||||
{upcomingRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
|
||||
No upcoming repetitions
|
||||
</p>
|
||||
)}
|
||||
@@ -239,7 +239,7 @@ export function DataEntry() {
|
||||
|
||||
{experiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
No experiments available for data entry
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,35 +291,35 @@ function RepetitionCard({ experiment, repetition, onSelect, status }: Repetition
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Large, bold experiment number */}
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
#{experiment.experiment_number}
|
||||
</span>
|
||||
{/* Smaller repetition number */}
|
||||
<span className="text-lg font-semibold text-gray-700">
|
||||
<span className="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
Rep #{repetition.repetition_number}
|
||||
</span>
|
||||
<span className="text-lg">{getStatusIcon()}</span>
|
||||
<span className="text-lg dark:text-white">{getStatusIcon()}</span>
|
||||
</div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.scheduled_date
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300'
|
||||
}`}>
|
||||
{repetition.scheduled_date ? 'scheduled' : 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Experiment details */}
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{experiment.soaking_duration_hr}h soaking • {experiment.air_drying_time_min}min drying
|
||||
</div>
|
||||
|
||||
{repetition.scheduled_date && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<strong>Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<strong className="dark:text-white">Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click to enter data for this repetition
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -61,9 +61,9 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Experiment Phases</h1>
|
||||
<p className="mt-2 text-gray-600">Select an experiment phase to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Phases</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment phase to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
|
||||
</div>
|
||||
{canManagePhases && (
|
||||
<button
|
||||
@@ -78,8 +78,8 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
<div className="mb-6 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -89,30 +89,30 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div
|
||||
key={phase.id}
|
||||
onClick={() => onPhaseSelect(phase)}
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{phase.name}
|
||||
</h3>
|
||||
|
||||
{phase.description && (
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{phase.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -121,31 +121,31 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{phase.has_soaking && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
||||
🌰 Soaking
|
||||
</span>
|
||||
)}
|
||||
{phase.has_airdrying && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
|
||||
💨 Air-Drying
|
||||
</span>
|
||||
)}
|
||||
{phase.has_cracking && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300">
|
||||
🔨 Cracking
|
||||
</span>
|
||||
)}
|
||||
{phase.has_shelling && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
|
||||
📊 Shelling
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Created {new Date(phase.created_at).toLocaleDateString()}</span>
|
||||
<div className="flex items-center text-blue-600 hover:text-blue-800">
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<span className="mr-1">View Experiments</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
@@ -159,11 +159,11 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
|
||||
{phases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiment phases found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment phases found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Get started by creating your first experiment phase.
|
||||
</p>
|
||||
{canManagePhases && (
|
||||
|
||||
@@ -36,7 +36,7 @@ export function Login({ onLoginSuccess }: LoginProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
|
||||
interface TopNavbarProps {
|
||||
user: User
|
||||
@@ -19,6 +20,7 @@ export function TopNavbar({
|
||||
onNavigateToProfile
|
||||
}: TopNavbarProps) {
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
const getPageTitle = (view: string) => {
|
||||
switch (view) {
|
||||
@@ -139,6 +141,48 @@ export function TopNavbar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none">
|
||||
{/* Theme Toggle Button */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center justify-center w-10 h-10 text-gray-500 border border-gray-200 rounded-lg hover:bg-gray-100 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
// Moon icon for dark mode
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// Sun icon for light mode
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User Area */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
@@ -103,15 +103,15 @@ export function UserManagement() {
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-100 text-red-800'
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
|
||||
case 'conductor':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||
case 'analyst':
|
||||
return 'bg-green-100 text-green-800'
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
|
||||
case 'data recorder':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function UserManagement() {
|
||||
<div className="p-6">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading users...</p>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -129,8 +129,8 @@ export function UserManagement() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
@@ -148,8 +148,8 @@ export function UserManagement() {
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="mt-2 text-gray-600">Manage user accounts, roles, and permissions</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">User Management</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Manage user accounts, roles, and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
@@ -162,7 +162,7 @@ export function UserManagement() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -170,15 +170,15 @@ export function UserManagement() {
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Total Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">{users.length}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -186,8 +186,8 @@ export function UserManagement() {
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Active Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{users.filter(u => u.status === 'active').length}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -196,7 +196,7 @@ export function UserManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -204,8 +204,8 @@ export function UserManagement() {
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Disabled Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Disabled Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{users.filter(u => u.status === 'disabled').length}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -214,7 +214,7 @@ export function UserManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -222,8 +222,8 @@ export function UserManagement() {
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Admins</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Admins</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{users.filter(u => u.roles.includes('admin')).length}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -234,35 +234,35 @@ export function UserManagement() {
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Users</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Users</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||
Click on any field to edit user details
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Roles
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
@@ -352,11 +352,11 @@ function UserRow({
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="block w-full border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
className="text-sm text-gray-900 dark:text-white cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{user.email}
|
||||
@@ -374,7 +374,7 @@ function UserRow({
|
||||
onChange={() => handleRoleToggle(role.name)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">{role.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -398,14 +398,14 @@ function UserRow({
|
||||
<button
|
||||
onClick={() => onStatusToggle(user.id, user.status)}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/40'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/40'
|
||||
}`}
|
||||
>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
@@ -413,13 +413,13 @@ function UserRow({
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -428,7 +428,7 @@ function UserRow({
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
|
||||
className="p-1 text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Edit user"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -437,7 +437,7 @@ function UserRow({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPasswordReset(user.id, user.email)}
|
||||
className="p-1 text-orange-600 hover:text-orange-900 hover:bg-orange-50 rounded"
|
||||
className="p-1 text-orange-600 dark:text-orange-400 hover:text-orange-900 dark:hover:text-orange-300 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded"
|
||||
title="Reset password to 'password123'"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
44
management-dashboard-web-app/src/hooks/useTheme.ts
Normal file
44
management-dashboard-web-app/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
// Initialize theme on script load (before React renders)
|
||||
function initializeTheme(): Theme {
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
if (stored) {
|
||||
return stored
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
// Apply theme class to document root
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
}
|
||||
|
||||
// Initialize theme immediately on module load
|
||||
const initialTheme = initializeTheme()
|
||||
applyTheme(initialTheme)
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme whenever it changes
|
||||
applyTheme(theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
|
||||
}
|
||||
|
||||
return { theme, toggleTheme }
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50;
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50 dark:bg-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type FeatureFlags = {
|
||||
enableExperimentModule: boolean
|
||||
enableCameraModule: boolean
|
||||
enableVisionSystemModule: boolean
|
||||
enableSchedulingModule: boolean
|
||||
}
|
||||
|
||||
const toBool = (v: unknown, fallback = false): boolean => {
|
||||
@@ -21,6 +22,7 @@ export const featureFlags: FeatureFlags = {
|
||||
enableExperimentModule: toBool(import.meta.env.VITE_ENABLE_EXPERIMENT_MODULE ?? false),
|
||||
enableCameraModule: toBool(import.meta.env.VITE_ENABLE_CAMERA_MODULE ?? false),
|
||||
enableVisionSystemModule: toBool(import.meta.env.VITE_ENABLE_VISION_SYSTEM_MODULE ?? false),
|
||||
enableSchedulingModule: toBool(import.meta.env.VITE_ENABLE_SCHEDULING_MODULE ?? false),
|
||||
}
|
||||
|
||||
export const isFeatureEnabled = (flag: keyof FeatureFlags): boolean => featureFlags[flag]
|
||||
|
||||
Reference in New Issue
Block a user