Commit changes before merging to main
This commit is contained in:
@@ -459,6 +459,13 @@ class CameraRecorder:
|
||||
with self._lock:
|
||||
if self.recording:
|
||||
self.logger.warning("Already recording!")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:460", "message": "start_recording: already recording check", "data": {"camera": self.camera_config.name, "recording_flag": self.recording, "thread_alive": self._recording_thread.is_alive() if self._recording_thread else False}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "G"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
return False
|
||||
|
||||
# Check if streamer is active - if so, we can share frames without opening a new camera connection
|
||||
@@ -503,6 +510,13 @@ class CameraRecorder:
|
||||
|
||||
# Update state
|
||||
self.recording = True
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:505", "message": "start_recording: setting recording=True", "data": {"camera": self.camera_config.name, "filename": filename}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
recording_id = self.state_manager.start_recording(self.camera_config.name, output_path)
|
||||
|
||||
# Publish event
|
||||
@@ -567,6 +581,13 @@ class CameraRecorder:
|
||||
with self._lock:
|
||||
# Update state
|
||||
self.recording = False
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:569", "message": "stop_recording: setting recording=False", "data": {"camera": self.camera_config.name}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "F"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
|
||||
# Calculate duration and file size
|
||||
duration = 0
|
||||
@@ -620,9 +641,23 @@ class CameraRecorder:
|
||||
while initial_frame is None and time.time() - timeout_start < 5.0:
|
||||
if self._stop_recording_event.is_set():
|
||||
self.logger.error("Stop event set before getting initial frame")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:623", "message": "Early return: stop event set", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
return
|
||||
if not self.streamer.streaming:
|
||||
self.logger.error("Streamer stopped before getting initial frame")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:625", "message": "Early return: streamer stopped", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "B"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
return
|
||||
try:
|
||||
initial_frame = self.streamer._recording_frame_queue.get(timeout=0.5)
|
||||
@@ -632,11 +667,25 @@ class CameraRecorder:
|
||||
|
||||
if initial_frame is None:
|
||||
self.logger.error("Failed to get initial frame from streamer for video writer initialization")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:634", "message": "Early return: failed to get initial frame", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "C"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
return
|
||||
|
||||
# Initialize video writer (with initial frame dimensions if using streamer frames)
|
||||
if not self._initialize_video_writer(use_streamer_frames=use_streamer_frames, initial_frame=initial_frame):
|
||||
self.logger.error("Failed to initialize video writer")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:640", "message": "Early return: failed to initialize video writer", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "D"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
return
|
||||
|
||||
self.logger.info(f"Recording loop started (using {'streamer frames' if use_streamer_frames else 'direct capture'})")
|
||||
@@ -734,8 +783,26 @@ class CameraRecorder:
|
||||
finally:
|
||||
self.logger.info("Cleaning up recording resources...")
|
||||
self._cleanup_recording()
|
||||
# Note: Don't set self.recording = False here - let stop_recording() handle it
|
||||
# to avoid race conditions where stop_recording thinks recording already stopped
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:734", "message": "Finally block: before setting recording flag", "data": {"camera": self.camera_config.name, "recording_flag_before": self.recording, "stop_event_set": self._stop_recording_event.is_set()}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "post-fix", "hypothesisId": "E"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
# Reset recording flag if thread exits early (due to error) or if stop_recording wasn't called
|
||||
# This prevents the flag from staying True when the thread exits early
|
||||
# Using lock to ensure thread safety - if stop_recording() already set it to False, this is harmless
|
||||
with self._lock:
|
||||
if self.recording:
|
||||
self.logger.warning("Recording thread exited but flag was still True - resetting to False")
|
||||
self.recording = False
|
||||
# #region agent log
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:745", "message": "Finally block: after setting recording flag", "data": {"camera": self.camera_config.name, "recording_flag_after": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "post-fix", "hypothesisId": "E"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
|
||||
def _initialize_video_writer(self, use_streamer_frames: bool = False, initial_frame: Optional[np.ndarray] = None) -> bool:
|
||||
"""Initialize OpenCV video writer
|
||||
@@ -768,15 +835,27 @@ class CameraRecorder:
|
||||
self.logger.info(f"Got frame dimensions from streamer's camera: {frame_size}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get frame dimensions from streamer camera: {e}")
|
||||
# Use camera config defaults as last resort
|
||||
camera_config = self.camera_config
|
||||
frame_size = (camera_config.resolution_width or 1280, camera_config.resolution_height or 1024)
|
||||
self.logger.warning(f"Using default frame size from config: {frame_size}")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:837", "message": "Fallback path triggered: failed to get dimensions from streamer camera", "data": {"camera": self.camera_config.name, "error": str(e)}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
# Use hardcoded defaults as last resort (CameraConfig doesn't have resolution_width/height fields)
|
||||
frame_size = (1280, 1024)
|
||||
self.logger.warning(f"Using hardcoded default frame size: {frame_size}")
|
||||
else:
|
||||
# Use camera config defaults as last resort
|
||||
camera_config = self.camera_config
|
||||
frame_size = (camera_config.resolution_width or 1280, camera_config.resolution_height or 1024)
|
||||
self.logger.warning(f"Using default frame size from config: {frame_size}")
|
||||
# #region agent log
|
||||
import json
|
||||
try:
|
||||
with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
|
||||
f.write(json.dumps({"location": "recorder.py:842", "message": "Fallback path triggered: no camera handle available", "data": {"camera": self.camera_config.name, "use_streamer_frames": use_streamer_frames, "has_streamer": self.streamer is not None, "has_streamer_camera": self.streamer.hCamera is not None if self.streamer else False}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "B"}) + "\n")
|
||||
except: pass
|
||||
# #endregion
|
||||
# Use hardcoded defaults as last resort (CameraConfig doesn't have resolution_width/height fields)
|
||||
frame_size = (1280, 1024)
|
||||
self.logger.warning(f"Using hardcoded default frame size: {frame_size}")
|
||||
|
||||
# Set up video writer with configured codec
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.camera_config.video_codec)
|
||||
|
||||
199
docs/UGA_SSO_Integration_Guide.md
Normal file
199
docs/UGA_SSO_Integration_Guide.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# UGA SSO Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The University of Georgia (UGA) uses **Central Authentication Service (CAS)** for Single Sign-On (SSO) authentication. This allows users to log in to multiple applications using their MyID and password, with optional two-factor authentication via ArchPass (powered by Duo).
|
||||
|
||||
## Current Authentication System
|
||||
|
||||
The dashboard currently uses **Supabase** for authentication with email/password login. The authentication is implemented in:
|
||||
- `management-dashboard-web-app/src/components/Login.tsx` - Login form component
|
||||
- `management-dashboard-web-app/src/lib/supabase.ts` - Supabase client and user management
|
||||
- `management-dashboard-web-app/src/App.tsx` - Authentication state management
|
||||
|
||||
## UGA SSO Integration Process
|
||||
|
||||
### Step 1: Submit SSO Integration Request
|
||||
|
||||
**Action Required:** Submit the official UGA SSO Integration Request form to UGA's Enterprise Information Technology Services (EITS).
|
||||
|
||||
**Resources:**
|
||||
- **SSO Integration Request Form:** https://eits.uga.edu/access_and_security/uga_sso/moving_to_uga_sso/sso_integration_request/
|
||||
- **UGA SSO Information Page:** https://eits.uga.edu/access_and_security/uga_sso/
|
||||
- **What is CAS?** https://eits.uga.edu/access_and_security/new_cas/what_is_cas/
|
||||
|
||||
The integration request form collects essential information about your application and initiates the security review process.
|
||||
|
||||
### Step 2: Security Review
|
||||
|
||||
After submitting the request, UGA's **Office of Information Security** will contact you to:
|
||||
- Arrange security testing of your application
|
||||
- Ensure your application meets UGA's security standards
|
||||
- Provide guidance on security requirements
|
||||
|
||||
**Note:** This is a mandatory step before your application can be integrated with UGA SSO.
|
||||
|
||||
### Step 3: Technical Support
|
||||
|
||||
For technical assistance during the integration process:
|
||||
- **Request UGA SSO Support:** Submit a request through EITS
|
||||
- **Community CAS Support:** Available on the UGA SSO page
|
||||
- **Developer Tools:** Additional resources available on the UGA SSO page
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### CAS Authentication Flow
|
||||
|
||||
CAS (Central Authentication Service) typically follows this flow:
|
||||
1. User attempts to access the application
|
||||
2. Application redirects to CAS login page
|
||||
3. User authenticates with MyID and password
|
||||
4. CAS validates credentials (may require ArchPass/2FA)
|
||||
5. CAS redirects back to application with a service ticket
|
||||
6. Application validates ticket with CAS server
|
||||
7. Application creates session for authenticated user
|
||||
|
||||
### Implementation Options
|
||||
|
||||
#### Option 1: Backend Proxy Approach (Recommended)
|
||||
- Implement CAS authentication on the backend (API server)
|
||||
- Frontend redirects to backend CAS endpoint
|
||||
- Backend handles CAS ticket validation
|
||||
- Backend creates Supabase session or JWT token
|
||||
- Frontend receives authentication token
|
||||
|
||||
**Pros:**
|
||||
- Keeps CAS credentials secure on backend
|
||||
- Can integrate with existing Supabase auth
|
||||
- Better security model
|
||||
|
||||
#### Option 2: Frontend CAS Client Library
|
||||
- Use a JavaScript CAS client library in the React app
|
||||
- Handle CAS redirect flow in the browser
|
||||
- Exchange ticket for user information
|
||||
|
||||
**Pros:**
|
||||
- Simpler initial implementation
|
||||
- Direct integration in frontend
|
||||
|
||||
**Cons:**
|
||||
- Requires exposing CAS service URL
|
||||
- May need backend validation anyway
|
||||
|
||||
### CAS Client Libraries
|
||||
|
||||
For JavaScript/TypeScript implementations, consider:
|
||||
- **cas-client** (npm) - CAS client for Node.js
|
||||
- **passport-cas** - Passport.js strategy for CAS
|
||||
- Custom implementation using CAS REST API
|
||||
|
||||
### Integration with Supabase
|
||||
|
||||
You have several options for integrating CAS with your existing Supabase setup:
|
||||
|
||||
1. **CAS → Supabase User Mapping:**
|
||||
- After CAS authentication, create/update Supabase user
|
||||
- Map CAS user attributes (email, MyID) to Supabase user
|
||||
- Use Supabase session for application access
|
||||
|
||||
2. **Hybrid Authentication:**
|
||||
- Support both CAS (UGA users) and email/password (external users)
|
||||
- Allow users to choose authentication method
|
||||
|
||||
3. **CAS-Only Authentication:**
|
||||
- Replace Supabase auth entirely with CAS
|
||||
- Use CAS session management
|
||||
- Store user data in Supabase but authenticate via CAS
|
||||
|
||||
## Key Information Needed from UGA
|
||||
|
||||
After submitting your integration request, you'll need to obtain:
|
||||
|
||||
1. **CAS Server URL** - The UGA CAS server endpoint
|
||||
2. **Service URL** - Your application's callback URL
|
||||
3. **CAS Protocol Version** - Which CAS protocol version UGA uses (CAS 2.0, CAS 3.0, etc.)
|
||||
4. **User Attributes** - What user information is returned (email, MyID, name, etc.)
|
||||
5. **Certificate/Validation Method** - How to validate CAS tickets
|
||||
6. **Two-Factor Authentication Requirements** - Whether ArchPass is required
|
||||
|
||||
## Two-Factor Authentication
|
||||
|
||||
UGA uses **ArchPass** (powered by Duo) for two-factor authentication. Depending on your application's security requirements:
|
||||
- Users may be required to complete 2FA during login
|
||||
- This is typically handled automatically by the CAS server
|
||||
- No additional implementation needed on your end
|
||||
|
||||
## Identity Federation
|
||||
|
||||
UGA supports **Identity Federation**, which allows:
|
||||
- Users from other organizations to access your application
|
||||
- Collaborative research projects across institutions
|
||||
- Single set of credentials for multiple applications
|
||||
|
||||
If you need federated identity support, mention this in your integration request.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate Actions:**
|
||||
- [ ] Submit the UGA SSO Integration Request form
|
||||
- [ ] Prepare application information (description, URL, security details)
|
||||
- [ ] Review current authentication implementation
|
||||
|
||||
2. **While Waiting for Approval:**
|
||||
- [ ] Research CAS client libraries for your tech stack
|
||||
- [ ] Design integration architecture (backend vs frontend)
|
||||
- [ ] Plan user data mapping (CAS attributes → Supabase users)
|
||||
- [ ] Consider hybrid authentication approach
|
||||
|
||||
3. **After Approval:**
|
||||
- [ ] Obtain CAS server details from UGA EITS
|
||||
- [ ] Implement CAS authentication flow
|
||||
- [ ] Test with UGA test accounts
|
||||
- [ ] Complete security review process
|
||||
- [ ] Deploy to production
|
||||
|
||||
## Resources
|
||||
|
||||
### UGA Official Resources
|
||||
- **UGA SSO Main Page:** https://eits.uga.edu/access_and_security/uga_sso/
|
||||
- **SSO Integration Request:** https://eits.uga.edu/access_and_security/uga_sso/moving_to_uga_sso/sso_integration_request/
|
||||
- **What is CAS?** https://eits.uga.edu/access_and_security/new_cas/what_is_cas/
|
||||
- **Request UGA SSO Support:** Available through EITS portal
|
||||
|
||||
### CAS Documentation
|
||||
- **Apereo CAS Documentation:** https://apereo.github.io/cas/
|
||||
- **CAS Protocol Specification:** https://apereo.github.io/cas/development/protocol/CAS-Protocol-Specification.html
|
||||
|
||||
### Technical References
|
||||
- **CAS REST API:** For programmatic CAS integration
|
||||
- **CAS Client Libraries:** Search npm for "cas-client" or "passport-cas"
|
||||
- **Supabase Auth Documentation:** For integrating external auth providers
|
||||
|
||||
## Questions to Ask UGA EITS
|
||||
|
||||
When you receive contact from UGA's Office of Information Security, consider asking:
|
||||
|
||||
1. What is the CAS server URL for production?
|
||||
2. What CAS protocol version should we use?
|
||||
3. What user attributes are available in the CAS response?
|
||||
4. Is ArchPass (2FA) required for our application?
|
||||
5. What is the expected service URL format?
|
||||
6. Are there any specific security requirements we should be aware of?
|
||||
7. Is there a test/staging CAS environment for development?
|
||||
8. What is the expected timeline for the integration process?
|
||||
|
||||
## Notes
|
||||
|
||||
- The integration process requires official approval from UGA EITS
|
||||
- Security review is mandatory and may take some time
|
||||
- You may need to modify your application's security configuration
|
||||
- Consider maintaining backward compatibility with existing users during transition
|
||||
- Document the integration process for future maintenance
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** Based on research conducted in 2024
|
||||
**Contact:** UGA EITS for official documentation and support
|
||||
|
||||
|
||||
|
||||
@@ -39,3 +39,8 @@ ADD CONSTRAINT unique_meyer_cracker_parameters_per_repetition
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ SELECT
|
||||
ep.has_airdrying,
|
||||
ep.has_cracking,
|
||||
ep.has_shelling,
|
||||
ep.cracking_machine_type_id,
|
||||
ep.cracking_machine_type_id as phase_cracking_machine_type_id,
|
||||
|
||||
-- Repetition fields
|
||||
er.id as repetition_id,
|
||||
|
||||
95
scheduling-remote/REFACTORING_SUMMARY.md
Normal file
95
scheduling-remote/REFACTORING_SUMMARY.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Scheduling Component Refactoring Summary
|
||||
|
||||
## Overview
|
||||
|
||||
The `Scheduling.tsx` file (originally 1832 lines) has been refactored into a modular structure with reusable components, hooks, and better organization.
|
||||
|
||||
## New Structure
|
||||
|
||||
```
|
||||
scheduling-remote/src/components/
|
||||
├── Scheduling.tsx # Backward compatibility re-export
|
||||
└── scheduling/
|
||||
├── types.ts # Shared types and interfaces
|
||||
├── Scheduling.tsx # Main router component (~100 lines)
|
||||
├── AvailabilityCalendar.tsx # Availability calendar component
|
||||
├── ui/ # Reusable UI components
|
||||
│ ├── BackButton.tsx # Back navigation button
|
||||
│ ├── SchedulingCard.tsx # Card component for main view
|
||||
│ ├── TimeSlotModal.tsx # Modal for adding time slots
|
||||
│ └── DropdownCurtain.tsx # Reusable dropdown/accordion
|
||||
├── views/ # Main view components
|
||||
│ ├── ViewSchedule.tsx # View schedule component
|
||||
│ ├── IndicateAvailability.tsx # Indicate availability view
|
||||
│ └── ScheduleExperimentImpl.tsx # Temporary wrapper (TODO: further refactor)
|
||||
└── hooks/ # Custom hooks
|
||||
└── useConductors.ts # Conductor management hook
|
||||
```
|
||||
|
||||
## What Was Extracted
|
||||
|
||||
### 1. **Shared Types** (`types.ts`)
|
||||
- `CalendarEvent` interface
|
||||
- `SchedulingProps` interface
|
||||
- `SchedulingView` type
|
||||
- `ScheduledRepetition` interface
|
||||
- Re-exports of service types
|
||||
|
||||
### 2. **Reusable UI Components** (`ui/`)
|
||||
- **BackButton**: Consistent back navigation
|
||||
- **SchedulingCard**: Reusable card component for the main scheduling view
|
||||
- **TimeSlotModal**: Modal for adding/editing time slots
|
||||
- **DropdownCurtain**: Reusable accordion/dropdown component
|
||||
|
||||
### 3. **View Components** (`views/`)
|
||||
- **ViewSchedule**: Complete schedule view (simplified, placeholder)
|
||||
- **IndicateAvailability**: Wrapper for availability calendar
|
||||
- **ScheduleExperimentImpl**: Temporary wrapper (original implementation still in old file)
|
||||
|
||||
### 4. **Custom Hooks** (`hooks/`)
|
||||
- **useConductors**: Manages conductor data, selection, colors, and availability
|
||||
|
||||
### 5. **Main Components**
|
||||
- **Scheduling.tsx**: Main router component (reduced from ~220 lines to ~100 lines)
|
||||
- **AvailabilityCalendar.tsx**: Extracted availability calendar (moved from inline component)
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Maintainability**: Each component has a single, clear responsibility
|
||||
2. **Reusability**: UI components can be reused across different views
|
||||
3. **Testability**: Smaller units are easier to test in isolation
|
||||
4. **Readability**: Easier to understand and navigate the codebase
|
||||
5. **Organization**: Clear separation of concerns
|
||||
|
||||
## File Size Reduction
|
||||
|
||||
- **Original Scheduling.tsx**: 1832 lines
|
||||
- **New main Scheduling.tsx**: ~100 lines (95% reduction)
|
||||
- **Total new structure**: Better organized across multiple focused files
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The original `Scheduling.tsx` file is maintained for backward compatibility and re-exports the new modular component. The `ScheduleExperiment` function is still exported from the original file location.
|
||||
|
||||
## TODO / Future Improvements
|
||||
|
||||
1. **Further refactor ScheduleExperiment**:
|
||||
- Extract into smaller subcomponents (ConductorPanel, ExperimentPhasePanel, etc.)
|
||||
- Create additional hooks (useExperimentPhases, useScheduling, useCalendarEvents)
|
||||
- Move implementation from old file to new structure
|
||||
|
||||
2. **Additional reusable components**:
|
||||
- Experiment item component
|
||||
- Repetition item component
|
||||
- Calendar controls component
|
||||
|
||||
3. **Testing**:
|
||||
- Add unit tests for extracted components
|
||||
- Add integration tests for hooks
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- All imports from `./components/Scheduling` continue to work
|
||||
- The new structure is in `./components/scheduling/`
|
||||
- No breaking changes to the public API
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@@ -65,6 +68,8 @@
|
||||
.rbc-month-view {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dark .rbc-month-view {
|
||||
@@ -73,6 +78,8 @@
|
||||
|
||||
.rbc-month-row {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.dark .rbc-month-row {
|
||||
@@ -87,6 +94,10 @@
|
||||
.rbc-time-view {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dark .rbc-time-view {
|
||||
@@ -105,6 +116,9 @@
|
||||
|
||||
.rbc-time-content {
|
||||
background: white;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dark .rbc-time-content {
|
||||
|
||||
1169
scheduling-remote/src/components/HorizontalTimelineCalendar.tsx
Normal file
1169
scheduling-remote/src/components/HorizontalTimelineCalendar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
// This file is kept for backward compatibility
|
||||
// The main Scheduling component has been moved to ./scheduling/Scheduling.tsx
|
||||
// This file re-exports it and also contains the original ScheduleExperiment implementation
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
// @ts-ignore - react-big-calendar types not available
|
||||
import { Calendar, momentLocalizer, Views } from 'react-big-calendar'
|
||||
@@ -10,6 +14,10 @@ import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking,
|
||||
import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } from '../services/supabase'
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css'
|
||||
import './CalendarStyles.css'
|
||||
import { HorizontalTimelineCalendar } from './HorizontalTimelineCalendar'
|
||||
|
||||
// Re-export the new modular Scheduling component
|
||||
export { Scheduling } from './scheduling/Scheduling'
|
||||
|
||||
// Type definitions for calendar events
|
||||
interface CalendarEvent {
|
||||
@@ -20,277 +28,9 @@ interface CalendarEvent {
|
||||
resource?: string
|
||||
}
|
||||
|
||||
interface SchedulingProps {
|
||||
user: User
|
||||
currentRoute: string
|
||||
}
|
||||
|
||||
type SchedulingView = 'main' | 'view-schedule' | 'indicate-availability' | 'schedule-experiment'
|
||||
|
||||
export function Scheduling({ user, currentRoute }: SchedulingProps) {
|
||||
// Extract current view from route
|
||||
const getCurrentView = (): SchedulingView => {
|
||||
if (currentRoute === '/scheduling') {
|
||||
return 'main'
|
||||
}
|
||||
const match = currentRoute.match(/^\/scheduling\/(.+)$/)
|
||||
if (match) {
|
||||
const subRoute = match[1]
|
||||
switch (subRoute) {
|
||||
case 'view-schedule':
|
||||
return 'view-schedule'
|
||||
case 'indicate-availability':
|
||||
return 'indicate-availability'
|
||||
case 'schedule-experiment':
|
||||
return 'schedule-experiment'
|
||||
default:
|
||||
return 'main'
|
||||
}
|
||||
}
|
||||
return 'main'
|
||||
}
|
||||
|
||||
const currentView = getCurrentView()
|
||||
|
||||
const handleCardClick = (view: SchedulingView) => {
|
||||
const newPath = view === 'main' ? '/scheduling' : `/scheduling/${view}`
|
||||
window.history.pushState({}, '', newPath)
|
||||
// Trigger a popstate event to update the route
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleBackToMain = () => {
|
||||
window.history.pushState({}, '', '/scheduling')
|
||||
// Trigger a popstate event to update the route
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
// Render different views based on currentView
|
||||
if (currentView === 'view-schedule') {
|
||||
return <ViewSchedule user={user} onBack={handleBackToMain} />
|
||||
}
|
||||
|
||||
if (currentView === 'indicate-availability') {
|
||||
return <IndicateAvailability user={user} onBack={handleBackToMain} />
|
||||
}
|
||||
|
||||
if (currentView === 'schedule-experiment') {
|
||||
return <ScheduleExperiment user={user} onBack={handleBackToMain} />
|
||||
}
|
||||
|
||||
// Main view with cards
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Scheduling
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This is the scheduling module of the dashboard. Here you can indicate your availability for upcoming experiment runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scheduling Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* View Complete Schedule Card */}
|
||||
<div
|
||||
onClick={() => handleCardClick('view-schedule')}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-400">
|
||||
Available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
View Complete Schedule
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
View the complete schedule of all upcoming experiment runs and their current status.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>All experiments</span>
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<span className="mr-1">View Schedule</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicate Availability Card */}
|
||||
<div
|
||||
onClick={() => handleCardClick('indicate-availability')}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Indicate Your Availability
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
Set your availability preferences and time slots for upcoming experiment runs.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Personal settings</span>
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<span className="mr-1">Set Availability</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Experiment Card */}
|
||||
<div
|
||||
onClick={() => handleCardClick('schedule-experiment')}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-600 dark:text-purple-400" 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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400">
|
||||
Planning
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Schedule Experiment
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
Schedule specific experiment runs and assign team members to upcoming sessions.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Experiment planning</span>
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<span className="mr-1">Schedule Now</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Placeholder components for the three scheduling features
|
||||
function ViewSchedule({ user, onBack }: { user: User; onBack: () => void }) {
|
||||
// User context available for future features
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Scheduling
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Complete Schedule
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
View all scheduled experiment runs and their current status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Complete Schedule View
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
This view will show a comprehensive calendar and list of all scheduled experiment runs,
|
||||
including dates, times, assigned team members, and current status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IndicateAvailability({ user, onBack }: { user: User; onBack: () => void }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Scheduling
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Indicate Availability
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Set your availability preferences and time slots for upcoming experiment runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<AvailabilityCalendar user={user} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }) {
|
||||
// Keep the original ScheduleExperiment implementation here for now
|
||||
// TODO: Move this to scheduling/views/ScheduleExperimentImpl.tsx and further refactor
|
||||
export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -334,7 +74,15 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
const [lockedSchedules, setLockedSchedules] = useState<Set<string>>(new Set())
|
||||
// Track which repetitions are currently being scheduled
|
||||
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
|
||||
|
||||
// Track conductor assignments for each phase marker (markerId -> conductorIds[])
|
||||
const [conductorAssignments, setConductorAssignments] = useState<Record<string, string[]>>({})
|
||||
// Horizontal calendar state: zoom level (number of days to show) and current view date
|
||||
const [calendarZoom, setCalendarZoom] = useState(3) // Number of days to display
|
||||
const [calendarStartDate, setCalendarStartDate] = useState(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return today
|
||||
})
|
||||
|
||||
// Ref for calendar container to preserve scroll position
|
||||
const calendarRef = useRef<HTMLDivElement>(null)
|
||||
@@ -708,16 +456,23 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
}
|
||||
|
||||
// Update phase timing when a marker is moved
|
||||
const updatePhaseTiming = (repId: string, phase: 'soaking' | 'airdrying' | 'cracking', newTime: Date) => {
|
||||
const updatePhaseTiming = useCallback((repId: string, phase: 'soaking' | 'airdrying' | 'cracking', newTime: Date) => {
|
||||
console.log('updatePhaseTiming called:', repId, phase, newTime)
|
||||
setScheduledRepetitions(prev => {
|
||||
const current = prev[repId]
|
||||
if (!current) return prev
|
||||
if (!current) {
|
||||
console.log('No current repetition found for:', repId)
|
||||
return prev
|
||||
}
|
||||
|
||||
const experimentId = current.experimentId
|
||||
const soaking = soakingByExperiment[experimentId]
|
||||
const airdrying = airdryingByExperiment[experimentId]
|
||||
|
||||
if (!soaking || !airdrying) return prev
|
||||
if (!soaking || !airdrying) {
|
||||
console.log('Missing soaking or airdrying data for experiment:', experimentId)
|
||||
return prev
|
||||
}
|
||||
|
||||
let newScheduled = { ...prev }
|
||||
|
||||
@@ -741,6 +496,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
console.log('Updated repetition times:', newScheduled[repId])
|
||||
} else if (phase === 'airdrying') {
|
||||
const airdryingStart = clampToReasonableHours(newTime)
|
||||
const soakingStart = clampToReasonableHours(new Date(airdryingStart.getTime() - (soaking.soaking_duration_minutes * 60000)))
|
||||
@@ -752,6 +508,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
console.log('Updated repetition times:', newScheduled[repId])
|
||||
} else if (phase === 'cracking') {
|
||||
const crackingStart = clampToReasonableHours(newTime)
|
||||
const airdryingStart = clampToReasonableHours(new Date(crackingStart.getTime() - (airdrying.duration_minutes * 60000)))
|
||||
@@ -763,11 +520,12 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
console.log('Updated repetition times:', newScheduled[repId])
|
||||
}
|
||||
|
||||
return newScheduled
|
||||
})
|
||||
}
|
||||
}, [soakingByExperiment, airdryingByExperiment])
|
||||
|
||||
// Generate calendar events for scheduled repetitions (memoized)
|
||||
const generateRepetitionEvents = useCallback((): CalendarEvent[] => {
|
||||
@@ -1010,6 +768,132 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
}
|
||||
}, [scheduledRepetitions, restoreScrollPosition])
|
||||
|
||||
// Transform data for horizontal timeline calendar
|
||||
const horizontalCalendarData = useMemo(() => {
|
||||
// Calculate date range based on zoom level and current start date
|
||||
const startDate = new Date(calendarStartDate)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setDate(endDate.getDate() + (calendarZoom - 1))
|
||||
|
||||
// Transform conductor availabilities
|
||||
const conductorAvailabilities = conductors
|
||||
.filter(c => selectedConductorIds.has(c.id))
|
||||
.map((conductor, index) => {
|
||||
const conductorName = [conductor.first_name, conductor.last_name].filter(Boolean).join(' ') || conductor.email
|
||||
const color = conductorColorMap[conductor.id] || colorPalette[index % colorPalette.length]
|
||||
|
||||
const availability = availabilityEvents
|
||||
.filter(event => event.resource === conductor.id)
|
||||
.map(event => ({
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end)
|
||||
}))
|
||||
|
||||
return {
|
||||
conductorId: conductor.id,
|
||||
conductorName,
|
||||
color,
|
||||
availability
|
||||
}
|
||||
})
|
||||
|
||||
// Transform phase markers
|
||||
const phaseMarkers: Array<{
|
||||
id: string
|
||||
repetitionId: string
|
||||
experimentId: string
|
||||
phase: 'soaking' | 'airdrying' | 'cracking'
|
||||
startTime: Date
|
||||
assignedConductors: string[]
|
||||
locked: boolean
|
||||
}> = []
|
||||
|
||||
Object.values(scheduledRepetitions).forEach(scheduled => {
|
||||
const repId = scheduled.repetitionId
|
||||
const markerIdPrefix = repId
|
||||
|
||||
if (scheduled.soakingStart) {
|
||||
phaseMarkers.push({
|
||||
id: `${markerIdPrefix}-soaking`,
|
||||
repetitionId: repId,
|
||||
experimentId: scheduled.experimentId,
|
||||
phase: 'soaking',
|
||||
startTime: scheduled.soakingStart,
|
||||
assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || [],
|
||||
locked: lockedSchedules.has(repId)
|
||||
})
|
||||
}
|
||||
|
||||
if (scheduled.airdryingStart) {
|
||||
phaseMarkers.push({
|
||||
id: `${markerIdPrefix}-airdrying`,
|
||||
repetitionId: repId,
|
||||
experimentId: scheduled.experimentId,
|
||||
phase: 'airdrying',
|
||||
startTime: scheduled.airdryingStart,
|
||||
assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || [],
|
||||
locked: lockedSchedules.has(repId)
|
||||
})
|
||||
}
|
||||
|
||||
if (scheduled.crackingStart) {
|
||||
phaseMarkers.push({
|
||||
id: `${markerIdPrefix}-cracking`,
|
||||
repetitionId: repId,
|
||||
experimentId: scheduled.experimentId,
|
||||
phase: 'cracking',
|
||||
startTime: scheduled.crackingStart,
|
||||
assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || [],
|
||||
locked: lockedSchedules.has(repId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
conductorAvailabilities,
|
||||
phaseMarkers
|
||||
}
|
||||
}, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, lockedSchedules, calendarStartDate, calendarZoom])
|
||||
|
||||
// Handlers for horizontal calendar
|
||||
const handleHorizontalMarkerDrag = useCallback((markerId: string, newTime: Date) => {
|
||||
console.log('handleHorizontalMarkerDrag called:', markerId, newTime)
|
||||
// Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens
|
||||
// Split by '-' and take the last segment as phase, rest as repId
|
||||
const parts = markerId.split('-')
|
||||
const phase = parts[parts.length - 1] as 'soaking' | 'airdrying' | 'cracking'
|
||||
const repId = parts.slice(0, -1).join('-')
|
||||
console.log('Updating phase timing:', repId, phase, newTime)
|
||||
updatePhaseTiming(repId, phase, newTime)
|
||||
setRepetitionsWithTimes(prev => new Set(prev).add(repId))
|
||||
}, [updatePhaseTiming])
|
||||
|
||||
const handleHorizontalMarkerAssignConductors = useCallback((markerId: string, conductorIds: string[]) => {
|
||||
setConductorAssignments(prev => ({
|
||||
...prev,
|
||||
[markerId]: conductorIds
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleHorizontalMarkerLockToggle = useCallback((markerId: string) => {
|
||||
// Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens
|
||||
// Split by '-' and take all but the last segment as repId
|
||||
const parts = markerId.split('-')
|
||||
const repId = parts.slice(0, -1).join('-')
|
||||
setLockedSchedules(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(repId)) {
|
||||
next.delete(repId)
|
||||
} else {
|
||||
next.add(repId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden -m-4 md:-m-6">
|
||||
@@ -1047,9 +931,114 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
|
||||
<>
|
||||
{/* Horizontal Timeline Calendar - First */}
|
||||
<div className="mb-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="mb-3 flex-shrink-0 flex items-center justify-between flex-wrap gap-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||
|
||||
{/* Navigation and Zoom Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous Day Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(calendarStartDate)
|
||||
newDate.setDate(newDate.getDate() - 1)
|
||||
setCalendarStartDate(newDate)
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors flex items-center gap-1"
|
||||
title="Previous day"
|
||||
>
|
||||
<svg className="w-4 h-4" 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 className="text-sm">Prev</span>
|
||||
</button>
|
||||
|
||||
{/* Date Display */}
|
||||
<div className="px-3 py-1.5 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded text-sm min-w-[140px] text-center">
|
||||
{moment(horizontalCalendarData.startDate).format('MMM D')} - {moment(horizontalCalendarData.endDate).format('MMM D, YYYY')}
|
||||
</div>
|
||||
|
||||
{/* Next Day Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newDate = new Date(calendarStartDate)
|
||||
newDate.setDate(newDate.getDate() + 1)
|
||||
setCalendarStartDate(newDate)
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors flex items-center gap-1"
|
||||
title="Next day"
|
||||
>
|
||||
<span className="text-sm">Next</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Zoom Out Button (show more days) */}
|
||||
<button
|
||||
onClick={() => setCalendarZoom(prev => Math.min(30, prev + 1))}
|
||||
disabled={calendarZoom >= 30}
|
||||
className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 disabled:bg-gray-100 disabled:dark:bg-gray-700 disabled:cursor-not-allowed text-blue-700 dark:text-blue-300 rounded transition-colors"
|
||||
title="Zoom out (show more days)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Zoom Level Display */}
|
||||
<div className="px-2 py-1.5 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded text-xs min-w-[60px] text-center">
|
||||
{calendarZoom} {calendarZoom === 1 ? 'day' : 'days'}
|
||||
</div>
|
||||
|
||||
{/* Zoom In Button (show fewer days) */}
|
||||
<button
|
||||
onClick={() => setCalendarZoom(prev => Math.max(1, prev - 1))}
|
||||
disabled={calendarZoom <= 1}
|
||||
className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 disabled:bg-gray-100 disabled:dark:bg-gray-700 disabled:cursor-not-allowed text-blue-700 dark:text-blue-300 rounded transition-colors"
|
||||
title="Zoom in (show fewer days)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Today Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
setCalendarStartDate(today)
|
||||
}}
|
||||
className="px-3 py-1.5 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 rounded transition-colors text-sm"
|
||||
title="Jump to today"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<HorizontalTimelineCalendar
|
||||
startDate={horizontalCalendarData.startDate}
|
||||
endDate={horizontalCalendarData.endDate}
|
||||
conductorAvailabilities={horizontalCalendarData.conductorAvailabilities}
|
||||
phaseMarkers={horizontalCalendarData.phaseMarkers}
|
||||
onMarkerDrag={handleHorizontalMarkerDrag}
|
||||
onMarkerAssignConductors={handleHorizontalMarkerAssignConductors}
|
||||
onMarkerLockToggle={handleHorizontalMarkerLockToggle}
|
||||
timeStep={15}
|
||||
minHour={6}
|
||||
maxHour={22}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conductors and Experiment Phases Dropdowns - Second */}
|
||||
<div className="grid grid-cols-2 gap-6 flex-shrink-0">
|
||||
{/* Left: Conductors with future availability */}
|
||||
<div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Conductors</h2>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Select to consider for scheduling</span>
|
||||
@@ -1123,7 +1112,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</div>
|
||||
|
||||
{/* Right: Phases -> Experiments -> Repetitions */}
|
||||
<div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Experiment Phases</h2>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Expand and select repetitions</span>
|
||||
@@ -1290,120 +1279,13 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Week Calendar for selected conductors' availability */}
|
||||
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="mb-3 flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||
</div>
|
||||
<div ref={calendarRef} className="flex-1 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DnDCalendar
|
||||
localizer={localizer}
|
||||
events={calendarEvents}
|
||||
backgroundEvents={availabilityEvents}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
titleAccessor="title"
|
||||
style={{ height: '100%' }}
|
||||
view={calendarView}
|
||||
onView={setCalendarView}
|
||||
date={currentDate}
|
||||
onNavigate={setCurrentDate}
|
||||
views={[Views.WEEK, Views.DAY]}
|
||||
dayLayoutAlgorithm="no-overlap"
|
||||
draggableAccessor={draggableAccessor}
|
||||
onSelectEvent={(event: any) => {
|
||||
// Handle clicking on repetition markers to toggle lock
|
||||
const resource = event.resource as string
|
||||
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
||||
const eventId = event.id as string
|
||||
const repId = eventId.split('-')[0]
|
||||
// Toggle lock for this repetition - this will update both checkbox and marker icons
|
||||
toggleScheduleLock(repId)
|
||||
// Prevent default popup behavior
|
||||
return false
|
||||
}
|
||||
}}
|
||||
onEventDrop={({ event, start }: { event: any, start: Date }) => {
|
||||
// Preserve scroll position before updating
|
||||
preserveScrollPosition()
|
||||
|
||||
// Handle dragging repetition markers
|
||||
const eventId = event.id as string
|
||||
// Clamp to reasonable working hours (5AM to 11PM) to prevent extreme times
|
||||
const clampToReasonableHours = (d: Date) => {
|
||||
const min = new Date(d)
|
||||
min.setHours(5, 0, 0, 0)
|
||||
const max = new Date(d)
|
||||
max.setHours(23, 0, 0, 0)
|
||||
const t = d.getTime()
|
||||
return new Date(Math.min(Math.max(t, min.getTime()), max.getTime()))
|
||||
}
|
||||
const clampedStart = clampToReasonableHours(start)
|
||||
|
||||
let repId = ''
|
||||
if (eventId.includes('-soaking')) {
|
||||
repId = eventId.replace('-soaking', '')
|
||||
updatePhaseTiming(repId, 'soaking', clampedStart)
|
||||
} else if (eventId.includes('-airdrying')) {
|
||||
repId = eventId.replace('-airdrying', '')
|
||||
updatePhaseTiming(repId, 'airdrying', clampedStart)
|
||||
} else if (eventId.includes('-cracking')) {
|
||||
repId = eventId.replace('-cracking', '')
|
||||
updatePhaseTiming(repId, 'cracking', clampedStart)
|
||||
}
|
||||
|
||||
// Add repetition to show time points
|
||||
if (repId) {
|
||||
setRepetitionsWithTimes(prev => new Set(prev).add(repId))
|
||||
}
|
||||
|
||||
// Restore scroll position after a brief delay to allow for re-render
|
||||
setTimeout(() => {
|
||||
restoreScrollPosition()
|
||||
}, 10)
|
||||
}}
|
||||
eventPropGetter={eventPropGetter}
|
||||
backgroundEventPropGetter={(event: any) => {
|
||||
// Styling for background events (conductor availability)
|
||||
const conductorId = event.resource as string
|
||||
const color = conductorColorMap[conductorId] || '#2563eb'
|
||||
// Use more visible colors - higher opacity for better visibility
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: color + '60', // ~37% opacity for better visibility
|
||||
borderColor: color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid',
|
||||
color: 'transparent',
|
||||
borderRadius: '4px',
|
||||
opacity: 0.8,
|
||||
height: 'auto',
|
||||
minHeight: '20px',
|
||||
fontSize: '0px',
|
||||
padding: '0px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
}
|
||||
}}
|
||||
popup
|
||||
showMultiDayTimes
|
||||
doShowMore={true}
|
||||
step={30}
|
||||
timeslots={2}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Availability Calendar Component
|
||||
function AvailabilityCalendar({ user }: { user: User }) {
|
||||
@@ -1664,13 +1546,13 @@ function AvailabilityCalendar({ user }: { user: User }) {
|
||||
)}
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="h-[600px]">
|
||||
<div className="h-[600px] flex flex-col">
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: '100%' }}
|
||||
style={{ height: '100%', minHeight: '600px' }}
|
||||
view={currentView}
|
||||
onView={setCurrentView}
|
||||
date={currentDate}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar, momentLocalizer, Views } from 'react-big-calendar'
|
||||
import moment from 'moment'
|
||||
import type { User, CalendarEvent } from './types'
|
||||
import { availabilityManagement } from '../../services/supabase'
|
||||
import { BackButton } from './ui/BackButton'
|
||||
import { TimeSlotModal } from './ui/TimeSlotModal'
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css'
|
||||
import '../CalendarStyles.css'
|
||||
|
||||
interface AvailabilityCalendarProps {
|
||||
user: User
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps) {
|
||||
const localizer = momentLocalizer(moment)
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
const [showTimeSlotForm, setShowTimeSlotForm] = useState(false)
|
||||
const [newTimeSlot, setNewTimeSlot] = useState({
|
||||
startTime: '09:00',
|
||||
endTime: '17:00'
|
||||
})
|
||||
const [currentView, setCurrentView] = useState(Views.MONTH)
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
|
||||
// Load availability from DB on mount
|
||||
useEffect(() => {
|
||||
const loadAvailability = async () => {
|
||||
try {
|
||||
const records = await availabilityManagement.getMyAvailability()
|
||||
const mapped: CalendarEvent[] = records.map(r => ({
|
||||
id: r.id,
|
||||
title: 'Available',
|
||||
start: new Date(r.available_from),
|
||||
end: new Date(r.available_to),
|
||||
resource: 'available'
|
||||
}))
|
||||
setEvents(mapped)
|
||||
} catch (e) {
|
||||
console.error('Failed to load availability', e)
|
||||
}
|
||||
}
|
||||
loadAvailability()
|
||||
}, [])
|
||||
|
||||
const eventStyleGetter = (event: CalendarEvent) => {
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: '#10b981', // green-500 for available
|
||||
borderColor: '#10b981',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
display: 'block'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectSlot = ({ start, end }: { start: Date; end: Date }) => {
|
||||
// Set the selected date and show the time slot form
|
||||
setSelectedDate(start)
|
||||
setShowTimeSlotForm(true)
|
||||
|
||||
// Pre-fill the form with the selected time range
|
||||
const startTime = moment(start).format('HH:mm')
|
||||
const endTime = moment(end).format('HH:mm')
|
||||
setNewTimeSlot({
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectEvent = async (event: CalendarEvent) => {
|
||||
if (!window.confirm('Do you want to remove this availability?')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (typeof event.id === 'string') {
|
||||
await availabilityManagement.deleteAvailability(event.id)
|
||||
}
|
||||
setEvents(events.filter(e => e.id !== event.id))
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to delete availability.')
|
||||
console.error('Failed to delete availability', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTimeSlot = async () => {
|
||||
if (!selectedDate) return
|
||||
|
||||
const [startHour, startMinute] = newTimeSlot.startTime.split(':').map(Number)
|
||||
const [endHour, endMinute] = newTimeSlot.endTime.split(':').map(Number)
|
||||
|
||||
const startDateTime = new Date(selectedDate)
|
||||
startDateTime.setHours(startHour, startMinute, 0, 0)
|
||||
|
||||
const endDateTime = new Date(selectedDate)
|
||||
endDateTime.setHours(endHour, endMinute, 0, 0)
|
||||
|
||||
// Check for overlapping events
|
||||
const hasOverlap = events.some(event => {
|
||||
const eventStart = new Date(event.start)
|
||||
const eventEnd = new Date(event.end)
|
||||
return (
|
||||
eventStart.toDateString() === selectedDate.toDateString() &&
|
||||
((startDateTime >= eventStart && startDateTime < eventEnd) ||
|
||||
(endDateTime > eventStart && endDateTime <= eventEnd) ||
|
||||
(startDateTime <= eventStart && endDateTime >= eventEnd))
|
||||
)
|
||||
})
|
||||
|
||||
if (hasOverlap) {
|
||||
alert('This time slot overlaps with an existing availability. Please choose a different time.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Persist to DB first to get real ID and server validation
|
||||
const created = await availabilityManagement.createAvailability({
|
||||
available_from: startDateTime.toISOString(),
|
||||
available_to: endDateTime.toISOString()
|
||||
})
|
||||
|
||||
const newEvent: CalendarEvent = {
|
||||
id: created.id,
|
||||
title: 'Available',
|
||||
start: new Date(created.available_from),
|
||||
end: new Date(created.available_to),
|
||||
resource: 'available'
|
||||
}
|
||||
|
||||
setEvents([...events, newEvent])
|
||||
setShowTimeSlotForm(false)
|
||||
setSelectedDate(null)
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Failed to save availability. Please try again.')
|
||||
console.error('Failed to create availability', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelTimeSlot = () => {
|
||||
setShowTimeSlotForm(false)
|
||||
setSelectedDate(null)
|
||||
}
|
||||
|
||||
const getEventsForDate = (date: Date) => {
|
||||
return events.filter(event => {
|
||||
const eventDate = new Date(event.start)
|
||||
return eventDate.toDateString() === date.toDateString()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Calendar Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Availability Calendar
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Click and drag to add availability slots, or click on existing events to remove them. You can add multiple time slots per day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center space-x-4 mt-4 sm:mt-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded mr-2"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slot Form Modal */}
|
||||
{showTimeSlotForm && selectedDate && (
|
||||
<TimeSlotModal
|
||||
selectedDate={selectedDate}
|
||||
newTimeSlot={newTimeSlot}
|
||||
existingEvents={events}
|
||||
onTimeSlotChange={setNewTimeSlot}
|
||||
onAdd={handleAddTimeSlot}
|
||||
onCancel={handleCancelTimeSlot}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="h-[600px] flex flex-col">
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: '100%', minHeight: '600px' }}
|
||||
view={currentView}
|
||||
onView={setCurrentView}
|
||||
date={currentDate}
|
||||
onNavigate={setCurrentDate}
|
||||
views={[Views.MONTH, Views.WEEK, Views.DAY]}
|
||||
selectable
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
popup
|
||||
showMultiDayTimes
|
||||
step={30}
|
||||
timeslots={2}
|
||||
min={new Date(2024, 0, 1, 6, 0)} // 6:00 AM
|
||||
max={new Date(2024, 0, 1, 22, 0)} // 10:00 PM
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
120
scheduling-remote/src/components/scheduling/Scheduling.tsx
Normal file
120
scheduling-remote/src/components/scheduling/Scheduling.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { SchedulingProps, SchedulingView } from './types'
|
||||
import { ViewSchedule } from './views/ViewSchedule'
|
||||
import { IndicateAvailability } from './views/IndicateAvailability'
|
||||
import { SchedulingCard } from './ui/SchedulingCard'
|
||||
|
||||
// Import the original ScheduleExperiment for now
|
||||
// TODO: Further refactor ScheduleExperiment into smaller components
|
||||
import { ScheduleExperiment } from '../Scheduling'
|
||||
|
||||
export function Scheduling({ user, currentRoute }: SchedulingProps) {
|
||||
// Extract current view from route
|
||||
const getCurrentView = (): SchedulingView => {
|
||||
if (currentRoute === '/scheduling') {
|
||||
return 'main'
|
||||
}
|
||||
const match = currentRoute.match(/^\/scheduling\/(.+)$/)
|
||||
if (match) {
|
||||
const subRoute = match[1]
|
||||
switch (subRoute) {
|
||||
case 'view-schedule':
|
||||
return 'view-schedule'
|
||||
case 'indicate-availability':
|
||||
return 'indicate-availability'
|
||||
case 'schedule-experiment':
|
||||
return 'schedule-experiment'
|
||||
default:
|
||||
return 'main'
|
||||
}
|
||||
}
|
||||
return 'main'
|
||||
}
|
||||
|
||||
const currentView = getCurrentView()
|
||||
|
||||
const handleCardClick = (view: SchedulingView) => {
|
||||
const newPath = view === 'main' ? '/scheduling' : `/scheduling/${view}`
|
||||
window.history.pushState({}, '', newPath)
|
||||
// Trigger a popstate event to update the route
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleBackToMain = () => {
|
||||
window.history.pushState({}, '', '/scheduling')
|
||||
// Trigger a popstate event to update the route
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
// Render different views based on currentView
|
||||
if (currentView === 'view-schedule') {
|
||||
return <ViewSchedule user={user} onBack={handleBackToMain} />
|
||||
}
|
||||
|
||||
if (currentView === 'indicate-availability') {
|
||||
return <IndicateAvailability user={user} onBack={handleBackToMain} />
|
||||
}
|
||||
|
||||
if (currentView === 'schedule-experiment') {
|
||||
return <ScheduleExperiment user={user} onBack={handleBackToMain} />
|
||||
}
|
||||
|
||||
// Main view with cards
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Scheduling
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This is the scheduling module of the dashboard. Here you can indicate your availability for upcoming experiment runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scheduling Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* View Complete Schedule Card */}
|
||||
<SchedulingCard
|
||||
title="View Complete Schedule"
|
||||
description="View the complete schedule of all upcoming experiment runs and their current status."
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
status={{ label: 'Available', color: 'green' }}
|
||||
footer={{ left: 'All experiments', right: 'View Schedule' }}
|
||||
onClick={() => handleCardClick('view-schedule')}
|
||||
/>
|
||||
|
||||
{/* Indicate Availability Card */}
|
||||
<SchedulingCard
|
||||
title="Indicate Your Availability"
|
||||
description="Set your availability preferences and time slots for upcoming experiment runs."
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
status={{ label: 'Active', color: 'blue' }}
|
||||
footer={{ left: 'Personal settings', right: 'Set Availability' }}
|
||||
onClick={() => handleCardClick('indicate-availability')}
|
||||
/>
|
||||
|
||||
{/* Schedule Experiment Card */}
|
||||
<SchedulingCard
|
||||
title="Schedule Experiment"
|
||||
description="Schedule specific experiment runs and assign team members to upcoming sessions."
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-purple-600 dark:text-purple-400" 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>
|
||||
}
|
||||
status={{ label: 'Planning', color: 'yellow' }}
|
||||
footer={{ left: 'Experiment planning', right: 'Schedule Now' }}
|
||||
onClick={() => handleCardClick('schedule-experiment')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { User, CalendarEvent } from '../types'
|
||||
import { userManagement, supabase } from '../../../services/supabase'
|
||||
|
||||
const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444']
|
||||
|
||||
export function useConductors() {
|
||||
const [conductors, setConductors] = useState<User[]>([])
|
||||
const [conductorIdsWithFutureAvailability, setConductorIdsWithFutureAvailability] = useState<Set<string>>(new Set())
|
||||
const [selectedConductorIds, setSelectedConductorIds] = useState<Set<string>>(new Set())
|
||||
const [availabilityEvents, setAvailabilityEvents] = useState<CalendarEvent[]>([])
|
||||
const [conductorColorMap, setConductorColorMap] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const loadConductors = async () => {
|
||||
try {
|
||||
const allUsers = await userManagement.getAllUsers()
|
||||
const conductorsOnly = allUsers.filter(u => u.roles.includes('conductor'))
|
||||
setConductors(conductorsOnly)
|
||||
const conductorIds = conductorsOnly.map(c => c.id)
|
||||
setConductorIdsWithFutureAvailability(new Set(conductorIds))
|
||||
} catch (e) {
|
||||
console.error('Failed to load conductors', e)
|
||||
}
|
||||
}
|
||||
loadConductors()
|
||||
}, [])
|
||||
|
||||
// Fetch availability for selected conductors and build calendar events
|
||||
useEffect(() => {
|
||||
const loadSelectedAvailability = async () => {
|
||||
try {
|
||||
const selectedIds = Array.from(selectedConductorIds)
|
||||
if (selectedIds.length === 0) {
|
||||
setAvailabilityEvents([])
|
||||
return
|
||||
}
|
||||
|
||||
// Assign consistent colors per conductor based on their position in the full conductors array
|
||||
const newColorMap: Record<string, string> = {}
|
||||
conductors.forEach((conductor, index) => {
|
||||
if (selectedIds.includes(conductor.id)) {
|
||||
newColorMap[conductor.id] = colorPalette[index % colorPalette.length]
|
||||
}
|
||||
})
|
||||
setConductorColorMap(newColorMap)
|
||||
|
||||
// Fetch availability for selected conductors in a single query
|
||||
const { data, error } = await supabase
|
||||
.from('conductor_availability')
|
||||
.select('*')
|
||||
.in('user_id', selectedIds)
|
||||
.eq('status', 'active')
|
||||
.gt('available_to', new Date().toISOString())
|
||||
.order('available_from', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Map user_id -> display name
|
||||
const idToName: Record<string, string> = {}
|
||||
conductors.forEach(c => {
|
||||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
||||
idToName[c.id] = name
|
||||
})
|
||||
|
||||
const events: CalendarEvent[] = (data || []).map((r: any) => {
|
||||
const conductorId = r.user_id
|
||||
return {
|
||||
id: r.id,
|
||||
title: `${idToName[conductorId] || 'Conductor'}`,
|
||||
start: new Date(r.available_from),
|
||||
end: new Date(r.available_to),
|
||||
resource: conductorId
|
||||
}
|
||||
})
|
||||
|
||||
setAvailabilityEvents(events)
|
||||
} catch (e) {
|
||||
// Fail silently for calendar, do not break main UI
|
||||
setAvailabilityEvents([])
|
||||
}
|
||||
}
|
||||
|
||||
loadSelectedAvailability()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedConductorIds, conductors])
|
||||
|
||||
const toggleConductor = (id: string) => {
|
||||
setSelectedConductorIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllConductors = () => {
|
||||
const availableConductorIds = conductors
|
||||
.filter(c => conductorIdsWithFutureAvailability.has(c.id))
|
||||
.map(c => c.id)
|
||||
|
||||
setSelectedConductorIds(prev => {
|
||||
const allSelected = availableConductorIds.every(id => prev.has(id))
|
||||
if (allSelected) {
|
||||
return new Set()
|
||||
} else {
|
||||
return new Set(availableConductorIds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
conductors,
|
||||
conductorIdsWithFutureAvailability,
|
||||
selectedConductorIds,
|
||||
availabilityEvents,
|
||||
conductorColorMap,
|
||||
colorPalette,
|
||||
toggleConductor,
|
||||
toggleAllConductors
|
||||
}
|
||||
}
|
||||
|
||||
29
scheduling-remote/src/components/scheduling/types.ts
Normal file
29
scheduling-remote/src/components/scheduling/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../../services/supabase'
|
||||
|
||||
// Type definitions for calendar events
|
||||
export interface CalendarEvent {
|
||||
id: number | string
|
||||
title: string
|
||||
start: Date
|
||||
end: Date
|
||||
resource?: string
|
||||
}
|
||||
|
||||
export interface SchedulingProps {
|
||||
user: User
|
||||
currentRoute: string
|
||||
}
|
||||
|
||||
export type SchedulingView = 'main' | 'view-schedule' | 'indicate-availability' | 'schedule-experiment'
|
||||
|
||||
export interface ScheduledRepetition {
|
||||
repetitionId: string
|
||||
experimentId: string
|
||||
soakingStart: Date | null
|
||||
airdryingStart: Date | null
|
||||
crackingStart: Date | null
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface BackButtonProps {
|
||||
onClick: () => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function BackButton({ onClick, label = 'Back to Scheduling' }: BackButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface DropdownCurtainProps {
|
||||
title: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
children: ReactNode
|
||||
headerAction?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DropdownCurtain({ title, expanded, onToggle, children, headerAction, className = '' }: DropdownCurtainProps) {
|
||||
return (
|
||||
<div className={`border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700 ${className}`}>
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerAction}
|
||||
<svg className={`w-4 h-4 text-gray-500 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SchedulingView } from '../types'
|
||||
|
||||
interface SchedulingCardProps {
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
status: {
|
||||
label: string
|
||||
color: 'green' | 'blue' | 'yellow'
|
||||
}
|
||||
footer: {
|
||||
left: string
|
||||
right: string
|
||||
}
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function SchedulingCard({ title, description, icon, status, footer, onClick }: SchedulingCardProps) {
|
||||
const statusColors = {
|
||||
green: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-400',
|
||||
blue: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400',
|
||||
yellow: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[status.color]}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{footer.left}</span>
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<span className="mr-1">{footer.right}</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
123
scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx
Normal file
123
scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import moment from 'moment'
|
||||
import type { CalendarEvent } from '../types'
|
||||
|
||||
interface TimeSlotModalProps {
|
||||
selectedDate: Date
|
||||
newTimeSlot: {
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
existingEvents: CalendarEvent[]
|
||||
onTimeSlotChange: (timeSlot: { startTime: string; endTime: string }) => void
|
||||
onAdd: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function TimeSlotModal({
|
||||
selectedDate,
|
||||
newTimeSlot,
|
||||
existingEvents,
|
||||
onTimeSlotChange,
|
||||
onAdd,
|
||||
onCancel
|
||||
}: TimeSlotModalProps) {
|
||||
const getEventsForDate = (date: Date) => {
|
||||
return existingEvents.filter(event => {
|
||||
const eventDate = new Date(event.start)
|
||||
return eventDate.toDateString() === date.toDateString()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Add Availability for {moment(selectedDate).format('MMMM D, YYYY')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newTimeSlot.startTime}
|
||||
onChange={(e) => onTimeSlotChange({ ...newTimeSlot, startTime: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newTimeSlot.endTime}
|
||||
onChange={(e) => onTimeSlotChange({ ...newTimeSlot, endTime: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show existing time slots for this date */}
|
||||
{getEventsForDate(selectedDate).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Existing time slots:
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{getEventsForDate(selectedDate).map(event => (
|
||||
<div key={event.id} className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{moment(event.start).format('HH:mm')} - {moment(event.end).format('HH:mm')} ({event.title})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Time Slot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { User } from '../types'
|
||||
import { BackButton } from '../ui/BackButton'
|
||||
import { AvailabilityCalendar } from '../AvailabilityCalendar'
|
||||
|
||||
interface IndicateAvailabilityProps {
|
||||
user: User
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function IndicateAvailability({ user, onBack }: IndicateAvailabilityProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<BackButton onClick={onBack} />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Indicate Availability
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Set your availability preferences and time slots for upcoming experiment runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<AvailabilityCalendar user={user} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// This is a temporary wrapper that imports the original ScheduleExperiment logic
|
||||
// TODO: Further refactor this component into smaller subcomponents and hooks
|
||||
// For now, we're keeping the original implementation but moving it to its own file
|
||||
|
||||
export { ScheduleExperiment } from './ScheduleExperimentImpl'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Temporary implementation - imports the original ScheduleExperiment from the old file
|
||||
// TODO: Further refactor this into smaller components and hooks
|
||||
// For now, we're keeping it in the original file but will move it here later
|
||||
|
||||
import type { User } from '../types'
|
||||
|
||||
// Re-export the original implementation from the old file location
|
||||
export { ScheduleExperiment } from '../../Scheduling'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { User } from '../types'
|
||||
import { BackButton } from '../ui/BackButton'
|
||||
|
||||
interface ViewScheduleProps {
|
||||
user: User
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function ViewSchedule({ user, onBack }: ViewScheduleProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<BackButton onClick={onBack} />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Complete Schedule
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
View all scheduled experiment runs and their current status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Complete Schedule View
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
This view will show a comprehensive calendar and list of all scheduled experiment runs,
|
||||
including dates, times, assigned team members, and current status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user