Enhance scheduling and drag-and-drop functionality in the Calendar component
- Improved drag-and-drop experience for event scheduling with visual feedback and better cursor styles. - Added state management for tracking repetitions, including locked schedules and currently scheduling repetitions. - Implemented re-staggering logic to prevent overlap of scheduled events. - Enhanced event generation to include time points for soaking, airdrying, and cracking phases. - Updated the calendar to preserve and restore scroll position during event updates. - Refactored event handling to ensure smooth interaction and improved user experience.
This commit is contained in:
@@ -186,6 +186,52 @@
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
// @ts-ignore - react-big-calendar types not available
|
||||
import { Calendar, momentLocalizer, Views } from 'react-big-calendar'
|
||||
// @ts-ignore - react-big-calendar dragAndDrop types not available
|
||||
@@ -327,9 +327,20 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
crackingStart: Date | null
|
||||
}>>({})
|
||||
|
||||
// Track repetitions that have been dropped/moved and should show time points
|
||||
const [repetitionsWithTimes, setRepetitionsWithTimes] = useState<Set<string>>(new Set())
|
||||
// Track which repetitions are locked (prevent dragging)
|
||||
const [lockedSchedules, setLockedSchedules] = useState<Set<string>>(new Set())
|
||||
// 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)
|
||||
const scrollPositionRef = useRef<{ scrollTop: number; scrollLeft: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -503,10 +514,33 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
delete newScheduled[repId]
|
||||
return newScheduled
|
||||
})
|
||||
// Clear all related state when unchecked
|
||||
setRepetitionsWithTimes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(repId)
|
||||
return next
|
||||
})
|
||||
setLockedSchedules(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(repId)
|
||||
return next
|
||||
})
|
||||
setSchedulingRepetitions(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(repId)
|
||||
return next
|
||||
})
|
||||
// Re-stagger remaining repetitions
|
||||
const remainingIds = Array.from(next).filter(id => id !== repId)
|
||||
if (remainingIds.length > 0) {
|
||||
reStaggerRepetitions(remainingIds)
|
||||
}
|
||||
} else {
|
||||
next.add(repId)
|
||||
// Auto-spawn when checked
|
||||
spawnSingleRepetition(repId)
|
||||
// Re-stagger all existing repetitions to prevent overlap
|
||||
reStaggerRepetitions([...next, repId])
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -570,6 +604,54 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
}
|
||||
}
|
||||
|
||||
// Re-stagger all repetitions to prevent overlap
|
||||
const reStaggerRepetitions = (repIds: string[]) => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setHours(9, 0, 0, 0)
|
||||
|
||||
setScheduledRepetitions(prev => {
|
||||
const newScheduled = { ...prev }
|
||||
|
||||
repIds.forEach((repId, index) => {
|
||||
if (newScheduled[repId]) {
|
||||
const staggerMinutes = index * 15 // 15 minutes between each repetition
|
||||
const baseTime = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
||||
|
||||
// Find the experiment for this repetition
|
||||
let experimentId = ''
|
||||
for (const [expId, reps] of Object.entries(repetitionsByExperiment)) {
|
||||
if (reps.find(r => r.id === repId)) {
|
||||
experimentId = expId
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (experimentId) {
|
||||
const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === experimentId)
|
||||
const soaking = soakingByExperiment[experimentId]
|
||||
const airdrying = airdryingByExperiment[experimentId]
|
||||
|
||||
if (experiment && soaking && airdrying) {
|
||||
const soakingStart = new Date(baseTime)
|
||||
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
|
||||
const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))
|
||||
|
||||
newScheduled[repId] = {
|
||||
...newScheduled[repId],
|
||||
soakingStart,
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return newScheduled
|
||||
})
|
||||
}
|
||||
|
||||
// Spawn a single repetition in calendar
|
||||
const spawnSingleRepetition = (repId: string) => {
|
||||
const tomorrow = new Date()
|
||||
@@ -591,7 +673,12 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
const airdrying = airdryingByExperiment[experimentId]
|
||||
|
||||
if (experiment && soaking && airdrying) {
|
||||
const soakingStart = new Date(tomorrow)
|
||||
// Stagger the positioning to avoid overlap when multiple repetitions are selected
|
||||
const selectedReps = Array.from(selectedRepetitionIds)
|
||||
const repIndex = selectedReps.indexOf(repId)
|
||||
const staggerMinutes = repIndex * 15 // 15 minutes between each repetition's time points
|
||||
|
||||
const soakingStart = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
||||
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
|
||||
const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))
|
||||
|
||||
@@ -623,35 +710,47 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
|
||||
let newScheduled = { ...prev }
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
if (phase === 'soaking') {
|
||||
const airdryingStart = new Date(newTime.getTime() + (soaking.soaking_duration_minutes * 60000))
|
||||
const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))
|
||||
const soakingStart = clampToReasonableHours(newTime)
|
||||
const airdryingStart = clampToReasonableHours(new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000)))
|
||||
const crackingStart = clampToReasonableHours(new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000)))
|
||||
|
||||
newScheduled[repId] = {
|
||||
...current,
|
||||
soakingStart: newTime,
|
||||
soakingStart,
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
} else if (phase === 'airdrying') {
|
||||
const soakingStart = new Date(newTime.getTime() - (soaking.soaking_duration_minutes * 60000))
|
||||
const crackingStart = new Date(newTime.getTime() + (airdrying.duration_minutes * 60000))
|
||||
|
||||
newScheduled[repId] = {
|
||||
...current,
|
||||
soakingStart,
|
||||
airdryingStart: newTime,
|
||||
crackingStart
|
||||
}
|
||||
} else if (phase === 'cracking') {
|
||||
const airdryingStart = new Date(newTime.getTime() - (airdrying.duration_minutes * 60000))
|
||||
const soakingStart = new Date(airdryingStart.getTime() - (soaking.soaking_duration_minutes * 60000))
|
||||
const airdryingStart = clampToReasonableHours(newTime)
|
||||
const soakingStart = clampToReasonableHours(new Date(airdryingStart.getTime() - (soaking.soaking_duration_minutes * 60000)))
|
||||
const crackingStart = clampToReasonableHours(new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000)))
|
||||
|
||||
newScheduled[repId] = {
|
||||
...current,
|
||||
soakingStart,
|
||||
airdryingStart,
|
||||
crackingStart: newTime
|
||||
crackingStart
|
||||
}
|
||||
} else if (phase === 'cracking') {
|
||||
const crackingStart = clampToReasonableHours(newTime)
|
||||
const airdryingStart = clampToReasonableHours(new Date(crackingStart.getTime() - (airdrying.duration_minutes * 60000)))
|
||||
const soakingStart = clampToReasonableHours(new Date(airdryingStart.getTime() - (soaking.soaking_duration_minutes * 60000)))
|
||||
|
||||
newScheduled[repId] = {
|
||||
...current,
|
||||
soakingStart,
|
||||
airdryingStart,
|
||||
crackingStart
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,8 +758,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
})
|
||||
}
|
||||
|
||||
// Generate calendar events for scheduled repetitions
|
||||
const generateRepetitionEvents = (): CalendarEvent[] => {
|
||||
// Generate calendar events for scheduled repetitions (memoized)
|
||||
const generateRepetitionEvents = useCallback((): CalendarEvent[] => {
|
||||
const events: CalendarEvent[] = []
|
||||
|
||||
Object.values(scheduledRepetitions).forEach(scheduled => {
|
||||
@@ -673,7 +772,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
id: `${scheduled.repetitionId}-soaking`,
|
||||
title: `💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
||||
start: scheduled.soakingStart,
|
||||
end: new Date(scheduled.soakingStart.getTime() + 30 * 60000), // 30 minute duration for visibility
|
||||
end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||
resource: 'soaking'
|
||||
})
|
||||
|
||||
@@ -683,7 +782,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
id: `${scheduled.repetitionId}-airdrying`,
|
||||
title: `🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
||||
start: scheduled.airdryingStart,
|
||||
end: new Date(scheduled.airdryingStart.getTime() + 30 * 60000),
|
||||
end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||
resource: 'airdrying'
|
||||
})
|
||||
}
|
||||
@@ -694,7 +793,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
id: `${scheduled.repetitionId}-cracking`,
|
||||
title: `⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
||||
start: scheduled.crackingStart,
|
||||
end: new Date(scheduled.crackingStart.getTime() + 30 * 60000),
|
||||
end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
|
||||
resource: 'cracking'
|
||||
})
|
||||
}
|
||||
@@ -702,8 +801,203 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
})
|
||||
|
||||
return events
|
||||
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment])
|
||||
|
||||
// Memoize the calendar events to prevent unnecessary re-renders
|
||||
const calendarEvents = useMemo(() => generateRepetitionEvents(), [generateRepetitionEvents])
|
||||
|
||||
// Functions to preserve and restore scroll position
|
||||
const preserveScrollPosition = useCallback(() => {
|
||||
if (calendarRef.current) {
|
||||
const scrollContainer = calendarRef.current.querySelector('.rbc-time-content') as HTMLElement
|
||||
if (scrollContainer) {
|
||||
scrollPositionRef.current = {
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
scrollLeft: scrollContainer.scrollLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
if (calendarRef.current && scrollPositionRef.current) {
|
||||
const scrollContainer = calendarRef.current.querySelector('.rbc-time-content') as HTMLElement
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollPositionRef.current.scrollTop
|
||||
scrollContainer.scrollLeft = scrollPositionRef.current.scrollLeft
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Helper functions for scheduling
|
||||
const formatTime = (date: Date | null) => {
|
||||
if (!date) return 'Not set'
|
||||
return moment(date).format('MMM D, h:mm A')
|
||||
}
|
||||
|
||||
const toggleScheduleLock = (repId: string) => {
|
||||
setLockedSchedules(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(repId)) {
|
||||
next.delete(repId)
|
||||
} else {
|
||||
next.add(repId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const draggableAccessor = useCallback((event: any) => {
|
||||
// Only make repetition markers draggable, not availability events
|
||||
const resource = event.resource as string
|
||||
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
||||
// Check if the repetition is locked
|
||||
const eventId = event.id as string
|
||||
const repId = eventId.split('-')[0]
|
||||
const isLocked = lockedSchedules.has(repId)
|
||||
return !isLocked
|
||||
}
|
||||
return false
|
||||
}, [lockedSchedules])
|
||||
|
||||
const eventPropGetter = useCallback((event: any) => {
|
||||
const resource = event.resource as string
|
||||
|
||||
// Styling for repetition markers (foreground events)
|
||||
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
||||
const eventId = event.id as string
|
||||
const repId = eventId.split('-')[0]
|
||||
const isLocked = lockedSchedules.has(repId)
|
||||
|
||||
const colors = {
|
||||
soaking: '#3b82f6', // blue
|
||||
airdrying: '#10b981', // green
|
||||
cracking: '#f59e0b' // orange
|
||||
}
|
||||
const color = colors[resource as keyof typeof colors] || '#6b7280'
|
||||
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: isLocked ? '#9ca3af' : color, // gray if locked
|
||||
borderColor: isLocked ? color : color, // border takes original color when locked
|
||||
color: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
height: '40px',
|
||||
minHeight: '40px',
|
||||
fontSize: '12px',
|
||||
padding: '8px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10,
|
||||
position: 'relative',
|
||||
lineHeight: '1.4',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.7)',
|
||||
gap: '8px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: isLocked ? 'not-allowed' : 'grab',
|
||||
boxShadow: isLocked ? '0 1px 2px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.2)',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isLocked ? 0.7 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default styling for other events
|
||||
return {}
|
||||
}, [lockedSchedules])
|
||||
|
||||
const scheduleRepetition = async (repId: string, experimentId: string) => {
|
||||
setSchedulingRepetitions(prev => new Set(prev).add(repId))
|
||||
|
||||
try {
|
||||
const scheduled = scheduledRepetitions[repId]
|
||||
if (!scheduled) throw new Error('No scheduled times found')
|
||||
|
||||
const { soakingStart, airdryingStart, crackingStart } = scheduled
|
||||
if (!soakingStart || !airdryingStart || !crackingStart) {
|
||||
throw new Error('All time points must be set')
|
||||
}
|
||||
|
||||
const soaking = soakingByExperiment[experimentId]
|
||||
const airdrying = airdryingByExperiment[experimentId]
|
||||
|
||||
if (!soaking || !airdrying) throw new Error('Phase data not found')
|
||||
|
||||
// Update repetition scheduled_date (earliest time point)
|
||||
await repetitionManagement.updateRepetition(repId, {
|
||||
scheduled_date: soakingStart.toISOString()
|
||||
})
|
||||
|
||||
// Create/update soaking record with repetition_id
|
||||
await phaseManagement.createSoaking({
|
||||
experiment_id: experimentId,
|
||||
repetition_id: repId,
|
||||
scheduled_start_time: soakingStart.toISOString(),
|
||||
soaking_duration_minutes: soaking.soaking_duration_minutes
|
||||
})
|
||||
|
||||
// Create/update airdrying record with repetition_id
|
||||
await phaseManagement.createAirdrying({
|
||||
experiment_id: experimentId,
|
||||
repetition_id: repId,
|
||||
scheduled_start_time: airdryingStart.toISOString(),
|
||||
duration_minutes: airdrying.duration_minutes
|
||||
})
|
||||
|
||||
// Create/update cracking record with repetition_id
|
||||
// Note: cracking requires machine_type_id - need to get from experiment phase
|
||||
const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === experimentId)
|
||||
const phase = phases.find(p => p.id === experiment?.phase_id)
|
||||
|
||||
if (phase?.cracking_machine_type_id) {
|
||||
await phaseManagement.createCracking({
|
||||
experiment_id: experimentId,
|
||||
repetition_id: repId,
|
||||
machine_type_id: phase.cracking_machine_type_id,
|
||||
scheduled_start_time: crackingStart.toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// Update local state to reflect scheduling
|
||||
setRepetitionsByExperiment(prev => ({
|
||||
...prev,
|
||||
[experimentId]: prev[experimentId]?.map(r =>
|
||||
r.id === repId
|
||||
? { ...r, scheduled_date: soakingStart.toISOString() }
|
||||
: r
|
||||
) || []
|
||||
}))
|
||||
|
||||
} catch (error: any) {
|
||||
setError(error?.message || 'Failed to schedule repetition')
|
||||
} finally {
|
||||
setSchedulingRepetitions(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(repId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position after scheduledRepetitions changes
|
||||
useEffect(() => {
|
||||
if (scrollPositionRef.current) {
|
||||
// Use a longer delay to ensure the calendar has fully re-rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
restoreScrollPosition()
|
||||
}, 50)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [scheduledRepetitions, restoreScrollPosition])
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
@@ -874,19 +1168,65 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 pb-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
<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 (
|
||||
<label key={rep.id} className="flex items-center gap-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-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-xs text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
|
||||
</label>
|
||||
<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 && (
|
||||
@@ -957,11 +1297,11 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
|
||||
<div ref={calendarRef} className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DnDCalendar
|
||||
localizer={localizer}
|
||||
events={generateRepetitionEvents()}
|
||||
events={calendarEvents}
|
||||
backgroundEvents={availabilityEvents}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
@@ -973,76 +1313,47 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
onNavigate={setCurrentDate}
|
||||
views={[Views.WEEK, Views.DAY]}
|
||||
dayLayoutAlgorithm="no-overlap"
|
||||
draggableAccessor={(event: any) => {
|
||||
// Only make repetition markers draggable, not availability events
|
||||
const resource = event.resource as string
|
||||
return resource === 'soaking' || resource === 'airdrying' || resource === 'cracking'
|
||||
}}
|
||||
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')) {
|
||||
const repId = eventId.replace('-soaking', '')
|
||||
updatePhaseTiming(repId, 'soaking', start)
|
||||
repId = eventId.replace('-soaking', '')
|
||||
updatePhaseTiming(repId, 'soaking', clampedStart)
|
||||
} else if (eventId.includes('-airdrying')) {
|
||||
const repId = eventId.replace('-airdrying', '')
|
||||
updatePhaseTiming(repId, 'airdrying', start)
|
||||
repId = eventId.replace('-airdrying', '')
|
||||
updatePhaseTiming(repId, 'airdrying', clampedStart)
|
||||
} else if (eventId.includes('-cracking')) {
|
||||
const repId = eventId.replace('-cracking', '')
|
||||
updatePhaseTiming(repId, 'cracking', start)
|
||||
}
|
||||
}}
|
||||
eventPropGetter={(event: any) => {
|
||||
const resource = event.resource as string
|
||||
|
||||
// Styling for repetition markers (foreground events)
|
||||
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
||||
const colors = {
|
||||
soaking: '#3b82f6', // blue
|
||||
airdrying: '#10b981', // green
|
||||
cracking: '#f59e0b' // orange
|
||||
}
|
||||
const color = colors[resource as keyof typeof colors] || '#6b7280'
|
||||
|
||||
// Get step names and icons
|
||||
const stepInfo = {
|
||||
soaking: { name: 'Soaking', icon: '💧' },
|
||||
airdrying: { name: 'Airdrying', icon: '🌬️' },
|
||||
cracking: { name: 'Cracking', icon: '⚡' }
|
||||
}
|
||||
const step = stepInfo[resource as keyof typeof stepInfo]
|
||||
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
height: '36px',
|
||||
minHeight: '36px',
|
||||
fontSize: '13px',
|
||||
padding: '6px 10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10,
|
||||
position: 'relative',
|
||||
lineHeight: '1.4',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.7)',
|
||||
gap: '6px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
}
|
||||
repId = eventId.replace('-cracking', '')
|
||||
updatePhaseTiming(repId, 'cracking', clampedStart)
|
||||
}
|
||||
|
||||
// Default styling for other events
|
||||
return {}
|
||||
// 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
|
||||
@@ -1074,6 +1385,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user