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:
salirezav
2025-11-02 19:33:13 -05:00
parent f6a37ca1ba
commit 868aa3f036
33 changed files with 7471 additions and 136 deletions

View File

@@ -196,7 +196,13 @@ class CameraMonitor:
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...") self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
except mvsdk.CameraException as init_e: except mvsdk.CameraException as init_e:
self.logger.warning(f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})") self.logger.warning(f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})")
return "error", f"Camera initialization failed: {init_e.message}", self._get_device_info_dict(device_info) # Get device info dict before returning - wrap in try/except in case device_info is corrupted
try:
device_info_dict = self._get_device_info_dict(device_info)
except Exception as dev_info_e:
self.logger.warning(f"Failed to get device info dict after CameraInit failure: {dev_info_e}")
device_info_dict = None
return "error", f"Camera initialization failed: {init_e.message}", device_info_dict
# Quick test - try to get one frame # Quick test - try to get one frame
try: try:
@@ -232,10 +238,38 @@ class CameraMonitor:
def _get_device_info_dict(self, device_info) -> Dict[str, Any]: def _get_device_info_dict(self, device_info) -> Dict[str, Any]:
"""Convert device info to dictionary""" """Convert device info to dictionary"""
if device_info is None:
return {"error": "device_info is None"}
try: try:
return {"friendly_name": device_info.GetFriendlyName(), "port_type": device_info.GetPortType(), "serial_number": getattr(device_info, "acSn", "Unknown"), "last_checked": time.time()} # Safely access device info methods - wrap each in try/except to prevent segfaults
friendly_name = "Unknown"
port_type = "Unknown"
serial_number = "Unknown"
try:
friendly_name = device_info.GetFriendlyName()
except Exception as e:
self.logger.warning(f"Failed to get friendly name: {e}")
try:
port_type = device_info.GetPortType()
except Exception as e:
self.logger.warning(f"Failed to get port type: {e}")
try:
serial_number = getattr(device_info, "acSn", "Unknown")
except Exception as e:
self.logger.warning(f"Failed to get serial number: {e}")
return {
"friendly_name": friendly_name,
"port_type": port_type,
"serial_number": serial_number,
"last_checked": time.time()
}
except Exception as e: except Exception as e:
self.logger.error(f"Error getting device info: {e}") self.logger.error(f"Error getting device info: {e}", exc_info=True)
return {"error": str(e)} return {"error": str(e)}
def check_camera_now(self, camera_name: str) -> Dict[str, Any]: def check_camera_now(self, camera_name: str) -> Dict[str, Any]:

View File

@@ -273,33 +273,99 @@ class CameraStreamer:
return False return False
# Initialize camera (suppress output to avoid MVCAMAPI error messages) # Initialize camera (suppress output to avoid MVCAMAPI error messages)
with suppress_camera_errors(): try:
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1) with suppress_camera_errors():
self.logger.info("Camera initialized successfully for streaming") self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
self.logger.info("Camera initialized successfully for streaming")
except mvsdk.CameraException as init_e:
self.logger.error(f"CameraInit failed for streaming {self.camera_config.name}: {init_e.message} (error_code: {init_e.error_code})")
self.hCamera = None
return False
# Ensure hCamera is valid before proceeding
if self.hCamera is None:
self.logger.error("Camera initialization returned None handle")
return False
# Get camera capabilities # Get camera capabilities
self.cap = mvsdk.CameraGetCapability(self.hCamera) try:
self.cap = mvsdk.CameraGetCapability(self.hCamera)
except mvsdk.CameraException as cap_e:
self.logger.error(f"CameraGetCapability failed for {self.camera_config.name}: {cap_e.message} (error_code: {cap_e.error_code})")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Determine if camera is monochrome # Determine if camera is monochrome
self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0 self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0
# Set output format based on camera type and bit depth # Set output format based on camera type and bit depth
if self.monoCamera: try:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8) if self.monoCamera:
else: mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8) else:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
except mvsdk.CameraException as fmt_e:
self.logger.error(f"CameraSetIspOutFormat failed for {self.camera_config.name}: {fmt_e.message} (error_code: {fmt_e.error_code})")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Configure camera settings for streaming (optimized for preview) # Configure camera settings for streaming (optimized for preview)
self._configure_streaming_settings() try:
self._configure_streaming_settings()
except Exception as config_e:
self.logger.error(f"Failed to configure streaming settings for {self.camera_config.name}: {config_e}")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Allocate frame buffer # Allocate frame buffer
bytes_per_pixel = 1 if self.monoCamera else 3 try:
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel bytes_per_pixel = 1 if self.monoCamera else 3
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16) self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
except Exception as buf_e:
self.logger.error(f"Failed to allocate frame buffer for {self.camera_config.name}: {buf_e}")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Start camera # Start camera
mvsdk.CameraPlay(self.hCamera) try:
self.logger.info("Camera started successfully for streaming") mvsdk.CameraPlay(self.hCamera)
self.logger.info("Camera started successfully for streaming")
except mvsdk.CameraException as play_e:
self.logger.error(f"CameraPlay failed for {self.camera_config.name}: {play_e.message} (error_code: {play_e.error_code})")
if self.frame_buffer:
try:
mvsdk.CameraAlignFree(self.frame_buffer)
except:
pass
self.frame_buffer = None
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
return True return True

View File

@@ -104,6 +104,26 @@ services:
ports: ports:
- "3002:3002" - "3002:3002"
scheduling-remote:
image: node:20-alpine
working_dir: /app
env_file:
- ./management-dashboard-web-app/.env
environment:
- CHOKIDAR_USEPOLLING=true
- TZ=America/New_York
volumes:
- ./scheduling-remote:/app
command: >
sh -lc "
npm install;
npm run dev:watch
"
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "3003:3003"
media-api: media-api:
build: build:
context: ./media-api context: ./media-api

View File

@@ -5,6 +5,15 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Experiments Dashboard</title> <title>Experiments Dashboard</title>
<script>
// Prevent flash of unstyled content by applying theme immediately
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.classList.add(theme);
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -115,7 +115,7 @@ function App() {
if (loading) { if (loading) {
return ( 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-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div> <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> <p className="mt-4 text-gray-600">Loading...</p>
@@ -127,7 +127,7 @@ function App() {
// Handle signout route // Handle signout route
if (currentRoute === '/signout') { if (currentRoute === '/signout') {
return ( 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-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div> <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> <p className="mt-4 text-gray-600">Signing out...</p>

View File

@@ -7,7 +7,8 @@ import { ExperimentManagement } from './ExperimentManagement'
import { DataEntry } from './DataEntry' import { DataEntry } from './DataEntry'
// VisionSystem is now loaded as a microfrontend - see RemoteVisionSystem below // VisionSystem is now loaded as a microfrontend - see RemoteVisionSystem below
// import { VisionSystem } from './VisionSystem' // 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 React, { Suspense } from 'react'
import { loadRemoteComponent } from '../lib/loadRemote' import { loadRemoteComponent } from '../lib/loadRemote'
import { ErrorBoundary } from './ErrorBoundary' import { ErrorBoundary } from './ErrorBoundary'
@@ -172,6 +173,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
LocalVisionSystemPlaceholder as any LocalVisionSystemPlaceholder as any
) as unknown as React.ComponentType ) 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 = () => { const renderCurrentView = () => {
if (!user) return null if (!user) return null
@@ -216,7 +224,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
</ErrorBoundary> </ErrorBoundary>
) )
case 'scheduling': 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': case 'video-library':
return ( return (
<ErrorBoundary fallback={<div className="p-6">Failed to load video module. Please try again.</div>}> <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) { if (loading) {
return ( 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-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto"></div> <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> <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) { if (error) {
return ( 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="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="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> <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) { if (!user) {
return ( 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-center">
<div className="text-gray-600 dark:text-gray-400">No user data available</div> <div className="text-gray-600 dark:text-gray-400">No user data available</div>
<button <button
@@ -298,7 +312,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)} )}
</div> </div>
<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" : ""}`} } ${isMobileOpen ? "ml-0" : ""}`}
> >
<TopNavbar <TopNavbar

View File

@@ -98,7 +98,7 @@ export function DataEntry() {
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <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> </div>
</div> </div>
@@ -108,8 +108,8 @@ export function DataEntry() {
if (error) { if (error) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4"> <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">{error}</div> <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div> </div>
</div> </div>
) )
@@ -129,8 +129,8 @@ export function DataEntry() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Entry</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Data Entry</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Select a repetition to enter measurement data Select a repetition to enter measurement data
</p> </p>
</div> </div>
@@ -142,13 +142,13 @@ export function DataEntry() {
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Past/Completed Repetitions */} {/* Past/Completed Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg"> <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"> <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 flex items-center"> <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> <span className="w-4 h-4 bg-green-500 rounded-full mr-3"></span>
Past/Completed ({pastRepetitions.length}) Past/Completed ({pastRepetitions.length})
</h2> </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 Completed or past scheduled repetitions
</p> </p>
</div> </div>
@@ -164,7 +164,7 @@ export function DataEntry() {
/> />
))} ))}
{pastRepetitions.length === 0 && ( {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 No completed repetitions
</p> </p>
)} )}
@@ -173,13 +173,13 @@ export function DataEntry() {
</div> </div>
{/* In Progress Repetitions */} {/* In Progress Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg"> <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"> <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 flex items-center"> <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> <span className="w-4 h-4 bg-blue-500 rounded-full mr-3"></span>
In Progress ({inProgressRepetitions.length}) In Progress ({inProgressRepetitions.length})
</h2> </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 Currently scheduled or active repetitions
</p> </p>
</div> </div>
@@ -195,7 +195,7 @@ export function DataEntry() {
/> />
))} ))}
{inProgressRepetitions.length === 0 && ( {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 No repetitions in progress
</p> </p>
)} )}
@@ -204,13 +204,13 @@ export function DataEntry() {
</div> </div>
{/* Upcoming Repetitions */} {/* Upcoming Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg"> <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"> <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 flex items-center"> <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> <span className="w-4 h-4 bg-yellow-500 rounded-full mr-3"></span>
Upcoming ({upcomingRepetitions.length}) Upcoming ({upcomingRepetitions.length})
</h2> </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 Future scheduled repetitions
</p> </p>
</div> </div>
@@ -226,7 +226,7 @@ export function DataEntry() {
/> />
))} ))}
{upcomingRepetitions.length === 0 && ( {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 No upcoming repetitions
</p> </p>
)} )}
@@ -239,7 +239,7 @@ export function DataEntry() {
{experiments.length === 0 && ( {experiments.length === 0 && (
<div className="text-center py-12"> <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 No experiments available for data entry
</div> </div>
</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 justify-between mb-3">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{/* Large, bold experiment number */} {/* 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} #{experiment.experiment_number}
</span> </span>
{/* Smaller repetition number */} {/* 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} Rep #{repetition.repetition_number}
</span> </span>
<span className="text-lg">{getStatusIcon()}</span> <span className="text-lg dark:text-white">{getStatusIcon()}</span>
</div> </div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.scheduled_date <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.scheduled_date
? 'bg-blue-100 text-blue-800' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
: 'bg-yellow-100 text-yellow-800' : 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300'
}`}> }`}>
{repetition.scheduled_date ? 'scheduled' : 'pending'} {repetition.scheduled_date ? 'scheduled' : 'pending'}
</span> </span>
</div> </div>
{/* Experiment details */} {/* 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 {experiment.soaking_duration_hr}h soaking {experiment.air_drying_time_min}min drying
</div> </div>
{repetition.scheduled_date && ( {repetition.scheduled_date && (
<div className="text-sm text-gray-600 mb-2"> <div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()} <strong className="dark:text-white">Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
</div> </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 Click to enter data for this repetition
</div> </div>
</button> </button>

View File

@@ -61,9 +61,9 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Experiment Phases</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">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 dark:text-gray-400">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> <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> </div>
{canManagePhases && ( {canManagePhases && (
<button <button
@@ -78,8 +78,8 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="mb-6 rounded-md bg-red-50 p-4"> <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">{error}</div> <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div> </div>
)} )}
@@ -89,30 +89,30 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div <div
key={phase.id} key={phase.id}
onClick={() => onPhaseSelect(phase)} 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="p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center"> <div className="flex items-center">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center"> <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" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
</div> </div>
</div> </div>
<div className="text-right"> <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 Active
</span> </span>
</div> </div>
</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} {phase.name}
</h3> </h3>
{phase.description && ( {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} {phase.description}
</p> </p>
)} )}
@@ -121,31 +121,31 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div className="mb-4"> <div className="mb-4">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{phase.has_soaking && ( {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 🌰 Soaking
</span> </span>
)} )}
{phase.has_airdrying && ( {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 💨 Air-Drying
</span> </span>
)} )}
{phase.has_cracking && ( {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 🔨 Cracking
</span> </span>
)} )}
{phase.has_shelling && ( {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 📊 Shelling
</span> </span>
)} )}
</div> </div>
</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> <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> <span className="mr-1">View Experiments</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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 && ( {phases.length === 0 && (
<div className="text-center py-12"> <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" /> <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> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiment phases found</h3> <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"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Get started by creating your first experiment phase. Get started by creating your first experiment phase.
</p> </p>
{canManagePhases && ( {canManagePhases && (

View File

@@ -36,7 +36,7 @@ export function Login({ onLoginSuccess }: LoginProps) {
} }
return ( 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 className="max-w-md w-full space-y-8">
<div> <div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">

View File

@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import type { User } from '../lib/supabase' import type { User } from '../lib/supabase'
import { useTheme } from '../hooks/useTheme'
interface TopNavbarProps { interface TopNavbarProps {
user: User user: User
@@ -19,6 +20,7 @@ export function TopNavbar({
onNavigateToProfile onNavigateToProfile
}: TopNavbarProps) { }: TopNavbarProps) {
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false) const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
const { theme, toggleTheme } = useTheme()
const getPageTitle = (view: string) => { const getPageTitle = (view: string) => {
switch (view) { switch (view) {
@@ -139,6 +141,48 @@ export function TopNavbar({
</div> </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"> <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 */} {/* User Area */}
<div className="relative"> <div className="relative">
<button <button

View File

@@ -103,15 +103,15 @@ export function UserManagement() {
const getRoleBadgeColor = (role: string) => { const getRoleBadgeColor = (role: string) => {
switch (role) { switch (role) {
case 'admin': 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': 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': 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': 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: 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="p-6">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div> <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>
</div> </div>
) )
@@ -129,8 +129,8 @@ export function UserManagement() {
if (error) { if (error) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="rounded-md bg-red-50 p-4"> <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">{error}</div> <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div> </div>
<button <button
onClick={loadData} onClick={loadData}
@@ -148,8 +148,8 @@ export function UserManagement() {
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">User Management</h1>
<p className="mt-2 text-gray-600">Manage user accounts, roles, and permissions</p> <p className="mt-2 text-gray-600 dark:text-gray-400">Manage user accounts, roles, and permissions</p>
</div> </div>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
@@ -162,7 +162,7 @@ export function UserManagement() {
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <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="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -170,15 +170,15 @@ export function UserManagement() {
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Users</dt> <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">{users.length}</dd> <dd className="text-lg font-medium text-gray-900 dark:text-white">{users.length}</dd>
</dl> </dl>
</div> </div>
</div> </div>
</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="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -186,8 +186,8 @@ export function UserManagement() {
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt> <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"> <dd className="text-lg font-medium text-gray-900 dark:text-white">
{users.filter(u => u.status === 'active').length} {users.filter(u => u.status === 'active').length}
</dd> </dd>
</dl> </dl>
@@ -196,7 +196,7 @@ export function UserManagement() {
</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="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -204,8 +204,8 @@ export function UserManagement() {
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Disabled Users</dt> <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"> <dd className="text-lg font-medium text-gray-900 dark:text-white">
{users.filter(u => u.status === 'disabled').length} {users.filter(u => u.status === 'disabled').length}
</dd> </dd>
</dl> </dl>
@@ -214,7 +214,7 @@ export function UserManagement() {
</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="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -222,8 +222,8 @@ export function UserManagement() {
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Admins</dt> <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"> <dd className="text-lg font-medium text-gray-900 dark:text-white">
{users.filter(u => u.roles.includes('admin')).length} {users.filter(u => u.roles.includes('admin')).length}
</dd> </dd>
</dl> </dl>
@@ -234,35 +234,35 @@ export function UserManagement() {
</div> </div>
{/* Users Table */} {/* Users Table */}
<div className="bg-white shadow overflow-hidden sm:rounded-md"> <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"> <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">Users</h3> <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"> <p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
Click on any field to edit user details Click on any field to edit user details
</p> </p>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-900">
<tr> <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 Email
</th> </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 Roles
</th> </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 Status
</th> </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 Created
</th> </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 Actions
</th> </th>
</tr> </tr>
</thead> </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) => ( {users.map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
@@ -352,11 +352,11 @@ function UserRow({
type="email" type="email"
value={editEmail} value={editEmail}
onChange={(e) => setEditEmail(e.target.value)} 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 <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} onClick={onEdit}
> >
{user.email} {user.email}
@@ -374,7 +374,7 @@ function UserRow({
onChange={() => handleRoleToggle(role.name)} onChange={() => handleRoleToggle(role.name)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" 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> </label>
))} ))}
</div> </div>
@@ -398,14 +398,14 @@ function UserRow({
<button <button
onClick={() => onStatusToggle(user.id, user.status)} 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' 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-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 text-red-800 hover:bg-red-200' : '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)} {user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</button> </button>
</td> </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()} {new Date(user.created_at).toLocaleDateString()}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
@@ -413,13 +413,13 @@ function UserRow({
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={handleSave} 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 Save
</button> </button>
<button <button
onClick={onCancel} 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 Cancel
</button> </button>
@@ -428,7 +428,7 @@ function UserRow({
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={onEdit} 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" title="Edit user"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -437,7 +437,7 @@ function UserRow({
</button> </button>
<button <button
onClick={() => onPasswordReset(user.id, user.email)} 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'" title="Reset password to 'password123'"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View 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 }
}

View File

@@ -188,7 +188,7 @@
} }
body { 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;
} }
} }

View File

@@ -4,6 +4,7 @@ export type FeatureFlags = {
enableExperimentModule: boolean enableExperimentModule: boolean
enableCameraModule: boolean enableCameraModule: boolean
enableVisionSystemModule: boolean enableVisionSystemModule: boolean
enableSchedulingModule: boolean
} }
const toBool = (v: unknown, fallback = false): 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), enableExperimentModule: toBool(import.meta.env.VITE_ENABLE_EXPERIMENT_MODULE ?? false),
enableCameraModule: toBool(import.meta.env.VITE_ENABLE_CAMERA_MODULE ?? false), enableCameraModule: toBool(import.meta.env.VITE_ENABLE_CAMERA_MODULE ?? false),
enableVisionSystemModule: toBool(import.meta.env.VITE_ENABLE_VISION_SYSTEM_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] export const isFeatureEnabled = (flag: keyof FeatureFlags): boolean => featureFlags[flag]

View File

@@ -20,7 +20,8 @@ export default defineConfig({
remotes: { remotes: {
// Allow overriding by env; default to localhost for dev // Allow overriding by env; default to localhost for dev
videoRemote: process.env.VITE_VIDEO_REMOTE_URL || 'http://localhost:3001/assets/remoteEntry.js', videoRemote: process.env.VITE_VIDEO_REMOTE_URL || 'http://localhost:3001/assets/remoteEntry.js',
visionSystemRemote: process.env.VITE_VISION_SYSTEM_REMOTE_URL || 'http://localhost:3002/assets/remoteEntry.js' visionSystemRemote: process.env.VITE_VISION_SYSTEM_REMOTE_URL || 'http://localhost:3002/assets/remoteEntry.js',
schedulingRemote: process.env.VITE_SCHEDULING_REMOTE_URL || 'http://localhost:3003/assets/remoteEntry.js'
}, },
shared: { shared: {
react: { singleton: true, eager: true }, react: { singleton: true, eager: true },

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scheduling Module</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4494
scheduling-remote/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "scheduling-remote",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:watch": "vite build --watch",
"serve:dist": "serve -s dist -l 3003",
"preview": "vite preview --port 3003",
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3003 --cors -c-1"
},
"dependencies": {
"@supabase/supabase-js": "^2.52.0",
"moment": "^2.30.1",
"react": "^19.1.0",
"react-big-calendar": "^1.19.4",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { Scheduling } from './components/Scheduling'
import type { User } from './services/supabase'
interface AppProps {
user?: User
currentRoute?: string
}
export default function App(props: AppProps) {
// Get user and route from props or try to get from window (for standalone testing)
const user = props.user || (window as any).__SCHEDULING_USER__
const currentRoute = props.currentRoute || window.location.pathname
if (!user) {
return (
<div className="p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
User information is required to use the scheduling module. Please ensure you are logged in.
</div>
</div>
</div>
)
}
return <Scheduling user={user} currentRoute={currentRoute} />
}

View File

@@ -0,0 +1,250 @@
/* Custom styles for React Big Calendar to match dashboard theme */
.rbc-calendar {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* Dark mode support */
.dark .rbc-calendar {
background: #1f2937;
color: #f9fafb;
}
/* Header styling */
.rbc-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
color: #374151;
font-weight: 600;
padding: 12px 8px;
}
.dark .rbc-header {
background: #374151;
border-bottom: 1px solid #4b5563;
color: #f9fafb;
}
/* Today styling */
.rbc-today {
background: #eff6ff;
}
.dark .rbc-today {
background: #1e3a8a;
}
/* Date cells */
.rbc-date-cell {
color: #374151;
font-weight: 500;
}
.dark .rbc-date-cell {
color: #f9fafb;
}
/* Event styling */
.rbc-event {
border-radius: 4px;
border: none;
font-size: 12px;
font-weight: 500;
padding: 2px 4px;
}
.rbc-event-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Month view specific */
.rbc-month-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.dark .rbc-month-view {
border: 1px solid #4b5563;
}
.rbc-month-row {
border-bottom: 1px solid #e2e8f0;
}
.dark .rbc-month-row {
border-bottom: 1px solid #4b5563;
}
.rbc-date-cell {
padding: 8px;
}
/* Week and day view specific */
.rbc-time-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.dark .rbc-time-view {
border: 1px solid #4b5563;
}
.rbc-time-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.dark .rbc-time-header {
background: #374151;
border-bottom: 1px solid #4b5563;
}
.rbc-time-content {
background: white;
}
.dark .rbc-time-content {
background: #1f2937;
}
/* Time slots */
.rbc-time-slot {
border-top: 1px solid #f1f5f9;
color: #64748b;
}
.dark .rbc-time-slot {
border-top: 1px solid #374151;
color: #9ca3af;
}
/* Toolbar */
.rbc-toolbar {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
margin-bottom: 0;
}
.dark .rbc-toolbar {
background: #374151;
border-bottom: 1px solid #4b5563;
}
.rbc-toolbar button {
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #374151;
font-weight: 500;
padding: 6px 12px;
transition: all 0.2s;
}
.rbc-toolbar button:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.rbc-toolbar button:active,
.rbc-toolbar button.rbc-active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.dark .rbc-toolbar button {
background: #1f2937;
border: 1px solid #4b5563;
color: #f9fafb;
}
.dark .rbc-toolbar button:hover {
background: #374151;
border-color: #6b7280;
}
.dark .rbc-toolbar button:active,
.dark .rbc-toolbar button.rbc-active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Labels */
.rbc-toolbar-label {
color: #111827;
font-size: 18px;
font-weight: 600;
}
.dark .rbc-toolbar-label {
color: #f9fafb;
}
/* Drag and drop improvements */
.rbc-event {
cursor: grab !important;
user-select: none;
}
.rbc-event:active {
cursor: grabbing !important;
transform: scale(1.05);
z-index: 1000 !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event spacing and visibility */
.rbc-event-content {
pointer-events: none;
}
/* Better visual feedback for dragging */
.rbc-addons-dnd-dragging {
opacity: 0.8;
transform: rotate(2deg);
z-index: 1000 !important;
}
.rbc-addons-dnd-drag-preview {
background: rgba(255, 255, 255, 0.9) !important;
border: 2px dashed #3b82f6 !important;
border-radius: 8px !important;
padding: 8px 12px !important;
font-weight: bold !important;
color: #1f2937 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event hover states */
.rbc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
}
/* Better spacing between events */
.rbc-time-slot {
min-height: 24px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.rbc-toolbar {
flex-direction: column;
gap: 8px;
}
.rbc-toolbar button {
font-size: 14px;
padding: 8px 12px;
}
.rbc-toolbar-label {
font-size: 16px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,415 @@
import { createClient } from '@supabase/supabase-js'
// Supabase configuration from environment
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase is not configured. Please set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your environment (.env)')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Type definitions
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
export type UserStatus = 'active' | 'disabled'
export interface User {
id: string
email: string
first_name?: string
last_name?: string
roles: RoleName[]
status: UserStatus
created_at: string
updated_at: string
}
export interface ExperimentPhase {
id: string
name: string
description?: string | null
has_soaking: boolean
has_airdrying: boolean
has_cracking: boolean
has_shelling: boolean
cracking_machine_type_id?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface Experiment {
id: string
experiment_number: number
reps_required: number
weight_per_repetition_lbs: number
phase_id?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface ExperimentRepetition {
id: string
experiment_id: string
repetition_number: number
scheduled_date?: string | null
completion_status: boolean
created_at: string
updated_at: string
created_by: string
}
export interface Soaking {
id: string
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
actual_start_time?: string | null
soaking_duration_minutes: number
scheduled_end_time: string
actual_end_time?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface Airdrying {
id: string
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
actual_start_time?: string | null
duration_minutes: number
scheduled_end_time: string
actual_end_time?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface ConductorAvailability {
id: string
user_id: string
available_from: string
available_to: string
notes?: string | null
status: 'active' | 'cancelled'
created_at: string
updated_at: string
created_by: string
}
export interface CreateAvailabilityRequest {
available_from: string
available_to: string
notes?: string
}
export interface CreateRepetitionRequest {
experiment_id: string
repetition_number: number
scheduled_date?: string | null
}
export interface UpdateRepetitionRequest {
scheduled_date?: string | null
completion_status?: boolean
}
export interface CreateSoakingRequest {
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
soaking_duration_minutes: number
}
export interface CreateAirdryingRequest {
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
duration_minutes: number
}
export interface CreateCrackingRequest {
experiment_id: string
repetition_id?: string | null
machine_type_id: string
scheduled_start_time: string
}
// Service APIs
export const userManagement = {
async getAllUsers(): Promise<User[]> {
const { data: profiles, error: profilesError } = await supabase
.from('user_profiles')
.select(`
id,
email,
first_name,
last_name,
status,
created_at,
updated_at
`)
if (profilesError) throw profilesError
const usersWithRoles = await Promise.all(
profiles.map(async (profile) => {
const { data: userRoles, error: rolesError } = await supabase
.from('user_roles')
.select(`
roles!inner (
name
)
`)
.eq('user_id', profile.id)
if (rolesError) throw rolesError
return {
...profile,
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
}
})
)
return usersWithRoles
},
}
export const experimentPhaseManagement = {
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
const { data, error } = await supabase
.from('experiment_phases')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
},
}
export const experimentManagement = {
async getExperimentsByPhaseId(phaseId: string): Promise<Experiment[]> {
const { data, error } = await supabase
.from('experiments')
.select('*')
.eq('phase_id', phaseId)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
}
export const repetitionManagement = {
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data, error } = await supabase
.from('experiment_repetitions')
.select('*')
.eq('experiment_id', experimentId)
.order('repetition_number', { ascending: true })
if (error) throw error
return data
},
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
// Get experiment to find reps_required
const { data: experiment, error: expError } = await supabase
.from('experiments')
.select('reps_required')
.eq('id', experimentId)
.single()
if (expError || !experiment) throw new Error('Experiment not found')
// Get existing repetitions to determine next repetition number
const existingReps = await this.getExperimentRepetitions(experimentId)
const nextRepNumber = existingReps.length > 0
? Math.max(...existingReps.map(r => r.repetition_number)) + 1
: 1
// Create all remaining repetitions
const repsToCreate = experiment.reps_required - existingReps.length
const newReps: ExperimentRepetition[] = []
for (let i = 0; i < repsToCreate; i++) {
const { data, error } = await supabase
.from('experiment_repetitions')
.insert({
experiment_id: experimentId,
repetition_number: nextRepNumber + i,
completion_status: false,
created_by: user.id
})
.select()
.single()
if (error) throw error
if (data) newReps.push(data)
}
return [...existingReps, ...newReps]
},
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
const { data, error } = await supabase
.from('experiment_repetitions')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
},
}
export const phaseManagement = {
async createSoaking(request: CreateSoakingRequest): Promise<Soaking> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.soaking_duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('soaking')
.upsert({
...request,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async createAirdrying(request: CreateAirdryingRequest): Promise<Airdrying> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('airdrying')
.upsert({
...request,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async createCracking(request: CreateCrackingRequest): Promise<any> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('cracking')
.upsert({
...request,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
const { data, error } = await supabase
.from('soaking')
.select('*')
.eq('experiment_id', experimentId)
.is('repetition_id', null)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return data
},
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
const { data, error } = await supabase
.from('airdrying')
.select('*')
.eq('experiment_id', experimentId)
.is('repetition_id', null)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return data
},
}
export const availabilityManagement = {
async getMyAvailability(): Promise<ConductorAvailability[]> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('conductor_availability')
.select('*')
.eq('user_id', user.id)
.eq('status', 'active')
.order('available_from', { ascending: true })
if (error) throw error
return data
},
async createAvailability(request: CreateAvailabilityRequest): Promise<ConductorAvailability> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('conductor_availability')
.insert({
user_id: user.id,
available_from: request.available_from,
available_to: request.available_to,
notes: request.notes,
created_by: user.id
})
.select()
.single()
if (error) throw error
return data
},
async deleteAvailability(id: string): Promise<void> {
const { error } = await supabase
.from('conductor_availability')
.update({ status: 'cancelled' })
.eq('id', id)
if (error) throw error
},
}

11
scheduling-remote/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL?: string
readonly VITE_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "src/vite-env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
federation({
name: 'schedulingRemote',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
},
}),
],
server: {
port: 3003,
host: '0.0.0.0',
allowedHosts: ['exp-dash', 'localhost'],
cors: true
},
build: {
target: 'esnext',
},
})

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Helper script to add dark mode classes to components
# This is a reference - actual changes should be made manually for safety
echo "Patterns to apply for dark mode:"
echo ""
echo "1. Backgrounds:"
echo " bg-white -> bg-white dark:bg-gray-800"
echo " bg-gray-50 -> bg-gray-50 dark:bg-gray-900"
echo ""
echo "2. Text:"
echo " text-gray-900 -> text-gray-900 dark:text-white"
echo " text-gray-800 -> text-gray-800 dark:text-white/90"
echo " text-gray-700 -> text-gray-700 dark:text-gray-300"
echo " text-gray-600 -> text-gray-600 dark:text-gray-400"
echo " text-gray-500 -> text-gray-500 dark:text-gray-400"
echo ""
echo "3. Borders:"
echo " border-gray-200 -> border-gray-200 dark:border-gray-700"
echo " border-gray-300 -> border-gray-300 dark:border-gray-600"
echo ""
echo "4. Buttons/Inputs:"
echo " bg-blue-100 -> bg-blue-100 dark:bg-blue-900/20"
echo " text-blue-800 -> text-blue-800 dark:text-blue-300"

View File

@@ -51,3 +51,5 @@ echo ""
echo "All services have been reset and are running in detached mode." echo "All services have been reset and are running in detached mode."
echo "Use 'docker compose logs -f' to view logs or 'docker compose ps' to check status." echo "Use 'docker compose logs -f' to view logs or 'docker compose ps' to check status."

View File

@@ -57,8 +57,10 @@ export default function App() {
setRecordings(recordingsData) setRecordings(recordingsData)
setLastUpdate(new Date()) setLastUpdate(new Date())
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch data') // Don't set error state - let widgets show API is unavailable
// Keep existing state so UI can still render
console.error('Failed to fetch initial data:', err) console.error('Failed to fetch initial data:', err)
setLastUpdate(new Date()) // Update timestamp even on error to show we tried
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -221,12 +223,32 @@ export default function App() {
const handlePreviewModal = useCallback((cameraName: string) => { const handlePreviewModal = useCallback((cameraName: string) => {
setPreviewCamera(cameraName) setPreviewCamera(cameraName)
setPreviewModalOpen(true) setPreviewModalOpen(true)
// The modal will start streaming and notify us via onStreamStarted callback
}, []) }, [])
const handlePreviewNewWindow = useCallback((cameraName: string) => { const handlePreviewNewWindow = useCallback(async (cameraName: string) => {
// Open camera stream in new window/tab try {
const streamUrl = visionApi.getStreamUrl(cameraName) // Start streaming before opening new window
window.open(streamUrl, '_blank') const result = await visionApi.startStream(cameraName)
if (result.success) {
// Immediately update camera status to show "Stop Streaming" button
setCameras((prev) => ({
...prev,
[cameraName]: {
...prev[cameraName],
status: 'streaming',
},
}))
// Open camera stream in new window/tab
const streamUrl = visionApi.getStreamUrl(cameraName)
window.open(streamUrl, '_blank')
} else {
setNotification({ type: 'error', message: `Failed to start stream: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Failed to start stream' })
}
}, []) }, [])
const handleConfigure = useCallback((cameraName: string) => { const handleConfigure = useCallback((cameraName: string) => {
@@ -259,8 +281,20 @@ export default function App() {
const result = await visionApi.stopStream(cameraName) const result = await visionApi.stopStream(cameraName)
if (result.success) { if (result.success) {
setNotification({ type: 'success', message: 'Streaming stopped' }) setNotification({ type: 'success', message: 'Streaming stopped' })
// Refresh camera status
visionApi.getCameras().then(setCameras).catch(console.error) // Immediately update camera status (UI updates instantly)
setCameras((prev) => ({
...prev,
[cameraName]: {
...prev[cameraName],
status: 'available',
},
}))
// Refresh camera status from API as backup
setTimeout(() => {
visionApi.getCameras().then(setCameras).catch(console.error)
}, 500)
} else { } else {
setNotification({ type: 'error', message: `Failed: ${result.message}` }) setNotification({ type: 'error', message: `Failed: ${result.message}` })
} }
@@ -438,6 +472,26 @@ export default function App() {
setPreviewModalOpen(false) setPreviewModalOpen(false)
setPreviewCamera(null) setPreviewCamera(null)
}} }}
onStreamStarted={() => {
// Update camera status when streaming starts
setCameras((prev) => ({
...prev,
[previewCamera]: {
...prev[previewCamera],
status: 'streaming',
},
}))
}}
onStreamStopped={() => {
// Update camera status when streaming stops
setCameras((prev) => ({
...prev,
[previewCamera]: {
...prev[previewCamera],
status: 'available',
},
}))
}}
/> />
)} )}

View File

@@ -195,11 +195,11 @@ export const CameraCard: React.FC<CameraCardProps> = ({
{isStreaming && ( {isStreaming && (
<button <button
onClick={() => onStopStreaming(cameraName)} onClick={() => onStopStreaming(cameraName)}
className="px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors" className="flex items-center justify-center px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
title="Stop streaming" title="Stop streaming"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)} )}

View File

@@ -6,6 +6,8 @@ interface CameraPreviewModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onError?: (error: string) => void onError?: (error: string) => void
onStreamStarted?: () => void
onStreamStopped?: () => void
} }
export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
@@ -13,6 +15,8 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onError, onError,
onStreamStarted,
onStreamStopped,
}) => { }) => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState(false) const [streaming, setStreaming] = useState(false)
@@ -46,6 +50,9 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
if (imgRef.current) { if (imgRef.current) {
imgRef.current.src = `${streamUrl}?t=${Date.now()}` imgRef.current.src = `${streamUrl}?t=${Date.now()}`
} }
// Notify parent that streaming started
onStreamStarted?.()
} else { } else {
throw new Error(result.message) throw new Error(result.message)
} }
@@ -68,6 +75,9 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
if (imgRef.current) { if (imgRef.current) {
imgRef.current.src = '' imgRef.current.src = ''
} }
// Notify parent that streaming stopped
onStreamStopped?.()
} }
} catch (err) { } catch (err) {
console.error('Error stopping stream:', err) console.error('Error stopping stream:', err)
@@ -82,12 +92,13 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-[999999] flex items-center justify-center overflow-y-auto"> <div className="fixed inset-0 z-[999999] flex items-center justify-center overflow-y-auto p-4">
<div <div
className="fixed inset-0 h-full w-full bg-gray-900/60 backdrop-blur-sm" className="fixed inset-0 h-full w-full bg-gray-900/70 backdrop-blur-md"
onClick={handleClose} onClick={handleClose}
style={{ backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)' }}
/> />
<div className="relative w-11/12 max-w-5xl rounded-xl bg-white shadow-2xl dark:bg-gray-800 p-6" onClick={(e) => e.stopPropagation()}> <div className="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl dark:bg-gray-800 p-6" onClick={(e) => e.stopPropagation()}>
{/* Close Button */} {/* Close Button */}
<button <button
onClick={handleClose} onClick={handleClose}
@@ -148,7 +159,7 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
<img <img
ref={imgRef} ref={imgRef}
alt={`Live stream from ${cameraName}`} alt={`Live stream from ${cameraName}`}
className="w-full h-auto max-h-[70vh] object-contain" className="w-full h-auto max-h-[60vh] object-contain"
onError={() => setError('Failed to load camera stream')} onError={() => setError('Failed to load camera stream')}
/> />
</div> </div>