Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references

This commit is contained in:
Alireza Vaezi
2025-08-07 22:07:25 -04:00
parent 28dab3a366
commit fc2da16728
281 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,162 @@
import { memo, useState, useEffect } from 'react'
import { visionApi, type AutoRecordingStatusResponse } from '../lib/visionApi'
import { useAuth } from '../hooks/useAuth'
const AutoRecordingStatus = memo(() => {
const { isAdmin } = useAuth()
const isAdminUser = isAdmin()
const [status, setStatus] = useState<AutoRecordingStatusResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Fetch auto-recording status
const fetchStatus = async () => {
try {
setLoading(true)
setError(null)
const statusData = await visionApi.getAutoRecordingStatus()
setStatus(statusData)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch auto-recording status'
setError(errorMessage)
console.error('Failed to fetch auto-recording status:', err)
} finally {
setLoading(false)
}
}
// Fetch status on mount and set up polling
useEffect(() => {
if (!isAdminUser) {
return
}
fetchStatus()
const interval = setInterval(fetchStatus, 10000) // Poll every 10 seconds
return () => clearInterval(interval)
}, [isAdminUser])
// Only show to admins
if (!isAdminUser) {
return null
}
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording System</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Server-side automatic recording based on machine state changes
</p>
</div>
<div className="flex items-center space-x-2">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status?.running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{status?.running ? 'Running' : 'Stopped'}
</div>
<button
onClick={fetchStatus}
disabled={loading}
className="bg-indigo-600 text-white px-3 py-1 rounded-md text-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
</div>
{error && (
<div className="px-4 py-3 border-t border-gray-200">
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<p className="text-red-800 text-sm">{error}</p>
</div>
</div>
)}
{status && (
<div className="border-t border-gray-200">
<div className="px-4 py-5 sm:px-6">
<h4 className="text-md font-medium text-gray-900 mb-3">System Status</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">System Running:</span>
<span className={`font-medium ${status.running ? 'text-green-600' : 'text-red-600'
}`}>
{status.running ? 'YES' : 'NO'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Auto-Recording Enabled:</span>
<span className={`font-medium ${status.auto_recording_enabled ? 'text-green-600' : 'text-gray-600'
}`}>
{status.auto_recording_enabled ? 'YES' : 'NO'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Enabled Cameras:</span>
<span className="font-medium text-gray-900">
{status.enabled_cameras.length}
</span>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Retry Queue:</span>
<span className="font-medium text-gray-900">
{Object.keys(status.retry_queue).length} items
</span>
</div>
</div>
</div>
{status.enabled_cameras.length > 0 && (
<div className="mt-4">
<h5 className="text-sm font-medium text-gray-900 mb-2">Enabled Cameras:</h5>
<div className="flex flex-wrap gap-2">
{status.enabled_cameras.map((camera) => (
<span
key={camera}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
{camera}
</span>
))}
</div>
</div>
)}
{Object.keys(status.retry_queue).length > 0 && (
<div className="mt-4">
<h5 className="text-sm font-medium text-gray-900 mb-2">Retry Queue:</h5>
<div className="space-y-1">
{Object.entries(status.retry_queue).map(([camera, retryInfo]) => (
<div key={camera} className="text-xs text-gray-600 bg-yellow-50 p-2 rounded">
<strong>{camera}:</strong> {JSON.stringify(retryInfo)}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{!status && !loading && !error && (
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<div className="text-center text-gray-500">
<p>Auto-recording status not available</p>
<p className="text-sm mt-1">Click "Refresh" to fetch the current status</p>
</div>
</div>
)}
</div>
)
})
AutoRecordingStatus.displayName = 'AutoRecordingStatus'
export { AutoRecordingStatus }

View File

@@ -0,0 +1,193 @@
/**
* Auto-Recording Test Component
*
* This component provides a testing interface for the auto-recording functionality.
* It allows admins to simulate MQTT events and verify auto-recording behavior.
*/
import { useState } from 'react'
import { visionApi } from '../lib/visionApi'
import { useAuth } from '../hooks/useAuth'
interface TestEvent {
machine: string
state: 'on' | 'off'
timestamp: Date
result?: string
}
export function AutoRecordingTest() {
const { isAdmin } = useAuth()
const [testEvents, setTestEvents] = useState<TestEvent[]>([])
const [isLoading, setIsLoading] = useState(false)
if (!isAdmin()) {
return null
}
const simulateEvent = async (machine: string, state: 'on' | 'off') => {
setIsLoading(true)
const event: TestEvent = {
machine,
state,
timestamp: new Date()
}
try {
// Map machines to their corresponding cameras
const machineToCamera: Record<string, string> = {
'blower_separator': 'camera1', // camera1 is for blower separator
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
}
const cameraName = machineToCamera[machine]
if (!cameraName) {
event.result = `❌ Error: No camera mapped for machine ${machine}`
setTestEvents(prev => [event, ...prev.slice(0, 9)])
setIsLoading(false)
return
}
if (state === 'on') {
// Simulate starting recording on the correct camera
const result = await visionApi.startRecording(cameraName, {
filename: `test_auto_${machine}_${Date.now()}.mp4`
})
event.result = result.success ? `✅ Recording started on ${cameraName}: ${result.filename}` : `❌ Failed: ${result.message}`
} else {
// Simulate stopping recording on the correct camera
const result = await visionApi.stopRecording(cameraName)
event.result = result.success ? `⏹️ Recording stopped on ${cameraName} (${result.duration_seconds}s)` : `❌ Failed: ${result.message}`
}
} catch (error) {
event.result = `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}
setTestEvents(prev => [event, ...prev.slice(0, 9)]) // Keep last 10 events
setIsLoading(false)
}
const clearEvents = () => {
setTestEvents([])
}
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording Test</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Simulate machine state changes to test auto-recording functionality
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<div className="space-y-4">
{/* Test Controls */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-3">Simulate Machine Events</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
onClick={() => simulateEvent('vibratory_conveyor', 'on')}
disabled={isLoading}
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Conveyor ON
</button>
<button
onClick={() => simulateEvent('vibratory_conveyor', 'off')}
disabled={isLoading}
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Conveyor OFF
</button>
<button
onClick={() => simulateEvent('blower_separator', 'on')}
disabled={isLoading}
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Blower ON
</button>
<button
onClick={() => simulateEvent('blower_separator', 'off')}
disabled={isLoading}
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Blower OFF
</button>
</div>
</div>
{/* Clear Button */}
{testEvents.length > 0 && (
<div className="flex justify-end">
<button
onClick={clearEvents}
className="bg-gray-600 text-white px-3 py-2 rounded-md text-sm hover:bg-gray-700"
>
Clear Events
</button>
</div>
)}
{/* Test Results */}
{testEvents.length > 0 && (
<div>
<h4 className="text-md font-medium text-gray-900 mb-3">Test Results</h4>
<div className="space-y-2">
{testEvents.map((event, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-sm font-medium text-gray-900">
{event.machine.replace(/_/g, ' ')}
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${event.state === 'on'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{event.state.toUpperCase()}
</span>
<span className="text-xs text-gray-500">
{event.timestamp.toLocaleTimeString()}
</span>
</div>
</div>
{event.result && (
<div className="mt-2 text-sm text-gray-700">
{event.result}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">Testing Instructions</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li>1. Ensure auto-recording is enabled for cameras in their configuration</li>
<li>2. Start the auto-recording manager in the Vision System page</li>
<li>3. Click the buttons above to simulate machine state changes</li>
<li>4. Verify that recordings start/stop automatically</li>
<li>5. Check the storage section for auto-generated recording files</li>
</ul>
</div>
{/* Expected Behavior */}
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<h4 className="text-sm font-medium text-gray-900 mb-2">Expected Behavior</h4>
<div className="text-sm text-gray-700 space-y-1">
<div><strong>Conveyor ON:</strong> Camera2 should start recording automatically</div>
<div><strong>Conveyor OFF:</strong> Camera2 should stop recording automatically</div>
<div><strong>Blower ON:</strong> Camera1 should start recording automatically</div>
<div><strong>Blower OFF:</strong> Camera1 should stop recording automatically</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,754 @@
import { useState, useEffect } from 'react'
import { visionApi, type CameraConfig, type CameraConfigUpdate } from '../lib/visionApi'
interface CameraConfigModalProps {
cameraName: string
isOpen: boolean
onClose: () => void
onSuccess?: (message: string) => void
onError?: (error: string) => void
}
export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onError }: CameraConfigModalProps) {
const [config, setConfig] = useState<CameraConfig | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
const [originalConfig, setOriginalConfig] = useState<CameraConfig | null>(null)
useEffect(() => {
if (isOpen && cameraName) {
loadConfig()
}
}, [isOpen, cameraName])
const loadConfig = async () => {
try {
setLoading(true)
setError(null)
const configData = await visionApi.getCameraConfig(cameraName)
// The API should now include all fields including video format settings
const configWithDefaults = configData
setConfig(configWithDefaults)
setOriginalConfig(configWithDefaults)
setHasChanges(false)
} catch (err) {
let errorMessage = 'Failed to load camera configuration'
if (err instanceof Error) {
errorMessage = err.message
// Handle specific API validation errors for missing video format fields
if (err.message.includes('video_format') || err.message.includes('video_codec') || err.message.includes('video_quality')) {
errorMessage = 'Camera configuration is missing video format settings. This may indicate the backend needs to be updated to support MP4 format. Using default values.'
// Create a default configuration for display
const defaultConfig = {
name: cameraName,
machine_topic: '',
storage_path: '',
enabled: true,
auto_record_on_machine_start: false,
auto_start_recording_enabled: false,
auto_recording_max_retries: 3,
auto_recording_retry_delay_seconds: 2,
exposure_ms: 1.0,
gain: 3.5,
target_fps: 0,
video_format: 'mp4',
video_codec: 'mp4v',
video_quality: 95,
sharpness: 120,
contrast: 110,
saturation: 100,
gamma: 100,
noise_filter_enabled: true,
denoise_3d_enabled: false,
auto_white_balance: true,
color_temperature_preset: 0,
anti_flicker_enabled: true,
light_frequency: 1,
bit_depth: 8,
hdr_enabled: false,
hdr_gain_mode: 0,
}
setConfig(defaultConfig)
setOriginalConfig(defaultConfig)
setHasChanges(false)
setError(errorMessage)
return
}
}
setError(errorMessage)
onError?.(errorMessage)
} finally {
setLoading(false)
}
}
const updateSetting = (key: keyof CameraConfigUpdate, value: number | boolean | string) => {
if (!config) return
const newConfig = { ...config, [key]: value }
setConfig(newConfig)
// Check if there are changes from original
const hasChanges = originalConfig && Object.keys(newConfig).some(k => {
const configKey = k as keyof CameraConfig
return newConfig[configKey] !== originalConfig[configKey]
})
setHasChanges(!!hasChanges)
// Video format settings are read-only, no validation needed
}
const saveConfig = async () => {
if (!config || !originalConfig) return
try {
setSaving(true)
setError(null)
// Build update object with only changed values
const updates: CameraConfigUpdate = {}
const configKeys: (keyof CameraConfigUpdate)[] = [
'exposure_ms', 'gain', 'target_fps', 'sharpness', 'contrast', 'saturation',
'gamma', 'noise_filter_enabled', 'denoise_3d_enabled', 'auto_white_balance',
'color_temperature_preset', 'anti_flicker_enabled', 'light_frequency',
'hdr_enabled', 'hdr_gain_mode', 'auto_record_on_machine_start',
'auto_start_recording_enabled', 'auto_recording_max_retries', 'auto_recording_retry_delay_seconds'
]
configKeys.forEach(key => {
if (config[key] !== originalConfig[key]) {
updates[key] = config[key] as any
}
})
if (Object.keys(updates).length === 0) {
onSuccess?.('No changes to save')
return
}
const result = await visionApi.updateCameraConfig(cameraName, updates)
if (result.success) {
setOriginalConfig(config)
setHasChanges(false)
onSuccess?.(`Configuration updated: ${result.updated_settings.join(', ')}`)
} else {
throw new Error(result.message)
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to save configuration'
setError(errorMessage)
onError?.(errorMessage)
} finally {
setSaving(false)
}
}
const resetChanges = () => {
if (originalConfig) {
setConfig(originalConfig)
setHasChanges(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
/>
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-4 max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
Camera Configuration - {cameraName}
</h3>
</div>
</div>
{/* Content */}
<div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-140px)]">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<span className="ml-2 text-gray-600">Loading configuration...</span>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Configuration Error</h3>
<p className="mt-2 text-sm text-red-700">{error}</p>
{error.includes('video_format') && (
<p className="mt-2 text-sm text-red-600">
<strong>Note:</strong> The video format settings are displayed with default values.
You can still modify and save the configuration, but the backend may need to be updated
to fully support MP4 format settings.
</p>
)}
</div>
</div>
</div>
)}
{config && !loading && (
<div className="space-y-6">
{/* System Information (Read-Only) */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">System Information</h4>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Camera Name</label>
<div className="text-sm text-gray-900 font-medium">{config.name}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Machine Topic</label>
<div className="text-sm text-gray-900 font-medium">{config.machine_topic}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Storage Path</label>
<div className="text-sm text-gray-900 font-medium">{config.storage_path}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<div className="text-sm text-gray-900 font-medium">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{config.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Auto-Recording Settings (Read-Only) */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Auto-Recording Settings</h4>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auto Recording</label>
<div className="text-sm text-gray-900 font-medium">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.auto_start_recording_enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{config.auto_start_recording_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Retries</label>
<div className="text-sm text-gray-900 font-medium">{config.auto_recording_max_retries}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Retry Delay</label>
<div className="text-sm text-gray-900 font-medium">{config.auto_recording_retry_delay_seconds}s</div>
</div>
</div>
<p className="text-xs text-gray-500 mt-3">Auto-recording settings are configured in the system configuration file</p>
</div>
</div>
{/* Basic Settings */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Basic Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Exposure (ms): {config.exposure_ms}
</label>
<input
type="range"
min="0.1"
max="10"
step="0.1"
value={config.exposure_ms}
onChange={(e) => updateSetting('exposure_ms', parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0.1ms</span>
<span>10ms</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Gain: {config.gain}
</label>
<input
type="range"
min="0"
max="10"
step="0.1"
value={config.gain}
onChange={(e) => updateSetting('gain', parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>10</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Target FPS: {config.target_fps} {config.target_fps === 0 ? '(Maximum)' : ''}
</label>
<input
type="range"
min="0"
max="30"
step="1"
value={config.target_fps}
onChange={(e) => updateSetting('target_fps', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0 (Max)</span>
<span>30</span>
</div>
</div>
</div>
</div>
{/* Image Quality Settings */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Image Quality</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sharpness: {config.sharpness}
</label>
<input
type="range"
min="0"
max="200"
value={config.sharpness}
onChange={(e) => updateSetting('sharpness', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>200</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contrast: {config.contrast}
</label>
<input
type="range"
min="0"
max="200"
value={config.contrast}
onChange={(e) => updateSetting('contrast', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>200</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Saturation: {config.saturation}
</label>
<input
type="range"
min="0"
max="200"
value={config.saturation}
onChange={(e) => updateSetting('saturation', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>200</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Gamma: {config.gamma}
</label>
<input
type="range"
min="0"
max="300"
value={config.gamma}
onChange={(e) => updateSetting('gamma', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>300</span>
</div>
</div>
</div>
</div>
{/* Color Settings */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Color Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={config.auto_white_balance}
onChange={(e) => updateSetting('auto_white_balance', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700">Auto White Balance</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Color Temperature Preset: {config.color_temperature_preset} {config.color_temperature_preset === 0 ? '(Auto)' : ''}
</label>
<input
type="range"
min="0"
max="10"
value={config.color_temperature_preset}
onChange={(e) => updateSetting('color_temperature_preset', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0 (Auto)</span>
<span>10</span>
</div>
</div>
</div>
</div>
{/* White Balance RGB Gains */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">White Balance RGB Gains</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Red Gain: {config.wb_red_gain?.toFixed(2) || '1.00'}
</label>
<input
type="range"
min="0"
max="3.99"
step="0.01"
value={config.wb_red_gain || 1.0}
onChange={(e) => updateSetting('wb_red_gain', parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0.00</span>
<span>3.99</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Green Gain: {config.wb_green_gain?.toFixed(2) || '1.00'}
</label>
<input
type="range"
min="0"
max="3.99"
step="0.01"
value={config.wb_green_gain || 1.0}
onChange={(e) => updateSetting('wb_green_gain', parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0.00</span>
<span>3.99</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Blue Gain: {config.wb_blue_gain?.toFixed(2) || '1.00'}
</label>
<input
type="range"
min="0"
max="3.99"
step="0.01"
value={config.wb_blue_gain || 1.0}
onChange={(e) => updateSetting('wb_blue_gain', parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0.00</span>
<span>3.99</span>
</div>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">Manual white balance gains (only effective when Auto White Balance is disabled)</p>
</div>
{/* Advanced Settings */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Advanced Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={config.anti_flicker_enabled}
onChange={(e) => updateSetting('anti_flicker_enabled', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700">Anti-flicker Enabled</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Light Frequency: {config.light_frequency === 0 ? '50Hz' : '60Hz'}
</label>
<select
value={config.light_frequency}
onChange={(e) => updateSetting('light_frequency', parseInt(e.target.value))}
className="w-full border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value={0}>50Hz</option>
<option value={1}>60Hz</option>
</select>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={config.noise_filter_enabled}
onChange={(e) => updateSetting('noise_filter_enabled', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700">Noise Filter Enabled</span>
</label>
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={config.denoise_3d_enabled}
onChange={(e) => updateSetting('denoise_3d_enabled', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700">3D Denoise Enabled</span>
</label>
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
</div>
</div>
</div>
{/* HDR Settings */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">HDR Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={config.hdr_enabled}
onChange={(e) => updateSetting('hdr_enabled', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700">HDR Enabled</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
HDR Gain Mode: {config.hdr_gain_mode}
</label>
<input
type="range"
min="0"
max="3"
value={config.hdr_gain_mode}
onChange={(e) => updateSetting('hdr_gain_mode', parseInt(e.target.value))}
className="w-full"
disabled={!config.hdr_enabled}
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>3</span>
</div>
</div>
</div>
</div>
{/* Video Recording Settings (Read-Only) */}
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Video Recording Settings</h4>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Video Format
</label>
<div className="text-sm text-gray-900 font-medium">
{config.video_format?.toUpperCase() || 'MP4'}
</div>
<p className="text-xs text-gray-500">Current recording format</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Video Codec
</label>
<div className="text-sm text-gray-900 font-medium">
{config.video_codec?.toUpperCase() || 'MP4V'}
</div>
<p className="text-xs text-gray-500">Compression codec</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Video Quality
</label>
<div className="text-sm text-gray-900 font-medium">
{config.video_quality || 95}%
</div>
<p className="text-xs text-gray-500">Recording quality</p>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">Video Format Information</h3>
<div className="mt-2 text-sm text-blue-700">
<p>Video recording settings are configured in the system configuration file and require a service restart to modify.</p>
<p className="mt-1"><strong>Current benefits:</strong> MP4 format provides ~40% smaller file sizes and better web compatibility than AVI.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Information */}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">Configuration Notes</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc list-inside space-y-1">
<li><strong>Real-time settings:</strong> Exposure, gain, image quality, white balance - apply immediately</li>
<li><strong>System settings:</strong> Video format, noise reduction, auto-recording - configured in system files</li>
<li><strong>Performance:</strong> HDR mode may impact frame rate when enabled</li>
<li><strong>White balance:</strong> RGB gains only effective when auto white balance is disabled</li>
</ul>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
{config && !loading && (
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{hasChanges && (
<span className="text-sm text-orange-600 font-medium">
You have unsaved changes
</span>
)}
</div>
<div className="flex items-center space-x-3">
{hasChanges && (
<button
onClick={resetChanges}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Reset
</button>
)}
<button
onClick={saveConfig}
disabled={!hasChanges || saving}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import { useState, useEffect, useRef } from 'react'
import { visionApi } from '../lib/visionApi'
interface CameraPreviewModalProps {
cameraName: string
isOpen: boolean
onClose: () => void
onError?: (error: string) => void
}
export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: CameraPreviewModalProps) {
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const imgRef = useRef<HTMLImageElement>(null)
const streamUrlRef = useRef<string | null>(null)
// Start streaming when modal opens
useEffect(() => {
if (isOpen && cameraName) {
startStreaming()
}
}, [isOpen, cameraName])
// Stop streaming when modal closes
useEffect(() => {
if (!isOpen && streaming) {
stopStreaming()
}
}, [isOpen, streaming])
// Cleanup on unmount
useEffect(() => {
return () => {
if (streaming) {
stopStreaming()
}
}
}, [streaming])
const startStreaming = async () => {
try {
setLoading(true)
setError(null)
const result = await visionApi.startStream(cameraName)
if (result.success) {
setStreaming(true)
const streamUrl = visionApi.getStreamUrl(cameraName)
streamUrlRef.current = streamUrl
// Add timestamp to prevent caching
if (imgRef.current) {
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
}
} else {
throw new Error(result.message)
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to start stream'
setError(errorMessage)
onError?.(errorMessage)
} finally {
setLoading(false)
}
}
const stopStreaming = async () => {
try {
if (streaming) {
await visionApi.stopStream(cameraName)
setStreaming(false)
streamUrlRef.current = null
// Clear the image source
if (imgRef.current) {
imgRef.current.src = ''
}
}
} catch (err) {
console.error('Error stopping stream:', err)
// Don't show error to user for stop stream failures
}
}
const handleClose = () => {
stopStreaming()
onClose()
}
const handleImageError = () => {
setError('Failed to load camera stream')
}
const handleImageLoad = () => {
setError(null)
}
if (!isOpen) return null
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={handleClose}
/>
<div className="relative w-11/12 max-w-4xl rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 p-5" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={handleClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
<div className="mt-3">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
Camera Preview: {cameraName}
</h3>
</div>
{/* Content */}
<div className="mb-4">
{loading && (
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
<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">Starting camera stream...</p>
</div>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Stream Error</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={startStreaming}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Retry
</button>
</div>
</div>
</div>
</div>
)}
{streaming && !loading && !error && (
<div className="bg-black rounded-lg overflow-hidden">
<img
ref={imgRef}
alt={`Live stream from ${cameraName}`}
className="w-full h-auto max-h-96 object-contain"
onError={handleImageError}
onLoad={handleImageLoad}
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{streaming && (
<div className="flex items-center text-green-600">
<div className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
<span className="text-sm font-medium">Live Stream Active</span>
</div>
)}
</div>
<div className="flex space-x-3">
<button
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,288 @@
import { useState } from 'react'
import { userManagement, type User, type Role, type RoleName, type CreateUserRequest } from '../lib/supabase'
interface CreateUserModalProps {
roles: Role[]
onClose: () => void
onUserCreated: (user: User) => void
}
export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserModalProps) {
const [formData, setFormData] = useState<CreateUserRequest>({
email: '',
roles: [],
tempPassword: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [generatedPassword, setGeneratedPassword] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const handleRoleToggle = (roleName: RoleName) => {
if (formData.roles.includes(roleName)) {
setFormData({
...formData,
roles: formData.roles.filter(r => r !== roleName)
})
} else {
setFormData({
...formData,
roles: [...formData.roles, roleName]
})
}
}
const generatePassword = () => {
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
setFormData({ ...formData, tempPassword: result })
setGeneratedPassword(result)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
// Validation
if (!formData.email) {
setError('Email is required')
return
}
if (formData.roles.length === 0) {
setError('At least one role must be selected')
return
}
if (!formData.tempPassword) {
setError('Password is required')
return
}
try {
setLoading(true)
const response = await userManagement.createUser(formData)
// Create user object for the parent component
const newUser: User = {
id: response.user_id,
email: response.email,
roles: response.roles,
status: response.status,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
onUserCreated(newUser)
// Show success message with password
alert(`User created successfully!\n\nEmail: ${response.email}\nTemporary Password: ${response.temp_password}\n\nPlease save this password as it won't be shown again.`)
} catch (err: any) {
setError(err.message || 'Failed to create user')
console.error('Create user error:', err)
} finally {
setLoading(false)
}
}
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
case 'conductor':
return 'bg-blue-100 text-blue-800'
case 'analyst':
return 'bg-green-100 text-green-800'
case 'data recorder':
return 'bg-purple-100 text-purple-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
/>
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">Create New User</h3>
</div>
<div className="p-6">
{/* Form */}
<form id="create-user-form" onSubmit={handleSubmit} className="space-y-6">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
id="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
placeholder="user@example.com"
required
/>
</div>
{/* Roles */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Roles (select at least one)
</label>
<div className="space-y-3">
{roles.map((role) => (
<label key={role.id} className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<input
type="checkbox"
checked={formData.roles.includes(role.name)}
onChange={() => handleRoleToggle(role.name)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
/>
<div className="ml-3 flex-1">
<span className="text-sm font-medium text-gray-900 capitalize">{role.name}</span>
<p className="text-xs text-gray-500 mt-1">{role.description}</p>
</div>
</label>
))}
</div>
{/* Selected roles preview */}
{formData.roles.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-500 mb-1">Selected roles:</div>
<div className="flex flex-wrap gap-1">
{formData.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</div>
)}
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Temporary Password
</label>
<div className="flex rounded-lg border border-gray-300 overflow-hidden focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500">
<input
type={showPassword ? 'text' : 'password'}
id="password"
value={formData.tempPassword}
onChange={(e) => setFormData({ ...formData, tempPassword: e.target.value })}
className="flex-1 px-4 py-3 border-0 focus:ring-0 focus:outline-none text-sm placeholder-gray-400"
placeholder="Enter password or generate one"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="px-3 py-3 bg-gray-50 hover:bg-gray-100 text-gray-600 border-l border-gray-300 transition-colors"
title={showPassword ? 'Hide password' : 'Show password'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878l-1.414-1.414M14.12 14.12l1.414 1.414M14.12 14.12L15.536 15.536M14.12 14.12l1.414 1.414" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
)}
</svg>
</button>
<button
type="button"
onClick={generatePassword}
className="px-4 py-3 bg-blue-50 hover:bg-blue-100 text-blue-600 border-l border-gray-300 text-sm font-medium transition-colors"
>
Generate
</button>
</div>
<p className="mt-2 text-xs text-gray-500">
User will need to change this password on first login
</p>
</div>
{/* Error */}
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-red-700">{error}</div>
</div>
</div>
)}
</form>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 rounded-b-2xl">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 border border-gray-300 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-3 focus:ring-brand-500/10 transition-colors"
>
Cancel
</button>
<button
type="submit"
form="create-user-form"
disabled={loading}
className="px-6 py-2.5 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<div className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating...
</div>
) : (
'Create User'
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { DashboardLayout } from "./DashboardLayout"
interface DashboardProps {
onLogout: () => void
}
export function Dashboard({ onLogout }: DashboardProps) {
return <DashboardLayout onLogout={onLogout} />
}

View File

@@ -0,0 +1,185 @@
import type { User } from '../lib/supabase'
interface DashboardHomeProps {
user: User
}
export function DashboardHome({ user }: DashboardHomeProps) {
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
case 'conductor':
return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
case 'analyst':
return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
case 'data recorder':
return 'bg-theme-purple-500/10 text-theme-purple-500'
default:
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
}
}
const getPermissionsByRole = (role: string) => {
switch (role) {
case 'admin':
return ['Full system access', 'User management', 'All modules', 'System configuration']
case 'conductor':
return ['Experiment management', 'Data collection', 'Analytics access', 'Data entry']
case 'analyst':
return ['Data analysis', 'Report generation', 'Read-only access', 'Analytics dashboard']
case 'data recorder':
return ['Data entry', 'Record management', 'Basic reporting', 'Data validation']
default:
return []
}
}
return (
<div className="grid grid-cols-12 gap-4 md:gap-6">
{/* Welcome Section */}
<div className="col-span-12 mb-6">
<h1 className="text-title-md font-bold text-gray-800 dark:text-white/90">Dashboard</h1>
<p className="mt-2 text-gray-500 dark:text-gray-400">Welcome to the Pecan Experiments Dashboard</p>
</div>
{/* User Information Card */}
<div className="col-span-12 xl:col-span-8">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
User Information
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Your account details and role permissions.
</p>
<div className="space-y-4">
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Email</span>
<span className="text-sm text-gray-800 dark:text-white/90">{user.email}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Roles</span>
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</div>
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Status</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
? 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
: 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
}`}>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">User ID</span>
<span className="text-sm text-gray-800 dark:text-white/90 font-mono">{user.id}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Member since</span>
<span className="text-sm text-gray-800 dark:text-white/90">
{new Date(user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
</div>
</div>
</div>
{/* Role Permissions */}
<div className="col-span-12 xl:col-span-4">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
Role Permissions
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Your access levels and capabilities.
</p>
<div className="space-y-4">
{user.roles.map((role) => (
<div key={role} className="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
<div className="flex items-center mb-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
</div>
<ul className="space-y-2">
{getPermissionsByRole(role).map((permission, index) => (
<li key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<span className="text-success-500 mr-2"></span>
{permission}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
{/* Quick Actions */}
{user.roles.includes('admin') && (
<div className="col-span-12">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
Quick Actions
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Administrative shortcuts and tools.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-lg text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 transition-colors">
👥 Manage Users
</button>
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
🧪 View Experiments
</button>
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
📊 Analytics
</button>
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
Settings
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react'
import { Sidebar } from './Sidebar'
import { TopNavbar } from './TopNavbar'
import { DashboardHome } from './DashboardHome'
import { UserManagement } from './UserManagement'
import { Experiments } from './Experiments'
import { DataEntry } from './DataEntry'
import { VisionSystem } from './VisionSystem'
import { VideoStreamingPage } from '../features/video-streaming'
import { userManagement, type User } from '../lib/supabase'
interface DashboardLayoutProps {
onLogout: () => void
}
export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentView, setCurrentView] = useState('dashboard')
const [isExpanded, setIsExpanded] = useState(true)
const [isMobileOpen, setIsMobileOpen] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
fetchUserProfile()
}, [])
const fetchUserProfile = async () => {
try {
setLoading(true)
setError(null)
const currentUser = await userManagement.getCurrentUser()
if (currentUser) {
setUser(currentUser)
} else {
setError('No authenticated user found')
}
} catch (err) {
setError('Failed to fetch user profile')
console.error('Profile fetch error:', err)
} finally {
setLoading(false)
}
}
const handleLogout = async () => {
// Navigate to signout route which will handle the actual logout
window.history.pushState({}, '', '/signout')
window.dispatchEvent(new PopStateEvent('popstate'))
}
const toggleSidebar = () => {
setIsExpanded(!isExpanded)
}
const toggleMobileSidebar = () => {
setIsMobileOpen(!isMobileOpen)
}
const handleToggleSidebar = () => {
if (window.innerWidth >= 1024) {
toggleSidebar()
} else {
toggleMobileSidebar()
}
}
const renderCurrentView = () => {
if (!user) return null
switch (currentView) {
case 'dashboard':
return <DashboardHome user={user} />
case 'user-management':
if (user.roles.includes('admin')) {
return <UserManagement />
} else {
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">
Access denied. You need admin privileges to access user management.
</div>
</div>
</div>
)
}
case 'experiments':
return <Experiments />
case 'analytics':
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Analytics</h1>
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<div className="text-sm text-green-700">
Analytics module coming soon...
</div>
</div>
</div>
)
case 'data-entry':
return <DataEntry />
case 'vision-system':
return <VisionSystem />
case 'video-library':
return <VideoStreamingPage />
default:
return <DashboardHome user={user} />
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<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>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<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>
</div>
<button
onClick={handleLogout}
className="mt-4 w-full flex justify-center py-2.5 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gray-600 hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
Back to Login
</button>
</div>
</div>
)
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-gray-600 dark:text-gray-400">No user data available</div>
<button
onClick={handleLogout}
className="mt-4 px-4 py-2.5 bg-gray-600 text-white rounded-lg hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
Back to Login
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen xl:flex">
<div>
<Sidebar
user={user}
currentView={currentView}
onViewChange={setCurrentView}
isExpanded={isExpanded}
isMobileOpen={isMobileOpen}
isHovered={isHovered}
setIsHovered={setIsHovered}
/>
{/* Backdrop for mobile */}
{isMobileOpen && (
<div
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
onClick={() => setIsMobileOpen(false)}
/>
)}
</div>
<div
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
} ${isMobileOpen ? "ml-0" : ""}`}
>
<TopNavbar
user={user}
onLogout={handleLogout}
currentView={currentView}
onToggleSidebar={handleToggleSidebar}
isSidebarOpen={isMobileOpen}
/>
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
{renderCurrentView()}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,327 @@
import { useState, useEffect } from 'react'
import { experimentManagement, repetitionManagement, userManagement, type Experiment, type ExperimentRepetition, type User } from '../lib/supabase'
import { RepetitionDataEntryInterface } from './RepetitionDataEntryInterface'
export function DataEntry() {
const [experiments, setExperiments] = useState<Experiment[]>([])
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
const [selectedRepetition, setSelectedRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
setError(null)
const [experimentsData, userData] = await Promise.all([
experimentManagement.getAllExperiments(),
userManagement.getCurrentUser()
])
setExperiments(experimentsData)
setCurrentUser(userData)
// Load repetitions for each experiment
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
for (const experiment of experimentsData) {
try {
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
repetitionsMap[experiment.id] = repetitions
} catch (err) {
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
repetitionsMap[experiment.id] = []
}
}
setExperimentRepetitions(repetitionsMap)
} catch (err: any) {
setError(err.message || 'Failed to load data')
console.error('Load data error:', err)
} finally {
setLoading(false)
}
}
const handleRepetitionSelect = (experiment: Experiment, repetition: ExperimentRepetition) => {
setSelectedRepetition({ experiment, repetition })
}
const handleBackToList = () => {
setSelectedRepetition(null)
}
const getAllRepetitionsWithExperiments = () => {
const allRepetitions: Array<{ experiment: Experiment; repetition: ExperimentRepetition }> = []
experiments.forEach(experiment => {
const repetitions = experimentRepetitions[experiment.id] || []
repetitions.forEach(repetition => {
allRepetitions.push({ experiment, repetition })
})
})
return allRepetitions
}
const categorizeRepetitions = () => {
const allRepetitions = getAllRepetitionsWithExperiments()
const now = new Date()
const past = allRepetitions.filter(({ repetition }) =>
repetition.completion_status || (repetition.scheduled_date && new Date(repetition.scheduled_date) < now)
)
const inProgress = allRepetitions.filter(({ repetition }) =>
!repetition.completion_status &&
repetition.scheduled_date &&
new Date(repetition.scheduled_date) <= now &&
new Date(repetition.scheduled_date) > new Date(now.getTime() - 24 * 60 * 60 * 1000)
)
const upcoming = allRepetitions.filter(({ repetition }) =>
!repetition.completion_status &&
repetition.scheduled_date &&
new Date(repetition.scheduled_date) > now
)
return { past, inProgress, upcoming }
}
if (loading) {
return (
<div className="p-6">
<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>
</div>
</div>
</div>
)
}
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>
</div>
)
}
if (selectedRepetition) {
return (
<RepetitionDataEntryInterface
experiment={selectedRepetition.experiment}
repetition={selectedRepetition.repetition}
currentUser={currentUser!}
onBack={handleBackToList}
/>
)
}
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">
Select a repetition to enter measurement data
</p>
</div>
{/* Repetitions organized by status - flat list */}
{(() => {
const { past: pastRepetitions, inProgress: inProgressRepetitions, upcoming: upcomingRepetitions } = categorizeRepetitions()
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">
<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">
Completed or past scheduled repetitions
</p>
</div>
<div className="p-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{pastRepetitions.map(({ experiment, repetition }) => (
<RepetitionCard
key={repetition.id}
experiment={experiment}
repetition={repetition}
onSelect={handleRepetitionSelect}
status="past"
/>
))}
{pastRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
No completed repetitions
</p>
)}
</div>
</div>
</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">
<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">
Currently scheduled or active repetitions
</p>
</div>
<div className="p-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{inProgressRepetitions.map(({ experiment, repetition }) => (
<RepetitionCard
key={repetition.id}
experiment={experiment}
repetition={repetition}
onSelect={handleRepetitionSelect}
status="in-progress"
/>
))}
{inProgressRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
No repetitions in progress
</p>
)}
</div>
</div>
</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">
<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">
Future scheduled repetitions
</p>
</div>
<div className="p-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{upcomingRepetitions.map(({ experiment, repetition }) => (
<RepetitionCard
key={repetition.id}
experiment={experiment}
repetition={repetition}
onSelect={handleRepetitionSelect}
status="upcoming"
/>
))}
{upcomingRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
No upcoming repetitions
</p>
)}
</div>
</div>
</div>
</div>
)
})()}
{experiments.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500">
No experiments available for data entry
</div>
</div>
)}
</div>
)
}
// RepetitionCard component for displaying individual repetitions
interface RepetitionCardProps {
experiment: Experiment
repetition: ExperimentRepetition
onSelect: (experiment: Experiment, repetition: ExperimentRepetition) => void
status: 'past' | 'in-progress' | 'upcoming'
}
function RepetitionCard({ experiment, repetition, onSelect, status }: RepetitionCardProps) {
const getStatusColor = () => {
switch (status) {
case 'past':
return 'border-green-200 bg-green-50 hover:bg-green-100'
case 'in-progress':
return 'border-blue-200 bg-blue-50 hover:bg-blue-100'
case 'upcoming':
return 'border-yellow-200 bg-yellow-50 hover:bg-yellow-100'
default:
return 'border-gray-200 bg-gray-50 hover:bg-gray-100'
}
}
const getStatusIcon = () => {
switch (status) {
case 'past':
return '✓'
case 'in-progress':
return '▶'
case 'upcoming':
return '⏰'
default:
return '○'
}
}
return (
<button
onClick={() => onSelect(experiment, repetition)}
className={`w-full text-left p-4 border-2 rounded-lg hover:shadow-lg transition-all duration-200 ${getStatusColor()}`}
>
<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">
#{experiment.experiment_number}
</span>
{/* Smaller repetition number */}
<span className="text-lg font-semibold text-gray-700">
Rep #{repetition.repetition_number}
</span>
<span className="text-lg">{getStatusIcon()}</span>
</div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.schedule_status === 'scheduled'
? 'bg-blue-100 text-blue-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
</span>
</div>
{/* Experiment details */}
<div className="text-sm text-gray-600 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>
)}
<div className="text-xs text-gray-500">
Click to enter data for this repetition
</div>
</button>
)
}

View File

@@ -0,0 +1,229 @@
import { useState, useEffect } from 'react'
import { type Experiment, type User, type ExperimentPhase } from '../lib/supabase'
import { DraftManager } from './DraftManager'
import { PhaseSelector } from './PhaseSelector'
// DEPRECATED: This component is deprecated in favor of RepetitionDataEntryInterface
// which uses the new phase-specific draft system
interface DataEntryInterfaceProps {
experiment: Experiment
currentUser: User
onBack: () => void
}
// Temporary type for backward compatibility
interface LegacyDataEntry {
id: string
experiment_id: string
user_id: string
status: 'draft' | 'submitted'
entry_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
}
export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntryInterfaceProps) {
const [userDataEntries, setUserDataEntries] = useState<LegacyDataEntry[]>([])
const [selectedDataEntry, setSelectedDataEntry] = useState<LegacyDataEntry | null>(null)
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
const [showDraftManager, setShowDraftManager] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadUserDataEntries()
}, [experiment.id, currentUser.id])
const loadUserDataEntries = async () => {
try {
setLoading(true)
setError(null)
// DEPRECATED: Using empty array since this component is deprecated
const entries: LegacyDataEntry[] = []
setUserDataEntries(entries)
// Auto-select the most recent draft or create a new one
const drafts = entries.filter(entry => entry.status === 'draft')
if (drafts.length > 0) {
setSelectedDataEntry(drafts[0])
} else {
// Create a new draft entry
await handleCreateNewDraft()
}
} catch (err: any) {
setError(err.message || 'Failed to load data entries')
console.error('Load data entries error:', err)
} finally {
setLoading(false)
}
}
const handleCreateNewDraft = async () => {
setError('This component is deprecated. Please use the new repetition-based data entry system.')
}
const handleSelectDataEntry = (entry: LegacyDataEntry) => {
setSelectedDataEntry(entry)
setShowDraftManager(false)
setSelectedPhase(null)
}
const handleDeleteDraft = async (_entryId: string) => {
setError('This component is deprecated. Please use the new repetition-based data entry system.')
}
const handleSubmitEntry = async (_entryId: string) => {
setError('This component is deprecated. Please use the new repetition-based data entry system.')
}
const handlePhaseSelect = (phase: ExperimentPhase) => {
setSelectedPhase(phase)
}
const handleBackToPhases = () => {
setSelectedPhase(null)
}
if (loading) {
return (
<div className="p-6">
<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 data entries...</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<div className="text-sm text-red-700">{error}</div>
</div>
<button
onClick={onBack}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
Back to Experiments
</button>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Experiments
</button>
<h1 className="text-3xl font-bold text-gray-900">
Experiment #{experiment.experiment_number}
</h1>
</div>
<div className="text-right">
<button
onClick={() => setShowDraftManager(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 mr-2"
>
Manage Drafts
</button>
<button
onClick={handleCreateNewDraft}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
New Draft
</button>
</div>
</div>
{/* Experiment Details */}
<div className="mt-4 bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Repetitions:</span>
<span className="ml-1 text-gray-900">{experiment.reps_required}</span>
</div>
<div>
<span className="font-medium text-gray-700">Soaking Duration:</span>
<span className="ml-1 text-gray-900">{experiment.soaking_duration_hr}h</span>
</div>
<div>
<span className="font-medium text-gray-700">Air Drying:</span>
<span className="ml-1 text-gray-900">{experiment.air_drying_time_min}min</span>
</div>
<div>
<span className="font-medium text-gray-700">Status:</span>
<span className={`ml-1 ${experiment.completion_status ? 'text-green-600' : 'text-yellow-600'}`}>
{experiment.completion_status ? 'Completed' : 'In Progress'}
</span>
</div>
</div>
{/* Scheduled date removed - this is now handled at repetition level */}
</div>
{/* Current Draft Info */}
{selectedDataEntry && (
<div className="mt-4 bg-blue-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<span className="font-medium text-blue-700">Current Draft:</span>
<span className="ml-2 text-blue-900">{selectedDataEntry.entry_name}</span>
<span className="ml-2 text-sm text-blue-600">
Created: {new Date(selectedDataEntry.created_at).toLocaleString()}
</span>
</div>
<button
onClick={() => handleSubmitEntry(selectedDataEntry.id)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
>
Submit Entry
</button>
</div>
</div>
)}
</div>
{/* Main Content */}
{showDraftManager ? (
<DraftManager
userDataEntries={userDataEntries}
selectedDataEntry={selectedDataEntry}
onSelectEntry={handleSelectDataEntry}
onDeleteDraft={handleDeleteDraft}
onCreateNew={handleCreateNewDraft}
onClose={() => setShowDraftManager(false)}
/>
) : selectedPhase ? (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
This component is deprecated. Please use the new repetition-based data entry system.
</div>
</div>
) : selectedDataEntry ? (
<PhaseSelector
dataEntry={selectedDataEntry}
onPhaseSelect={handlePhaseSelect}
/>
) : (
<div className="text-center text-gray-500">
No data entry selected. Please create a new draft or select an existing one.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,188 @@
// DEPRECATED: This component is deprecated in favor of PhaseDraftManager
// Temporary type for backward compatibility
interface LegacyDataEntry {
id: string
experiment_id: string
user_id: string
status: 'draft' | 'submitted'
entry_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
}
interface DraftManagerProps {
userDataEntries: LegacyDataEntry[]
selectedDataEntry: LegacyDataEntry | null
onSelectEntry: (entry: LegacyDataEntry) => void
onDeleteDraft: (entryId: string) => void
onCreateNew: () => void
onClose: () => void
}
export function DraftManager({
userDataEntries,
selectedDataEntry,
onSelectEntry,
onDeleteDraft,
onCreateNew,
onClose
}: DraftManagerProps) {
const drafts = userDataEntries.filter(entry => entry.status === 'draft')
const submitted = userDataEntries.filter(entry => entry.status === 'submitted')
return (
<div className="bg-white rounded-lg shadow-lg">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">Draft Manager</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-6">
{/* Draft Entries */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-md font-medium text-gray-900">
Draft Entries ({drafts.length})
</h3>
<button
onClick={onCreateNew}
className="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
Create New Draft
</button>
</div>
{drafts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>No draft entries found</p>
<p className="text-sm mt-1">Create a new draft to start entering data</p>
</div>
) : (
<div className="space-y-3">
{drafts.map((entry) => (
<div
key={entry.id}
className={`border rounded-lg p-4 ${selectedDataEntry?.id === entry.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900">
{entry.entry_name || 'Untitled Draft'}
</h4>
{selectedDataEntry?.id === entry.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Current
</span>
)}
</div>
<div className="mt-1 text-xs text-gray-500">
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
<div>Last updated: {new Date(entry.updated_at).toLocaleString()}</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onSelectEntry(entry)}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
{selectedDataEntry?.id === entry.id ? 'Continue' : 'Select'}
</button>
<button
onClick={() => onDeleteDraft(entry.id)}
className="px-3 py-1 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Submitted Entries */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-4">
Submitted Entries ({submitted.length})
</h3>
{submitted.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No submitted entries found</p>
<p className="text-sm mt-1">Submit a draft to see it here</p>
</div>
) : (
<div className="space-y-3">
{submitted.map((entry) => (
<div
key={entry.id}
className="border border-green-200 bg-green-50 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900">
{entry.entry_name || 'Untitled Entry'}
</h4>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Submitted
</span>
</div>
<div className="mt-1 text-xs text-gray-500">
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
{entry.submitted_at && (
<div>Submitted: {new Date(entry.submitted_at).toLocaleString()}</div>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onSelectEntry(entry)}
className="px-3 py-1 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
>
View
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
Close
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,370 @@
import { useState } from 'react'
import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase'
interface ExperimentFormProps {
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
onCancel: () => void
isEditing?: boolean
loading?: boolean
}
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) {
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
experiment_number: initialData?.experiment_number || 0,
reps_required: initialData?.reps_required || 1,
soaking_duration_hr: initialData?.soaking_duration_hr || 0,
air_drying_time_min: initialData?.air_drying_time_min || 0,
plate_contact_frequency_hz: initialData?.plate_contact_frequency_hz || 1,
throughput_rate_pecans_sec: initialData?.throughput_rate_pecans_sec || 1,
crush_amount_in: initialData?.crush_amount_in || 0,
entry_exit_height_diff_in: initialData?.entry_exit_height_diff_in || 0,
schedule_status: initialData?.schedule_status || 'pending schedule',
results_status: initialData?.results_status || 'valid',
completion_status: initialData?.completion_status || false
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
// Required field validation
if (!formData.experiment_number || formData.experiment_number <= 0) {
newErrors.experiment_number = 'Experiment number must be a positive integer'
}
if (!formData.reps_required || formData.reps_required <= 0) {
newErrors.reps_required = 'Repetitions required must be a positive integer'
}
if (formData.soaking_duration_hr < 0) {
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
}
if (formData.air_drying_time_min < 0) {
newErrors.air_drying_time_min = 'Air drying time cannot be negative'
}
if (!formData.plate_contact_frequency_hz || formData.plate_contact_frequency_hz <= 0) {
newErrors.plate_contact_frequency_hz = 'Plate contact frequency must be positive'
}
if (!formData.throughput_rate_pecans_sec || formData.throughput_rate_pecans_sec <= 0) {
newErrors.throughput_rate_pecans_sec = 'Throughput rate must be positive'
}
if (formData.crush_amount_in < 0) {
newErrors.crush_amount_in = 'Crush amount cannot be negative'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
return
}
try {
// Prepare data for submission
const submitData = isEditing ? formData : {
experiment_number: formData.experiment_number,
reps_required: formData.reps_required,
soaking_duration_hr: formData.soaking_duration_hr,
air_drying_time_min: formData.air_drying_time_min,
plate_contact_frequency_hz: formData.plate_contact_frequency_hz,
throughput_rate_pecans_sec: formData.throughput_rate_pecans_sec,
crush_amount_in: formData.crush_amount_in,
entry_exit_height_diff_in: formData.entry_exit_height_diff_in,
schedule_status: formData.schedule_status,
results_status: formData.results_status
}
await onSubmit(submitData)
} catch (error) {
console.error('Form submission error:', error)
}
}
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean) => {
setFormData(prev => ({
...prev,
[field]: value
}))
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}))
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="experiment_number" className="block text-sm font-medium text-gray-700 mb-2">
Experiment Number *
</label>
<input
type="number"
id="experiment_number"
value={formData.experiment_number}
onChange={(e) => handleInputChange('experiment_number', parseInt(e.target.value) || 0)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.experiment_number ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Enter unique experiment number"
min="1"
step="1"
required
/>
{errors.experiment_number && (
<p className="mt-1 text-sm text-red-600">{errors.experiment_number}</p>
)}
</div>
<div>
<label htmlFor="reps_required" className="block text-sm font-medium text-gray-700 mb-2">
Repetitions Required *
</label>
<input
type="number"
id="reps_required"
value={formData.reps_required}
onChange={(e) => handleInputChange('reps_required', parseInt(e.target.value) || 1)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.reps_required ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Total repetitions needed"
min="1"
step="1"
required
/>
{errors.reps_required && (
<p className="mt-1 text-sm text-red-600">{errors.reps_required}</p>
)}
</div>
</div>
{/* Experiment Parameters */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Experiment Parameters</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="soaking_duration_hr" className="block text-sm font-medium text-gray-700 mb-2">
Soaking Duration (hours) *
</label>
<input
type="number"
id="soaking_duration_hr"
value={formData.soaking_duration_hr}
onChange={(e) => handleInputChange('soaking_duration_hr', parseFloat(e.target.value) || 0)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.soaking_duration_hr ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0.0"
min="0"
step="0.1"
required
/>
{errors.soaking_duration_hr && (
<p className="mt-1 text-sm text-red-600">{errors.soaking_duration_hr}</p>
)}
</div>
<div>
<label htmlFor="air_drying_time_min" className="block text-sm font-medium text-gray-700 mb-2">
Air Drying Time (minutes) *
</label>
<input
type="number"
id="air_drying_time_min"
value={formData.air_drying_time_min}
onChange={(e) => handleInputChange('air_drying_time_min', parseInt(e.target.value) || 0)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.air_drying_time_min ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0"
min="0"
step="1"
required
/>
{errors.air_drying_time_min && (
<p className="mt-1 text-sm text-red-600">{errors.air_drying_time_min}</p>
)}
</div>
<div>
<label htmlFor="plate_contact_frequency_hz" className="block text-sm font-medium text-gray-700 mb-2">
Plate Contact Frequency (Hz) *
</label>
<input
type="number"
id="plate_contact_frequency_hz"
value={formData.plate_contact_frequency_hz}
onChange={(e) => handleInputChange('plate_contact_frequency_hz', parseFloat(e.target.value) || 1)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.plate_contact_frequency_hz ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="1.0"
min="0.1"
step="0.1"
required
/>
{errors.plate_contact_frequency_hz && (
<p className="mt-1 text-sm text-red-600">{errors.plate_contact_frequency_hz}</p>
)}
</div>
<div>
<label htmlFor="throughput_rate_pecans_sec" className="block text-sm font-medium text-gray-700 mb-2">
Throughput Rate (pecans/sec) *
</label>
<input
type="number"
id="throughput_rate_pecans_sec"
value={formData.throughput_rate_pecans_sec}
onChange={(e) => handleInputChange('throughput_rate_pecans_sec', parseFloat(e.target.value) || 1)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.throughput_rate_pecans_sec ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="1.0"
min="0.1"
step="0.1"
required
/>
{errors.throughput_rate_pecans_sec && (
<p className="mt-1 text-sm text-red-600">{errors.throughput_rate_pecans_sec}</p>
)}
</div>
<div>
<label htmlFor="crush_amount_in" className="block text-sm font-medium text-gray-700 mb-2">
Crush Amount (thousandths of inch) *
</label>
<input
type="number"
id="crush_amount_in"
value={formData.crush_amount_in}
onChange={(e) => handleInputChange('crush_amount_in', parseFloat(e.target.value) || 0)}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.crush_amount_in ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0.0"
min="0"
step="0.001"
required
/>
{errors.crush_amount_in && (
<p className="mt-1 text-sm text-red-600">{errors.crush_amount_in}</p>
)}
</div>
<div className="md:col-span-2">
<label htmlFor="entry_exit_height_diff_in" className="block text-sm font-medium text-gray-700 mb-2">
Entry/Exit Height Difference (inches) *
</label>
<input
type="number"
id="entry_exit_height_diff_in"
value={formData.entry_exit_height_diff_in}
onChange={(e) => handleInputChange('entry_exit_height_diff_in', parseFloat(e.target.value) || 0)}
className={`max-w-sm px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.entry_exit_height_diff_in ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0.0 (can be negative)"
step="0.1"
required
/>
{errors.entry_exit_height_diff_in && (
<p className="mt-1 text-sm text-red-600">{errors.entry_exit_height_diff_in}</p>
)}
<p className="mt-1 text-sm text-gray-500">Positive values indicate entry is higher than exit</p>
</div>
</div>
</div>
{/* Status Fields (only show when editing) */}
{isEditing && (
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="schedule_status" className="block text-sm font-medium text-gray-700 mb-2">
Schedule Status
</label>
<select
id="schedule_status"
value={formData.schedule_status}
onChange={(e) => handleInputChange('schedule_status', e.target.value as ScheduleStatus)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
>
<option value="pending schedule">Pending Schedule</option>
<option value="scheduled">Scheduled</option>
<option value="canceled">Canceled</option>
<option value="aborted">Aborted</option>
</select>
</div>
<div>
<label htmlFor="results_status" className="block text-sm font-medium text-gray-700 mb-2">
Results Status
</label>
<select
id="results_status"
value={formData.results_status}
onChange={(e) => handleInputChange('results_status', e.target.value as ResultsStatus)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
>
<option value="valid">Valid</option>
<option value="invalid">Invalid</option>
</select>
</div>
<div>
<label htmlFor="completion_status" className="block text-sm font-medium text-gray-700 mb-2">
Completion Status
</label>
<div className="flex items-center">
<input
type="checkbox"
id="completion_status"
checked={formData.completion_status}
onChange={(e) => handleInputChange('completion_status', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="completion_status" className="ml-2 text-sm text-gray-700">
Mark as completed
</label>
</div>
</div>
</div>
</div>
)}
{/* Form Actions */}
<div className="flex justify-end space-x-4 pt-6 border-t">
<button
type="button"
onClick={onCancel}
className="px-6 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-3 border border-transparent rounded-lg text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (isEditing ? 'Updating...' : 'Creating...') : (isEditing ? 'Update Experiment' : 'Create Experiment')}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react'
import { ExperimentForm } from './ExperimentForm'
import { experimentManagement } from '../lib/supabase'
import type { Experiment, CreateExperimentRequest, UpdateExperimentRequest } from '../lib/supabase'
interface ExperimentModalProps {
experiment?: Experiment
onClose: () => void
onExperimentSaved: (experiment: Experiment) => void
}
export function ExperimentModal({ experiment, onClose, onExperimentSaved }: ExperimentModalProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isEditing = !!experiment
const handleSubmit = async (data: CreateExperimentRequest | UpdateExperimentRequest) => {
setError(null)
setLoading(true)
try {
let savedExperiment: Experiment
if (isEditing && experiment) {
// Check if experiment number is unique (excluding current experiment)
if ('experiment_number' in data && data.experiment_number !== undefined && data.experiment_number !== experiment.experiment_number) {
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, experiment.id)
if (!isUnique) {
setError('Experiment number already exists. Please choose a different number.')
return
}
}
savedExperiment = await experimentManagement.updateExperiment(experiment.id, data)
} else {
// Check if experiment number is unique for new experiments
const createData = data as CreateExperimentRequest
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number)
if (!isUnique) {
setError('Experiment number already exists. Please choose a different number.')
return
}
savedExperiment = await experimentManagement.createExperiment(createData)
}
onExperimentSaved(savedExperiment)
onClose()
} catch (err: any) {
setError(err.message || `Failed to ${isEditing ? 'update' : 'create'} experiment`)
console.error('Experiment save error:', err)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
onClose()
}
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
/>
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-900 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800 rounded-t-2xl">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
{isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'}
</h3>
</div>
<div className="p-6">
{/* Error Message */}
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
{/* Form */}
<ExperimentForm
initialData={experiment}
onSubmit={handleSubmit}
onCancel={handleCancel}
isEditing={isEditing}
loading={loading}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,445 @@
import { useState, useEffect } from 'react'
import { ExperimentModal } from './ExperimentModal'
import { RepetitionScheduleModal } from './RepetitionScheduleModal'
import { experimentManagement, repetitionManagement, userManagement } from '../lib/supabase'
import type { Experiment, ExperimentRepetition, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
export function Experiments() {
const [experiments, setExperiments] = useState<Experiment[]>([])
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingExperiment, setEditingExperiment] = useState<Experiment | undefined>(undefined)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false)
const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
setError(null)
const [experimentsData, userData] = await Promise.all([
experimentManagement.getAllExperiments(),
userManagement.getCurrentUser()
])
setExperiments(experimentsData)
setCurrentUser(userData)
// Load repetitions for each experiment
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
for (const experiment of experimentsData) {
try {
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
repetitionsMap[experiment.id] = repetitions
} catch (err) {
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
repetitionsMap[experiment.id] = []
}
}
setExperimentRepetitions(repetitionsMap)
} catch (err: any) {
setError(err.message || 'Failed to load experiments')
console.error('Load experiments error:', err)
} finally {
setLoading(false)
}
}
const canManageExperiments = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
const handleCreateExperiment = () => {
setEditingExperiment(undefined)
setShowModal(true)
}
const handleEditExperiment = (experiment: Experiment) => {
setEditingExperiment(experiment)
setShowModal(true)
}
const handleExperimentSaved = async (experiment: Experiment) => {
if (editingExperiment) {
// Update existing experiment
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp))
} else {
// Add new experiment and create all its repetitions
setExperiments(prev => [experiment, ...prev])
try {
// Create all repetitions for the new experiment
const repetitions = await repetitionManagement.createAllRepetitions(experiment.id)
setExperimentRepetitions(prev => ({
...prev,
[experiment.id]: repetitions
}))
} catch (err) {
console.error('Failed to create repetitions:', err)
}
}
setShowModal(false)
setEditingExperiment(undefined)
}
const handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => {
setSchedulingRepetition({ experiment, repetition })
setShowRepetitionScheduleModal(true)
}
const handleRepetitionScheduleUpdated = (updatedRepetition: ExperimentRepetition) => {
setExperimentRepetitions(prev => ({
...prev,
[updatedRepetition.experiment_id]: prev[updatedRepetition.experiment_id]?.map(rep =>
rep.id === updatedRepetition.id ? updatedRepetition : rep
) || []
}))
setShowRepetitionScheduleModal(false)
setSchedulingRepetition(undefined)
}
const handleCreateRepetition = async (experiment: Experiment, repetitionNumber: number) => {
try {
const newRepetition = await repetitionManagement.createRepetition({
experiment_id: experiment.id,
repetition_number: repetitionNumber,
schedule_status: 'pending schedule'
})
setExperimentRepetitions(prev => ({
...prev,
[experiment.id]: [...(prev[experiment.id] || []), newRepetition].sort((a, b) => a.repetition_number - b.repetition_number)
}))
} catch (err: any) {
setError(err.message || 'Failed to create repetition')
}
}
const handleDeleteExperiment = async (experiment: Experiment) => {
if (!currentUser?.roles.includes('admin')) {
alert('Only administrators can delete experiments.')
return
}
if (!confirm(`Are you sure you want to delete Experiment #${experiment.experiment_number}? This action cannot be undone.`)) {
return
}
try {
await experimentManagement.deleteExperiment(experiment.id)
setExperiments(prev => prev.filter(exp => exp.id !== experiment.id))
} catch (err: any) {
alert(`Failed to delete experiment: ${err.message}`)
console.error('Delete experiment error:', err)
}
}
const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => {
const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length
const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length
const completed = repetitions.filter(r => r.completion_status).length
return { scheduled, pending, completed, total: repetitions.length }
}
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => {
switch (status) {
case 'pending schedule':
return 'bg-yellow-100 text-yellow-800'
case 'scheduled':
return 'bg-blue-100 text-blue-800'
case 'canceled':
return 'bg-red-100 text-red-800'
case 'aborted':
return 'bg-red-100 text-red-800'
case 'valid':
return 'bg-green-100 text-green-800'
case 'invalid':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
// Remove filtering for now since experiments don't have schedule_status anymore
const filteredExperiments = experiments
if (loading) {
return (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Experiments</h1>
<p className="mt-2 text-gray-600">Manage pecan processing experiment definitions</p>
<p className="mt-2 text-gray-600">This is where you define the blueprint of an experiment with the required configurations and parameters, as well as the number of repetitions needed for that experiment.</p>
</div>
{canManageExperiments && (
<button
onClick={handleCreateExperiment}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
New Experiment
</button>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Experiments 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">
Experiments ({filteredExperiments.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
{canManageExperiments ? 'Click on any experiment to edit details' : 'View experiment definitions and status'}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Experiment #
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reps Required
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Experiment Parameters
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Repetitions Status
</th>
{canManageExperiments && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Manage Repetitions
</th>
)}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Results Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Completion
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
{canManageExperiments && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredExperiments.map((experiment) => (
<tr
key={experiment.id}
className={canManageExperiments ? "hover:bg-gray-50 cursor-pointer" : ""}
onClick={canManageExperiments ? () => handleEditExperiment(experiment) : undefined}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
#{experiment.experiment_number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{experiment.reps_required}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<div className="space-y-1">
<div>Soaking: {experiment.soaking_duration_hr}h</div>
<div>Drying: {experiment.air_drying_time_min}min</div>
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{(() => {
const repetitions = experimentRepetitions[experiment.id] || []
const summary = getRepetitionStatusSummary(repetitions)
return (
<div className="space-y-1">
<div className="text-xs text-gray-600">
{summary.total} total {summary.scheduled} scheduled {summary.pending} pending
</div>
<div className="flex space-x-1">
{summary.scheduled > 0 && (
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{summary.scheduled} scheduled
</span>
)}
{summary.pending > 0 && (
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
{summary.pending} pending
</span>
)}
</div>
</div>
)
})()}
</td>
{canManageExperiments && (
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="space-y-2">
{(() => {
const repetitions = experimentRepetitions[experiment.id] || []
return repetitions.map((repetition) => (
<div key={repetition.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">Rep #{repetition.repetition_number}</span>
<div className="flex items-center space-x-2">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.schedule_status)}`}>
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
</span>
<button
onClick={(e) => {
e.stopPropagation()
handleScheduleRepetition(experiment, repetition)
}}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
title={repetition.schedule_status === 'scheduled' ? 'Reschedule' : 'Schedule'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
))
})()}
{(() => {
const repetitions = experimentRepetitions[experiment.id] || []
const missingReps = experiment.reps_required - repetitions.length
if (missingReps > 0) {
return (
<button
onClick={(e) => {
e.stopPropagation()
handleCreateRepetition(experiment, repetitions.length + 1)
}}
className="w-full text-sm text-blue-600 hover:text-blue-900 py-1 px-2 border border-blue-300 rounded hover:bg-blue-50 transition-colors"
>
+ Add Rep #{repetitions.length + 1}
</button>
)
}
return null
})()}
</div>
</td>
)}
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(experiment.results_status)}`}>
{experiment.results_status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${experiment.completion_status
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{experiment.completion_status ? 'Completed' : 'In Progress'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(experiment.created_at).toLocaleDateString()}
</td>
{canManageExperiments && (
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation()
handleEditExperiment(experiment)
}}
className="text-blue-600 hover:text-blue-900"
>
Edit
</button>
{currentUser?.roles.includes('admin') && (
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteExperiment(experiment)
}}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{filteredExperiments.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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating your first experiment.
</p>
{canManageExperiments && (
<div className="mt-6">
<button
onClick={handleCreateExperiment}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Create First Experiment
</button>
</div>
)}
</div>
)}
</div>
{/* Experiment Modal */}
{showModal && (
<ExperimentModal
experiment={editingExperiment}
onClose={() => setShowModal(false)}
onExperimentSaved={handleExperimentSaved}
/>
)}
{/* Repetition Schedule Modal */}
{showRepetitionScheduleModal && schedulingRepetition && (
<RepetitionScheduleModal
experiment={schedulingRepetition.experiment}
repetition={schedulingRepetition.repetition}
onClose={() => setShowRepetitionScheduleModal(false)}
onScheduleUpdated={handleRepetitionScheduleUpdated}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { supabase } from '../lib/supabase'
interface LoginProps {
onLoginSuccess: () => void
}
export function Login({ onLoginSuccess }: LoginProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
} else if (data.user) {
onLoginSuccess()
}
} catch (err) {
setError('An unexpected error occurred')
console.error('Login error:', err)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
RBAC Authentication System
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,828 @@
import { useState, useEffect, useCallback } from 'react'
import { phaseDraftManagement, type Experiment, type ExperimentPhaseDraft, type ExperimentPhase, type ExperimentPhaseData, type ExperimentRepetition, type User } from '../lib/supabase'
import { PhaseDraftManager } from './PhaseDraftManager'
interface PhaseDataEntryProps {
experiment: Experiment
repetition: ExperimentRepetition
phase: ExperimentPhase
currentUser: User
onBack: () => void
onDataSaved: () => void
}
export function PhaseDataEntry({ experiment, repetition, phase, currentUser, onBack, onDataSaved }: PhaseDataEntryProps) {
const [selectedDraft, setSelectedDraft] = useState<ExperimentPhaseDraft | null>(null)
const [phaseData, setPhaseData] = useState<Partial<ExperimentPhaseData>>({})
const [diameterMeasurements, setDiameterMeasurements] = useState<number[]>(Array(10).fill(0))
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const [showDraftManager, setShowDraftManager] = useState(false)
// Auto-save interval (30 seconds)
const AUTO_SAVE_INTERVAL = 30000
useEffect(() => {
loadUserDrafts()
}, [repetition.id, phase])
const loadUserDrafts = async () => {
try {
setLoading(true)
setError(null)
const drafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
// Auto-select the most recent draft or show draft manager if none exist
if (drafts.length > 0) {
const mostRecentDraft = drafts[0] // Already sorted by created_at desc
setSelectedDraft(mostRecentDraft)
await loadPhaseDataForDraft(mostRecentDraft)
} else {
setShowDraftManager(true)
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load drafts'
setError(errorMessage)
console.error('Load drafts error:', err)
} finally {
setLoading(false)
}
}
const loadPhaseDataForDraft = async (draft: ExperimentPhaseDraft) => {
try {
setError(null)
const existingData = await phaseDraftManagement.getPhaseDataForDraft(draft.id)
if (existingData) {
setPhaseData(existingData)
// Load diameter measurements if they exist
if (existingData.diameter_measurements) {
const measurements = Array(10).fill(0)
existingData.diameter_measurements.forEach(measurement => {
if (measurement.measurement_number >= 1 && measurement.measurement_number <= 10) {
measurements[measurement.measurement_number - 1] = measurement.diameter_in
}
})
setDiameterMeasurements(measurements)
}
} else {
// Initialize empty phase data
setPhaseData({
phase_draft_id: draft.id,
phase_name: phase
})
setDiameterMeasurements(Array(10).fill(0))
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load phase data'
setError(errorMessage)
console.error('Load phase data error:', err)
}
}
const autoSave = useCallback(async () => {
if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts
try {
await phaseDraftManagement.autoSaveDraft(selectedDraft.id, phaseData)
// Save diameter measurements if this is air-drying phase and we have measurements
if (phase === 'air-drying' && phaseData.id && diameterMeasurements.some(m => m > 0)) {
const validMeasurements = diameterMeasurements.filter(m => m > 0)
if (validMeasurements.length > 0) {
await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
// Update average diameter
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter }))
}
}
setLastSaved(new Date())
} catch (error) {
console.warn('Auto-save failed:', error)
}
}, [selectedDraft, phase, phaseData, diameterMeasurements])
// Auto-save effect
useEffect(() => {
if (!loading && selectedDraft && phaseData.phase_draft_id) {
const interval = setInterval(() => {
autoSave()
}, AUTO_SAVE_INTERVAL)
return () => clearInterval(interval)
}
}, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft])
const handleInputChange = (field: string, value: unknown) => {
setPhaseData(prev => ({
...prev,
[field]: value
}))
}
const handleDiameterChange = (index: number, value: number) => {
const newMeasurements = [...diameterMeasurements]
newMeasurements[index] = value
setDiameterMeasurements(newMeasurements)
// Calculate and update average
const validMeasurements = newMeasurements.filter(m => m > 0)
if (validMeasurements.length > 0) {
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
handleInputChange('avg_pecan_diameter_in', avgDiameter)
}
}
const handleSave = async () => {
if (!selectedDraft) return
try {
setSaving(true)
setError(null)
// Save phase data
const savedData = await phaseDraftManagement.upsertPhaseData(selectedDraft.id, phaseData)
setPhaseData(savedData)
// Save diameter measurements if this is air-drying phase
if (phase === 'air-drying' && diameterMeasurements.some(m => m > 0)) {
await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
}
setLastSaved(new Date())
onDataSaved()
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to save data'
setError(errorMessage)
console.error('Save error:', err)
} finally {
setSaving(false)
}
}
const handleSelectDraft = (draft: ExperimentPhaseDraft) => {
setSelectedDraft(draft)
setShowDraftManager(false)
loadPhaseDataForDraft(draft)
}
const isFieldDisabled = () => {
const isAdmin = currentUser.roles.includes('admin')
return !selectedDraft ||
selectedDraft.status === 'submitted' ||
selectedDraft.status === 'withdrawn' ||
(repetition.is_locked && !isAdmin)
}
const getPhaseTitle = () => {
switch (phase) {
case 'pre-soaking': return 'Pre-Soaking Phase'
case 'air-drying': return 'Air-Drying Phase'
case 'cracking': return 'Cracking Phase'
case 'shelling': return 'Shelling Phase'
default: return 'Unknown Phase'
}
}
const calculateSoakingEndTime = () => {
if (phaseData.soaking_start_time && experiment.soaking_duration_hr) {
const startTime = new Date(phaseData.soaking_start_time)
const endTime = new Date(startTime.getTime() + experiment.soaking_duration_hr * 60 * 60 * 1000)
return endTime.toISOString().slice(0, 16) // Format for datetime-local input
}
return ''
}
const calculateAirDryingEndTime = () => {
if (phaseData.airdrying_start_time && experiment.air_drying_time_min) {
const startTime = new Date(phaseData.airdrying_start_time)
const endTime = new Date(startTime.getTime() + experiment.air_drying_time_min * 60 * 1000)
return endTime.toISOString().slice(0, 16) // Format for datetime-local input
}
return ''
}
if (loading) {
return (
<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 phase data...</p>
</div>
</div>
)
}
return (
<div>
{/* Draft Manager Modal */}
{showDraftManager && (
<PhaseDraftManager
repetition={repetition}
phase={phase}
currentUser={currentUser}
onSelectDraft={handleSelectDraft}
onClose={() => setShowDraftManager(false)}
/>
)}
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Phases
</button>
<h2 className="text-2xl font-bold text-gray-900">{getPhaseTitle()}</h2>
{selectedDraft && (
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-gray-600">
Draft: {selectedDraft.draft_name || `Draft ${selectedDraft.id.slice(-8)}`}
</span>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${selectedDraft.status === 'draft' ? 'bg-yellow-100 text-yellow-800' :
selectedDraft.status === 'submitted' ? 'bg-green-100 text-green-800' :
'bg-red-100 text-red-800'
}`}>
{selectedDraft.status}
</span>
{repetition.is_locked && (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
🔒 Locked
</span>
)}
</div>
)}
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => setShowDraftManager(true)}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Manage Drafts
</button>
{lastSaved && (
<span className="text-sm text-gray-500">
Last saved: {lastSaved.toLocaleTimeString()}
</span>
)}
<button
onClick={handleSave}
disabled={saving || !selectedDraft || selectedDraft.status === 'submitted'}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{selectedDraft?.status === 'submitted' && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
This draft has been submitted and is read-only. Create a new draft to make changes.
</div>
</div>
)}
{selectedDraft?.status === 'withdrawn' && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
This draft has been withdrawn. Create a new draft to make changes.
</div>
</div>
)}
{repetition.is_locked && !currentUser.roles.includes('admin') && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
This repetition has been locked by an admin. No changes can be made to drafts.
</div>
</div>
)}
{repetition.is_locked && currentUser.roles.includes('admin') && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
🔒 This repetition is locked, but you can still make changes as an admin.
</div>
</div>
)}
{!selectedDraft && (
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="text-sm text-blue-700">
No draft selected. Use "Manage Drafts" to create or select a draft for this phase.
</div>
</div>
)}
</div>
{/* Phase-specific forms */}
<div className="bg-white rounded-lg shadow-md p-6">
{phase === 'pre-soaking' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Pre-Soaking Measurements</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Batch Initial Weight (lbs) *
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.batch_initial_weight_lbs || ''}
onChange={(e) => handleInputChange('batch_initial_weight_lbs', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Initial Shell Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.initial_shell_moisture_pct || ''}
onChange={(e) => handleInputChange('initial_shell_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Initial Kernel Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.initial_kernel_moisture_pct || ''}
onChange={(e) => handleInputChange('initial_kernel_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Soaking Start Time *
</label>
<input
type="datetime-local"
value={phaseData.soaking_start_time ? new Date(phaseData.soaking_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('soaking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isFieldDisabled()}
/>
</div>
</div>
{/* Calculated Soaking End Time */}
{phaseData.soaking_start_time && (
<div className="bg-gray-50 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Soaking End Time (Calculated)
</label>
<input
type="datetime-local"
value={calculateSoakingEndTime()}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated based on soaking duration ({experiment.soaking_duration_hr}h)
</p>
</div>
)}
</div>
)}
{phase === 'air-drying' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Air-Drying Measurements</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Air-Drying Start Time *
</label>
<input
type="datetime-local"
value={phaseData.airdrying_start_time ? new Date(phaseData.airdrying_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('airdrying_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Post-Soak Weight (lbs)
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.post_soak_weight_lbs || ''}
onChange={(e) => handleInputChange('post_soak_weight_lbs', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Post-Soak Kernel Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.post_soak_kernel_moisture_pct || ''}
onChange={(e) => handleInputChange('post_soak_kernel_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Post-Soak Shell Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.post_soak_shell_moisture_pct || ''}
onChange={(e) => handleInputChange('post_soak_shell_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
disabled={isFieldDisabled()}
/>
</div>
</div>
{/* Calculated Air-Drying End Time */}
{phaseData.airdrying_start_time && (
<div className="bg-gray-50 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Air-Drying End Time (Calculated)
</label>
<input
type="datetime-local"
value={calculateAirDryingEndTime()}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated based on air-drying duration ({experiment.air_drying_time_min} minutes)
</p>
</div>
)}
{/* Pecan Diameter Measurements */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Pecan Diameter Measurements (inches)</h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{diameterMeasurements.map((measurement, index) => (
<div key={index}>
<label className="block text-sm font-medium text-gray-700 mb-1">
Measurement {index + 1}
</label>
<input
type="number"
step="0.001"
min="0"
value={measurement || ''}
onChange={(e) => handleDiameterChange(index, parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.000"
disabled={isFieldDisabled()}
/>
</div>
))}
</div>
{/* Average Diameter Display */}
<div className="mt-4 bg-blue-50 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Average Pecan Diameter (Calculated)
</label>
<input
type="number"
step="0.001"
value={phaseData.avg_pecan_diameter_in || ''}
onChange={(e) => handleInputChange('avg_pecan_diameter_in', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.000"
disabled={isFieldDisabled()}
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated from individual measurements above
</p>
</div>
</div>
</div>
)}
{phase === 'cracking' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Cracking Phase</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cracking Start Time *
</label>
<input
type="datetime-local"
value={phaseData.cracking_start_time ? new Date(phaseData.cracking_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('cracking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isFieldDisabled()}
/>
</div>
</div>
{/* Machine Parameters Display */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-md font-medium text-gray-700 mb-3">Cracker Machine Parameters (Read-Only)</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Plate Contact Frequency (Hz)
</label>
<input
type="number"
value={experiment.plate_contact_frequency_hz}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Throughput Rate (pecans/sec)
</label>
<input
type="number"
value={experiment.throughput_rate_pecans_sec}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Crush Amount (inches)
</label>
<input
type="number"
value={experiment.crush_amount_in}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Entry/Exit Height Difference (inches)
</label>
<input
type="number"
value={experiment.entry_exit_height_diff_in}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
</div>
</div>
</div>
)}
{phase === 'shelling' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Shelling Phase</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Shelling Start Time *
</label>
<input
type="datetime-local"
value={phaseData.shelling_start_time ? new Date(phaseData.shelling_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('shelling_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isFieldDisabled()}
/>
</div>
</div>
{/* Bin Weights */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Bin Weights (lbs)</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 1 Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_1_weight_lbs || ''}
onChange={(e) => handleInputChange('bin_1_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 2 Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_2_weight_lbs || ''}
onChange={(e) => handleInputChange('bin_2_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 3 Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_3_weight_lbs || ''}
onChange={(e) => handleInputChange('bin_3_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Discharge Bin Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.discharge_bin_weight_lbs || ''}
onChange={(e) => handleInputChange('discharge_bin_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
</div>
</div>
{/* Full Yield Weights */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Full Yield Weights (oz)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 1 Full Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_1_full_yield_oz || ''}
onChange={(e) => handleInputChange('bin_1_full_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 2 Full Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_2_full_yield_oz || ''}
onChange={(e) => handleInputChange('bin_2_full_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 3 Full Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_3_full_yield_oz || ''}
onChange={(e) => handleInputChange('bin_3_full_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
</div>
</div>
{/* Half Yield Weights */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Half Yield Weights (oz)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 1 Half Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_1_half_yield_oz || ''}
onChange={(e) => handleInputChange('bin_1_half_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 2 Half Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_2_half_yield_oz || ''}
onChange={(e) => handleInputChange('bin_2_half_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 3 Half Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_3_half_yield_oz || ''}
onChange={(e) => handleInputChange('bin_3_half_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
disabled={isFieldDisabled()}
/>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,276 @@
import { useState, useEffect } from 'react'
import { phaseDraftManagement, type ExperimentPhaseDraft, type ExperimentPhase, type User, type ExperimentRepetition } from '../lib/supabase'
interface PhaseDraftManagerProps {
repetition: ExperimentRepetition
phase: ExperimentPhase
currentUser: User
onSelectDraft: (draft: ExperimentPhaseDraft) => void
onClose: () => void
}
export function PhaseDraftManager({ repetition, phase, currentUser, onSelectDraft, onClose }: PhaseDraftManagerProps) {
const [drafts, setDrafts] = useState<ExperimentPhaseDraft[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newDraftName, setNewDraftName] = useState('')
useEffect(() => {
loadDrafts()
}, [repetition.id, phase])
const loadDrafts = async () => {
try {
setLoading(true)
setError(null)
const userDrafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
setDrafts(userDrafts)
} catch (err: any) {
setError(err.message || 'Failed to load drafts')
console.error('Load drafts error:', err)
} finally {
setLoading(false)
}
}
const handleCreateDraft = async () => {
try {
setCreating(true)
setError(null)
const newDraft = await phaseDraftManagement.createPhaseDraft({
experiment_id: repetition.experiment_id,
repetition_id: repetition.id,
phase_name: phase,
draft_name: newDraftName || undefined,
status: 'draft'
})
setDrafts(prev => [newDraft, ...prev])
setNewDraftName('')
onSelectDraft(newDraft)
} catch (err: any) {
setError(err.message || 'Failed to create draft')
} finally {
setCreating(false)
}
}
const handleDeleteDraft = async (draftId: string) => {
if (!confirm('Are you sure you want to delete this draft? This action cannot be undone.')) {
return
}
try {
await phaseDraftManagement.deletePhaseDraft(draftId)
setDrafts(prev => prev.filter(draft => draft.id !== draftId))
} catch (err: any) {
setError(err.message || 'Failed to delete draft')
}
}
const handleSubmitDraft = async (draftId: string) => {
if (!confirm('Are you sure you want to submit this draft? Once submitted, it can only be withdrawn by you or locked by an admin.')) {
return
}
try {
const submittedDraft = await phaseDraftManagement.submitPhaseDraft(draftId)
setDrafts(prev => prev.map(draft =>
draft.id === draftId ? submittedDraft : draft
))
} catch (err: any) {
setError(err.message || 'Failed to submit draft')
}
}
const handleWithdrawDraft = async (draftId: string) => {
if (!confirm('Are you sure you want to withdraw this submitted draft? It will be marked as withdrawn.')) {
return
}
try {
const withdrawnDraft = await phaseDraftManagement.withdrawPhaseDraft(draftId)
setDrafts(prev => prev.map(draft =>
draft.id === draftId ? withdrawnDraft : draft
))
} catch (err: any) {
setError(err.message || 'Failed to withdraw draft')
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
case 'submitted':
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
case 'withdrawn':
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
default:
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">{status}</span>
}
}
const canDeleteDraft = (draft: ExperimentPhaseDraft) => {
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
}
const canSubmitDraft = (draft: ExperimentPhaseDraft) => {
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
}
const canWithdrawDraft = (draft: ExperimentPhaseDraft) => {
return draft.status === 'submitted' && (!repetition.is_locked || currentUser.roles.includes('admin'))
}
const canCreateDraft = () => {
return !repetition.is_locked || currentUser.roles.includes('admin')
}
const formatPhaseTitle = (phase: string) => {
return phase.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 className="text-xl font-semibold text-gray-900">
{formatPhaseTitle(phase)} Phase Drafts
</h2>
<p className="text-sm text-gray-600 mt-1">
Repetition {repetition.repetition_number}
{repetition.is_locked && (
<span className="ml-2 px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
🔒 Locked
</span>
)}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6">
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Create New Draft */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 className="text-sm font-medium text-gray-900 mb-3">Create New Draft</h3>
<div className="flex gap-3">
<input
type="text"
placeholder="Draft name (optional)"
value={newDraftName}
onChange={(e) => setNewDraftName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={creating || repetition.is_locked}
/>
<button
onClick={handleCreateDraft}
disabled={creating || !canCreateDraft()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{creating ? 'Creating...' : 'Create Draft'}
</button>
</div>
{repetition.is_locked && !currentUser.roles.includes('admin') && (
<p className="text-xs text-red-600 mt-2">
Cannot create new drafts: repetition is locked by admin
</p>
)}
</div>
{/* Drafts List */}
<div className="space-y-4">
{loading ? (
<div className="text-center py-8">
<div className="text-gray-500">Loading drafts...</div>
</div>
) : drafts.length === 0 ? (
<div className="text-center py-8">
<div className="text-gray-500">No drafts found for this phase</div>
<p className="text-sm text-gray-400 mt-1">Create a new draft to get started</p>
</div>
) : (
drafts.map((draft) => (
<div key={draft.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-medium text-gray-900">
{draft.draft_name || `Draft ${draft.id.slice(-8)}`}
</h4>
{getStatusBadge(draft.status)}
</div>
<div className="text-sm text-gray-600">
<p>Created: {new Date(draft.created_at).toLocaleString()}</p>
<p>Updated: {new Date(draft.updated_at).toLocaleString()}</p>
{draft.submitted_at && (
<p>Submitted: {new Date(draft.submitted_at).toLocaleString()}</p>
)}
{draft.withdrawn_at && (
<p>Withdrawn: {new Date(draft.withdrawn_at).toLocaleString()}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onSelectDraft(draft)}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{draft.status === 'draft' ? 'Edit' : 'View'}
</button>
{canSubmitDraft(draft) && (
<button
onClick={() => handleSubmitDraft(draft.id)}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Submit
</button>
)}
{canWithdrawDraft(draft) && (
<button
onClick={() => handleWithdrawDraft(draft.id)}
className="px-3 py-1 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
>
Withdraw
</button>
)}
{canDeleteDraft(draft) && (
<button
onClick={() => handleDeleteDraft(draft.id)}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Delete
</button>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react'
import { type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
// DEPRECATED: This component is deprecated in favor of RepetitionPhaseSelector
// which uses the new phase-specific draft system
// Temporary type for backward compatibility
interface LegacyDataEntry {
id: string
experiment_id: string
user_id: string
status: 'draft' | 'submitted'
entry_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
}
interface PhaseSelectorProps {
dataEntry: LegacyDataEntry
onPhaseSelect: (phase: ExperimentPhase) => void
}
interface PhaseInfo {
name: ExperimentPhase
title: string
description: string
icon: string
color: string
}
const phases: PhaseInfo[] = [
{
name: 'pre-soaking',
title: 'Pre-Soaking',
description: 'Initial measurements before soaking process',
icon: '🌰',
color: 'bg-blue-500'
},
{
name: 'air-drying',
title: 'Air-Drying',
description: 'Post-soak measurements and air-drying data',
icon: '💨',
color: 'bg-green-500'
},
{
name: 'cracking',
title: 'Cracking',
description: 'Cracking process timing and parameters',
icon: '🔨',
color: 'bg-yellow-500'
},
{
name: 'shelling',
title: 'Shelling',
description: 'Final measurements and yield data',
icon: '📊',
color: 'bg-purple-500'
}
]
export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps) {
const [phaseData, setPhaseData] = useState<Record<ExperimentPhase, ExperimentPhaseData | null>>({
'pre-soaking': null,
'air-drying': null,
'cracking': null,
'shelling': null
})
const [loading, setLoading] = useState(true)
useEffect(() => {
loadPhaseData()
}, [dataEntry.id])
const loadPhaseData = async () => {
try {
setLoading(true)
// DEPRECATED: Using empty array since this component is deprecated
const allPhaseData: ExperimentPhaseData[] = []
const phaseDataMap: Record<ExperimentPhase, ExperimentPhaseData | null> = {
'pre-soaking': null,
'air-drying': null,
'cracking': null,
'shelling': null
}
allPhaseData.forEach(data => {
phaseDataMap[data.phase_name] = data
})
setPhaseData(phaseDataMap)
} catch (error) {
console.error('Failed to load phase data:', error)
} finally {
setLoading(false)
}
}
const getPhaseCompletionStatus = (phaseName: ExperimentPhase): 'empty' | 'partial' | 'complete' => {
const data = phaseData[phaseName]
if (!data) return 'empty'
// Check if phase has any data
const hasAnyData = Object.entries(data).some(([key, value]) => {
if (['id', 'data_entry_id', 'phase_name', 'created_at', 'updated_at', 'diameter_measurements'].includes(key)) {
return false
}
return value !== null && value !== undefined && value !== ''
})
if (!hasAnyData) return 'empty'
// For now, consider any data as partial completion
// You could implement more sophisticated completion logic here
return 'partial'
}
const getStatusIcon = (status: 'empty' | 'partial' | 'complete') => {
switch (status) {
case 'empty':
return (
<div className="w-6 h-6 rounded-full border-2 border-gray-300 bg-white"></div>
)
case 'partial':
return (
<div className="w-6 h-6 rounded-full bg-yellow-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)
case 'complete':
return (
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)
}
}
const getLastUpdated = (phaseName: ExperimentPhase): string | null => {
const data = phaseData[phaseName]
if (!data) return null
return new Date(data.updated_at).toLocaleString()
}
if (loading) {
return (
<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 phase data...</p>
</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">Select Experiment Phase</h2>
<p className="text-gray-600">
Click on any phase card to enter or edit data for that phase
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{phases.map((phase) => {
const status = getPhaseCompletionStatus(phase.name)
const lastUpdated = getLastUpdated(phase.name)
return (
<button
key={phase.name}
onClick={() => onPhaseSelect(phase.name)}
className="text-left p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200 hover:border-gray-300"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className={`w-12 h-12 rounded-lg ${phase.color} flex items-center justify-center text-white text-xl mr-4`}>
{phase.icon}
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">{phase.title}</h3>
<p className="text-sm text-gray-500">{phase.description}</p>
</div>
</div>
<div className="flex flex-col items-end">
{getStatusIcon(status)}
<span className="text-xs text-gray-400 mt-1">
{status === 'empty' ? 'No data' : status === 'partial' ? 'In progress' : 'Complete'}
</span>
</div>
</div>
{lastUpdated && (
<div className="text-xs text-gray-400">
Last updated: {lastUpdated}
</div>
)}
<div className="mt-4 flex items-center text-blue-600">
<span className="text-sm font-medium">
{status === 'empty' ? 'Start entering data' : 'Continue editing'}
</span>
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
)
})}
</div>
{/* Phase Navigation */}
<div className="mt-8 bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Phase Progress</h3>
<div className="flex items-center space-x-4">
{phases.map((phase, index) => {
const status = getPhaseCompletionStatus(phase.name)
return (
<div key={phase.name} className="flex items-center">
<button
onClick={() => onPhaseSelect(phase.name)}
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-gray-100 transition-colors"
>
{getStatusIcon(status)}
<span className="text-sm text-gray-700">{phase.title}</span>
</button>
{index < phases.length - 1 && (
<svg className="w-4 h-4 text-gray-400 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useState, useEffect } from 'react'
import { type Experiment, type ExperimentRepetition, type User, type ExperimentPhase } from '../lib/supabase'
import { RepetitionPhaseSelector } from './RepetitionPhaseSelector'
import { PhaseDataEntry } from './PhaseDataEntry'
import { RepetitionLockManager } from './RepetitionLockManager'
interface RepetitionDataEntryInterfaceProps {
experiment: Experiment
repetition: ExperimentRepetition
currentUser: User
onBack: () => void
}
export function RepetitionDataEntryInterface({ experiment, repetition, currentUser, onBack }: RepetitionDataEntryInterfaceProps) {
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
const [loading, setLoading] = useState(true)
const [currentRepetition, setCurrentRepetition] = useState<ExperimentRepetition>(repetition)
useEffect(() => {
// Skip loading old data entries - go directly to phase selection
setLoading(false)
}, [repetition.id, currentUser.id])
const handlePhaseSelect = (phase: ExperimentPhase) => {
setSelectedPhase(phase)
}
const handleBackToPhases = () => {
setSelectedPhase(null)
}
const handleRepetitionUpdated = (updatedRepetition: ExperimentRepetition) => {
setCurrentRepetition(updatedRepetition)
}
if (loading) {
return (
<div className="p-6">
<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...</p>
</div>
</div>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-4">
<button
onClick={onBack}
className="text-blue-600 hover:text-blue-800 flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Repetitions</span>
</button>
</div>
<div className="mt-4">
<h1 className="text-3xl font-bold text-gray-900">
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
</h1>
<div className="mt-2 text-sm text-gray-600 space-y-1">
<div>Soaking: {experiment.soaking_duration_hr}h Air Drying: {experiment.air_drying_time_min}min</div>
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz Throughput: {experiment.throughput_rate_pecans_sec}/sec</div>
{repetition.scheduled_date && (
<div>Scheduled: {new Date(repetition.scheduled_date).toLocaleString()}</div>
)}
</div>
</div>
</div>
{/* No additional controls needed - phase-specific draft management is handled within each phase */}
</div>
</div>
{/* Admin Controls */}
<RepetitionLockManager
repetition={currentRepetition}
currentUser={currentUser}
onRepetitionUpdated={handleRepetitionUpdated}
/>
{/* Main Content */}
{selectedPhase ? (
<PhaseDataEntry
experiment={experiment}
repetition={currentRepetition}
phase={selectedPhase}
currentUser={currentUser}
onBack={handleBackToPhases}
onDataSaved={() => {
// Data is automatically saved in the new phase-specific system
}}
/>
) : (
<RepetitionPhaseSelector
repetition={currentRepetition}
currentUser={currentUser}
onPhaseSelect={handlePhaseSelect}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { repetitionManagement, type ExperimentRepetition, type User } from '../lib/supabase'
interface RepetitionLockManagerProps {
repetition: ExperimentRepetition
currentUser: User
onRepetitionUpdated: (updatedRepetition: ExperimentRepetition) => void
}
export function RepetitionLockManager({ repetition, currentUser, onRepetitionUpdated }: RepetitionLockManagerProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isAdmin = currentUser.roles.includes('admin')
const handleLockRepetition = async () => {
if (!confirm('Are you sure you want to lock this repetition? This will prevent users from modifying or withdrawing any submitted drafts.')) {
return
}
try {
setLoading(true)
setError(null)
const updatedRepetition = await repetitionManagement.lockRepetition(repetition.id)
onRepetitionUpdated(updatedRepetition)
} catch (err: any) {
setError(err.message || 'Failed to lock repetition')
} finally {
setLoading(false)
}
}
const handleUnlockRepetition = async () => {
if (!confirm('Are you sure you want to unlock this repetition? This will allow users to modify and withdraw submitted drafts again.')) {
return
}
try {
setLoading(true)
setError(null)
const updatedRepetition = await repetitionManagement.unlockRepetition(repetition.id)
onRepetitionUpdated(updatedRepetition)
} catch (err: any) {
setError(err.message || 'Failed to unlock repetition')
} finally {
setLoading(false)
}
}
if (!isAdmin) {
return null
}
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Admin Controls</h3>
{error && (
<div className="mb-3 bg-red-50 border border-red-200 rounded-md p-3">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700">Repetition Status:</span>
{repetition.is_locked ? (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
🔒 Locked
</span>
) : (
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
🔓 Unlocked
</span>
)}
</div>
{repetition.is_locked && repetition.locked_at && (
<div className="mt-1 text-xs text-gray-500">
Locked: {new Date(repetition.locked_at).toLocaleString()}
</div>
)}
</div>
<div className="flex items-center gap-2">
{repetition.is_locked ? (
<button
onClick={handleUnlockRepetition}
disabled={loading}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Unlocking...' : 'Unlock'}
</button>
) : (
<button
onClick={handleLockRepetition}
disabled={loading}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Locking...' : 'Lock'}
</button>
)}
</div>
</div>
<div className="mt-3 text-xs text-gray-600">
{repetition.is_locked ? (
<p>
When locked, users cannot create new drafts, delete existing drafts, or withdraw submitted drafts.
Only admins can modify the lock status.
</p>
) : (
<p>
When unlocked, users can freely create, edit, delete, submit, and withdraw drafts.
Lock this repetition to prevent further changes to submitted data.
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,223 @@
import { useState, useEffect } from 'react'
import { phaseDraftManagement, type ExperimentRepetition, type ExperimentPhase, type ExperimentPhaseDraft, type User } from '../lib/supabase'
interface RepetitionPhaseSelectorProps {
repetition: ExperimentRepetition
currentUser: User
onPhaseSelect: (phase: ExperimentPhase) => void
}
interface PhaseInfo {
name: ExperimentPhase
title: string
description: string
icon: string
color: string
}
const phases: PhaseInfo[] = [
{
name: 'pre-soaking',
title: 'Pre-Soaking',
description: 'Initial measurements before soaking process',
icon: '🌰',
color: 'bg-blue-500'
},
{
name: 'air-drying',
title: 'Air-Drying',
description: 'Post-soak measurements and air-drying data',
icon: '💨',
color: 'bg-green-500'
},
{
name: 'cracking',
title: 'Cracking',
description: 'Cracking process timing and parameters',
icon: '🔨',
color: 'bg-yellow-500'
},
{
name: 'shelling',
title: 'Shelling',
description: 'Final measurements and yield data',
icon: '📊',
color: 'bg-purple-500'
}
]
export function RepetitionPhaseSelector({ repetition, currentUser: _currentUser, onPhaseSelect }: RepetitionPhaseSelectorProps) {
const [phaseDrafts, setPhaseDrafts] = useState<Record<ExperimentPhase, ExperimentPhaseDraft[]>>({
'pre-soaking': [],
'air-drying': [],
'cracking': [],
'shelling': []
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadPhaseDrafts()
}, [repetition.id])
const loadPhaseDrafts = async () => {
try {
setLoading(true)
setError(null)
const allDrafts = await phaseDraftManagement.getUserPhaseDraftsForRepetition(repetition.id)
// Group drafts by phase
const groupedDrafts: Record<ExperimentPhase, ExperimentPhaseDraft[]> = {
'pre-soaking': [],
'air-drying': [],
'cracking': [],
'shelling': []
}
allDrafts.forEach(draft => {
groupedDrafts[draft.phase_name].push(draft)
})
setPhaseDrafts(groupedDrafts)
} catch (err: any) {
setError(err.message || 'Failed to load phase drafts')
console.error('Load phase drafts error:', err)
} finally {
setLoading(false)
}
}
const getPhaseStatus = (phase: ExperimentPhase) => {
const drafts = phaseDrafts[phase]
if (drafts.length === 0) return 'empty'
const hasSubmitted = drafts.some(d => d.status === 'submitted')
const hasDraft = drafts.some(d => d.status === 'draft')
const hasWithdrawn = drafts.some(d => d.status === 'withdrawn')
if (hasSubmitted) return 'submitted'
if (hasDraft) return 'draft'
if (hasWithdrawn) return 'withdrawn'
return 'empty'
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'submitted':
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
case 'draft':
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
case 'withdrawn':
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
case 'empty':
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">No Data</span>
default:
return null
}
}
const getDraftCount = (phase: ExperimentPhase) => {
return phaseDrafts[phase].length
}
if (loading) {
return (
<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 phases...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Select Phase</h2>
<p className="text-gray-600">
Choose a phase to enter or view data. Each phase can have multiple drafts.
</p>
{repetition.is_locked && (
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-center">
<span className="text-red-800 text-sm font-medium">🔒 This repetition is locked by an admin</span>
</div>
<p className="text-red-700 text-xs mt-1">
You can view existing data but cannot create new drafts or modify existing ones.
</p>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{phases.map((phase) => {
const status = getPhaseStatus(phase.name)
const draftCount = getDraftCount(phase.name)
return (
<div
key={phase.name}
onClick={() => onPhaseSelect(phase.name)}
className="bg-white rounded-lg shadow-md border border-gray-200 p-6 cursor-pointer hover:shadow-lg hover:border-blue-300 transition-all duration-200"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className={`w-12 h-12 ${phase.color} rounded-lg flex items-center justify-center text-white text-xl mr-4`}>
{phase.icon}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{phase.title}</h3>
<p className="text-sm text-gray-600">{phase.description}</p>
</div>
</div>
{getStatusBadge(status)}
</div>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
{draftCount === 0 ? 'No drafts' : `${draftCount} draft${draftCount === 1 ? '' : 's'}`}
</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
{draftCount > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-wrap gap-1">
{phaseDrafts[phase.name].slice(0, 3).map((draft, index) => (
<span
key={draft.id}
className={`px-2 py-1 text-xs rounded ${draft.status === 'submitted' ? 'bg-green-100 text-green-700' :
draft.status === 'draft' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}
>
{draft.draft_name || `Draft ${index + 1}`}
</span>
))}
{draftCount > 3 && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
+{draftCount - 3} more
</span>
)}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,225 @@
import { useState } from 'react'
import { repetitionManagement } from '../lib/supabase'
import type { Experiment, ExperimentRepetition } from '../lib/supabase'
interface RepetitionScheduleModalProps {
experiment: Experiment
repetition: ExperimentRepetition
onClose: () => void
onScheduleUpdated: (updatedRepetition: ExperimentRepetition) => void
}
export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Initialize with existing scheduled date or current date/time
const getInitialDateTime = () => {
if (repetition.scheduled_date) {
const date = new Date(repetition.scheduled_date)
return {
date: date.toISOString().split('T')[0],
time: date.toTimeString().slice(0, 5)
}
}
const now = new Date()
// Set to next hour by default
now.setHours(now.getHours() + 1, 0, 0, 0)
return {
date: now.toISOString().split('T')[0],
time: now.toTimeString().slice(0, 5)
}
}
const [dateTime, setDateTime] = useState(getInitialDateTime())
const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
// Validate date/time
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
const now = new Date()
if (selectedDateTime <= now) {
setError('Scheduled date and time must be in the future')
setLoading(false)
return
}
// Schedule the repetition
const updatedRepetition = await repetitionManagement.scheduleRepetition(
repetition.id,
selectedDateTime.toISOString()
)
onScheduleUpdated(updatedRepetition)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to schedule repetition')
console.error('Schedule repetition error:', err)
} finally {
setLoading(false)
}
}
const handleRemoveSchedule = async () => {
if (!confirm('Are you sure you want to remove the schedule for this repetition?')) {
return
}
setError(null)
setLoading(true)
try {
const updatedRepetition = await repetitionManagement.removeRepetitionSchedule(repetition.id)
onScheduleUpdated(updatedRepetition)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to remove schedule')
console.error('Remove schedule error:', err)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
onClose()
}
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
/>
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={handleCancel}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white/90">
Schedule Repetition
</h3>
</div>
<div className="p-6">
{/* Experiment and Repetition Info */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
</h4>
<p className="text-sm text-gray-600">
{experiment.reps_required} reps required {experiment.soaking_duration_hr}h soaking
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Current Schedule (if exists) */}
{isScheduled && (
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
<p className="text-sm text-blue-700">
{new Date(repetition.scheduled_date!).toLocaleString()}
</p>
</div>
)}
{/* Schedule Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-2">
Date *
</label>
<input
type="date"
id="date"
value={dateTime.date}
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
required
/>
</div>
<div>
<label htmlFor="time" className="block text-sm font-medium text-gray-700 mb-2">
Time *
</label>
<input
type="time"
id="time"
value={dateTime.time}
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
required
/>
</div>
{/* Action Buttons */}
<div className="flex justify-between pt-4">
<div>
{isScheduled && (
<button
type="button"
onClick={handleRemoveSchedule}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
>
Remove Schedule
</button>
)}
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={handleCancel}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
>
{loading ? 'Scheduling...' : (isScheduled ? 'Update Schedule' : 'Schedule Repetition')}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useState } from 'react'
import { experimentManagement } from '../lib/supabase'
import type { Experiment } from '../lib/supabase'
interface ScheduleModalProps {
experiment: Experiment
onClose: () => void
onScheduleUpdated: (experiment: Experiment) => void
}
export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: ScheduleModalProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Initialize with existing scheduled date or current date/time
const getInitialDateTime = () => {
if (experiment.scheduled_date) {
const date = new Date(experiment.scheduled_date)
return {
date: date.toISOString().split('T')[0],
time: date.toTimeString().slice(0, 5)
}
}
const now = new Date()
// Set to next hour by default
now.setHours(now.getHours() + 1, 0, 0, 0)
return {
date: now.toISOString().split('T')[0],
time: now.toTimeString().slice(0, 5)
}
}
const [dateTime, setDateTime] = useState(getInitialDateTime())
const isScheduled = !!experiment.scheduled_date
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
// Validate date/time
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
const now = new Date()
if (selectedDateTime <= now) {
setError('Scheduled date and time must be in the future')
setLoading(false)
return
}
// Schedule the experiment
const updatedExperiment = await experimentManagement.scheduleExperiment(
experiment.id,
selectedDateTime.toISOString()
)
onScheduleUpdated(updatedExperiment)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to schedule experiment')
console.error('Schedule experiment error:', err)
} finally {
setLoading(false)
}
}
const handleRemoveSchedule = async () => {
if (!confirm('Are you sure you want to remove the schedule for this experiment?')) {
return
}
setError(null)
setLoading(true)
try {
const updatedExperiment = await experimentManagement.removeExperimentSchedule(experiment.id)
onScheduleUpdated(updatedExperiment)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to remove schedule')
console.error('Remove schedule error:', err)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
onClose()
}
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
/>
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
{isScheduled ? 'Update Schedule' : 'Schedule Experiment'}
</h3>
</div>
<div className="p-6">
{/* Experiment Info */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Experiment #{experiment.experiment_number}</h4>
<p className="text-sm text-gray-600">
{experiment.reps_required} reps required {experiment.soaking_duration_hr}h soaking
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Current Schedule (if exists) */}
{isScheduled && (
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
<p className="text-sm text-blue-700">
{new Date(experiment.scheduled_date!).toLocaleString()}
</p>
</div>
)}
{/* Schedule Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Date *
</label>
<div className="relative max-w-xs">
<input
type="date"
id="date"
value={dateTime.date}
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
required
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</span>
</div>
</div>
<div>
<label htmlFor="time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Time *
</label>
<div className="relative max-w-xs">
<input
type="time"
id="time"
value={dateTime.time}
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
required
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-between pt-4">
<div>
{isScheduled && (
<button
type="button"
onClick={handleRemoveSchedule}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-error-600 hover:text-error-700 hover:bg-error-50 dark:text-error-500 dark:hover:bg-error-500/15 rounded-lg transition-colors disabled:opacity-50"
>
Remove Schedule
</button>
)}
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={handleCancel}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : (isScheduled ? 'Update Schedule' : 'Schedule Experiment')}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,311 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { User } from '../lib/supabase'
interface SidebarProps {
user: User
currentView: string
onViewChange: (view: string) => void
isExpanded?: boolean
isMobileOpen?: boolean
isHovered?: boolean
setIsHovered?: (hovered: boolean) => void
}
interface MenuItem {
id: string
name: string
icon: React.ReactElement
requiredRoles?: string[]
subItems?: { name: string; id: string; requiredRoles?: string[] }[]
}
export function Sidebar({
user,
currentView,
onViewChange,
isExpanded = true,
isMobileOpen = false,
isHovered = false,
setIsHovered
}: SidebarProps) {
const [openSubmenu, setOpenSubmenu] = useState<number | null>(null)
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({})
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({})
const menuItems: MenuItem[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
</svg>
),
},
{
id: 'user-management',
name: 'User Management',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
requiredRoles: ['admin']
},
{
id: 'experiments',
name: 'Experiments',
icon: (
<svg className="w-6 h-6" 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>
),
requiredRoles: ['admin', 'conductor']
},
{
id: 'video-library',
name: 'Video Library',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
},
{
id: 'analytics',
name: 'Analytics',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
requiredRoles: ['admin', 'conductor', 'analyst']
},
{
id: 'data-entry',
name: 'Data Entry',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
),
requiredRoles: ['admin', 'conductor', 'data recorder']
},
{
id: 'vision-system',
name: 'Vision System',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
}
]
// const isActive = (path: string) => location.pathname === path;
const isActive = useCallback(
(id: string) => currentView === id,
[currentView]
)
useEffect(() => {
// Auto-open submenu if current view is in a submenu
menuItems.forEach((nav, index) => {
if (nav.subItems) {
nav.subItems.forEach((subItem) => {
if (isActive(subItem.id)) {
setOpenSubmenu(index)
}
})
}
})
}, [currentView, isActive, menuItems])
useEffect(() => {
if (openSubmenu !== null) {
const key = `submenu-${openSubmenu}`
if (subMenuRefs.current[key]) {
setSubMenuHeight((prevHeights) => ({
...prevHeights,
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
}))
}
}
}, [openSubmenu])
const handleSubmenuToggle = (index: number) => {
setOpenSubmenu((prevOpenSubmenu) => {
if (prevOpenSubmenu === index) {
return null
}
return index
})
}
const hasAccess = (item: MenuItem): boolean => {
if (!item.requiredRoles) return true
return item.requiredRoles.some(role => user.roles.includes(role as any))
}
const renderMenuItems = (items: MenuItem[]) => (
<ul className="flex flex-col gap-4">
{items.map((nav, index) => {
if (!hasAccess(nav)) return null
return (
<li key={nav.id}>
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(index)}
className={`menu-item group ${openSubmenu === index
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
>
<span
className={`menu-item-icon-size ${openSubmenu === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
{(isExpanded || isHovered || isMobileOpen) && (
<svg
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu === index
? "rotate-180 text-brand-500"
: ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
) : (
<button
onClick={() => onViewChange(nav.id)}
className={`menu-item group ${isActive(nav.id) ? "menu-item-active" : "menu-item-inactive"
}`}
>
<span
className={`menu-item-icon-size ${isActive(nav.id)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
</button>
)}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div
ref={(el) => {
subMenuRefs.current[`submenu-${index}`] = el
}}
className="overflow-hidden transition-all duration-300"
style={{
height:
openSubmenu === index
? `${subMenuHeight[`submenu-${index}`]}px`
: "0px",
}}
>
<ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => {
if (subItem.requiredRoles && !subItem.requiredRoles.some(role => user.roles.includes(role as any))) {
return null
}
return (
<li key={subItem.id}>
<button
onClick={() => onViewChange(subItem.id)}
className={`menu-dropdown-item ${isActive(subItem.id)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
{subItem.name}
</button>
</li>
)
})}
</ul>
</div>
)}
</li>
)
})}
</ul>
)
return (
<aside
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${isExpanded || isMobileOpen
? "w-[290px]"
: isHovered
? "w-[290px]"
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered && setIsHovered(true)}
onMouseLeave={() => setIsHovered && setIsHovered(false)}
>
<div
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
}`}
>
<div>
{isExpanded || isHovered || isMobileOpen ? (
<>
<h1 className="text-xl font-bold text-gray-800 dark:text-white/90">Pecan Experiments</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Research Dashboard</p>
</>
) : (
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center text-white font-bold text-lg">
P
</div>
)}
</div>
</div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav className="mb-6">
<div className="flex flex-col gap-4">
<div>
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Menu"
) : (
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
)}
</h2>
{renderMenuItems(menuItems)}
</div>
</div>
</nav>
</div>
</aside>
)
}

View File

@@ -0,0 +1,269 @@
import { useState } from 'react'
import type { User } from '../lib/supabase'
interface TopNavbarProps {
user: User
onLogout: () => void
currentView?: string
onToggleSidebar?: () => void
isSidebarOpen?: boolean
}
export function TopNavbar({
user,
onLogout,
currentView = 'dashboard',
onToggleSidebar,
isSidebarOpen = false
}: TopNavbarProps) {
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
const getPageTitle = (view: string) => {
switch (view) {
case 'dashboard':
return 'Dashboard'
case 'user-management':
return 'User Management'
case 'experiments':
return 'Experiments'
case 'analytics':
return 'Analytics'
case 'data-entry':
return 'Data Entry'
case 'vision-system':
return 'Vision System'
case 'video-library':
return 'Video Library'
default:
return 'Dashboard'
}
}
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
case 'conductor':
return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
case 'analyst':
return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
case 'data recorder':
return 'bg-theme-purple-500/10 text-theme-purple-500'
default:
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
}
}
return (
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
<button
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
onClick={onToggleSidebar}
aria-label="Toggle Sidebar"
>
{isSidebarOpen ? (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
) : (
<svg
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"
/>
</svg>
)}
</button>
{/* Page title */}
<div className="flex items-center lg:hidden">
<h1 className="text-lg font-medium text-gray-800 dark:text-white/90">{getPageTitle(currentView)}</h1>
</div>
{/* Search bar - hidden on mobile, shown on desktop */}
<div className="hidden lg:block">
<form>
<div className="relative">
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
<svg
className="fill-gray-500 dark:fill-gray-400"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
fill=""
/>
</svg>
</span>
<input
type="text"
placeholder="Search or type command..."
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
/>
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
<span> </span>
<span> K </span>
</button>
</div>
</form>
</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">
{/* User Area */}
<div className="relative">
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center text-gray-700 dropdown-toggle dark:text-gray-400"
>
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
{user.email.charAt(0).toUpperCase()}
</div>
</span>
<span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
<svg
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
}`}
width="18"
height="20"
viewBox="0 0 18 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.3125 8.65625L9 13.3437L13.6875 8.65625"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Dropdown menu */}
{isUserMenuOpen && (
<div className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark">
<div>
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
{user.email.split('@')[0]}
</span>
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
{user.email}
</span>
</div>
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
<li>
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
<svg
className="fill-gray-500 dark:fill-gray-400"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z"
fill=""
/>
</svg>
Profile
</div>
</li>
<li>
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
<span className="text-xs text-gray-500 dark:text-gray-400">Status:</span>
<span className={user.status === 'active' ? 'text-success-600 dark:text-success-500' : 'text-error-600 dark:text-error-500'}>
{user.status}
</span>
</div>
</li>
<li>
<div className="px-3 py-2">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">Roles:</div>
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</div>
</li>
</ul>
<button
onClick={() => {
setIsUserMenuOpen(false)
onLogout()
}}
className="flex items-center gap-3 px-3 py-2 mt-3 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z"
fill=""
/>
</svg>
Sign out
</button>
</div>
)}
</div>
</div>
</div>
{/* Click outside to close dropdown */}
{isUserMenuOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsUserMenuOpen(false)}
/>
)}
</header>
)
}

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react'
import { userManagement, type User, type Role, type RoleName, type UserStatus } from '../lib/supabase'
import { CreateUserModal } from './CreateUserModal'
export function UserManagement() {
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingUser, setEditingUser] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
setError(null)
const [usersData, rolesData] = await Promise.all([
userManagement.getAllUsers(),
userManagement.getAllRoles()
])
setUsers(usersData)
setRoles(rolesData)
} catch (err) {
setError('Failed to load user data')
console.error('Load data error:', err)
} finally {
setLoading(false)
}
}
const handleStatusToggle = async (userId: string, currentStatus: UserStatus) => {
try {
const newStatus: UserStatus = currentStatus === 'active' ? 'disabled' : 'active'
await userManagement.updateUserStatus(userId, newStatus)
// Update local state
setUsers(users.map(user =>
user.id === userId ? { ...user, status: newStatus } : user
))
} catch (err) {
console.error('Status update error:', err)
alert('Failed to update user status')
}
}
const handleRoleUpdate = async (userId: string, newRoles: RoleName[]) => {
try {
await userManagement.updateUserRoles(userId, newRoles)
// Update local state
setUsers(users.map(user =>
user.id === userId ? { ...user, roles: newRoles } : user
))
setEditingUser(null)
} catch (err) {
console.error('Role update error:', err)
alert('Failed to update user roles')
}
}
const handleEmailUpdate = async (userId: string, newEmail: string) => {
try {
await userManagement.updateUserEmail(userId, newEmail)
// Update local state
setUsers(users.map(user =>
user.id === userId ? { ...user, email: newEmail } : user
))
setEditingUser(null)
} catch (err) {
console.error('Email update error:', err)
alert('Failed to update user email')
}
}
const handleUserCreated = (newUser: User) => {
setUsers([...users, newUser])
setShowCreateModal(false)
}
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
case 'conductor':
return 'bg-blue-100 text-blue-800'
case 'analyst':
return 'bg-green-100 text-green-800'
case 'data recorder':
return 'bg-purple-100 text-purple-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
if (loading) {
return (
<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>
</div>
</div>
)
}
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>
<button
onClick={loadData}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Retry
</button>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<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>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Add New User
</button>
</div>
</div>
{/* 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="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">👥</span>
</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>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl"></span>
</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">
{users.filter(u => u.status === 'active').length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">🔴</span>
</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">
{users.filter(u => u.status === 'disabled').length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">👑</span>
</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">
{users.filter(u => u.roles.includes('admin')).length}
</dd>
</dl>
</div>
</div>
</div>
</div>
</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">
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">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<UserRow
key={user.id}
user={user}
roles={roles}
isEditing={editingUser === user.id}
onEdit={() => setEditingUser(user.id)}
onCancel={() => setEditingUser(null)}
onStatusToggle={handleStatusToggle}
onRoleUpdate={handleRoleUpdate}
onEmailUpdate={handleEmailUpdate}
getRoleBadgeColor={getRoleBadgeColor}
/>
))}
</tbody>
</table>
</div>
</div>
{/* Create User Modal */}
{showCreateModal && (
<CreateUserModal
roles={roles}
onClose={() => setShowCreateModal(false)}
onUserCreated={handleUserCreated}
/>
)}
</div>
)
}
// UserRow component for inline editing
interface UserRowProps {
user: User
roles: Role[]
isEditing: boolean
onEdit: () => void
onCancel: () => void
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
onEmailUpdate: (userId: string, newEmail: string) => void
getRoleBadgeColor: (role: string) => string
}
function UserRow({
user,
roles,
isEditing,
onEdit,
onCancel,
onStatusToggle,
onRoleUpdate,
onEmailUpdate,
getRoleBadgeColor
}: UserRowProps) {
const [editEmail, setEditEmail] = useState(user.email)
const [editRoles, setEditRoles] = useState<RoleName[]>(user.roles)
const handleSave = () => {
if (editEmail !== user.email) {
onEmailUpdate(user.id, editEmail)
}
if (JSON.stringify(editRoles.sort()) !== JSON.stringify(user.roles.sort())) {
onRoleUpdate(user.id, editRoles)
}
if (editEmail === user.email && JSON.stringify(editRoles.sort()) === JSON.stringify(user.roles.sort())) {
onCancel()
}
}
const handleRoleToggle = (roleName: RoleName) => {
if (editRoles.includes(roleName)) {
setEditRoles(editRoles.filter(r => r !== roleName))
} else {
setEditRoles([...editRoles, roleName])
}
}
return (
<tr>
<td className="px-6 py-4 whitespace-nowrap">
{isEditing ? (
<input
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"
/>
) : (
<div
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
onClick={onEdit}
>
{user.email}
</div>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<div className="space-y-2">
{roles.map((role) => (
<label key={role.id} className="flex items-center">
<input
type="checkbox"
checked={editRoles.includes(role.name)}
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>
</label>
))}
</div>
) : (
<div
className="flex flex-wrap gap-1 cursor-pointer"
onClick={onEdit}
>
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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'
}`}
>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{isEditing ? (
<div className="flex space-x-2">
<button
onClick={handleSave}
className="text-blue-600 hover:text-blue-900"
>
Save
</button>
<button
onClick={onCancel}
className="text-gray-600 hover:text-gray-900"
>
Cancel
</button>
</div>
) : (
<button
onClick={onEdit}
className="text-blue-600 hover:text-blue-900"
>
Edit
</button>
)}
</td>
</tr>
)
}

View File

@@ -0,0 +1,963 @@
import { useState, useEffect, useRef, useCallback, useMemo, memo, startTransition } from 'react'
import {
visionApi,
type SystemStatus,
type CameraStatus,
type MachineStatus,
type StorageStats,
type RecordingInfo,
type MqttStatus,
type MqttEventsResponse,
type MqttEvent,
formatBytes,
formatDuration,
formatUptime
} from '../lib/visionApi'
import { useAuth } from '../hooks/useAuth'
import { CameraConfigModal } from './CameraConfigModal'
import { CameraPreviewModal } from './CameraPreviewModal'
// Memoized components to prevent unnecessary re-renders
const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{systemStatus.system_started ? 'Online' : 'Offline'}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">System Status</div>
<div className="mt-1 text-sm text-gray-500">
Uptime: {formatUptime(systemStatus.uptime_seconds)}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">MQTT Status</div>
<div className="mt-1 text-sm text-gray-500">
Last message: {systemStatus.last_mqtt_message || 'Never'}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{systemStatus.active_recordings} Active
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">Recordings</div>
<div className="mt-1 text-sm text-gray-500">
Total: {systemStatus.total_recordings}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{Object.keys(systemStatus.cameras).length} Cameras
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">Devices</div>
<div className="mt-1 text-sm text-gray-500">
{Object.keys(systemStatus.machines).length} Machines
</div>
</div>
</div>
</div>
</div>
))
const StorageOverview = memo(({ storageStats }: { storageStats: StorageStats }) => (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Storage usage and file statistics
</p>
</div>
<div className="border-t border-gray-200 p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
<div className="text-sm text-gray-500">Total Files</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
<div className="text-sm text-gray-500">Total Size</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
<div className="text-sm text-gray-500">Free Space</div>
</div>
</div>
{/* Disk Usage Bar */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Disk Usage</span>
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
</div>
</div>
{/* Per-Camera Statistics */}
{Object.keys(storageStats.cameras).length > 0 && (
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => (
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">{cameraName}</h5>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Files:</span>
<span className="text-gray-900">{stats.file_count}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Size:</span>
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))
const CamerasStatus = memo(({
systemStatus,
onConfigureCamera,
onStartRecording,
onStopRecording,
onPreviewCamera,
onStopStreaming
}: {
systemStatus: SystemStatus,
onConfigureCamera: (cameraName: string) => void,
onStartRecording: (cameraName: string) => Promise<void>,
onStopRecording: (cameraName: string) => Promise<void>,
onPreviewCamera: (cameraName: string) => void,
onStopStreaming: (cameraName: string) => Promise<void>
}) => {
const { isAdmin } = useAuth()
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Current status of all cameras in the system
</p>
</div>
<div className="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
const friendlyName = camera.device_info?.friendly_name
const hasDeviceInfo = !!camera.device_info
const hasSerial = !!camera.device_info?.serial_number
// Determine if camera is connected based on status
const isConnected = camera.status === 'available' || camera.status === 'connected'
const hasError = camera.status === 'error'
const statusText = camera.status || 'unknown'
return (
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-gray-900">
{friendlyName || cameraName}
{friendlyName && (
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
)}
</h4>
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isConnected ? 'bg-green-100 text-green-800' :
hasError ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{isConnected ? 'Connected' : hasError ? 'Error' : 'Disconnected'}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Status:</span>
<span className={`font-medium ${isConnected ? 'text-green-600' :
hasError ? 'text-yellow-600' :
'text-red-600'
}`}>
{statusText.charAt(0).toUpperCase() + statusText.slice(1)}
</span>
</div>
{camera.is_recording && (
<div className="flex justify-between">
<span className="text-gray-500">Recording:</span>
<span className="text-red-600 font-medium flex items-center">
<div className="w-2 h-2 bg-red-500 rounded-full mr-2 animate-pulse"></div>
Active
</span>
</div>
)}
{hasDeviceInfo && (
<>
{camera.device_info.model && (
<div className="flex justify-between">
<span className="text-gray-500">Model:</span>
<span className="text-gray-900">{camera.device_info.model}</span>
</div>
)}
{hasSerial && (
<div className="flex justify-between">
<span className="text-gray-500">Serial:</span>
<span className="text-gray-900 font-mono text-xs">{camera.device_info.serial_number}</span>
</div>
)}
{camera.device_info.firmware_version && (
<div className="flex justify-between">
<span className="text-gray-500">Firmware:</span>
<span className="text-gray-900 font-mono text-xs">{camera.device_info.firmware_version}</span>
</div>
)}
</>
)}
{camera.last_frame_time && (
<div className="flex justify-between">
<span className="text-gray-500">Last Frame:</span>
<span className="text-gray-900">{new Date(camera.last_frame_time).toLocaleTimeString()}</span>
</div>
)}
{camera.frame_rate && (
<div className="flex justify-between">
<span className="text-gray-500">Frame Rate:</span>
<span className="text-gray-900">{camera.frame_rate.toFixed(1)} fps</span>
</div>
)}
{camera.last_checked && (
<div className="flex justify-between">
<span className="text-gray-500">Last Checked:</span>
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
</div>
)}
{camera.current_recording_file && (
<div className="flex justify-between">
<span className="text-gray-500">Recording File:</span>
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
</div>
)}
{camera.last_error && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
<div className="text-red-800 text-xs">
<strong>Error:</strong> {camera.last_error}
</div>
</div>
)}
{/* Camera Control Buttons */}
<div className="mt-3 pt-3 border-t border-gray-200 space-y-2">
{/* Recording Controls */}
<div className="flex space-x-2">
{!camera.is_recording ? (
<button
onClick={() => onStartRecording(cameraName)}
disabled={!isConnected}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
? 'text-green-600 bg-green-50 border border-green-200 hover:bg-green-100 focus:ring-green-500'
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h8m-9-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Start Recording
</button>
) : (
<button
onClick={() => onStopRecording(cameraName)}
className="flex-1 px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9h6v6H9z" />
</svg>
Stop Recording
</button>
)}
</div>
{/* Preview and Streaming Controls */}
<div className="flex space-x-2">
<button
onClick={() => onPreviewCamera(cameraName)}
disabled={!isConnected}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
? 'text-blue-600 bg-blue-50 border border-blue-200 hover:bg-blue-100 focus:ring-blue-500'
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Preview
</button>
<button
onClick={() => onStopStreaming(cameraName)}
disabled={!isConnected}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
? 'text-orange-600 bg-orange-50 border border-orange-200 hover:bg-orange-100 focus:ring-orange-500'
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
}`}
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Stop Streaming
</button>
</div>
</div>
{/* Admin Configuration Button */}
{isAdmin() && (
<div className="mt-3 pt-3 border-t border-gray-200">
<button
onClick={() => onConfigureCamera(cameraName)}
className="w-full px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configure Camera
</button>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
})
const RecentRecordings = memo(({ recordings, systemStatus }: { recordings: Record<string, RecordingInfo>, systemStatus: SystemStatus | null }) => (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Latest recording sessions
</p>
</div>
<div className="border-t border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Camera
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Filename
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
const camera = systemStatus?.cameras[recording.camera_name]
const displayName = camera?.device_info?.friendly_name || recording.camera_name
return (
<tr key={recordingId}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{displayName}
{camera?.device_info?.friendly_name && (
<div className="text-xs text-gray-500">({recording.camera_name})</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
{recording.filename}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${recording.status === 'recording' ? 'bg-red-100 text-red-800' :
recording.status === 'completed' ? 'bg-green-100 text-green-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{recording.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(recording.start_time).toLocaleString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
))
export function VisionSystem() {
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
const [mqttStatus, setMqttStatus] = useState<MqttStatus | null>(null)
const [mqttEvents, setMqttEvents] = useState<MqttEvent[]>([])
// Camera configuration modal state
const [configModalOpen, setConfigModalOpen] = useState(false)
const [selectedCamera, setSelectedCamera] = useState<string | null>(null)
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null)
// Camera preview modal state
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewCamera, setPreviewCamera] = useState<string | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const clearAutoRefresh = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startAutoRefresh = useCallback(() => {
clearAutoRefresh()
if (autoRefreshEnabled && refreshInterval > 0) {
intervalRef.current = setInterval(fetchData, refreshInterval)
}
}, [autoRefreshEnabled, refreshInterval])
useEffect(() => {
fetchData()
startAutoRefresh()
return clearAutoRefresh
}, [startAutoRefresh])
useEffect(() => {
startAutoRefresh()
}, [autoRefreshEnabled, refreshInterval, startAutoRefresh])
const fetchData = useCallback(async (showRefreshIndicator = true) => {
try {
setError(null)
if (!systemStatus) {
setLoading(true)
} else if (showRefreshIndicator) {
setRefreshing(true)
}
const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([
visionApi.getSystemStatus(),
visionApi.getStorageStats(),
visionApi.getRecordings(),
visionApi.getMqttStatus().catch(err => {
console.warn('Failed to fetch MQTT status:', err)
return null
}),
visionApi.getMqttEvents(10).catch(err => {
console.warn('Failed to fetch MQTT events:', err)
return { events: [], total_events: 0, last_updated: '' }
})
])
// If cameras don't have device_info, try to fetch individual camera status
if (statusData.cameras) {
const camerasNeedingInfo = Object.entries(statusData.cameras)
.filter(([_, camera]) => !camera.device_info?.friendly_name)
.map(([cameraName, _]) => cameraName)
if (camerasNeedingInfo.length > 0) {
console.log('Fetching individual camera info for:', camerasNeedingInfo)
try {
const individualCameraData = await Promise.all(
camerasNeedingInfo.map(cameraName =>
visionApi.getCameraStatus(cameraName).catch(err => {
console.warn(`Failed to get individual status for ${cameraName}:`, err)
return null
})
)
)
// Merge the individual camera data back into statusData
camerasNeedingInfo.forEach((cameraName, index) => {
const individualData = individualCameraData[index]
if (individualData && individualData.device_info) {
statusData.cameras[cameraName] = {
...statusData.cameras[cameraName],
device_info: individualData.device_info
}
}
})
} catch (err) {
console.warn('Failed to fetch individual camera data:', err)
}
}
}
// Batch state updates to minimize re-renders using startTransition for non-urgent updates
const updateTime = new Date()
// Use startTransition for non-urgent state updates to keep the UI responsive
startTransition(() => {
setSystemStatus(statusData)
setStorageStats(storageData)
setRecordings(recordingsData)
setLastUpdateTime(updateTime)
// Update MQTT status and events
if (mqttStatusData) {
setMqttStatus(mqttStatusData)
}
if (mqttEventsData && mqttEventsData.events) {
setMqttEvents(mqttEventsData.events)
}
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
console.error('Vision system fetch error:', err)
// Don't disable auto-refresh on errors, just log them
} finally {
setLoading(false)
setRefreshing(false)
}
}, [systemStatus])
// Camera configuration handlers
const handleConfigureCamera = (cameraName: string) => {
setSelectedCamera(cameraName)
setConfigModalOpen(true)
}
const handleConfigSuccess = (message: string) => {
setNotification({ type: 'success', message })
setTimeout(() => setNotification(null), 5000)
}
const handleConfigError = (message: string) => {
setNotification({ type: 'error', message })
setTimeout(() => setNotification(null), 5000)
}
// Recording control handlers
const handleStartRecording = async (cameraName: string) => {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `manual_${cameraName}_${timestamp}.mp4`
const result = await visionApi.startRecording(cameraName, { filename })
if (result.success) {
setNotification({ type: 'success', message: `Recording started: ${result.filename}` })
// Refresh data to update recording status
fetchData(false)
} else {
setNotification({ type: 'error', message: `Failed to start recording: ${result.message}` })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setNotification({ type: 'error', message: `Error starting recording: ${errorMessage}` })
}
}
const handleStopRecording = async (cameraName: string) => {
try {
const result = await visionApi.stopRecording(cameraName)
if (result.success) {
setNotification({ type: 'success', message: `Recording stopped: ${result.filename}` })
// Refresh data to update recording status
fetchData(false)
} else {
setNotification({ type: 'error', message: `Failed to stop recording: ${result.message}` })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setNotification({ type: 'error', message: `Error stopping recording: ${errorMessage}` })
}
}
const handlePreviewCamera = (cameraName: string) => {
setPreviewCamera(cameraName)
setPreviewModalOpen(true)
}
const handleStopStreaming = async (cameraName: string) => {
try {
const result = await visionApi.stopStream(cameraName)
if (result.success) {
setNotification({ type: 'success', message: `Streaming stopped for ${cameraName}` })
// Refresh data to update camera status
fetchData(false)
} else {
setNotification({ type: 'error', message: `Failed to stop streaming: ${result.message}` })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setNotification({ type: 'error', message: `Error stopping stream: ${errorMessage}` })
}
}
const getStatusColor = (status: string, isRecording: boolean = false) => {
// If camera is recording, always show red regardless of status
if (isRecording) {
return 'text-red-600 bg-red-100'
}
switch (status.toLowerCase()) {
case 'available':
case 'connected':
case 'healthy':
case 'on':
return 'text-green-600 bg-green-100'
case 'disconnected':
case 'off':
case 'failed':
return 'text-red-600 bg-red-100'
case 'error':
case 'warning':
case 'degraded':
return 'text-yellow-600 bg-yellow-100'
default:
return 'text-yellow-600 bg-yellow-100'
}
}
const getMachineStateColor = (state: string) => {
switch (state.toLowerCase()) {
case 'on':
case 'running':
return 'text-green-600 bg-green-100'
case 'off':
case 'stopped':
return 'text-gray-600 bg-gray-100'
default:
return 'text-yellow-600 bg-yellow-100'
}
}
if (loading) {
return (
<div className="p-6">
<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-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading vision system data...</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading vision system</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={() => fetchData(true)}
disabled={refreshing}
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 disabled:opacity-50"
>
{refreshing ? 'Retrying...' : 'Try Again'}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
{lastUpdateTime && (
<p className={`mt-1 text-sm text-gray-500 flex items-center space-x-2 ${refreshing ? 'animate-pulse' : ''}`}>
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</span>
{refreshing && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<span className="animate-spin rounded-full h-3 w-3 border-b border-blue-600 mr-1 inline-block"></span>
Updating...
</span>
)}
{autoRefreshEnabled && !refreshing && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Auto-refresh: {refreshInterval / 1000}s
</span>
)}
</p>
)}
</div>
<div className="flex items-center space-x-4">
{/* Auto-refresh controls */}
<div className="flex items-center space-x-2">
<label className="flex items-center space-x-2 text-sm text-gray-600">
<input
type="checkbox"
checked={autoRefreshEnabled}
onChange={(e) => setAutoRefreshEnabled(e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Auto-refresh</span>
</label>
{autoRefreshEnabled && (
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value={2000}>2s</option>
<option value={5000}>5s</option>
<option value={10000}>10s</option>
<option value={30000}>30s</option>
<option value={60000}>1m</option>
</select>
)}
</div>
{/* Refresh indicator and button */}
<div className="flex items-center space-x-2">
{refreshing && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
)}
<button
onClick={() => fetchData(true)}
disabled={refreshing}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
Refresh
</button>
</div>
</div>
</div>
{/* System Overview */}
{systemStatus && <SystemOverview systemStatus={systemStatus} />}
{/* Cameras Status */}
{systemStatus && (
<CamerasStatus
systemStatus={systemStatus}
onConfigureCamera={handleConfigureCamera}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
onPreviewCamera={handlePreviewCamera}
onStopStreaming={handleStopStreaming}
/>
)}
{/* Machines Status */}
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Machines</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Current status of all machines in the system
</p>
</div>
<div className="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
{Object.entries(systemStatus.machines).map(([machineName, machine]) => (
<div key={machineName} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-gray-900 capitalize">
{machineName.replace(/_/g, ' ')}
</h4>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMachineStateColor(machine.state)}`}>
{machine.state}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Last updated:</span>
<span className="text-gray-900">{new Date(machine.last_updated).toLocaleTimeString()}</span>
</div>
{machine.last_message && (
<div className="flex justify-between">
<span className="text-gray-500">Last message:</span>
<span className="text-gray-900">{machine.last_message}</span>
</div>
)}
{machine.mqtt_topic && (
<div className="flex justify-between">
<span className="text-gray-500">MQTT topic:</span>
<span className="text-gray-900 text-xs font-mono">{machine.mqtt_topic}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Storage Statistics */}
{storageStats && <StorageOverview storageStats={storageStats} />}
{/* Recent Recordings */}
{Object.keys(recordings).length > 0 && <RecentRecordings recordings={recordings} systemStatus={systemStatus} />}
{/* Camera Configuration Modal */}
{selectedCamera && (
<CameraConfigModal
cameraName={selectedCamera}
isOpen={configModalOpen}
onClose={() => {
setConfigModalOpen(false)
setSelectedCamera(null)
}}
onSuccess={handleConfigSuccess}
onError={handleConfigError}
/>
)}
{/* Camera Preview Modal */}
{previewCamera && (
<CameraPreviewModal
cameraName={previewCamera}
isOpen={previewModalOpen}
onClose={() => {
setPreviewModalOpen(false)
setPreviewCamera(null)
}}
/>
)}
{/* Notification */}
{notification && (
<div className={`fixed top-4 right-4 z-50 p-4 rounded-md shadow-lg ${notification.type === 'success'
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-red-50 border border-red-200 text-red-800'
}`}>
<div className="flex items-center">
<div className="flex-shrink-0">
{notification.type === 'success' ? (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className="text-sm font-medium">{notification.message}</p>
</div>
<div className="ml-auto pl-3">
<button
onClick={() => setNotification(null)}
className={`inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 ${notification.type === 'success'
? 'text-green-500 hover:bg-green-100 focus:ring-green-600'
: 'text-red-500 hover:bg-red-100 focus:ring-red-600'
}`}
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
)}
</div>
)
}