From 0d0c67d5c1332ef442333c08933ffbe2e929beba Mon Sep 17 00:00:00 2001 From: Alireza Vaezi Date: Wed, 23 Jul 2025 21:21:59 -0400 Subject: [PATCH] data entry and draft system work --- src/components/DashboardLayout.tsx | 12 +- src/components/DataEntry.tsx | 154 ++++ src/components/DataEntryInterface.tsx | 263 +++++++ src/components/DraftManager.tsx | 177 +++++ src/components/ExperimentForm.tsx | 29 +- src/components/Experiments.tsx | 11 + src/components/PhaseDataEntry.tsx | 719 ++++++++++++++++++ src/components/PhaseSelector.tsx | 230 ++++++ src/components/Sidebar.tsx | 2 +- src/lib/supabase.ts | 265 ++++++- .../20250720000003_experiments_table.sql | 3 + ...723000001_experiment_data_entry_system.sql | 263 +++++++ 12 files changed, 2110 insertions(+), 18 deletions(-) create mode 100644 src/components/DataEntry.tsx create mode 100644 src/components/DataEntryInterface.tsx create mode 100644 src/components/DraftManager.tsx create mode 100644 src/components/PhaseDataEntry.tsx create mode 100644 src/components/PhaseSelector.tsx create mode 100644 supabase/migrations/20250723000001_experiment_data_entry_system.sql diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 8e4770a..b80b53c 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -4,6 +4,7 @@ import { TopNavbar } from './TopNavbar' import { DashboardHome } from './DashboardHome' import { UserManagement } from './UserManagement' import { Experiments } from './Experiments' +import { DataEntry } from './DataEntry' import { userManagement, type User } from '../lib/supabase' interface DashboardLayoutProps { @@ -79,16 +80,7 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) { ) case 'data-entry': - return ( -
-

Data Entry

-
-
- Data entry module coming soon... -
-
-
- ) + return default: return } diff --git a/src/components/DataEntry.tsx b/src/components/DataEntry.tsx new file mode 100644 index 0000000..dc5793d --- /dev/null +++ b/src/components/DataEntry.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from 'react' +import { experimentManagement, userManagement, type Experiment, type User } from '../lib/supabase' +import { DataEntryInterface } from './DataEntryInterface' + +export function DataEntry() { + const [experiments, setExperiments] = useState([]) + const [selectedExperiment, setSelectedExperiment] = useState(null) + const [currentUser, setCurrentUser] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + 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 data') + console.error('Load data error:', err) + } finally { + setLoading(false) + } + } + + const handleExperimentSelect = (experiment: Experiment) => { + setSelectedExperiment(experiment) + } + + const handleBackToList = () => { + setSelectedExperiment(null) + } + + if (loading) { + return ( +
+
+
+
+

Loading experiments...

+
+
+
+ ) + } + + if (error) { + return ( +
+
+
{error}
+
+
+ ) + } + + if (selectedExperiment) { + return ( + + ) + } + + return ( +
+
+

Data Entry

+

+ Select an experiment to enter measurement data +

+
+ + {/* Experiments List */} +
+
+

+ Available Experiments ({experiments.length}) +

+

+ Click on any experiment to start entering data +

+
+ + {experiments.length === 0 ? ( +
+
+ No experiments available for data entry +
+
+ ) : ( +
    + {experiments.map((experiment) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/components/DataEntryInterface.tsx b/src/components/DataEntryInterface.tsx new file mode 100644 index 0000000..f51d21b --- /dev/null +++ b/src/components/DataEntryInterface.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect } from 'react' +import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type User, type ExperimentPhase } from '../lib/supabase' +import { DraftManager } from './DraftManager' +import { PhaseSelector } from './PhaseSelector' +import { PhaseDataEntry } from './PhaseDataEntry' + +interface DataEntryInterfaceProps { + experiment: Experiment + currentUser: User + onBack: () => void +} + +export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntryInterfaceProps) { + const [userDataEntries, setUserDataEntries] = useState([]) + const [selectedDataEntry, setSelectedDataEntry] = useState(null) + const [selectedPhase, setSelectedPhase] = useState(null) + const [showDraftManager, setShowDraftManager] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + loadUserDataEntries() + }, [experiment.id, currentUser.id]) + + const loadUserDataEntries = async () => { + try { + setLoading(true) + setError(null) + + const entries = await dataEntryManagement.getUserDataEntriesForExperiment(experiment.id) + setUserDataEntries(entries) + + // Auto-select the most recent draft or create a new one + const drafts = entries.filter(entry => entry.status === 'draft') + if (drafts.length > 0) { + setSelectedDataEntry(drafts[0]) + } else { + // Create a new draft entry + await handleCreateNewDraft() + } + } catch (err: any) { + setError(err.message || 'Failed to load data entries') + console.error('Load data entries error:', err) + } finally { + setLoading(false) + } + } + + const handleCreateNewDraft = async () => { + try { + const newEntry = await dataEntryManagement.createDataEntry({ + experiment_id: experiment.id, + entry_name: `Draft ${new Date().toLocaleString()}`, + status: 'draft' + }) + + setUserDataEntries(prev => [newEntry, ...prev]) + setSelectedDataEntry(newEntry) + setShowDraftManager(false) + } catch (err: any) { + setError(err.message || 'Failed to create new draft') + } + } + + const handleSelectDataEntry = (entry: ExperimentDataEntry) => { + setSelectedDataEntry(entry) + setShowDraftManager(false) + setSelectedPhase(null) + } + + const handleDeleteDraft = async (entryId: string) => { + try { + await dataEntryManagement.deleteDataEntry(entryId) + setUserDataEntries(prev => prev.filter(entry => entry.id !== entryId)) + + // If we deleted the currently selected entry, select another or create new + if (selectedDataEntry?.id === entryId) { + const remainingDrafts = userDataEntries.filter(entry => entry.id !== entryId && entry.status === 'draft') + if (remainingDrafts.length > 0) { + setSelectedDataEntry(remainingDrafts[0]) + } else { + await handleCreateNewDraft() + } + } + } catch (err: any) { + setError(err.message || 'Failed to delete draft') + } + } + + const handleSubmitEntry = async (entryId: string) => { + try { + const submittedEntry = await dataEntryManagement.submitDataEntry(entryId) + setUserDataEntries(prev => prev.map(entry => + entry.id === entryId ? submittedEntry : entry + )) + + // Create a new draft for continued work + await handleCreateNewDraft() + } catch (err: any) { + setError(err.message || 'Failed to submit entry') + } + } + + const handlePhaseSelect = (phase: ExperimentPhase) => { + setSelectedPhase(phase) + } + + const handleBackToPhases = () => { + setSelectedPhase(null) + } + + if (loading) { + return ( +
+
+
+
+

Loading data entries...

+
+
+
+ ) + } + + if (error) { + return ( +
+
+
{error}
+
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ +

+ Experiment #{experiment.experiment_number} +

+
+
+ + +
+
+ + {/* Experiment Details */} +
+
+
+ Repetitions: + {experiment.reps_required} +
+
+ Soaking Duration: + {experiment.soaking_duration_hr}h +
+
+ Air Drying: + {experiment.air_drying_time_min}min +
+
+ Status: + + {experiment.completion_status ? 'Completed' : 'In Progress'} + +
+
+ {experiment.scheduled_date && ( +
+ Scheduled: + + {new Date(experiment.scheduled_date).toLocaleString()} + +
+ )} +
+ + {/* Current Draft Info */} + {selectedDataEntry && ( +
+
+
+ Current Draft: + {selectedDataEntry.entry_name} + + Created: {new Date(selectedDataEntry.created_at).toLocaleString()} + +
+ +
+
+ )} +
+ + {/* Main Content */} + {showDraftManager ? ( + setShowDraftManager(false)} + /> + ) : selectedPhase && selectedDataEntry ? ( + { + // Refresh data entries to show updated timestamps + loadUserDataEntries() + }} + /> + ) : selectedDataEntry ? ( + + ) : ( +
+ No data entry selected. Please create a new draft or select an existing one. +
+ )} +
+ ) +} diff --git a/src/components/DraftManager.tsx b/src/components/DraftManager.tsx new file mode 100644 index 0000000..173ca3c --- /dev/null +++ b/src/components/DraftManager.tsx @@ -0,0 +1,177 @@ +import { type ExperimentDataEntry } from '../lib/supabase' + +interface DraftManagerProps { + userDataEntries: ExperimentDataEntry[] + selectedDataEntry: ExperimentDataEntry | null + onSelectEntry: (entry: ExperimentDataEntry) => void + onDeleteDraft: (entryId: string) => void + onCreateNew: () => void + onClose: () => void +} + +export function DraftManager({ + userDataEntries, + selectedDataEntry, + onSelectEntry, + onDeleteDraft, + onCreateNew, + onClose +}: DraftManagerProps) { + const drafts = userDataEntries.filter(entry => entry.status === 'draft') + const submitted = userDataEntries.filter(entry => entry.status === 'submitted') + + return ( +
+
+
+

Draft Manager

+ +
+
+ +
+ {/* Draft Entries */} +
+
+

+ Draft Entries ({drafts.length}) +

+ +
+ + {drafts.length === 0 ? ( +
+ + + +

No draft entries found

+

Create a new draft to start entering data

+
+ ) : ( +
+ {drafts.map((entry) => ( +
+
+
+
+

+ {entry.entry_name || 'Untitled Draft'} +

+ {selectedDataEntry?.id === entry.id && ( + + Current + + )} +
+
+
Created: {new Date(entry.created_at).toLocaleString()}
+
Last updated: {new Date(entry.updated_at).toLocaleString()}
+
+
+
+ + +
+
+
+ ))} +
+ )} +
+ + {/* Submitted Entries */} +
+

+ Submitted Entries ({submitted.length}) +

+ + {submitted.length === 0 ? ( +
+ + + +

No submitted entries found

+

Submit a draft to see it here

+
+ ) : ( +
+ {submitted.map((entry) => ( +
+
+
+
+

+ {entry.entry_name || 'Untitled Entry'} +

+ + Submitted + +
+
+
Created: {new Date(entry.created_at).toLocaleString()}
+ {entry.submitted_at && ( +
Submitted: {new Date(entry.submitted_at).toLocaleString()}
+ )} +
+
+
+ +
+
+
+ ))} +
+ )} +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/src/components/ExperimentForm.tsx b/src/components/ExperimentForm.tsx index e138bbd..20c7944 100644 --- a/src/components/ExperimentForm.tsx +++ b/src/components/ExperimentForm.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase' interface ExperimentFormProps { - initialData?: Partial + initialData?: Partial onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise onCancel: () => void isEditing?: boolean @@ -10,7 +10,7 @@ interface ExperimentFormProps { } export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ experiment_number: initialData?.experiment_number || 0, reps_required: initialData?.reps_required || 1, soaking_duration_hr: initialData?.soaking_duration_hr || 0, @@ -20,7 +20,8 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa 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' + results_status: initialData?.results_status || 'valid', + completion_status: initialData?.completion_status || false }) const [errors, setErrors] = useState>({}) @@ -93,7 +94,7 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa } } - const handleInputChange = (field: keyof typeof formData, value: string | number) => { + const handleInputChange = (field: keyof typeof formData, value: string | number | boolean) => { setFormData(prev => ({ ...prev, [field]: value @@ -325,6 +326,24 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa + +
+ +
+ handleInputChange('completion_status', e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+
)} diff --git a/src/components/Experiments.tsx b/src/components/Experiments.tsx index da1edf5..c1b2f70 100644 --- a/src/components/Experiments.tsx +++ b/src/components/Experiments.tsx @@ -224,6 +224,9 @@ export function Experiments() { Results Status + + Completion + Created @@ -287,6 +290,14 @@ export function Experiments() { {experiment.results_status} + + + {experiment.completion_status ? 'Completed' : 'In Progress'} + + {new Date(experiment.created_at).toLocaleDateString()} diff --git a/src/components/PhaseDataEntry.tsx b/src/components/PhaseDataEntry.tsx new file mode 100644 index 0000000..1cebbfb --- /dev/null +++ b/src/components/PhaseDataEntry.tsx @@ -0,0 +1,719 @@ +import { useState, useEffect, useCallback } from 'react' +import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase' + +interface PhaseDataEntryProps { + experiment: Experiment + dataEntry: ExperimentDataEntry + phase: ExperimentPhase + onBack: () => void + onDataSaved: () => void +} + +export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSaved }: PhaseDataEntryProps) { + const [phaseData, setPhaseData] = useState>({}) + const [diameterMeasurements, setDiameterMeasurements] = useState(Array(10).fill(0)) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [lastSaved, setLastSaved] = useState(null) + + // Auto-save interval (30 seconds) + const AUTO_SAVE_INTERVAL = 30000 + + const loadPhaseData = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const existingData = await dataEntryManagement.getPhaseData(dataEntry.id, phase) + + if (existingData) { + setPhaseData(existingData) + + // Load diameter measurements if they exist + if (existingData.diameter_measurements) { + const measurements = Array(10).fill(0) + existingData.diameter_measurements.forEach(measurement => { + if (measurement.measurement_number >= 1 && measurement.measurement_number <= 10) { + measurements[measurement.measurement_number - 1] = measurement.diameter_in + } + }) + setDiameterMeasurements(measurements) + } + } else { + // Initialize empty phase data + setPhaseData({ + data_entry_id: dataEntry.id, + phase_name: phase + }) + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load phase data' + setError(errorMessage) + console.error('Load phase data error:', err) + } finally { + setLoading(false) + } + }, [dataEntry.id, phase]) + + const autoSave = useCallback(async () => { + if (dataEntry.status === 'submitted') return // Don't auto-save submitted entries + + try { + await dataEntryManagement.autoSaveDraft(dataEntry.id, phase, phaseData) + + // Save diameter measurements if this is air-drying phase and we have measurements + if (phase === 'air-drying' && phaseData.id && diameterMeasurements.some(m => m > 0)) { + const validMeasurements = diameterMeasurements.filter(m => m > 0) + if (validMeasurements.length > 0) { + await dataEntryManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements) + + // Update average diameter + const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements) + setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter })) + } + } + + setLastSaved(new Date()) + } catch (error) { + console.warn('Auto-save failed:', error) + } + }, [dataEntry.id, dataEntry.status, phase, phaseData, diameterMeasurements]) + + useEffect(() => { + loadPhaseData() + }, [loadPhaseData]) + + // Auto-save effect + useEffect(() => { + if (!loading && phaseData.id) { + const interval = setInterval(() => { + autoSave() + }, AUTO_SAVE_INTERVAL) + + return () => clearInterval(interval) + } + }, [phaseData, diameterMeasurements, loading, autoSave]) + + const handleInputChange = (field: string, value: unknown) => { + setPhaseData(prev => ({ + ...prev, + [field]: value + })) + } + + const handleDiameterChange = (index: number, value: number) => { + const newMeasurements = [...diameterMeasurements] + newMeasurements[index] = value + setDiameterMeasurements(newMeasurements) + + // Calculate and update average + const validMeasurements = newMeasurements.filter(m => m > 0) + if (validMeasurements.length > 0) { + const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements) + handleInputChange('avg_pecan_diameter_in', avgDiameter) + } + } + + const handleSave = async () => { + try { + setSaving(true) + setError(null) + + // Save phase data + const savedData = await dataEntryManagement.upsertPhaseData(dataEntry.id, phase, phaseData) + setPhaseData(savedData) + + // Save diameter measurements if this is air-drying phase + if (phase === 'air-drying' && diameterMeasurements.some(m => m > 0)) { + await dataEntryManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements) + } + + setLastSaved(new Date()) + onDataSaved() + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to save data' + setError(errorMessage) + console.error('Save error:', err) + } finally { + setSaving(false) + } + } + + const getPhaseTitle = () => { + switch (phase) { + case 'pre-soaking': return 'Pre-Soaking Phase' + case 'air-drying': return 'Air-Drying Phase' + case 'cracking': return 'Cracking Phase' + case 'shelling': return 'Shelling Phase' + default: return 'Unknown Phase' + } + } + + const calculateSoakingEndTime = () => { + if (phaseData.soaking_start_time && experiment.soaking_duration_hr) { + const startTime = new Date(phaseData.soaking_start_time) + const endTime = new Date(startTime.getTime() + experiment.soaking_duration_hr * 60 * 60 * 1000) + return endTime.toISOString().slice(0, 16) // Format for datetime-local input + } + return '' + } + + const calculateAirDryingEndTime = () => { + if (phaseData.airdrying_start_time && experiment.air_drying_time_min) { + const startTime = new Date(phaseData.airdrying_start_time) + const endTime = new Date(startTime.getTime() + experiment.air_drying_time_min * 60 * 1000) + return endTime.toISOString().slice(0, 16) // Format for datetime-local input + } + return '' + } + + if (loading) { + return ( +
+
+
+

Loading phase data...

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ +

{getPhaseTitle()}

+
+
+ {lastSaved && ( + + Last saved: {lastSaved.toLocaleTimeString()} + + )} + +
+
+ + {error && ( +
+
{error}
+
+ )} + + {dataEntry.status === 'submitted' && ( +
+
+ This entry has been submitted and is read-only. Create a new draft to make changes. +
+
+ )} +
+ + {/* Phase-specific forms */} +
+ {phase === 'pre-soaking' && ( +
+

Pre-Soaking Measurements

+ +
+
+ + handleInputChange('batch_initial_weight_lbs', parseFloat(e.target.value) || null)} + 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" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+ +
+ + handleInputChange('initial_shell_moisture_pct', parseFloat(e.target.value) || null)} + 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" + placeholder="0.0" + disabled={dataEntry.status === 'submitted'} + /> +
+ +
+ + handleInputChange('initial_kernel_moisture_pct', parseFloat(e.target.value) || null)} + 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" + placeholder="0.0" + disabled={dataEntry.status === 'submitted'} + /> +
+ +
+ + handleInputChange('soaking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} + 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" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + {/* Calculated Soaking End Time */} + {phaseData.soaking_start_time && ( +
+ + +

+ Automatically calculated based on soaking duration ({experiment.soaking_duration_hr}h) +

+
+ )} +
+ )} + + {phase === 'air-drying' && ( +
+

Air-Drying Measurements

+ +
+
+ + handleInputChange('airdrying_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} + 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" + disabled={dataEntry.status === 'submitted'} + /> +
+ +
+ + handleInputChange('post_soak_weight_lbs', parseFloat(e.target.value) || null)} + 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" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+ +
+ + handleInputChange('post_soak_kernel_moisture_pct', parseFloat(e.target.value) || null)} + 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" + placeholder="0.0" + disabled={dataEntry.status === 'submitted'} + /> +
+ +
+ + handleInputChange('post_soak_shell_moisture_pct', parseFloat(e.target.value) || null)} + 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" + placeholder="0.0" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + {/* Calculated Air-Drying End Time */} + {phaseData.airdrying_start_time && ( +
+ + +

+ Automatically calculated based on air-drying duration ({experiment.air_drying_time_min} minutes) +

+
+ )} + + {/* Pecan Diameter Measurements */} +
+

Pecan Diameter Measurements (inches)

+
+ {diameterMeasurements.map((measurement, index) => ( +
+ + handleDiameterChange(index, parseFloat(e.target.value) || 0)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.000" + disabled={dataEntry.status === 'submitted'} + /> +
+ ))} +
+ + {/* Average Diameter Display */} +
+ + handleInputChange('avg_pecan_diameter_in', parseFloat(e.target.value) || null)} + 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" + placeholder="0.000" + disabled={dataEntry.status === 'submitted'} + /> +

+ Automatically calculated from individual measurements above +

+
+
+
+ )} + + {phase === 'cracking' && ( +
+

Cracking Phase

+ +
+
+ + handleInputChange('cracking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} + 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" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + {/* Machine Parameters Display */} +
+

Cracker Machine Parameters (Read-Only)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )} + + {phase === 'shelling' && ( +
+

Shelling Phase

+ +
+
+ + handleInputChange('shelling_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} + 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" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + {/* Bin Weights */} +
+

Bin Weights (lbs)

+
+
+ + handleInputChange('bin_1_weight_lbs', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('bin_2_weight_lbs', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('bin_3_weight_lbs', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('discharge_bin_weight_lbs', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+
+ + {/* Full Yield Weights */} +
+

Full Yield Weights (oz)

+
+
+ + handleInputChange('bin_1_full_yield_oz', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('bin_2_full_yield_oz', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('bin_3_full_yield_oz', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+
+ + {/* Half Yield Weights */} +
+

Half Yield Weights (oz)

+
+
+ + handleInputChange('bin_1_half_yield_oz', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('bin_2_half_yield_oz', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+ + handleInputChange('bin_3_half_yield_oz', parseFloat(e.target.value) || null)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="0.00" + disabled={dataEntry.status === 'submitted'} + /> +
+
+
+
+ )} +
+
+ ) +} diff --git a/src/components/PhaseSelector.tsx b/src/components/PhaseSelector.tsx new file mode 100644 index 0000000..4488f58 --- /dev/null +++ b/src/components/PhaseSelector.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from 'react' +import { dataEntryManagement, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase' + +interface PhaseSelectorProps { + dataEntry: ExperimentDataEntry + onPhaseSelect: (phase: ExperimentPhase) => void +} + +interface PhaseInfo { + name: ExperimentPhase + title: string + description: string + icon: string + color: string +} + +const phases: PhaseInfo[] = [ + { + name: 'pre-soaking', + title: 'Pre-Soaking', + description: 'Initial measurements before soaking process', + icon: '🌰', + color: 'bg-blue-500' + }, + { + name: 'air-drying', + title: 'Air-Drying', + description: 'Post-soak measurements and air-drying data', + icon: '💨', + color: 'bg-green-500' + }, + { + name: 'cracking', + title: 'Cracking', + description: 'Cracking process timing and parameters', + icon: '🔨', + color: 'bg-yellow-500' + }, + { + name: 'shelling', + title: 'Shelling', + description: 'Final measurements and yield data', + icon: '📊', + color: 'bg-purple-500' + } +] + +export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps) { + const [phaseData, setPhaseData] = useState>({ + 'pre-soaking': null, + 'air-drying': null, + 'cracking': null, + 'shelling': null + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadPhaseData() + }, [dataEntry.id]) + + const loadPhaseData = async () => { + try { + setLoading(true) + const allPhaseData = await dataEntryManagement.getPhaseDataForEntry(dataEntry.id) + + const phaseDataMap: Record = { + 'pre-soaking': null, + 'air-drying': null, + 'cracking': null, + 'shelling': null + } + + allPhaseData.forEach(data => { + phaseDataMap[data.phase_name] = data + }) + + setPhaseData(phaseDataMap) + } catch (error) { + console.error('Failed to load phase data:', error) + } finally { + setLoading(false) + } + } + + const getPhaseCompletionStatus = (phaseName: ExperimentPhase): 'empty' | 'partial' | 'complete' => { + const data = phaseData[phaseName] + if (!data) return 'empty' + + // Check if phase has any data + const hasAnyData = Object.entries(data).some(([key, value]) => { + if (['id', 'data_entry_id', 'phase_name', 'created_at', 'updated_at', 'diameter_measurements'].includes(key)) { + return false + } + return value !== null && value !== undefined && value !== '' + }) + + if (!hasAnyData) return 'empty' + + // For now, consider any data as partial completion + // You could implement more sophisticated completion logic here + return 'partial' + } + + const getStatusIcon = (status: 'empty' | 'partial' | 'complete') => { + switch (status) { + case 'empty': + return ( +
+ ) + case 'partial': + return ( +
+ + + +
+ ) + case 'complete': + return ( +
+ + + +
+ ) + } + } + + const getLastUpdated = (phaseName: ExperimentPhase): string | null => { + const data = phaseData[phaseName] + if (!data) return null + return new Date(data.updated_at).toLocaleString() + } + + if (loading) { + return ( +
+
+
+

Loading phase data...

+
+
+ ) + } + + return ( +
+
+

Select Experiment Phase

+

+ Click on any phase card to enter or edit data for that phase +

+
+ +
+ {phases.map((phase) => { + const status = getPhaseCompletionStatus(phase.name) + const lastUpdated = getLastUpdated(phase.name) + + return ( + + ) + })} +
+ + {/* Phase Navigation */} +
+

Phase Progress

+
+ {phases.map((phase, index) => { + const status = getPhaseCompletionStatus(phase.name) + return ( +
+ + {index < phases.length - 1 && ( + + + + )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 03d7511..cf002cb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ interface SidebarProps { interface MenuItem { id: string name: string - icon: JSX.Element + icon: React.ReactElement requiredRoles?: string[] } diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 74b1eee..7d943d1 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -40,6 +40,7 @@ export interface Experiment { entry_exit_height_diff_in: number schedule_status: ScheduleStatus results_status: ResultsStatus + completion_status: boolean scheduled_date?: string | null created_at: string updated_at: string @@ -57,6 +58,7 @@ export interface CreateExperimentRequest { entry_exit_height_diff_in: number schedule_status?: ScheduleStatus results_status?: ResultsStatus + completion_status?: boolean scheduled_date?: string | null } @@ -71,9 +73,95 @@ export interface UpdateExperimentRequest { entry_exit_height_diff_in?: number schedule_status?: ScheduleStatus results_status?: ResultsStatus + completion_status?: boolean scheduled_date?: string | null } +// Data Entry System Interfaces +export type DataEntryStatus = 'draft' | 'submitted' +export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling' + +export interface ExperimentDataEntry { + id: string + experiment_id: string + user_id: string + status: DataEntryStatus + entry_name?: string | null + created_at: string + updated_at: string + submitted_at?: string | null +} + +export interface PecanDiameterMeasurement { + id: string + phase_data_id: string + measurement_number: number + diameter_in: number + created_at: string +} + +export interface ExperimentPhaseData { + id: string + data_entry_id: string + phase_name: ExperimentPhase + + // Pre-soaking phase + batch_initial_weight_lbs?: number | null + initial_shell_moisture_pct?: number | null + initial_kernel_moisture_pct?: number | null + soaking_start_time?: string | null + + // Air-drying phase + airdrying_start_time?: string | null + post_soak_weight_lbs?: number | null + post_soak_kernel_moisture_pct?: number | null + post_soak_shell_moisture_pct?: number | null + avg_pecan_diameter_in?: number | null + + // Cracking phase + cracking_start_time?: string | null + + // Shelling phase + shelling_start_time?: string | null + bin_1_weight_lbs?: number | null + bin_2_weight_lbs?: number | null + bin_3_weight_lbs?: number | null + discharge_bin_weight_lbs?: number | null + bin_1_full_yield_oz?: number | null + bin_2_full_yield_oz?: number | null + bin_3_full_yield_oz?: number | null + bin_1_half_yield_oz?: number | null + bin_2_half_yield_oz?: number | null + bin_3_half_yield_oz?: number | null + + created_at: string + updated_at: string + + // Related data + diameter_measurements?: PecanDiameterMeasurement[] +} + +export interface CreateDataEntryRequest { + experiment_id: string + entry_name?: string + status?: DataEntryStatus +} + +export interface UpdateDataEntryRequest { + entry_name?: string + status?: DataEntryStatus +} + +export interface CreatePhaseDataRequest { + data_entry_id: string + phase_name: ExperimentPhase + [key: string]: any // For phase-specific data fields +} + +export interface UpdatePhaseDataRequest { + [key: string]: any // For phase-specific data fields +} + export interface UserRole { id: string user_id: string @@ -137,7 +225,7 @@ export const userManagement = { return { ...profile, - roles: userRoles.map(ur => ur.roles.name as RoleName) + roles: userRoles.map(ur => (ur.roles as any).name as RoleName) } }) ) @@ -252,7 +340,7 @@ export const userManagement = { return { ...profile, - roles: userRoles.map(ur => ur.roles.name as RoleName) + roles: userRoles.map(ur => (ur.roles as any).name as RoleName) } } } @@ -389,3 +477,176 @@ export const experimentManagement = { return data.length === 0 } } + +// Data Entry Management +export const dataEntryManagement = { + // Get all data entries for an experiment + async getDataEntriesForExperiment(experimentId: string): Promise { + const { data, error } = await supabase + .from('experiment_data_entries') + .select('*') + .eq('experiment_id', experimentId) + .order('created_at', { ascending: false }) + + if (error) throw error + return data + }, + + // Get user's data entries for an experiment + async getUserDataEntriesForExperiment(experimentId: string, userId?: string): Promise { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) throw new Error('User not authenticated') + + const targetUserId = userId || user.id + + const { data, error } = await supabase + .from('experiment_data_entries') + .select('*') + .eq('experiment_id', experimentId) + .eq('user_id', targetUserId) + .order('created_at', { ascending: false }) + + if (error) throw error + return data + }, + + // Create a new data entry + async createDataEntry(request: CreateDataEntryRequest): 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('experiment_data_entries') + .insert({ + ...request, + user_id: user.id + }) + .select() + .single() + + if (error) throw error + return data + }, + + // Update a data entry + async updateDataEntry(id: string, updates: UpdateDataEntryRequest): Promise { + const { data, error } = await supabase + .from('experiment_data_entries') + .update(updates) + .eq('id', id) + .select() + .single() + + if (error) throw error + return data + }, + + // Delete a data entry (only drafts) + async deleteDataEntry(id: string): Promise { + const { error } = await supabase + .from('experiment_data_entries') + .delete() + .eq('id', id) + + if (error) throw error + }, + + // Submit a data entry (change status from draft to submitted) + async submitDataEntry(id: string): Promise { + return this.updateDataEntry(id, { status: 'submitted' }) + }, + + // Get phase data for a data entry + async getPhaseDataForEntry(dataEntryId: string): Promise { + const { data, error } = await supabase + .from('experiment_phase_data') + .select(` + *, + diameter_measurements:pecan_diameter_measurements(*) + `) + .eq('data_entry_id', dataEntryId) + .order('phase_name') + + if (error) throw error + return data + }, + + // Get specific phase data + async getPhaseData(dataEntryId: string, phaseName: ExperimentPhase): Promise { + const { data, error } = await supabase + .from('experiment_phase_data') + .select(` + *, + diameter_measurements:pecan_diameter_measurements(*) + `) + .eq('data_entry_id', dataEntryId) + .eq('phase_name', phaseName) + .single() + + if (error) { + if (error.code === 'PGRST116') return null // Not found + throw error + } + return data + }, + + // Create or update phase data + async upsertPhaseData(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial): Promise { + const { data, error } = await supabase + .from('experiment_phase_data') + .upsert({ + data_entry_id: dataEntryId, + phase_name: phaseName, + ...phaseData + }, { + onConflict: 'data_entry_id,phase_name' + }) + .select() + .single() + + if (error) throw error + return data + }, + + // Save diameter measurements + async saveDiameterMeasurements(phaseDataId: string, measurements: number[]): Promise { + // First, delete existing measurements + await supabase + .from('pecan_diameter_measurements') + .delete() + .eq('phase_data_id', phaseDataId) + + // Then insert new measurements + const measurementData = measurements.map((diameter, index) => ({ + phase_data_id: phaseDataId, + measurement_number: index + 1, + diameter_in: diameter + })) + + const { data, error } = await supabase + .from('pecan_diameter_measurements') + .insert(measurementData) + .select() + + if (error) throw error + return data + }, + + // Calculate average diameter from measurements + calculateAverageDiameter(measurements: number[]): number { + if (measurements.length === 0) return 0 + const validMeasurements = measurements.filter(m => m > 0) + if (validMeasurements.length === 0) return 0 + return validMeasurements.reduce((sum, m) => sum + m, 0) / validMeasurements.length + }, + + // Auto-save draft data (for periodic saves) + async autoSaveDraft(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial): Promise { + try { + await this.upsertPhaseData(dataEntryId, phaseName, phaseData) + } catch (error) { + console.warn('Auto-save failed:', error) + // Don't throw error for auto-save failures + } + } +} diff --git a/supabase/migrations/20250720000003_experiments_table.sql b/supabase/migrations/20250720000003_experiments_table.sql index 0a3d204..70b1888 100644 --- a/supabase/migrations/20250720000003_experiments_table.sql +++ b/supabase/migrations/20250720000003_experiments_table.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS public.experiments ( 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')), + completion_status BOOLEAN NOT NULL DEFAULT false, 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) @@ -24,6 +25,7 @@ CREATE INDEX IF NOT EXISTS idx_experiments_experiment_number ON public.experimen 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_completion_status ON public.experiments(completion_status); CREATE INDEX IF NOT EXISTS idx_experiments_created_at ON public.experiments(created_at); -- Create trigger for updated_at @@ -98,3 +100,4 @@ COMMENT ON COLUMN public.experiments.crush_amount_in IS 'Crushing amount in thou 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'; +COMMENT ON COLUMN public.experiments.completion_status IS 'Boolean flag indicating if the experiment has been completed'; diff --git a/supabase/migrations/20250723000001_experiment_data_entry_system.sql b/supabase/migrations/20250723000001_experiment_data_entry_system.sql new file mode 100644 index 0000000..8a9866c --- /dev/null +++ b/supabase/migrations/20250723000001_experiment_data_entry_system.sql @@ -0,0 +1,263 @@ +-- Experiment Data Entry System Migration +-- Creates tables for collaborative data entry with draft functionality and phase-based organization + +-- Create experiment_data_entries table for main data entry records +CREATE TABLE IF NOT EXISTS public.experiment_data_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.user_profiles(id), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted')), + entry_name TEXT, -- Optional name for the entry (e.g., "Morning Run", "Batch A") + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted' + + -- Constraint: Only one submitted entry per user per experiment + CONSTRAINT unique_submitted_entry_per_user_experiment + EXCLUDE (experiment_id WITH =, user_id WITH =) + WHERE (status = 'submitted') +); + +-- Create experiment_phase_data table for phase-specific measurements +CREATE TABLE IF NOT EXISTS public.experiment_phase_data ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + data_entry_id UUID NOT NULL REFERENCES public.experiment_data_entries(id) ON DELETE CASCADE, + phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')), + + -- Pre-soaking phase data + batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0), + initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100), + initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100), + soaking_start_time TIMESTAMP WITH TIME ZONE, + + -- Air-drying phase data + airdrying_start_time TIMESTAMP WITH TIME ZONE, + post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0), + post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100), + post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100), + avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0), + + -- Cracking phase data + cracking_start_time TIMESTAMP WITH TIME ZONE, + + -- Shelling phase data + shelling_start_time TIMESTAMP WITH TIME ZONE, + bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0), + bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0), + bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0), + discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0), + bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0), + bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0), + bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0), + bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0), + bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0), + bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Constraint: One record per phase per data entry + CONSTRAINT unique_phase_per_data_entry UNIQUE (data_entry_id, phase_name) +); + +-- Create pecan_diameter_measurements table for individual diameter measurements +CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE, + measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10), + diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Constraint: Unique measurement number per phase data + CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_experiment_id ON public.experiment_data_entries(experiment_id); +CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_user_id ON public.experiment_data_entries(user_id); +CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_status ON public.experiment_data_entries(status); +CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_created_at ON public.experiment_data_entries(created_at); + +CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_entry_id ON public.experiment_phase_data(data_entry_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name); + +CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id); + +-- Create triggers for updated_at +CREATE TRIGGER set_updated_at_experiment_data_entries + BEFORE UPDATE ON public.experiment_data_entries + FOR EACH ROW + EXECUTE FUNCTION public.handle_updated_at(); + +CREATE TRIGGER set_updated_at_experiment_phase_data + BEFORE UPDATE ON public.experiment_phase_data + FOR EACH ROW + EXECUTE FUNCTION public.handle_updated_at(); + +-- Create trigger to set submitted_at timestamp +CREATE OR REPLACE FUNCTION public.handle_data_entry_submission() +RETURNS TRIGGER AS $$ +BEGIN + -- Set submitted_at when status changes to 'submitted' + IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN + NEW.submitted_at = NOW(); + END IF; + + -- Clear submitted_at when status changes from 'submitted' to 'draft' + IF NEW.status = 'draft' AND OLD.status = 'submitted' THEN + NEW.submitted_at = NULL; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_submitted_at_experiment_data_entries + BEFORE UPDATE ON public.experiment_data_entries + FOR EACH ROW + EXECUTE FUNCTION public.handle_data_entry_submission(); + +-- Enable RLS on all tables +ALTER TABLE public.experiment_data_entries ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for experiment_data_entries table + +-- Policy: All authenticated users can view all data entries +CREATE POLICY "experiment_data_entries_select_policy" ON public.experiment_data_entries + FOR SELECT + TO authenticated + USING (true); + +-- Policy: All authenticated users can insert data entries +CREATE POLICY "experiment_data_entries_insert_policy" ON public.experiment_data_entries + FOR INSERT + TO authenticated + WITH CHECK (user_id = auth.uid()); + +-- Policy: Users can only update their own data entries +CREATE POLICY "experiment_data_entries_update_policy" ON public.experiment_data_entries + FOR UPDATE + TO authenticated + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +-- Policy: Users can only delete their own draft entries +CREATE POLICY "experiment_data_entries_delete_policy" ON public.experiment_data_entries + FOR DELETE + TO authenticated + USING (user_id = auth.uid() AND status = 'draft'); + +-- RLS Policies for experiment_phase_data table + +-- Policy: All authenticated users can view phase data +CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data + FOR SELECT + TO authenticated + USING (true); + +-- Policy: Users can insert phase data for their own data entries +CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data + FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_data_entries ede + WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() + ) + ); + +-- Policy: Users can update phase data for their own data entries +CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data + FOR UPDATE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_data_entries ede + WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_data_entries ede + WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() + ) + ); + +-- Policy: Users can delete phase data for their own draft entries +CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data + FOR DELETE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_data_entries ede + WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() AND ede.status = 'draft' + ) + ); + +-- RLS Policies for pecan_diameter_measurements table + +-- Policy: All authenticated users can view diameter measurements +CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements + FOR SELECT + TO authenticated + USING (true); + +-- Policy: Users can insert measurements for their own phase data +CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements + FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id + WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() + ) + ); + +-- Policy: Users can update measurements for their own phase data +CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements + FOR UPDATE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id + WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id + WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() + ) + ); + +-- Policy: Users can delete measurements for their own draft entries +CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements + FOR DELETE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id + WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() AND ede.status = 'draft' + ) + ); + +-- Add comments for documentation +COMMENT ON TABLE public.experiment_data_entries IS 'Main data entry records for experiments with draft/submitted status tracking'; +COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments'; +COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)'; + +COMMENT ON COLUMN public.experiment_data_entries.status IS 'Entry status: draft (editable) or submitted (final)'; +COMMENT ON COLUMN public.experiment_data_entries.entry_name IS 'Optional descriptive name for the data entry'; +COMMENT ON COLUMN public.experiment_data_entries.submitted_at IS 'Timestamp when entry was submitted (status changed to submitted)'; + +COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling'; +COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements'; + +COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)'; +COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches';