feat: Implement Vision System API Client with comprehensive endpoints and utility functions
- Added VisionApiClient class to interact with the vision system API. - Defined interfaces for system status, machine status, camera status, recordings, and storage stats. - Implemented methods for health checks, system status retrieval, camera control, and storage management. - Introduced utility functions for formatting bytes, durations, and uptime. test: Create manual verification script for Vision API functionality - Added a test script to verify utility functions and API endpoints. - Included tests for health check, system status, cameras, machines, and storage stats. feat: Create experiment repetitions system migration - Added experiment_repetitions table to manage experiment repetitions with scheduling. - Implemented triggers and functions for validation and timestamp management. - Established row-level security policies for user access control. feat: Introduce phase-specific draft management system migration - Created experiment_phase_drafts and experiment_phase_data tables for managing phase-specific drafts and measurements. - Added pecan_diameter_measurements table for individual diameter measurements. - Implemented row-level security policies for user access control. fix: Adjust draft constraints to allow multiple drafts while preventing multiple submitted drafts - Modified constraints on experiment_phase_drafts to allow multiple drafts in 'draft' or 'withdrawn' status. - Ensured only one 'submitted' draft per user per phase per repetition.
This commit is contained in:
137
VISION_SYSTEM_README.md
Normal file
137
VISION_SYSTEM_README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Vision System Dashboard
|
||||
|
||||
This document describes the Vision System dashboard that has been added to the Pecan Experiments application.
|
||||
|
||||
## Overview
|
||||
|
||||
The Vision System dashboard provides real-time monitoring and control of the USDA Vision Camera System. It displays information about cameras, machines, storage, and recording sessions.
|
||||
|
||||
## Features
|
||||
|
||||
### System Overview
|
||||
- **System Status**: Shows if the vision system is online/offline with uptime information
|
||||
- **MQTT Connection**: Displays MQTT connectivity status and last message timestamp
|
||||
- **Active Recordings**: Shows current number of active recordings and total recordings
|
||||
- **Camera/Machine Count**: Quick overview of connected devices
|
||||
|
||||
### Camera Monitoring
|
||||
- **Real-time Status**: Shows connection status for each camera (camera1, camera2)
|
||||
- **Recording State**: Indicates if cameras are currently recording
|
||||
- **Device Information**: Displays friendly names and serial numbers
|
||||
- **Error Reporting**: Shows any camera errors or issues
|
||||
- **Current Recording Files**: Shows active recording filenames
|
||||
|
||||
### Machine Status
|
||||
- **Machine States**: Monitors vibratory conveyor, blower separator, and other machines
|
||||
- **MQTT Topics**: Shows the MQTT topics for each machine
|
||||
- **Last Updated**: Timestamps for when each machine status was last updated
|
||||
- **State Colors**: Visual indicators for machine states (on/off/running/stopped)
|
||||
|
||||
### Storage Management
|
||||
- **Disk Usage**: Visual progress bar showing total disk usage
|
||||
- **File Statistics**: Total files, total size, and free space
|
||||
- **Per-Camera Breakdown**: Storage usage statistics for each camera
|
||||
- **Storage Path**: Shows the base storage directory
|
||||
|
||||
### Recording Sessions
|
||||
- **Recent Recordings Table**: Shows the latest recording sessions
|
||||
- **Recording Details**: Filename, camera, status, duration, file size, start time
|
||||
- **Status Indicators**: Visual status badges for completed/active/failed recordings
|
||||
|
||||
## API Integration
|
||||
|
||||
The dashboard connects to the Vision System API running on `http://localhost:8000` and provides:
|
||||
|
||||
### Endpoints Used
|
||||
- `GET /system/status` - System overview and status
|
||||
- `GET /cameras` - Camera status and information
|
||||
- `GET /machines` - Machine status and MQTT data
|
||||
- `GET /storage/stats` - Storage usage statistics
|
||||
- `GET /recordings` - Recording session information
|
||||
|
||||
### Auto-Refresh
|
||||
- The dashboard automatically refreshes data every 5 seconds
|
||||
- Manual refresh button available for immediate updates
|
||||
- Loading indicators show when data is being fetched
|
||||
|
||||
## Access Control
|
||||
|
||||
The Vision System dashboard is accessible to all authenticated users regardless of role:
|
||||
- **Admin**: Full access to all features
|
||||
- **Conductor**: Full access to all features
|
||||
- **Analyst**: Full access to all features
|
||||
- **Data Recorder**: Full access to all features
|
||||
|
||||
## Error Handling
|
||||
|
||||
The dashboard includes comprehensive error handling:
|
||||
- **Connection Errors**: Shows user-friendly messages when the vision system is unavailable
|
||||
- **API Errors**: Displays specific error messages from the vision system API
|
||||
- **Retry Functionality**: "Try Again" button to retry failed requests
|
||||
- **Graceful Degradation**: Shows partial data if some API calls fail
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files Added/Modified
|
||||
- `src/lib/visionApi.ts` - API client for vision system integration
|
||||
- `src/components/VisionSystem.tsx` - Main dashboard component
|
||||
- `src/components/DashboardLayout.tsx` - Added routing for vision system
|
||||
- `src/components/Sidebar.tsx` - Added menu item for vision system
|
||||
- `src/components/TopNavbar.tsx` - Added page title for vision system
|
||||
|
||||
### Dependencies
|
||||
- Uses existing React/TypeScript setup
|
||||
- Leverages Tailwind CSS for styling
|
||||
- No additional dependencies required
|
||||
|
||||
### API Client Features
|
||||
- TypeScript interfaces for all API responses
|
||||
- Comprehensive error handling
|
||||
- Utility functions for formatting (bytes, duration, uptime)
|
||||
- Singleton pattern for API client instance
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Navigate to Vision System**: Click "Vision System" in the sidebar menu
|
||||
2. **Monitor Status**: View real-time system, camera, and machine status
|
||||
3. **Check Storage**: Monitor disk usage and file statistics
|
||||
4. **Review Recordings**: See recent recording sessions and their details
|
||||
5. **Refresh Data**: Use the refresh button or wait for auto-refresh
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Failed to fetch vision system data"**
|
||||
- Ensure the vision system API is running on localhost:8000
|
||||
- Check network connectivity
|
||||
- Verify the vision system service is started
|
||||
|
||||
2. **Empty Dashboard**
|
||||
- Vision system may not have any cameras or machines configured
|
||||
- Check vision system configuration
|
||||
- Verify MQTT connectivity
|
||||
|
||||
3. **Outdated Information**
|
||||
- Data refreshes every 5 seconds automatically
|
||||
- Use the manual refresh button for immediate updates
|
||||
- Check if vision system is responding to API calls
|
||||
|
||||
### API Base URL Configuration
|
||||
|
||||
The API base URL is configured in `src/lib/visionApi.ts`:
|
||||
```typescript
|
||||
const VISION_API_BASE_URL = 'http://localhost:8000'
|
||||
```
|
||||
|
||||
To change the API endpoint, modify this constant and rebuild the application.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for the vision system dashboard:
|
||||
- **Camera Controls**: Add start/stop recording buttons
|
||||
- **Camera Recovery**: Add diagnostic and recovery action buttons
|
||||
- **File Management**: Add file browsing and cleanup functionality
|
||||
- **Real-time Streaming**: Add live camera feed display
|
||||
- **Alerts**: Add notifications for system issues
|
||||
- **Historical Data**: Add charts and trends for system metrics
|
||||
434
api-endpoints.http
Normal file
434
api-endpoints.http
Normal file
@@ -0,0 +1,434 @@
|
||||
###############################################################################
|
||||
# USDA Vision Camera System - Complete API Endpoints Documentation
|
||||
# Base URL: http://localhost:8000
|
||||
###############################################################################
|
||||
|
||||
###############################################################################
|
||||
# SYSTEM ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Root endpoint - API information
|
||||
GET http://localhost:8000/
|
||||
# Response: SuccessResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "USDA Vision Camera System API",
|
||||
# "data": null,
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Health check
|
||||
GET http://localhost:8000/health
|
||||
# Response: Simple health status
|
||||
# {
|
||||
# "status": "healthy",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get system status
|
||||
GET http://localhost:8000/system/status
|
||||
# Response: SystemStatusResponse
|
||||
# {
|
||||
# "system_started": true,
|
||||
# "mqtt_connected": true,
|
||||
# "last_mqtt_message": "2025-07-28T12:00:00",
|
||||
# "machines": {
|
||||
# "vibratory_conveyor": {
|
||||
# "name": "vibratory_conveyor",
|
||||
# "state": "off",
|
||||
# "last_updated": "2025-07-28T12:00:00"
|
||||
# }
|
||||
# },
|
||||
# "cameras": {
|
||||
# "camera1": {
|
||||
# "name": "camera1",
|
||||
# "status": "connected",
|
||||
# "is_recording": false
|
||||
# }
|
||||
# },
|
||||
# "active_recordings": 0,
|
||||
# "total_recordings": 5,
|
||||
# "uptime_seconds": 3600.5
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# MACHINE ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get all machines status
|
||||
GET http://localhost:8000/machines
|
||||
# Response: Dict[str, MachineStatusResponse]
|
||||
# {
|
||||
# "vibratory_conveyor": {
|
||||
# "name": "vibratory_conveyor",
|
||||
# "state": "off",
|
||||
# "last_updated": "2025-07-28T12:00:00",
|
||||
# "last_message": "off",
|
||||
# "mqtt_topic": "vision/vibratory_conveyor/state"
|
||||
# },
|
||||
# "blower_separator": {
|
||||
# "name": "blower_separator",
|
||||
# "state": "on",
|
||||
# "last_updated": "2025-07-28T12:00:00",
|
||||
# "last_message": "on",
|
||||
# "mqtt_topic": "vision/blower_separator/state"
|
||||
# }
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# MQTT ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get MQTT status and statistics
|
||||
GET http://localhost:8000/mqtt/status
|
||||
# Response: MQTTStatusResponse
|
||||
# {
|
||||
# "connected": true,
|
||||
# "broker_host": "192.168.1.110",
|
||||
# "broker_port": 1883,
|
||||
# "subscribed_topics": [
|
||||
# "vision/vibratory_conveyor/state",
|
||||
# "vision/blower_separator/state"
|
||||
# ],
|
||||
# "last_message_time": "2025-07-28T12:00:00",
|
||||
# "message_count": 42,
|
||||
# "error_count": 0,
|
||||
# "uptime_seconds": 3600.5
|
||||
# }
|
||||
|
||||
### Get recent MQTT events history
|
||||
GET http://localhost:8000/mqtt/events
|
||||
# Optional query parameter: limit (default: 5, max: 50)
|
||||
# Response: MQTTEventsHistoryResponse
|
||||
# {
|
||||
# "events": [
|
||||
# {
|
||||
# "machine_name": "vibratory_conveyor",
|
||||
# "topic": "vision/vibratory_conveyor/state",
|
||||
# "payload": "on",
|
||||
# "normalized_state": "on",
|
||||
# "timestamp": "2025-07-28T15:30:45.123456",
|
||||
# "message_number": 15
|
||||
# },
|
||||
# {
|
||||
# "machine_name": "blower_separator",
|
||||
# "topic": "vision/blower_separator/state",
|
||||
# "payload": "off",
|
||||
# "normalized_state": "off",
|
||||
# "timestamp": "2025-07-28T15:29:12.654321",
|
||||
# "message_number": 14
|
||||
# }
|
||||
# ],
|
||||
# "total_events": 15,
|
||||
# "last_updated": "2025-07-28T15:30:45.123456"
|
||||
# }
|
||||
|
||||
### Get recent MQTT events with custom limit
|
||||
GET http://localhost:8000/mqtt/events?limit=10
|
||||
|
||||
###############################################################################
|
||||
# CAMERA ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get all cameras status
|
||||
GET http://localhost:8000/cameras
|
||||
# Response: Dict[str, CameraStatusResponse]
|
||||
# {
|
||||
# "camera1": {
|
||||
# "name": "camera1",
|
||||
# "status": "connected",
|
||||
# "is_recording": false,
|
||||
# "last_checked": "2025-07-28T12:00:00",
|
||||
# "last_error": null,
|
||||
# "device_info": {
|
||||
# "friendly_name": "MindVision Camera",
|
||||
# "serial_number": "ABC123"
|
||||
# },
|
||||
# "current_recording_file": null,
|
||||
# "recording_start_time": null
|
||||
# }
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get specific camera status
|
||||
GET http://localhost:8000/cameras/camera1/status
|
||||
### Get specific camera status
|
||||
GET http://localhost:8000/cameras/camera2/status
|
||||
# Response: CameraStatusResponse (same as above for single camera)
|
||||
|
||||
###############################################################################
|
||||
# RECORDING CONTROL ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Start recording (with all optional parameters)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "test_recording.avi",
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 3.0,
|
||||
"fps": 0
|
||||
}
|
||||
###
|
||||
# Request Parameters (all optional):
|
||||
# - filename: string - Custom filename (datetime prefix auto-added)
|
||||
# - exposure_ms: float - Exposure time in milliseconds
|
||||
# - gain: float - Camera gain value
|
||||
# - fps: float - Target frames per second (0 = maximum speed, omit = use config default)
|
||||
#
|
||||
# Response: StartRecordingResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Recording started for camera1",
|
||||
# "filename": "20250728_120000_test_recording.avi"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Start recording (minimal - only filename)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "simple_test.avi"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Start recording (only camera settings)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 0
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Start recording (empty body - all defaults)
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
||||
###
|
||||
|
||||
### Stop recording
|
||||
POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
### Stop recording
|
||||
POST http://localhost:8000/cameras/camera2/stop-recording
|
||||
# No request body required
|
||||
# Response: StopRecordingResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Recording stopped for camera1",
|
||||
# "duration_seconds": 45.2
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# CAMERA RECOVERY & DIAGNOSTICS ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Test camera connection
|
||||
POST http://localhost:8000/cameras/camera1/test-connection
|
||||
POST http://localhost:8000/cameras/camera2/test-connection
|
||||
# No request body required
|
||||
# Response: CameraTestResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Camera camera1 connection test passed",
|
||||
# "camera_name": "camera1",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Reconnect camera (soft recovery)
|
||||
POST http://localhost:8000/cameras/camera1/reconnect
|
||||
POST http://localhost:8000/cameras/camera2/reconnect
|
||||
# No request body required
|
||||
# Response: CameraRecoveryResponse
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Camera camera1 reconnected successfully",
|
||||
# "camera_name": "camera1",
|
||||
# "operation": "reconnect",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Restart camera grab process
|
||||
POST http://localhost:8000/cameras/camera1/restart-grab
|
||||
POST http://localhost:8000/cameras/camera2/restart-grab
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Reset camera timestamp
|
||||
POST http://localhost:8000/cameras/camera1/reset-timestamp
|
||||
POST http://localhost:8000/cameras/camera2/reset-timestamp
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Full camera reset (hard recovery)
|
||||
POST http://localhost:8000/cameras/camera1/full-reset
|
||||
### Full camera reset (hard recovery)
|
||||
|
||||
POST http://localhost:8000/cameras/camera2/full-reset
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###
|
||||
|
||||
### Reinitialize failed camera
|
||||
POST http://localhost:8000/cameras/camera1/reinitialize
|
||||
### Reinitialize failed camera
|
||||
|
||||
POST http://localhost:8000/cameras/camera2/reinitialize
|
||||
# Response: CameraRecoveryResponse (same structure as reconnect)
|
||||
|
||||
###############################################################################
|
||||
# RECORDING SESSIONS ENDPOINT
|
||||
###############################################################################
|
||||
|
||||
### Get all recording sessions
|
||||
GET http://localhost:8000/recordings
|
||||
# Response: Dict[str, RecordingInfoResponse]
|
||||
# {
|
||||
# "rec_001": {
|
||||
# "camera_name": "camera1",
|
||||
# "filename": "20250728_120000_test.avi",
|
||||
# "start_time": "2025-07-28T12:00:00",
|
||||
# "state": "completed",
|
||||
# "end_time": "2025-07-28T12:05:00",
|
||||
# "file_size_bytes": 1048576,
|
||||
# "frame_count": 1500,
|
||||
# "duration_seconds": 300.0,
|
||||
# "error_message": null
|
||||
# }
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# STORAGE ENDPOINTS
|
||||
###############################################################################
|
||||
|
||||
### Get storage statistics
|
||||
GET http://localhost:8000/storage/stats
|
||||
# Response: StorageStatsResponse
|
||||
# {
|
||||
# "base_path": "/storage",
|
||||
# "total_files": 25,
|
||||
# "total_size_bytes": 52428800,
|
||||
# "cameras": {
|
||||
# "camera1": {
|
||||
# "file_count": 15,
|
||||
# "total_size_bytes": 31457280
|
||||
# }
|
||||
# },
|
||||
# "disk_usage": {
|
||||
# "total": 1000000000,
|
||||
# "used": 500000000,
|
||||
# "free": 500000000
|
||||
# }
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get recording files list (with filters)
|
||||
POST http://localhost:8000/storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_name": "camera1",
|
||||
"start_date": "2025-07-25T00:00:00",
|
||||
"end_date": "2025-07-28T23:59:59",
|
||||
"limit": 50
|
||||
}
|
||||
# Request Parameters (all optional):
|
||||
# - camera_name: string - Filter by specific camera
|
||||
# - start_date: string (ISO format) - Filter files from this date
|
||||
# - end_date: string (ISO format) - Filter files until this date
|
||||
# - limit: integer (max 1000, default 100) - Maximum number of files to return
|
||||
#
|
||||
# Response: FileListResponse
|
||||
# {
|
||||
# "files": [
|
||||
# {
|
||||
# "filename": "20250728_120000_test.avi",
|
||||
# "camera_name": "camera1",
|
||||
# "file_size_bytes": 1048576,
|
||||
# "created_date": "2025-07-28T12:00:00",
|
||||
# "duration_seconds": 300.0
|
||||
# }
|
||||
# ],
|
||||
# "total_count": 1
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get all files (no camera filter)
|
||||
POST http://localhost:8000/storage/files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"limit": 100
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Cleanup old storage files
|
||||
POST http://localhost:8000/storage/cleanup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"max_age_days": 7
|
||||
}
|
||||
# Request Parameters:
|
||||
# - max_age_days: integer (optional) - Remove files older than this many days
|
||||
# If not provided, uses config default (30 days)
|
||||
#
|
||||
# Response: CleanupResponse
|
||||
# {
|
||||
# "files_removed": 5,
|
||||
# "bytes_freed": 10485760,
|
||||
# "errors": []
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# ERROR RESPONSES
|
||||
###############################################################################
|
||||
# All endpoints may return ErrorResponse on failure:
|
||||
# {
|
||||
# "error": "Error description",
|
||||
# "details": "Additional error details",
|
||||
# "timestamp": "2025-07-28T12:00:00"
|
||||
# }
|
||||
# Common HTTP status codes:
|
||||
# - 200: Success
|
||||
# - 400: Bad Request (invalid parameters)
|
||||
# - 404: Not Found (camera/resource not found)
|
||||
# - 500: Internal Server Error
|
||||
# - 503: Service Unavailable (camera manager not available)
|
||||
|
||||
###############################################################################
|
||||
# NOTES
|
||||
###############################################################################
|
||||
# 1. All timestamps are in ISO 8601 format
|
||||
# 2. File sizes are in bytes
|
||||
# 3. Camera names: "camera1", "camera2"
|
||||
# 4. Machine names: "vibratory_conveyor", "blower_separator"
|
||||
# 5. FPS behavior:
|
||||
# - fps > 0: Capture at specified frame rate
|
||||
# - fps = 0: Capture at MAXIMUM possible speed (no delay)
|
||||
# - fps omitted: Uses camera config default
|
||||
# 6. Filenames automatically get datetime prefix: YYYYMMDD_HHMMSS_filename.avi
|
||||
# 7. Recovery endpoints should be used in order: test-connection → reconnect → restart-grab → full-reset → reinitialize
|
||||
61
phase_2_experimental_run_sheet.csv
Normal file
61
phase_2_experimental_run_sheet.csv
Normal file
@@ -0,0 +1,61 @@
|
||||
experiment_id,run_number,soaking_duration_hr,air_drying_time_min,plate_contact_frequency_hz,throughput_rate_pecans_sec,crush_amount_in,entry_exit_height_diff_in,reps,rep
|
||||
1,0,34,19,53,28,0.05,-0.09,3,1
|
||||
2,1,24,27,34,29,0.03,0.01,3,3
|
||||
3,12,28,59,37,23,0.06,-0.08,3,1
|
||||
4,15,16,60,30,24,0.07,0.02,3,1
|
||||
5,4,13,41,41,38,0.05,0.03,3,2
|
||||
6,18,18,49,38,35,0.07,-0.08,3,1
|
||||
7,11,24,59,42,25,0.07,-0.05,3,1
|
||||
8,16,20,59,41,14,0.07,0.04,3,1
|
||||
9,4,13,41,41,38,0.05,0.03,3,1
|
||||
10,19,11,25,56,34,0.06,-0.09,3,1
|
||||
11,15,16,60,30,24,0.07,0.02,3,2
|
||||
12,16,20,59,41,14,0.07,0.04,3,3
|
||||
13,10,26,60,44,12,0.08,-0.1,3,2
|
||||
14,1,24,27,34,29,0.03,0.01,3,1
|
||||
15,17,34,60,34,29,0.07,-0.09,3,2
|
||||
16,5,30,33,30,36,0.05,-0.04,3,3
|
||||
17,2,38,10,60,28,0.06,-0.1,3,3
|
||||
18,2,38,10,60,28,0.06,-0.1,3,1
|
||||
19,13,21,59,41,21,0.06,-0.09,3,2
|
||||
20,1,24,27,34,29,0.03,0.01,3,2
|
||||
21,14,22,59,45,17,0.07,-0.08,3,2
|
||||
22,6,10,22,37,30,0.06,0.02,3,2
|
||||
23,11,24,59,42,25,0.07,-0.05,3,2
|
||||
24,19,11,25,56,34,0.06,-0.09,3,2
|
||||
25,8,27,12,55,24,0.04,0.04,3,2
|
||||
26,18,18,49,38,35,0.07,-0.08,3,3
|
||||
27,5,30,33,30,36,0.05,-0.04,3,1
|
||||
28,9,32,26,47,26,0.07,0.03,3,1
|
||||
29,3,11,36,42,13,0.07,-0.07,3,1
|
||||
30,10,26,60,44,12,0.08,-0.1,3,1
|
||||
31,8,27,12,55,24,0.04,0.04,3,3
|
||||
32,5,30,33,30,36,0.05,-0.04,3,2
|
||||
33,8,27,12,55,24,0.04,0.04,3,1
|
||||
34,18,18,49,38,35,0.07,-0.08,3,2
|
||||
35,3,11,36,42,13,0.07,-0.07,3,3
|
||||
36,10,26,60,44,12,0.08,-0.1,3,3
|
||||
37,17,34,60,34,29,0.07,-0.09,3,3
|
||||
38,13,21,59,41,21,0.06,-0.09,3,3
|
||||
39,12,28,59,37,23,0.06,-0.08,3,2
|
||||
40,9,32,26,47,26,0.07,0.03,3,3
|
||||
41,14,22,59,45,17,0.07,-0.08,3,3
|
||||
42,0,34,19,53,28,0.05,-0.09,3,2
|
||||
43,7,15,30,35,32,0.05,-0.07,3,1
|
||||
44,0,34,19,53,28,0.05,-0.09,3,3
|
||||
45,15,16,60,30,24,0.07,0.02,3,3
|
||||
46,13,21,59,41,21,0.06,-0.09,3,1
|
||||
47,11,24,59,42,25,0.07,-0.05,3,3
|
||||
48,7,15,30,35,32,0.05,-0.07,3,3
|
||||
49,16,20,59,41,14,0.07,0.04,3,2
|
||||
50,3,11,36,42,13,0.07,-0.07,3,2
|
||||
51,7,15,30,35,32,0.05,-0.07,3,2
|
||||
52,6,10,22,37,30,0.06,0.02,3,1
|
||||
53,19,11,25,56,34,0.06,-0.09,3,3
|
||||
54,6,10,22,37,30,0.06,0.02,3,3
|
||||
55,2,38,10,60,28,0.06,-0.1,3,2
|
||||
56,14,22,59,45,17,0.07,-0.08,3,1
|
||||
57,4,13,41,41,38,0.05,0.03,3,3
|
||||
58,9,32,26,47,26,0.07,0.03,3,2
|
||||
59,17,34,60,34,29,0.07,-0.09,3,1
|
||||
60,12,28,59,37,23,0.06,-0.08,3,3
|
||||
|
@@ -39,7 +39,7 @@ export function DashboardHome({ user }: DashboardHomeProps) {
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">Welcome to the RBAC system</p>
|
||||
<p className="mt-2 text-gray-600">Welcome to the Pecan Experiments Dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* User Information Card */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DashboardHome } from './DashboardHome'
|
||||
import { UserManagement } from './UserManagement'
|
||||
import { Experiments } from './Experiments'
|
||||
import { DataEntry } from './DataEntry'
|
||||
import { VisionSystem } from './VisionSystem'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
@@ -81,6 +82,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
)
|
||||
case 'data-entry':
|
||||
return <DataEntry />
|
||||
case 'vision-system':
|
||||
return <VisionSystem />
|
||||
default:
|
||||
return <DashboardHome user={user} />
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { experimentManagement, userManagement, type Experiment, type User } from '../lib/supabase'
|
||||
import { DataEntryInterface } from './DataEntryInterface'
|
||||
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 [selectedExperiment, setSelectedExperiment] = useState<Experiment | null>(null)
|
||||
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)
|
||||
@@ -25,6 +26,19 @@ export function DataEntry() {
|
||||
|
||||
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)
|
||||
@@ -33,12 +47,49 @@ export function DataEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExperimentSelect = (experiment: Experiment) => {
|
||||
setSelectedExperiment(experiment)
|
||||
const handleRepetitionSelect = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSelectedRepetition({ experiment, repetition })
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
setSelectedExperiment(null)
|
||||
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) {
|
||||
@@ -64,10 +115,11 @@ export function DataEntry() {
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedExperiment) {
|
||||
if (selectedRepetition) {
|
||||
return (
|
||||
<DataEntryInterface
|
||||
experiment={selectedExperiment}
|
||||
<RepetitionDataEntryInterface
|
||||
experiment={selectedRepetition.experiment}
|
||||
repetition={selectedRepetition.repetition}
|
||||
currentUser={currentUser!}
|
||||
onBack={handleBackToList}
|
||||
/>
|
||||
@@ -79,76 +131,197 @@ export function DataEntry() {
|
||||
<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 an experiment to enter measurement data
|
||||
Select a repetition to enter measurement data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Experiments List */}
|
||||
<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">
|
||||
Available Experiments ({experiments.length})
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Click on any experiment to start entering data
|
||||
</p>
|
||||
</div>
|
||||
{/* Repetitions organized by status - flat list */}
|
||||
{(() => {
|
||||
const { past: pastRepetitions, inProgress: inProgressRepetitions, upcoming: upcomingRepetitions } = categorizeRepetitions()
|
||||
|
||||
{experiments.length === 0 ? (
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<div className="text-center text-gray-500">
|
||||
No experiments available for data entry
|
||||
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>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{experiments.map((experiment) => (
|
||||
<li key={experiment.id}>
|
||||
<button
|
||||
onClick={() => handleExperimentSelect(experiment)}
|
||||
className="w-full text-left px-4 py-4 hover:bg-gray-50 focus:outline-none focus:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Experiment #{experiment.experiment_number}
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${experiment.completion_status
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>Reps: {experiment.reps_required}</div>
|
||||
<div>Soaking: {experiment.soaking_duration_hr}h</div>
|
||||
<div>Drying: {experiment.air_drying_time_min}min</div>
|
||||
<div>Status: {experiment.schedule_status}</div>
|
||||
</div>
|
||||
{experiment.scheduled_date && (
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
Scheduled: {new Date(experiment.scheduled_date).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<svg className="h-5 w-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>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type User, type ExperimentPhase } from '../lib/supabase'
|
||||
import { type Experiment, type User, type ExperimentPhase } from '../lib/supabase'
|
||||
import { DraftManager } from './DraftManager'
|
||||
import { PhaseSelector } from './PhaseSelector'
|
||||
import { PhaseDataEntry } from './PhaseDataEntry'
|
||||
|
||||
// DEPRECATED: This component is deprecated in favor of RepetitionDataEntryInterface
|
||||
// which uses the new phase-specific draft system
|
||||
|
||||
interface DataEntryInterfaceProps {
|
||||
experiment: Experiment
|
||||
@@ -10,9 +12,21 @@ interface DataEntryInterfaceProps {
|
||||
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<ExperimentDataEntry[]>([])
|
||||
const [selectedDataEntry, setSelectedDataEntry] = useState<ExperimentDataEntry | null>(null)
|
||||
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)
|
||||
@@ -27,7 +41,8 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const entries = await dataEntryManagement.getUserDataEntriesForExperiment(experiment.id)
|
||||
// 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
|
||||
@@ -47,58 +62,21 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
|
||||
}
|
||||
|
||||
const handleCreateNewDraft = async () => {
|
||||
try {
|
||||
const newEntry = await dataEntryManagement.createDataEntry({
|
||||
experiment_id: experiment.id,
|
||||
entry_name: `Draft ${new Date().toLocaleString()}`,
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
setUserDataEntries(prev => [newEntry, ...prev])
|
||||
setSelectedDataEntry(newEntry)
|
||||
setShowDraftManager(false)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create new draft')
|
||||
}
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handleSelectDataEntry = (entry: ExperimentDataEntry) => {
|
||||
const handleSelectDataEntry = (entry: LegacyDataEntry) => {
|
||||
setSelectedDataEntry(entry)
|
||||
setShowDraftManager(false)
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
const handleDeleteDraft = async (entryId: string) => {
|
||||
try {
|
||||
await dataEntryManagement.deleteDataEntry(entryId)
|
||||
setUserDataEntries(prev => prev.filter(entry => entry.id !== entryId))
|
||||
|
||||
// If we deleted the currently selected entry, select another or create new
|
||||
if (selectedDataEntry?.id === entryId) {
|
||||
const remainingDrafts = userDataEntries.filter(entry => entry.id !== entryId && entry.status === 'draft')
|
||||
if (remainingDrafts.length > 0) {
|
||||
setSelectedDataEntry(remainingDrafts[0])
|
||||
} else {
|
||||
await handleCreateNewDraft()
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete draft')
|
||||
}
|
||||
const handleDeleteDraft = async (_entryId: string) => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handleSubmitEntry = async (entryId: string) => {
|
||||
try {
|
||||
const submittedEntry = await dataEntryManagement.submitDataEntry(entryId)
|
||||
setUserDataEntries(prev => prev.map(entry =>
|
||||
entry.id === entryId ? submittedEntry : entry
|
||||
))
|
||||
|
||||
// Create a new draft for continued work
|
||||
await handleCreateNewDraft()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to submit entry')
|
||||
}
|
||||
const handleSubmitEntry = async (_entryId: string) => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handlePhaseSelect = (phase: ExperimentPhase) => {
|
||||
@@ -195,14 +173,7 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{experiment.scheduled_date && (
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="font-medium text-gray-700">Scheduled:</span>
|
||||
<span className="ml-1 text-gray-900">
|
||||
{new Date(experiment.scheduled_date).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Scheduled date removed - this is now handled at repetition level */}
|
||||
</div>
|
||||
|
||||
{/* Current Draft Info */}
|
||||
@@ -237,17 +208,12 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
|
||||
onCreateNew={handleCreateNewDraft}
|
||||
onClose={() => setShowDraftManager(false)}
|
||||
/>
|
||||
) : selectedPhase && selectedDataEntry ? (
|
||||
<PhaseDataEntry
|
||||
experiment={experiment}
|
||||
dataEntry={selectedDataEntry}
|
||||
phase={selectedPhase}
|
||||
onBack={handleBackToPhases}
|
||||
onDataSaved={() => {
|
||||
// Refresh data entries to show updated timestamps
|
||||
loadUserDataEntries()
|
||||
}}
|
||||
/>
|
||||
) : 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}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { type ExperimentDataEntry } from '../lib/supabase'
|
||||
// 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: ExperimentDataEntry[]
|
||||
selectedDataEntry: ExperimentDataEntry | null
|
||||
onSelectEntry: (entry: ExperimentDataEntry) => void
|
||||
userDataEntries: LegacyDataEntry[]
|
||||
selectedDataEntry: LegacyDataEntry | null
|
||||
onSelectEntry: (entry: LegacyDataEntry) => void
|
||||
onDeleteDraft: (entryId: string) => void
|
||||
onCreateNew: () => void
|
||||
onClose: () => void
|
||||
@@ -64,11 +76,10 @@ export function DraftManager({
|
||||
{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'
|
||||
}`}
|
||||
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">
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExperimentModal } from './ExperimentModal'
|
||||
import { ScheduleModal } from './ScheduleModal'
|
||||
import { experimentManagement, userManagement } from '../lib/supabase'
|
||||
import type { Experiment, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||
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 [filterStatus, setFilterStatus] = useState<ScheduleStatus | 'all'>('all')
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||
const [schedulingExperiment, setSchedulingExperiment] = useState<Experiment | undefined>(undefined)
|
||||
|
||||
const [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false)
|
||||
const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -31,6 +32,19 @@ export function Experiments() {
|
||||
|
||||
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)
|
||||
@@ -51,29 +65,60 @@ export function Experiments() {
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleExperimentSaved = (experiment: Experiment) => {
|
||||
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
|
||||
// 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 handleScheduleExperiment = (experiment: Experiment) => {
|
||||
setSchedulingExperiment(experiment)
|
||||
setShowScheduleModal(true)
|
||||
const handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSchedulingRepetition({ experiment, repetition })
|
||||
setShowRepetitionScheduleModal(true)
|
||||
}
|
||||
|
||||
const handleScheduleUpdated = (updatedExperiment: Experiment) => {
|
||||
setExperiments(prev => prev.map(exp =>
|
||||
exp.id === updatedExperiment.id ? updatedExperiment : exp
|
||||
))
|
||||
setShowScheduleModal(false)
|
||||
setSchedulingExperiment(undefined)
|
||||
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) => {
|
||||
@@ -95,18 +140,12 @@ export function Experiments() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusUpdate = async (experiment: Experiment, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus) => {
|
||||
try {
|
||||
const updatedExperiment = await experimentManagement.updateExperimentStatus(
|
||||
experiment.id,
|
||||
scheduleStatus,
|
||||
resultsStatus
|
||||
)
|
||||
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? updatedExperiment : exp))
|
||||
} catch (err: any) {
|
||||
alert(`Failed to update status: ${err.message}`)
|
||||
console.error('Update status 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) => {
|
||||
@@ -128,9 +167,8 @@ export function Experiments() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredExperiments = filterStatus === 'all'
|
||||
? experiments
|
||||
: experiments.filter(exp => exp.schedule_status === filterStatus)
|
||||
// Remove filtering for now since experiments don't have schedule_status anymore
|
||||
const filteredExperiments = experiments
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -150,6 +188,7 @@ export function Experiments() {
|
||||
<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
|
||||
@@ -169,26 +208,7 @@ export function Experiments() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label htmlFor="status-filter" className="text-sm font-medium text-gray-700">
|
||||
Filter by Schedule Status:
|
||||
</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as ScheduleStatus | 'all')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<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>
|
||||
|
||||
|
||||
{/* Experiments Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
@@ -214,11 +234,11 @@ export function Experiments() {
|
||||
Experiment Parameters
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Schedule Status
|
||||
Repetitions Status
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Scheduled Date/Time
|
||||
Manage Repetitions
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
@@ -258,30 +278,76 @@ export function Experiments() {
|
||||
</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.schedule_status)}`}>
|
||||
{experiment.schedule_status}
|
||||
</span>
|
||||
{(() => {
|
||||
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="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleScheduleExperiment(experiment)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title="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>
|
||||
{experiment.scheduled_date && (
|
||||
<span className="text-xs">
|
||||
{new Date(experiment.scheduled_date).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
@@ -292,8 +358,8 @@ export function Experiments() {
|
||||
</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'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
@@ -340,11 +406,9 @@ export function Experiments() {
|
||||
</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">
|
||||
{filterStatus === 'all'
|
||||
? 'Get started by creating your first experiment.'
|
||||
: `No experiments with status "${filterStatus}".`}
|
||||
Get started by creating your first experiment.
|
||||
</p>
|
||||
{canManageExperiments && filterStatus === 'all' && (
|
||||
{canManageExperiments && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
@@ -367,12 +431,13 @@ export function Experiments() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Schedule Modal */}
|
||||
{showScheduleModal && schedulingExperiment && (
|
||||
<ScheduleModal
|
||||
experiment={schedulingExperiment}
|
||||
onClose={() => setShowScheduleModal(false)}
|
||||
onScheduleUpdated={handleScheduleUpdated}
|
||||
{/* Repetition Schedule Modal */}
|
||||
{showRepetitionScheduleModal && schedulingRepetition && (
|
||||
<RepetitionScheduleModal
|
||||
experiment={schedulingRepetition.experiment}
|
||||
repetition={schedulingRepetition.repetition}
|
||||
onClose={() => setShowRepetitionScheduleModal(false)}
|
||||
onScheduleUpdated={handleRepetitionScheduleUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
|
||||
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
|
||||
dataEntry: ExperimentDataEntry
|
||||
repetition: ExperimentRepetition
|
||||
phase: ExperimentPhase
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
onDataSaved: () => void
|
||||
}
|
||||
|
||||
export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSaved }: PhaseDataEntryProps) {
|
||||
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
|
||||
|
||||
const loadPhaseData = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
loadUserDrafts()
|
||||
}, [repetition.id, phase])
|
||||
|
||||
const loadUserDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const existingData = await dataEntryManagement.getPhaseData(dataEntry.id, phase)
|
||||
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)
|
||||
@@ -43,33 +74,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
} else {
|
||||
// Initialize empty phase data
|
||||
setPhaseData({
|
||||
data_entry_id: dataEntry.id,
|
||||
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)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dataEntry.id, phase])
|
||||
}
|
||||
|
||||
const autoSave = useCallback(async () => {
|
||||
if (dataEntry.status === 'submitted') return // Don't auto-save submitted entries
|
||||
if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts
|
||||
|
||||
try {
|
||||
await dataEntryManagement.autoSaveDraft(dataEntry.id, phase, phaseData)
|
||||
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 dataEntryManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
|
||||
await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
|
||||
|
||||
// Update average diameter
|
||||
const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements)
|
||||
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
|
||||
setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter }))
|
||||
}
|
||||
}
|
||||
@@ -78,22 +108,18 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
}
|
||||
}, [dataEntry.id, dataEntry.status, phase, phaseData, diameterMeasurements])
|
||||
|
||||
useEffect(() => {
|
||||
loadPhaseData()
|
||||
}, [loadPhaseData])
|
||||
}, [selectedDraft, phase, phaseData, diameterMeasurements])
|
||||
|
||||
// Auto-save effect
|
||||
useEffect(() => {
|
||||
if (!loading && phaseData.id) {
|
||||
if (!loading && selectedDraft && phaseData.phase_draft_id) {
|
||||
const interval = setInterval(() => {
|
||||
autoSave()
|
||||
}, AUTO_SAVE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [phaseData, diameterMeasurements, loading, autoSave])
|
||||
}, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft])
|
||||
|
||||
const handleInputChange = (field: string, value: unknown) => {
|
||||
setPhaseData(prev => ({
|
||||
@@ -110,23 +136,25 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
// Calculate and update average
|
||||
const validMeasurements = newMeasurements.filter(m => m > 0)
|
||||
if (validMeasurements.length > 0) {
|
||||
const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements)
|
||||
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 dataEntryManagement.upsertPhaseData(dataEntry.id, phase, phaseData)
|
||||
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 dataEntryManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
|
||||
await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
|
||||
}
|
||||
|
||||
setLastSaved(new Date())
|
||||
@@ -140,6 +168,20 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -181,6 +223,17 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
|
||||
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">
|
||||
@@ -195,8 +248,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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()}
|
||||
@@ -204,7 +281,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || dataEntry.status === 'submitted'}
|
||||
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'}
|
||||
@@ -218,10 +295,42 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataEntry.status === 'submitted' && (
|
||||
{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 entry has been submitted and is read-only. Create a new draft to make changes.
|
||||
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>
|
||||
)}
|
||||
@@ -246,7 +355,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +372,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -280,7 +389,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -293,7 +402,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,7 +441,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -348,7 +457,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -365,7 +474,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -382,7 +491,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,7 +531,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -440,7 +549,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated from individual measurements above
|
||||
@@ -464,7 +573,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,7 +645,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +666,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -572,7 +681,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -587,7 +696,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -602,7 +711,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -624,7 +733,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -639,7 +748,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -654,7 +763,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -676,7 +785,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -691,7 +800,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -706,7 +815,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
|
||||
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={dataEntry.status === 'submitted'}
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
276
src/components/PhaseDraftManager.tsx
Normal file
276
src/components/PhaseDraftManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { dataEntryManagement, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
|
||||
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: ExperimentDataEntry
|
||||
dataEntry: LegacyDataEntry
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
@@ -61,7 +76,8 @@ export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps)
|
||||
const loadPhaseData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const allPhaseData = await dataEntryManagement.getPhaseDataForEntry(dataEntry.id)
|
||||
// DEPRECATED: Using empty array since this component is deprecated
|
||||
const allPhaseData: ExperimentPhaseData[] = []
|
||||
|
||||
const phaseDataMap: Record<ExperimentPhase, ExperimentPhaseData | null> = {
|
||||
'pre-soaking': null,
|
||||
|
||||
115
src/components/RepetitionDataEntryInterface.tsx
Normal file
115
src/components/RepetitionDataEntryInterface.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/components/RepetitionLockManager.tsx
Normal file
124
src/components/RepetitionLockManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
223
src/components/RepetitionPhaseSelector.tsx
Normal file
223
src/components/RepetitionPhaseSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
src/components/RepetitionScheduleModal.tsx
Normal file
208
src/components/RepetitionScheduleModal.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
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 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Schedule Repetition
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
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">
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -67,6 +67,15 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor', 'data recorder']
|
||||
},
|
||||
{
|
||||
id: 'vision-system',
|
||||
name: 'Vision System',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" 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>
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
@@ -82,7 +91,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">RBAC System</h1>
|
||||
<h1 className="text-xl font-bold text-white">Pecan Experiments</h1>
|
||||
<p className="text-sm text-slate-400">Admin Dashboard</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,8 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
|
||||
return 'Analytics'
|
||||
case 'data-entry':
|
||||
return 'Data Entry'
|
||||
case 'vision-system':
|
||||
return 'Vision System'
|
||||
default:
|
||||
return 'Dashboard'
|
||||
}
|
||||
|
||||
735
src/components/VisionSystem.tsx
Normal file
735
src/components/VisionSystem.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import { useState, useEffect, useRef, useCallback } 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'
|
||||
|
||||
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[]>([])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only update state if data has actually changed to prevent unnecessary re-renders
|
||||
setSystemStatus(prevStatus => {
|
||||
if (JSON.stringify(prevStatus) !== JSON.stringify(statusData)) {
|
||||
return statusData
|
||||
}
|
||||
return prevStatus
|
||||
})
|
||||
|
||||
setStorageStats(prevStats => {
|
||||
if (JSON.stringify(prevStats) !== JSON.stringify(storageData)) {
|
||||
return storageData
|
||||
}
|
||||
return prevStats
|
||||
})
|
||||
|
||||
setRecordings(prevRecordings => {
|
||||
if (JSON.stringify(prevRecordings) !== JSON.stringify(recordingsData)) {
|
||||
return recordingsData
|
||||
}
|
||||
return prevRecordings
|
||||
})
|
||||
|
||||
setLastUpdateTime(new Date())
|
||||
|
||||
// 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])
|
||||
|
||||
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">
|
||||
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</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 && (
|
||||
<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 justify-between">
|
||||
<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>
|
||||
{systemStatus.mqtt_connected && (
|
||||
<div className="ml-3 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-600">Live</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mqttStatus && (
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div>{mqttStatus.message_count} messages</div>
|
||||
<div>{mqttStatus.error_count} errors</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">MQTT</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{mqttStatus ? (
|
||||
<div>
|
||||
<div>Broker: {mqttStatus.broker_host}:{mqttStatus.broker_port}</div>
|
||||
<div>Last message: {new Date(mqttStatus.last_message_time).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>Last message: {new Date(systemStatus.last_mqtt_message).toLocaleTimeString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MQTT Events History */}
|
||||
{mqttEvents.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">Recent Events</h4>
|
||||
<span className="text-xs text-gray-500">{mqttEvents.length} events</span>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-2">
|
||||
{mqttEvents.map((event, index) => (
|
||||
<div key={`${event.timestamp}-${event.message_number}`} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<span className="text-gray-500 font-mono w-12 flex-shrink-0">
|
||||
{new Date(event.timestamp).toLocaleTimeString().slice(-8, -3)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 flex-shrink-0">
|
||||
{event.machine_name.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-gray-900 font-medium truncate">
|
||||
{event.payload}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400 ml-2 flex-shrink-0">#{event.message_number}</span>
|
||||
</div>
|
||||
))}
|
||||
</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="text-3xl font-bold text-indigo-600">
|
||||
{systemStatus.active_recordings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-lg font-medium text-gray-900">Active 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="text-3xl font-bold text-purple-600">
|
||||
{Object.keys(systemStatus.cameras).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-lg font-medium text-gray-900">Cameras</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Machines: {Object.keys(systemStatus.machines).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Cameras Status */}
|
||||
{systemStatus && (
|
||||
<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]) => {
|
||||
// Debug logging to see what data we're getting
|
||||
console.log(`Camera ${cameraName} data:`, JSON.stringify(camera, null, 2))
|
||||
|
||||
const friendlyName = camera.device_info?.friendly_name
|
||||
const hasDeviceInfo = !!camera.device_info
|
||||
const hasSerial = !!camera.device_info?.serial_number
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{friendlyName ? (
|
||||
<div>
|
||||
<div className="text-lg">{friendlyName}</div>
|
||||
<div className="text-sm text-gray-600 font-normal">({cameraName})</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-lg">{cameraName}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{hasDeviceInfo ? 'Device info available but no friendly name' : 'No device info available'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(camera.status, camera.is_recording)}`}>
|
||||
{camera.is_recording ? 'Recording' : camera.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording:</span>
|
||||
<span className={`font-medium ${camera.is_recording ? 'text-red-600' : 'text-gray-900'}`}>
|
||||
{camera.is_recording ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{camera.device_info?.serial_number && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Serial:</span>
|
||||
<span className="text-gray-900">{camera.device_info.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info - remove this after fixing */}
|
||||
<div className="mt-2 p-2 bg-gray-50 border border-gray-200 rounded text-xs">
|
||||
<div className="font-medium text-gray-700 mb-1">Debug Info:</div>
|
||||
<div className="text-gray-600">
|
||||
<div>Has device_info: {hasDeviceInfo ? 'Yes' : 'No'}</div>
|
||||
<div>Has friendly_name: {friendlyName ? 'Yes' : 'No'}</div>
|
||||
<div>Has serial: {hasSerial ? 'Yes' : 'No'}</div>
|
||||
<div>Last error: {camera.last_error || 'None'}</div>
|
||||
{camera.device_info && (
|
||||
<div className="mt-1">
|
||||
<div>Raw device_info: {JSON.stringify(camera.device_info)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<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]) => {
|
||||
// Find the corresponding camera to get friendly name
|
||||
const camera = systemStatus?.cameras[cameraName]
|
||||
const displayName = camera?.device_info?.friendly_name || cameraName
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">
|
||||
{camera?.device_info?.friendly_name ? (
|
||||
<>
|
||||
{displayName}
|
||||
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
|
||||
</>
|
||||
) : (
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Recent Recordings */}
|
||||
{Object.keys(recordings).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">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]) => {
|
||||
// Find the corresponding camera to get friendly name
|
||||
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 font-medium text-gray-900">
|
||||
{camera?.device_info?.friendly_name ? (
|
||||
<div>
|
||||
<div>{displayName}</div>
|
||||
<div className="text-xs text-gray-500">({recording.camera_name})</div>
|
||||
</div>
|
||||
) : (
|
||||
recording.camera_name
|
||||
)}
|
||||
</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 ${getStatusColor(recording.state)}`}>
|
||||
{recording.state}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,15 +38,15 @@ export interface Experiment {
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
schedule_status: ScheduleStatus
|
||||
results_status: ResultsStatus
|
||||
completion_status: boolean
|
||||
scheduled_date?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
@@ -56,10 +56,8 @@ export interface CreateExperimentRequest {
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
schedule_status?: ScheduleStatus
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
scheduled_date?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
@@ -71,25 +69,54 @@ export interface UpdateExperimentRequest {
|
||||
throughput_rate_pecans_sec?: number
|
||||
crush_amount_in?: number
|
||||
entry_exit_height_diff_in?: number
|
||||
schedule_status?: ScheduleStatus
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
}
|
||||
|
||||
export interface UpdateRepetitionRequest {
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
// Data Entry System Interfaces
|
||||
export type DataEntryStatus = 'draft' | 'submitted'
|
||||
export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
|
||||
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
|
||||
|
||||
export interface ExperimentDataEntry {
|
||||
export interface ExperimentPhaseDraft {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
user_id: string
|
||||
status: DataEntryStatus
|
||||
entry_name?: string | null
|
||||
phase_name: ExperimentPhase
|
||||
status: PhaseDraftStatus
|
||||
draft_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
withdrawn_at?: string | null
|
||||
}
|
||||
|
||||
export interface ExperimentRepetition {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status: ScheduleStatus
|
||||
completion_status: boolean
|
||||
is_locked: boolean
|
||||
locked_at?: string | null
|
||||
locked_by?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface PecanDiameterMeasurement {
|
||||
@@ -102,7 +129,7 @@ export interface PecanDiameterMeasurement {
|
||||
|
||||
export interface ExperimentPhaseData {
|
||||
id: string
|
||||
data_entry_id: string
|
||||
phase_draft_id: string
|
||||
phase_name: ExperimentPhase
|
||||
|
||||
// Pre-soaking phase
|
||||
@@ -141,15 +168,17 @@ export interface ExperimentPhaseData {
|
||||
diameter_measurements?: PecanDiameterMeasurement[]
|
||||
}
|
||||
|
||||
export interface CreateDataEntryRequest {
|
||||
export interface CreatePhaseDraftRequest {
|
||||
experiment_id: string
|
||||
entry_name?: string
|
||||
status?: DataEntryStatus
|
||||
repetition_id: string
|
||||
phase_name: ExperimentPhase
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface UpdateDataEntryRequest {
|
||||
entry_name?: string
|
||||
status?: DataEntryStatus
|
||||
export interface UpdatePhaseDraftRequest {
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface CreatePhaseDataRequest {
|
||||
@@ -440,25 +469,7 @@ export const experimentManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Schedule an experiment
|
||||
async scheduleExperiment(id: string, scheduledDate: string): Promise<Experiment> {
|
||||
const updates: UpdateExperimentRequest = {
|
||||
scheduled_date: scheduledDate,
|
||||
schedule_status: 'scheduled'
|
||||
}
|
||||
|
||||
return this.updateExperiment(id, updates)
|
||||
},
|
||||
|
||||
// Remove experiment schedule
|
||||
async removeExperimentSchedule(id: string): Promise<Experiment> {
|
||||
const updates: UpdateExperimentRequest = {
|
||||
scheduled_date: null,
|
||||
schedule_status: 'pending schedule'
|
||||
}
|
||||
|
||||
return this.updateExperiment(id, updates)
|
||||
},
|
||||
|
||||
// Check if experiment number is unique
|
||||
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
|
||||
@@ -478,45 +489,237 @@ export const experimentManagement = {
|
||||
}
|
||||
}
|
||||
|
||||
// Data Entry Management
|
||||
export const dataEntryManagement = {
|
||||
// Get all data entries for an experiment
|
||||
async getDataEntriesForExperiment(experimentId: string): Promise<ExperimentDataEntry[]> {
|
||||
// Experiment Repetitions Management
|
||||
export const repetitionManagement = {
|
||||
// Get all repetitions for an experiment
|
||||
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_data_entries')
|
||||
.from('experiment_repetitions')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.order('repetition_number', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new repetition
|
||||
async createRepetition(repetitionData: CreateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert({
|
||||
...repetitionData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a repetition
|
||||
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Schedule a repetition
|
||||
async scheduleRepetition(id: string, scheduledDate: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: scheduledDate,
|
||||
schedule_status: 'scheduled'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Remove repetition schedule
|
||||
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: null,
|
||||
schedule_status: 'pending schedule'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Delete a repetition
|
||||
async deleteRepetition(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get repetitions by status
|
||||
async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise<ExperimentRepetition[]> {
|
||||
let query = supabase.from('experiment_repetitions').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get repetitions with experiment details
|
||||
async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select(`
|
||||
*,
|
||||
experiment:experiments(*)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's data entries for an experiment
|
||||
async getUserDataEntriesForExperiment(experimentId: string, userId?: string): Promise<ExperimentDataEntry[]> {
|
||||
// Create all repetitions for an experiment
|
||||
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
// First get the experiment to know how many reps are required
|
||||
const { data: experiment, error: expError } = await supabase
|
||||
.from('experiments')
|
||||
.select('reps_required')
|
||||
.eq('id', experimentId)
|
||||
.single()
|
||||
|
||||
if (expError) throw expError
|
||||
|
||||
// Create repetitions for each required rep
|
||||
const repetitions: CreateRepetitionRequest[] = []
|
||||
for (let i = 1; i <= experiment.reps_required; i++) {
|
||||
repetitions.push({
|
||||
experiment_id: experimentId,
|
||||
repetition_number: i,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const targetUserId = userId || user.id
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert(repetitions.map(rep => ({
|
||||
...rep,
|
||||
created_by: user.id
|
||||
})))
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Lock a repetition (admin only)
|
||||
async lockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_data_entries')
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: true,
|
||||
locked_at: new Date().toISOString(),
|
||||
locked_by: user.id
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Unlock a repetition (admin only)
|
||||
async unlockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: false,
|
||||
locked_at: null,
|
||||
locked_by: null
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Phase Draft Management
|
||||
export const phaseDraftManagement = {
|
||||
// Get all phase drafts for a repetition
|
||||
async getPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.eq('user_id', targetUserId)
|
||||
.eq('repetition_id', repetitionId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new data entry
|
||||
async createDataEntry(request: CreateDataEntryRequest): Promise<ExperimentDataEntry> {
|
||||
// Get user's phase drafts for a repetition
|
||||
async getUserPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_data_entries')
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a specific phase and repetition
|
||||
async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('phase_name', phase)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new phase draft
|
||||
async createPhaseDraft(request: CreatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.insert({
|
||||
...request,
|
||||
user_id: user.id
|
||||
@@ -528,10 +731,10 @@ export const dataEntryManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a data entry
|
||||
async updateDataEntry(id: string, updates: UpdateDataEntryRequest): Promise<ExperimentDataEntry> {
|
||||
// Update a phase draft
|
||||
async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_data_entries')
|
||||
.from('experiment_phase_drafts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
@@ -541,65 +744,53 @@ export const dataEntryManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete a data entry (only drafts)
|
||||
async deleteDataEntry(id: string): Promise<void> {
|
||||
// Delete a phase draft (only drafts)
|
||||
async deletePhaseDraft(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_data_entries')
|
||||
.from('experiment_phase_drafts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Submit a data entry (change status from draft to submitted)
|
||||
async submitDataEntry(id: string): Promise<ExperimentDataEntry> {
|
||||
return this.updateDataEntry(id, { status: 'submitted' })
|
||||
// Submit a phase draft (change status from draft to submitted)
|
||||
async submitPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'submitted' })
|
||||
},
|
||||
|
||||
// Get phase data for a data entry
|
||||
async getPhaseDataForEntry(dataEntryId: string): Promise<ExperimentPhaseData[]> {
|
||||
// Withdraw a phase draft (change status from submitted to withdrawn)
|
||||
async withdrawPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'withdrawn' })
|
||||
},
|
||||
|
||||
// Get phase data for a phase draft
|
||||
async getPhaseDataForDraft(phaseDraftId: string): Promise<ExperimentPhaseData | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.select(`
|
||||
*,
|
||||
diameter_measurements:pecan_diameter_measurements(*)
|
||||
`)
|
||||
.eq('data_entry_id', dataEntryId)
|
||||
.order('phase_name')
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get specific phase data
|
||||
async getPhaseData(dataEntryId: string, phaseName: ExperimentPhase): Promise<ExperimentPhaseData | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.select(`
|
||||
*,
|
||||
diameter_measurements:pecan_diameter_measurements(*)
|
||||
`)
|
||||
.eq('data_entry_id', dataEntryId)
|
||||
.eq('phase_name', phaseName)
|
||||
.eq('phase_draft_id', phaseDraftId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
if (error.code === 'PGRST116') return null // No rows found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create or update phase data
|
||||
async upsertPhaseData(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
|
||||
// Create or update phase data for a draft
|
||||
async upsertPhaseData(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.upsert({
|
||||
data_entry_id: dataEntryId,
|
||||
phase_name: phaseName,
|
||||
phase_draft_id: phaseDraftId,
|
||||
...phaseData
|
||||
}, {
|
||||
onConflict: 'data_entry_id,phase_name'
|
||||
onConflict: 'phase_draft_id,phase_name'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -641,9 +832,9 @@ export const dataEntryManagement = {
|
||||
},
|
||||
|
||||
// Auto-save draft data (for periodic saves)
|
||||
async autoSaveDraft(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
|
||||
async autoSaveDraft(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
|
||||
try {
|
||||
await this.upsertPhaseData(dataEntryId, phaseName, phaseData)
|
||||
await this.upsertPhaseData(phaseDraftId, phaseData)
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
// Don't throw error for auto-save failures
|
||||
|
||||
336
src/lib/visionApi.ts
Normal file
336
src/lib/visionApi.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// Vision System API Client
|
||||
// Base URL for the vision system API
|
||||
const VISION_API_BASE_URL = 'http://localhost:8000'
|
||||
|
||||
// Types based on the API documentation
|
||||
export interface SystemStatus {
|
||||
system_started: boolean
|
||||
mqtt_connected: boolean
|
||||
last_mqtt_message: string
|
||||
machines: Record<string, MachineStatus>
|
||||
cameras: Record<string, CameraStatus>
|
||||
active_recordings: number
|
||||
total_recordings: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MachineStatus {
|
||||
name: string
|
||||
state: string
|
||||
last_updated: string
|
||||
last_message?: string
|
||||
mqtt_topic?: string
|
||||
}
|
||||
|
||||
export interface CameraStatus {
|
||||
name: string
|
||||
status: string
|
||||
is_recording: boolean
|
||||
last_checked: string
|
||||
last_error: string | null
|
||||
device_info?: {
|
||||
friendly_name: string
|
||||
serial_number: string
|
||||
}
|
||||
current_recording_file: string | null
|
||||
recording_start_time: string | null
|
||||
}
|
||||
|
||||
export interface RecordingInfo {
|
||||
camera_name: string
|
||||
filename: string
|
||||
start_time: string
|
||||
state: string
|
||||
end_time?: string
|
||||
file_size_bytes?: number
|
||||
frame_count?: number
|
||||
duration_seconds?: number
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
base_path: string
|
||||
total_files: number
|
||||
total_size_bytes: number
|
||||
cameras: Record<string, {
|
||||
file_count: number
|
||||
total_size_bytes: number
|
||||
}>
|
||||
disk_usage: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecordingFile {
|
||||
filename: string
|
||||
camera_name: string
|
||||
file_size_bytes: number
|
||||
created_date: string
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
export interface StartRecordingRequest {
|
||||
filename?: string
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
fps?: number
|
||||
}
|
||||
|
||||
export interface StartRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface StopRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface CameraTestResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface CameraRecoveryResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
operation: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface MqttMessage {
|
||||
timestamp: string
|
||||
topic: string
|
||||
message: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface MqttStatus {
|
||||
connected: boolean
|
||||
broker_host: string
|
||||
broker_port: number
|
||||
subscribed_topics: string[]
|
||||
last_message_time: string
|
||||
message_count: number
|
||||
error_count: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MqttEvent {
|
||||
machine_name: string
|
||||
topic: string
|
||||
payload: string
|
||||
normalized_state: string
|
||||
timestamp: string
|
||||
message_number: number
|
||||
}
|
||||
|
||||
export interface MqttEventsResponse {
|
||||
events: MqttEvent[]
|
||||
total_events: number
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
export interface FileListRequest {
|
||||
camera_name?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
files: RecordingFile[]
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface CleanupRequest {
|
||||
max_age_days?: number
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
files_removed: number
|
||||
bytes_freed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// API Client Class
|
||||
class VisionApiClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = VISION_API_BASE_URL) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// System endpoints
|
||||
async getHealth(): Promise<{ status: string; timestamp: string }> {
|
||||
return this.request('/health')
|
||||
}
|
||||
|
||||
async getSystemStatus(): Promise<SystemStatus> {
|
||||
return this.request('/system/status')
|
||||
}
|
||||
|
||||
// Machine endpoints
|
||||
async getMachines(): Promise<Record<string, MachineStatus>> {
|
||||
return this.request('/machines')
|
||||
}
|
||||
|
||||
// MQTT endpoints
|
||||
async getMqttStatus(): Promise<MqttStatus> {
|
||||
return this.request('/mqtt/status')
|
||||
}
|
||||
|
||||
async getMqttEvents(limit: number = 10): Promise<MqttEventsResponse> {
|
||||
return this.request(`/mqtt/events?limit=${limit}`)
|
||||
}
|
||||
|
||||
// Camera endpoints
|
||||
async getCameras(): Promise<Record<string, CameraStatus>> {
|
||||
return this.request('/cameras')
|
||||
}
|
||||
|
||||
async getCameraStatus(cameraName: string): Promise<CameraStatus> {
|
||||
return this.request(`/cameras/${cameraName}/status`)
|
||||
}
|
||||
|
||||
// Recording control
|
||||
async startRecording(cameraName: string, params: StartRecordingRequest = {}): Promise<StartRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-recording`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
async stopRecording(cameraName: string): Promise<StopRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/stop-recording`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Camera diagnostics
|
||||
async testCameraConnection(cameraName: string): Promise<CameraTestResponse> {
|
||||
return this.request(`/cameras/${cameraName}/test-connection`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async reconnectCamera(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reconnect`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async restartCameraGrab(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/restart-grab`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async resetCameraTimestamp(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reset-timestamp`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async fullCameraReset(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/full-reset`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async reinitializeCamera(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reinitialize`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Recording sessions
|
||||
async getRecordings(): Promise<Record<string, RecordingInfo>> {
|
||||
return this.request('/recordings')
|
||||
}
|
||||
|
||||
// Storage endpoints
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
return this.request('/storage/stats')
|
||||
}
|
||||
|
||||
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
|
||||
return this.request('/storage/files', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupStorage(params: CleanupRequest = {}): Promise<CleanupResponse> {
|
||||
return this.request('/storage/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const visionApi = new VisionApiClient()
|
||||
|
||||
// Utility functions
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`
|
||||
} else {
|
||||
return `${secs}s`
|
||||
}
|
||||
}
|
||||
|
||||
export const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h ${minutes}m`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
} else {
|
||||
return `${minutes}m`
|
||||
}
|
||||
}
|
||||
51
src/test/visionApi.test.ts
Normal file
51
src/test/visionApi.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Simple test file to verify vision API client functionality
|
||||
// This is not a formal test suite, just a manual verification script
|
||||
|
||||
import { visionApi, formatBytes, formatDuration, formatUptime } from '../lib/visionApi'
|
||||
|
||||
// Test utility functions
|
||||
console.log('Testing utility functions:')
|
||||
console.log('formatBytes(1024):', formatBytes(1024)) // Should be "1 KB"
|
||||
console.log('formatBytes(1048576):', formatBytes(1048576)) // Should be "1 MB"
|
||||
console.log('formatDuration(65):', formatDuration(65)) // Should be "1m 5s"
|
||||
console.log('formatUptime(3661):', formatUptime(3661)) // Should be "1h 1m"
|
||||
|
||||
// Test API endpoints (these will fail if vision system is not running)
|
||||
export async function testVisionApi() {
|
||||
try {
|
||||
console.log('Testing vision API endpoints...')
|
||||
|
||||
// Test health endpoint
|
||||
const health = await visionApi.getHealth()
|
||||
console.log('Health check:', health)
|
||||
|
||||
// Test system status
|
||||
const status = await visionApi.getSystemStatus()
|
||||
console.log('System status:', status)
|
||||
|
||||
// Test cameras
|
||||
const cameras = await visionApi.getCameras()
|
||||
console.log('Cameras:', cameras)
|
||||
|
||||
// Test machines
|
||||
const machines = await visionApi.getMachines()
|
||||
console.log('Machines:', machines)
|
||||
|
||||
// Test storage stats
|
||||
const storage = await visionApi.getStorageStats()
|
||||
console.log('Storage stats:', storage)
|
||||
|
||||
// Test recordings
|
||||
const recordings = await visionApi.getRecordings()
|
||||
console.log('Recordings:', recordings)
|
||||
|
||||
console.log('All API tests passed!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('API test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment the line below to run the test when this file is imported
|
||||
// testVisionApi()
|
||||
@@ -1,263 +0,0 @@
|
||||
-- Experiment Data Entry System Migration
|
||||
-- Creates tables for collaborative data entry with draft functionality and phase-based organization
|
||||
|
||||
-- Create experiment_data_entries table for main data entry records
|
||||
CREATE TABLE IF NOT EXISTS public.experiment_data_entries (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted')),
|
||||
entry_name TEXT, -- Optional name for the entry (e.g., "Morning Run", "Batch A")
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted'
|
||||
|
||||
-- Constraint: Only one submitted entry per user per experiment
|
||||
CONSTRAINT unique_submitted_entry_per_user_experiment
|
||||
EXCLUDE (experiment_id WITH =, user_id WITH =)
|
||||
WHERE (status = 'submitted')
|
||||
);
|
||||
|
||||
-- Create experiment_phase_data table for phase-specific measurements
|
||||
CREATE TABLE IF NOT EXISTS public.experiment_phase_data (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
data_entry_id UUID NOT NULL REFERENCES public.experiment_data_entries(id) ON DELETE CASCADE,
|
||||
phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
|
||||
|
||||
-- Pre-soaking phase data
|
||||
batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0),
|
||||
initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100),
|
||||
initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100),
|
||||
soaking_start_time TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Air-drying phase data
|
||||
airdrying_start_time TIMESTAMP WITH TIME ZONE,
|
||||
post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0),
|
||||
post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100),
|
||||
post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100),
|
||||
avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0),
|
||||
|
||||
-- Cracking phase data
|
||||
cracking_start_time TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Shelling phase data
|
||||
shelling_start_time TIMESTAMP WITH TIME ZONE,
|
||||
bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0),
|
||||
bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0),
|
||||
bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0),
|
||||
discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0),
|
||||
bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0),
|
||||
bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0),
|
||||
bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0),
|
||||
bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0),
|
||||
bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0),
|
||||
bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0),
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Constraint: One record per phase per data entry
|
||||
CONSTRAINT unique_phase_per_data_entry UNIQUE (data_entry_id, phase_name)
|
||||
);
|
||||
|
||||
-- Create pecan_diameter_measurements table for individual diameter measurements
|
||||
CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE,
|
||||
measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10),
|
||||
diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Constraint: Unique measurement number per phase data
|
||||
CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_experiment_id ON public.experiment_data_entries(experiment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_user_id ON public.experiment_data_entries(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_status ON public.experiment_data_entries(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_created_at ON public.experiment_data_entries(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_entry_id ON public.experiment_phase_data(data_entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id);
|
||||
|
||||
-- Create triggers for updated_at
|
||||
CREATE TRIGGER set_updated_at_experiment_data_entries
|
||||
BEFORE UPDATE ON public.experiment_data_entries
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_updated_at();
|
||||
|
||||
CREATE TRIGGER set_updated_at_experiment_phase_data
|
||||
BEFORE UPDATE ON public.experiment_phase_data
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_updated_at();
|
||||
|
||||
-- Create trigger to set submitted_at timestamp
|
||||
CREATE OR REPLACE FUNCTION public.handle_data_entry_submission()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Set submitted_at when status changes to 'submitted'
|
||||
IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN
|
||||
NEW.submitted_at = NOW();
|
||||
END IF;
|
||||
|
||||
-- Clear submitted_at when status changes from 'submitted' to 'draft'
|
||||
IF NEW.status = 'draft' AND OLD.status = 'submitted' THEN
|
||||
NEW.submitted_at = NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER set_submitted_at_experiment_data_entries
|
||||
BEFORE UPDATE ON public.experiment_data_entries
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_data_entry_submission();
|
||||
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE public.experiment_data_entries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for experiment_data_entries table
|
||||
|
||||
-- Policy: All authenticated users can view all data entries
|
||||
CREATE POLICY "experiment_data_entries_select_policy" ON public.experiment_data_entries
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Policy: All authenticated users can insert data entries
|
||||
CREATE POLICY "experiment_data_entries_insert_policy" ON public.experiment_data_entries
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Policy: Users can only update their own data entries
|
||||
CREATE POLICY "experiment_data_entries_update_policy" ON public.experiment_data_entries
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Policy: Users can only delete their own draft entries
|
||||
CREATE POLICY "experiment_data_entries_delete_policy" ON public.experiment_data_entries
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (user_id = auth.uid() AND status = 'draft');
|
||||
|
||||
-- RLS Policies for experiment_phase_data table
|
||||
|
||||
-- Policy: All authenticated users can view phase data
|
||||
CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Policy: Users can insert phase data for their own data entries
|
||||
CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_data_entries ede
|
||||
WHERE ede.id = data_entry_id AND ede.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can update phase data for their own data entries
|
||||
CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_data_entries ede
|
||||
WHERE ede.id = data_entry_id AND ede.user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_data_entries ede
|
||||
WHERE ede.id = data_entry_id AND ede.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can delete phase data for their own draft entries
|
||||
CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_data_entries ede
|
||||
WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() AND ede.status = 'draft'
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS Policies for pecan_diameter_measurements table
|
||||
|
||||
-- Policy: All authenticated users can view diameter measurements
|
||||
CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Policy: Users can insert measurements for their own phase data
|
||||
CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
|
||||
WHERE epd.id = phase_data_id AND ede.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can update measurements for their own phase data
|
||||
CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
|
||||
WHERE epd.id = phase_data_id AND ede.user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
|
||||
WHERE epd.id = phase_data_id AND ede.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can delete measurements for their own draft entries
|
||||
CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
|
||||
WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() AND ede.status = 'draft'
|
||||
)
|
||||
);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON TABLE public.experiment_data_entries IS 'Main data entry records for experiments with draft/submitted status tracking';
|
||||
COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments';
|
||||
COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)';
|
||||
|
||||
COMMENT ON COLUMN public.experiment_data_entries.status IS 'Entry status: draft (editable) or submitted (final)';
|
||||
COMMENT ON COLUMN public.experiment_data_entries.entry_name IS 'Optional descriptive name for the data entry';
|
||||
COMMENT ON COLUMN public.experiment_data_entries.submitted_at IS 'Timestamp when entry was submitted (status changed to submitted)';
|
||||
|
||||
COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling';
|
||||
COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements';
|
||||
|
||||
COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)';
|
||||
COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches';
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Experiment Repetitions System Migration
|
||||
-- Transforms experiments into blueprints/templates with schedulable repetitions
|
||||
-- This migration creates the repetitions table and removes scheduling from experiments
|
||||
|
||||
-- Note: Data clearing removed since this runs during fresh database setup
|
||||
|
||||
-- Create experiment_repetitions table
|
||||
CREATE TABLE IF NOT EXISTS public.experiment_repetitions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
||||
repetition_number INTEGER NOT NULL CHECK (repetition_number > 0),
|
||||
scheduled_date TIMESTAMP WITH TIME ZONE,
|
||||
schedule_status TEXT NOT NULL DEFAULT 'pending schedule'
|
||||
CHECK (schedule_status IN ('pending schedule', 'scheduled', 'canceled', 'aborted')),
|
||||
completion_status BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||
|
||||
-- Ensure unique repetition numbers per experiment
|
||||
CONSTRAINT unique_repetition_per_experiment UNIQUE (experiment_id, repetition_number)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_experiment_id ON public.experiment_repetitions(experiment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_schedule_status ON public.experiment_repetitions(schedule_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_completion_status ON public.experiment_repetitions(completion_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_scheduled_date ON public.experiment_repetitions(scheduled_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_created_by ON public.experiment_repetitions(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_created_at ON public.experiment_repetitions(created_at);
|
||||
|
||||
-- Remove scheduling fields from experiments table since experiments are now blueprints
|
||||
ALTER TABLE public.experiments DROP COLUMN IF EXISTS scheduled_date;
|
||||
ALTER TABLE public.experiments DROP COLUMN IF EXISTS schedule_status;
|
||||
|
||||
-- Drop related indexes that are no longer needed
|
||||
DROP INDEX IF EXISTS idx_experiments_schedule_status;
|
||||
DROP INDEX IF EXISTS idx_experiments_scheduled_date;
|
||||
|
||||
-- Note: experiment_data_entries table is replaced by experiment_phase_drafts in the new system
|
||||
|
||||
-- Function to validate repetition number doesn't exceed experiment's reps_required
|
||||
CREATE OR REPLACE FUNCTION validate_repetition_number()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
max_reps INTEGER;
|
||||
BEGIN
|
||||
-- Get the reps_required for this experiment
|
||||
SELECT reps_required INTO max_reps
|
||||
FROM public.experiments
|
||||
WHERE id = NEW.experiment_id;
|
||||
|
||||
-- Check if repetition number exceeds the limit
|
||||
IF NEW.repetition_number > max_reps THEN
|
||||
RAISE EXCEPTION 'Repetition number % exceeds maximum allowed repetitions % for experiment',
|
||||
NEW.repetition_number, max_reps;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update the trigger function for experiment_repetitions
|
||||
CREATE OR REPLACE FUNCTION update_experiment_repetitions_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to validate repetition number
|
||||
CREATE TRIGGER trigger_validate_repetition_number
|
||||
BEFORE INSERT OR UPDATE ON public.experiment_repetitions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_repetition_number();
|
||||
|
||||
-- Create trigger for updated_at on experiment_repetitions
|
||||
CREATE TRIGGER trigger_experiment_repetitions_updated_at
|
||||
BEFORE UPDATE ON public.experiment_repetitions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_experiment_repetitions_updated_at();
|
||||
|
||||
-- Enable RLS on experiment_repetitions table
|
||||
ALTER TABLE public.experiment_repetitions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create RLS policies for experiment_repetitions
|
||||
-- Users can view repetitions for experiments they have access to
|
||||
CREATE POLICY "Users can view experiment repetitions" ON public.experiment_repetitions
|
||||
FOR SELECT USING (
|
||||
experiment_id IN (
|
||||
SELECT id FROM public.experiments
|
||||
WHERE created_by = auth.uid()
|
||||
)
|
||||
OR public.is_admin()
|
||||
);
|
||||
|
||||
-- Users can insert repetitions for experiments they created or if they're admin
|
||||
CREATE POLICY "Users can create experiment repetitions" ON public.experiment_repetitions
|
||||
FOR INSERT WITH CHECK (
|
||||
experiment_id IN (
|
||||
SELECT id FROM public.experiments
|
||||
WHERE created_by = auth.uid()
|
||||
)
|
||||
OR public.is_admin()
|
||||
);
|
||||
|
||||
-- Users can update repetitions for experiments they created or if they're admin
|
||||
CREATE POLICY "Users can update experiment repetitions" ON public.experiment_repetitions
|
||||
FOR UPDATE USING (
|
||||
experiment_id IN (
|
||||
SELECT id FROM public.experiments
|
||||
WHERE created_by = auth.uid()
|
||||
)
|
||||
OR public.is_admin()
|
||||
);
|
||||
|
||||
-- Users can delete repetitions for experiments they created or if they're admin
|
||||
CREATE POLICY "Users can delete experiment repetitions" ON public.experiment_repetitions
|
||||
FOR DELETE USING (
|
||||
experiment_id IN (
|
||||
SELECT id FROM public.experiments
|
||||
WHERE created_by = auth.uid()
|
||||
)
|
||||
OR public.is_admin()
|
||||
);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON TABLE public.experiment_repetitions IS 'Individual repetitions of experiment blueprints that can be scheduled and executed';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.experiment_id IS 'Reference to the experiment blueprint';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.repetition_number IS 'Sequential number of this repetition (1, 2, 3, etc.)';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.scheduled_date IS 'Date and time when this repetition is scheduled to run';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.schedule_status IS 'Current scheduling status of this repetition';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.completion_status IS 'Whether this repetition has been completed';
|
||||
-- Note: experiment_data_entries table is replaced by experiment_phase_drafts in the new system
|
||||
@@ -0,0 +1,332 @@
|
||||
-- Phase-Specific Draft System Migration
|
||||
-- Creates tables for the new phase-specific draft management system
|
||||
|
||||
-- Create experiment_phase_drafts table for phase-specific draft management
|
||||
CREATE TABLE IF NOT EXISTS public.experiment_phase_drafts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
||||
repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||
phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'withdrawn')),
|
||||
draft_name TEXT, -- Optional name for the draft (e.g., "Morning Run", "Batch A")
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted'
|
||||
withdrawn_at TIMESTAMP WITH TIME ZONE -- When status changed to 'withdrawn'
|
||||
);
|
||||
|
||||
-- Add repetition locking support
|
||||
ALTER TABLE public.experiment_repetitions
|
||||
ADD COLUMN IF NOT EXISTS is_locked BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP WITH TIME ZONE,
|
||||
ADD COLUMN IF NOT EXISTS locked_by UUID REFERENCES public.user_profiles(id);
|
||||
|
||||
-- Create experiment_phase_data table for phase-specific measurements
|
||||
CREATE TABLE IF NOT EXISTS public.experiment_phase_data (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
phase_draft_id UUID NOT NULL REFERENCES public.experiment_phase_drafts(id) ON DELETE CASCADE,
|
||||
phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
|
||||
|
||||
-- Pre-soaking phase data
|
||||
batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0),
|
||||
initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100),
|
||||
initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100),
|
||||
soaking_start_time TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Air-drying phase data
|
||||
airdrying_start_time TIMESTAMP WITH TIME ZONE,
|
||||
post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0),
|
||||
post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100),
|
||||
post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100),
|
||||
avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0),
|
||||
|
||||
-- Cracking phase data
|
||||
cracking_start_time TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Shelling phase data
|
||||
shelling_start_time TIMESTAMP WITH TIME ZONE,
|
||||
bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0),
|
||||
bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0),
|
||||
bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0),
|
||||
discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0),
|
||||
bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0),
|
||||
bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0),
|
||||
bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0),
|
||||
bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0),
|
||||
bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0),
|
||||
bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0),
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Constraint: One record per phase draft
|
||||
CONSTRAINT unique_phase_per_draft UNIQUE (phase_draft_id, phase_name)
|
||||
);
|
||||
|
||||
-- Create pecan_diameter_measurements table for individual diameter measurements
|
||||
CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE,
|
||||
measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10),
|
||||
diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Constraint: Unique measurement number per phase data
|
||||
CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_experiment_id ON public.experiment_phase_drafts(experiment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_repetition_id ON public.experiment_phase_drafts(repetition_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_user_id ON public.experiment_phase_drafts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_phase_name ON public.experiment_phase_drafts(phase_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_status ON public.experiment_phase_drafts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_is_locked ON public.experiment_repetitions(is_locked);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_draft_id ON public.experiment_phase_data(phase_draft_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id);
|
||||
|
||||
-- Create triggers for updated_at
|
||||
CREATE TRIGGER set_updated_at_experiment_phase_drafts
|
||||
BEFORE UPDATE ON public.experiment_phase_drafts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_updated_at();
|
||||
|
||||
CREATE TRIGGER set_updated_at_experiment_phase_data
|
||||
BEFORE UPDATE ON public.experiment_phase_data
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_updated_at();
|
||||
|
||||
-- Create trigger to set submitted_at and withdrawn_at timestamps for phase drafts
|
||||
CREATE OR REPLACE FUNCTION public.handle_phase_draft_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Set submitted_at when status changes to 'submitted'
|
||||
IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN
|
||||
NEW.submitted_at = NOW();
|
||||
NEW.withdrawn_at = NULL;
|
||||
END IF;
|
||||
|
||||
-- Set withdrawn_at when status changes to 'withdrawn'
|
||||
IF NEW.status = 'withdrawn' AND OLD.status = 'submitted' THEN
|
||||
NEW.withdrawn_at = NOW();
|
||||
END IF;
|
||||
|
||||
-- Clear timestamps when status changes back to 'draft'
|
||||
IF NEW.status = 'draft' AND OLD.status IN ('submitted', 'withdrawn') THEN
|
||||
NEW.submitted_at = NULL;
|
||||
NEW.withdrawn_at = NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER set_timestamps_experiment_phase_drafts
|
||||
BEFORE UPDATE ON public.experiment_phase_drafts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_phase_draft_status_change();
|
||||
|
||||
-- Enable RLS on all tables
|
||||
ALTER TABLE public.experiment_phase_drafts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for experiment_phase_drafts table
|
||||
|
||||
-- Policy: All authenticated users can view all phase drafts
|
||||
CREATE POLICY "experiment_phase_drafts_select_policy" ON public.experiment_phase_drafts
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Policy: All authenticated users can insert phase drafts
|
||||
CREATE POLICY "experiment_phase_drafts_insert_policy" ON public.experiment_phase_drafts
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Policy: Users can update their own phase drafts if repetition is not locked, admins can update any
|
||||
CREATE POLICY "experiment_phase_drafts_update_policy" ON public.experiment_phase_drafts
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
(user_id = auth.uid() AND NOT EXISTS (
|
||||
SELECT 1 FROM public.experiment_repetitions
|
||||
WHERE id = repetition_id AND is_locked = true
|
||||
)) OR public.is_admin()
|
||||
)
|
||||
WITH CHECK (
|
||||
(user_id = auth.uid() AND NOT EXISTS (
|
||||
SELECT 1 FROM public.experiment_repetitions
|
||||
WHERE id = repetition_id AND is_locked = true
|
||||
)) OR public.is_admin()
|
||||
);
|
||||
|
||||
-- Policy: Users can delete their own draft phase drafts if repetition is not locked, admins can delete any
|
||||
CREATE POLICY "experiment_phase_drafts_delete_policy" ON public.experiment_phase_drafts
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
(user_id = auth.uid() AND status = 'draft' AND NOT EXISTS (
|
||||
SELECT 1 FROM public.experiment_repetitions
|
||||
WHERE id = repetition_id AND is_locked = true
|
||||
)) OR public.is_admin()
|
||||
);
|
||||
|
||||
-- RLS Policies for experiment_phase_data table
|
||||
|
||||
-- Policy: All authenticated users can view phase data
|
||||
CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Policy: Users can insert phase data for their own phase drafts
|
||||
CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_drafts epd
|
||||
WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can update phase data for their own phase drafts
|
||||
CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_drafts epd
|
||||
WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_drafts epd
|
||||
WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can delete phase data for their own draft phase drafts
|
||||
CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_drafts epd
|
||||
WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid() AND epd.status = 'draft'
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS Policies for pecan_diameter_measurements table
|
||||
|
||||
-- Policy: All authenticated users can view diameter measurements
|
||||
CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Policy: Users can insert measurements for their own phase data
|
||||
CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
|
||||
WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can update measurements for their own phase data
|
||||
CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
|
||||
WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
|
||||
WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Users can delete measurements for their own draft phase drafts
|
||||
CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.experiment_phase_data epd
|
||||
JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
|
||||
WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid() AND epdr.status = 'draft'
|
||||
)
|
||||
);
|
||||
|
||||
-- Add indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_repetition_id ON public.experiment_phase_drafts(repetition_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_user_id ON public.experiment_phase_drafts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_phase_name ON public.experiment_phase_drafts(phase_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_status ON public.experiment_phase_drafts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_is_locked ON public.experiment_repetitions(is_locked);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON TABLE public.experiment_phase_drafts IS 'Phase-specific draft records for experiment data entry with status tracking';
|
||||
COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments';
|
||||
COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)';
|
||||
|
||||
COMMENT ON COLUMN public.experiment_phase_drafts.status IS 'Draft status: draft (editable), submitted (final), or withdrawn (reverted from submitted)';
|
||||
COMMENT ON COLUMN public.experiment_phase_drafts.draft_name IS 'Optional descriptive name for the draft';
|
||||
COMMENT ON COLUMN public.experiment_phase_drafts.submitted_at IS 'Timestamp when draft was submitted (status changed to submitted)';
|
||||
COMMENT ON COLUMN public.experiment_phase_drafts.withdrawn_at IS 'Timestamp when draft was withdrawn (status changed from submitted to withdrawn)';
|
||||
|
||||
COMMENT ON COLUMN public.experiment_repetitions.is_locked IS 'Admin lock to prevent draft modifications and withdrawals';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.locked_at IS 'Timestamp when repetition was locked';
|
||||
COMMENT ON COLUMN public.experiment_repetitions.locked_by IS 'User who locked the repetition';
|
||||
|
||||
COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling';
|
||||
COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements';
|
||||
|
||||
COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)';
|
||||
COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches';
|
||||
|
||||
-- Add unique constraint to prevent multiple drafts of same phase by same user for same repetition
|
||||
ALTER TABLE public.experiment_phase_drafts
|
||||
ADD CONSTRAINT unique_user_phase_repetition_draft
|
||||
UNIQUE (user_id, repetition_id, phase_name, status)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
-- Add function to prevent withdrawal of submitted drafts when repetition is locked
|
||||
CREATE OR REPLACE FUNCTION public.check_repetition_lock_before_withdrawal()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check if repetition is locked when trying to withdraw a submitted draft
|
||||
IF NEW.status = 'withdrawn' AND OLD.status = 'submitted' THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.experiment_repetitions
|
||||
WHERE id = NEW.repetition_id AND is_locked = true
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot withdraw submitted draft: repetition is locked by admin';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER check_lock_before_withdrawal
|
||||
BEFORE UPDATE ON public.experiment_phase_drafts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.check_repetition_lock_before_withdrawal();
|
||||
17
supabase/migrations/20250725000003_fix_draft_constraints.sql
Normal file
17
supabase/migrations/20250725000003_fix_draft_constraints.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Fix Draft Constraints Migration
|
||||
-- Allows multiple drafts per phase while preventing multiple submitted drafts
|
||||
|
||||
-- Drop the overly restrictive constraint
|
||||
ALTER TABLE public.experiment_phase_drafts
|
||||
DROP CONSTRAINT IF EXISTS unique_user_phase_repetition_draft;
|
||||
|
||||
-- Add a proper constraint that only prevents multiple submitted drafts
|
||||
-- Users can have multiple drafts in 'draft' or 'withdrawn' status, but only one 'submitted' per phase
|
||||
ALTER TABLE public.experiment_phase_drafts
|
||||
ADD CONSTRAINT unique_submitted_draft_per_user_phase
|
||||
EXCLUDE (user_id WITH =, repetition_id WITH =, phase_name WITH =)
|
||||
WHERE (status = 'submitted');
|
||||
|
||||
-- Add comment explaining the constraint
|
||||
COMMENT ON CONSTRAINT unique_submitted_draft_per_user_phase ON public.experiment_phase_drafts
|
||||
IS 'Ensures only one submitted draft per user per phase per repetition, but allows multiple draft/withdrawn entries';
|
||||
@@ -1,6 +1,9 @@
|
||||
-- Seed data for testing experiment scheduling functionality
|
||||
-- Seed data for testing experiment repetitions functionality
|
||||
|
||||
-- Insert some sample experiments for testing
|
||||
-- Insert experiments from phase_2_experimental_run_sheet.csv
|
||||
-- These are experiment blueprints/templates with their parameters
|
||||
-- Using run_number from CSV as experiment_number in database
|
||||
-- Note: Some run_numbers are duplicated in the CSV, so we'll only insert unique ones
|
||||
INSERT INTO public.experiments (
|
||||
experiment_number,
|
||||
reps_required,
|
||||
@@ -10,51 +13,118 @@ INSERT INTO public.experiments (
|
||||
throughput_rate_pecans_sec,
|
||||
crush_amount_in,
|
||||
entry_exit_height_diff_in,
|
||||
schedule_status,
|
||||
results_status,
|
||||
created_by
|
||||
) VALUES
|
||||
(
|
||||
1001,
|
||||
5,
|
||||
2.5,
|
||||
30,
|
||||
50.0,
|
||||
2.5,
|
||||
0.005,
|
||||
1.2,
|
||||
'pending schedule',
|
||||
'valid',
|
||||
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||
),
|
||||
(
|
||||
1002,
|
||||
3,
|
||||
1.0,
|
||||
15,
|
||||
45.0,
|
||||
3.0,
|
||||
0.003,
|
||||
0.8,
|
||||
'pending schedule',
|
||||
'valid',
|
||||
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||
),
|
||||
(
|
||||
1003,
|
||||
4,
|
||||
3.0,
|
||||
45,
|
||||
55.0,
|
||||
2.0,
|
||||
0.007,
|
||||
1.5,
|
||||
'scheduled',
|
||||
'valid',
|
||||
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||
);
|
||||
) VALUES
|
||||
-- Unique experiments based on run_number from CSV
|
||||
(0, 3, 34, 19, 53, 28, 0.05, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(1, 3, 24, 27, 34, 29, 0.03, 0.01, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(2, 3, 38, 10, 60, 28, 0.06, -0.1, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(3, 3, 11, 36, 42, 13, 0.07, -0.07, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(4, 3, 13, 41, 41, 38, 0.05, 0.03, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(5, 3, 30, 33, 30, 36, 0.05, -0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(6, 3, 10, 22, 37, 30, 0.06, 0.02, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(7, 3, 15, 30, 35, 32, 0.05, -0.07, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(8, 3, 27, 12, 55, 24, 0.04, 0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(9, 3, 32, 26, 47, 26, 0.07, 0.03, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(10, 3, 26, 60, 44, 12, 0.08, -0.1, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(11, 3, 24, 59, 42, 25, 0.07, -0.05, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(12, 3, 28, 59, 37, 23, 0.06, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(13, 3, 21, 59, 41, 21, 0.06, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(14, 3, 22, 59, 45, 17, 0.07, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(15, 3, 16, 60, 30, 24, 0.07, 0.02, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(16, 3, 20, 59, 41, 14, 0.07, 0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(17, 3, 34, 60, 34, 29, 0.07, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(18, 3, 18, 49, 38, 35, 0.07, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
(19, 3, 11, 25, 56, 34, 0.06, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com'));
|
||||
|
||||
-- Update one experiment to have a scheduled date for testing
|
||||
UPDATE public.experiments
|
||||
SET scheduled_date = NOW() + INTERVAL '2 days'
|
||||
WHERE experiment_number = 1003;
|
||||
-- Create repetitions for all experiments based on CSV data
|
||||
-- Each experiment has 3 repetitions as specified in the CSV
|
||||
INSERT INTO public.experiment_repetitions (
|
||||
experiment_id,
|
||||
repetition_number,
|
||||
schedule_status,
|
||||
scheduled_date,
|
||||
completion_status,
|
||||
created_by
|
||||
) VALUES
|
||||
-- Experiment 0 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 0), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 0), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 0), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 1 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 1), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 1), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 1), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 2 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 2), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 2), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 2), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 3 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 3), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 3), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 3), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 4 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 4), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 4), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 4), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 5 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 5), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 5), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 5), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 6 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 6), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 6), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 6), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 7 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 7), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 7), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 7), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 8 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 8), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 8), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 8), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 9 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 9), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 9), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 9), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 10 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 10), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 10), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 10), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 11 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 11), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 11), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 11), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 12 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 12), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 12), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 12), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 13 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 13), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 13), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 13), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 14 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 14), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 14), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 14), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 15 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 15), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 15), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 15), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 16 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 16), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 16), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 16), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 17 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 17), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 17), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 17), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 18 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 18), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 18), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 18), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
-- Experiment 19 repetitions
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 19), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 19), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
|
||||
((SELECT id FROM public.experiments WHERE experiment_number = 19), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com'));
|
||||
Reference in New Issue
Block a user