add new experiment
This commit is contained in:
@@ -3,6 +3,7 @@ import { Sidebar } from './Sidebar'
|
|||||||
import { TopNavbar } from './TopNavbar'
|
import { TopNavbar } from './TopNavbar'
|
||||||
import { DashboardHome } from './DashboardHome'
|
import { DashboardHome } from './DashboardHome'
|
||||||
import { UserManagement } from './UserManagement'
|
import { UserManagement } from './UserManagement'
|
||||||
|
import { Experiments } from './Experiments'
|
||||||
import { userManagement, type User } from '../lib/supabase'
|
import { userManagement, type User } from '../lib/supabase'
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
@@ -65,16 +66,7 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'experiments':
|
case 'experiments':
|
||||||
return (
|
return <Experiments />
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Experiments</h1>
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
|
||||||
<div className="text-sm text-blue-700">
|
|
||||||
Experiments module coming soon...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case 'analytics':
|
case 'analytics':
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
|||||||
361
src/components/ExperimentForm.tsx
Normal file
361
src/components/ExperimentForm.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||||
|
|
||||||
|
interface ExperimentFormProps {
|
||||||
|
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus }>
|
||||||
|
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
isEditing?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus }>({
|
||||||
|
experiment_number: initialData?.experiment_number || 0,
|
||||||
|
reps_required: initialData?.reps_required || 1,
|
||||||
|
rep_number: initialData?.rep_number || 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,
|
||||||
|
schedule_status: initialData?.schedule_status || 'pending schedule',
|
||||||
|
results_status: initialData?.results_status || 'valid'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
|
||||||
|
// Required field validation
|
||||||
|
if (!formData.experiment_number || formData.experiment_number <= 0) {
|
||||||
|
newErrors.experiment_number = 'Experiment number must be a positive integer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.reps_required || formData.reps_required <= 0) {
|
||||||
|
newErrors.reps_required = 'Repetitions required must be a positive integer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.rep_number || formData.rep_number <= 0) {
|
||||||
|
newErrors.rep_number = 'Repetition number must be a positive integer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.rep_number > formData.reps_required) {
|
||||||
|
newErrors.rep_number = 'Repetition number cannot exceed repetitions required'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.soaking_duration_hr < 0) {
|
||||||
|
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.air_drying_time_min < 0) {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(formData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Form submission error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof typeof formData, value: string | number) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Clear error for this field when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="experiment_number" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Experiment Number *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="experiment_number"
|
||||||
|
value={formData.experiment_number}
|
||||||
|
onChange={(e) => handleInputChange('experiment_number', parseInt(e.target.value) || 0)}
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.experiment_number ? 'border-red-300' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter unique experiment number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors.experiment_number && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.experiment_number}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reps_required" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Repetitions Required *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="reps_required"
|
||||||
|
value={formData.reps_required}
|
||||||
|
onChange={(e) => handleInputChange('reps_required', parseInt(e.target.value) || 1)}
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.reps_required ? 'border-red-300' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Total repetitions needed"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors.reps_required && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.reps_required}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rep_number" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Current Repetition Number *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rep_number"
|
||||||
|
value={formData.rep_number}
|
||||||
|
onChange={(e) => handleInputChange('rep_number', parseInt(e.target.value) || 1)}
|
||||||
|
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.rep_number ? 'border-red-300' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Current repetition"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors.rep_number && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.rep_number}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={`w-full px-4 py-3 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>
|
||||||
|
|
||||||
|
{/* Process Parameters */}
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Process Parameters</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={`w-full px-4 py-3 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>
|
||||||
|
<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={`w-full px-4 py-3 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={`w-full px-4 py-3 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={`w-full px-4 py-3 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={`w-full px-4 py-3 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) */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Status</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="schedule_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Schedule Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="schedule_status"
|
||||||
|
value={formData.schedule_status}
|
||||||
|
onChange={(e) => handleInputChange('schedule_status', e.target.value as ScheduleStatus)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<option value="pending schedule">Pending Schedule</option>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="canceled">Canceled</option>
|
||||||
|
<option value="aborted">Aborted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="results_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Results Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="results_status"
|
||||||
|
value={formData.results_status}
|
||||||
|
onChange={(e) => handleInputChange('results_status', e.target.value as ResultsStatus)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<option value="valid">Valid</option>
|
||||||
|
<option value="invalid">Invalid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex justify-end space-x-4 pt-6 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-6 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-3 border border-transparent rounded-lg 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 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (isEditing ? 'Updating...' : 'Creating...') : (isEditing ? 'Update Experiment' : 'Create Experiment')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/components/ExperimentModal.tsx
Normal file
110
src/components/ExperimentModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ExperimentForm } from './ExperimentForm'
|
||||||
|
import { experimentManagement } from '../lib/supabase'
|
||||||
|
import type { Experiment, CreateExperimentRequest, UpdateExperimentRequest } from '../lib/supabase'
|
||||||
|
|
||||||
|
interface ExperimentModalProps {
|
||||||
|
experiment?: Experiment
|
||||||
|
onClose: () => void
|
||||||
|
onExperimentSaved: (experiment: Experiment) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperimentModal({ experiment, onClose, onExperimentSaved }: ExperimentModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isEditing = !!experiment
|
||||||
|
|
||||||
|
const handleSubmit = async (data: CreateExperimentRequest | UpdateExperimentRequest) => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let savedExperiment: Experiment
|
||||||
|
|
||||||
|
if (isEditing && experiment) {
|
||||||
|
// Check if experiment number is unique (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)
|
||||||
|
if (!isUnique) {
|
||||||
|
setError('Experiment number already exists. Please choose a different number.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
savedExperiment = await experimentManagement.updateExperiment(experiment.id, data)
|
||||||
|
} else {
|
||||||
|
// Check if experiment number is unique for new experiments
|
||||||
|
const createData = data as CreateExperimentRequest
|
||||||
|
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number)
|
||||||
|
if (!isUnique) {
|
||||||
|
setError('Experiment number already exists. Please choose a different number.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedExperiment = await experimentManagement.createExperiment(createData)
|
||||||
|
}
|
||||||
|
|
||||||
|
onExperimentSaved(savedExperiment)
|
||||||
|
onClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || `Failed to ${isEditing ? 'update' : 'create'} experiment`)
|
||||||
|
console.error('Experiment save error:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-4xl mx-auto max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white flex items-center justify-between p-6 border-b border-gray-200 rounded-t-xl">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
|
{isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||||
|
<div className="mt-2 text-sm text-red-700">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<ExperimentForm
|
||||||
|
initialData={experiment}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isEditing={isEditing}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
314
src/components/Experiments.tsx
Normal file
314
src/components/Experiments.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { ExperimentModal } from './ExperimentModal'
|
||||||
|
import { experimentManagement, userManagement } from '../lib/supabase'
|
||||||
|
import type { Experiment, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||||
|
|
||||||
|
export function Experiments() {
|
||||||
|
const [experiments, setExperiments] = useState<Experiment[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editingExperiment, setEditingExperiment] = useState<Experiment | undefined>(undefined)
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||||
|
const [filterStatus, setFilterStatus] = useState<ScheduleStatus | 'all'>('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const [experimentsData, userData] = await Promise.all([
|
||||||
|
experimentManagement.getAllExperiments(),
|
||||||
|
userManagement.getCurrentUser()
|
||||||
|
])
|
||||||
|
|
||||||
|
setExperiments(experimentsData)
|
||||||
|
setCurrentUser(userData)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load experiments')
|
||||||
|
console.error('Load experiments error:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManageExperiments = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
|
||||||
|
|
||||||
|
const handleCreateExperiment = () => {
|
||||||
|
setEditingExperiment(undefined)
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditExperiment = (experiment: Experiment) => {
|
||||||
|
setEditingExperiment(experiment)
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExperimentSaved = (experiment: Experiment) => {
|
||||||
|
if (editingExperiment) {
|
||||||
|
// Update existing experiment
|
||||||
|
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp))
|
||||||
|
} else {
|
||||||
|
// Add new experiment
|
||||||
|
setExperiments(prev => [experiment, ...prev])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteExperiment = async (experiment: Experiment) => {
|
||||||
|
if (!currentUser?.roles.includes('admin')) {
|
||||||
|
alert('Only administrators can delete experiments.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete Experiment #${experiment.experiment_number}? This action cannot be undone.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await experimentManagement.deleteExperiment(experiment.id)
|
||||||
|
setExperiments(prev => prev.filter(exp => exp.id !== experiment.id))
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Failed to delete experiment: ${err.message}`)
|
||||||
|
console.error('Delete experiment error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusUpdate = async (experiment: Experiment, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus) => {
|
||||||
|
try {
|
||||||
|
const updatedExperiment = await experimentManagement.updateExperimentStatus(
|
||||||
|
experiment.id,
|
||||||
|
scheduleStatus,
|
||||||
|
resultsStatus
|
||||||
|
)
|
||||||
|
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? updatedExperiment : exp))
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Failed to update status: ${err.message}`)
|
||||||
|
console.error('Update status error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending schedule':
|
||||||
|
return 'bg-yellow-100 text-yellow-800'
|
||||||
|
case 'scheduled':
|
||||||
|
return 'bg-blue-100 text-blue-800'
|
||||||
|
case 'canceled':
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
case 'aborted':
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
case 'valid':
|
||||||
|
return 'bg-green-100 text-green-800'
|
||||||
|
case 'invalid':
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredExperiments = filterStatus === 'all'
|
||||||
|
? experiments
|
||||||
|
: experiments.filter(exp => exp.schedule_status === filterStatus)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Experiments</h1>
|
||||||
|
<p className="mt-2 text-gray-600">Manage pecan processing experiment definitions</p>
|
||||||
|
</div>
|
||||||
|
{canManageExperiments && (
|
||||||
|
<button
|
||||||
|
onClick={handleCreateExperiment}
|
||||||
|
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 Experiment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||||
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<label htmlFor="status-filter" className="text-sm font-medium text-gray-700">
|
||||||
|
Filter by Schedule Status:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="status-filter"
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value as ScheduleStatus | 'all')}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="pending schedule">Pending Schedule</option>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="canceled">Canceled</option>
|
||||||
|
<option value="aborted">Aborted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Experiments Table */}
|
||||||
|
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<div className="px-4 py-5 sm:px-6">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Experiments ({filteredExperiments.length})
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||||
|
{canManageExperiments ? 'Click on any experiment to edit details' : 'View experiment definitions and status'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<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">
|
||||||
|
Repetitions
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Process Parameters
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Schedule Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Results Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
{canManageExperiments && (
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{filteredExperiments.map((experiment) => (
|
||||||
|
<tr
|
||||||
|
key={experiment.id}
|
||||||
|
className={canManageExperiments ? "hover:bg-gray-50 cursor-pointer" : ""}
|
||||||
|
onClick={canManageExperiments ? () => handleEditExperiment(experiment) : undefined}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
#{experiment.experiment_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{experiment.rep_number} / {experiment.reps_required}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>Soaking: {experiment.soaking_duration_hr}h</div>
|
||||||
|
<div>Drying: {experiment.air_drying_time_min}min</div>
|
||||||
|
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz</div>
|
||||||
|
</div>
|
||||||
|
</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.schedule_status)}`}>
|
||||||
|
{experiment.schedule_status}
|
||||||
|
</span>
|
||||||
|
</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)}`}>
|
||||||
|
{experiment.results_status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(experiment.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
{canManageExperiments && (
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEditExperiment(experiment)
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{currentUser?.roles.includes('admin') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteExperiment(experiment)
|
||||||
|
}}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredExperiments.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<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-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{filterStatus === 'all'
|
||||||
|
? 'Get started by creating your first experiment.'
|
||||||
|
: `No experiments with status "${filterStatus}".`}
|
||||||
|
</p>
|
||||||
|
{canManageExperiments && filterStatus === 'all' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateExperiment}
|
||||||
|
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 Experiment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<ExperimentModal
|
||||||
|
experiment={editingExperiment}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onExperimentSaved={handleExperimentSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
|||||||
// Database types for TypeScript
|
// Database types for TypeScript
|
||||||
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
||||||
export type UserStatus = 'active' | 'disabled'
|
export type UserStatus = 'active' | 'disabled'
|
||||||
|
export type ScheduleStatus = 'pending schedule' | 'scheduled' | 'canceled' | 'aborted'
|
||||||
|
export type ResultsStatus = 'valid' | 'invalid'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,6 +28,52 @@ export interface Role {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Experiment {
|
||||||
|
id: string
|
||||||
|
experiment_number: number
|
||||||
|
reps_required: number
|
||||||
|
rep_number: 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
|
||||||
|
schedule_status: ScheduleStatus
|
||||||
|
results_status: ResultsStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
created_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExperimentRequest {
|
||||||
|
experiment_number: number
|
||||||
|
reps_required: number
|
||||||
|
rep_number: 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
|
||||||
|
schedule_status?: ScheduleStatus
|
||||||
|
results_status?: ResultsStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateExperimentRequest {
|
||||||
|
experiment_number?: number
|
||||||
|
reps_required?: number
|
||||||
|
rep_number?: 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
|
||||||
|
schedule_status?: ScheduleStatus
|
||||||
|
results_status?: ResultsStatus
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserRole {
|
export interface UserRole {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
@@ -208,3 +256,116 @@ export const userManagement = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Experiment management utility functions
|
||||||
|
export const experimentManagement = {
|
||||||
|
// Get all experiments
|
||||||
|
async getAllExperiments(): Promise<Experiment[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get experiment by ID
|
||||||
|
async getExperimentById(id: string): Promise<Experiment | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') return null // Not found
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a new experiment
|
||||||
|
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
|
||||||
|
.from('experiments')
|
||||||
|
.insert({
|
||||||
|
...experimentData,
|
||||||
|
created_by: user.id
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update an experiment
|
||||||
|
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete an experiment (admin only)
|
||||||
|
async deleteExperiment(id: string): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update experiment status
|
||||||
|
async updateExperimentStatus(id: string, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment> {
|
||||||
|
const updates: Partial<UpdateExperimentRequest> = {}
|
||||||
|
if (scheduleStatus) updates.schedule_status = scheduleStatus
|
||||||
|
if (resultsStatus) updates.results_status = resultsStatus
|
||||||
|
|
||||||
|
return this.updateExperiment(id, updates)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get experiments by status
|
||||||
|
async getExperimentsByStatus(scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment[]> {
|
||||||
|
let query = supabase.from('experiments').select('*')
|
||||||
|
|
||||||
|
if (scheduleStatus) {
|
||||||
|
query = query.eq('schedule_status', scheduleStatus)
|
||||||
|
}
|
||||||
|
if (resultsStatus) {
|
||||||
|
query = query.eq('results_status', resultsStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if experiment number is unique
|
||||||
|
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
|
||||||
|
let query = supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select('id')
|
||||||
|
.eq('experiment_number', experimentNumber)
|
||||||
|
|
||||||
|
if (excludeId) {
|
||||||
|
query = query.neq('id', excludeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data.length === 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
102
supabase/migrations/20250720000003_experiments_table.sql
Normal file
102
supabase/migrations/20250720000003_experiments_table.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
-- Experiments Table Migration
|
||||||
|
-- Creates the experiments table for managing pecan processing experiment definitions
|
||||||
|
|
||||||
|
-- Create experiments table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.experiments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
experiment_number INTEGER UNIQUE NOT NULL,
|
||||||
|
reps_required INTEGER NOT NULL CHECK (reps_required > 0),
|
||||||
|
rep_number INTEGER NOT NULL CHECK (rep_number > 0),
|
||||||
|
soaking_duration_hr FLOAT NOT NULL CHECK (soaking_duration_hr >= 0),
|
||||||
|
air_drying_time_min INTEGER NOT NULL CHECK (air_drying_time_min >= 0),
|
||||||
|
plate_contact_frequency_hz FLOAT NOT NULL CHECK (plate_contact_frequency_hz > 0),
|
||||||
|
throughput_rate_pecans_sec FLOAT NOT NULL CHECK (throughput_rate_pecans_sec > 0),
|
||||||
|
crush_amount_in FLOAT NOT NULL CHECK (crush_amount_in >= 0),
|
||||||
|
entry_exit_height_diff_in FLOAT NOT NULL,
|
||||||
|
schedule_status TEXT NOT NULL DEFAULT 'pending schedule' CHECK (schedule_status IN ('pending schedule', 'scheduled', 'canceled', 'aborted')),
|
||||||
|
results_status TEXT NOT NULL DEFAULT 'valid' CHECK (results_status IN ('valid', 'invalid')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES public.user_profiles(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiments_experiment_number ON public.experiments(experiment_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiments_created_by ON public.experiments(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiments_schedule_status ON public.experiments(schedule_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiments_results_status ON public.experiments(results_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiments_created_at ON public.experiments(created_at);
|
||||||
|
|
||||||
|
-- Create trigger for updated_at
|
||||||
|
CREATE TRIGGER set_updated_at_experiments
|
||||||
|
BEFORE UPDATE ON public.experiments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Enable RLS on experiments table
|
||||||
|
ALTER TABLE public.experiments ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Helper function to check if user has admin or conductor role
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_manage_experiments()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_profiles up
|
||||||
|
JOIN public.user_roles ur ON up.id = ur.user_id
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE up.id = auth.uid()
|
||||||
|
AND r.name IN ('admin', 'conductor')
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- RLS Policies for experiments table
|
||||||
|
|
||||||
|
-- Policy: All authenticated users can view experiments
|
||||||
|
CREATE POLICY "experiments_select_policy" ON public.experiments
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- Policy: Only admin and conductor roles can insert experiments
|
||||||
|
CREATE POLICY "experiments_insert_policy" ON public.experiments
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (public.can_manage_experiments());
|
||||||
|
|
||||||
|
-- Policy: Only admin and conductor roles can update experiments
|
||||||
|
CREATE POLICY "experiments_update_policy" ON public.experiments
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (public.can_manage_experiments())
|
||||||
|
WITH CHECK (public.can_manage_experiments());
|
||||||
|
|
||||||
|
-- Policy: Only admin role can delete experiments
|
||||||
|
CREATE POLICY "experiments_delete_policy" ON public.experiments
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_profiles up
|
||||||
|
JOIN public.user_roles ur ON up.id = ur.user_id
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE up.id = auth.uid()
|
||||||
|
AND r.name = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add comment to table for documentation
|
||||||
|
COMMENT ON TABLE public.experiments IS 'Stores experiment definitions for pecan processing with parameters and status tracking';
|
||||||
|
COMMENT ON COLUMN public.experiments.experiment_number IS 'User-defined unique experiment identifier';
|
||||||
|
COMMENT ON COLUMN public.experiments.reps_required IS 'Total number of repetitions needed for this experiment';
|
||||||
|
COMMENT ON COLUMN public.experiments.rep_number IS 'Current repetition number for this entry';
|
||||||
|
COMMENT ON COLUMN public.experiments.soaking_duration_hr IS 'Soaking process duration in hours';
|
||||||
|
COMMENT ON COLUMN public.experiments.air_drying_time_min IS 'Air drying duration in minutes';
|
||||||
|
COMMENT ON COLUMN public.experiments.plate_contact_frequency_hz IS 'JC Cracker machine plate contact frequency in Hz';
|
||||||
|
COMMENT ON COLUMN public.experiments.throughput_rate_pecans_sec IS 'Pecan processing rate in pecans per second';
|
||||||
|
COMMENT ON COLUMN public.experiments.crush_amount_in IS 'Crushing amount in thousandths of an inch';
|
||||||
|
COMMENT ON COLUMN public.experiments.entry_exit_height_diff_in IS 'Height difference between entry/exit points in inches (can be negative)';
|
||||||
|
COMMENT ON COLUMN public.experiments.schedule_status IS 'Current scheduling status of the experiment';
|
||||||
|
COMMENT ON COLUMN public.experiments.results_status IS 'Validity status of experiment results';
|
||||||
Reference in New Issue
Block a user