data entry and draft system work

This commit is contained in:
Alireza Vaezi
2025-07-23 21:21:59 -04:00
parent 511ed848a3
commit 0d0c67d5c1
12 changed files with 2110 additions and 18 deletions

View File

@@ -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) {
</div>
)
case 'data-entry':
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Data Entry</h1>
<div className="bg-purple-50 border border-purple-200 rounded-md p-4">
<div className="text-sm text-purple-700">
Data entry module coming soon...
</div>
</div>
</div>
)
return <DataEntry />
default:
return <DashboardHome user={user} />
}

View File

@@ -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<Experiment[]>([])
const [selectedExperiment, setSelectedExperiment] = useState<Experiment | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading experiments...</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
</div>
)
}
if (selectedExperiment) {
return (
<DataEntryInterface
experiment={selectedExperiment}
currentUser={currentUser!}
onBack={handleBackToList}
/>
)
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Entry</h1>
<p className="mt-1 text-sm text-gray-500">
Select an experiment to enter measurement data
</p>
</div>
{/* Experiments List */}
<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">
Available Experiments ({experiments.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Click on any experiment to start entering data
</p>
</div>
{experiments.length === 0 ? (
<div className="px-4 py-5 sm:px-6">
<div className="text-center text-gray-500">
No experiments available for data entry
</div>
</div>
) : (
<ul className="divide-y divide-gray-200">
{experiments.map((experiment) => (
<li key={experiment.id}>
<button
onClick={() => handleExperimentSelect(experiment)}
className="w-full text-left px-4 py-4 hover:bg-gray-50 focus:outline-none focus:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<div className="text-sm font-medium text-gray-900">
Experiment #{experiment.experiment_number}
</div>
<div className="ml-2 flex-shrink-0">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${experiment.completion_status
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{experiment.completion_status ? 'Completed' : 'In Progress'}
</span>
</div>
</div>
<div className="mt-1 text-sm text-gray-500">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>Reps: {experiment.reps_required}</div>
<div>Soaking: {experiment.soaking_duration_hr}h</div>
<div>Drying: {experiment.air_drying_time_min}min</div>
<div>Status: {experiment.schedule_status}</div>
</div>
{experiment.scheduled_date && (
<div className="mt-1 text-xs text-gray-400">
Scheduled: {new Date(experiment.scheduled_date).toLocaleString()}
</div>
)}
</div>
</div>
<div className="ml-4 flex-shrink-0">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -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<ExperimentDataEntry[]>([])
const [selectedDataEntry, setSelectedDataEntry] = useState<ExperimentDataEntry | null>(null)
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
const [showDraftManager, setShowDraftManager] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading data entries...</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<div className="text-sm text-red-700">{error}</div>
</div>
<button
onClick={onBack}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
Back to Experiments
</button>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Experiments
</button>
<h1 className="text-3xl font-bold text-gray-900">
Experiment #{experiment.experiment_number}
</h1>
</div>
<div className="text-right">
<button
onClick={() => setShowDraftManager(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 mr-2"
>
Manage Drafts
</button>
<button
onClick={handleCreateNewDraft}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
New Draft
</button>
</div>
</div>
{/* Experiment Details */}
<div className="mt-4 bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Repetitions:</span>
<span className="ml-1 text-gray-900">{experiment.reps_required}</span>
</div>
<div>
<span className="font-medium text-gray-700">Soaking Duration:</span>
<span className="ml-1 text-gray-900">{experiment.soaking_duration_hr}h</span>
</div>
<div>
<span className="font-medium text-gray-700">Air Drying:</span>
<span className="ml-1 text-gray-900">{experiment.air_drying_time_min}min</span>
</div>
<div>
<span className="font-medium text-gray-700">Status:</span>
<span className={`ml-1 ${experiment.completion_status ? 'text-green-600' : 'text-yellow-600'}`}>
{experiment.completion_status ? 'Completed' : 'In Progress'}
</span>
</div>
</div>
{experiment.scheduled_date && (
<div className="mt-2 text-sm">
<span className="font-medium text-gray-700">Scheduled:</span>
<span className="ml-1 text-gray-900">
{new Date(experiment.scheduled_date).toLocaleString()}
</span>
</div>
)}
</div>
{/* Current Draft Info */}
{selectedDataEntry && (
<div className="mt-4 bg-blue-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<span className="font-medium text-blue-700">Current Draft:</span>
<span className="ml-2 text-blue-900">{selectedDataEntry.entry_name}</span>
<span className="ml-2 text-sm text-blue-600">
Created: {new Date(selectedDataEntry.created_at).toLocaleString()}
</span>
</div>
<button
onClick={() => handleSubmitEntry(selectedDataEntry.id)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
>
Submit Entry
</button>
</div>
</div>
)}
</div>
{/* Main Content */}
{showDraftManager ? (
<DraftManager
userDataEntries={userDataEntries}
selectedDataEntry={selectedDataEntry}
onSelectEntry={handleSelectDataEntry}
onDeleteDraft={handleDeleteDraft}
onCreateNew={handleCreateNewDraft}
onClose={() => setShowDraftManager(false)}
/>
) : selectedPhase && selectedDataEntry ? (
<PhaseDataEntry
experiment={experiment}
dataEntry={selectedDataEntry}
phase={selectedPhase}
onBack={handleBackToPhases}
onDataSaved={() => {
// Refresh data entries to show updated timestamps
loadUserDataEntries()
}}
/>
) : selectedDataEntry ? (
<PhaseSelector
dataEntry={selectedDataEntry}
onPhaseSelect={handlePhaseSelect}
/>
) : (
<div className="text-center text-gray-500">
No data entry selected. Please create a new draft or select an existing one.
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="bg-white rounded-lg shadow-lg">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">Draft Manager</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-6">
{/* Draft Entries */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-md font-medium text-gray-900">
Draft Entries ({drafts.length})
</h3>
<button
onClick={onCreateNew}
className="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
Create New Draft
</button>
</div>
{drafts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>No draft entries found</p>
<p className="text-sm mt-1">Create a new draft to start entering data</p>
</div>
) : (
<div className="space-y-3">
{drafts.map((entry) => (
<div
key={entry.id}
className={`border rounded-lg p-4 ${
selectedDataEntry?.id === entry.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900">
{entry.entry_name || 'Untitled Draft'}
</h4>
{selectedDataEntry?.id === entry.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Current
</span>
)}
</div>
<div className="mt-1 text-xs text-gray-500">
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
<div>Last updated: {new Date(entry.updated_at).toLocaleString()}</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onSelectEntry(entry)}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
{selectedDataEntry?.id === entry.id ? 'Continue' : 'Select'}
</button>
<button
onClick={() => onDeleteDraft(entry.id)}
className="px-3 py-1 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Submitted Entries */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-4">
Submitted Entries ({submitted.length})
</h3>
{submitted.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No submitted entries found</p>
<p className="text-sm mt-1">Submit a draft to see it here</p>
</div>
) : (
<div className="space-y-3">
{submitted.map((entry) => (
<div
key={entry.id}
className="border border-green-200 bg-green-50 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900">
{entry.entry_name || 'Untitled Entry'}
</h4>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Submitted
</span>
</div>
<div className="mt-1 text-xs text-gray-500">
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
{entry.submitted_at && (
<div>Submitted: {new Date(entry.submitted_at).toLocaleString()}</div>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onSelectEntry(entry)}
className="px-3 py-1 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
>
View
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
Close
</button>
</div>
</div>
</div>
)
}

View File

@@ -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<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus }>
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
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<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus }>({
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
experiment_number: initialData?.experiment_number || 0,
reps_required: initialData?.reps_required || 1,
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<Record<string, string>>({})
@@ -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
<option value="invalid">Invalid</option>
</select>
</div>
<div>
<label htmlFor="completion_status" className="block text-sm font-medium text-gray-700 mb-2">
Completion Status
</label>
<div className="flex items-center">
<input
type="checkbox"
id="completion_status"
checked={formData.completion_status}
onChange={(e) => handleInputChange('completion_status', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="completion_status" className="ml-2 text-sm text-gray-700">
Mark as completed
</label>
</div>
</div>
</div>
</div>
)}

View File

@@ -224,6 +224,9 @@ export function Experiments() {
<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">
Completion
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
@@ -287,6 +290,14 @@ export function Experiments() {
{experiment.results_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 ${experiment.completion_status
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{experiment.completion_status ? 'Completed' : 'In Progress'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(experiment.created_at).toLocaleDateString()}
</td>

View File

@@ -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<Partial<ExperimentPhaseData>>({})
const [diameterMeasurements, setDiameterMeasurements] = useState<number[]>(Array(10).fill(0))
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastSaved, setLastSaved] = useState<Date | null>(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 (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading phase data...</p>
</div>
</div>
)
}
return (
<div>
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Phases
</button>
<h2 className="text-2xl font-bold text-gray-900">{getPhaseTitle()}</h2>
</div>
<div className="flex items-center space-x-4">
{lastSaved && (
<span className="text-sm text-gray-500">
Last saved: {lastSaved.toLocaleTimeString()}
</span>
)}
<button
onClick={handleSave}
disabled={saving || dataEntry.status === 'submitted'}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{dataEntry.status === 'submitted' && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
This entry has been submitted and is read-only. Create a new draft to make changes.
</div>
</div>
)}
</div>
{/* Phase-specific forms */}
<div className="bg-white rounded-lg shadow-md p-6">
{phase === 'pre-soaking' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Pre-Soaking Measurements</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Batch Initial Weight (lbs) *
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.batch_initial_weight_lbs || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Initial Shell Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.initial_shell_moisture_pct || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Initial Kernel Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.initial_kernel_moisture_pct || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Soaking Start Time *
</label>
<input
type="datetime-local"
value={phaseData.soaking_start_time ? new Date(phaseData.soaking_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => 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'}
/>
</div>
</div>
{/* Calculated Soaking End Time */}
{phaseData.soaking_start_time && (
<div className="bg-gray-50 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Soaking End Time (Calculated)
</label>
<input
type="datetime-local"
value={calculateSoakingEndTime()}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated based on soaking duration ({experiment.soaking_duration_hr}h)
</p>
</div>
)}
</div>
)}
{phase === 'air-drying' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Air-Drying Measurements</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Air-Drying Start Time *
</label>
<input
type="datetime-local"
value={phaseData.airdrying_start_time ? new Date(phaseData.airdrying_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Post-Soak Weight (lbs)
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.post_soak_weight_lbs || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Post-Soak Kernel Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.post_soak_kernel_moisture_pct || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Post-Soak Shell Moisture (%)
</label>
<input
type="number"
step="0.1"
min="0"
max="100"
value={phaseData.post_soak_shell_moisture_pct || ''}
onChange={(e) => 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'}
/>
</div>
</div>
{/* Calculated Air-Drying End Time */}
{phaseData.airdrying_start_time && (
<div className="bg-gray-50 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Air-Drying End Time (Calculated)
</label>
<input
type="datetime-local"
value={calculateAirDryingEndTime()}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated based on air-drying duration ({experiment.air_drying_time_min} minutes)
</p>
</div>
)}
{/* Pecan Diameter Measurements */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Pecan Diameter Measurements (inches)</h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{diameterMeasurements.map((measurement, index) => (
<div key={index}>
<label className="block text-sm font-medium text-gray-700 mb-1">
Measurement {index + 1}
</label>
<input
type="number"
step="0.001"
min="0"
value={measurement || ''}
onChange={(e) => 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'}
/>
</div>
))}
</div>
{/* Average Diameter Display */}
<div className="mt-4 bg-blue-50 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Average Pecan Diameter (Calculated)
</label>
<input
type="number"
step="0.001"
value={phaseData.avg_pecan_diameter_in || ''}
onChange={(e) => 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'}
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated from individual measurements above
</p>
</div>
</div>
</div>
)}
{phase === 'cracking' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Cracking Phase</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cracking Start Time *
</label>
<input
type="datetime-local"
value={phaseData.cracking_start_time ? new Date(phaseData.cracking_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => 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'}
/>
</div>
</div>
{/* Machine Parameters Display */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-md font-medium text-gray-700 mb-3">Cracker Machine Parameters (Read-Only)</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Plate Contact Frequency (Hz)
</label>
<input
type="number"
value={experiment.plate_contact_frequency_hz}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Throughput Rate (pecans/sec)
</label>
<input
type="number"
value={experiment.throughput_rate_pecans_sec}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Crush Amount (inches)
</label>
<input
type="number"
value={experiment.crush_amount_in}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Entry/Exit Height Difference (inches)
</label>
<input
type="number"
value={experiment.entry_exit_height_diff_in}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
disabled
/>
</div>
</div>
</div>
</div>
)}
{phase === 'shelling' && (
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Shelling Phase</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Shelling Start Time *
</label>
<input
type="datetime-local"
value={phaseData.shelling_start_time ? new Date(phaseData.shelling_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => 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'}
/>
</div>
</div>
{/* Bin Weights */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Bin Weights (lbs)</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 1 Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_1_weight_lbs || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 2 Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_2_weight_lbs || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 3 Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_3_weight_lbs || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Discharge Bin Weight
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.discharge_bin_weight_lbs || ''}
onChange={(e) => 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'}
/>
</div>
</div>
</div>
{/* Full Yield Weights */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Full Yield Weights (oz)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 1 Full Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_1_full_yield_oz || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 2 Full Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_2_full_yield_oz || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 3 Full Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_3_full_yield_oz || ''}
onChange={(e) => 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'}
/>
</div>
</div>
</div>
{/* Half Yield Weights */}
<div className="border-t pt-6">
<h4 className="text-md font-medium text-gray-900 mb-4">Half Yield Weights (oz)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 1 Half Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_1_half_yield_oz || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 2 Half Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_2_half_yield_oz || ''}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bin 3 Half Yield
</label>
<input
type="number"
step="0.01"
min="0"
value={phaseData.bin_3_half_yield_oz || ''}
onChange={(e) => 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'}
/>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -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<Record<ExperimentPhase, ExperimentPhaseData | null>>({
'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<ExperimentPhase, ExperimentPhaseData | null> = {
'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 (
<div className="w-6 h-6 rounded-full border-2 border-gray-300 bg-white"></div>
)
case 'partial':
return (
<div className="w-6 h-6 rounded-full bg-yellow-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)
case 'complete':
return (
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)
}
}
const getLastUpdated = (phaseName: ExperimentPhase): string | null => {
const data = phaseData[phaseName]
if (!data) return null
return new Date(data.updated_at).toLocaleString()
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading phase data...</p>
</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">Select Experiment Phase</h2>
<p className="text-gray-600">
Click on any phase card to enter or edit data for that phase
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{phases.map((phase) => {
const status = getPhaseCompletionStatus(phase.name)
const lastUpdated = getLastUpdated(phase.name)
return (
<button
key={phase.name}
onClick={() => onPhaseSelect(phase.name)}
className="text-left p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200 hover:border-gray-300"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className={`w-12 h-12 rounded-lg ${phase.color} flex items-center justify-center text-white text-xl mr-4`}>
{phase.icon}
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">{phase.title}</h3>
<p className="text-sm text-gray-500">{phase.description}</p>
</div>
</div>
<div className="flex flex-col items-end">
{getStatusIcon(status)}
<span className="text-xs text-gray-400 mt-1">
{status === 'empty' ? 'No data' : status === 'partial' ? 'In progress' : 'Complete'}
</span>
</div>
</div>
{lastUpdated && (
<div className="text-xs text-gray-400">
Last updated: {lastUpdated}
</div>
)}
<div className="mt-4 flex items-center text-blue-600">
<span className="text-sm font-medium">
{status === 'empty' ? 'Start entering data' : 'Continue editing'}
</span>
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
)
})}
</div>
{/* Phase Navigation */}
<div className="mt-8 bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Phase Progress</h3>
<div className="flex items-center space-x-4">
{phases.map((phase, index) => {
const status = getPhaseCompletionStatus(phase.name)
return (
<div key={phase.name} className="flex items-center">
<button
onClick={() => onPhaseSelect(phase.name)}
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-gray-100 transition-colors"
>
{getStatusIcon(status)}
<span className="text-sm text-gray-700">{phase.title}</span>
</button>
{index < phases.length - 1 && (
<svg className="w-4 h-4 text-gray-400 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -10,7 +10,7 @@ interface SidebarProps {
interface MenuItem {
id: string
name: string
icon: JSX.Element
icon: React.ReactElement
requiredRoles?: string[]
}

View File

@@ -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<ExperimentDataEntry[]> {
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<ExperimentDataEntry[]> {
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<ExperimentDataEntry> {
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<ExperimentDataEntry> {
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<void> {
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<ExperimentDataEntry> {
return this.updateDataEntry(id, { status: 'submitted' })
},
// Get phase data for a data entry
async getPhaseDataForEntry(dataEntryId: string): Promise<ExperimentPhaseData[]> {
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<ExperimentPhaseData | null> {
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<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
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<PecanDiameterMeasurement[]> {
// 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<ExperimentPhaseData>): Promise<void> {
try {
await this.upsertPhaseData(dataEntryId, phaseName, phaseData)
} catch (error) {
console.warn('Auto-save failed:', error)
// Don't throw error for auto-save failures
}
}
}

View File

@@ -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';

View File

@@ -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';