Refactor experiment management and update data structures
- Renamed columns in the experimental run sheet CSV for clarity. - Updated the ExperimentForm component to include new fields for weight per repetition and additional parameters specific to Meyer Cracker experiments. - Enhanced the data entry logic to handle new experiment phases and machine types. - Refactored repetition scheduling logic to use scheduled_date instead of schedule_status for better clarity in status representation. - Improved the user interface for displaying experiment phases and their associated statuses. - Removed outdated seed data and updated database migration scripts to reflect the new schema changes.
This commit is contained in:
@@ -300,11 +300,11 @@ function RepetitionCard({ experiment, repetition, onSelect, status }: Repetition
|
||||
</span>
|
||||
<span className="text-lg">{getStatusIcon()}</span>
|
||||
</div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.schedule_status === 'scheduled'
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.scheduled_date
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
{repetition.scheduled_date ? 'scheduled' : 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus, ExperimentPhase, MachineType } from '../lib/supabase'
|
||||
import { experimentPhaseManagement, machineTypeManagement } from '../lib/supabase'
|
||||
|
||||
interface ExperimentFormProps {
|
||||
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
|
||||
@@ -14,12 +15,17 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
|
||||
experiment_number: initialData?.experiment_number || 0,
|
||||
reps_required: initialData?.reps_required || 1,
|
||||
weight_per_repetition_lbs: (initialData as any)?.weight_per_repetition_lbs || 1,
|
||||
soaking_duration_hr: initialData?.soaking_duration_hr || 0,
|
||||
air_drying_time_min: initialData?.air_drying_time_min || 0,
|
||||
plate_contact_frequency_hz: initialData?.plate_contact_frequency_hz || 1,
|
||||
throughput_rate_pecans_sec: initialData?.throughput_rate_pecans_sec || 1,
|
||||
crush_amount_in: initialData?.crush_amount_in || 0,
|
||||
entry_exit_height_diff_in: initialData?.entry_exit_height_diff_in || 0,
|
||||
// Meyer-specific (UI only)
|
||||
motor_speed_hz: (initialData as any)?.motor_speed_hz || 1,
|
||||
jig_displacement_inches: (initialData as any)?.jig_displacement_inches || 0,
|
||||
spring_stiffness_nm: (initialData as any)?.spring_stiffness_nm || 1,
|
||||
schedule_status: initialData?.schedule_status || 'pending schedule',
|
||||
results_status: initialData?.results_status || 'valid',
|
||||
completion_status: initialData?.completion_status || false,
|
||||
@@ -27,6 +33,31 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [phase, setPhase] = useState<ExperimentPhase | null>(null)
|
||||
const [crackingMachine, setCrackingMachine] = useState<MachineType | null>(null)
|
||||
const [metaLoading, setMetaLoading] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadMeta = async () => {
|
||||
if (!phaseId) return
|
||||
try {
|
||||
setMetaLoading(true)
|
||||
const p = await experimentPhaseManagement.getExperimentPhaseById(phaseId)
|
||||
setPhase(p)
|
||||
if (p?.has_cracking && p.cracking_machine_type_id) {
|
||||
const mt = await machineTypeManagement.getMachineTypeById(p.cracking_machine_type_id)
|
||||
setCrackingMachine(mt)
|
||||
} else {
|
||||
setCrackingMachine(null)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load phase/machine metadata', e)
|
||||
} finally {
|
||||
setMetaLoading(false)
|
||||
}
|
||||
}
|
||||
loadMeta()
|
||||
}, [phaseId])
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
@@ -40,8 +71,9 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
newErrors.reps_required = 'Repetitions required must be a positive integer'
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!formData.weight_per_repetition_lbs || formData.weight_per_repetition_lbs <= 0) {
|
||||
newErrors.weight_per_repetition_lbs = 'Weight per repetition must be positive'
|
||||
}
|
||||
|
||||
|
||||
if (formData.soaking_duration_hr < 0) {
|
||||
@@ -52,16 +84,30 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
newErrors.air_drying_time_min = 'Air drying time cannot be negative'
|
||||
}
|
||||
|
||||
if (!formData.plate_contact_frequency_hz || formData.plate_contact_frequency_hz <= 0) {
|
||||
newErrors.plate_contact_frequency_hz = 'Plate contact frequency must be positive'
|
||||
}
|
||||
|
||||
if (!formData.throughput_rate_pecans_sec || formData.throughput_rate_pecans_sec <= 0) {
|
||||
newErrors.throughput_rate_pecans_sec = 'Throughput rate must be positive'
|
||||
}
|
||||
|
||||
if (formData.crush_amount_in < 0) {
|
||||
newErrors.crush_amount_in = 'Crush amount cannot be negative'
|
||||
// Validate cracking fields depending on machine
|
||||
if (phase?.has_cracking) {
|
||||
if (crackingMachine?.name === 'JC Cracker') {
|
||||
if (!formData.plate_contact_frequency_hz || formData.plate_contact_frequency_hz <= 0) {
|
||||
newErrors.plate_contact_frequency_hz = 'Plate contact frequency must be positive'
|
||||
}
|
||||
if (!formData.throughput_rate_pecans_sec || formData.throughput_rate_pecans_sec <= 0) {
|
||||
newErrors.throughput_rate_pecans_sec = 'Throughput rate must be positive'
|
||||
}
|
||||
if (formData.crush_amount_in < 0) {
|
||||
newErrors.crush_amount_in = 'Crush amount cannot be negative'
|
||||
}
|
||||
}
|
||||
if (crackingMachine?.name === 'Meyer Cracker') {
|
||||
if (!(formData as any).motor_speed_hz || (formData as any).motor_speed_hz <= 0) {
|
||||
newErrors.motor_speed_hz = 'Motor speed must be positive'
|
||||
}
|
||||
if ((formData as any).jig_displacement_inches === undefined) {
|
||||
newErrors.jig_displacement_inches = 'Jig displacement is required'
|
||||
}
|
||||
if (!(formData as any).spring_stiffness_nm || (formData as any).spring_stiffness_nm <= 0) {
|
||||
newErrors.spring_stiffness_nm = 'Spring stiffness must be positive'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
@@ -80,14 +126,10 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
const submitData = isEditing ? formData : {
|
||||
experiment_number: formData.experiment_number,
|
||||
reps_required: formData.reps_required,
|
||||
soaking_duration_hr: formData.soaking_duration_hr,
|
||||
air_drying_time_min: formData.air_drying_time_min,
|
||||
plate_contact_frequency_hz: formData.plate_contact_frequency_hz,
|
||||
throughput_rate_pecans_sec: formData.throughput_rate_pecans_sec,
|
||||
crush_amount_in: formData.crush_amount_in,
|
||||
entry_exit_height_diff_in: formData.entry_exit_height_diff_in,
|
||||
schedule_status: formData.schedule_status,
|
||||
results_status: formData.results_status
|
||||
weight_per_repetition_lbs: formData.weight_per_repetition_lbs,
|
||||
results_status: formData.results_status,
|
||||
completion_status: formData.completion_status,
|
||||
phase_id: formData.phase_id
|
||||
}
|
||||
|
||||
await onSubmit(submitData)
|
||||
@@ -157,139 +199,264 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="weight_per_repetition_lbs" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Weight per Repetition (lbs) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="weight_per_repetition_lbs"
|
||||
value={formData.weight_per_repetition_lbs}
|
||||
onChange={(e) => handleInputChange('weight_per_repetition_lbs' as any, parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.weight_per_repetition_lbs ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="e.g. 10.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.weight_per_repetition_lbs && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.weight_per_repetition_lbs}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Experiment Parameters */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Experiment Parameters</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Dynamic Sections by Phase */}
|
||||
<div className="border-t pt-6 space-y-8">
|
||||
{/* Soaking */}
|
||||
{phase?.has_soaking && (
|
||||
<div>
|
||||
<label htmlFor="soaking_duration_hr" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking Duration (hours) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="soaking_duration_hr"
|
||||
value={formData.soaking_duration_hr}
|
||||
onChange={(e) => handleInputChange('soaking_duration_hr', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.soaking_duration_hr ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.soaking_duration_hr && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.soaking_duration_hr}</p>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Soaking</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="soaking_duration_hr" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking Duration (hours) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="soaking_duration_hr"
|
||||
value={formData.soaking_duration_hr}
|
||||
onChange={(e) => handleInputChange('soaking_duration_hr', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.soaking_duration_hr ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.soaking_duration_hr && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.soaking_duration_hr}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Air-Drying */}
|
||||
{phase?.has_airdrying && (
|
||||
<div>
|
||||
<label htmlFor="air_drying_time_min" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air Drying Time (minutes) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="air_drying_time_min"
|
||||
value={formData.air_drying_time_min}
|
||||
onChange={(e) => handleInputChange('air_drying_time_min', parseInt(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.air_drying_time_min ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.air_drying_time_min && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.air_drying_time_min}</p>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Air-Drying</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="air_drying_time_min" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air Drying Time (minutes) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="air_drying_time_min"
|
||||
value={formData.air_drying_time_min}
|
||||
onChange={(e) => handleInputChange('air_drying_time_min', parseInt(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.air_drying_time_min ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.air_drying_time_min && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.air_drying_time_min}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracking - machine specific */}
|
||||
{phase?.has_cracking && (
|
||||
<div>
|
||||
<label htmlFor="plate_contact_frequency_hz" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plate Contact Frequency (Hz) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="plate_contact_frequency_hz"
|
||||
value={formData.plate_contact_frequency_hz}
|
||||
onChange={(e) => handleInputChange('plate_contact_frequency_hz', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.plate_contact_frequency_hz ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.plate_contact_frequency_hz && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.plate_contact_frequency_hz}</p>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Cracking {crackingMachine ? `(${crackingMachine.name})` : ''}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{crackingMachine?.name === 'JC Cracker' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="plate_contact_frequency_hz" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plate Contact Frequency (Hz) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="plate_contact_frequency_hz"
|
||||
value={formData.plate_contact_frequency_hz}
|
||||
onChange={(e) => handleInputChange('plate_contact_frequency_hz', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.plate_contact_frequency_hz ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.plate_contact_frequency_hz && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.plate_contact_frequency_hz}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="throughput_rate_pecans_sec" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Throughput Rate (pecans/sec) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throughput_rate_pecans_sec"
|
||||
value={formData.throughput_rate_pecans_sec}
|
||||
onChange={(e) => handleInputChange('throughput_rate_pecans_sec', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.throughput_rate_pecans_sec ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.throughput_rate_pecans_sec && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.throughput_rate_pecans_sec}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crush_amount_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Crush Amount (thousandths of inch) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="crush_amount_in"
|
||||
value={formData.crush_amount_in}
|
||||
onChange={(e) => handleInputChange('crush_amount_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.crush_amount_in ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.001"
|
||||
required
|
||||
/>
|
||||
{errors.crush_amount_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.crush_amount_in}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="entry_exit_height_diff_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Entry/Exit Height Difference (inches) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="entry_exit_height_diff_in"
|
||||
value={formData.entry_exit_height_diff_in}
|
||||
onChange={(e) => handleInputChange('entry_exit_height_diff_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-sm px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.entry_exit_height_diff_in ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="0.0 (can be negative)"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.entry_exit_height_diff_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.entry_exit_height_diff_in}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">Positive values indicate entry is higher than exit</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{crackingMachine?.name === 'Meyer Cracker' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="motor_speed_hz" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Motor Speed (Hz) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="motor_speed_hz"
|
||||
value={(formData as any).motor_speed_hz}
|
||||
onChange={(e) => handleInputChange('motor_speed_hz' as any, parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.motor_speed_hz ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.motor_speed_hz && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.motor_speed_hz}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="jig_displacement_inches" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Jig Displacement (inches) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="jig_displacement_inches"
|
||||
value={(formData as any).jig_displacement_inches}
|
||||
onChange={(e) => handleInputChange('jig_displacement_inches' as any, parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.jig_displacement_inches ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
{errors.jig_displacement_inches && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.jig_displacement_inches}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="spring_stiffness_nm" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Spring Stiffness (N·m) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="spring_stiffness_nm"
|
||||
value={(formData as any).spring_stiffness_nm}
|
||||
onChange={(e) => handleInputChange('spring_stiffness_nm' as any, parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.spring_stiffness_nm ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.spring_stiffness_nm && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.spring_stiffness_nm}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shelling */}
|
||||
{phase?.has_shelling && (
|
||||
<div>
|
||||
<label htmlFor="throughput_rate_pecans_sec" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Throughput Rate (pecans/sec) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throughput_rate_pecans_sec"
|
||||
value={formData.throughput_rate_pecans_sec}
|
||||
onChange={(e) => handleInputChange('throughput_rate_pecans_sec', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.throughput_rate_pecans_sec ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.throughput_rate_pecans_sec && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.throughput_rate_pecans_sec}</p>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Shelling</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shelling Start Offset (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={(formData as any).shelling_start_offset_min || 0}
|
||||
onChange={(e) => handleInputChange('shelling_start_offset_min' as any, parseInt(e.target.value) || 0)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crush_amount_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Crush Amount (thousandths of inch) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="crush_amount_in"
|
||||
value={formData.crush_amount_in}
|
||||
onChange={(e) => handleInputChange('crush_amount_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.crush_amount_in ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.001"
|
||||
required
|
||||
/>
|
||||
{errors.crush_amount_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.crush_amount_in}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="entry_exit_height_diff_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Entry/Exit Height Difference (inches) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="entry_exit_height_diff_in"
|
||||
value={formData.entry_exit_height_diff_in}
|
||||
onChange={(e) => handleInputChange('entry_exit_height_diff_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-sm px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.entry_exit_height_diff_in ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0 (can be negative)"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.entry_exit_height_diff_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.entry_exit_height_diff_in}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">Positive values indicate entry is higher than exit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Fields (only show when editing) */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { experimentPhaseManagement, userManagement } from '../lib/supabase'
|
||||
import type { ExperimentPhase, User } from '../lib/supabase'
|
||||
import { PhaseModal } from './PhaseModal'
|
||||
|
||||
interface ExperimentPhasesProps {
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
@@ -11,6 +12,7 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -38,6 +40,11 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
|
||||
const canManagePhases = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
|
||||
|
||||
const handlePhaseCreated = (newPhase: ExperimentPhase) => {
|
||||
setPhases(prev => [newPhase, ...prev])
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -60,10 +67,7 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
</div>
|
||||
{canManagePhases && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement create phase modal
|
||||
alert('Create phase functionality will be implemented')
|
||||
}}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ New Phase
|
||||
@@ -113,6 +117,32 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Enabled Phases */}
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{phase.has_soaking && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
🌰 Soaking
|
||||
</span>
|
||||
)}
|
||||
{phase.has_airdrying && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
💨 Air-Drying
|
||||
</span>
|
||||
)}
|
||||
{phase.has_cracking && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
🔨 Cracking
|
||||
</span>
|
||||
)}
|
||||
{phase.has_shelling && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
📊 Shelling
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Created {new Date(phase.created_at).toLocaleDateString()}</span>
|
||||
<div className="flex items-center text-blue-600 hover:text-blue-800">
|
||||
@@ -139,10 +169,7 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
{canManagePhases && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement create phase modal
|
||||
alert('Create phase functionality will be implemented')
|
||||
}}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Create First Phase
|
||||
@@ -151,6 +178,14 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Phase Modal */}
|
||||
{showCreateModal && (
|
||||
<PhaseModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onPhaseCreated={handlePhaseCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,10 +108,8 @@ export function Experiments() {
|
||||
try {
|
||||
const newRepetition = await repetitionManagement.createRepetition({
|
||||
experiment_id: experiment.id,
|
||||
repetition_number: repetitionNumber,
|
||||
schedule_status: 'pending schedule'
|
||||
repetition_number: repetitionNumber
|
||||
})
|
||||
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[experiment.id]: [...(prev[experiment.id] || []), newRepetition].sort((a, b) => a.repetition_number - b.repetition_number)
|
||||
@@ -141,8 +139,8 @@ export function Experiments() {
|
||||
}
|
||||
|
||||
const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => {
|
||||
const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length
|
||||
const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length
|
||||
const scheduled = repetitions.filter(r => r.scheduled_date).length
|
||||
const pending = repetitions.filter(r => !r.scheduled_date).length
|
||||
const completed = repetitions.filter(r => r.completion_status).length
|
||||
|
||||
return { scheduled, pending, completed, total: repetitions.length }
|
||||
@@ -225,7 +223,7 @@ export function Experiments() {
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Experiment #
|
||||
#
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reps Required
|
||||
@@ -311,8 +309,8 @@ export function Experiments() {
|
||||
<div key={repetition.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">Rep #{repetition.repetition_number}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.schedule_status)}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.scheduled_date ? 'scheduled' : 'pending')}`}>
|
||||
{repetition.scheduled_date ? 'scheduled' : 'pending'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -320,7 +318,7 @@ export function Experiments() {
|
||||
handleScheduleRepetition(experiment, repetition)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title={repetition.schedule_status === 'scheduled' ? 'Reschedule' : 'Schedule'}
|
||||
title={repetition.scheduled_date ? 'Reschedule' : 'Schedule'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
@@ -352,7 +350,7 @@ export function Experiments() {
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(experiment.results_status)}`}>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${experiment.results_status}`}>
|
||||
{experiment.results_status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -114,7 +114,6 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
const newRepetition = await repetitionManagement.createRepetition({
|
||||
experiment_id: experiment.id,
|
||||
repetition_number: repetitionNumber,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
|
||||
setExperimentRepetitions(prev => ({
|
||||
@@ -146,16 +145,16 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
}
|
||||
|
||||
const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => {
|
||||
const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length
|
||||
const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length
|
||||
const scheduled = repetitions.filter(r => r.scheduled_date).length
|
||||
const pending = repetitions.filter(r => !r.scheduled_date).length
|
||||
const completed = repetitions.filter(r => r.completion_status).length
|
||||
|
||||
return { scheduled, pending, completed, total: repetitions.length }
|
||||
}
|
||||
|
||||
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => {
|
||||
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus | string) => {
|
||||
switch (status) {
|
||||
case 'pending schedule':
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'scheduled':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
@@ -326,7 +325,7 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
<span className="text-sm font-medium">Rep #{repetition.repetition_number}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.schedule_status)}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
{repetition.scheduled_date ? 'scheduled' : 'pending'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -334,7 +333,7 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
handleScheduleRepetition(experiment, repetition)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title={repetition.schedule_status === 'scheduled' ? 'Reschedule' : 'Schedule'}
|
||||
title={repetition.scheduled_date ? 'Reschedule' : 'Schedule'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
|
||||
288
management-dashboard-web-app/src/components/PhaseForm.tsx
Normal file
288
management-dashboard-web-app/src/components/PhaseForm.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateExperimentPhaseRequest, MachineType } from '../lib/supabase'
|
||||
import { machineTypeManagement } from '../lib/supabase'
|
||||
|
||||
interface PhaseFormProps {
|
||||
onSubmit: (data: CreateExperimentPhaseRequest) => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function PhaseForm({ onSubmit, onCancel, loading = false }: PhaseFormProps) {
|
||||
const [formData, setFormData] = useState<CreateExperimentPhaseRequest>({
|
||||
name: '',
|
||||
description: '',
|
||||
has_soaking: false,
|
||||
has_airdrying: false,
|
||||
has_cracking: false,
|
||||
has_shelling: false
|
||||
})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [machineTypes, setMachineTypes] = useState<MachineType[]>([])
|
||||
const [machinesLoading, setMachinesLoading] = useState(false)
|
||||
const [selectedCrackingMachineId, setSelectedCrackingMachineId] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
// Preload machine types for cracking selection
|
||||
const loadMachines = async () => {
|
||||
try {
|
||||
setMachinesLoading(true)
|
||||
const types = await machineTypeManagement.getAllMachineTypes()
|
||||
setMachineTypes(types)
|
||||
} catch (e) {
|
||||
// Non-fatal: we will show no options if it fails
|
||||
console.warn('Failed to load machine types', e)
|
||||
} finally {
|
||||
setMachinesLoading(false)
|
||||
}
|
||||
}
|
||||
loadMachines()
|
||||
}, [])
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
// Name is required
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Phase name is required'
|
||||
}
|
||||
|
||||
// At least one phase must be selected
|
||||
if (!formData.has_soaking && !formData.has_airdrying && !formData.has_cracking && !formData.has_shelling) {
|
||||
newErrors.phases = 'At least one phase must be selected'
|
||||
}
|
||||
|
||||
// If cracking is enabled, require a machine selection
|
||||
if (formData.has_cracking && !selectedCrackingMachineId) {
|
||||
newErrors.cracking_machine_type_id = 'Select a cracking machine'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validateForm()) {
|
||||
// Include cracking machine selection in payload when cracking is enabled
|
||||
const payload: CreateExperimentPhaseRequest = formData.has_cracking
|
||||
? { ...formData, cracking_machine_type_id: selectedCrackingMachineId }
|
||||
: formData
|
||||
onSubmit(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof CreateExperimentPhaseRequest, value: string | boolean | undefined) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const phaseOptions = [
|
||||
{
|
||||
key: 'has_soaking' as const,
|
||||
label: 'Soaking',
|
||||
description: 'Initial soaking process for pecans',
|
||||
icon: '🌰'
|
||||
},
|
||||
{
|
||||
key: 'has_airdrying' as const,
|
||||
label: 'Air-Drying',
|
||||
description: 'Post-soak air-drying process',
|
||||
icon: '💨'
|
||||
},
|
||||
{
|
||||
key: 'has_cracking' as const,
|
||||
label: 'Cracking',
|
||||
description: 'Pecan shell cracking process',
|
||||
icon: '🔨'
|
||||
},
|
||||
{
|
||||
key: 'has_shelling' as const,
|
||||
label: 'Shelling',
|
||||
description: 'Final shelling and yield measurement',
|
||||
icon: '📊'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Phase Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phase Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${errors.name ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter phase name (e.g., 'Full Process Experiment')"
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Optional description of this experiment phase"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phase Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Phases *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{phaseOptions.map((option) => (
|
||||
<div
|
||||
key={option.key}
|
||||
className={`relative border rounded-lg p-4 cursor-pointer transition-colors ${formData[option.key]
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onClick={() => {
|
||||
const newVal = !formData[option.key]
|
||||
handleInputChange(option.key, newVal)
|
||||
if (option.key === 'has_cracking' && !newVal) {
|
||||
setSelectedCrackingMachineId(undefined)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData[option.key]}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked
|
||||
handleInputChange(option.key, newVal)
|
||||
if (option.key === 'has_cracking' && !newVal) {
|
||||
setSelectedCrackingMachineId(undefined)
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-2xl mr-2">{option.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errors.phases && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.phases}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cracking machine selection */}
|
||||
{formData.has_cracking && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Cracking Machine *
|
||||
</label>
|
||||
<div className="max-w-sm">
|
||||
<select
|
||||
value={selectedCrackingMachineId || ''}
|
||||
onChange={(e) => setSelectedCrackingMachineId(e.target.value || undefined)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${errors.cracking_machine_type_id ? 'border-red-300' : 'border-gray-300'}`}
|
||||
disabled={loading || machinesLoading}
|
||||
>
|
||||
<option value="">{machinesLoading ? 'Loading...' : 'Select machine'}</option>
|
||||
{machineTypes.map(mt => (
|
||||
<option key={mt.id} value={mt.id}>{mt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{errors.cracking_machine_type_id && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.cracking_machine_type_id}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Phases Summary */}
|
||||
{Object.values(formData).some(value => typeof value === 'boolean' && value) && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Selected Phases:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phaseOptions
|
||||
.filter(option => formData[option.key])
|
||||
.map(option => (
|
||||
<span
|
||||
key={option.key}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{option.icon} {option.label}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Creating...
|
||||
</div>
|
||||
) : (
|
||||
'Create Phase'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
68
management-dashboard-web-app/src/components/PhaseModal.tsx
Normal file
68
management-dashboard-web-app/src/components/PhaseModal.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { experimentPhaseManagement } from '../lib/supabase'
|
||||
import type { CreateExperimentPhaseRequest, ExperimentPhase } from '../lib/supabase'
|
||||
import { PhaseForm } from './PhaseForm'
|
||||
|
||||
interface PhaseModalProps {
|
||||
onClose: () => void
|
||||
onPhaseCreated: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
export function PhaseModal({ onClose, onPhaseCreated }: PhaseModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (formData: CreateExperimentPhaseRequest) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const newPhase = await experimentPhaseManagement.createExperimentPhase(formData)
|
||||
onPhaseCreated(newPhase)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create experiment phase')
|
||||
console.error('Create phase error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Create New Experiment Phase
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<PhaseForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onClose}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function RepetitionScheduleModal({ experiment, repetition, onClose, onSch
|
||||
}
|
||||
|
||||
const [dateTime, setDateTime] = useState(getInitialDateTime())
|
||||
const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled'
|
||||
const isScheduled = !!repetition.scheduled_date
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'
|
||||
// @ts-ignore - react-big-calendar types not available
|
||||
import { Calendar, momentLocalizer, Views } from 'react-big-calendar'
|
||||
import moment from 'moment'
|
||||
import type { User } from '../lib/supabase'
|
||||
import { availabilityManagement } from '../lib/supabase'
|
||||
import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../lib/supabase'
|
||||
import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } from '../lib/supabase'
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css'
|
||||
import './CalendarStyles.css'
|
||||
|
||||
@@ -287,7 +287,211 @@ function IndicateAvailability({ user, onBack }: { user: User; onBack: () => void
|
||||
}
|
||||
|
||||
function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }) {
|
||||
// User context available for future features
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [conductors, setConductors] = useState<User[]>([])
|
||||
const [conductorIdsWithFutureAvailability, setConductorIdsWithFutureAvailability] = useState<Set<string>>(new Set())
|
||||
const [selectedConductorIds, setSelectedConductorIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const [phases, setPhases] = useState<ExperimentPhase[]>([])
|
||||
const [expandedPhaseIds, setExpandedPhaseIds] = useState<Set<string>>(new Set())
|
||||
const [experimentsByPhase, setExperimentsByPhase] = useState<Record<string, Experiment[]>>({})
|
||||
const [repetitionsByExperiment, setRepetitionsByExperiment] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||
const [selectedRepetitionIds, setSelectedRepetitionIds] = useState<Set<string>>(new Set())
|
||||
const [creatingRepetitionsFor, setCreatingRepetitionsFor] = useState<Set<string>>(new Set())
|
||||
const [soakingByExperiment, setSoakingByExperiment] = useState<Record<string, Soaking | null>>({})
|
||||
const [airdryingByExperiment, setAirdryingByExperiment] = useState<Record<string, Airdrying | null>>({})
|
||||
|
||||
// Calendar state for selected conductors' availability
|
||||
const localizer = momentLocalizer(moment)
|
||||
const [calendarView, setCalendarView] = useState(Views.WEEK)
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [availabilityEvents, setAvailabilityEvents] = useState<CalendarEvent[]>([])
|
||||
|
||||
// Color map per conductor for calendar events
|
||||
const [conductorColorMap, setConductorColorMap] = useState<Record<string, string>>({})
|
||||
const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444']
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [allUsers, allPhases] = await Promise.all([
|
||||
userManagement.getAllUsers(),
|
||||
experimentPhaseManagement.getAllExperimentPhases()
|
||||
])
|
||||
|
||||
// Filter conductors
|
||||
const conductorsOnly = allUsers.filter(u => u.roles.includes('conductor'))
|
||||
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.
|
||||
// To avoid that in UI layer, approximate by marking all conductors as selectable initially.
|
||||
// In a later iteration, backend endpoint can provide available conductors. For now, consider all conductors as available some time in future.
|
||||
setConductorIdsWithFutureAvailability(new Set(conductorIds))
|
||||
|
||||
setPhases(allPhases)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load scheduling data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const toggleConductor = (id: string) => {
|
||||
setSelectedConductorIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch availability for selected conductors and build calendar events
|
||||
useEffect(() => {
|
||||
const loadSelectedAvailability = async () => {
|
||||
try {
|
||||
const selectedIds = Array.from(selectedConductorIds)
|
||||
if (selectedIds.length === 0) {
|
||||
setAvailabilityEvents([])
|
||||
return
|
||||
}
|
||||
|
||||
// Assign consistent colors per selected conductor
|
||||
const newColorMap: Record<string, string> = {}
|
||||
selectedIds.forEach((conductorId, index) => {
|
||||
newColorMap[conductorId] = colorPalette[index % colorPalette.length]
|
||||
})
|
||||
setConductorColorMap(newColorMap)
|
||||
|
||||
// Fetch availability for selected conductors in a single query
|
||||
const { data, error } = await supabase
|
||||
.from('conductor_availability')
|
||||
.select('*')
|
||||
.in('user_id', selectedIds)
|
||||
.eq('status', 'active')
|
||||
.gt('available_to', new Date().toISOString())
|
||||
.order('available_from', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Map user_id -> display name
|
||||
const idToName: Record<string, string> = {}
|
||||
conductors.forEach(c => {
|
||||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
|
||||
idToName[c.id] = name
|
||||
})
|
||||
|
||||
const events: CalendarEvent[] = (data || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
title: `${idToName[r.user_id] || 'Conductor'}`,
|
||||
start: new Date(r.available_from),
|
||||
end: new Date(r.available_to),
|
||||
resource: newColorMap[r.user_id] || '#2563eb'
|
||||
}))
|
||||
|
||||
setAvailabilityEvents(events)
|
||||
} catch (e) {
|
||||
// Fail silently for calendar, do not break main UI
|
||||
setAvailabilityEvents([])
|
||||
}
|
||||
}
|
||||
|
||||
loadSelectedAvailability()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedConductorIds, conductors])
|
||||
|
||||
const togglePhaseExpand = async (phaseId: string) => {
|
||||
setExpandedPhaseIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(phaseId)) next.delete(phaseId)
|
||||
else next.add(phaseId)
|
||||
return next
|
||||
})
|
||||
|
||||
// Lazy-load experiments for this phase if not loaded
|
||||
if (!experimentsByPhase[phaseId]) {
|
||||
try {
|
||||
const exps = await experimentManagement.getExperimentsByPhaseId(phaseId)
|
||||
setExperimentsByPhase(prev => ({ ...prev, [phaseId]: exps }))
|
||||
|
||||
// For each experiment, load repetitions and phase data
|
||||
const repsEntries = await Promise.all(
|
||||
exps.map(async (exp) => {
|
||||
const [reps, soaking, airdrying] = await Promise.all([
|
||||
repetitionManagement.getExperimentRepetitions(exp.id),
|
||||
phaseManagement.getSoakingByExperimentId(exp.id),
|
||||
phaseManagement.getAirdryingByExperimentId(exp.id)
|
||||
])
|
||||
return [exp.id, reps, soaking, airdrying] as const
|
||||
})
|
||||
)
|
||||
|
||||
// Update repetitions
|
||||
setRepetitionsByExperiment(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(repsEntries.map(([id, reps]) => [id, reps]))
|
||||
}))
|
||||
|
||||
// Update soaking data
|
||||
setSoakingByExperiment(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(repsEntries.map(([id, , soaking]) => [id, soaking]))
|
||||
}))
|
||||
|
||||
// Update airdrying data
|
||||
setAirdryingByExperiment(prev => ({
|
||||
...prev,
|
||||
...Object.fromEntries(repsEntries.map(([id, , , airdrying]) => [id, airdrying]))
|
||||
}))
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load experiments or repetitions')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRepetition = (repId: string) => {
|
||||
setSelectedRepetitionIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(repId)) next.delete(repId)
|
||||
else next.add(repId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const createRepetitionsForExperiment = async (experimentId: string) => {
|
||||
try {
|
||||
setCreatingRepetitionsFor(prev => new Set(prev).add(experimentId))
|
||||
setError(null)
|
||||
|
||||
// Create all repetitions for this experiment
|
||||
const newRepetitions = await repetitionManagement.createAllRepetitions(experimentId)
|
||||
|
||||
// Update the repetitions state
|
||||
setRepetitionsByExperiment(prev => ({
|
||||
...prev,
|
||||
[experimentId]: newRepetitions
|
||||
}))
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to create repetitions')
|
||||
} finally {
|
||||
setCreatingRepetitionsFor(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(experimentId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
@@ -309,19 +513,212 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{error && (
|
||||
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600 dark:text-purple-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Loading…</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: Conductors with future availability */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{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)
|
||||
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">
|
||||
<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 in future' : 'No future availability'}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Phases -> Experiments -> Repetitions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Experiment Phases</h2>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Expand and select repetitions</span>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{phases.length === 0 && (
|
||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No phases defined.</div>
|
||||
)}
|
||||
{phases.map(phase => {
|
||||
const expanded = expandedPhaseIds.has(phase.id)
|
||||
const experiments = experimentsByPhase[phase.id] || []
|
||||
return (
|
||||
<div key={phase.id}>
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
onClick={() => togglePhaseExpand(phase.id)}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{phase.name}</span>
|
||||
<svg className={`w-4 h-4 text-gray-500 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-96 overflow-y-auto">
|
||||
{experiments.length === 0 && (
|
||||
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
|
||||
)}
|
||||
{experiments.map(exp => {
|
||||
const reps = repetitionsByExperiment[exp.id] || []
|
||||
const isCreating = creatingRepetitionsFor.has(exp.id)
|
||||
const allRepsCreated = reps.length >= exp.reps_required
|
||||
const soaking = soakingByExperiment[exp.id]
|
||||
const airdrying = airdryingByExperiment[exp.id]
|
||||
|
||||
const getSoakDisplay = () => {
|
||||
if (soaking) return `${soaking.soaking_duration_minutes}min`
|
||||
return '—'
|
||||
}
|
||||
|
||||
const getAirdryDisplay = () => {
|
||||
if (airdrying) return `${airdrying.duration_minutes}min`
|
||||
return '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={exp.id} className="border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
<span className="font-medium">Exp #{exp.experiment_number}</span>
|
||||
<span className="mx-2 text-gray-400">•</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-300">Soak: {getSoakDisplay()}</span>
|
||||
<span className="mx-2 text-gray-400">/</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-300">Air-dry: {getAirdryDisplay()}</span>
|
||||
</div>
|
||||
{!allRepsCreated && (
|
||||
<button
|
||||
onClick={() => createRepetitionsForExperiment(exp.id)}
|
||||
disabled={isCreating}
|
||||
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Add Repetition'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 pb-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{reps.map(rep => {
|
||||
const checked = selectedRepetitionIds.has(rep.id)
|
||||
return (
|
||||
<label key={rep.id} className="flex items-center gap-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
checked={checked}
|
||||
onChange={() => toggleRepetition(rep.id)}
|
||||
/>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{reps.length === 0 && !isCreating && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 col-span-full">No repetitions created. Click "Create Reps" to generate them.</div>
|
||||
)}
|
||||
{isCreating && (
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 col-span-full flex items-center gap-2">
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
|
||||
</svg>
|
||||
Creating {exp.reps_required} repetitions...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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'
|
||||
}
|
||||
}
|
||||
}}
|
||||
popup
|
||||
showMultiDayTimes
|
||||
doShowMore={true}
|
||||
step={30}
|
||||
timeslots={2}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Experiment Scheduling
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
This interface will allow you to schedule specific experiment runs, assign team members,
|
||||
and manage the timing of upcoming experimental sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface ExperimentPhase {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
has_soaking: boolean
|
||||
has_airdrying: boolean
|
||||
has_cracking: boolean
|
||||
has_shelling: boolean
|
||||
cracking_machine_type_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
@@ -47,15 +52,14 @@ export interface Experiment {
|
||||
id: string
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
weight_per_repetition_lbs: number
|
||||
results_status: ResultsStatus
|
||||
completion_status: boolean
|
||||
phase_id?: string | null
|
||||
soaking_id?: string | null
|
||||
airdrying_id?: string | null
|
||||
cracking_id?: string | null
|
||||
shelling_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
@@ -63,25 +67,115 @@ export interface Experiment {
|
||||
|
||||
|
||||
|
||||
// Machine Types
|
||||
export interface MachineType {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
// Phase-specific interfaces
|
||||
export interface Soaking {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string | null
|
||||
soaking_duration_minutes: number
|
||||
scheduled_end_time: string
|
||||
actual_end_time?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Airdrying {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string | null
|
||||
duration_minutes: number
|
||||
scheduled_end_time: string
|
||||
actual_end_time?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Cracking {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
machine_type_id: string
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string | null
|
||||
actual_end_time?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Shelling {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string | null
|
||||
actual_end_time?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
// Machine-specific parameter interfaces
|
||||
export interface JCCrackerParameters {
|
||||
id: string
|
||||
cracking_id: string
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MeyerCrackerParameters {
|
||||
id: string
|
||||
cracking_id: string
|
||||
motor_speed_hz: number
|
||||
jig_displacement_inches: number
|
||||
spring_stiffness_nm: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateExperimentPhaseRequest {
|
||||
name: string
|
||||
description?: string
|
||||
has_soaking: boolean
|
||||
has_airdrying: boolean
|
||||
has_cracking: boolean
|
||||
has_shelling: boolean
|
||||
cracking_machine_type_id?: string
|
||||
}
|
||||
|
||||
export interface UpdateExperimentPhaseRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
has_soaking?: boolean
|
||||
has_airdrying?: boolean
|
||||
has_cracking?: boolean
|
||||
has_shelling?: boolean
|
||||
}
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
weight_per_repetition_lbs: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
phase_id?: string
|
||||
@@ -90,12 +184,7 @@ export interface CreateExperimentRequest {
|
||||
export interface UpdateExperimentRequest {
|
||||
experiment_number?: number
|
||||
reps_required?: number
|
||||
soaking_duration_hr?: number
|
||||
air_drying_time_min?: number
|
||||
plate_contact_frequency_hz?: number
|
||||
throughput_rate_pecans_sec?: number
|
||||
crush_amount_in?: number
|
||||
entry_exit_height_diff_in?: number
|
||||
weight_per_repetition_lbs?: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
phase_id?: string
|
||||
@@ -105,12 +194,10 @@ export interface CreateRepetitionRequest {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
}
|
||||
|
||||
export interface UpdateRepetitionRequest {
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
@@ -137,7 +224,6 @@ export interface ExperimentRepetition {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status: ScheduleStatus
|
||||
completion_status: boolean
|
||||
is_locked: boolean
|
||||
locked_at?: string | null
|
||||
@@ -219,6 +305,58 @@ export interface UpdatePhaseDataRequest {
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
// Phase creation request interfaces
|
||||
export interface CreateSoakingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string
|
||||
scheduled_start_time: string
|
||||
soaking_duration_minutes: number
|
||||
actual_start_time?: string
|
||||
actual_end_time?: string
|
||||
}
|
||||
|
||||
export interface CreateAirdryingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string
|
||||
scheduled_start_time?: string // Will be auto-calculated from soaking if not provided
|
||||
duration_minutes: number
|
||||
actual_start_time?: string
|
||||
actual_end_time?: string
|
||||
}
|
||||
|
||||
export interface CreateCrackingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string
|
||||
machine_type_id: string
|
||||
scheduled_start_time?: string // Will be auto-calculated from airdrying if not provided
|
||||
actual_start_time?: string
|
||||
actual_end_time?: string
|
||||
}
|
||||
|
||||
export interface CreateShellingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string
|
||||
actual_end_time?: string
|
||||
}
|
||||
|
||||
// Machine parameter creation request interfaces
|
||||
export interface CreateJCCrackerParametersRequest {
|
||||
cracking_id: string
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
}
|
||||
|
||||
export interface CreateMeyerCrackerParametersRequest {
|
||||
cracking_id: string
|
||||
motor_speed_hz: number
|
||||
jig_displacement_inches: number
|
||||
spring_stiffness_nm: number
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
id: string
|
||||
user_id: string
|
||||
@@ -624,6 +762,349 @@ export const experimentManagement = {
|
||||
}
|
||||
}
|
||||
|
||||
// Machine Type Management
|
||||
export const machineTypeManagement = {
|
||||
// Get all machine types
|
||||
async getAllMachineTypes(): Promise<MachineType[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('machine_types')
|
||||
.select('*')
|
||||
.order('name')
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get machine type by ID
|
||||
async getMachineTypeById(id: string): Promise<MachineType | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('machine_types')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Phase Management
|
||||
export const phaseManagement = {
|
||||
// Soaking management
|
||||
async createSoaking(request: CreateSoakingRequest): Promise<Soaking> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('soaking')
|
||||
.insert({
|
||||
...request,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async updateSoaking(id: string, updates: Partial<Soaking>): Promise<Soaking> {
|
||||
const { data, error } = await supabase
|
||||
.from('soaking')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('soaking')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async getSoakingByRepetitionId(repetitionId: string): Promise<Soaking | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('soaking')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Airdrying management
|
||||
async createAirdrying(request: CreateAirdryingRequest): Promise<Airdrying> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('airdrying')
|
||||
.insert({
|
||||
...request,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async updateAirdrying(id: string, updates: Partial<Airdrying>): Promise<Airdrying> {
|
||||
const { data, error } = await supabase
|
||||
.from('airdrying')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('airdrying')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async getAirdryingByRepetitionId(repetitionId: string): Promise<Airdrying | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('airdrying')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Cracking management
|
||||
async createCracking(request: CreateCrackingRequest): Promise<Cracking> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('cracking')
|
||||
.insert({
|
||||
...request,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async updateCracking(id: string, updates: Partial<Cracking>): Promise<Cracking> {
|
||||
const { data, error } = await supabase
|
||||
.from('cracking')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getCrackingByExperimentId(experimentId: string): Promise<Cracking | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('cracking')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async getCrackingByRepetitionId(repetitionId: string): Promise<Cracking | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('cracking')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Shelling management
|
||||
async createShelling(request: CreateShellingRequest): Promise<Shelling> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('shelling')
|
||||
.insert({
|
||||
...request,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async updateShelling(id: string, updates: Partial<Shelling>): Promise<Shelling> {
|
||||
const { data, error } = await supabase
|
||||
.from('shelling')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getShellingByExperimentId(experimentId: string): Promise<Shelling | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('shelling')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async getShellingByRepetitionId(repetitionId: string): Promise<Shelling | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('shelling')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Machine Parameter Management
|
||||
export const machineParameterManagement = {
|
||||
// JC Cracker parameters
|
||||
async createJCCrackerParameters(request: CreateJCCrackerParametersRequest): Promise<JCCrackerParameters> {
|
||||
const { data, error } = await supabase
|
||||
.from('jc_cracker_parameters')
|
||||
.insert(request)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async updateJCCrackerParameters(id: string, updates: Partial<JCCrackerParameters>): Promise<JCCrackerParameters> {
|
||||
const { data, error } = await supabase
|
||||
.from('jc_cracker_parameters')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getJCCrackerParametersByCrackingId(crackingId: string): Promise<JCCrackerParameters | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('jc_cracker_parameters')
|
||||
.select('*')
|
||||
.eq('cracking_id', crackingId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Meyer Cracker parameters
|
||||
async createMeyerCrackerParameters(request: CreateMeyerCrackerParametersRequest): Promise<MeyerCrackerParameters> {
|
||||
const { data, error } = await supabase
|
||||
.from('meyer_cracker_parameters')
|
||||
.insert(request)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async updateMeyerCrackerParameters(id: string, updates: Partial<MeyerCrackerParameters>): Promise<MeyerCrackerParameters> {
|
||||
const { data, error } = await supabase
|
||||
.from('meyer_cracker_parameters')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getMeyerCrackerParametersByCrackingId(crackingId: string): Promise<MeyerCrackerParameters | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('meyer_cracker_parameters')
|
||||
.select('*')
|
||||
.eq('cracking_id', crackingId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment Repetitions Management
|
||||
export const repetitionManagement = {
|
||||
// Get all repetitions for an experiment
|
||||
@@ -673,7 +1154,6 @@ export const repetitionManagement = {
|
||||
async scheduleRepetition(id: string, scheduledDate: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: scheduledDate,
|
||||
schedule_status: 'scheduled'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
@@ -683,7 +1163,6 @@ export const repetitionManagement = {
|
||||
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: null,
|
||||
schedule_status: 'pending schedule'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
@@ -700,15 +1179,14 @@ export const repetitionManagement = {
|
||||
},
|
||||
|
||||
// Get repetitions by status
|
||||
async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise<ExperimentRepetition[]> {
|
||||
async getRepetitionsByStatus(isScheduled?: boolean): Promise<ExperimentRepetition[]> {
|
||||
let query = supabase.from('experiment_repetitions').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
if (isScheduled === true) {
|
||||
query = query.not('scheduled_date', 'is', null)
|
||||
} else if (isScheduled === false) {
|
||||
query = query.is('scheduled_date', null)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
@@ -743,8 +1221,7 @@ export const repetitionManagement = {
|
||||
for (let i = 1; i <= experiment.reps_required; i++) {
|
||||
repetitions.push({
|
||||
experiment_id: experimentId,
|
||||
repetition_number: i,
|
||||
schedule_status: 'pending schedule'
|
||||
repetition_number: i
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user