Enhance scheduling component and improve data handling
- Added conductorsExpanded state to manage the visibility of the conductors list in the scheduling component. - Updated color assignment logic for conductors to ensure consistent coloring based on their position in the array. - Modified spawnSingleRepetition function to accept an updated set of selected IDs for accurate stagger calculations. - Refactored soaking and airdrying data retrieval to map results from a unified experiment_phase_executions table, improving data consistency. - Enhanced UI for conductor selection, including a collapsible list and improved availability indicators.
This commit is contained in:
@@ -300,6 +300,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
const [phases, setPhases] = useState<ExperimentPhase[]>([])
|
const [phases, setPhases] = useState<ExperimentPhase[]>([])
|
||||||
const [expandedPhaseIds, setExpandedPhaseIds] = useState<Set<string>>(new Set())
|
const [expandedPhaseIds, setExpandedPhaseIds] = useState<Set<string>>(new Set())
|
||||||
|
const [conductorsExpanded, setConductorsExpanded] = useState<boolean>(true)
|
||||||
const [experimentsByPhase, setExperimentsByPhase] = useState<Record<string, Experiment[]>>({})
|
const [experimentsByPhase, setExperimentsByPhase] = useState<Record<string, Experiment[]>>({})
|
||||||
const [repetitionsByExperiment, setRepetitionsByExperiment] = useState<Record<string, ExperimentRepetition[]>>({})
|
const [repetitionsByExperiment, setRepetitionsByExperiment] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||||
const [selectedRepetitionIds, setSelectedRepetitionIds] = useState<Set<string>>(new Set())
|
const [selectedRepetitionIds, setSelectedRepetitionIds] = useState<Set<string>>(new Set())
|
||||||
@@ -410,10 +411,13 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign consistent colors per selected conductor
|
// Assign consistent colors per conductor based on their position in the full conductors array
|
||||||
|
// This ensures the same conductor always gets the same color, matching the checkbox list
|
||||||
const newColorMap: Record<string, string> = {}
|
const newColorMap: Record<string, string> = {}
|
||||||
selectedIds.forEach((conductorId, index) => {
|
conductors.forEach((conductor, index) => {
|
||||||
newColorMap[conductorId] = colorPalette[index % colorPalette.length]
|
if (selectedIds.includes(conductor.id)) {
|
||||||
|
newColorMap[conductor.id] = colorPalette[index % colorPalette.length]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
setConductorColorMap(newColorMap)
|
setConductorColorMap(newColorMap)
|
||||||
|
|
||||||
@@ -537,8 +541,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
next.add(repId)
|
next.add(repId)
|
||||||
// Auto-spawn when checked
|
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
|
||||||
spawnSingleRepetition(repId)
|
spawnSingleRepetition(repId, next)
|
||||||
// Re-stagger all existing repetitions to prevent overlap
|
// Re-stagger all existing repetitions to prevent overlap
|
||||||
reStaggerRepetitions([...next, repId])
|
reStaggerRepetitions([...next, repId])
|
||||||
}
|
}
|
||||||
@@ -572,8 +576,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
allRepetitions.forEach(rep => {
|
allRepetitions.forEach(rep => {
|
||||||
next.add(rep.id)
|
next.add(rep.id)
|
||||||
// Auto-spawn when checked
|
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
|
||||||
spawnSingleRepetition(rep.id)
|
spawnSingleRepetition(rep.id, next)
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -653,7 +657,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn a single repetition in calendar
|
// Spawn a single repetition in calendar
|
||||||
const spawnSingleRepetition = (repId: string) => {
|
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
|
||||||
const tomorrow = new Date()
|
const tomorrow = new Date()
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow
|
tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow
|
||||||
@@ -674,9 +678,11 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
if (experiment && soaking && airdrying) {
|
if (experiment && soaking && airdrying) {
|
||||||
// Stagger the positioning to avoid overlap when multiple repetitions are selected
|
// Stagger the positioning to avoid overlap when multiple repetitions are selected
|
||||||
const selectedReps = Array.from(selectedRepetitionIds)
|
// Use the updated set if provided, otherwise use current state (may be stale)
|
||||||
|
const selectedReps = updatedSelectedIds ? Array.from(updatedSelectedIds) : Array.from(selectedRepetitionIds)
|
||||||
const repIndex = selectedReps.indexOf(repId)
|
const repIndex = selectedReps.indexOf(repId)
|
||||||
const staggerMinutes = repIndex * 15 // 15 minutes between each repetition's time points
|
// If repId not found in selectedReps, use the count of scheduled repetitions as fallback
|
||||||
|
const staggerMinutes = repIndex >= 0 ? repIndex * 15 : Object.keys(scheduledRepetitions).length * 15
|
||||||
|
|
||||||
const soakingStart = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
const soakingStart = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
||||||
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
|
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
|
||||||
@@ -1038,51 +1044,71 @@ 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="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div className="mb-3">
|
{conductors.length === 0 ? (
|
||||||
<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>
|
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
|
||||||
)}
|
) : (
|
||||||
{conductors.map((c, index) => {
|
<div>
|
||||||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
<button
|
||||||
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
const checked = selectedConductorIds.has(c.id)
|
onClick={() => setConductorsExpanded(!conductorsExpanded)}
|
||||||
const conductorColor = checked ? colorPalette[index % colorPalette.length] : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={c.id}
|
|
||||||
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''}`}
|
|
||||||
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-sm font-medium text-gray-900 dark:text-white">All Conductors</span>
|
||||||
<input
|
<svg className={`w-4 h-4 text-gray-500 transition-transform ${conductorsExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="checkbox"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
</svg>
|
||||||
checked={checked}
|
</button>
|
||||||
onChange={() => toggleConductor(c.id)}
|
{conductorsExpanded && (
|
||||||
disabled={!hasFuture}
|
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-[420px] overflow-y-auto">
|
||||||
/>
|
{/* Select All checkbox */}
|
||||||
<div>
|
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
|
<label className="flex items-center gap-2">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
|
<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>
|
||||||
|
{/* Conductors list */}
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{conductors.map((c, index) => {
|
||||||
|
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
||||||
|
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
|
||||||
|
const checked = selectedConductorIds.has(c.id)
|
||||||
|
// Use the same color mapping as the calendar (from conductorColorMap)
|
||||||
|
const conductorColor = checked ? (conductorColorMap[c.id] || colorPalette[index % colorPalette.length]) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''} hover:bg-gray-100 dark:hover:bg-gray-700/30`}
|
||||||
|
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleConductor(c.id)}
|
||||||
|
disabled={!hasFuture}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available' : 'No availability'}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available in future' : 'No future availability'}</span>
|
)}
|
||||||
</label>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1255,7 +1281,39 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="flex justify-between items-center mb-3 flex-shrink-0">
|
<div className="flex justify-between items-center mb-3 flex-shrink-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Day-by-day navigation buttons (only show in week view) */}
|
||||||
|
{calendarView === Views.WEEK && (
|
||||||
|
<div className="flex items-center gap-1 mr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(currentDate)
|
||||||
|
newDate.setDate(newDate.getDate() - 1)
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
|
||||||
|
title="Previous day"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(currentDate)
|
||||||
|
newDate.setDate(newDate.getDate() + 1)
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
|
||||||
|
title="Next day"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">Day</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
|
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMarkerStyle('lines')}
|
onClick={() => setMarkerStyle('lines')}
|
||||||
|
|||||||
@@ -842,18 +842,36 @@ export const phaseManagement = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get soaking for the first repetition
|
// Get soaking from unified experiment_phase_executions table
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('soaking')
|
.from('experiment_phase_executions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('repetition_id', repetitions[0].id)
|
.eq('repetition_id', repetitions[0].id)
|
||||||
|
.eq('phase_type', 'soaking')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === 'PGRST116') return null // Not found
|
if (error.code === 'PGRST116') return null // Not found
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
// Map unified table data to Soaking interface format
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
repetition_id: data.repetition_id,
|
||||||
|
scheduled_start_time: data.scheduled_start_time,
|
||||||
|
actual_start_time: data.actual_start_time || null,
|
||||||
|
soaking_duration_minutes: data.soaking_duration_minutes || 0,
|
||||||
|
scheduled_end_time: data.scheduled_end_time || '',
|
||||||
|
actual_end_time: data.actual_end_time || null,
|
||||||
|
created_at: data.created_at,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
created_by: data.created_by
|
||||||
|
} as Soaking
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSoakingByRepetitionId(repetitionId: string): Promise<Soaking | null> {
|
async getSoakingByRepetitionId(repetitionId: string): Promise<Soaking | null> {
|
||||||
@@ -928,18 +946,36 @@ export const phaseManagement = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get airdrying for the first repetition
|
// Get airdrying from unified experiment_phase_executions table
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('airdrying')
|
.from('experiment_phase_executions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('repetition_id', repetitions[0].id)
|
.eq('repetition_id', repetitions[0].id)
|
||||||
|
.eq('phase_type', 'airdrying')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === 'PGRST116') return null // Not found
|
if (error.code === 'PGRST116') return null // Not found
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
// Map unified table data to Airdrying interface format
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
repetition_id: data.repetition_id,
|
||||||
|
scheduled_start_time: data.scheduled_start_time,
|
||||||
|
actual_start_time: data.actual_start_time || null,
|
||||||
|
duration_minutes: data.duration_minutes || 0,
|
||||||
|
scheduled_end_time: data.scheduled_end_time || '',
|
||||||
|
actual_end_time: data.actual_end_time || null,
|
||||||
|
created_at: data.created_at,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
created_by: data.created_by
|
||||||
|
} as Airdrying
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAirdryingByRepetitionId(repetitionId: string): Promise<Airdrying | null> {
|
async getAirdryingByRepetitionId(repetitionId: string): Promise<Airdrying | null> {
|
||||||
|
|||||||
@@ -33,3 +33,6 @@ ALTER TABLE public.meyer_cracker_parameters
|
|||||||
ADD CONSTRAINT unique_meyer_cracker_parameters_per_repetition
|
ADD CONSTRAINT unique_meyer_cracker_parameters_per_repetition
|
||||||
UNIQUE (repetition_id);
|
UNIQUE (repetition_id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
-- View: Experiments with All Repetitions and Phase Parameters
|
||||||
|
-- This view provides a comprehensive view of experiments with all their repetitions
|
||||||
|
-- and all phase execution parameters (soaking, airdrying, cracking, shelling)
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW public.experiments_with_all_reps_and_phases AS
|
||||||
|
SELECT
|
||||||
|
-- Experiment fields
|
||||||
|
e.id as experiment_id,
|
||||||
|
e.experiment_number,
|
||||||
|
e.reps_required,
|
||||||
|
e.weight_per_repetition_lbs,
|
||||||
|
e.results_status,
|
||||||
|
e.completion_status,
|
||||||
|
e.phase_id,
|
||||||
|
e.created_at as experiment_created_at,
|
||||||
|
e.updated_at as experiment_updated_at,
|
||||||
|
e.created_by as experiment_created_by,
|
||||||
|
|
||||||
|
-- Phase information
|
||||||
|
ep.name as phase_name,
|
||||||
|
ep.description as phase_description,
|
||||||
|
ep.has_soaking,
|
||||||
|
ep.has_airdrying,
|
||||||
|
ep.has_cracking,
|
||||||
|
ep.has_shelling,
|
||||||
|
ep.cracking_machine_type_id,
|
||||||
|
|
||||||
|
-- Repetition fields
|
||||||
|
er.id as repetition_id,
|
||||||
|
er.repetition_number,
|
||||||
|
er.status as repetition_status,
|
||||||
|
er.scheduled_date,
|
||||||
|
er.created_at as repetition_created_at,
|
||||||
|
er.updated_at as repetition_updated_at,
|
||||||
|
er.created_by as repetition_created_by,
|
||||||
|
|
||||||
|
-- Soaking phase execution
|
||||||
|
soaking_e.id as soaking_execution_id,
|
||||||
|
soaking_e.scheduled_start_time as soaking_scheduled_start,
|
||||||
|
soaking_e.actual_start_time as soaking_actual_start,
|
||||||
|
soaking_e.soaking_duration_minutes,
|
||||||
|
soaking_e.scheduled_end_time as soaking_scheduled_end,
|
||||||
|
soaking_e.actual_end_time as soaking_actual_end,
|
||||||
|
soaking_e.status as soaking_status,
|
||||||
|
|
||||||
|
-- Airdrying phase execution
|
||||||
|
airdrying_e.id as airdrying_execution_id,
|
||||||
|
airdrying_e.scheduled_start_time as airdrying_scheduled_start,
|
||||||
|
airdrying_e.actual_start_time as airdrying_actual_start,
|
||||||
|
airdrying_e.duration_minutes as airdrying_duration_minutes,
|
||||||
|
airdrying_e.scheduled_end_time as airdrying_scheduled_end,
|
||||||
|
airdrying_e.actual_end_time as airdrying_actual_end,
|
||||||
|
airdrying_e.status as airdrying_status,
|
||||||
|
|
||||||
|
-- Cracking phase execution
|
||||||
|
cracking_e.id as cracking_execution_id,
|
||||||
|
cracking_e.scheduled_start_time as cracking_scheduled_start,
|
||||||
|
cracking_e.actual_start_time as cracking_actual_start,
|
||||||
|
cracking_e.scheduled_end_time as cracking_scheduled_end,
|
||||||
|
cracking_e.actual_end_time as cracking_actual_end,
|
||||||
|
cracking_e.machine_type_id as cracking_machine_type_id,
|
||||||
|
cracking_e.status as cracking_status,
|
||||||
|
mt.name as machine_type_name,
|
||||||
|
|
||||||
|
-- Shelling phase execution
|
||||||
|
shelling_e.id as shelling_execution_id,
|
||||||
|
shelling_e.scheduled_start_time as shelling_scheduled_start,
|
||||||
|
shelling_e.actual_start_time as shelling_actual_start,
|
||||||
|
shelling_e.scheduled_end_time as shelling_scheduled_end,
|
||||||
|
shelling_e.actual_end_time as shelling_actual_end,
|
||||||
|
shelling_e.status as shelling_status
|
||||||
|
|
||||||
|
FROM public.experiments e
|
||||||
|
LEFT JOIN public.experiment_phases ep ON e.phase_id = ep.id
|
||||||
|
LEFT JOIN public.experiment_repetitions er ON er.experiment_id = e.id
|
||||||
|
LEFT JOIN public.experiment_phase_executions soaking_e
|
||||||
|
ON soaking_e.repetition_id = er.id AND soaking_e.phase_type = 'soaking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions airdrying_e
|
||||||
|
ON airdrying_e.repetition_id = er.id AND airdrying_e.phase_type = 'airdrying'
|
||||||
|
LEFT JOIN public.experiment_phase_executions cracking_e
|
||||||
|
ON cracking_e.repetition_id = er.id AND cracking_e.phase_type = 'cracking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions shelling_e
|
||||||
|
ON shelling_e.repetition_id = er.id AND shelling_e.phase_type = 'shelling'
|
||||||
|
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id
|
||||||
|
ORDER BY e.experiment_number, er.repetition_number;
|
||||||
|
|
||||||
|
-- Grant permissions
|
||||||
|
GRANT SELECT ON public.experiments_with_all_reps_and_phases TO authenticated;
|
||||||
|
|
||||||
|
-- Function: Get experiment with all repetitions and phase parameters
|
||||||
|
-- This function returns a JSON structure with experiment and all its repetitions
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_experiment_with_reps_and_phases(p_experiment_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
experiment_id UUID,
|
||||||
|
experiment_number INTEGER,
|
||||||
|
phase_name TEXT,
|
||||||
|
repetitions JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.experiment_number,
|
||||||
|
ep.name,
|
||||||
|
COALESCE(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'repetition_id', er.id,
|
||||||
|
'repetition_number', er.repetition_number,
|
||||||
|
'status', er.status,
|
||||||
|
'scheduled_date', er.scheduled_date,
|
||||||
|
'soaking', jsonb_build_object(
|
||||||
|
'scheduled_start', soaking_e.scheduled_start_time,
|
||||||
|
'actual_start', soaking_e.actual_start_time,
|
||||||
|
'duration_minutes', soaking_e.soaking_duration_minutes,
|
||||||
|
'scheduled_end', soaking_e.scheduled_end_time,
|
||||||
|
'actual_end', soaking_e.actual_end_time,
|
||||||
|
'status', soaking_e.status
|
||||||
|
),
|
||||||
|
'airdrying', jsonb_build_object(
|
||||||
|
'scheduled_start', airdrying_e.scheduled_start_time,
|
||||||
|
'actual_start', airdrying_e.actual_start_time,
|
||||||
|
'duration_minutes', airdrying_e.duration_minutes,
|
||||||
|
'scheduled_end', airdrying_e.scheduled_end_time,
|
||||||
|
'actual_end', airdrying_e.actual_end_time,
|
||||||
|
'status', airdrying_e.status
|
||||||
|
),
|
||||||
|
'cracking', jsonb_build_object(
|
||||||
|
'scheduled_start', cracking_e.scheduled_start_time,
|
||||||
|
'actual_start', cracking_e.actual_start_time,
|
||||||
|
'scheduled_end', cracking_e.scheduled_end_time,
|
||||||
|
'actual_end', cracking_e.actual_end_time,
|
||||||
|
'machine_type_id', cracking_e.machine_type_id,
|
||||||
|
'machine_type_name', mt.name,
|
||||||
|
'status', cracking_e.status
|
||||||
|
),
|
||||||
|
'shelling', jsonb_build_object(
|
||||||
|
'scheduled_start', shelling_e.scheduled_start_time,
|
||||||
|
'actual_start', shelling_e.actual_start_time,
|
||||||
|
'scheduled_end', shelling_e.scheduled_end_time,
|
||||||
|
'actual_end', shelling_e.actual_end_time,
|
||||||
|
'status', shelling_e.status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY er.repetition_number
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
) as repetitions
|
||||||
|
FROM public.experiments e
|
||||||
|
LEFT JOIN public.experiment_phases ep ON e.phase_id = ep.id
|
||||||
|
LEFT JOIN public.experiment_repetitions er ON er.experiment_id = e.id
|
||||||
|
LEFT JOIN public.experiment_phase_executions soaking_e
|
||||||
|
ON soaking_e.repetition_id = er.id AND soaking_e.phase_type = 'soaking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions airdrying_e
|
||||||
|
ON airdrying_e.repetition_id = er.id AND airdrying_e.phase_type = 'airdrying'
|
||||||
|
LEFT JOIN public.experiment_phase_executions cracking_e
|
||||||
|
ON cracking_e.repetition_id = er.id AND cracking_e.phase_type = 'cracking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions shelling_e
|
||||||
|
ON shelling_e.repetition_id = er.id AND shelling_e.phase_type = 'shelling'
|
||||||
|
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id
|
||||||
|
WHERE e.id = p_experiment_id
|
||||||
|
GROUP BY e.id, e.experiment_number, ep.name;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Grant execute permission
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_experiment_with_reps_and_phases(UUID) TO authenticated;
|
||||||
|
|
||||||
|
-- Update the existing experiments_with_phases view to use unified table
|
||||||
|
CREATE OR REPLACE VIEW public.experiments_with_phases AS
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.experiment_number,
|
||||||
|
e.reps_required,
|
||||||
|
e.weight_per_repetition_lbs,
|
||||||
|
e.results_status,
|
||||||
|
e.completion_status,
|
||||||
|
e.phase_id,
|
||||||
|
e.created_at,
|
||||||
|
e.updated_at,
|
||||||
|
e.created_by,
|
||||||
|
ep.name as phase_name,
|
||||||
|
ep.description as phase_description,
|
||||||
|
ep.has_soaking,
|
||||||
|
ep.has_airdrying,
|
||||||
|
ep.has_cracking,
|
||||||
|
ep.has_shelling,
|
||||||
|
er.id as first_repetition_id,
|
||||||
|
er.repetition_number as first_repetition_number,
|
||||||
|
soaking_e.id as soaking_id,
|
||||||
|
soaking_e.scheduled_start_time as soaking_scheduled_start,
|
||||||
|
soaking_e.actual_start_time as soaking_actual_start,
|
||||||
|
soaking_e.soaking_duration_minutes,
|
||||||
|
soaking_e.scheduled_end_time as soaking_scheduled_end,
|
||||||
|
soaking_e.actual_end_time as soaking_actual_end,
|
||||||
|
airdrying_e.id as airdrying_id,
|
||||||
|
airdrying_e.scheduled_start_time as airdrying_scheduled_start,
|
||||||
|
airdrying_e.actual_start_time as airdrying_actual_start,
|
||||||
|
airdrying_e.duration_minutes as airdrying_duration,
|
||||||
|
airdrying_e.scheduled_end_time as airdrying_scheduled_end,
|
||||||
|
airdrying_e.actual_end_time as airdrying_actual_end,
|
||||||
|
cracking_e.id as cracking_id,
|
||||||
|
cracking_e.scheduled_start_time as cracking_scheduled_start,
|
||||||
|
cracking_e.actual_start_time as cracking_actual_start,
|
||||||
|
cracking_e.actual_end_time as cracking_actual_end,
|
||||||
|
mt.name as machine_type_name,
|
||||||
|
shelling_e.id as shelling_id,
|
||||||
|
shelling_e.scheduled_start_time as shelling_scheduled_start,
|
||||||
|
shelling_e.actual_start_time as shelling_actual_start,
|
||||||
|
shelling_e.actual_end_time as shelling_actual_end
|
||||||
|
FROM public.experiments e
|
||||||
|
LEFT JOIN public.experiment_phases ep ON e.phase_id = ep.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, repetition_number
|
||||||
|
FROM public.experiment_repetitions
|
||||||
|
WHERE experiment_id = e.id
|
||||||
|
ORDER BY repetition_number
|
||||||
|
LIMIT 1
|
||||||
|
) er ON true
|
||||||
|
LEFT JOIN public.experiment_phase_executions soaking_e
|
||||||
|
ON soaking_e.repetition_id = er.id AND soaking_e.phase_type = 'soaking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions airdrying_e
|
||||||
|
ON airdrying_e.repetition_id = er.id AND airdrying_e.phase_type = 'airdrying'
|
||||||
|
LEFT JOIN public.experiment_phase_executions cracking_e
|
||||||
|
ON cracking_e.repetition_id = er.id AND cracking_e.phase_type = 'cracking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions shelling_e
|
||||||
|
ON shelling_e.repetition_id = er.id AND shelling_e.phase_type = 'shelling'
|
||||||
|
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id;
|
||||||
|
|
||||||
|
-- Update repetitions_with_phases view to use unified table
|
||||||
|
CREATE OR REPLACE VIEW public.repetitions_with_phases AS
|
||||||
|
SELECT
|
||||||
|
er.id,
|
||||||
|
er.experiment_id,
|
||||||
|
er.repetition_number,
|
||||||
|
er.status,
|
||||||
|
er.created_at,
|
||||||
|
er.updated_at,
|
||||||
|
er.created_by,
|
||||||
|
e.experiment_number,
|
||||||
|
e.phase_id,
|
||||||
|
e.weight_per_repetition_lbs,
|
||||||
|
ep.name as phase_name,
|
||||||
|
ep.has_soaking,
|
||||||
|
ep.has_airdrying,
|
||||||
|
ep.has_cracking,
|
||||||
|
ep.has_shelling,
|
||||||
|
soaking_e.scheduled_start_time as soaking_scheduled_start,
|
||||||
|
soaking_e.actual_start_time as soaking_actual_start,
|
||||||
|
soaking_e.soaking_duration_minutes,
|
||||||
|
soaking_e.scheduled_end_time as soaking_scheduled_end,
|
||||||
|
soaking_e.actual_end_time as soaking_actual_end,
|
||||||
|
airdrying_e.scheduled_start_time as airdrying_scheduled_start,
|
||||||
|
airdrying_e.actual_start_time as airdrying_actual_start,
|
||||||
|
airdrying_e.duration_minutes as airdrying_duration,
|
||||||
|
airdrying_e.scheduled_end_time as airdrying_scheduled_end,
|
||||||
|
airdrying_e.actual_end_time as airdrying_actual_end,
|
||||||
|
cracking_e.scheduled_start_time as cracking_scheduled_start,
|
||||||
|
cracking_e.actual_start_time as cracking_actual_start,
|
||||||
|
cracking_e.actual_end_time as cracking_actual_end,
|
||||||
|
mt.name as machine_type_name,
|
||||||
|
shelling_e.scheduled_start_time as shelling_scheduled_start,
|
||||||
|
shelling_e.actual_start_time as shelling_actual_start,
|
||||||
|
shelling_e.actual_end_time as shelling_actual_end
|
||||||
|
FROM public.experiment_repetitions er
|
||||||
|
JOIN public.experiments e ON er.experiment_id = e.id
|
||||||
|
LEFT JOIN public.experiment_phases ep ON e.phase_id = ep.id
|
||||||
|
LEFT JOIN public.experiment_phase_executions soaking_e
|
||||||
|
ON er.id = soaking_e.repetition_id AND soaking_e.phase_type = 'soaking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions airdrying_e
|
||||||
|
ON er.id = airdrying_e.repetition_id AND airdrying_e.phase_type = 'airdrying'
|
||||||
|
LEFT JOIN public.experiment_phase_executions cracking_e
|
||||||
|
ON er.id = cracking_e.repetition_id AND cracking_e.phase_type = 'cracking'
|
||||||
|
LEFT JOIN public.experiment_phase_executions shelling_e
|
||||||
|
ON er.id = shelling_e.repetition_id AND shelling_e.phase_type = 'shelling'
|
||||||
|
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id;
|
||||||
|
|
||||||
@@ -300,6 +300,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
const [phases, setPhases] = useState<ExperimentPhase[]>([])
|
const [phases, setPhases] = useState<ExperimentPhase[]>([])
|
||||||
const [expandedPhaseIds, setExpandedPhaseIds] = useState<Set<string>>(new Set())
|
const [expandedPhaseIds, setExpandedPhaseIds] = useState<Set<string>>(new Set())
|
||||||
|
const [conductorsExpanded, setConductorsExpanded] = useState<boolean>(true)
|
||||||
const [experimentsByPhase, setExperimentsByPhase] = useState<Record<string, Experiment[]>>({})
|
const [experimentsByPhase, setExperimentsByPhase] = useState<Record<string, Experiment[]>>({})
|
||||||
const [repetitionsByExperiment, setRepetitionsByExperiment] = useState<Record<string, ExperimentRepetition[]>>({})
|
const [repetitionsByExperiment, setRepetitionsByExperiment] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||||
const [selectedRepetitionIds, setSelectedRepetitionIds] = useState<Set<string>>(new Set())
|
const [selectedRepetitionIds, setSelectedRepetitionIds] = useState<Set<string>>(new Set())
|
||||||
@@ -406,10 +407,13 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign consistent colors per selected conductor
|
// Assign consistent colors per conductor based on their position in the full conductors array
|
||||||
|
// This ensures the same conductor always gets the same color, matching the checkbox list
|
||||||
const newColorMap: Record<string, string> = {}
|
const newColorMap: Record<string, string> = {}
|
||||||
selectedIds.forEach((conductorId, index) => {
|
conductors.forEach((conductor, index) => {
|
||||||
newColorMap[conductorId] = colorPalette[index % colorPalette.length]
|
if (selectedIds.includes(conductor.id)) {
|
||||||
|
newColorMap[conductor.id] = colorPalette[index % colorPalette.length]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
setConductorColorMap(newColorMap)
|
setConductorColorMap(newColorMap)
|
||||||
|
|
||||||
@@ -533,8 +537,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
next.add(repId)
|
next.add(repId)
|
||||||
// Auto-spawn when checked
|
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
|
||||||
spawnSingleRepetition(repId)
|
spawnSingleRepetition(repId, next)
|
||||||
// Re-stagger all existing repetitions to prevent overlap
|
// Re-stagger all existing repetitions to prevent overlap
|
||||||
reStaggerRepetitions([...next, repId])
|
reStaggerRepetitions([...next, repId])
|
||||||
}
|
}
|
||||||
@@ -568,8 +572,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
allRepetitions.forEach(rep => {
|
allRepetitions.forEach(rep => {
|
||||||
next.add(rep.id)
|
next.add(rep.id)
|
||||||
// Auto-spawn when checked
|
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
|
||||||
spawnSingleRepetition(rep.id)
|
spawnSingleRepetition(rep.id, next)
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -649,7 +653,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn a single repetition in calendar
|
// Spawn a single repetition in calendar
|
||||||
const spawnSingleRepetition = (repId: string) => {
|
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
|
||||||
const tomorrow = new Date()
|
const tomorrow = new Date()
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow
|
tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow
|
||||||
@@ -670,9 +674,11 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
if (experiment && soaking && airdrying) {
|
if (experiment && soaking && airdrying) {
|
||||||
// Stagger the positioning to avoid overlap when multiple repetitions are selected
|
// Stagger the positioning to avoid overlap when multiple repetitions are selected
|
||||||
const selectedReps = Array.from(selectedRepetitionIds)
|
// Use the updated set if provided, otherwise use current state (may be stale)
|
||||||
|
const selectedReps = updatedSelectedIds ? Array.from(updatedSelectedIds) : Array.from(selectedRepetitionIds)
|
||||||
const repIndex = selectedReps.indexOf(repId)
|
const repIndex = selectedReps.indexOf(repId)
|
||||||
const staggerMinutes = repIndex * 15 // 15 minutes between each repetition's time points
|
// If repId not found in selectedReps, use the count of scheduled repetitions as fallback
|
||||||
|
const staggerMinutes = repIndex >= 0 ? repIndex * 15 : Object.keys(scheduledRepetitions).length * 15
|
||||||
|
|
||||||
const soakingStart = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
const soakingStart = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
|
||||||
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
|
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
|
||||||
@@ -1034,51 +1040,71 @@ 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="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div className="mb-3">
|
{conductors.length === 0 ? (
|
||||||
<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>
|
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
|
||||||
)}
|
) : (
|
||||||
{conductors.map((c, index) => {
|
<div>
|
||||||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
<button
|
||||||
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
const checked = selectedConductorIds.has(c.id)
|
onClick={() => setConductorsExpanded(!conductorsExpanded)}
|
||||||
const conductorColor = checked ? colorPalette[index % colorPalette.length] : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={c.id}
|
|
||||||
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''}`}
|
|
||||||
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-sm font-medium text-gray-900 dark:text-white">All Conductors</span>
|
||||||
<input
|
<svg className={`w-4 h-4 text-gray-500 transition-transform ${conductorsExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="checkbox"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
</svg>
|
||||||
checked={checked}
|
</button>
|
||||||
onChange={() => toggleConductor(c.id)}
|
{conductorsExpanded && (
|
||||||
disabled={!hasFuture}
|
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-[420px] overflow-y-auto">
|
||||||
/>
|
{/* Select All checkbox */}
|
||||||
<div>
|
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
|
<label className="flex items-center gap-2">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
|
<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>
|
||||||
|
{/* Conductors list */}
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{conductors.map((c, index) => {
|
||||||
|
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
||||||
|
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
|
||||||
|
const checked = selectedConductorIds.has(c.id)
|
||||||
|
// Use the same color mapping as the calendar (from conductorColorMap)
|
||||||
|
const conductorColor = checked ? (conductorColorMap[c.id] || colorPalette[index % colorPalette.length]) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''} hover:bg-gray-100 dark:hover:bg-gray-700/30`}
|
||||||
|
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleConductor(c.id)}
|
||||||
|
disabled={!hasFuture}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available' : 'No availability'}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available in future' : 'No future availability'}</span>
|
)}
|
||||||
</label>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1251,7 +1277,39 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="flex justify-between items-center mb-3 flex-shrink-0">
|
<div className="flex justify-between items-center mb-3 flex-shrink-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Day-by-day navigation buttons (only show in week view) */}
|
||||||
|
{calendarView === Views.WEEK && (
|
||||||
|
<div className="flex items-center gap-1 mr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(currentDate)
|
||||||
|
newDate.setDate(newDate.getDate() - 1)
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
|
||||||
|
title="Previous day"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(currentDate)
|
||||||
|
newDate.setDate(newDate.getDate() + 1)
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
|
||||||
|
title="Next day"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">Day</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
|
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMarkerStyle('lines')}
|
onClick={() => setMarkerStyle('lines')}
|
||||||
|
|||||||
@@ -370,18 +370,36 @@ export const phaseManagement = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get soaking for the first repetition
|
// Get soaking from unified experiment_phase_executions table
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('soaking')
|
.from('experiment_phase_executions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('repetition_id', repetitions[0].id)
|
.eq('repetition_id', repetitions[0].id)
|
||||||
|
.eq('phase_type', 'soaking')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === 'PGRST116') return null
|
if (error.code === 'PGRST116') return null // Not found
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
// Map unified table data to Soaking interface format
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
repetition_id: data.repetition_id,
|
||||||
|
scheduled_start_time: data.scheduled_start_time,
|
||||||
|
actual_start_time: data.actual_start_time || null,
|
||||||
|
soaking_duration_minutes: data.soaking_duration_minutes || 0,
|
||||||
|
scheduled_end_time: data.scheduled_end_time || '',
|
||||||
|
actual_end_time: data.actual_end_time || null,
|
||||||
|
created_at: data.created_at,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
created_by: data.created_by
|
||||||
|
} as Soaking
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
|
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
|
||||||
@@ -397,18 +415,36 @@ export const phaseManagement = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get airdrying for the first repetition
|
// Get airdrying from unified experiment_phase_executions table
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('airdrying')
|
.from('experiment_phase_executions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('repetition_id', repetitions[0].id)
|
.eq('repetition_id', repetitions[0].id)
|
||||||
|
.eq('phase_type', 'airdrying')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code === 'PGRST116') return null
|
if (error.code === 'PGRST116') return null // Not found
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
// Map unified table data to Airdrying interface format
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
repetition_id: data.repetition_id,
|
||||||
|
scheduled_start_time: data.scheduled_start_time,
|
||||||
|
actual_start_time: data.actual_start_time || null,
|
||||||
|
duration_minutes: data.duration_minutes || 0,
|
||||||
|
scheduled_end_time: data.scheduled_end_time || '',
|
||||||
|
actual_end_time: data.actual_end_time || null,
|
||||||
|
created_at: data.created_at,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
created_by: data.created_by
|
||||||
|
} as Airdrying
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user