diff --git a/management-dashboard-web-app/package-lock.json b/management-dashboard-web-app/package-lock.json index 3a6bec5..dd434fa 100644 --- a/management-dashboard-web-app/package-lock.json +++ b/management-dashboard-web-app/package-lock.json @@ -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", diff --git a/management-dashboard-web-app/package.json b/management-dashboard-web-app/package.json index 854fa3d..a134400 100755 --- a/management-dashboard-web-app/package.json +++ b/management-dashboard-web-app/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/management-dashboard-web-app/src/components/Scheduling.tsx b/management-dashboard-web-app/src/components/Scheduling.tsx index e8f835d..282bee7 100644 --- a/management-dashboard-web-app/src/components/Scheduling.tsx +++ b/management-dashboard-web-app/src/components/Scheduling.tsx @@ -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([]) @@ -313,6 +318,18 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void } const [conductorColorMap, setConductorColorMap] = useState>({}) const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444'] + // Repetition scheduling state + const [scheduledRepetitions, setScheduledRepetitions] = useState>({}) + + // 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 (
@@ -534,6 +746,18 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }

Conductors

Select to consider for scheduling
+ {/* Select All checkbox for conductors */} +
+ +
{conductors.length === 0 && (
No conductors found.
@@ -596,6 +820,20 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void } {expanded && (
+ {/* Select All checkbox for this phase */} + {experiments.length > 0 && ( +
+ +
+ )} {experiments.length === 0 && (
No experiments in this phase.
)} @@ -677,47 +915,152 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void } )} {/* Week Calendar for selected conductors' availability */}
-

Selected Conductors' Availability

-
- { - 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' +
+

Selected Conductors' Availability & Experiment Scheduling

+
+ Marker Style: + + + + +
+
+
+ + { + // 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} + /> +