diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index ec43b6a..8e4770a 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -3,6 +3,7 @@ import { Sidebar } from './Sidebar' import { TopNavbar } from './TopNavbar' import { DashboardHome } from './DashboardHome' import { UserManagement } from './UserManagement' +import { Experiments } from './Experiments' import { userManagement, type User } from '../lib/supabase' interface DashboardLayoutProps { @@ -65,16 +66,7 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) { ) } case 'experiments': - return ( -
-

Experiments

-
-
- Experiments module coming soon... -
-
-
- ) + return case 'analytics': return (
diff --git a/src/components/ExperimentForm.tsx b/src/components/ExperimentForm.tsx new file mode 100644 index 0000000..a0f4017 --- /dev/null +++ b/src/components/ExperimentForm.tsx @@ -0,0 +1,361 @@ +import { useState, useEffect } from 'react' +import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase' + +interface ExperimentFormProps { + initialData?: Partial + onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise + onCancel: () => void + isEditing?: boolean + loading?: boolean +} + +export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) { + const [formData, setFormData] = useState({ + 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>({}) + + const validateForm = (): boolean => { + const newErrors: Record = {} + + // 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 ( +
+ {/* Basic Information */} +
+
+ + 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 && ( +

{errors.experiment_number}

+ )} +
+ +
+ + 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 && ( +

{errors.reps_required}

+ )} +
+ +
+ + 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 && ( +

{errors.rep_number}

+ )} +
+ +
+ + 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 && ( +

{errors.soaking_duration_hr}

+ )} +
+
+ + {/* Process Parameters */} +
+

Process Parameters

+
+
+ + 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 && ( +

{errors.air_drying_time_min}

+ )} +
+ +
+ + 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 && ( +

{errors.plate_contact_frequency_hz}

+ )} +
+ +
+ + 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 && ( +

{errors.throughput_rate_pecans_sec}

+ )} +
+ +
+ + 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 && ( +

{errors.crush_amount_in}

+ )} +
+ +
+ + 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 && ( +

{errors.entry_exit_height_diff_in}

+ )} +

Positive values indicate entry is higher than exit

+
+
+
+ + {/* Status Fields (only show when editing) */} + {isEditing && ( +
+

Status

+
+
+ + +
+ +
+ + +
+
+
+ )} + + {/* Form Actions */} +
+ + +
+
+ ) +} diff --git a/src/components/ExperimentModal.tsx b/src/components/ExperimentModal.tsx new file mode 100644 index 0000000..1ff276c --- /dev/null +++ b/src/components/ExperimentModal.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+

+ {isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'} +

+ +
+ +
+ {/* Error Message */} + {error && ( +
+
+
+ + + +
+
+

Error

+
{error}
+
+
+
+ )} + + {/* Form */} + +
+
+
+ ) +} diff --git a/src/components/Experiments.tsx b/src/components/Experiments.tsx new file mode 100644 index 0000000..183dff1 --- /dev/null +++ b/src/components/Experiments.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showModal, setShowModal] = useState(false) + const [editingExperiment, setEditingExperiment] = useState(undefined) + const [currentUser, setCurrentUser] = useState(null) + const [filterStatus, setFilterStatus] = useState('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 ( +
+
+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+

Experiments

+

Manage pecan processing experiment definitions

+
+ {canManageExperiments && ( + + )} +
+
+ + {/* Error Message */} + {error && ( +
+
{error}
+
+ )} + + {/* Filters */} +
+
+ + +
+
+ + {/* Experiments Table */} +
+
+

+ Experiments ({filteredExperiments.length}) +

+

+ {canManageExperiments ? 'Click on any experiment to edit details' : 'View experiment definitions and status'} +

+
+
+ + + + + + + + + + {canManageExperiments && ( + + )} + + + + {filteredExperiments.map((experiment) => ( + handleEditExperiment(experiment) : undefined} + > + + + + + + + {canManageExperiments && ( + + )} + + ))} + +
+ Experiment # + + Repetitions + + Process Parameters + + Schedule Status + + Results Status + + Created + + Actions +
+ #{experiment.experiment_number} + + {experiment.rep_number} / {experiment.reps_required} + +
+
Soaking: {experiment.soaking_duration_hr}h
+
Drying: {experiment.air_drying_time_min}min
+
Frequency: {experiment.plate_contact_frequency_hz}Hz
+
+
+ + {experiment.schedule_status} + + + + {experiment.results_status} + + + {new Date(experiment.created_at).toLocaleDateString()} + +
+ + {currentUser?.roles.includes('admin') && ( + + )} +
+
+
+ + {filteredExperiments.length === 0 && ( +
+ + + +

No experiments found

+

+ {filterStatus === 'all' + ? 'Get started by creating your first experiment.' + : `No experiments with status "${filterStatus}".`} +

+ {canManageExperiments && filterStatus === 'all' && ( +
+ +
+ )} +
+ )} +
+ + {/* Modal */} + {showModal && ( + setShowModal(false)} + onExperimentSaved={handleExperimentSaved} + /> + )} +
+ ) +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index da643da..eddafba 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -9,6 +9,8 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey) // Database types for TypeScript export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder' export type UserStatus = 'active' | 'disabled' +export type ScheduleStatus = 'pending schedule' | 'scheduled' | 'canceled' | 'aborted' +export type ResultsStatus = 'valid' | 'invalid' export interface User { id: string @@ -26,6 +28,52 @@ export interface Role { 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const updates: Partial = {} + 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 { + 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 { + 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 + } +} diff --git a/supabase/migrations/20250720000003_experiments_table.sql b/supabase/migrations/20250720000003_experiments_table.sql new file mode 100644 index 0000000..d802734 --- /dev/null +++ b/supabase/migrations/20250720000003_experiments_table.sql @@ -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';