Remove CalendarStyles.css and Scheduling.tsx components, updating the project structure for improved maintainability. Update Supabase CLI version and modify experiment_repetitions SQL migration to include scheduled_date. Enhance scheduling component in the remote app with improved drag-and-drop functionality and UI adjustments for better user experience.
This commit is contained in:
@@ -1,250 +0,0 @@
|
|||||||
/* Custom styles for React Big Calendar to match dashboard theme */
|
|
||||||
|
|
||||||
.rbc-calendar {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode support */
|
|
||||||
.dark .rbc-calendar {
|
|
||||||
background: #1f2937;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header styling */
|
|
||||||
.rbc-header {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
color: #374151;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 12px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-header {
|
|
||||||
background: #374151;
|
|
||||||
border-bottom: 1px solid #4b5563;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Today styling */
|
|
||||||
.rbc-today {
|
|
||||||
background: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-today {
|
|
||||||
background: #1e3a8a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date cells */
|
|
||||||
.rbc-date-cell {
|
|
||||||
color: #374151;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-date-cell {
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Event styling */
|
|
||||||
.rbc-event {
|
|
||||||
border-radius: 4px;
|
|
||||||
border: none;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-event-content {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Month view specific */
|
|
||||||
.rbc-month-view {
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-month-view {
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-month-row {
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-month-row {
|
|
||||||
border-bottom: 1px solid #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-date-cell {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Week and day view specific */
|
|
||||||
.rbc-time-view {
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-time-view {
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-time-header {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-time-header {
|
|
||||||
background: #374151;
|
|
||||||
border-bottom: 1px solid #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-time-content {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-time-content {
|
|
||||||
background: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time slots */
|
|
||||||
.rbc-time-slot {
|
|
||||||
border-top: 1px solid #f1f5f9;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-time-slot {
|
|
||||||
border-top: 1px solid #374151;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar */
|
|
||||||
.rbc-toolbar {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-toolbar {
|
|
||||||
background: #374151;
|
|
||||||
border-bottom: 1px solid #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-toolbar button {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #374151;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 6px 12px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-toolbar button:hover {
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-toolbar button:active,
|
|
||||||
.rbc-toolbar button.rbc-active {
|
|
||||||
background: #3b82f6;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-toolbar button {
|
|
||||||
background: #1f2937;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-toolbar button:hover {
|
|
||||||
background: #374151;
|
|
||||||
border-color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-toolbar button:active,
|
|
||||||
.dark .rbc-toolbar button.rbc-active {
|
|
||||||
background: #3b82f6;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Labels */
|
|
||||||
.rbc-toolbar-label {
|
|
||||||
color: #111827;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .rbc-toolbar-label {
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Drag and drop improvements */
|
|
||||||
.rbc-event {
|
|
||||||
cursor: grab !important;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-event:active {
|
|
||||||
cursor: grabbing !important;
|
|
||||||
transform: scale(1.05);
|
|
||||||
z-index: 1000 !important;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve event spacing and visibility */
|
|
||||||
.rbc-event-content {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better visual feedback for dragging */
|
|
||||||
.rbc-addons-dnd-dragging {
|
|
||||||
opacity: 0.8;
|
|
||||||
transform: rotate(2deg);
|
|
||||||
z-index: 1000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-addons-dnd-drag-preview {
|
|
||||||
background: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
border: 2px dashed #3b82f6 !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
padding: 8px 12px !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
color: #1f2937 !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve event hover states */
|
|
||||||
.rbc-event:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better spacing between events */
|
|
||||||
.rbc-time-slot {
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.rbc-toolbar {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-toolbar button {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-toolbar-label {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
v2.65.2
|
v2.65.5
|
||||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS public.experiment_repetitions (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
||||||
repetition_number INTEGER NOT NULL CHECK (repetition_number > 0),
|
repetition_number INTEGER NOT NULL CHECK (repetition_number > 0),
|
||||||
|
scheduled_date TIMESTAMP WITH TIME ZONE,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'cancelled')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'cancelled')),
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|||||||
@@ -36,3 +36,6 @@ ADD CONSTRAINT unique_meyer_cracker_parameters_per_repetition
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
156
scheduling-remote/REFACTORING_PROPOSAL.md
Normal file
156
scheduling-remote/REFACTORING_PROPOSAL.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Scheduling Component Refactoring Proposal
|
||||||
|
|
||||||
|
## Current State Analysis
|
||||||
|
|
||||||
|
The `ScheduleExperiment` component is approximately **1,140 lines** and handles multiple responsibilities:
|
||||||
|
|
||||||
|
1. **Conductor Management** (~150 lines)
|
||||||
|
- Fetching conductors
|
||||||
|
- Selection state
|
||||||
|
- Color mapping
|
||||||
|
- Availability fetching
|
||||||
|
|
||||||
|
2. **Phase/Experiment/Repetition Management** (~300 lines)
|
||||||
|
- Phase expansion/collapse
|
||||||
|
- Experiment loading
|
||||||
|
- Repetition creation/selection
|
||||||
|
- Soaking/airdrying data fetching
|
||||||
|
|
||||||
|
3. **Calendar Event Management** (~200 lines)
|
||||||
|
- Event generation
|
||||||
|
- Drag and drop handling
|
||||||
|
- Availability events
|
||||||
|
- Styling and theming
|
||||||
|
|
||||||
|
4. **Scheduling Logic** (~300 lines)
|
||||||
|
- Repetition spawning
|
||||||
|
- Staggering calculations
|
||||||
|
- Phase timing updates
|
||||||
|
- Lock/unlock functionality
|
||||||
|
- Database persistence
|
||||||
|
|
||||||
|
5. **UI Rendering** (~200 lines)
|
||||||
|
- Conductor panel
|
||||||
|
- Phase/Experiment/Repetition tree
|
||||||
|
- Calendar component
|
||||||
|
- Loading/error states
|
||||||
|
|
||||||
|
## Proposed Structure
|
||||||
|
|
||||||
|
### Component Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
ScheduleExperiment (Main Container - ~150 lines)
|
||||||
|
├── ConductorPanel (UI Component - ~100 lines)
|
||||||
|
├── ExperimentPhasePanel (UI Component - ~150 lines)
|
||||||
|
│ ├── ExperimentItem (UI Component - ~80 lines)
|
||||||
|
│ │ └── RepetitionItem (UI Component - ~120 lines)
|
||||||
|
└── SchedulingCalendar (UI Component - ~150 lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Hooks
|
||||||
|
|
||||||
|
```
|
||||||
|
hooks/
|
||||||
|
├── useConductors.ts (~100 lines)
|
||||||
|
│ - Conductor fetching
|
||||||
|
│ - Selection state
|
||||||
|
│ - Color mapping
|
||||||
|
│ - Availability fetching
|
||||||
|
│
|
||||||
|
├── useExperimentPhases.ts (~150 lines)
|
||||||
|
│ - Phase/Experiment/Repetition data fetching
|
||||||
|
│ - Expansion state
|
||||||
|
│ - Selection state
|
||||||
|
│ - Soaking/airdrying data
|
||||||
|
│
|
||||||
|
├── useScheduling.ts (~200 lines)
|
||||||
|
│ - Repetition scheduling state
|
||||||
|
│ - Spawn/stagger logic
|
||||||
|
│ - Phase timing updates
|
||||||
|
│ - Lock/unlock functionality
|
||||||
|
│ - Database persistence
|
||||||
|
│
|
||||||
|
└── useCalendarEvents.ts (~150 lines)
|
||||||
|
- Calendar event generation
|
||||||
|
- Drag and drop handlers
|
||||||
|
- Event styling
|
||||||
|
- Scroll preservation
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
scheduling-remote/src/components/
|
||||||
|
├── Scheduling.tsx (Main router - unchanged)
|
||||||
|
├── ScheduleExperiment/
|
||||||
|
│ ├── index.tsx (Main container - ~150 lines)
|
||||||
|
│ ├── ConductorPanel.tsx (~100 lines)
|
||||||
|
│ ├── ExperimentPhasePanel.tsx (~150 lines)
|
||||||
|
│ ├── ExperimentItem.tsx (~80 lines)
|
||||||
|
│ ├── RepetitionItem.tsx (~120 lines)
|
||||||
|
│ └── SchedulingCalendar.tsx (~150 lines)
|
||||||
|
└── hooks/
|
||||||
|
├── useConductors.ts (~100 lines)
|
||||||
|
├── useExperimentPhases.ts (~150 lines)
|
||||||
|
├── useScheduling.ts (~200 lines)
|
||||||
|
└── useCalendarEvents.ts (~150 lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Maintainability**: Each component has a single, clear responsibility
|
||||||
|
2. **Testability**: Smaller units are easier to test in isolation
|
||||||
|
3. **Reusability**: Components and hooks can be reused elsewhere
|
||||||
|
4. **Readability**: Easier to understand and navigate
|
||||||
|
5. **Performance**: Better optimization opportunities with smaller components
|
||||||
|
6. **Collaboration**: Multiple developers can work on different parts simultaneously
|
||||||
|
|
||||||
|
## Refactoring Strategy
|
||||||
|
|
||||||
|
### Phase 1: Extract Custom Hooks (Low Risk)
|
||||||
|
1. Extract `useConductors` hook
|
||||||
|
2. Extract `useExperimentPhases` hook
|
||||||
|
3. Extract `useScheduling` hook
|
||||||
|
4. Extract `useCalendarEvents` hook
|
||||||
|
5. Test each hook independently
|
||||||
|
|
||||||
|
### Phase 2: Extract UI Components (Medium Risk)
|
||||||
|
1. Extract `ConductorPanel` component
|
||||||
|
2. Extract `ExperimentPhasePanel` component
|
||||||
|
3. Extract `ExperimentItem` component
|
||||||
|
4. Extract `RepetitionItem` component
|
||||||
|
5. Extract `SchedulingCalendar` component
|
||||||
|
6. Test component integration
|
||||||
|
|
||||||
|
### Phase 3: Refactor Main Component (Low Risk)
|
||||||
|
1. Update `ScheduleExperiment` to use extracted hooks and components
|
||||||
|
2. Remove duplicate code
|
||||||
|
3. Final integration testing
|
||||||
|
|
||||||
|
## Best Practices Applied
|
||||||
|
|
||||||
|
1. **Single Responsibility Principle**: Each component/hook has one clear purpose
|
||||||
|
2. **Custom Hooks for Logic**: Business logic separated from UI
|
||||||
|
3. **Props Drilling vs Context**: Use props for direct parent-child, context only if needed
|
||||||
|
4. **Memoization**: Preserve existing `useMemo`/`useCallback` optimizations
|
||||||
|
5. **Type Safety**: Maintain all TypeScript types
|
||||||
|
6. **State Management**: Keep related state together in hooks
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. **Backward Compatible**: All functionality preserved
|
||||||
|
2. **Incremental**: Can be done in phases
|
||||||
|
3. **Testable**: Each phase can be tested independently
|
||||||
|
4. **Reversible**: Changes can be rolled back if needed
|
||||||
|
|
||||||
|
## Estimated Impact
|
||||||
|
|
||||||
|
- **Main Component**: 1,140 lines → ~150 lines (87% reduction)
|
||||||
|
- **Total Lines**: ~1,140 → ~1,200 (slight increase due to imports/exports, but much better organized)
|
||||||
|
- **Complexity**: Significantly reduced per file
|
||||||
|
- **Maintainability**: Dramatically improved
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -186,45 +186,23 @@
|
|||||||
color: #f9fafb;
|
color: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag and drop improvements */
|
/* Basic drag and drop styles - minimal and safe */
|
||||||
.rbc-event {
|
.rbc-event {
|
||||||
cursor: grab !important;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rbc-event:active {
|
.rbc-event:active {
|
||||||
cursor: grabbing !important;
|
cursor: grabbing;
|
||||||
transform: scale(1.05);
|
|
||||||
z-index: 1000 !important;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improve event spacing and visibility */
|
|
||||||
.rbc-event-content {
|
.rbc-event-content {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Better visual feedback for dragging */
|
/* Simple drag feedback */
|
||||||
.rbc-addons-dnd-dragging {
|
.rbc-addons-dnd-dragging {
|
||||||
opacity: 0.8;
|
opacity: 0.5;
|
||||||
transform: rotate(2deg);
|
|
||||||
z-index: 1000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rbc-addons-dnd-drag-preview {
|
|
||||||
background: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
border: 2px dashed #3b82f6 !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
padding: 8px 12px !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
color: #1f2937 !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve event hover states */
|
|
||||||
.rbc-event:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Better spacing between events */
|
/* Better spacing between events */
|
||||||
|
|||||||
@@ -335,8 +335,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
// Track which repetitions are currently being scheduled
|
// Track which repetitions are currently being scheduled
|
||||||
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
|
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// Visual style for repetition markers
|
|
||||||
const [markerStyle, setMarkerStyle] = useState<'circles' | 'dots' | 'icons' | 'lines'>('lines')
|
|
||||||
|
|
||||||
// Ref for calendar container to preserve scroll position
|
// Ref for calendar container to preserve scroll position
|
||||||
const calendarRef = useRef<HTMLDivElement>(null)
|
const calendarRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -435,13 +433,16 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
idToName[c.id] = name
|
idToName[c.id] = name
|
||||||
})
|
})
|
||||||
|
|
||||||
const events: CalendarEvent[] = (data || []).map((r: any) => ({
|
const events: CalendarEvent[] = (data || []).map((r: any) => {
|
||||||
id: r.id,
|
const conductorId = r.user_id
|
||||||
title: `${idToName[r.user_id] || 'Conductor'}`,
|
return {
|
||||||
start: new Date(r.available_from),
|
id: r.id,
|
||||||
end: new Date(r.available_to),
|
title: `${idToName[conductorId] || 'Conductor'}`,
|
||||||
resource: r.user_id // Store conductor ID, not color
|
start: new Date(r.available_from),
|
||||||
}))
|
end: new Date(r.available_to),
|
||||||
|
resource: conductorId // Store conductor ID for color mapping
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
setAvailabilityEvents(events)
|
setAvailabilityEvents(events)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -540,6 +541,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
|
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
|
||||||
spawnSingleRepetition(repId, next)
|
spawnSingleRepetition(repId, next)
|
||||||
// Re-stagger all existing repetitions to prevent overlap
|
// Re-stagger all existing repetitions to prevent overlap
|
||||||
|
// Note: reStaggerRepetitions will automatically skip locked repetitions
|
||||||
reStaggerRepetitions([...next, repId])
|
reStaggerRepetitions([...next, repId])
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@@ -605,7 +607,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-stagger all repetitions to prevent overlap
|
// Re-stagger all repetitions to prevent overlap
|
||||||
const reStaggerRepetitions = (repIds: string[]) => {
|
// IMPORTANT: Skip locked repetitions to prevent them from moving
|
||||||
|
const reStaggerRepetitions = useCallback((repIds: string[]) => {
|
||||||
const tomorrow = new Date()
|
const tomorrow = new Date()
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
tomorrow.setHours(9, 0, 0, 0)
|
tomorrow.setHours(9, 0, 0, 0)
|
||||||
@@ -613,9 +616,14 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
setScheduledRepetitions(prev => {
|
setScheduledRepetitions(prev => {
|
||||||
const newScheduled = { ...prev }
|
const newScheduled = { ...prev }
|
||||||
|
|
||||||
repIds.forEach((repId, index) => {
|
// Filter out locked repetitions - they should not be moved
|
||||||
|
const unlockedRepIds = repIds.filter(repId => !lockedSchedules.has(repId))
|
||||||
|
|
||||||
|
// Calculate stagger index only for unlocked repetitions
|
||||||
|
let staggerIndex = 0
|
||||||
|
unlockedRepIds.forEach((repId) => {
|
||||||
if (newScheduled[repId]) {
|
if (newScheduled[repId]) {
|
||||||
const staggerMinutes = index * 15 // 15 minutes between each repetition
|
const staggerMinutes = staggerIndex * 15 // 15 minutes between each repetition
|
||||||
const baseTime = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
const baseTime = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
||||||
|
|
||||||
// Find the experiment for this repetition
|
// Find the experiment for this repetition
|
||||||
@@ -643,6 +651,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
airdryingStart,
|
airdryingStart,
|
||||||
crackingStart
|
crackingStart
|
||||||
}
|
}
|
||||||
|
staggerIndex++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,7 +659,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
return newScheduled
|
return newScheduled
|
||||||
})
|
})
|
||||||
}
|
}, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment])
|
||||||
|
|
||||||
// Spawn a single repetition in calendar
|
// Spawn a single repetition in calendar
|
||||||
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
|
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
|
||||||
@@ -769,10 +778,13 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId)
|
const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId)
|
||||||
|
|
||||||
if (experiment && repetition && scheduled.soakingStart) {
|
if (experiment && repetition && scheduled.soakingStart) {
|
||||||
|
const isLocked = lockedSchedules.has(scheduled.repetitionId)
|
||||||
|
const lockIcon = isLocked ? '🔒' : '🔓'
|
||||||
|
|
||||||
// Soaking marker
|
// Soaking marker
|
||||||
events.push({
|
events.push({
|
||||||
id: `${scheduled.repetitionId}-soaking`,
|
id: `${scheduled.repetitionId}-soaking`,
|
||||||
title: `💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
title: `${lockIcon} 💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
||||||
start: scheduled.soakingStart,
|
start: scheduled.soakingStart,
|
||||||
end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||||
resource: 'soaking'
|
resource: 'soaking'
|
||||||
@@ -782,7 +794,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
if (scheduled.airdryingStart) {
|
if (scheduled.airdryingStart) {
|
||||||
events.push({
|
events.push({
|
||||||
id: `${scheduled.repetitionId}-airdrying`,
|
id: `${scheduled.repetitionId}-airdrying`,
|
||||||
title: `🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
title: `${lockIcon} 🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
||||||
start: scheduled.airdryingStart,
|
start: scheduled.airdryingStart,
|
||||||
end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||||
resource: 'airdrying'
|
resource: 'airdrying'
|
||||||
@@ -793,7 +805,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
if (scheduled.crackingStart) {
|
if (scheduled.crackingStart) {
|
||||||
events.push({
|
events.push({
|
||||||
id: `${scheduled.repetitionId}-cracking`,
|
id: `${scheduled.repetitionId}-cracking`,
|
||||||
title: `⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
title: `${lockIcon} ⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
||||||
start: scheduled.crackingStart,
|
start: scheduled.crackingStart,
|
||||||
end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||||
resource: 'cracking'
|
resource: 'cracking'
|
||||||
@@ -803,10 +815,12 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
})
|
})
|
||||||
|
|
||||||
return events
|
return events
|
||||||
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment])
|
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules])
|
||||||
|
|
||||||
// Memoize the calendar events to prevent unnecessary re-renders
|
// Memoize the calendar events
|
||||||
const calendarEvents = useMemo(() => generateRepetitionEvents(), [generateRepetitionEvents])
|
const calendarEvents = useMemo(() => {
|
||||||
|
return generateRepetitionEvents()
|
||||||
|
}, [generateRepetitionEvents])
|
||||||
|
|
||||||
// Functions to preserve and restore scroll position
|
// Functions to preserve and restore scroll position
|
||||||
const preserveScrollPosition = useCallback(() => {
|
const preserveScrollPosition = useCallback(() => {
|
||||||
@@ -1019,426 +1033,372 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
<div className="px-6 pb-6 flex-1 min-h-0 overflow-hidden">
|
<div className="px-6 pb-6 flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 h-full flex flex-col min-h-0 overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 h-full flex flex-col min-h-0 overflow-hidden">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="mx-auto w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center mb-4">
|
<div className="mx-auto w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-purple-600 dark:text-purple-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-purple-600 dark:text-purple-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Loading…</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Loading…</h3>
|
) : (
|
||||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
|
||||||
</div>
|
{/* Left: Conductors with future availability */}
|
||||||
) : (
|
<div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
|
<div className="flex items-center justify-between mb-3">
|
||||||
{/* Left: Conductors with future availability */}
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Conductors</h2>
|
||||||
<div>
|
<span className="text-xs text-gray-500 dark:text-gray-400">Select to consider for scheduling</span>
|
||||||
<div className="flex items-center justify-between mb-3">
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Conductors</h2>
|
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Select to consider for scheduling</span>
|
{conductors.length === 0 ? (
|
||||||
</div>
|
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
) : (
|
||||||
{conductors.length === 0 ? (
|
<div>
|
||||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
||||||
onClick={() => setConductorsExpanded(!conductorsExpanded)}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white">All Conductors</span>
|
|
||||||
<svg className={`w-4 h-4 text-gray-500 transition-transform ${conductorsExpanded ? '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>
|
|
||||||
</button>
|
|
||||||
{conductorsExpanded && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-[420px] overflow-y-auto">
|
|
||||||
{/* Select All checkbox */}
|
|
||||||
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
||||||
checked={conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
|
|
||||||
onChange={toggleAllConductors}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Available Conductors</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{/* Conductors list */}
|
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{conductors.map((c, index) => {
|
|
||||||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
|
||||||
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
|
|
||||||
const checked = selectedConductorIds.has(c.id)
|
|
||||||
// Use the same color mapping as the calendar (from conductorColorMap)
|
|
||||||
const conductorColor = checked ? (conductorColorMap[c.id] || colorPalette[index % colorPalette.length]) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={c.id}
|
|
||||||
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''} hover:bg-gray-100 dark:hover:bg-gray-700/30`}
|
|
||||||
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleConductor(c.id)}
|
|
||||||
disabled={!hasFuture}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available' : 'No availability'}</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Phases -> Experiments -> Repetitions */}
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{phases.length === 0 && (
|
|
||||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No phases defined.</div>
|
|
||||||
)}
|
|
||||||
{phases.map(phase => {
|
|
||||||
const expanded = expandedPhaseIds.has(phase.id)
|
|
||||||
const experiments = experimentsByPhase[phase.id] || []
|
|
||||||
return (
|
|
||||||
<div key={phase.id}>
|
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
onClick={() => togglePhaseExpand(phase.id)}
|
onClick={() => setConductorsExpanded(!conductorsExpanded)}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{phase.name}</span>
|
<span className="text-sm font-medium text-gray-900 dark:text-white">All Conductors</span>
|
||||||
<svg className={`w-4 h-4 text-gray-500 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={`w-4 h-4 text-gray-500 transition-transform ${conductorsExpanded ? '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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{conductorsExpanded && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-96 overflow-y-auto">
|
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-[420px] overflow-y-auto">
|
||||||
{/* Select All checkbox for this phase */}
|
{/* Select All checkbox */}
|
||||||
{experiments.length > 0 && (
|
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
<label className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-2">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
checked={conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
|
||||||
checked={experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
|
onChange={toggleAllConductors}
|
||||||
onChange={() => toggleAllRepetitionsInPhase(phase.id)}
|
/>
|
||||||
/>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Available Conductors</span>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Repetitions in {phase.name}</span>
|
</label>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
{/* Conductors list */}
|
||||||
)}
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{experiments.length === 0 && (
|
{conductors.map((c, index) => {
|
||||||
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
|
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
||||||
)}
|
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
|
||||||
{experiments.map(exp => {
|
const checked = selectedConductorIds.has(c.id)
|
||||||
const reps = repetitionsByExperiment[exp.id] || []
|
// Use the same color mapping as the calendar (from conductorColorMap)
|
||||||
const isCreating = creatingRepetitionsFor.has(exp.id)
|
const conductorColor = checked ? (conductorColorMap[c.id] || colorPalette[index % colorPalette.length]) : null
|
||||||
const allRepsCreated = reps.length >= exp.reps_required
|
|
||||||
const soaking = soakingByExperiment[exp.id]
|
|
||||||
const airdrying = airdryingByExperiment[exp.id]
|
|
||||||
|
|
||||||
const getSoakDisplay = () => {
|
return (
|
||||||
if (soaking) return `${soaking.soaking_duration_minutes}min`
|
<label
|
||||||
return '—'
|
key={c.id}
|
||||||
}
|
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''} hover:bg-gray-100 dark:hover:bg-gray-700/30`}
|
||||||
|
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
|
||||||
const getAirdryDisplay = () => {
|
>
|
||||||
if (airdrying) return `${airdrying.duration_minutes}min`
|
<div className="flex items-center gap-3">
|
||||||
return '—'
|
<input
|
||||||
}
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
return (
|
checked={checked}
|
||||||
<div key={exp.id} className="border-t border-gray-200 dark:border-gray-700">
|
onChange={() => toggleConductor(c.id)}
|
||||||
<div className="px-3 py-2 flex items-center justify-between">
|
disabled={!hasFuture}
|
||||||
<div className="text-sm text-gray-900 dark:text-white">
|
/>
|
||||||
<span className="font-medium">Exp #{exp.experiment_number}</span>
|
<div>
|
||||||
<span className="mx-2 text-gray-400">•</span>
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-300">Soak: {getSoakDisplay()}</span>
|
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
|
||||||
<span className="mx-2 text-gray-400">/</span>
|
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-300">Air-dry: {getAirdryDisplay()}</span>
|
|
||||||
</div>
|
|
||||||
{!allRepsCreated && (
|
|
||||||
<button
|
|
||||||
onClick={() => createRepetitionsForExperiment(exp.id)}
|
|
||||||
disabled={isCreating}
|
|
||||||
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{isCreating ? 'Creating...' : 'Add Repetition'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 pb-2 space-y-2">
|
|
||||||
{reps.map(rep => {
|
|
||||||
const checked = selectedRepetitionIds.has(rep.id)
|
|
||||||
const hasTimes = repetitionsWithTimes.has(rep.id)
|
|
||||||
const scheduled = scheduledRepetitions[rep.id]
|
|
||||||
const isLocked = lockedSchedules.has(rep.id)
|
|
||||||
const isScheduling = schedulingRepetitions.has(rep.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={rep.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
|
|
||||||
{/* Checkbox row */}
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleRepetition(rep.id)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Time points (shown only if has been dropped/moved) */}
|
|
||||||
{hasTimes && scheduled && (
|
|
||||||
<div className="mt-2 ml-6 text-xs space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>💧</span>
|
|
||||||
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>🌬️</span>
|
|
||||||
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>⚡</span>
|
|
||||||
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lock checkbox and Schedule button */}
|
|
||||||
<div className="flex items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 text-blue-600 border-gray-300 rounded"
|
|
||||||
checked={isLocked}
|
|
||||||
onChange={() => toggleScheduleLock(rep.id)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Lock</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => scheduleRepetition(rep.id, exp.id)}
|
|
||||||
disabled={isScheduling || !isLocked}
|
|
||||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
|
|
||||||
>
|
|
||||||
{isScheduling ? 'Scheduling...' : 'Schedule'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{reps.length === 0 && !isCreating && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 col-span-full">No repetitions created. Click "Create Reps" to generate them.</div>
|
|
||||||
)}
|
|
||||||
{isCreating && (
|
|
||||||
<div className="text-xs text-blue-600 dark:text-blue-400 col-span-full flex items-center gap-2">
|
|
||||||
<svg className="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
|
|
||||||
</svg>
|
|
||||||
Creating {exp.reps_required} repetitions...
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available' : 'No availability'}</span>
|
||||||
</div>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Phases -> Experiments -> Repetitions */}
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{phases.length === 0 && (
|
||||||
|
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No phases defined.</div>
|
||||||
|
)}
|
||||||
|
{phases.map(phase => {
|
||||||
|
const expanded = expandedPhaseIds.has(phase.id)
|
||||||
|
const experiments = experimentsByPhase[phase.id] || []
|
||||||
|
return (
|
||||||
|
<div key={phase.id}>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
|
onClick={() => togglePhaseExpand(phase.id)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{phase.name}</span>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-96 overflow-y-auto">
|
||||||
|
{/* Select All checkbox for this phase */}
|
||||||
|
{experiments.length > 0 && (
|
||||||
|
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
checked={experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
|
||||||
|
onChange={() => toggleAllRepetitionsInPhase(phase.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Repetitions in {phase.name}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{experiments.length === 0 && (
|
||||||
|
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
|
||||||
|
)}
|
||||||
|
{experiments.map(exp => {
|
||||||
|
const reps = repetitionsByExperiment[exp.id] || []
|
||||||
|
const isCreating = creatingRepetitionsFor.has(exp.id)
|
||||||
|
const allRepsCreated = reps.length >= exp.reps_required
|
||||||
|
const soaking = soakingByExperiment[exp.id]
|
||||||
|
const airdrying = airdryingByExperiment[exp.id]
|
||||||
|
|
||||||
|
const getSoakDisplay = () => {
|
||||||
|
if (soaking) return `${soaking.soaking_duration_minutes}min`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAirdryDisplay = () => {
|
||||||
|
if (airdrying) return `${airdrying.duration_minutes}min`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={exp.id} className="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="px-3 py-2 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white">
|
||||||
|
<span className="font-medium">Exp #{exp.experiment_number}</span>
|
||||||
|
<span className="mx-2 text-gray-400">•</span>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-300">Soak: {getSoakDisplay()}</span>
|
||||||
|
<span className="mx-2 text-gray-400">/</span>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-300">Air-dry: {getAirdryDisplay()}</span>
|
||||||
|
</div>
|
||||||
|
{!allRepsCreated && (
|
||||||
|
<button
|
||||||
|
onClick={() => createRepetitionsForExperiment(exp.id)}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Add Repetition'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-2 space-y-2">
|
||||||
|
{reps.map(rep => {
|
||||||
|
const checked = selectedRepetitionIds.has(rep.id)
|
||||||
|
const hasTimes = repetitionsWithTimes.has(rep.id)
|
||||||
|
const scheduled = scheduledRepetitions[rep.id]
|
||||||
|
const isLocked = lockedSchedules.has(rep.id)
|
||||||
|
const isScheduling = schedulingRepetitions.has(rep.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={rep.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
|
||||||
|
{/* Checkbox row */}
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleRepetition(rep.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Time points (shown only if has been dropped/moved) */}
|
||||||
|
{hasTimes && scheduled && (
|
||||||
|
<div className="mt-2 ml-6 text-xs space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>💧</span>
|
||||||
|
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>🌬️</span>
|
||||||
|
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>⚡</span>
|
||||||
|
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock checkbox and Schedule button */}
|
||||||
|
<div className="flex items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3 text-blue-600 border-gray-300 rounded"
|
||||||
|
checked={isLocked}
|
||||||
|
onChange={() => {
|
||||||
|
toggleScheduleLock(rep.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{isLocked ? '🔒 Locked' : '🔓 Unlocked'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => scheduleRepetition(rep.id, exp.id)}
|
||||||
|
disabled={isScheduling || !isLocked}
|
||||||
|
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
{isScheduling ? 'Scheduling...' : 'Schedule'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{reps.length === 0 && !isCreating && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 col-span-full">No repetitions created. Click "Create Reps" to generate them.</div>
|
||||||
|
)}
|
||||||
|
{isCreating && (
|
||||||
|
<div className="text-xs text-blue-600 dark:text-blue-400 col-span-full flex items-center gap-2">
|
||||||
|
<svg className="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
|
||||||
|
</svg>
|
||||||
|
Creating {exp.reps_required} repetitions...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{/* Week Calendar for selected conductors' availability */}
|
||||||
{/* Week Calendar for selected conductors' availability */}
|
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="mb-3 flex-shrink-0">
|
||||||
<div className="flex justify-between items-center mb-3 flex-shrink-0">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{/* Day-by-day navigation buttons (only show in week view) */}
|
|
||||||
{calendarView === Views.WEEK && (
|
|
||||||
<div className="flex items-center gap-1 mr-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newDate = new Date(currentDate)
|
|
||||||
newDate.setDate(newDate.getDate() - 1)
|
|
||||||
setCurrentDate(newDate)
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newDate = new Date(currentDate)
|
|
||||||
newDate.setDate(newDate.getDate() + 1)
|
|
||||||
setCurrentDate(newDate)
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
|
|
||||||
title="Next day"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">Day</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setMarkerStyle('lines')}
|
|
||||||
className={`px-3 py-1 text-xs rounded ${markerStyle === 'lines'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Lines
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMarkerStyle('circles')}
|
|
||||||
className={`px-3 py-1 text-xs rounded ${markerStyle === 'circles'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Circles
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMarkerStyle('dots')}
|
|
||||||
className={`px-3 py-1 text-xs rounded ${markerStyle === 'dots'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Dots
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMarkerStyle('icons')}
|
|
||||||
className={`px-3 py-1 text-xs rounded ${markerStyle === 'icons'
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Icons
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div ref={calendarRef} className="flex-1 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
|
||||||
<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}>
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DnDCalendar
|
||||||
<DnDCalendar
|
localizer={localizer}
|
||||||
localizer={localizer}
|
events={calendarEvents}
|
||||||
events={calendarEvents}
|
backgroundEvents={availabilityEvents}
|
||||||
backgroundEvents={availabilityEvents}
|
startAccessor="start"
|
||||||
startAccessor="start"
|
endAccessor="end"
|
||||||
endAccessor="end"
|
titleAccessor="title"
|
||||||
titleAccessor="title"
|
style={{ height: '100%' }}
|
||||||
style={{ height: '100%' }}
|
view={calendarView}
|
||||||
view={calendarView}
|
onView={setCalendarView}
|
||||||
onView={setCalendarView}
|
date={currentDate}
|
||||||
date={currentDate}
|
onNavigate={setCurrentDate}
|
||||||
onNavigate={setCurrentDate}
|
views={[Views.WEEK, Views.DAY]}
|
||||||
views={[Views.WEEK, Views.DAY]}
|
dayLayoutAlgorithm="no-overlap"
|
||||||
dayLayoutAlgorithm="no-overlap"
|
draggableAccessor={draggableAccessor}
|
||||||
draggableAccessor={draggableAccessor}
|
onSelectEvent={(event: any) => {
|
||||||
onEventDrop={({ event, start }: { event: any, start: Date }) => {
|
// Handle clicking on repetition markers to toggle lock
|
||||||
// Preserve scroll position before updating
|
const resource = event.resource as string
|
||||||
preserveScrollPosition()
|
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
||||||
|
const eventId = event.id as string
|
||||||
// Handle dragging repetition markers
|
const repId = eventId.split('-')[0]
|
||||||
const eventId = event.id as string
|
// Toggle lock for this repetition - this will update both checkbox and marker icons
|
||||||
// Clamp to reasonable working hours (5AM to 11PM) to prevent extreme times
|
toggleScheduleLock(repId)
|
||||||
const clampToReasonableHours = (d: Date) => {
|
// Prevent default popup behavior
|
||||||
const min = new Date(d)
|
return false
|
||||||
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
|
|
||||||
const color = conductorColorMap[conductorId] || '#2563eb'
|
|
||||||
return {
|
|
||||||
style: {
|
|
||||||
backgroundColor: color + '40', // ~25% transparency for background
|
|
||||||
borderColor: color,
|
|
||||||
borderWidth: '1px',
|
|
||||||
borderStyle: 'solid',
|
|
||||||
color: 'transparent',
|
|
||||||
borderRadius: '4px',
|
|
||||||
opacity: 0.6,
|
|
||||||
height: 'auto',
|
|
||||||
minHeight: '20px',
|
|
||||||
fontSize: '0px',
|
|
||||||
padding: '0px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onEventDrop={({ event, start }: { event: any, start: Date }) => {
|
||||||
popup
|
// Preserve scroll position before updating
|
||||||
showMultiDayTimes
|
preserveScrollPosition()
|
||||||
doShowMore={true}
|
|
||||||
step={30}
|
|
||||||
timeslots={2}
|
|
||||||
/>
|
|
||||||
</DndProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
// 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user