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",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"tailwindcss": "^4.1.11"
|
||||
@@ -1060,6 +1062,24 @@
|
||||
"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": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
@@ -2440,6 +2460,17 @@
|
||||
"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": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -2716,7 +2747,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -2915,6 +2945,15 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3770,6 +3809,45 @@
|
||||
"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": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
@@ -3856,6 +3934,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"moment": "^2.30.1",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"tailwindcss": "^4.1.11"
|
||||
@@ -34,4 +36,4 @@
|
||||
"typescript-eslint": "^8.28.1",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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