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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user