data entry and draft system work
This commit is contained in:
@@ -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} />
|
||||
}
|
||||
|
||||
154
src/components/DataEntry.tsx
Normal file
154
src/components/DataEntry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
src/components/DataEntryInterface.tsx
Normal file
263
src/components/DataEntryInterface.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/components/DraftManager.tsx
Normal file
177
src/components/DraftManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
719
src/components/PhaseDataEntry.tsx
Normal file
719
src/components/PhaseDataEntry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
src/components/PhaseSelector.tsx
Normal file
230
src/components/PhaseSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface SidebarProps {
|
||||
interface MenuItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: JSX.Element
|
||||
icon: React.ReactElement
|
||||
requiredRoles?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user