Add drag-and-drop scheduling functionality to the Scheduling component

- Integrated react-dnd and react-dnd-html5-backend for drag-and-drop capabilities.
- Enhanced the Scheduling component to allow users to visually manage experiment repetitions on the calendar.
- Added state management for scheduled repetitions and their timing.
- Implemented select-all checkboxes for conductors and repetitions for improved user experience.
- Updated calendar event generation to include new repetition markers with distinct styles.
- Refactored event handling to support draggable repetition markers and update their timing dynamically.
This commit is contained in:
salirezav
2025-09-24 21:23:38 -04:00
parent aaeb164a32
commit 853cec1b13
3 changed files with 478 additions and 46 deletions

View File

@@ -1,6 +1,10 @@
import { useEffect, useState } 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
import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import moment from 'moment'
import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../lib/supabase'
import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } from '../lib/supabase'
@@ -305,6 +309,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
// Calendar state for selected conductors' availability
const localizer = momentLocalizer(moment)
const DnDCalendar = withDragAndDrop(Calendar)
const [calendarView, setCalendarView] = useState(Views.WEEK)
const [currentDate, setCurrentDate] = useState(new Date())
const [availabilityEvents, setAvailabilityEvents] = useState<CalendarEvent[]>([])
@@ -313,6 +318,18 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
const [conductorColorMap, setConductorColorMap] = useState<Record<string, string>>({})
const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444']
// Repetition scheduling state
const [scheduledRepetitions, setScheduledRepetitions] = useState<Record<string, {
repetitionId: string
experimentId: string
soakingStart: Date | null
airdryingStart: Date | null
crackingStart: Date | null
}>>({})
// Visual style for repetition markers
const [markerStyle, setMarkerStyle] = useState<'circles' | 'dots' | 'icons' | 'lines'>('lines')
useEffect(() => {
const load = async () => {
try {
@@ -329,7 +346,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
setConductors(conductorsOnly)
// For each conductor, check if they have any availability in the future
const nowIso = new Date().toISOString()
// Query availability table directly for efficiency
const conductorIds = conductorsOnly.map(c => c.id)
// Fallback: since availabilityManagement is scoped to current user, we query via supabase client here would require direct import.
@@ -356,6 +372,23 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
})
}
const toggleAllConductors = () => {
const availableConductorIds = conductors
.filter(c => conductorIdsWithFutureAvailability.has(c.id))
.map(c => c.id)
setSelectedConductorIds(prev => {
// If all available conductors are selected, deselect all
const allSelected = availableConductorIds.every(id => prev.has(id))
if (allSelected) {
return new Set()
} else {
// Select all available conductors
return new Set(availableConductorIds)
}
})
}
// Fetch availability for selected conductors and build calendar events
useEffect(() => {
const loadSelectedAvailability = async () => {
@@ -396,7 +429,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
title: `${idToName[r.user_id] || 'Conductor'}`,
start: new Date(r.available_from),
end: new Date(r.available_to),
resource: newColorMap[r.user_id] || '#2563eb'
resource: r.user_id // Store conductor ID, not color
}))
setAvailabilityEvents(events)
@@ -462,12 +495,57 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
const toggleRepetition = (repId: string) => {
setSelectedRepetitionIds(prev => {
const next = new Set(prev)
if (next.has(repId)) next.delete(repId)
else next.add(repId)
if (next.has(repId)) {
next.delete(repId)
// Remove from scheduled repetitions when unchecked
setScheduledRepetitions(prevScheduled => {
const newScheduled = { ...prevScheduled }
delete newScheduled[repId]
return newScheduled
})
} else {
next.add(repId)
// Auto-spawn when checked
spawnSingleRepetition(repId)
}
return next
})
}
const toggleAllRepetitionsInPhase = (phaseId: string) => {
const experiments = experimentsByPhase[phaseId] || []
const allRepetitions = experiments.flatMap(exp => repetitionsByExperiment[exp.id] || [])
setSelectedRepetitionIds(prev => {
// Check if all repetitions in this phase are selected
const allSelected = allRepetitions.every(rep => prev.has(rep.id))
if (allSelected) {
// Deselect all repetitions in this phase
const next = new Set(prev)
allRepetitions.forEach(rep => {
next.delete(rep.id)
// Remove from scheduled repetitions
setScheduledRepetitions(prevScheduled => {
const newScheduled = { ...prevScheduled }
delete newScheduled[rep.id]
return newScheduled
})
})
return next
} else {
// Select all repetitions in this phase
const next = new Set(prev)
allRepetitions.forEach(rep => {
next.add(rep.id)
// Auto-spawn when checked
spawnSingleRepetition(rep.id)
})
return next
}
})
}
const createRepetitionsForExperiment = async (experimentId: string) => {
try {
setCreatingRepetitionsFor(prev => new Set(prev).add(experimentId))
@@ -492,6 +570,140 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
}
}
// Spawn a single repetition in calendar
const spawnSingleRepetition = (repId: string) => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow
// 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(tomorrow)
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))
setScheduledRepetitions(prev => ({
...prev,
[repId]: {
repetitionId: repId,
experimentId,
soakingStart,
airdryingStart,
crackingStart
}
}))
}
}
}
// Update phase timing when a marker is moved
const updatePhaseTiming = (repId: string, phase: 'soaking' | 'airdrying' | 'cracking', newTime: Date) => {
setScheduledRepetitions(prev => {
const current = prev[repId]
if (!current) return prev
const experimentId = current.experimentId
const soaking = soakingByExperiment[experimentId]
const airdrying = airdryingByExperiment[experimentId]
if (!soaking || !airdrying) return prev
let newScheduled = { ...prev }
if (phase === 'soaking') {
const airdryingStart = new Date(newTime.getTime() + (soaking.soaking_duration_minutes * 60000))
const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))
newScheduled[repId] = {
...current,
soakingStart: newTime,
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))
newScheduled[repId] = {
...current,
soakingStart,
airdryingStart,
crackingStart: newTime
}
}
return newScheduled
})
}
// Generate calendar events for scheduled repetitions
const generateRepetitionEvents = (): CalendarEvent[] => {
const events: CalendarEvent[] = []
Object.values(scheduledRepetitions).forEach(scheduled => {
const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === scheduled.experimentId)
const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId)
if (experiment && repetition && scheduled.soakingStart) {
// Soaking marker
events.push({
id: `${scheduled.repetitionId}-soaking`,
title: `Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.soakingStart,
end: new Date(scheduled.soakingStart.getTime() + 30 * 60000), // 30 minute duration for visibility
resource: 'soaking'
})
// Airdrying marker
if (scheduled.airdryingStart) {
events.push({
id: `${scheduled.repetitionId}-airdrying`,
title: `Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.airdryingStart,
end: new Date(scheduled.airdryingStart.getTime() + 30 * 60000),
resource: 'airdrying'
})
}
// Cracking marker
if (scheduled.crackingStart) {
events.push({
id: `${scheduled.repetitionId}-cracking`,
title: `Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.crackingStart,
end: new Date(scheduled.crackingStart.getTime() + 30 * 60000),
resource: 'cracking'
})
}
}
})
return events
}
return (
<div className="p-6">
<div className="mb-6">
@@ -534,6 +746,18 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
<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>
{/* Select All checkbox for conductors */}
<div className="mb-3">
<label className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded border border-gray-200 dark:border-gray-600">
<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>
<div className="max-h-[420px] overflow-y-auto divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
{conductors.length === 0 && (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
@@ -596,6 +820,20 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
</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>
)}
@@ -677,47 +915,152 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
)}
{/* Week Calendar for selected conductors' availability */}
<div className="mt-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Selected Conductors' Availability</h3>
<div className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
<Calendar
localizer={localizer}
events={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"
eventPropGetter={(event) => {
const color = event.resource || '#2563eb'
return {
style: {
backgroundColor: color + '80', // ~50% transparency
borderColor: color,
color: 'white',
borderRadius: '4px',
border: 'none',
opacity: 0.8,
height: 'auto',
minHeight: '20px',
fontSize: '12px',
padding: '2px 4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
<div className="flex gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
<button
onClick={() => setMarkerStyle('lines')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'lines'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Lines
</button>
<button
onClick={() => setMarkerStyle('circles')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'circles'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Circles
</button>
<button
onClick={() => setMarkerStyle('dots')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'dots'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Dots
</button>
<button
onClick={() => setMarkerStyle('icons')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'icons'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Icons
</button>
</div>
</div>
<div className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
<DndProvider backend={HTML5Backend}>
<DnDCalendar
localizer={localizer}
events={generateRepetitionEvents()}
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={(event: any) => {
// Only make repetition markers draggable, not availability events
const resource = event.resource as string
return resource === 'soaking' || resource === 'airdrying' || resource === 'cracking'
}}
onEventDrop={({ event, start }: { event: any, start: Date }) => {
// Handle dragging repetition markers
const eventId = event.id as string
if (eventId.includes('-soaking')) {
const repId = eventId.replace('-soaking', '')
updatePhaseTiming(repId, 'soaking', start)
} else if (eventId.includes('-airdrying')) {
const repId = eventId.replace('-airdrying', '')
updatePhaseTiming(repId, 'airdrying', start)
} else if (eventId.includes('-cracking')) {
const repId = eventId.replace('-cracking', '')
updatePhaseTiming(repId, 'cracking', start)
}
}
}}
popup
showMultiDayTimes
doShowMore={true}
step={30}
timeslots={2}
/>
}}
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'
return {
style: {
backgroundColor: color,
borderColor: color,
color: 'white',
borderRadius: '4px',
border: 'none',
height: '8px',
minHeight: '8px',
fontSize: '10px',
padding: '2px 4px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
fontWeight: 'bold',
zIndex: 10,
position: 'relative',
lineHeight: '1.2',
textShadow: '1px 1px 2px rgba(0,0,0,0.7)'
}
}
}
// Default styling for other events
return {}
}}
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'
}
}
}}
popup
showMultiDayTimes
doShowMore={true}
step={30}
timeslots={2}
/>
</DndProvider>
</div>
</div>
</div>