Enhance HorizontalTimelineCalendar and Scheduling components with repetition metadata and improved interaction
- Added RepetitionMetadata interface to manage phase details for repetitions. - Implemented onScrollToRepetition and onScheduleRepetition callbacks for better user navigation and scheduling. - Updated HorizontalTimelineCalendar to display phase names, experiment numbers, and repetition numbers in the timeline. - Removed locked state management from Scheduling component, simplifying repetition handling. - Improved conductor assignment visibility and interaction within the scheduling interface.
This commit is contained in:
@@ -18,7 +18,13 @@ interface PhaseMarker {
|
|||||||
phase: 'soaking' | 'airdrying' | 'cracking'
|
phase: 'soaking' | 'airdrying' | 'cracking'
|
||||||
startTime: Date
|
startTime: Date
|
||||||
assignedConductors: string[] // Array of conductor IDs
|
assignedConductors: string[] // Array of conductor IDs
|
||||||
locked: boolean
|
}
|
||||||
|
|
||||||
|
interface RepetitionMetadata {
|
||||||
|
phaseName: string
|
||||||
|
experimentNumber: number
|
||||||
|
repetitionNumber: number
|
||||||
|
experimentId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HorizontalTimelineCalendarProps {
|
interface HorizontalTimelineCalendarProps {
|
||||||
@@ -28,7 +34,9 @@ interface HorizontalTimelineCalendarProps {
|
|||||||
phaseMarkers: PhaseMarker[]
|
phaseMarkers: PhaseMarker[]
|
||||||
onMarkerDrag: (markerId: string, newTime: Date) => void
|
onMarkerDrag: (markerId: string, newTime: Date) => void
|
||||||
onMarkerAssignConductors: (markerId: string, conductorIds: string[]) => void
|
onMarkerAssignConductors: (markerId: string, conductorIds: string[]) => void
|
||||||
onMarkerLockToggle: (markerId: string) => void
|
repetitionMetadata?: Record<string, RepetitionMetadata> // Map from repetitionId to metadata
|
||||||
|
onScrollToRepetition?: (repetitionId: string) => void // Callback to scroll to repetition in accordion
|
||||||
|
onScheduleRepetition?: (repId: string, experimentId: string) => void // Callback to schedule repetition
|
||||||
timeStep?: number // Minutes per pixel or time unit
|
timeStep?: number // Minutes per pixel or time unit
|
||||||
minHour?: number
|
minHour?: number
|
||||||
maxHour?: number
|
maxHour?: number
|
||||||
@@ -51,6 +59,14 @@ function RepetitionBorder({
|
|||||||
dragOffset = { x: 0 },
|
dragOffset = { x: 0 },
|
||||||
extendLeft = false,
|
extendLeft = false,
|
||||||
extendRight = false,
|
extendRight = false,
|
||||||
|
phaseName,
|
||||||
|
experimentNumber,
|
||||||
|
repetitionNumber,
|
||||||
|
experimentId,
|
||||||
|
onScrollToRepetition,
|
||||||
|
onScheduleRepetition,
|
||||||
|
visibleMarkers,
|
||||||
|
getTimePosition,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
left: number
|
left: number
|
||||||
@@ -67,10 +83,60 @@ function RepetitionBorder({
|
|||||||
dragOffset?: { x: number }
|
dragOffset?: { x: number }
|
||||||
extendLeft?: boolean
|
extendLeft?: boolean
|
||||||
extendRight?: boolean
|
extendRight?: boolean
|
||||||
|
phaseName?: string
|
||||||
|
experimentNumber?: number
|
||||||
|
repetitionNumber?: number
|
||||||
|
experimentId?: string
|
||||||
|
onScrollToRepetition?: (repetitionId: string) => void
|
||||||
|
onScheduleRepetition?: (repId: string, experimentId: string) => void
|
||||||
|
visibleMarkers: Array<{ id: string; startTime: Date; assignedConductors: string[] }>
|
||||||
|
getTimePosition: (time: Date) => number
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
const handleGoToRepetition = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (onScrollToRepetition) {
|
||||||
|
onScrollToRepetition(repId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSchedule = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (onScheduleRepetition && experimentId) {
|
||||||
|
onScheduleRepetition(repId, experimentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate positions for the text container
|
||||||
|
const firstMarker = visibleMarkers[0]
|
||||||
|
const secondMarker = visibleMarkers[1]
|
||||||
|
|
||||||
|
let textContainerLeft = 0
|
||||||
|
let textContainerWidth = 0
|
||||||
|
|
||||||
|
if (firstMarker) {
|
||||||
|
const firstMarkerX = getTimePosition(firstMarker.startTime)
|
||||||
|
const firstMarkerLeftRelative = firstMarkerX - left
|
||||||
|
const MARKER_ICON_SIZE = 32
|
||||||
|
// Start after the first marker (marker center + half icon size + padding)
|
||||||
|
textContainerLeft = firstMarkerLeftRelative + (MARKER_ICON_SIZE / 2) + 8
|
||||||
|
|
||||||
|
if (secondMarker) {
|
||||||
|
// Span to the second marker
|
||||||
|
const secondMarkerX = getTimePosition(secondMarker.startTime)
|
||||||
|
const secondMarkerLeftRelative = secondMarkerX - left
|
||||||
|
textContainerWidth = secondMarkerLeftRelative - (MARKER_ICON_SIZE / 2) - textContainerLeft - 8
|
||||||
|
} else {
|
||||||
|
// Span to the end of the repetition border
|
||||||
|
textContainerWidth = width - textContainerLeft - 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all markers have at least one conductor assigned
|
||||||
|
const allMarkersHaveConductors = visibleMarkers.length > 0 && visibleMarkers.every(m => m.assignedConductors.length > 0)
|
||||||
|
|
||||||
// Build border style based on extensions
|
// Build border style based on extensions
|
||||||
|
|
||||||
// Border radius: top-left top-right bottom-right bottom-left
|
// Border radius: top-left top-right bottom-right bottom-left
|
||||||
@@ -98,7 +164,6 @@ function RepetitionBorder({
|
|||||||
: (isLocked ? 'rgba(156, 163, 175, 0.3)' : 'rgba(59, 130, 246, 0.3)'),
|
: (isLocked ? 'rgba(156, 163, 175, 0.3)' : 'rgba(59, 130, 246, 0.3)'),
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
zIndex: isDragging ? 10 : 2,
|
|
||||||
opacity: isDragging ? 0.7 : 1,
|
opacity: isDragging ? 0.7 : 1,
|
||||||
transition: isDragging ? 'none' : 'left 0.2s ease-out, border-color 0.2s ease-in-out',
|
transition: isDragging ? 'none' : 'left 0.2s ease-out, border-color 0.2s ease-in-out',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
@@ -108,9 +173,56 @@ function RepetitionBorder({
|
|||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
{/* Text container div spanning from after first marker to next marker */}
|
||||||
|
{firstMarker && textContainerWidth > 0 && (phaseName || experimentNumber !== undefined || repetitionNumber !== undefined) && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1 flex items-center gap-2 pointer-events-auto z-10"
|
||||||
|
style={{
|
||||||
|
left: `${textContainerLeft}px`,
|
||||||
|
width: `${textContainerWidth}px`,
|
||||||
|
height: `${height - 2}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Text content (non-clickable) */}
|
||||||
|
<div
|
||||||
|
className="text-xs font-medium text-gray-700 dark:text-gray-300 flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
lineHeight: '1.2',
|
||||||
|
whiteSpace: 'pre-line'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phaseName && <div>{phaseName}</div>}
|
||||||
|
{experimentNumber !== undefined && <div>Exp {experimentNumber}</div>}
|
||||||
|
{repetitionNumber !== undefined && <div>Rep {repetitionNumber}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Go to repetition button */}
|
||||||
|
<button
|
||||||
|
onClick={handleGoToRepetition}
|
||||||
|
className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors"
|
||||||
|
title="Go to repetition"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Schedule button */}
|
||||||
|
{onScheduleRepetition && experimentId && (
|
||||||
|
<button
|
||||||
|
onClick={handleSchedule}
|
||||||
|
disabled={!allMarkersHaveConductors}
|
||||||
|
className="flex-shrink-0 px-2 py-1 text-xs bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded transition-colors"
|
||||||
|
title={allMarkersHaveConductors ? "Schedule repetition" : "All markers must have at least one conductor assigned"}
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -122,7 +234,9 @@ export function HorizontalTimelineCalendar({
|
|||||||
phaseMarkers,
|
phaseMarkers,
|
||||||
onMarkerDrag,
|
onMarkerDrag,
|
||||||
onMarkerAssignConductors,
|
onMarkerAssignConductors,
|
||||||
onMarkerLockToggle,
|
repetitionMetadata = {},
|
||||||
|
onScrollToRepetition,
|
||||||
|
onScheduleRepetition,
|
||||||
timeStep = 60, // 60 minutes (1 hour) per time slot for 24 divisions
|
timeStep = 60, // 60 minutes (1 hour) per time slot for 24 divisions
|
||||||
minHour = 0,
|
minHour = 0,
|
||||||
maxHour = 24,
|
maxHour = 24,
|
||||||
@@ -135,17 +249,16 @@ export function HorizontalTimelineCalendar({
|
|||||||
const [draggingMarker, setDraggingMarker] = useState<string | null>(null)
|
const [draggingMarker, setDraggingMarker] = useState<string | null>(null)
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||||
const [hoveredMarker, setHoveredMarker] = useState<string | null>(null)
|
const [hoveredMarker, setHoveredMarker] = useState<string | null>(null)
|
||||||
const [selectedMarker, setSelectedMarker] = useState<string | null>(null)
|
|
||||||
const [assignmentPanelPosition, setAssignmentPanelPosition] = useState<{ x: number; y: number } | null>(null)
|
|
||||||
const [hoveredAvailability, setHoveredAvailability] = useState<string | null>(null) // Format: "conductorId-availIndex"
|
const [hoveredAvailability, setHoveredAvailability] = useState<string | null>(null) // Format: "conductorId-availIndex"
|
||||||
const [hoveredVerticalLine, setHoveredVerticalLine] = useState<string | null>(null) // Marker ID
|
const [hoveredVerticalLine, setHoveredVerticalLine] = useState<string | null>(null) // Marker ID
|
||||||
const [verticalLineTooltip, setVerticalLineTooltip] = useState<{ markerId: string; x: number; y: number; time: string } | null>(null) // Tooltip position and data
|
const [verticalLineTooltip, setVerticalLineTooltip] = useState<{ markerId: string; x: number; y: number; time: string } | null>(null) // Tooltip position and data
|
||||||
|
const [hoveredIntersection, setHoveredIntersection] = useState<string | null>(null) // Format: "markerId-conductorId"
|
||||||
const [draggingRepetition, setDraggingRepetition] = useState<string | null>(null) // Repetition ID being dragged
|
const [draggingRepetition, setDraggingRepetition] = useState<string | null>(null) // Repetition ID being dragged
|
||||||
|
const [showIntersections, setShowIntersections] = useState<boolean>(true) // Control visibility of intersection buttons
|
||||||
const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 })
|
const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 })
|
||||||
const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position
|
const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(0)
|
const [containerWidth, setContainerWidth] = useState<number>(0)
|
||||||
const timelineRef = useRef<HTMLDivElement>(null)
|
const timelineRef = useRef<HTMLDivElement>(null)
|
||||||
const assignmentPanelRef = useRef<HTMLDivElement>(null)
|
|
||||||
const scrollableContainersRef = useRef<HTMLDivElement[]>([])
|
const scrollableContainersRef = useRef<HTMLDivElement[]>([])
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -286,28 +399,6 @@ export function HorizontalTimelineCalendar({
|
|||||||
}
|
}
|
||||||
}, [days.length]) // Re-run when days change
|
}, [days.length]) // Re-run when days change
|
||||||
|
|
||||||
// Close assignment panel when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
selectedMarker &&
|
|
||||||
assignmentPanelRef.current &&
|
|
||||||
!assignmentPanelRef.current.contains(event.target as Node) &&
|
|
||||||
!(event.target as HTMLElement).closest('[data-marker-id]') &&
|
|
||||||
!(event.target as HTMLElement).closest('button[title="Assign Conductors"]')
|
|
||||||
) {
|
|
||||||
setSelectedMarker(null)
|
|
||||||
setAssignmentPanelPosition(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMarker && assignmentPanelPosition) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedMarker, assignmentPanelPosition])
|
|
||||||
|
|
||||||
// Generate time slots for a day - 24 hours, one slot per hour
|
// Generate time slots for a day - 24 hours, one slot per hour
|
||||||
const timeSlots = useMemo(() => {
|
const timeSlots = useMemo(() => {
|
||||||
@@ -357,11 +448,14 @@ export function HorizontalTimelineCalendar({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const marker = phaseMarkers.find(m => m.id === markerId)
|
const marker = phaseMarkers.find(m => m.id === markerId)
|
||||||
if (!marker || marker.locked) return
|
if (!marker) return
|
||||||
|
|
||||||
// Find all markers in this repetition
|
// Find all markers in this repetition
|
||||||
const markers = phaseMarkers.filter(m => m.repetitionId === marker.repetitionId)
|
const markers = phaseMarkers.filter(m => m.repetitionId === marker.repetitionId)
|
||||||
if (markers.some(m => m.locked)) return
|
|
||||||
|
// Check if any marker in this repetition has assigned conductors - if so, disable drag
|
||||||
|
const hasAssignedConductors = markers.some(m => m.assignedConductors.length > 0)
|
||||||
|
if (hasAssignedConductors) return
|
||||||
|
|
||||||
if (!timelineRef.current) return
|
if (!timelineRef.current) return
|
||||||
|
|
||||||
@@ -385,6 +479,8 @@ export function HorizontalTimelineCalendar({
|
|||||||
x: offsetX
|
x: offsetX
|
||||||
})
|
})
|
||||||
setDragPosition({ x: borderLeft })
|
setDragPosition({ x: borderLeft })
|
||||||
|
// Hide intersections when dragging starts
|
||||||
|
setShowIntersections(false)
|
||||||
}, [phaseMarkers, getTimePosition])
|
}, [phaseMarkers, getTimePosition])
|
||||||
|
|
||||||
// Handle repetition border drag start
|
// Handle repetition border drag start
|
||||||
@@ -393,7 +489,11 @@ export function HorizontalTimelineCalendar({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const markers = phaseMarkers.filter(m => m.repetitionId === repetitionId)
|
const markers = phaseMarkers.filter(m => m.repetitionId === repetitionId)
|
||||||
if (markers.length === 0 || markers.some(m => m.locked)) return
|
if (markers.length === 0) return
|
||||||
|
|
||||||
|
// Check if any marker in this repetition has assigned conductors - if so, disable drag
|
||||||
|
const hasAssignedConductors = markers.some(m => m.assignedConductors.length > 0)
|
||||||
|
if (hasAssignedConductors) return
|
||||||
|
|
||||||
if (!timelineRef.current) return
|
if (!timelineRef.current) return
|
||||||
|
|
||||||
@@ -411,6 +511,8 @@ export function HorizontalTimelineCalendar({
|
|||||||
x: offsetX
|
x: offsetX
|
||||||
})
|
})
|
||||||
setDragPosition({ x: borderLeft })
|
setDragPosition({ x: borderLeft })
|
||||||
|
// Hide intersections when dragging starts
|
||||||
|
setShowIntersections(false)
|
||||||
}, [phaseMarkers])
|
}, [phaseMarkers])
|
||||||
|
|
||||||
// Handle mouse move during drag - only update visual position, save on mouse up
|
// Handle mouse move during drag - only update visual position, save on mouse up
|
||||||
@@ -471,6 +573,11 @@ export function HorizontalTimelineCalendar({
|
|||||||
setDraggingRepetition(null)
|
setDraggingRepetition(null)
|
||||||
setRepetitionDragOffset({ x: 0 })
|
setRepetitionDragOffset({ x: 0 })
|
||||||
setDragPosition(null)
|
setDragPosition(null)
|
||||||
|
|
||||||
|
// Show intersections again after animation completes (200ms transition duration)
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowIntersections(true)
|
||||||
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove)
|
window.addEventListener('mousemove', handleMouseMove)
|
||||||
@@ -626,7 +733,7 @@ export function HorizontalTimelineCalendar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={availIndex}
|
key={availIndex}
|
||||||
className="absolute top-1/2 -translate-y-1/2 rounded-full z-10 transition-all duration-200 ease-in-out cursor-pointer"
|
className="absolute top-1/2 -translate-y-1/2 rounded-full z-0 transition-all duration-200 ease-in-out cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
left: `${startPos}px`,
|
left: `${startPos}px`,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
@@ -643,6 +750,73 @@ export function HorizontalTimelineCalendar({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Render intersection indicators for markers that intersect with this conductor's availability */}
|
||||||
|
{showIntersections && visibleMarkers.map((marker) => {
|
||||||
|
// Check if marker's time falls within any of this conductor's availability windows
|
||||||
|
const intersectingAvailability = conductor.availability.find(avail => {
|
||||||
|
return marker.startTime >= avail.start && marker.startTime <= avail.end
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!intersectingAvailability) return null
|
||||||
|
|
||||||
|
// Calculate marker's x position
|
||||||
|
const markerX = getTimePosition(marker.startTime)
|
||||||
|
|
||||||
|
// Check if marker is within the availability line's bounds
|
||||||
|
const availStartPos = getTimePosition(intersectingAvailability.start)
|
||||||
|
const availEndPos = getTimePosition(intersectingAvailability.end)
|
||||||
|
|
||||||
|
if (markerX < availStartPos || markerX > availEndPos) return null
|
||||||
|
|
||||||
|
const intersectionKey = `${marker.id}-${conductor.conductorId}`
|
||||||
|
const isHovered = hoveredIntersection === intersectionKey
|
||||||
|
const isAssigned = marker.assignedConductors.includes(conductor.conductorId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={intersectionKey}
|
||||||
|
className={`absolute rounded-full flex items-center justify-center text-white font-bold shadow-lg transition-all duration-200 ease-in-out focus:outline-none focus:ring-0 ${isHovered
|
||||||
|
? isAssigned
|
||||||
|
? 'w-6 h-6 bg-red-500 hover:bg-red-600 text-sm'
|
||||||
|
: 'w-6 h-6 bg-green-500 hover:bg-green-600 text-sm'
|
||||||
|
: isAssigned
|
||||||
|
? 'w-3 h-3 bg-blue-500 border-2 border-blue-600'
|
||||||
|
: 'w-3 h-3 bg-gray-300 border-2 border-gray-400'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
left: `${markerX + 2}px`, // Push 2px to the right
|
||||||
|
top: '47%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 10001 // Higher than vertical lines (z-index: 2) and everything else
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
if (isAssigned) {
|
||||||
|
// Unassign conductor
|
||||||
|
const newAssignments = marker.assignedConductors.filter(id => id !== conductor.conductorId)
|
||||||
|
onMarkerAssignConductors(marker.id, newAssignments)
|
||||||
|
} else {
|
||||||
|
// Assign conductor
|
||||||
|
const newAssignments = [...marker.assignedConductors, conductor.conductorId]
|
||||||
|
onMarkerAssignConductors(marker.id, newAssignments)
|
||||||
|
}
|
||||||
|
// Clear hover state after click to prevent showing both states
|
||||||
|
setHoveredIntersection(null)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredIntersection(intersectionKey)}
|
||||||
|
onMouseLeave={() => setHoveredIntersection(null)}
|
||||||
|
title={isHovered
|
||||||
|
? (isAssigned ? `Unassign ${conductor.conductorName}` : `Assign ${conductor.conductorName}`)
|
||||||
|
: (isAssigned ? `${conductor.conductorName} assigned` : `Click to assign ${conductor.conductorName}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isHovered && (isAssigned ? '−' : '+')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -826,6 +1000,10 @@ export function HorizontalTimelineCalendar({
|
|||||||
const times = rep.visibleMarkers.map(m => moment(m.startTime).format('h:mm A')).join(', ')
|
const times = rep.visibleMarkers.map(m => moment(m.startTime).format('h:mm A')).join(', ')
|
||||||
const totalAssigned = new Set(rep.visibleMarkers.flatMap(m => m.assignedConductors)).size
|
const totalAssigned = new Set(rep.visibleMarkers.flatMap(m => m.assignedConductors)).size
|
||||||
|
|
||||||
|
// Check if any marker has assigned conductors - if so, disable drag
|
||||||
|
const hasAssignedConductors = rep.visibleMarkers.some(m => m.assignedConductors.length > 0)
|
||||||
|
const isDraggable = !hasAssignedConductors
|
||||||
|
|
||||||
// Calculate height based on markers (just enough for markers)
|
// Calculate height based on markers (just enough for markers)
|
||||||
const repHeight = MARKER_HEIGHT
|
const repHeight = MARKER_HEIGHT
|
||||||
|
|
||||||
@@ -838,9 +1016,11 @@ export function HorizontalTimelineCalendar({
|
|||||||
const markerElements = rep.visibleMarkers.map((marker) => {
|
const markerElements = rep.visibleMarkers.map((marker) => {
|
||||||
const style = getPhaseStyle(marker.phase)
|
const style = getPhaseStyle(marker.phase)
|
||||||
const absoluteX = getTimePosition(marker.startTime)
|
const absoluteX = getTimePosition(marker.startTime)
|
||||||
const isSelected = selectedMarker === marker.id
|
|
||||||
const isVerticalLineHovered = hoveredVerticalLine === marker.id
|
const isVerticalLineHovered = hoveredVerticalLine === marker.id
|
||||||
|
|
||||||
|
// Check if this repetition has any assigned conductors (affects all markers)
|
||||||
|
const markerIsDraggable = !hasAssignedConductors
|
||||||
|
|
||||||
// Calculate marker position relative to repetition border's left edge
|
// Calculate marker position relative to repetition border's left edge
|
||||||
// The repetition border starts at currentLeft, and markers are positioned relative to that
|
// The repetition border starts at currentLeft, and markers are positioned relative to that
|
||||||
let markerLeftRelative = absoluteX - currentLeft
|
let markerLeftRelative = absoluteX - currentLeft
|
||||||
@@ -873,8 +1053,7 @@ export function HorizontalTimelineCalendar({
|
|||||||
<div
|
<div
|
||||||
key={marker.id}
|
key={marker.id}
|
||||||
data-marker-id={marker.id}
|
data-marker-id={marker.id}
|
||||||
className={`absolute ${marker.locked ? 'cursor-not-allowed' : 'cursor-grab'} active:cursor-grabbing transition-all ${isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
className={`absolute ${markerIsDraggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed'} transition-all ${isDragging ? 'opacity-50' : ''}`}
|
||||||
} ${isDragging ? 'opacity-50 z-50' : 'z-40'}`}
|
|
||||||
style={{
|
style={{
|
||||||
left: `${markerLeftRelative}px`,
|
left: `${markerLeftRelative}px`,
|
||||||
top: `${MARKER_TOP_OFFSET}px`,
|
top: `${MARKER_TOP_OFFSET}px`,
|
||||||
@@ -882,7 +1061,7 @@ export function HorizontalTimelineCalendar({
|
|||||||
transition: isDragging ? 'none' : 'left 0.2s ease-out',
|
transition: isDragging ? 'none' : 'left 0.2s ease-out',
|
||||||
overflow: 'visible'
|
overflow: 'visible'
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => handleMarkerMouseDown(e, marker.id)}
|
onMouseDown={markerIsDraggable ? (e) => handleMarkerMouseDown(e, marker.id) : undefined}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setHoveredMarker(marker.id)
|
setHoveredMarker(marker.id)
|
||||||
setHoveredVerticalLine(marker.id)
|
setHoveredVerticalLine(marker.id)
|
||||||
@@ -902,10 +1081,10 @@ export function HorizontalTimelineCalendar({
|
|||||||
width: isVerticalLineHovered ? '4px' : '2px',
|
width: isVerticalLineHovered ? '4px' : '2px',
|
||||||
height: `${lineHeight}px`,
|
height: `${lineHeight}px`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
backgroundColor: marker.locked ? '#9ca3af' : style.color,
|
backgroundColor: style.color,
|
||||||
opacity: isVerticalLineHovered ? 0.9 : 0.4,
|
opacity: isVerticalLineHovered ? 0.9 : 0.4,
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
zIndex: isDragging ? 30 : 10000,
|
zIndex: 2,
|
||||||
transition: isDragging ? 'none' : 'width 0.2s ease-in-out, opacity 0.2s ease-in-out'
|
transition: isDragging ? 'none' : 'width 0.2s ease-in-out, opacity 0.2s ease-in-out'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -939,46 +1118,32 @@ export function HorizontalTimelineCalendar({
|
|||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
backgroundColor: marker.locked ? '#9ca3af' : style.color,
|
backgroundColor: style.color,
|
||||||
border: `2px solid ${marker.locked ? '#6b7280' : style.color}`,
|
border: `2px solid ${style.color}`,
|
||||||
boxShadow: isSelected ? `0 0 0 3px ${style.color}40` : '0 2px 4px rgba(0,0,0,0.2)'
|
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-lg" style={{ filter: marker.locked ? 'grayscale(100%)' : 'none' }}>
|
<span className="text-lg">
|
||||||
{style.icon}
|
{style.icon}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Assign Conductors button */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
const markerElement = e.currentTarget.closest('[data-marker-id]') as HTMLElement
|
|
||||||
if (markerElement) {
|
|
||||||
const markerRect = markerElement.getBoundingClientRect()
|
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
|
||||||
if (containerRect) {
|
|
||||||
setSelectedMarker(marker.id)
|
|
||||||
setAssignmentPanelPosition({
|
|
||||||
x: markerRect.right - containerRect.left + 8,
|
|
||||||
y: markerRect.top - containerRect.top
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center text-xs shadow-md z-50"
|
|
||||||
style={{ fontSize: '10px' }}
|
|
||||||
title="Assign Conductors"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection indicators on assigned conductors */}
|
{/* Connection indicators on assigned conductors */}
|
||||||
|
{/* Only show dots for conductors that don't have an intersection button visible */}
|
||||||
{marker.assignedConductors.map((conductorId, lineIndex) => {
|
{marker.assignedConductors.map((conductorId, lineIndex) => {
|
||||||
const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId)
|
const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId)
|
||||||
if (conductorIndex === -1) return null
|
if (conductorIndex === -1) return null
|
||||||
|
|
||||||
|
// Check if this conductor has an intersection button (i.e., their availability intersects with this marker)
|
||||||
|
const conductor = conductorAvailabilities[conductorIndex]
|
||||||
|
const hasIntersectionButton = conductor.availability.some(avail => {
|
||||||
|
return marker.startTime >= avail.start && marker.startTime <= avail.end
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hide the dot if there's an intersection button for this conductor
|
||||||
|
if (hasIntersectionButton) return null
|
||||||
|
|
||||||
const CONDUCTOR_ROW_HEIGHT = 36
|
const CONDUCTOR_ROW_HEIGHT = 36
|
||||||
const HEADER_ROW_HEIGHT = 60
|
const HEADER_ROW_HEIGHT = 60
|
||||||
const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36
|
const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36
|
||||||
@@ -1014,6 +1179,14 @@ export function HorizontalTimelineCalendar({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const metadata = repetitionMetadata[rep.repId]
|
||||||
|
// Convert visible markers to format needed by RepetitionBorder
|
||||||
|
const borderMarkers = rep.visibleMarkers.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
startTime: m.startTime,
|
||||||
|
assignedConductors: m.assignedConductors
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepetitionBorder
|
<RepetitionBorder
|
||||||
key={rep.repId}
|
key={rep.repId}
|
||||||
@@ -1021,16 +1194,24 @@ export function HorizontalTimelineCalendar({
|
|||||||
width={rep.width}
|
width={rep.width}
|
||||||
top={0}
|
top={0}
|
||||||
height={repHeight}
|
height={repHeight}
|
||||||
isLocked={firstMarker.locked}
|
isLocked={!isDraggable}
|
||||||
allPhases={allPhases}
|
allPhases={allPhases}
|
||||||
times={times}
|
times={times}
|
||||||
assignedCount={totalAssigned}
|
assignedCount={totalAssigned}
|
||||||
repId={rep.repId}
|
repId={rep.repId}
|
||||||
onMouseDown={(e) => handleRepetitionMouseDown(e, rep.repId)}
|
onMouseDown={isDraggable ? (e) => handleRepetitionMouseDown(e, rep.repId) : undefined}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragOffset={repetitionDragOffset}
|
dragOffset={repetitionDragOffset}
|
||||||
extendLeft={rep.extendLeft}
|
extendLeft={rep.extendLeft}
|
||||||
extendRight={rep.extendRight}
|
extendRight={rep.extendRight}
|
||||||
|
phaseName={metadata?.phaseName}
|
||||||
|
experimentNumber={metadata?.experimentNumber}
|
||||||
|
repetitionNumber={metadata?.repetitionNumber}
|
||||||
|
experimentId={metadata?.experimentId}
|
||||||
|
onScrollToRepetition={onScrollToRepetition}
|
||||||
|
onScheduleRepetition={onScheduleRepetition}
|
||||||
|
visibleMarkers={borderMarkers}
|
||||||
|
getTimePosition={getTimePosition}
|
||||||
>
|
>
|
||||||
{markerElements}
|
{markerElements}
|
||||||
</RepetitionBorder>
|
</RepetitionBorder>
|
||||||
@@ -1044,78 +1225,6 @@ export function HorizontalTimelineCalendar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conductor assignment panel (shown when marker is selected) */}
|
|
||||||
{selectedMarker && assignmentPanelPosition && (
|
|
||||||
<div
|
|
||||||
ref={assignmentPanelRef}
|
|
||||||
className="absolute bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50 min-w-[300px] max-h-[400px] overflow-y-auto"
|
|
||||||
style={{
|
|
||||||
left: `${assignmentPanelPosition.x}px`,
|
|
||||||
top: `${assignmentPanelPosition.y}px`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3 sticky top-0 bg-white dark:bg-gray-800 pb-2 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
Assign Conductors
|
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedMarker(null)
|
|
||||||
setAssignmentPanelPosition(null)
|
|
||||||
}}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{conductorAvailabilities.map((conductor) => {
|
|
||||||
const marker = phaseMarkers.find(m => m.id === selectedMarker)
|
|
||||||
const isAssigned = marker?.assignedConductors.includes(conductor.conductorId) || false
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={conductor.conductorId}
|
|
||||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isAssigned}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!marker) return
|
|
||||||
const newAssignments = e.target.checked
|
|
||||||
? [...marker.assignedConductors, conductor.conductorId]
|
|
||||||
: marker.assignedConductors.filter(id => id !== conductor.conductorId)
|
|
||||||
onMarkerAssignConductors(selectedMarker, newAssignments)
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: conductor.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-900 dark:text-white">
|
|
||||||
{conductor.conductorName}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const marker = visibleMarkers.find(m => m.id === selectedMarker) || phaseMarkers.find(m => m.id === selectedMarker)
|
|
||||||
if (marker) {
|
|
||||||
onMarkerLockToggle(selectedMarker)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-xs px-3 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{phaseMarkers.find(m => m.id === selectedMarker)?.locked ? '🔒 Unlock' : '🔓 Lock'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
|
|
||||||
// Track repetitions that have been dropped/moved and should show time points
|
// Track repetitions that have been dropped/moved and should show time points
|
||||||
const [repetitionsWithTimes, setRepetitionsWithTimes] = useState<Set<string>>(new Set())
|
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
|
// Track which repetitions are currently being scheduled
|
||||||
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
|
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
|
||||||
// Track conductor assignments for each phase marker (markerId -> conductorIds[])
|
// Track conductor assignments for each phase marker (markerId -> conductorIds[])
|
||||||
@@ -269,28 +267,18 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
next.delete(repId)
|
next.delete(repId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
setLockedSchedules(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(repId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setSchedulingRepetitions(prev => {
|
setSchedulingRepetitions(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
next.delete(repId)
|
next.delete(repId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
// Re-stagger remaining repetitions
|
// Don't re-stagger remaining repetitions - they should keep their positions
|
||||||
const remainingIds = Array.from(next).filter(id => id !== repId)
|
|
||||||
if (remainingIds.length > 0) {
|
|
||||||
reStaggerRepetitions(remainingIds)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
next.add(repId)
|
next.add(repId)
|
||||||
// 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 will position the new repetition relative to existing ones
|
||||||
|
// without resetting existing positions
|
||||||
spawnSingleRepetition(repId, next)
|
spawnSingleRepetition(repId, next)
|
||||||
// Re-stagger all existing repetitions to prevent overlap
|
|
||||||
// Note: reStaggerRepetitions will automatically skip locked repetitions
|
|
||||||
reStaggerRepetitions([...next, repId])
|
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -356,7 +344,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
|
|
||||||
// Re-stagger all repetitions to prevent overlap
|
// Re-stagger all repetitions to prevent overlap
|
||||||
// IMPORTANT: Skip locked repetitions to prevent them from moving
|
// IMPORTANT: Skip locked repetitions to prevent them from moving
|
||||||
const reStaggerRepetitions = useCallback((repIds: string[]) => {
|
const reStaggerRepetitions = useCallback((repIds: string[], onlyResetWithoutCustomTimes: boolean = false) => {
|
||||||
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)
|
||||||
@@ -364,8 +352,11 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
setScheduledRepetitions(prev => {
|
setScheduledRepetitions(prev => {
|
||||||
const newScheduled = { ...prev }
|
const newScheduled = { ...prev }
|
||||||
|
|
||||||
// Filter out locked repetitions - they should not be moved
|
// If onlyResetWithoutCustomTimes is true, filter out repetitions that have custom times set
|
||||||
const unlockedRepIds = repIds.filter(repId => !lockedSchedules.has(repId))
|
let unlockedRepIds = repIds
|
||||||
|
if (onlyResetWithoutCustomTimes) {
|
||||||
|
unlockedRepIds = unlockedRepIds.filter(repId => !repetitionsWithTimes.has(repId))
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate stagger index only for unlocked repetitions
|
// Calculate stagger index only for unlocked repetitions
|
||||||
let staggerIndex = 0
|
let staggerIndex = 0
|
||||||
@@ -407,7 +398,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
|
|
||||||
return newScheduled
|
return newScheduled
|
||||||
})
|
})
|
||||||
}, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment])
|
}, [repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment, repetitionsWithTimes])
|
||||||
|
|
||||||
// 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>) => {
|
||||||
@@ -537,13 +528,10 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
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: `${lockIcon} 💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
title: `💧 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'
|
||||||
@@ -553,7 +541,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
if (scheduled.airdryingStart) {
|
if (scheduled.airdryingStart) {
|
||||||
events.push({
|
events.push({
|
||||||
id: `${scheduled.repetitionId}-airdrying`,
|
id: `${scheduled.repetitionId}-airdrying`,
|
||||||
title: `${lockIcon} 🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
title: `🌬️ 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'
|
||||||
@@ -564,7 +552,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
if (scheduled.crackingStart) {
|
if (scheduled.crackingStart) {
|
||||||
events.push({
|
events.push({
|
||||||
id: `${scheduled.repetitionId}-cracking`,
|
id: `${scheduled.repetitionId}-cracking`,
|
||||||
title: `${lockIcon} ⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
|
title: `⚡ 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'
|
||||||
@@ -574,7 +562,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
return events
|
return events
|
||||||
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules])
|
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment])
|
||||||
|
|
||||||
// Memoize the calendar events
|
// Memoize the calendar events
|
||||||
const calendarEvents = useMemo(() => {
|
const calendarEvents = useMemo(() => {
|
||||||
@@ -610,15 +598,16 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
return moment(date).format('MMM D, h:mm A')
|
return moment(date).format('MMM D, h:mm A')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleScheduleLock = (repId: string) => {
|
// Remove all conductor assignments from a repetition
|
||||||
setLockedSchedules(prev => {
|
const removeRepetitionAssignments = (repId: string) => {
|
||||||
const next = new Set(prev)
|
const markerIdPrefix = repId
|
||||||
if (next.has(repId)) {
|
setConductorAssignments(prev => {
|
||||||
next.delete(repId)
|
const newAssignments = { ...prev }
|
||||||
} else {
|
// Remove assignments for all three phases
|
||||||
next.add(repId)
|
delete newAssignments[`${markerIdPrefix}-soaking`]
|
||||||
}
|
delete newAssignments[`${markerIdPrefix}-airdrying`]
|
||||||
return next
|
delete newAssignments[`${markerIdPrefix}-cracking`]
|
||||||
|
return newAssignments
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,24 +615,16 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
// Only make repetition markers draggable, not availability events
|
// Only make repetition markers draggable, not availability events
|
||||||
const resource = event.resource as string
|
const resource = event.resource as string
|
||||||
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
||||||
// Check if the repetition is locked
|
return true
|
||||||
const eventId = event.id as string
|
|
||||||
const repId = eventId.split('-')[0]
|
|
||||||
const isLocked = lockedSchedules.has(repId)
|
|
||||||
return !isLocked
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, [lockedSchedules])
|
}, [])
|
||||||
|
|
||||||
const eventPropGetter = useCallback((event: any) => {
|
const eventPropGetter = useCallback((event: any) => {
|
||||||
const resource = event.resource as string
|
const resource = event.resource as string
|
||||||
|
|
||||||
// Styling for repetition markers (foreground events)
|
// Styling for repetition markers (foreground events)
|
||||||
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
|
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 = {
|
const colors = {
|
||||||
soaking: '#3b82f6', // blue
|
soaking: '#3b82f6', // blue
|
||||||
airdrying: '#10b981', // green
|
airdrying: '#10b981', // green
|
||||||
@@ -653,8 +634,8 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: isLocked ? '#9ca3af' : color, // gray if locked
|
backgroundColor: color,
|
||||||
borderColor: isLocked ? color : color, // border takes original color when locked
|
borderColor: color,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '2px solid',
|
border: '2px solid',
|
||||||
@@ -675,17 +656,17 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
cursor: isLocked ? 'not-allowed' : 'grab',
|
cursor: 'grab',
|
||||||
boxShadow: isLocked ? '0 1px 2px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.2)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
opacity: isLocked ? 0.7 : 1
|
opacity: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default styling for other events
|
// Default styling for other events
|
||||||
return {}
|
return {}
|
||||||
}, [lockedSchedules])
|
}, [])
|
||||||
|
|
||||||
const scheduleRepetition = async (repId: string, experimentId: string) => {
|
const scheduleRepetition = async (repId: string, experimentId: string) => {
|
||||||
setSchedulingRepetitions(prev => new Set(prev).add(repId))
|
setSchedulingRepetitions(prev => new Set(prev).add(repId))
|
||||||
@@ -807,7 +788,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
phase: 'soaking' | 'airdrying' | 'cracking'
|
phase: 'soaking' | 'airdrying' | 'cracking'
|
||||||
startTime: Date
|
startTime: Date
|
||||||
assignedConductors: string[]
|
assignedConductors: string[]
|
||||||
locked: boolean
|
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
Object.values(scheduledRepetitions).forEach(scheduled => {
|
Object.values(scheduledRepetitions).forEach(scheduled => {
|
||||||
@@ -821,8 +801,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
experimentId: scheduled.experimentId,
|
experimentId: scheduled.experimentId,
|
||||||
phase: 'soaking',
|
phase: 'soaking',
|
||||||
startTime: scheduled.soakingStart,
|
startTime: scheduled.soakingStart,
|
||||||
assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || [],
|
assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || []
|
||||||
locked: lockedSchedules.has(repId)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,8 +812,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
experimentId: scheduled.experimentId,
|
experimentId: scheduled.experimentId,
|
||||||
phase: 'airdrying',
|
phase: 'airdrying',
|
||||||
startTime: scheduled.airdryingStart,
|
startTime: scheduled.airdryingStart,
|
||||||
assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || [],
|
assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || []
|
||||||
locked: lockedSchedules.has(repId)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,8 +823,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
experimentId: scheduled.experimentId,
|
experimentId: scheduled.experimentId,
|
||||||
phase: 'cracking',
|
phase: 'cracking',
|
||||||
startTime: scheduled.crackingStart,
|
startTime: scheduled.crackingStart,
|
||||||
assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || [],
|
assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || []
|
||||||
locked: lockedSchedules.has(repId)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -857,7 +834,59 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
conductorAvailabilities,
|
conductorAvailabilities,
|
||||||
phaseMarkers
|
phaseMarkers
|
||||||
}
|
}
|
||||||
}, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, lockedSchedules, calendarStartDate, calendarZoom])
|
}, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, calendarStartDate, calendarZoom])
|
||||||
|
|
||||||
|
// Build repetition metadata mapping for timeline display
|
||||||
|
const repetitionMetadata = useMemo(() => {
|
||||||
|
const metadata: Record<string, { phaseName: string; experimentNumber: number; repetitionNumber: number }> = {}
|
||||||
|
|
||||||
|
Object.values(scheduledRepetitions).forEach(scheduled => {
|
||||||
|
const repId = scheduled.repetitionId
|
||||||
|
const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === scheduled.experimentId)
|
||||||
|
const repetition = Object.values(repetitionsByExperiment).flat().find(r => r.id === repId)
|
||||||
|
const phase = phases.find(p =>
|
||||||
|
Object.values(experimentsByPhase[p.id] || []).some(e => e.id === scheduled.experimentId)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (experiment && repetition && phase) {
|
||||||
|
metadata[repId] = {
|
||||||
|
phaseName: phase.name,
|
||||||
|
experimentNumber: experiment.experiment_number,
|
||||||
|
repetitionNumber: repetition.repetition_number,
|
||||||
|
experimentId: scheduled.experimentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, phases])
|
||||||
|
|
||||||
|
// Scroll to repetition in accordion
|
||||||
|
const handleScrollToRepetition = useCallback(async (repetitionId: string) => {
|
||||||
|
// First, expand the phase if it's collapsed
|
||||||
|
const repetition = Object.values(repetitionsByExperiment).flat().find(r => r.id === repetitionId)
|
||||||
|
if (repetition) {
|
||||||
|
const experiment = Object.values(experimentsByPhase).flat().find(e =>
|
||||||
|
(repetitionsByExperiment[e.id] || []).some(r => r.id === repetitionId)
|
||||||
|
)
|
||||||
|
if (experiment) {
|
||||||
|
const phase = phases.find(p =>
|
||||||
|
(experimentsByPhase[p.id] || []).some(e => e.id === experiment.id)
|
||||||
|
)
|
||||||
|
if (phase && !expandedPhaseIds.has(phase.id)) {
|
||||||
|
await togglePhaseExpand(phase.id)
|
||||||
|
// Wait a bit for the accordion to expand
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then scroll to the element
|
||||||
|
const element = document.getElementById(`repetition-${repetitionId}`)
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}, [repetitionsByExperiment, experimentsByPhase, phases, expandedPhaseIds, togglePhaseExpand])
|
||||||
|
|
||||||
// Handlers for horizontal calendar
|
// Handlers for horizontal calendar
|
||||||
const handleHorizontalMarkerDrag = useCallback((markerId: string, newTime: Date) => {
|
const handleHorizontalMarkerDrag = useCallback((markerId: string, newTime: Date) => {
|
||||||
@@ -879,21 +908,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleHorizontalMarkerLockToggle = useCallback((markerId: string) => {
|
|
||||||
// Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens
|
|
||||||
// Split by '-' and take all but the last segment as repId
|
|
||||||
const parts = markerId.split('-')
|
|
||||||
const repId = parts.slice(0, -1).join('-')
|
|
||||||
setLockedSchedules(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(repId)) {
|
|
||||||
next.delete(repId)
|
|
||||||
} else {
|
|
||||||
next.add(repId)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1028,7 +1042,9 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
phaseMarkers={horizontalCalendarData.phaseMarkers}
|
phaseMarkers={horizontalCalendarData.phaseMarkers}
|
||||||
onMarkerDrag={handleHorizontalMarkerDrag}
|
onMarkerDrag={handleHorizontalMarkerDrag}
|
||||||
onMarkerAssignConductors={handleHorizontalMarkerAssignConductors}
|
onMarkerAssignConductors={handleHorizontalMarkerAssignConductors}
|
||||||
onMarkerLockToggle={handleHorizontalMarkerLockToggle}
|
repetitionMetadata={repetitionMetadata}
|
||||||
|
onScrollToRepetition={handleScrollToRepetition}
|
||||||
|
onScheduleRepetition={scheduleRepetition}
|
||||||
timeStep={15}
|
timeStep={15}
|
||||||
minHour={6}
|
minHour={6}
|
||||||
maxHour={22}
|
maxHour={22}
|
||||||
@@ -1197,11 +1213,21 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
const checked = selectedRepetitionIds.has(rep.id)
|
const checked = selectedRepetitionIds.has(rep.id)
|
||||||
const hasTimes = repetitionsWithTimes.has(rep.id)
|
const hasTimes = repetitionsWithTimes.has(rep.id)
|
||||||
const scheduled = scheduledRepetitions[rep.id]
|
const scheduled = scheduledRepetitions[rep.id]
|
||||||
const isLocked = lockedSchedules.has(rep.id)
|
|
||||||
const isScheduling = schedulingRepetitions.has(rep.id)
|
const isScheduling = schedulingRepetitions.has(rep.id)
|
||||||
|
|
||||||
|
// Check if there are any conductor assignments
|
||||||
|
const markerIdPrefix = rep.id
|
||||||
|
const soakingConductors = conductorAssignments[`${markerIdPrefix}-soaking`] || []
|
||||||
|
const airdryingConductors = conductorAssignments[`${markerIdPrefix}-airdrying`] || []
|
||||||
|
const crackingConductors = conductorAssignments[`${markerIdPrefix}-cracking`] || []
|
||||||
|
const hasAssignments = soakingConductors.length > 0 || airdryingConductors.length > 0 || crackingConductors.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={rep.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
|
<div
|
||||||
|
key={rep.id}
|
||||||
|
id={`repetition-${rep.id}`}
|
||||||
|
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3"
|
||||||
|
>
|
||||||
{/* Checkbox row */}
|
{/* Checkbox row */}
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1216,37 +1242,70 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
|
|||||||
{/* Time points (shown only if has been dropped/moved) */}
|
{/* Time points (shown only if has been dropped/moved) */}
|
||||||
{hasTimes && scheduled && (
|
{hasTimes && scheduled && (
|
||||||
<div className="mt-2 ml-6 text-xs space-y-1">
|
<div className="mt-2 ml-6 text-xs space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
{(() => {
|
||||||
<span>💧</span>
|
const repId = rep.id
|
||||||
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
|
const markerIdPrefix = repId
|
||||||
</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 */}
|
// Get assigned conductors for each phase
|
||||||
|
const soakingConductors = conductorAssignments[`${markerIdPrefix}-soaking`] || []
|
||||||
|
const airdryingConductors = conductorAssignments[`${markerIdPrefix}-airdrying`] || []
|
||||||
|
const crackingConductors = conductorAssignments[`${markerIdPrefix}-cracking`] || []
|
||||||
|
|
||||||
|
// Helper to get conductor names
|
||||||
|
const getConductorNames = (conductorIds: string[]) => {
|
||||||
|
return conductorIds.map(id => {
|
||||||
|
const conductor = conductors.find(c => c.id === id)
|
||||||
|
if (!conductor) return null
|
||||||
|
return [conductor.first_name, conductor.last_name].filter(Boolean).join(' ') || conductor.email
|
||||||
|
}).filter(Boolean).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span>💧</span>
|
||||||
|
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
|
||||||
|
{soakingConductors.length > 0 && (
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">
|
||||||
|
({getConductorNames(soakingConductors)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span>🌬️</span>
|
||||||
|
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
|
||||||
|
{airdryingConductors.length > 0 && (
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
({getConductorNames(airdryingConductors)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span>⚡</span>
|
||||||
|
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
|
||||||
|
{crackingConductors.length > 0 && (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">
|
||||||
|
({getConductorNames(crackingConductors)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Remove Assignments button and Schedule button */}
|
||||||
<div className="flex items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
|
<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">
|
{hasAssignments && (
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
onClick={() => removeRepetitionAssignments(rep.id)}
|
||||||
className="h-3 w-3 text-blue-600 border-gray-300 rounded"
|
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs transition-colors"
|
||||||
checked={isLocked}
|
>
|
||||||
onChange={() => {
|
Remove Assignments
|
||||||
toggleScheduleLock(rep.id)
|
</button>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{isLocked ? '🔒 Locked' : '🔓 Unlocked'}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => scheduleRepetition(rep.id, exp.id)}
|
onClick={() => scheduleRepetition(rep.id, exp.id)}
|
||||||
disabled={isScheduling || !isLocked}
|
disabled={isScheduling}
|
||||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
|
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'}
|
{isScheduling ? 'Scheduling...' : 'Schedule'}
|
||||||
|
|||||||
Reference in New Issue
Block a user