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:
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;
|
||||
}
|
||||
|
||||
/* Drag and drop improvements */
|
||||
/* Basic drag and drop styles - minimal and safe */
|
||||
.rbc-event {
|
||||
cursor: grab !important;
|
||||
cursor: grab;
|
||||
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;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Improve event spacing and visibility */
|
||||
.rbc-event-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Better visual feedback for dragging */
|
||||
/* Simple drag feedback */
|
||||
.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;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Better spacing between events */
|
||||
|
||||
@@ -335,8 +335,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
// Track which repetitions are currently being scheduled
|
||||
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
|
||||
const calendarRef = useRef<HTMLDivElement>(null)
|
||||
@@ -435,13 +433,16 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
idToName[c.id] = name
|
||||
})
|
||||
|
||||
const events: CalendarEvent[] = (data || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
title: `${idToName[r.user_id] || 'Conductor'}`,
|
||||
start: new Date(r.available_from),
|
||||
end: new Date(r.available_to),
|
||||
resource: r.user_id // Store conductor ID, not color
|
||||
}))
|
||||
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 // Store conductor ID for color mapping
|
||||
}
|
||||
})
|
||||
|
||||
setAvailabilityEvents(events)
|
||||
} 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
|
||||
spawnSingleRepetition(repId, next)
|
||||
// Re-stagger all existing repetitions to prevent overlap
|
||||
// Note: reStaggerRepetitions will automatically skip locked repetitions
|
||||
reStaggerRepetitions([...next, repId])
|
||||
}
|
||||
return next
|
||||
@@ -605,7 +607,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
}
|
||||
|
||||
// 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()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setHours(9, 0, 0, 0)
|
||||
@@ -613,9 +616,14 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
setScheduledRepetitions(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]) {
|
||||
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))
|
||||
|
||||
// Find the experiment for this repetition
|
||||
@@ -643,6 +651,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
staggerIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,7 +659,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
|
||||
return newScheduled
|
||||
})
|
||||
}
|
||||
}, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment])
|
||||
|
||||
// Spawn a single repetition in calendar
|
||||
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)
|
||||
|
||||
if (experiment && repetition && scheduled.soakingStart) {
|
||||
const isLocked = lockedSchedules.has(scheduled.repetitionId)
|
||||
const lockIcon = isLocked ? '🔒' : '🔓'
|
||||
|
||||
// Soaking marker
|
||||
events.push({
|
||||
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,
|
||||
end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||
resource: 'soaking'
|
||||
@@ -782,7 +794,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
if (scheduled.airdryingStart) {
|
||||
events.push({
|
||||
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,
|
||||
end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||
resource: 'airdrying'
|
||||
@@ -793,7 +805,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
if (scheduled.crackingStart) {
|
||||
events.push({
|
||||
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,
|
||||
end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||
resource: 'cracking'
|
||||
@@ -803,10 +815,12 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
})
|
||||
|
||||
return events
|
||||
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment])
|
||||
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules])
|
||||
|
||||
// Memoize the calendar events to prevent unnecessary re-renders
|
||||
const calendarEvents = useMemo(() => generateRepetitionEvents(), [generateRepetitionEvents])
|
||||
// Memoize the calendar events
|
||||
const calendarEvents = useMemo(() => {
|
||||
return generateRepetitionEvents()
|
||||
}, [generateRepetitionEvents])
|
||||
|
||||
// Functions to preserve and restore scroll position
|
||||
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="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 && (
|
||||
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
{error && (
|
||||
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<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">
|
||||
<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" />
|
||||
</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>
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
|
||||
{/* Left: Conductors with future availability */}
|
||||
<div>
|
||||
<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>
|
||||
</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 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}>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
|
||||
{/* Left: Conductors with future availability */}
|
||||
<div>
|
||||
<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>
|
||||
</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 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={() => togglePhaseExpand(phase.id)}
|
||||
onClick={() => setConductorsExpanded(!conductorsExpanded)}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
{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]
|
||||
{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
|
||||
|
||||
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">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...
|
||||
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>
|
||||
</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
|
||||
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>
|
||||
)}
|
||||
{/* Week Calendar for selected conductors' availability */}
|
||||
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{/* 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>
|
||||
<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}
|
||||
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
|
||||
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'
|
||||
<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
|
||||
}
|
||||
}
|
||||
}}
|
||||
popup
|
||||
showMultiDayTimes
|
||||
doShowMore={true}
|
||||
step={30}
|
||||
timeslots={2}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
}}
|
||||
onEventDrop={({ event, start }: { event: any, start: Date }) => {
|
||||
// Preserve scroll position before updating
|
||||
preserveScrollPosition()
|
||||
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user