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:
Alireza Vaezi
2025-07-28 16:30:56 -04:00
parent 0d0c67d5c1
commit d598281164
27 changed files with 4219 additions and 683 deletions

137
VISION_SYSTEM_README.md Normal file
View 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
View 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

View 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
1 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
2 1 0 34 19 53 28 0.05 -0.09 3 1
3 2 1 24 27 34 29 0.03 0.01 3 3
4 3 12 28 59 37 23 0.06 -0.08 3 1
5 4 15 16 60 30 24 0.07 0.02 3 1
6 5 4 13 41 41 38 0.05 0.03 3 2
7 6 18 18 49 38 35 0.07 -0.08 3 1
8 7 11 24 59 42 25 0.07 -0.05 3 1
9 8 16 20 59 41 14 0.07 0.04 3 1
10 9 4 13 41 41 38 0.05 0.03 3 1
11 10 19 11 25 56 34 0.06 -0.09 3 1
12 11 15 16 60 30 24 0.07 0.02 3 2
13 12 16 20 59 41 14 0.07 0.04 3 3
14 13 10 26 60 44 12 0.08 -0.1 3 2
15 14 1 24 27 34 29 0.03 0.01 3 1
16 15 17 34 60 34 29 0.07 -0.09 3 2
17 16 5 30 33 30 36 0.05 -0.04 3 3
18 17 2 38 10 60 28 0.06 -0.1 3 3
19 18 2 38 10 60 28 0.06 -0.1 3 1
20 19 13 21 59 41 21 0.06 -0.09 3 2
21 20 1 24 27 34 29 0.03 0.01 3 2
22 21 14 22 59 45 17 0.07 -0.08 3 2
23 22 6 10 22 37 30 0.06 0.02 3 2
24 23 11 24 59 42 25 0.07 -0.05 3 2
25 24 19 11 25 56 34 0.06 -0.09 3 2
26 25 8 27 12 55 24 0.04 0.04 3 2
27 26 18 18 49 38 35 0.07 -0.08 3 3
28 27 5 30 33 30 36 0.05 -0.04 3 1
29 28 9 32 26 47 26 0.07 0.03 3 1
30 29 3 11 36 42 13 0.07 -0.07 3 1
31 30 10 26 60 44 12 0.08 -0.1 3 1
32 31 8 27 12 55 24 0.04 0.04 3 3
33 32 5 30 33 30 36 0.05 -0.04 3 2
34 33 8 27 12 55 24 0.04 0.04 3 1
35 34 18 18 49 38 35 0.07 -0.08 3 2
36 35 3 11 36 42 13 0.07 -0.07 3 3
37 36 10 26 60 44 12 0.08 -0.1 3 3
38 37 17 34 60 34 29 0.07 -0.09 3 3
39 38 13 21 59 41 21 0.06 -0.09 3 3
40 39 12 28 59 37 23 0.06 -0.08 3 2
41 40 9 32 26 47 26 0.07 0.03 3 3
42 41 14 22 59 45 17 0.07 -0.08 3 3
43 42 0 34 19 53 28 0.05 -0.09 3 2
44 43 7 15 30 35 32 0.05 -0.07 3 1
45 44 0 34 19 53 28 0.05 -0.09 3 3
46 45 15 16 60 30 24 0.07 0.02 3 3
47 46 13 21 59 41 21 0.06 -0.09 3 1
48 47 11 24 59 42 25 0.07 -0.05 3 3
49 48 7 15 30 35 32 0.05 -0.07 3 3
50 49 16 20 59 41 14 0.07 0.04 3 2
51 50 3 11 36 42 13 0.07 -0.07 3 2
52 51 7 15 30 35 32 0.05 -0.07 3 2
53 52 6 10 22 37 30 0.06 0.02 3 1
54 53 19 11 25 56 34 0.06 -0.09 3 3
55 54 6 10 22 37 30 0.06 0.02 3 3
56 55 2 38 10 60 28 0.06 -0.1 3 2
57 56 14 22 59 45 17 0.07 -0.08 3 1
58 57 4 13 41 41 38 0.05 0.03 3 3
59 58 9 32 26 47 26 0.07 0.03 3 2
60 59 17 34 60 34 29 0.07 -0.09 3 1
61 60 12 28 59 37 23 0.06 -0.08 3 3

View File

@@ -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 */}

View File

@@ -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} />
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

@@ -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>
)}

View File

@@ -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'
}

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

View File

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

View 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()

View File

@@ -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';

View File

@@ -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

View File

@@ -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();

View 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';

View File

@@ -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'));