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:
89
management-dashboard-web-app/package-lock.json
generated
89
management-dashboard-web-app/package-lock.json
generated
@@ -14,6 +14,8 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-big-calendar": "^1.19.4",
|
"react-big-calendar": "^1.19.4",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"tailwindcss": "^4.1.11"
|
"tailwindcss": "^4.1.11"
|
||||||
@@ -1060,6 +1062,24 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-dnd/asap": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/invariant": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/shallowequal": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||||
@@ -2440,6 +2460,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dnd-core": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/asap": "^5.0.1",
|
||||||
|
"@react-dnd/invariant": "^4.0.1",
|
||||||
|
"redux": "^4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -2716,7 +2747,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
@@ -2915,6 +2945,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3770,6 +3809,45 @@
|
|||||||
"react-dom": "^16.14.0 || ^17 || ^18 || ^19"
|
"react-dom": "^16.14.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dnd": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/invariant": "^4.0.1",
|
||||||
|
"@react-dnd/shallowequal": "^4.0.1",
|
||||||
|
"dnd-core": "^16.0.1",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||||
|
"@types/node": ">= 12",
|
||||||
|
"@types/react": ">= 16",
|
||||||
|
"react": ">= 16.14"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/hoist-non-react-statics": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dnd-html5-backend": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dnd-core": "^16.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||||
@@ -3856,6 +3934,15 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-big-calendar": "^1.19.4",
|
"react-big-calendar": "^1.19.4",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"tailwindcss": "^4.1.11"
|
"tailwindcss": "^4.1.11"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
// @ts-ignore - react-big-calendar types not available
|
// @ts-ignore - react-big-calendar types not available
|
||||||
import { Calendar, momentLocalizer, Views } from 'react-big-calendar'
|
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 moment from 'moment'
|
||||||
import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../lib/supabase'
|
import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../lib/supabase'
|
||||||
import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } 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
|
// Calendar state for selected conductors' availability
|
||||||
const localizer = momentLocalizer(moment)
|
const localizer = momentLocalizer(moment)
|
||||||
|
const DnDCalendar = withDragAndDrop(Calendar)
|
||||||
const [calendarView, setCalendarView] = useState(Views.WEEK)
|
const [calendarView, setCalendarView] = useState(Views.WEEK)
|
||||||
const [currentDate, setCurrentDate] = useState(new Date())
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
const [availabilityEvents, setAvailabilityEvents] = useState<CalendarEvent[]>([])
|
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 [conductorColorMap, setConductorColorMap] = useState<Record<string, string>>({})
|
||||||
const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444']
|
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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -329,7 +346,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
setConductors(conductorsOnly)
|
setConductors(conductorsOnly)
|
||||||
|
|
||||||
// For each conductor, check if they have any availability in the future
|
// For each conductor, check if they have any availability in the future
|
||||||
const nowIso = new Date().toISOString()
|
|
||||||
// Query availability table directly for efficiency
|
// Query availability table directly for efficiency
|
||||||
const conductorIds = conductorsOnly.map(c => c.id)
|
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.
|
// 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
|
// Fetch availability for selected conductors and build calendar events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSelectedAvailability = async () => {
|
const loadSelectedAvailability = async () => {
|
||||||
@@ -396,7 +429,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
title: `${idToName[r.user_id] || 'Conductor'}`,
|
title: `${idToName[r.user_id] || 'Conductor'}`,
|
||||||
start: new Date(r.available_from),
|
start: new Date(r.available_from),
|
||||||
end: new Date(r.available_to),
|
end: new Date(r.available_to),
|
||||||
resource: newColorMap[r.user_id] || '#2563eb'
|
resource: r.user_id // Store conductor ID, not color
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setAvailabilityEvents(events)
|
setAvailabilityEvents(events)
|
||||||
@@ -462,12 +495,57 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
const toggleRepetition = (repId: string) => {
|
const toggleRepetition = (repId: string) => {
|
||||||
setSelectedRepetitionIds(prev => {
|
setSelectedRepetitionIds(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(repId)) next.delete(repId)
|
if (next.has(repId)) {
|
||||||
else next.add(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
|
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) => {
|
const createRepetitionsForExperiment = async (experimentId: string) => {
|
||||||
try {
|
try {
|
||||||
setCreatingRepetitionsFor(prev => new Set(prev).add(experimentId))
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-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>
|
<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>
|
<span className="text-xs text-gray-500 dark:text-gray-400">Select to consider for scheduling</span>
|
||||||
</div>
|
</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">
|
<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 && (
|
{conductors.length === 0 && (
|
||||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
|
<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>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-96 overflow-y-auto">
|
<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 && (
|
{experiments.length === 0 && (
|
||||||
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
|
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
|
||||||
)}
|
)}
|
||||||
@@ -677,11 +915,54 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
)}
|
)}
|
||||||
{/* Week Calendar for selected conductors' availability */}
|
{/* Week Calendar for selected conductors' availability */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Selected Conductors' Availability</h3>
|
<div className="flex justify-between items-center mb-3">
|
||||||
<div className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||||
<Calendar
|
<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}
|
localizer={localizer}
|
||||||
events={availabilityEvents}
|
events={generateRepetitionEvents()}
|
||||||
|
backgroundEvents={availabilityEvents}
|
||||||
startAccessor="start"
|
startAccessor="start"
|
||||||
endAccessor="end"
|
endAccessor="end"
|
||||||
titleAccessor="title"
|
titleAccessor="title"
|
||||||
@@ -692,20 +973,81 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
onNavigate={setCurrentDate}
|
onNavigate={setCurrentDate}
|
||||||
views={[Views.WEEK, Views.DAY]}
|
views={[Views.WEEK, Views.DAY]}
|
||||||
dayLayoutAlgorithm="no-overlap"
|
dayLayoutAlgorithm="no-overlap"
|
||||||
eventPropGetter={(event) => {
|
draggableAccessor={(event: any) => {
|
||||||
const color = event.resource || '#2563eb'
|
// 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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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 {
|
return {
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: color + '80', // ~50% transparency
|
backgroundColor: color,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
opacity: 0.8,
|
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',
|
height: 'auto',
|
||||||
minHeight: '20px',
|
minHeight: '20px',
|
||||||
fontSize: '12px',
|
fontSize: '0px',
|
||||||
padding: '2px 4px',
|
padding: '0px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
@@ -718,6 +1060,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
step={30}
|
step={30}
|
||||||
timeslots={2}
|
timeslots={2}
|
||||||
/>
|
/>
|
||||||
|
</DndProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user