Refactor Experiment components to support new experiment book structure
- Updated ExperimentForm to handle additional phase parameters and improved initial state management. - Modified ExperimentModal to fetch experiment data with phase configuration and ensure unique experiment numbers within the same phase. - Renamed references from "phases" to "books" across ExperimentPhases, PhaseExperiments, and related components for consistency with the new terminology. - Enhanced error handling and validation for new shelling parameters in ExperimentForm. - Updated Supabase interface definitions to reflect changes in experiment and phase data structures.
This commit is contained in:
@@ -3,7 +3,7 @@ import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus,
|
||||
import { experimentPhaseManagement, machineTypeManagement } from '../lib/supabase'
|
||||
|
||||
interface ExperimentFormProps {
|
||||
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
|
||||
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }> & { phase_id?: string | null }
|
||||
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
|
||||
onCancel: () => void
|
||||
isEditing?: boolean
|
||||
@@ -12,31 +12,41 @@ interface ExperimentFormProps {
|
||||
}
|
||||
|
||||
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false, phaseId }: ExperimentFormProps) {
|
||||
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,
|
||||
phase_id: initialData?.phase_id || phaseId
|
||||
const getInitialFormState = (d: any) => ({
|
||||
experiment_number: d?.experiment_number ?? 0,
|
||||
reps_required: d?.reps_required ?? 1,
|
||||
weight_per_repetition_lbs: d?.weight_per_repetition_lbs ?? 1,
|
||||
soaking_duration_hr: d?.soaking?.soaking_duration_hr ?? d?.soaking_duration_hr ?? 0,
|
||||
air_drying_time_min: d?.airdrying?.duration_minutes ?? d?.air_drying_time_min ?? 0,
|
||||
plate_contact_frequency_hz: d?.cracking?.plate_contact_frequency_hz ?? d?.plate_contact_frequency_hz ?? 1,
|
||||
throughput_rate_pecans_sec: d?.cracking?.throughput_rate_pecans_sec ?? d?.throughput_rate_pecans_sec ?? 1,
|
||||
crush_amount_in: d?.cracking?.crush_amount_in ?? d?.crush_amount_in ?? 0,
|
||||
entry_exit_height_diff_in: d?.cracking?.entry_exit_height_diff_in ?? d?.entry_exit_height_diff_in ?? 0,
|
||||
motor_speed_hz: d?.cracking?.motor_speed_hz ?? d?.motor_speed_hz ?? 1,
|
||||
jig_displacement_inches: d?.cracking?.jig_displacement_inches ?? d?.jig_displacement_inches ?? 0,
|
||||
spring_stiffness_nm: d?.cracking?.spring_stiffness_nm ?? d?.spring_stiffness_nm ?? 1,
|
||||
schedule_status: d?.schedule_status ?? 'pending schedule',
|
||||
results_status: d?.results_status ?? 'valid',
|
||||
completion_status: d?.completion_status ?? false,
|
||||
phase_id: d?.phase_id ?? phaseId,
|
||||
ring_gap_inches: d?.shelling?.ring_gap_inches ?? d?.ring_gap_inches ?? null,
|
||||
drum_rpm: d?.shelling?.drum_rpm ?? d?.drum_rpm ?? null
|
||||
})
|
||||
|
||||
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>(() => getInitialFormState(initialData))
|
||||
|
||||
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)
|
||||
|
||||
// When initialData loads with phase config (edit mode), sync form state
|
||||
useEffect(() => {
|
||||
if ((initialData as any)?.id) {
|
||||
setFormData(prev => ({ ...prev, ...getInitialFormState(initialData) }))
|
||||
}
|
||||
}, [initialData])
|
||||
|
||||
useEffect(() => {
|
||||
const loadMeta = async () => {
|
||||
if (!phaseId) return
|
||||
@@ -76,11 +86,11 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
}
|
||||
|
||||
|
||||
if (formData.soaking_duration_hr < 0) {
|
||||
if ((formData.soaking_duration_hr ?? 0) < 0) {
|
||||
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
|
||||
}
|
||||
|
||||
if (formData.air_drying_time_min < 0) {
|
||||
if ((formData.air_drying_time_min ?? 0) < 0) {
|
||||
newErrors.air_drying_time_min = 'Air drying time cannot be negative'
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
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) {
|
||||
if ((formData.crush_amount_in ?? 0) < 0) {
|
||||
newErrors.crush_amount_in = 'Crush amount cannot be negative'
|
||||
}
|
||||
}
|
||||
@@ -110,6 +120,16 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
}
|
||||
}
|
||||
|
||||
// Shelling: if provided, must be positive
|
||||
if (phase?.has_shelling) {
|
||||
if (formData.ring_gap_inches != null && (typeof formData.ring_gap_inches !== 'number' || formData.ring_gap_inches <= 0)) {
|
||||
newErrors.ring_gap_inches = 'Ring gap must be positive'
|
||||
}
|
||||
if (formData.drum_rpm != null && (typeof formData.drum_rpm !== 'number' || formData.drum_rpm <= 0)) {
|
||||
newErrors.drum_rpm = 'Drum RPM must be positive'
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
@@ -122,14 +142,25 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare data for submission
|
||||
// Prepare data: include all phase params so they are stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling
|
||||
const submitData = isEditing ? formData : {
|
||||
experiment_number: formData.experiment_number,
|
||||
reps_required: formData.reps_required,
|
||||
weight_per_repetition_lbs: formData.weight_per_repetition_lbs,
|
||||
results_status: formData.results_status,
|
||||
completion_status: formData.completion_status,
|
||||
phase_id: formData.phase_id
|
||||
phase_id: formData.phase_id,
|
||||
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,
|
||||
motor_speed_hz: (formData as any).motor_speed_hz,
|
||||
jig_displacement_inches: (formData as any).jig_displacement_inches,
|
||||
spring_stiffness_nm: (formData as any).spring_stiffness_nm,
|
||||
ring_gap_inches: formData.ring_gap_inches ?? undefined,
|
||||
drum_rpm: formData.drum_rpm ?? undefined
|
||||
}
|
||||
|
||||
await onSubmit(submitData)
|
||||
@@ -138,7 +169,7 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean) => {
|
||||
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean | null | undefined) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
@@ -441,18 +472,40 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
<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 htmlFor="ring_gap_inches" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ring gap (inches)
|
||||
</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"
|
||||
id="ring_gap_inches"
|
||||
value={formData.ring_gap_inches ?? ''}
|
||||
onChange={(e) => handleInputChange('ring_gap_inches' as any, e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||
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.ring_gap_inches ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="e.g. 0.25"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
{errors.ring_gap_inches && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.ring_gap_inches}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="drum_rpm" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Drum RPM
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="drum_rpm"
|
||||
value={formData.drum_rpm ?? ''}
|
||||
onChange={(e) => handleInputChange('drum_rpm' as any, e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
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.drum_rpm ? 'border-red-300' : 'border-gray-300'}`}
|
||||
placeholder="e.g. 300"
|
||||
min="1"
|
||||
step="1"
|
||||
/>
|
||||
{errors.drum_rpm && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.drum_rpm}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExperimentForm } from './ExperimentForm'
|
||||
import { experimentManagement } from '../lib/supabase'
|
||||
import type { Experiment, CreateExperimentRequest, UpdateExperimentRequest } from '../lib/supabase'
|
||||
@@ -13,9 +13,20 @@ interface ExperimentModalProps {
|
||||
export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseId }: ExperimentModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initialData, setInitialData] = useState<Experiment | (Experiment & { soaking?: any; airdrying?: any; cracking?: any; shelling?: any }) | undefined>(experiment ?? undefined)
|
||||
|
||||
const isEditing = !!experiment
|
||||
|
||||
useEffect(() => {
|
||||
if (experiment) {
|
||||
experimentManagement.getExperimentWithPhaseConfig(experiment.id)
|
||||
.then((data) => setInitialData(data ?? experiment))
|
||||
.catch(() => setInitialData(experiment))
|
||||
} else {
|
||||
setInitialData(undefined)
|
||||
}
|
||||
}, [experiment?.id])
|
||||
|
||||
const handleSubmit = async (data: CreateExperimentRequest | UpdateExperimentRequest) => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
@@ -24,22 +35,24 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseI
|
||||
let savedExperiment: Experiment
|
||||
|
||||
if (isEditing && experiment) {
|
||||
// Check if experiment number is unique (excluding current experiment)
|
||||
// Check if experiment number is unique within this phase (excluding current experiment)
|
||||
if ('experiment_number' in data && data.experiment_number !== undefined && data.experiment_number !== experiment.experiment_number) {
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, experiment.id)
|
||||
const phaseIdToCheck = data.phase_id ?? experiment.phase_id ?? phaseId
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, phaseIdToCheck ?? undefined, experiment.id)
|
||||
if (!isUnique) {
|
||||
setError('Experiment number already exists. Please choose a different number.')
|
||||
setError('Experiment number already exists in this phase. Please choose a different number.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savedExperiment = await experimentManagement.updateExperiment(experiment.id, data)
|
||||
} else {
|
||||
// Check if experiment number is unique for new experiments
|
||||
// Check if experiment number is unique within this phase for new experiments
|
||||
const createData = data as CreateExperimentRequest
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number)
|
||||
const phaseIdToCheck = createData.phase_id ?? phaseId
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number, phaseIdToCheck ?? undefined)
|
||||
if (!isUnique) {
|
||||
setError('Experiment number already exists. Please choose a different number.')
|
||||
setError('Experiment number already exists in this phase. Please choose a different number.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,7 +128,7 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseI
|
||||
|
||||
{/* Form */}
|
||||
<ExperimentForm
|
||||
initialData={experiment}
|
||||
initialData={initialData ? { ...initialData, phase_id: initialData.phase_id ?? undefined } : undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -31,8 +31,8 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
setPhases(phasesData)
|
||||
setCurrentUser(userData)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load experiment phases')
|
||||
console.error('Load experiment phases error:', err)
|
||||
setError(err.message || 'Failed to load experiment books')
|
||||
console.error('Load experiment books error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -61,16 +61,16 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Phases</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment phase to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Books</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment book to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment books help organize experiments into logical groups for easier navigation and management.</p>
|
||||
</div>
|
||||
{canManagePhases && (
|
||||
<button
|
||||
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
|
||||
➕ New Book
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,9 +162,9 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment phases found</h3>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment books found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Get started by creating your first experiment phase.
|
||||
Get started by creating your first experiment book.
|
||||
</p>
|
||||
{canManagePhases && (
|
||||
<div className="mt-6">
|
||||
@@ -172,7 +172,7 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
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
|
||||
➕ Create First Book
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Phases
|
||||
Back to Books
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +203,7 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
{phase.description && (
|
||||
<p className="mt-2 text-gray-600">{phase.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-gray-600">Manage experiments within this phase</p>
|
||||
<p className="mt-2 text-gray-600">Manage experiments within this book</p>
|
||||
</div>
|
||||
{canManageExperiments && (
|
||||
<button
|
||||
@@ -417,9 +417,9 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found in this phase</h3>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found in this book</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first experiment in this phase.
|
||||
Get started by creating your first experiment in this book.
|
||||
</p>
|
||||
{canManageExperiments && (
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -147,7 +147,7 @@ export function PhaseForm({ onSubmit, onCancel, loading = false }: PhaseFormProp
|
||||
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"
|
||||
placeholder="Optional description of this experiment book"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PhaseModal({ onClose, onPhaseCreated }: PhaseModalProps) {
|
||||
onPhaseCreated(newPhase)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create experiment phase')
|
||||
setError(err.message || 'Failed to create experiment book')
|
||||
console.error('Create phase error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -35,7 +35,7 @@ export function PhaseModal({ onClose, onPhaseCreated }: PhaseModalProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Create New Experiment Phase
|
||||
Create New Experiment Book
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface Experiment {
|
||||
airdrying_id?: string | null
|
||||
cracking_id?: string | null
|
||||
shelling_id?: string | null
|
||||
ring_gap_inches?: number | null
|
||||
drum_rpm?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
@@ -170,6 +172,51 @@ export interface UpdateExperimentPhaseRequest {
|
||||
has_shelling?: boolean
|
||||
}
|
||||
|
||||
// Experiment-level phase config (one row per experiment per phase; stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
|
||||
export interface ExperimentSoakingConfig {
|
||||
id: string
|
||||
experiment_id: string
|
||||
soaking_duration_hr: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ExperimentAirdryingConfig {
|
||||
id: string
|
||||
experiment_id: string
|
||||
duration_minutes: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ExperimentCrackingConfig {
|
||||
id: string
|
||||
experiment_id: string
|
||||
machine_type_id: string
|
||||
plate_contact_frequency_hz?: number | null
|
||||
throughput_rate_pecans_sec?: number | null
|
||||
crush_amount_in?: number | null
|
||||
entry_exit_height_diff_in?: number | null
|
||||
motor_speed_hz?: number | null
|
||||
jig_displacement_inches?: number | null
|
||||
spring_stiffness_nm?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ExperimentShellingConfig {
|
||||
id: string
|
||||
experiment_id: string
|
||||
ring_gap_inches?: number | null
|
||||
drum_rpm?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
@@ -177,6 +224,19 @@ export interface CreateExperimentRequest {
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
phase_id?: string
|
||||
// Phase config (stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
|
||||
soaking_duration_hr?: number
|
||||
air_drying_time_min?: number
|
||||
// Cracking: machine_type comes from book; params below are JC or Meyer specific
|
||||
plate_contact_frequency_hz?: number
|
||||
throughput_rate_pecans_sec?: number
|
||||
crush_amount_in?: number
|
||||
entry_exit_height_diff_in?: number
|
||||
motor_speed_hz?: number
|
||||
jig_displacement_inches?: number
|
||||
spring_stiffness_nm?: number
|
||||
ring_gap_inches?: number | null
|
||||
drum_rpm?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
@@ -186,6 +246,17 @@ export interface UpdateExperimentRequest {
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
phase_id?: string
|
||||
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
|
||||
motor_speed_hz?: number
|
||||
jig_displacement_inches?: number
|
||||
spring_stiffness_nm?: number
|
||||
ring_gap_inches?: number | null
|
||||
drum_rpm?: number | null
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
@@ -560,12 +631,12 @@ export const userManagement = {
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment phase management utility functions
|
||||
// Experiment book management (table: experiment_books)
|
||||
export const experimentPhaseManagement = {
|
||||
// Get all experiment phases
|
||||
// Get all experiment books
|
||||
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.from('experiment_books')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
@@ -573,10 +644,10 @@ export const experimentPhaseManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiment phase by ID
|
||||
// Get experiment book by ID
|
||||
async getExperimentPhaseById(id: string): Promise<ExperimentPhase | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.from('experiment_books')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
@@ -588,13 +659,13 @@ export const experimentPhaseManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new experiment phase
|
||||
// Create a new experiment book
|
||||
async createExperimentPhase(phaseData: CreateExperimentPhaseRequest): Promise<ExperimentPhase> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.from('experiment_books')
|
||||
.insert({
|
||||
...phaseData,
|
||||
created_by: user.id
|
||||
@@ -606,10 +677,10 @@ export const experimentPhaseManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Update an experiment phase
|
||||
// Update an experiment book
|
||||
async updateExperimentPhase(id: string, updates: UpdateExperimentPhaseRequest): Promise<ExperimentPhase> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.from('experiment_books')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
@@ -619,10 +690,10 @@ export const experimentPhaseManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete an experiment phase
|
||||
// Delete an experiment book
|
||||
async deleteExperimentPhase(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.from('experiment_books')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
@@ -670,33 +741,170 @@ export const experimentManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new experiment
|
||||
// Get experiment with its phase config (soaking, airdrying, cracking, shelling) for edit form
|
||||
async getExperimentWithPhaseConfig(id: string): Promise<(Experiment & {
|
||||
soaking?: ExperimentSoakingConfig | null
|
||||
airdrying?: ExperimentAirdryingConfig | null
|
||||
cracking?: ExperimentCrackingConfig | null
|
||||
shelling?: ExperimentShellingConfig | null
|
||||
}) | null> {
|
||||
const experiment = await this.getExperimentById(id)
|
||||
if (!experiment) return null
|
||||
|
||||
const [soakingRes, airdryingRes, crackingRes, shellingRes] = await Promise.all([
|
||||
supabase.from('experiment_soaking').select('*').eq('experiment_id', id).maybeSingle(),
|
||||
supabase.from('experiment_airdrying').select('*').eq('experiment_id', id).maybeSingle(),
|
||||
supabase.from('experiment_cracking').select('*').eq('experiment_id', id).maybeSingle(),
|
||||
supabase.from('experiment_shelling').select('*').eq('experiment_id', id).maybeSingle()
|
||||
])
|
||||
if (soakingRes.error) throw soakingRes.error
|
||||
if (airdryingRes.error) throw airdryingRes.error
|
||||
if (crackingRes.error) throw crackingRes.error
|
||||
if (shellingRes.error) throw shellingRes.error
|
||||
|
||||
return {
|
||||
...experiment,
|
||||
soaking: soakingRes.data ?? null,
|
||||
airdrying: airdryingRes.data ?? null,
|
||||
cracking: crackingRes.data ?? null,
|
||||
shelling: shellingRes.data ?? null
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new experiment and its phase config rows (experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
|
||||
async createExperiment(experimentData: CreateExperimentRequest): Promise<Experiment> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
const phaseId = experimentData.phase_id
|
||||
const corePayload = {
|
||||
experiment_number: experimentData.experiment_number,
|
||||
reps_required: experimentData.reps_required,
|
||||
weight_per_repetition_lbs: experimentData.weight_per_repetition_lbs,
|
||||
results_status: experimentData.results_status ?? 'valid',
|
||||
completion_status: experimentData.completion_status ?? false,
|
||||
phase_id: phaseId,
|
||||
created_by: user.id
|
||||
}
|
||||
// phase_id required for phase configs
|
||||
if (!phaseId) {
|
||||
const { data, error } = await supabase.from('experiments').insert(corePayload).select().single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
const { data: experiment, error } = await supabase
|
||||
.from('experiments')
|
||||
.insert({
|
||||
...experimentData,
|
||||
created_by: user.id
|
||||
})
|
||||
.insert(corePayload)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
|
||||
const book = await experimentPhaseManagement.getExperimentPhaseById(phaseId)
|
||||
if (!book) return experiment
|
||||
|
||||
if (book.has_soaking && experimentData.soaking_duration_hr != null) {
|
||||
await supabase.from('experiment_soaking').insert({
|
||||
experiment_id: experiment.id,
|
||||
soaking_duration_hr: experimentData.soaking_duration_hr,
|
||||
created_by: user.id
|
||||
})
|
||||
}
|
||||
if (book.has_airdrying && experimentData.air_drying_time_min != null) {
|
||||
await supabase.from('experiment_airdrying').insert({
|
||||
experiment_id: experiment.id,
|
||||
duration_minutes: experimentData.air_drying_time_min,
|
||||
created_by: user.id
|
||||
})
|
||||
}
|
||||
if (book.has_cracking && book.cracking_machine_type_id) {
|
||||
const crackPayload: Record<string, unknown> = {
|
||||
experiment_id: experiment.id,
|
||||
machine_type_id: book.cracking_machine_type_id,
|
||||
created_by: user.id
|
||||
}
|
||||
if (experimentData.plate_contact_frequency_hz != null) crackPayload.plate_contact_frequency_hz = experimentData.plate_contact_frequency_hz
|
||||
if (experimentData.throughput_rate_pecans_sec != null) crackPayload.throughput_rate_pecans_sec = experimentData.throughput_rate_pecans_sec
|
||||
if (experimentData.crush_amount_in != null) crackPayload.crush_amount_in = experimentData.crush_amount_in
|
||||
if (experimentData.entry_exit_height_diff_in != null) crackPayload.entry_exit_height_diff_in = experimentData.entry_exit_height_diff_in
|
||||
if (experimentData.motor_speed_hz != null) crackPayload.motor_speed_hz = experimentData.motor_speed_hz
|
||||
if (experimentData.jig_displacement_inches != null) crackPayload.jig_displacement_inches = experimentData.jig_displacement_inches
|
||||
if (experimentData.spring_stiffness_nm != null) crackPayload.spring_stiffness_nm = experimentData.spring_stiffness_nm
|
||||
await supabase.from('experiment_cracking').insert(crackPayload)
|
||||
}
|
||||
if (book.has_shelling && (experimentData.ring_gap_inches != null || experimentData.drum_rpm != null)) {
|
||||
await supabase.from('experiment_shelling').insert({
|
||||
experiment_id: experiment.id,
|
||||
ring_gap_inches: experimentData.ring_gap_inches ?? null,
|
||||
drum_rpm: experimentData.drum_rpm ?? null,
|
||||
created_by: user.id
|
||||
})
|
||||
}
|
||||
|
||||
return experiment
|
||||
},
|
||||
|
||||
// Update an experiment
|
||||
// Update an experiment and upsert its phase config rows
|
||||
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const coreKeys = ['experiment_number', 'reps_required', 'weight_per_repetition_lbs', 'results_status', 'completion_status', 'phase_id'] as const
|
||||
const coreUpdates: Partial<UpdateExperimentRequest> = {}
|
||||
for (const k of coreKeys) {
|
||||
if (updates[k] !== undefined) coreUpdates[k] = updates[k]
|
||||
}
|
||||
if (Object.keys(coreUpdates).length > 0) {
|
||||
const { data, error } = await supabase.from('experiments').update(coreUpdates).eq('id', id).select().single()
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
if (updates.soaking_duration_hr !== undefined) {
|
||||
const { data: existing } = await supabase.from('experiment_soaking').select('id').eq('experiment_id', id).maybeSingle()
|
||||
if (existing) {
|
||||
await supabase.from('experiment_soaking').update({ soaking_duration_hr: updates.soaking_duration_hr, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
||||
} else {
|
||||
await supabase.from('experiment_soaking').insert({ experiment_id: id, soaking_duration_hr: updates.soaking_duration_hr, created_by: user.id })
|
||||
}
|
||||
}
|
||||
if (updates.air_drying_time_min !== undefined) {
|
||||
const { data: existing } = await supabase.from('experiment_airdrying').select('id').eq('experiment_id', id).maybeSingle()
|
||||
if (existing) {
|
||||
await supabase.from('experiment_airdrying').update({ duration_minutes: updates.air_drying_time_min, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
||||
} else {
|
||||
await supabase.from('experiment_airdrying').insert({ experiment_id: id, duration_minutes: updates.air_drying_time_min, created_by: user.id })
|
||||
}
|
||||
}
|
||||
const crackKeys = ['plate_contact_frequency_hz', 'throughput_rate_pecans_sec', 'crush_amount_in', 'entry_exit_height_diff_in', 'motor_speed_hz', 'jig_displacement_inches', 'spring_stiffness_nm'] as const
|
||||
const hasCrackUpdates = crackKeys.some(k => updates[k] !== undefined)
|
||||
if (hasCrackUpdates) {
|
||||
const { data: existing } = await supabase.from('experiment_cracking').select('id').eq('experiment_id', id).maybeSingle()
|
||||
const crackPayload: Record<string, unknown> = {}
|
||||
crackKeys.forEach(k => { if (updates[k] !== undefined) crackPayload[k] = updates[k] })
|
||||
if (Object.keys(crackPayload).length > 0) {
|
||||
if (existing) {
|
||||
await supabase.from('experiment_cracking').update({ ...crackPayload, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
||||
} else {
|
||||
const exp = await this.getExperimentById(id)
|
||||
const book = exp?.phase_id ? await experimentPhaseManagement.getExperimentPhaseById(exp.phase_id) : null
|
||||
if (book?.has_cracking && book.cracking_machine_type_id) {
|
||||
await supabase.from('experiment_cracking').insert({ experiment_id: id, machine_type_id: book.cracking_machine_type_id, ...crackPayload, created_by: user.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updates.ring_gap_inches !== undefined || updates.drum_rpm !== undefined) {
|
||||
const { data: existing } = await supabase.from('experiment_shelling').select('id').eq('experiment_id', id).maybeSingle()
|
||||
const shellPayload = { ring_gap_inches: updates.ring_gap_inches ?? null, drum_rpm: updates.drum_rpm ?? null }
|
||||
if (existing) {
|
||||
await supabase.from('experiment_shelling').update({ ...shellPayload, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
||||
} else {
|
||||
await supabase.from('experiment_shelling').insert({ experiment_id: id, ...shellPayload, created_by: user.id })
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from('experiments').select('*').eq('id', id).single()
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
@@ -739,13 +947,16 @@ export const experimentManagement = {
|
||||
|
||||
|
||||
|
||||
// Check if experiment number is unique
|
||||
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
|
||||
// Check if experiment number is unique within the same phase (experiment_number + phase_id must be unique)
|
||||
async isExperimentNumberUnique(experimentNumber: number, phaseId?: string, excludeId?: string): Promise<boolean> {
|
||||
let query = supabase
|
||||
.from('experiments')
|
||||
.select('id')
|
||||
.eq('experiment_number', experimentNumber)
|
||||
|
||||
if (phaseId) {
|
||||
query = query.eq('phase_id', phaseId)
|
||||
}
|
||||
if (excludeId) {
|
||||
query = query.neq('id', excludeId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user