feat: Implement Vision System API Client with comprehensive endpoints and utility functions

- Added VisionApiClient class to interact with the vision system API.
- Defined interfaces for system status, machine status, camera status, recordings, and storage stats.
- Implemented methods for health checks, system status retrieval, camera control, and storage management.
- Introduced utility functions for formatting bytes, durations, and uptime.

test: Create manual verification script for Vision API functionality

- Added a test script to verify utility functions and API endpoints.
- Included tests for health check, system status, cameras, machines, and storage stats.

feat: Create experiment repetitions system migration

- Added experiment_repetitions table to manage experiment repetitions with scheduling.
- Implemented triggers and functions for validation and timestamp management.
- Established row-level security policies for user access control.

feat: Introduce phase-specific draft management system migration

- Created experiment_phase_drafts and experiment_phase_data tables for managing phase-specific drafts and measurements.
- Added pecan_diameter_measurements table for individual diameter measurements.
- Implemented row-level security policies for user access control.

fix: Adjust draft constraints to allow multiple drafts while preventing multiple submitted drafts

- Modified constraints on experiment_phase_drafts to allow multiple drafts in 'draft' or 'withdrawn' status.
- Ensured only one 'submitted' draft per user per phase per repetition.
This commit is contained in:
Alireza Vaezi
2025-07-28 16:30:56 -04:00
parent 0d0c67d5c1
commit d598281164
27 changed files with 4219 additions and 683 deletions

View File

@@ -39,7 +39,7 @@ export function DashboardHome({ user }: DashboardHomeProps) {
<div className="p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Welcome to the RBAC system</p>
<p className="mt-2 text-gray-600">Welcome to the Pecan Experiments Dashboard</p>
</div>
{/* User Information Card */}

View File

@@ -5,6 +5,7 @@ import { DashboardHome } from './DashboardHome'
import { UserManagement } from './UserManagement'
import { Experiments } from './Experiments'
import { DataEntry } from './DataEntry'
import { VisionSystem } from './VisionSystem'
import { userManagement, type User } from '../lib/supabase'
interface DashboardLayoutProps {
@@ -81,6 +82,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
)
case 'data-entry':
return <DataEntry />
case 'vision-system':
return <VisionSystem />
default:
return <DashboardHome user={user} />
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { experimentManagement, userManagement, type Experiment, type User } from '../lib/supabase'
import { DataEntryInterface } from './DataEntryInterface'
import { experimentManagement, repetitionManagement, userManagement, type Experiment, type ExperimentRepetition, type User } from '../lib/supabase'
import { RepetitionDataEntryInterface } from './RepetitionDataEntryInterface'
export function DataEntry() {
const [experiments, setExperiments] = useState<Experiment[]>([])
const [selectedExperiment, setSelectedExperiment] = useState<Experiment | null>(null)
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
const [selectedRepetition, setSelectedRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -25,6 +26,19 @@ export function DataEntry() {
setExperiments(experimentsData)
setCurrentUser(userData)
// Load repetitions for each experiment
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
for (const experiment of experimentsData) {
try {
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
repetitionsMap[experiment.id] = repetitions
} catch (err) {
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
repetitionsMap[experiment.id] = []
}
}
setExperimentRepetitions(repetitionsMap)
} catch (err: any) {
setError(err.message || 'Failed to load data')
console.error('Load data error:', err)
@@ -33,12 +47,49 @@ export function DataEntry() {
}
}
const handleExperimentSelect = (experiment: Experiment) => {
setSelectedExperiment(experiment)
const handleRepetitionSelect = (experiment: Experiment, repetition: ExperimentRepetition) => {
setSelectedRepetition({ experiment, repetition })
}
const handleBackToList = () => {
setSelectedExperiment(null)
setSelectedRepetition(null)
}
const getAllRepetitionsWithExperiments = () => {
const allRepetitions: Array<{ experiment: Experiment; repetition: ExperimentRepetition }> = []
experiments.forEach(experiment => {
const repetitions = experimentRepetitions[experiment.id] || []
repetitions.forEach(repetition => {
allRepetitions.push({ experiment, repetition })
})
})
return allRepetitions
}
const categorizeRepetitions = () => {
const allRepetitions = getAllRepetitionsWithExperiments()
const now = new Date()
const past = allRepetitions.filter(({ repetition }) =>
repetition.completion_status || (repetition.scheduled_date && new Date(repetition.scheduled_date) < now)
)
const inProgress = allRepetitions.filter(({ repetition }) =>
!repetition.completion_status &&
repetition.scheduled_date &&
new Date(repetition.scheduled_date) <= now &&
new Date(repetition.scheduled_date) > new Date(now.getTime() - 24 * 60 * 60 * 1000)
)
const upcoming = allRepetitions.filter(({ repetition }) =>
!repetition.completion_status &&
repetition.scheduled_date &&
new Date(repetition.scheduled_date) > now
)
return { past, inProgress, upcoming }
}
if (loading) {
@@ -64,10 +115,11 @@ export function DataEntry() {
)
}
if (selectedExperiment) {
if (selectedRepetition) {
return (
<DataEntryInterface
experiment={selectedExperiment}
<RepetitionDataEntryInterface
experiment={selectedRepetition.experiment}
repetition={selectedRepetition.repetition}
currentUser={currentUser!}
onBack={handleBackToList}
/>
@@ -79,76 +131,197 @@ export function DataEntry() {
<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
Select a repetition 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>
{/* Repetitions organized by status - flat list */}
{(() => {
const { past: pastRepetitions, inProgress: inProgressRepetitions, upcoming: upcomingRepetitions } = categorizeRepetitions()
{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
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Past/Completed Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900 flex items-center">
<span className="w-4 h-4 bg-green-500 rounded-full mr-3"></span>
Past/Completed ({pastRepetitions.length})
</h2>
<p className="mt-1 text-sm text-gray-500">
Completed or past scheduled repetitions
</p>
</div>
<div className="p-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{pastRepetitions.map(({ experiment, repetition }) => (
<RepetitionCard
key={repetition.id}
experiment={experiment}
repetition={repetition}
onSelect={handleRepetitionSelect}
status="past"
/>
))}
{pastRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
No completed repetitions
</p>
)}
</div>
</div>
</div>
{/* In Progress Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900 flex items-center">
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3"></span>
In Progress ({inProgressRepetitions.length})
</h2>
<p className="mt-1 text-sm text-gray-500">
Currently scheduled or active repetitions
</p>
</div>
<div className="p-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{inProgressRepetitions.map(({ experiment, repetition }) => (
<RepetitionCard
key={repetition.id}
experiment={experiment}
repetition={repetition}
onSelect={handleRepetitionSelect}
status="in-progress"
/>
))}
{inProgressRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
No repetitions in progress
</p>
)}
</div>
</div>
</div>
{/* Upcoming Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900 flex items-center">
<span className="w-4 h-4 bg-yellow-500 rounded-full mr-3"></span>
Upcoming ({upcomingRepetitions.length})
</h2>
<p className="mt-1 text-sm text-gray-500">
Future scheduled repetitions
</p>
</div>
<div className="p-4">
<div className="space-y-3 max-h-96 overflow-y-auto">
{upcomingRepetitions.map(({ experiment, repetition }) => (
<RepetitionCard
key={repetition.id}
experiment={experiment}
repetition={repetition}
onSelect={handleRepetitionSelect}
status="upcoming"
/>
))}
{upcomingRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
No upcoming repetitions
</p>
)}
</div>
</div>
</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>
)
})()}
{experiments.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500">
No experiments available for data entry
</div>
</div>
)}
</div>
)
}
// RepetitionCard component for displaying individual repetitions
interface RepetitionCardProps {
experiment: Experiment
repetition: ExperimentRepetition
onSelect: (experiment: Experiment, repetition: ExperimentRepetition) => void
status: 'past' | 'in-progress' | 'upcoming'
}
function RepetitionCard({ experiment, repetition, onSelect, status }: RepetitionCardProps) {
const getStatusColor = () => {
switch (status) {
case 'past':
return 'border-green-200 bg-green-50 hover:bg-green-100'
case 'in-progress':
return 'border-blue-200 bg-blue-50 hover:bg-blue-100'
case 'upcoming':
return 'border-yellow-200 bg-yellow-50 hover:bg-yellow-100'
default:
return 'border-gray-200 bg-gray-50 hover:bg-gray-100'
}
}
const getStatusIcon = () => {
switch (status) {
case 'past':
return '✓'
case 'in-progress':
return '▶'
case 'upcoming':
return '⏰'
default:
return '○'
}
}
return (
<button
onClick={() => onSelect(experiment, repetition)}
className={`w-full text-left p-4 border-2 rounded-lg hover:shadow-lg transition-all duration-200 ${getStatusColor()}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
{/* Large, bold experiment number */}
<span className="text-2xl font-bold text-gray-900">
#{experiment.experiment_number}
</span>
{/* Smaller repetition number */}
<span className="text-lg font-semibold text-gray-700">
Rep #{repetition.repetition_number}
</span>
<span className="text-lg">{getStatusIcon()}</span>
</div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.schedule_status === 'scheduled'
? 'bg-blue-100 text-blue-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
</span>
</div>
{/* Experiment details */}
<div className="text-sm text-gray-600 mb-2">
{experiment.soaking_duration_hr}h soaking {experiment.air_drying_time_min}min drying
</div>
{repetition.scheduled_date && (
<div className="text-sm text-gray-600 mb-2">
<strong>Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
</div>
)}
<div className="text-xs text-gray-500">
Click to enter data for this repetition
</div>
</button>
)
}

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'
import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type User, type ExperimentPhase } from '../lib/supabase'
import { type Experiment, type User, type ExperimentPhase } from '../lib/supabase'
import { DraftManager } from './DraftManager'
import { PhaseSelector } from './PhaseSelector'
import { PhaseDataEntry } from './PhaseDataEntry'
// DEPRECATED: This component is deprecated in favor of RepetitionDataEntryInterface
// which uses the new phase-specific draft system
interface DataEntryInterfaceProps {
experiment: Experiment
@@ -10,9 +12,21 @@ interface DataEntryInterfaceProps {
onBack: () => void
}
// Temporary type for backward compatibility
interface LegacyDataEntry {
id: string
experiment_id: string
user_id: string
status: 'draft' | 'submitted'
entry_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
}
export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntryInterfaceProps) {
const [userDataEntries, setUserDataEntries] = useState<ExperimentDataEntry[]>([])
const [selectedDataEntry, setSelectedDataEntry] = useState<ExperimentDataEntry | null>(null)
const [userDataEntries, setUserDataEntries] = useState<LegacyDataEntry[]>([])
const [selectedDataEntry, setSelectedDataEntry] = useState<LegacyDataEntry | null>(null)
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
const [showDraftManager, setShowDraftManager] = useState(false)
const [loading, setLoading] = useState(true)
@@ -27,7 +41,8 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
setLoading(true)
setError(null)
const entries = await dataEntryManagement.getUserDataEntriesForExperiment(experiment.id)
// DEPRECATED: Using empty array since this component is deprecated
const entries: LegacyDataEntry[] = []
setUserDataEntries(entries)
// Auto-select the most recent draft or create a new one
@@ -47,58 +62,21 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
}
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')
}
setError('This component is deprecated. Please use the new repetition-based data entry system.')
}
const handleSelectDataEntry = (entry: ExperimentDataEntry) => {
const handleSelectDataEntry = (entry: LegacyDataEntry) => {
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 handleDeleteDraft = async (_entryId: string) => {
setError('This component is deprecated. Please use the new repetition-based data entry system.')
}
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 handleSubmitEntry = async (_entryId: string) => {
setError('This component is deprecated. Please use the new repetition-based data entry system.')
}
const handlePhaseSelect = (phase: ExperimentPhase) => {
@@ -195,14 +173,7 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
</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>
)}
{/* Scheduled date removed - this is now handled at repetition level */}
</div>
{/* Current Draft Info */}
@@ -237,17 +208,12 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
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()
}}
/>
) : selectedPhase ? (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
This component is deprecated. Please use the new repetition-based data entry system.
</div>
</div>
) : selectedDataEntry ? (
<PhaseSelector
dataEntry={selectedDataEntry}

View File

@@ -1,9 +1,21 @@
import { type ExperimentDataEntry } from '../lib/supabase'
// DEPRECATED: This component is deprecated in favor of PhaseDraftManager
// Temporary type for backward compatibility
interface LegacyDataEntry {
id: string
experiment_id: string
user_id: string
status: 'draft' | 'submitted'
entry_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
}
interface DraftManagerProps {
userDataEntries: ExperimentDataEntry[]
selectedDataEntry: ExperimentDataEntry | null
onSelectEntry: (entry: ExperimentDataEntry) => void
userDataEntries: LegacyDataEntry[]
selectedDataEntry: LegacyDataEntry | null
onSelectEntry: (entry: LegacyDataEntry) => void
onDeleteDraft: (entryId: string) => void
onCreateNew: () => void
onClose: () => void
@@ -64,11 +76,10 @@ export function DraftManager({
{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'
}`}
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">

View File

@@ -1,19 +1,20 @@
import { useState, useEffect } from 'react'
import { ExperimentModal } from './ExperimentModal'
import { ScheduleModal } from './ScheduleModal'
import { experimentManagement, userManagement } from '../lib/supabase'
import type { Experiment, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
import { RepetitionScheduleModal } from './RepetitionScheduleModal'
import { experimentManagement, repetitionManagement, userManagement } from '../lib/supabase'
import type { Experiment, ExperimentRepetition, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
export function Experiments() {
const [experiments, setExperiments] = useState<Experiment[]>([])
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingExperiment, setEditingExperiment] = useState<Experiment | undefined>(undefined)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [filterStatus, setFilterStatus] = useState<ScheduleStatus | 'all'>('all')
const [showScheduleModal, setShowScheduleModal] = useState(false)
const [schedulingExperiment, setSchedulingExperiment] = useState<Experiment | undefined>(undefined)
const [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false)
const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined)
useEffect(() => {
loadData()
@@ -31,6 +32,19 @@ export function Experiments() {
setExperiments(experimentsData)
setCurrentUser(userData)
// Load repetitions for each experiment
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
for (const experiment of experimentsData) {
try {
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
repetitionsMap[experiment.id] = repetitions
} catch (err) {
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
repetitionsMap[experiment.id] = []
}
}
setExperimentRepetitions(repetitionsMap)
} catch (err: any) {
setError(err.message || 'Failed to load experiments')
console.error('Load experiments error:', err)
@@ -51,29 +65,60 @@ export function Experiments() {
setShowModal(true)
}
const handleExperimentSaved = (experiment: Experiment) => {
const handleExperimentSaved = async (experiment: Experiment) => {
if (editingExperiment) {
// Update existing experiment
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp))
} else {
// Add new experiment
// Add new experiment and create all its repetitions
setExperiments(prev => [experiment, ...prev])
try {
// Create all repetitions for the new experiment
const repetitions = await repetitionManagement.createAllRepetitions(experiment.id)
setExperimentRepetitions(prev => ({
...prev,
[experiment.id]: repetitions
}))
} catch (err) {
console.error('Failed to create repetitions:', err)
}
}
setShowModal(false)
setEditingExperiment(undefined)
}
const handleScheduleExperiment = (experiment: Experiment) => {
setSchedulingExperiment(experiment)
setShowScheduleModal(true)
const handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => {
setSchedulingRepetition({ experiment, repetition })
setShowRepetitionScheduleModal(true)
}
const handleScheduleUpdated = (updatedExperiment: Experiment) => {
setExperiments(prev => prev.map(exp =>
exp.id === updatedExperiment.id ? updatedExperiment : exp
))
setShowScheduleModal(false)
setSchedulingExperiment(undefined)
const handleRepetitionScheduleUpdated = (updatedRepetition: ExperimentRepetition) => {
setExperimentRepetitions(prev => ({
...prev,
[updatedRepetition.experiment_id]: prev[updatedRepetition.experiment_id]?.map(rep =>
rep.id === updatedRepetition.id ? updatedRepetition : rep
) || []
}))
setShowRepetitionScheduleModal(false)
setSchedulingRepetition(undefined)
}
const handleCreateRepetition = async (experiment: Experiment, repetitionNumber: number) => {
try {
const newRepetition = await repetitionManagement.createRepetition({
experiment_id: experiment.id,
repetition_number: repetitionNumber,
schedule_status: 'pending schedule'
})
setExperimentRepetitions(prev => ({
...prev,
[experiment.id]: [...(prev[experiment.id] || []), newRepetition].sort((a, b) => a.repetition_number - b.repetition_number)
}))
} catch (err: any) {
setError(err.message || 'Failed to create repetition')
}
}
const handleDeleteExperiment = async (experiment: Experiment) => {
@@ -95,18 +140,12 @@ export function Experiments() {
}
}
const handleStatusUpdate = async (experiment: Experiment, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus) => {
try {
const updatedExperiment = await experimentManagement.updateExperimentStatus(
experiment.id,
scheduleStatus,
resultsStatus
)
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? updatedExperiment : exp))
} catch (err: any) {
alert(`Failed to update status: ${err.message}`)
console.error('Update status error:', err)
}
const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => {
const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length
const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length
const completed = repetitions.filter(r => r.completion_status).length
return { scheduled, pending, completed, total: repetitions.length }
}
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => {
@@ -128,9 +167,8 @@ export function Experiments() {
}
}
const filteredExperiments = filterStatus === 'all'
? experiments
: experiments.filter(exp => exp.schedule_status === filterStatus)
// Remove filtering for now since experiments don't have schedule_status anymore
const filteredExperiments = experiments
if (loading) {
return (
@@ -150,6 +188,7 @@ export function Experiments() {
<div>
<h1 className="text-3xl font-bold text-gray-900">Experiments</h1>
<p className="mt-2 text-gray-600">Manage pecan processing experiment definitions</p>
<p className="mt-2 text-gray-600">This is where you define the blueprint of an experiment with the required configurations and parameters, as well as the number of repetitions needed for that experiment.</p>
</div>
{canManageExperiments && (
<button
@@ -169,26 +208,7 @@ export function Experiments() {
</div>
)}
{/* Filters */}
<div className="mb-6">
<div className="flex items-center space-x-4">
<label htmlFor="status-filter" className="text-sm font-medium text-gray-700">
Filter by Schedule Status:
</label>
<select
id="status-filter"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as ScheduleStatus | 'all')}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Statuses</option>
<option value="pending schedule">Pending Schedule</option>
<option value="scheduled">Scheduled</option>
<option value="canceled">Canceled</option>
<option value="aborted">Aborted</option>
</select>
</div>
</div>
{/* Experiments Table */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
@@ -214,11 +234,11 @@ export function Experiments() {
Experiment Parameters
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Schedule Status
Repetitions Status
</th>
{canManageExperiments && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Scheduled Date/Time
Manage Repetitions
</th>
)}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
@@ -258,30 +278,76 @@ export function Experiments() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(experiment.schedule_status)}`}>
{experiment.schedule_status}
</span>
{(() => {
const repetitions = experimentRepetitions[experiment.id] || []
const summary = getRepetitionStatusSummary(repetitions)
return (
<div className="space-y-1">
<div className="text-xs text-gray-600">
{summary.total} total {summary.scheduled} scheduled {summary.pending} pending
</div>
<div className="flex space-x-1">
{summary.scheduled > 0 && (
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{summary.scheduled} scheduled
</span>
)}
{summary.pending > 0 && (
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
{summary.pending} pending
</span>
)}
</div>
</div>
)
})()}
</td>
{canManageExperiments && (
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center space-x-2">
<button
onClick={(e) => {
e.stopPropagation()
handleScheduleExperiment(experiment)
}}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
title="Schedule"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
{experiment.scheduled_date && (
<span className="text-xs">
{new Date(experiment.scheduled_date).toLocaleString()}
</span>
)}
<div className="space-y-2">
{(() => {
const repetitions = experimentRepetitions[experiment.id] || []
return repetitions.map((repetition) => (
<div key={repetition.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">Rep #{repetition.repetition_number}</span>
<div className="flex items-center space-x-2">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.schedule_status)}`}>
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
</span>
<button
onClick={(e) => {
e.stopPropagation()
handleScheduleRepetition(experiment, repetition)
}}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
title={repetition.schedule_status === 'scheduled' ? 'Reschedule' : 'Schedule'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
))
})()}
{(() => {
const repetitions = experimentRepetitions[experiment.id] || []
const missingReps = experiment.reps_required - repetitions.length
if (missingReps > 0) {
return (
<button
onClick={(e) => {
e.stopPropagation()
handleCreateRepetition(experiment, repetitions.length + 1)
}}
className="w-full text-sm text-blue-600 hover:text-blue-900 py-1 px-2 border border-blue-300 rounded hover:bg-blue-50 transition-colors"
>
+ Add Rep #{repetitions.length + 1}
</button>
)
}
return null
})()}
</div>
</td>
)}
@@ -292,8 +358,8 @@ export function Experiments() {
</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'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{experiment.completion_status ? 'Completed' : 'In Progress'}
</span>
@@ -340,11 +406,9 @@ export function Experiments() {
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found</h3>
<p className="mt-1 text-sm text-gray-500">
{filterStatus === 'all'
? 'Get started by creating your first experiment.'
: `No experiments with status "${filterStatus}".`}
Get started by creating your first experiment.
</p>
{canManageExperiments && filterStatus === 'all' && (
{canManageExperiments && (
<div className="mt-6">
<button
onClick={handleCreateExperiment}
@@ -367,12 +431,13 @@ export function Experiments() {
/>
)}
{/* Schedule Modal */}
{showScheduleModal && schedulingExperiment && (
<ScheduleModal
experiment={schedulingExperiment}
onClose={() => setShowScheduleModal(false)}
onScheduleUpdated={handleScheduleUpdated}
{/* Repetition Schedule Modal */}
{showRepetitionScheduleModal && schedulingRepetition && (
<RepetitionScheduleModal
experiment={schedulingRepetition.experiment}
repetition={schedulingRepetition.repetition}
onClose={() => setShowRepetitionScheduleModal(false)}
onScheduleUpdated={handleRepetitionScheduleUpdated}
/>
)}
</div>

View File

@@ -1,31 +1,62 @@
import { useState, useEffect, useCallback } from 'react'
import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
import { phaseDraftManagement, type Experiment, type ExperimentPhaseDraft, type ExperimentPhase, type ExperimentPhaseData, type ExperimentRepetition, type User } from '../lib/supabase'
import { PhaseDraftManager } from './PhaseDraftManager'
interface PhaseDataEntryProps {
experiment: Experiment
dataEntry: ExperimentDataEntry
repetition: ExperimentRepetition
phase: ExperimentPhase
currentUser: User
onBack: () => void
onDataSaved: () => void
}
export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSaved }: PhaseDataEntryProps) {
export function PhaseDataEntry({ experiment, repetition, phase, currentUser, onBack, onDataSaved }: PhaseDataEntryProps) {
const [selectedDraft, setSelectedDraft] = useState<ExperimentPhaseDraft | null>(null)
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)
const [showDraftManager, setShowDraftManager] = useState(false)
// Auto-save interval (30 seconds)
const AUTO_SAVE_INTERVAL = 30000
const loadPhaseData = useCallback(async () => {
useEffect(() => {
loadUserDrafts()
}, [repetition.id, phase])
const loadUserDrafts = async () => {
try {
setLoading(true)
setError(null)
const existingData = await dataEntryManagement.getPhaseData(dataEntry.id, phase)
const drafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
// Auto-select the most recent draft or show draft manager if none exist
if (drafts.length > 0) {
const mostRecentDraft = drafts[0] // Already sorted by created_at desc
setSelectedDraft(mostRecentDraft)
await loadPhaseDataForDraft(mostRecentDraft)
} else {
setShowDraftManager(true)
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load drafts'
setError(errorMessage)
console.error('Load drafts error:', err)
} finally {
setLoading(false)
}
}
const loadPhaseDataForDraft = async (draft: ExperimentPhaseDraft) => {
try {
setError(null)
const existingData = await phaseDraftManagement.getPhaseDataForDraft(draft.id)
if (existingData) {
setPhaseData(existingData)
@@ -43,33 +74,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
} else {
// Initialize empty phase data
setPhaseData({
data_entry_id: dataEntry.id,
phase_draft_id: draft.id,
phase_name: phase
})
setDiameterMeasurements(Array(10).fill(0))
}
} 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
if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts
try {
await dataEntryManagement.autoSaveDraft(dataEntry.id, phase, phaseData)
await phaseDraftManagement.autoSaveDraft(selectedDraft.id, 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)
await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
// Update average diameter
const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements)
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter }))
}
}
@@ -78,22 +108,18 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
} catch (error) {
console.warn('Auto-save failed:', error)
}
}, [dataEntry.id, dataEntry.status, phase, phaseData, diameterMeasurements])
useEffect(() => {
loadPhaseData()
}, [loadPhaseData])
}, [selectedDraft, phase, phaseData, diameterMeasurements])
// Auto-save effect
useEffect(() => {
if (!loading && phaseData.id) {
if (!loading && selectedDraft && phaseData.phase_draft_id) {
const interval = setInterval(() => {
autoSave()
}, AUTO_SAVE_INTERVAL)
return () => clearInterval(interval)
}
}, [phaseData, diameterMeasurements, loading, autoSave])
}, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft])
const handleInputChange = (field: string, value: unknown) => {
setPhaseData(prev => ({
@@ -110,23 +136,25 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
// Calculate and update average
const validMeasurements = newMeasurements.filter(m => m > 0)
if (validMeasurements.length > 0) {
const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements)
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
handleInputChange('avg_pecan_diameter_in', avgDiameter)
}
}
const handleSave = async () => {
if (!selectedDraft) return
try {
setSaving(true)
setError(null)
// Save phase data
const savedData = await dataEntryManagement.upsertPhaseData(dataEntry.id, phase, phaseData)
const savedData = await phaseDraftManagement.upsertPhaseData(selectedDraft.id, 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)
await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
}
setLastSaved(new Date())
@@ -140,6 +168,20 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
}
}
const handleSelectDraft = (draft: ExperimentPhaseDraft) => {
setSelectedDraft(draft)
setShowDraftManager(false)
loadPhaseDataForDraft(draft)
}
const isFieldDisabled = () => {
const isAdmin = currentUser.roles.includes('admin')
return !selectedDraft ||
selectedDraft.status === 'submitted' ||
selectedDraft.status === 'withdrawn' ||
(repetition.is_locked && !isAdmin)
}
const getPhaseTitle = () => {
switch (phase) {
case 'pre-soaking': return 'Pre-Soaking Phase'
@@ -181,6 +223,17 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
return (
<div>
{/* Draft Manager Modal */}
{showDraftManager && (
<PhaseDraftManager
repetition={repetition}
phase={phase}
currentUser={currentUser}
onSelectDraft={handleSelectDraft}
onClose={() => setShowDraftManager(false)}
/>
)}
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
@@ -195,8 +248,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
Back to Phases
</button>
<h2 className="text-2xl font-bold text-gray-900">{getPhaseTitle()}</h2>
{selectedDraft && (
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-gray-600">
Draft: {selectedDraft.draft_name || `Draft ${selectedDraft.id.slice(-8)}`}
</span>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${selectedDraft.status === 'draft' ? 'bg-yellow-100 text-yellow-800' :
selectedDraft.status === 'submitted' ? 'bg-green-100 text-green-800' :
'bg-red-100 text-red-800'
}`}>
{selectedDraft.status}
</span>
{repetition.is_locked && (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
🔒 Locked
</span>
)}
</div>
)}
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => setShowDraftManager(true)}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Manage Drafts
</button>
{lastSaved && (
<span className="text-sm text-gray-500">
Last saved: {lastSaved.toLocaleTimeString()}
@@ -204,7 +281,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
)}
<button
onClick={handleSave}
disabled={saving || dataEntry.status === 'submitted'}
disabled={saving || !selectedDraft || selectedDraft.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'}
@@ -218,10 +295,42 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
</div>
)}
{dataEntry.status === 'submitted' && (
{selectedDraft?.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.
This draft has been submitted and is read-only. Create a new draft to make changes.
</div>
</div>
)}
{selectedDraft?.status === 'withdrawn' && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
This draft has been withdrawn. Create a new draft to make changes.
</div>
</div>
)}
{repetition.is_locked && !currentUser.roles.includes('admin') && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">
This repetition has been locked by an admin. No changes can be made to drafts.
</div>
</div>
)}
{repetition.is_locked && currentUser.roles.includes('admin') && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
🔒 This repetition is locked, but you can still make changes as an admin.
</div>
</div>
)}
{!selectedDraft && (
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="text-sm text-blue-700">
No draft selected. Use "Manage Drafts" to create or select a draft for this phase.
</div>
</div>
)}
@@ -246,7 +355,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
@@ -263,7 +372,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
@@ -280,7 +389,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
@@ -293,7 +402,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>
@@ -332,7 +441,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
@@ -348,7 +457,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
@@ -365,7 +474,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
@@ -382,7 +491,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>
@@ -422,7 +531,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
))}
@@ -440,7 +549,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
<p className="mt-1 text-xs text-gray-500">
Automatically calculated from individual measurements above
@@ -464,7 +573,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>
@@ -536,7 +645,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>
@@ -557,7 +666,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -572,7 +681,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -587,7 +696,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -602,7 +711,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>
@@ -624,7 +733,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -639,7 +748,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -654,7 +763,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>
@@ -676,7 +785,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -691,7 +800,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
<div>
@@ -706,7 +815,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
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'}
disabled={isFieldDisabled()}
/>
</div>
</div>

View File

@@ -0,0 +1,276 @@
import { useState, useEffect } from 'react'
import { phaseDraftManagement, type ExperimentPhaseDraft, type ExperimentPhase, type User, type ExperimentRepetition } from '../lib/supabase'
interface PhaseDraftManagerProps {
repetition: ExperimentRepetition
phase: ExperimentPhase
currentUser: User
onSelectDraft: (draft: ExperimentPhaseDraft) => void
onClose: () => void
}
export function PhaseDraftManager({ repetition, phase, currentUser, onSelectDraft, onClose }: PhaseDraftManagerProps) {
const [drafts, setDrafts] = useState<ExperimentPhaseDraft[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newDraftName, setNewDraftName] = useState('')
useEffect(() => {
loadDrafts()
}, [repetition.id, phase])
const loadDrafts = async () => {
try {
setLoading(true)
setError(null)
const userDrafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
setDrafts(userDrafts)
} catch (err: any) {
setError(err.message || 'Failed to load drafts')
console.error('Load drafts error:', err)
} finally {
setLoading(false)
}
}
const handleCreateDraft = async () => {
try {
setCreating(true)
setError(null)
const newDraft = await phaseDraftManagement.createPhaseDraft({
experiment_id: repetition.experiment_id,
repetition_id: repetition.id,
phase_name: phase,
draft_name: newDraftName || undefined,
status: 'draft'
})
setDrafts(prev => [newDraft, ...prev])
setNewDraftName('')
onSelectDraft(newDraft)
} catch (err: any) {
setError(err.message || 'Failed to create draft')
} finally {
setCreating(false)
}
}
const handleDeleteDraft = async (draftId: string) => {
if (!confirm('Are you sure you want to delete this draft? This action cannot be undone.')) {
return
}
try {
await phaseDraftManagement.deletePhaseDraft(draftId)
setDrafts(prev => prev.filter(draft => draft.id !== draftId))
} catch (err: any) {
setError(err.message || 'Failed to delete draft')
}
}
const handleSubmitDraft = async (draftId: string) => {
if (!confirm('Are you sure you want to submit this draft? Once submitted, it can only be withdrawn by you or locked by an admin.')) {
return
}
try {
const submittedDraft = await phaseDraftManagement.submitPhaseDraft(draftId)
setDrafts(prev => prev.map(draft =>
draft.id === draftId ? submittedDraft : draft
))
} catch (err: any) {
setError(err.message || 'Failed to submit draft')
}
}
const handleWithdrawDraft = async (draftId: string) => {
if (!confirm('Are you sure you want to withdraw this submitted draft? It will be marked as withdrawn.')) {
return
}
try {
const withdrawnDraft = await phaseDraftManagement.withdrawPhaseDraft(draftId)
setDrafts(prev => prev.map(draft =>
draft.id === draftId ? withdrawnDraft : draft
))
} catch (err: any) {
setError(err.message || 'Failed to withdraw draft')
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
case 'submitted':
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
case 'withdrawn':
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
default:
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">{status}</span>
}
}
const canDeleteDraft = (draft: ExperimentPhaseDraft) => {
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
}
const canSubmitDraft = (draft: ExperimentPhaseDraft) => {
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
}
const canWithdrawDraft = (draft: ExperimentPhaseDraft) => {
return draft.status === 'submitted' && (!repetition.is_locked || currentUser.roles.includes('admin'))
}
const canCreateDraft = () => {
return !repetition.is_locked || currentUser.roles.includes('admin')
}
const formatPhaseTitle = (phase: string) => {
return phase.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 className="text-xl font-semibold text-gray-900">
{formatPhaseTitle(phase)} Phase Drafts
</h2>
<p className="text-sm text-gray-600 mt-1">
Repetition {repetition.repetition_number}
{repetition.is_locked && (
<span className="ml-2 px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
🔒 Locked
</span>
)}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<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 className="p-6">
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Create New Draft */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 className="text-sm font-medium text-gray-900 mb-3">Create New Draft</h3>
<div className="flex gap-3">
<input
type="text"
placeholder="Draft name (optional)"
value={newDraftName}
onChange={(e) => setNewDraftName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={creating || repetition.is_locked}
/>
<button
onClick={handleCreateDraft}
disabled={creating || !canCreateDraft()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{creating ? 'Creating...' : 'Create Draft'}
</button>
</div>
{repetition.is_locked && !currentUser.roles.includes('admin') && (
<p className="text-xs text-red-600 mt-2">
Cannot create new drafts: repetition is locked by admin
</p>
)}
</div>
{/* Drafts List */}
<div className="space-y-4">
{loading ? (
<div className="text-center py-8">
<div className="text-gray-500">Loading drafts...</div>
</div>
) : drafts.length === 0 ? (
<div className="text-center py-8">
<div className="text-gray-500">No drafts found for this phase</div>
<p className="text-sm text-gray-400 mt-1">Create a new draft to get started</p>
</div>
) : (
drafts.map((draft) => (
<div key={draft.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-medium text-gray-900">
{draft.draft_name || `Draft ${draft.id.slice(-8)}`}
</h4>
{getStatusBadge(draft.status)}
</div>
<div className="text-sm text-gray-600">
<p>Created: {new Date(draft.created_at).toLocaleString()}</p>
<p>Updated: {new Date(draft.updated_at).toLocaleString()}</p>
{draft.submitted_at && (
<p>Submitted: {new Date(draft.submitted_at).toLocaleString()}</p>
)}
{draft.withdrawn_at && (
<p>Withdrawn: {new Date(draft.withdrawn_at).toLocaleString()}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onSelectDraft(draft)}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{draft.status === 'draft' ? 'Edit' : 'View'}
</button>
{canSubmitDraft(draft) && (
<button
onClick={() => handleSubmitDraft(draft.id)}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Submit
</button>
)}
{canWithdrawDraft(draft) && (
<button
onClick={() => handleWithdrawDraft(draft.id)}
className="px-3 py-1 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
>
Withdraw
</button>
)}
{canDeleteDraft(draft) && (
<button
onClick={() => handleDeleteDraft(draft.id)}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Delete
</button>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,23 @@
import { useState, useEffect } from 'react'
import { dataEntryManagement, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
import { type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
// DEPRECATED: This component is deprecated in favor of RepetitionPhaseSelector
// which uses the new phase-specific draft system
// Temporary type for backward compatibility
interface LegacyDataEntry {
id: string
experiment_id: string
user_id: string
status: 'draft' | 'submitted'
entry_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
}
interface PhaseSelectorProps {
dataEntry: ExperimentDataEntry
dataEntry: LegacyDataEntry
onPhaseSelect: (phase: ExperimentPhase) => void
}
@@ -61,7 +76,8 @@ export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps)
const loadPhaseData = async () => {
try {
setLoading(true)
const allPhaseData = await dataEntryManagement.getPhaseDataForEntry(dataEntry.id)
// DEPRECATED: Using empty array since this component is deprecated
const allPhaseData: ExperimentPhaseData[] = []
const phaseDataMap: Record<ExperimentPhase, ExperimentPhaseData | null> = {
'pre-soaking': null,

View File

@@ -0,0 +1,115 @@
import { useState, useEffect } from 'react'
import { type Experiment, type ExperimentRepetition, type User, type ExperimentPhase } from '../lib/supabase'
import { RepetitionPhaseSelector } from './RepetitionPhaseSelector'
import { PhaseDataEntry } from './PhaseDataEntry'
import { RepetitionLockManager } from './RepetitionLockManager'
interface RepetitionDataEntryInterfaceProps {
experiment: Experiment
repetition: ExperimentRepetition
currentUser: User
onBack: () => void
}
export function RepetitionDataEntryInterface({ experiment, repetition, currentUser, onBack }: RepetitionDataEntryInterfaceProps) {
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
const [loading, setLoading] = useState(true)
const [currentRepetition, setCurrentRepetition] = useState<ExperimentRepetition>(repetition)
useEffect(() => {
// Skip loading old data entries - go directly to phase selection
setLoading(false)
}, [repetition.id, currentUser.id])
const handlePhaseSelect = (phase: ExperimentPhase) => {
setSelectedPhase(phase)
}
const handleBackToPhases = () => {
setSelectedPhase(null)
}
const handleRepetitionUpdated = (updatedRepetition: ExperimentRepetition) => {
setCurrentRepetition(updatedRepetition)
}
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...</p>
</div>
</div>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-4">
<button
onClick={onBack}
className="text-blue-600 hover:text-blue-800 flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Repetitions</span>
</button>
</div>
<div className="mt-4">
<h1 className="text-3xl font-bold text-gray-900">
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
</h1>
<div className="mt-2 text-sm text-gray-600 space-y-1">
<div>Soaking: {experiment.soaking_duration_hr}h Air Drying: {experiment.air_drying_time_min}min</div>
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz Throughput: {experiment.throughput_rate_pecans_sec}/sec</div>
{repetition.scheduled_date && (
<div>Scheduled: {new Date(repetition.scheduled_date).toLocaleString()}</div>
)}
</div>
</div>
</div>
{/* No additional controls needed - phase-specific draft management is handled within each phase */}
</div>
</div>
{/* Admin Controls */}
<RepetitionLockManager
repetition={currentRepetition}
currentUser={currentUser}
onRepetitionUpdated={handleRepetitionUpdated}
/>
{/* Main Content */}
{selectedPhase ? (
<PhaseDataEntry
experiment={experiment}
repetition={currentRepetition}
phase={selectedPhase}
currentUser={currentUser}
onBack={handleBackToPhases}
onDataSaved={() => {
// Data is automatically saved in the new phase-specific system
}}
/>
) : (
<RepetitionPhaseSelector
repetition={currentRepetition}
currentUser={currentUser}
onPhaseSelect={handlePhaseSelect}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { repetitionManagement, type ExperimentRepetition, type User } from '../lib/supabase'
interface RepetitionLockManagerProps {
repetition: ExperimentRepetition
currentUser: User
onRepetitionUpdated: (updatedRepetition: ExperimentRepetition) => void
}
export function RepetitionLockManager({ repetition, currentUser, onRepetitionUpdated }: RepetitionLockManagerProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isAdmin = currentUser.roles.includes('admin')
const handleLockRepetition = async () => {
if (!confirm('Are you sure you want to lock this repetition? This will prevent users from modifying or withdrawing any submitted drafts.')) {
return
}
try {
setLoading(true)
setError(null)
const updatedRepetition = await repetitionManagement.lockRepetition(repetition.id)
onRepetitionUpdated(updatedRepetition)
} catch (err: any) {
setError(err.message || 'Failed to lock repetition')
} finally {
setLoading(false)
}
}
const handleUnlockRepetition = async () => {
if (!confirm('Are you sure you want to unlock this repetition? This will allow users to modify and withdraw submitted drafts again.')) {
return
}
try {
setLoading(true)
setError(null)
const updatedRepetition = await repetitionManagement.unlockRepetition(repetition.id)
onRepetitionUpdated(updatedRepetition)
} catch (err: any) {
setError(err.message || 'Failed to unlock repetition')
} finally {
setLoading(false)
}
}
if (!isAdmin) {
return null
}
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Admin Controls</h3>
{error && (
<div className="mb-3 bg-red-50 border border-red-200 rounded-md p-3">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700">Repetition Status:</span>
{repetition.is_locked ? (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
🔒 Locked
</span>
) : (
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
🔓 Unlocked
</span>
)}
</div>
{repetition.is_locked && repetition.locked_at && (
<div className="mt-1 text-xs text-gray-500">
Locked: {new Date(repetition.locked_at).toLocaleString()}
</div>
)}
</div>
<div className="flex items-center gap-2">
{repetition.is_locked ? (
<button
onClick={handleUnlockRepetition}
disabled={loading}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Unlocking...' : 'Unlock'}
</button>
) : (
<button
onClick={handleLockRepetition}
disabled={loading}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Locking...' : 'Lock'}
</button>
)}
</div>
</div>
<div className="mt-3 text-xs text-gray-600">
{repetition.is_locked ? (
<p>
When locked, users cannot create new drafts, delete existing drafts, or withdraw submitted drafts.
Only admins can modify the lock status.
</p>
) : (
<p>
When unlocked, users can freely create, edit, delete, submit, and withdraw drafts.
Lock this repetition to prevent further changes to submitted data.
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,223 @@
import { useState, useEffect } from 'react'
import { phaseDraftManagement, type ExperimentRepetition, type ExperimentPhase, type ExperimentPhaseDraft, type User } from '../lib/supabase'
interface RepetitionPhaseSelectorProps {
repetition: ExperimentRepetition
currentUser: User
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 RepetitionPhaseSelector({ repetition, currentUser: _currentUser, onPhaseSelect }: RepetitionPhaseSelectorProps) {
const [phaseDrafts, setPhaseDrafts] = useState<Record<ExperimentPhase, ExperimentPhaseDraft[]>>({
'pre-soaking': [],
'air-drying': [],
'cracking': [],
'shelling': []
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadPhaseDrafts()
}, [repetition.id])
const loadPhaseDrafts = async () => {
try {
setLoading(true)
setError(null)
const allDrafts = await phaseDraftManagement.getUserPhaseDraftsForRepetition(repetition.id)
// Group drafts by phase
const groupedDrafts: Record<ExperimentPhase, ExperimentPhaseDraft[]> = {
'pre-soaking': [],
'air-drying': [],
'cracking': [],
'shelling': []
}
allDrafts.forEach(draft => {
groupedDrafts[draft.phase_name].push(draft)
})
setPhaseDrafts(groupedDrafts)
} catch (err: any) {
setError(err.message || 'Failed to load phase drafts')
console.error('Load phase drafts error:', err)
} finally {
setLoading(false)
}
}
const getPhaseStatus = (phase: ExperimentPhase) => {
const drafts = phaseDrafts[phase]
if (drafts.length === 0) return 'empty'
const hasSubmitted = drafts.some(d => d.status === 'submitted')
const hasDraft = drafts.some(d => d.status === 'draft')
const hasWithdrawn = drafts.some(d => d.status === 'withdrawn')
if (hasSubmitted) return 'submitted'
if (hasDraft) return 'draft'
if (hasWithdrawn) return 'withdrawn'
return 'empty'
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'submitted':
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
case 'draft':
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
case 'withdrawn':
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
case 'empty':
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">No Data</span>
default:
return null
}
}
const getDraftCount = (phase: ExperimentPhase) => {
return phaseDrafts[phase].length
}
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 phases...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Select Phase</h2>
<p className="text-gray-600">
Choose a phase to enter or view data. Each phase can have multiple drafts.
</p>
{repetition.is_locked && (
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-center">
<span className="text-red-800 text-sm font-medium">🔒 This repetition is locked by an admin</span>
</div>
<p className="text-red-700 text-xs mt-1">
You can view existing data but cannot create new drafts or modify existing ones.
</p>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{phases.map((phase) => {
const status = getPhaseStatus(phase.name)
const draftCount = getDraftCount(phase.name)
return (
<div
key={phase.name}
onClick={() => onPhaseSelect(phase.name)}
className="bg-white rounded-lg shadow-md border border-gray-200 p-6 cursor-pointer hover:shadow-lg hover:border-blue-300 transition-all duration-200"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className={`w-12 h-12 ${phase.color} rounded-lg flex items-center justify-center text-white text-xl mr-4`}>
{phase.icon}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{phase.title}</h3>
<p className="text-sm text-gray-600">{phase.description}</p>
</div>
</div>
{getStatusBadge(status)}
</div>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
{draftCount === 0 ? 'No drafts' : `${draftCount} draft${draftCount === 1 ? '' : 's'}`}
</span>
<svg className="w-5 h-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>
{draftCount > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-wrap gap-1">
{phaseDrafts[phase.name].slice(0, 3).map((draft, index) => (
<span
key={draft.id}
className={`px-2 py-1 text-xs rounded ${draft.status === 'submitted' ? 'bg-green-100 text-green-700' :
draft.status === 'draft' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}
>
{draft.draft_name || `Draft ${index + 1}`}
</span>
))}
{draftCount > 3 && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
+{draftCount - 3} more
</span>
)}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { useState } from 'react'
import { repetitionManagement } from '../lib/supabase'
import type { Experiment, ExperimentRepetition } from '../lib/supabase'
interface RepetitionScheduleModalProps {
experiment: Experiment
repetition: ExperimentRepetition
onClose: () => void
onScheduleUpdated: (updatedRepetition: ExperimentRepetition) => void
}
export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Initialize with existing scheduled date or current date/time
const getInitialDateTime = () => {
if (repetition.scheduled_date) {
const date = new Date(repetition.scheduled_date)
return {
date: date.toISOString().split('T')[0],
time: date.toTimeString().slice(0, 5)
}
}
const now = new Date()
// Set to next hour by default
now.setHours(now.getHours() + 1, 0, 0, 0)
return {
date: now.toISOString().split('T')[0],
time: now.toTimeString().slice(0, 5)
}
}
const [dateTime, setDateTime] = useState(getInitialDateTime())
const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
// Validate date/time
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
const now = new Date()
if (selectedDateTime <= now) {
setError('Scheduled date and time must be in the future')
setLoading(false)
return
}
// Schedule the repetition
const updatedRepetition = await repetitionManagement.scheduleRepetition(
repetition.id,
selectedDateTime.toISOString()
)
onScheduleUpdated(updatedRepetition)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to schedule repetition')
console.error('Schedule repetition error:', err)
} finally {
setLoading(false)
}
}
const handleRemoveSchedule = async () => {
if (!confirm('Are you sure you want to remove the schedule for this repetition?')) {
return
}
setError(null)
setLoading(true)
try {
const updatedRepetition = await repetitionManagement.removeRepetitionSchedule(repetition.id)
onScheduleUpdated(updatedRepetition)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to remove schedule')
console.error('Remove schedule error:', err)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
onClose()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-semibold text-gray-900">
Schedule Repetition
</h3>
<button
onClick={handleCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<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 className="p-6">
{/* Experiment and Repetition Info */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
</h4>
<p className="text-sm text-gray-600">
{experiment.reps_required} reps required {experiment.soaking_duration_hr}h soaking
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Current Schedule (if exists) */}
{isScheduled && (
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
<p className="text-sm text-blue-700">
{new Date(repetition.scheduled_date!).toLocaleString()}
</p>
</div>
)}
{/* Schedule Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-2">
Date *
</label>
<input
type="date"
id="date"
value={dateTime.date}
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
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 transition-colors text-sm"
required
/>
</div>
<div>
<label htmlFor="time" className="block text-sm font-medium text-gray-700 mb-2">
Time *
</label>
<input
type="time"
id="time"
value={dateTime.time}
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
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 transition-colors text-sm"
required
/>
</div>
{/* Action Buttons */}
<div className="flex justify-between pt-4">
<div>
{isScheduled && (
<button
type="button"
onClick={handleRemoveSchedule}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
>
Remove Schedule
</button>
)}
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={handleCancel}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
>
{loading ? 'Scheduling...' : (isScheduled ? 'Update Schedule' : 'Schedule Repetition')}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -67,6 +67,15 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
</svg>
),
requiredRoles: ['admin', 'conductor', 'data recorder']
},
{
id: 'vision-system',
name: 'Vision System',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
}
]
@@ -82,7 +91,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
<div className="flex items-center justify-between">
{!isCollapsed && (
<div>
<h1 className="text-xl font-bold text-white">RBAC System</h1>
<h1 className="text-xl font-bold text-white">Pecan Experiments</h1>
<p className="text-sm text-slate-400">Admin Dashboard</p>
</div>
)}

View File

@@ -22,6 +22,8 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
return 'Analytics'
case 'data-entry':
return 'Data Entry'
case 'vision-system':
return 'Vision System'
default:
return 'Dashboard'
}

View File

@@ -0,0 +1,735 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
visionApi,
type SystemStatus,
type CameraStatus,
type MachineStatus,
type StorageStats,
type RecordingInfo,
type MqttStatus,
type MqttEventsResponse,
type MqttEvent,
formatBytes,
formatDuration,
formatUptime
} from '../lib/visionApi'
export function VisionSystem() {
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
const [mqttStatus, setMqttStatus] = useState<MqttStatus | null>(null)
const [mqttEvents, setMqttEvents] = useState<MqttEvent[]>([])
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const clearAutoRefresh = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startAutoRefresh = useCallback(() => {
clearAutoRefresh()
if (autoRefreshEnabled && refreshInterval > 0) {
intervalRef.current = setInterval(fetchData, refreshInterval)
}
}, [autoRefreshEnabled, refreshInterval])
useEffect(() => {
fetchData()
startAutoRefresh()
return clearAutoRefresh
}, [startAutoRefresh])
useEffect(() => {
startAutoRefresh()
}, [autoRefreshEnabled, refreshInterval, startAutoRefresh])
const fetchData = useCallback(async (showRefreshIndicator = true) => {
try {
setError(null)
if (!systemStatus) {
setLoading(true)
} else if (showRefreshIndicator) {
setRefreshing(true)
}
const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([
visionApi.getSystemStatus(),
visionApi.getStorageStats(),
visionApi.getRecordings(),
visionApi.getMqttStatus().catch(err => {
console.warn('Failed to fetch MQTT status:', err)
return null
}),
visionApi.getMqttEvents(10).catch(err => {
console.warn('Failed to fetch MQTT events:', err)
return { events: [], total_events: 0, last_updated: '' }
})
])
// If cameras don't have device_info, try to fetch individual camera status
if (statusData.cameras) {
const camerasNeedingInfo = Object.entries(statusData.cameras)
.filter(([_, camera]) => !camera.device_info?.friendly_name)
.map(([cameraName, _]) => cameraName)
if (camerasNeedingInfo.length > 0) {
console.log('Fetching individual camera info for:', camerasNeedingInfo)
try {
const individualCameraData = await Promise.all(
camerasNeedingInfo.map(cameraName =>
visionApi.getCameraStatus(cameraName).catch(err => {
console.warn(`Failed to get individual status for ${cameraName}:`, err)
return null
})
)
)
// Merge the individual camera data back into statusData
camerasNeedingInfo.forEach((cameraName, index) => {
const individualData = individualCameraData[index]
if (individualData && individualData.device_info) {
statusData.cameras[cameraName] = {
...statusData.cameras[cameraName],
device_info: individualData.device_info
}
}
})
} catch (err) {
console.warn('Failed to fetch individual camera data:', err)
}
}
}
// Only update state if data has actually changed to prevent unnecessary re-renders
setSystemStatus(prevStatus => {
if (JSON.stringify(prevStatus) !== JSON.stringify(statusData)) {
return statusData
}
return prevStatus
})
setStorageStats(prevStats => {
if (JSON.stringify(prevStats) !== JSON.stringify(storageData)) {
return storageData
}
return prevStats
})
setRecordings(prevRecordings => {
if (JSON.stringify(prevRecordings) !== JSON.stringify(recordingsData)) {
return recordingsData
}
return prevRecordings
})
setLastUpdateTime(new Date())
// Update MQTT status and events
if (mqttStatusData) {
setMqttStatus(mqttStatusData)
}
if (mqttEventsData && mqttEventsData.events) {
setMqttEvents(mqttEventsData.events)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
console.error('Vision system fetch error:', err)
// Don't disable auto-refresh on errors, just log them
} finally {
setLoading(false)
setRefreshing(false)
}
}, [systemStatus])
const getStatusColor = (status: string, isRecording: boolean = false) => {
// If camera is recording, always show red regardless of status
if (isRecording) {
return 'text-red-600 bg-red-100'
}
switch (status.toLowerCase()) {
case 'available':
case 'connected':
case 'healthy':
case 'on':
return 'text-green-600 bg-green-100'
case 'disconnected':
case 'off':
case 'failed':
return 'text-red-600 bg-red-100'
case 'error':
case 'warning':
case 'degraded':
return 'text-yellow-600 bg-yellow-100'
default:
return 'text-yellow-600 bg-yellow-100'
}
}
const getMachineStateColor = (state: string) => {
switch (state.toLowerCase()) {
case 'on':
case 'running':
return 'text-green-600 bg-green-100'
case 'off':
case 'stopped':
return 'text-gray-600 bg-gray-100'
default:
return 'text-yellow-600 bg-yellow-100'
}
}
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-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading vision system data...</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="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading vision system</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={() => fetchData(true)}
disabled={refreshing}
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 disabled:opacity-50"
>
{refreshing ? 'Retrying...' : 'Try Again'}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
{lastUpdateTime && (
<p className="mt-1 text-sm text-gray-500 flex items-center space-x-2">
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</span>
{autoRefreshEnabled && !refreshing && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Auto-refresh: {refreshInterval / 1000}s
</span>
)}
</p>
)}
</div>
<div className="flex items-center space-x-4">
{/* Auto-refresh controls */}
<div className="flex items-center space-x-2">
<label className="flex items-center space-x-2 text-sm text-gray-600">
<input
type="checkbox"
checked={autoRefreshEnabled}
onChange={(e) => setAutoRefreshEnabled(e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Auto-refresh</span>
</label>
{autoRefreshEnabled && (
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value={2000}>2s</option>
<option value={5000}>5s</option>
<option value={10000}>10s</option>
<option value={30000}>30s</option>
<option value={60000}>1m</option>
</select>
)}
</div>
{/* Refresh indicator and button */}
<div className="flex items-center space-x-2">
{refreshing && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
)}
<button
onClick={() => fetchData(true)}
disabled={refreshing}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
Refresh
</button>
</div>
</div>
</div>
{/* System Overview */}
{systemStatus && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{systemStatus.system_started ? 'Online' : 'Offline'}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">System Status</div>
<div className="mt-1 text-sm text-gray-500">
Uptime: {formatUptime(systemStatus.uptime_seconds)}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
</div>
</div>
{systemStatus.mqtt_connected && (
<div className="ml-3 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-green-600">Live</span>
</div>
)}
</div>
{mqttStatus && (
<div className="text-right text-xs text-gray-500">
<div>{mqttStatus.message_count} messages</div>
<div>{mqttStatus.error_count} errors</div>
</div>
)}
</div>
<div className="mt-4">
<div className="text-2xl font-semibold text-gray-900">MQTT</div>
<div className="mt-1 text-sm text-gray-500">
{mqttStatus ? (
<div>
<div>Broker: {mqttStatus.broker_host}:{mqttStatus.broker_port}</div>
<div>Last message: {new Date(mqttStatus.last_message_time).toLocaleTimeString()}</div>
</div>
) : (
<div>Last message: {new Date(systemStatus.last_mqtt_message).toLocaleTimeString()}</div>
)}
</div>
</div>
{/* MQTT Events History */}
{mqttEvents.length > 0 && (
<div className="mt-4 border-t border-gray-200 pt-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">Recent Events</h4>
<span className="text-xs text-gray-500">{mqttEvents.length} events</span>
</div>
<div className="max-h-32 overflow-y-auto space-y-2">
{mqttEvents.map((event, index) => (
<div key={`${event.timestamp}-${event.message_number}`} className="flex items-center justify-between text-xs">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-gray-500 font-mono w-12 flex-shrink-0">
{new Date(event.timestamp).toLocaleTimeString().slice(-8, -3)}
</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 flex-shrink-0">
{event.machine_name.replace('_', ' ')}
</span>
<span className="text-gray-900 font-medium truncate">
{event.payload}
</span>
</div>
<span className="text-gray-400 ml-2 flex-shrink-0">#{event.message_number}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="text-3xl font-bold text-indigo-600">
{systemStatus.active_recordings}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-lg font-medium text-gray-900">Active Recordings</div>
<div className="mt-1 text-sm text-gray-500">
Total: {systemStatus.total_recordings}
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="text-3xl font-bold text-purple-600">
{Object.keys(systemStatus.cameras).length}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-lg font-medium text-gray-900">Cameras</div>
<div className="mt-1 text-sm text-gray-500">
Machines: {Object.keys(systemStatus.machines).length}
</div>
</div>
</div>
</div>
</div>
)}
{/* Cameras Status */}
{systemStatus && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Current status of all cameras in the system
</p>
</div>
<div className="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
// Debug logging to see what data we're getting
console.log(`Camera ${cameraName} data:`, JSON.stringify(camera, null, 2))
const friendlyName = camera.device_info?.friendly_name
const hasDeviceInfo = !!camera.device_info
const hasSerial = !!camera.device_info?.serial_number
return (
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-lg font-medium text-gray-900">
{friendlyName ? (
<div>
<div className="text-lg">{friendlyName}</div>
<div className="text-sm text-gray-600 font-normal">({cameraName})</div>
</div>
) : (
<div>
<div className="text-lg">{cameraName}</div>
<div className="text-xs text-gray-500">
{hasDeviceInfo ? 'Device info available but no friendly name' : 'No device info available'}
</div>
</div>
)}
</h4>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(camera.status, camera.is_recording)}`}>
{camera.is_recording ? 'Recording' : camera.status}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Recording:</span>
<span className={`font-medium ${camera.is_recording ? 'text-red-600' : 'text-gray-900'}`}>
{camera.is_recording ? 'Yes' : 'No'}
</span>
</div>
{camera.device_info?.serial_number && (
<div className="flex justify-between">
<span className="text-gray-500">Serial:</span>
<span className="text-gray-900">{camera.device_info.serial_number}</span>
</div>
)}
{/* Debug info - remove this after fixing */}
<div className="mt-2 p-2 bg-gray-50 border border-gray-200 rounded text-xs">
<div className="font-medium text-gray-700 mb-1">Debug Info:</div>
<div className="text-gray-600">
<div>Has device_info: {hasDeviceInfo ? 'Yes' : 'No'}</div>
<div>Has friendly_name: {friendlyName ? 'Yes' : 'No'}</div>
<div>Has serial: {hasSerial ? 'Yes' : 'No'}</div>
<div>Last error: {camera.last_error || 'None'}</div>
{camera.device_info && (
<div className="mt-1">
<div>Raw device_info: {JSON.stringify(camera.device_info)}</div>
</div>
)}
</div>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Last checked:</span>
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
</div>
{camera.current_recording_file && (
<div className="flex justify-between">
<span className="text-gray-500">Recording file:</span>
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{/* Machines Status */}
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Machines</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Current status of all machines in the system
</p>
</div>
<div className="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
{Object.entries(systemStatus.machines).map(([machineName, machine]) => (
<div key={machineName} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-gray-900 capitalize">
{machineName.replace(/_/g, ' ')}
</h4>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMachineStateColor(machine.state)}`}>
{machine.state}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Last updated:</span>
<span className="text-gray-900">{new Date(machine.last_updated).toLocaleTimeString()}</span>
</div>
{machine.last_message && (
<div className="flex justify-between">
<span className="text-gray-500">Last message:</span>
<span className="text-gray-900">{machine.last_message}</span>
</div>
)}
{machine.mqtt_topic && (
<div className="flex justify-between">
<span className="text-gray-500">MQTT topic:</span>
<span className="text-gray-900 text-xs font-mono">{machine.mqtt_topic}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Storage Statistics */}
{storageStats && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Storage usage and file statistics
</p>
</div>
<div className="border-t border-gray-200 p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
<div className="text-sm text-gray-500">Total Files</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
<div className="text-sm text-gray-500">Total Size</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
<div className="text-sm text-gray-500">Free Space</div>
</div>
</div>
{/* Disk Usage Bar */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Disk Usage</span>
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
</div>
</div>
{/* Per-Camera Statistics */}
{Object.keys(storageStats.cameras).length > 0 && (
<div>
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => {
// Find the corresponding camera to get friendly name
const camera = systemStatus?.cameras[cameraName]
const displayName = camera?.device_info?.friendly_name || cameraName
return (
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
<h5 className="font-medium text-gray-900 mb-2">
{camera?.device_info?.friendly_name ? (
<>
{displayName}
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
</>
) : (
cameraName
)}
</h5>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Files:</span>
<span className="text-gray-900">{stats.file_count}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Size:</span>
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)}
{/* Recent Recordings */}
{Object.keys(recordings).length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Latest recording sessions
</p>
</div>
<div className="border-t border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Camera
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Filename
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
// Find the corresponding camera to get friendly name
const camera = systemStatus?.cameras[recording.camera_name]
const displayName = camera?.device_info?.friendly_name || recording.camera_name
return (
<tr key={recordingId}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{camera?.device_info?.friendly_name ? (
<div>
<div>{displayName}</div>
<div className="text-xs text-gray-500">({recording.camera_name})</div>
</div>
) : (
recording.camera_name
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
{recording.filename}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(recording.state)}`}>
{recording.state}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(recording.start_time).toLocaleString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -38,15 +38,15 @@ export interface Experiment {
throughput_rate_pecans_sec: number
crush_amount_in: number
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
created_by: string
}
export interface CreateExperimentRequest {
experiment_number: number
reps_required: number
@@ -56,10 +56,8 @@ export interface CreateExperimentRequest {
throughput_rate_pecans_sec: number
crush_amount_in: number
entry_exit_height_diff_in: number
schedule_status?: ScheduleStatus
results_status?: ResultsStatus
completion_status?: boolean
scheduled_date?: string | null
}
export interface UpdateExperimentRequest {
@@ -71,25 +69,54 @@ export interface UpdateExperimentRequest {
throughput_rate_pecans_sec?: number
crush_amount_in?: number
entry_exit_height_diff_in?: number
schedule_status?: ScheduleStatus
results_status?: ResultsStatus
completion_status?: boolean
}
export interface CreateRepetitionRequest {
experiment_id: string
repetition_number: number
scheduled_date?: string | null
schedule_status?: ScheduleStatus
}
export interface UpdateRepetitionRequest {
scheduled_date?: string | null
schedule_status?: ScheduleStatus
completion_status?: boolean
}
// Data Entry System Interfaces
export type DataEntryStatus = 'draft' | 'submitted'
export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
export interface ExperimentDataEntry {
export interface ExperimentPhaseDraft {
id: string
experiment_id: string
repetition_id: string
user_id: string
status: DataEntryStatus
entry_name?: string | null
phase_name: ExperimentPhase
status: PhaseDraftStatus
draft_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
withdrawn_at?: string | null
}
export interface ExperimentRepetition {
id: string
experiment_id: string
repetition_number: number
scheduled_date?: string | null
schedule_status: ScheduleStatus
completion_status: boolean
is_locked: boolean
locked_at?: string | null
locked_by?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface PecanDiameterMeasurement {
@@ -102,7 +129,7 @@ export interface PecanDiameterMeasurement {
export interface ExperimentPhaseData {
id: string
data_entry_id: string
phase_draft_id: string
phase_name: ExperimentPhase
// Pre-soaking phase
@@ -141,15 +168,17 @@ export interface ExperimentPhaseData {
diameter_measurements?: PecanDiameterMeasurement[]
}
export interface CreateDataEntryRequest {
export interface CreatePhaseDraftRequest {
experiment_id: string
entry_name?: string
status?: DataEntryStatus
repetition_id: string
phase_name: ExperimentPhase
draft_name?: string
status?: PhaseDraftStatus
}
export interface UpdateDataEntryRequest {
entry_name?: string
status?: DataEntryStatus
export interface UpdatePhaseDraftRequest {
draft_name?: string
status?: PhaseDraftStatus
}
export interface CreatePhaseDataRequest {
@@ -440,25 +469,7 @@ export const experimentManagement = {
return data
},
// Schedule an experiment
async scheduleExperiment(id: string, scheduledDate: string): Promise<Experiment> {
const updates: UpdateExperimentRequest = {
scheduled_date: scheduledDate,
schedule_status: 'scheduled'
}
return this.updateExperiment(id, updates)
},
// Remove experiment schedule
async removeExperimentSchedule(id: string): Promise<Experiment> {
const updates: UpdateExperimentRequest = {
scheduled_date: null,
schedule_status: 'pending schedule'
}
return this.updateExperiment(id, updates)
},
// Check if experiment number is unique
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
@@ -478,45 +489,237 @@ export const experimentManagement = {
}
}
// Data Entry Management
export const dataEntryManagement = {
// Get all data entries for an experiment
async getDataEntriesForExperiment(experimentId: string): Promise<ExperimentDataEntry[]> {
// Experiment Repetitions Management
export const repetitionManagement = {
// Get all repetitions for an experiment
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data, error } = await supabase
.from('experiment_data_entries')
.from('experiment_repetitions')
.select('*')
.eq('experiment_id', experimentId)
.order('repetition_number', { ascending: true })
if (error) throw error
return data
},
// Create a new repetition
async createRepetition(repetitionData: CreateRepetitionRequest): Promise<ExperimentRepetition> {
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_repetitions')
.insert({
...repetitionData,
created_by: user.id
})
.select()
.single()
if (error) throw error
return data
},
// Update a repetition
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
const { data, error } = await supabase
.from('experiment_repetitions')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
},
// Schedule a repetition
async scheduleRepetition(id: string, scheduledDate: string): Promise<ExperimentRepetition> {
const updates: UpdateRepetitionRequest = {
scheduled_date: scheduledDate,
schedule_status: 'scheduled'
}
return this.updateRepetition(id, updates)
},
// Remove repetition schedule
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
const updates: UpdateRepetitionRequest = {
scheduled_date: null,
schedule_status: 'pending schedule'
}
return this.updateRepetition(id, updates)
},
// Delete a repetition
async deleteRepetition(id: string): Promise<void> {
const { error } = await supabase
.from('experiment_repetitions')
.delete()
.eq('id', id)
if (error) throw error
},
// Get repetitions by status
async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise<ExperimentRepetition[]> {
let query = supabase.from('experiment_repetitions').select('*')
if (scheduleStatus) {
query = query.eq('schedule_status', scheduleStatus)
}
const { data, error } = await query.order('created_at', { ascending: false })
if (error) throw error
return data
},
// Get repetitions with experiment details
async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> {
const { data, error } = await supabase
.from('experiment_repetitions')
.select(`
*,
experiment:experiments(*)
`)
.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[]> {
// Create all repetitions for an experiment
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
// First get the experiment to know how many reps are required
const { data: experiment, error: expError } = await supabase
.from('experiments')
.select('reps_required')
.eq('id', experimentId)
.single()
if (expError) throw expError
// Create repetitions for each required rep
const repetitions: CreateRepetitionRequest[] = []
for (let i = 1; i <= experiment.reps_required; i++) {
repetitions.push({
experiment_id: experimentId,
repetition_number: i,
schedule_status: 'pending schedule'
})
}
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_repetitions')
.insert(repetitions.map(rep => ({
...rep,
created_by: user.id
})))
.select()
if (error) throw error
return data
},
// Lock a repetition (admin only)
async lockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
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')
.from('experiment_repetitions')
.update({
is_locked: true,
locked_at: new Date().toISOString(),
locked_by: user.id
})
.eq('id', repetitionId)
.select()
.single()
if (error) throw error
return data
},
// Unlock a repetition (admin only)
async unlockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
const { data, error } = await supabase
.from('experiment_repetitions')
.update({
is_locked: false,
locked_at: null,
locked_by: null
})
.eq('id', repetitionId)
.select()
.single()
if (error) throw error
return data
}
}
// Phase Draft Management
export const phaseDraftManagement = {
// Get all phase drafts for a repetition
async getPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
const { data, error } = await supabase
.from('experiment_phase_drafts')
.select('*')
.eq('experiment_id', experimentId)
.eq('user_id', targetUserId)
.eq('repetition_id', repetitionId)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
// Create a new data entry
async createDataEntry(request: CreateDataEntryRequest): Promise<ExperimentDataEntry> {
// Get user's phase drafts for a repetition
async getUserPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
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')
.from('experiment_phase_drafts')
.select('*')
.eq('repetition_id', repetitionId)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
// Get user's phase drafts for a specific phase and repetition
async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise<ExperimentPhaseDraft[]> {
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_phase_drafts')
.select('*')
.eq('repetition_id', repetitionId)
.eq('user_id', user.id)
.eq('phase_name', phase)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
// Create a new phase draft
async createPhaseDraft(request: CreatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
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_phase_drafts')
.insert({
...request,
user_id: user.id
@@ -528,10 +731,10 @@ export const dataEntryManagement = {
return data
},
// Update a data entry
async updateDataEntry(id: string, updates: UpdateDataEntryRequest): Promise<ExperimentDataEntry> {
// Update a phase draft
async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
const { data, error } = await supabase
.from('experiment_data_entries')
.from('experiment_phase_drafts')
.update(updates)
.eq('id', id)
.select()
@@ -541,65 +744,53 @@ export const dataEntryManagement = {
return data
},
// Delete a data entry (only drafts)
async deleteDataEntry(id: string): Promise<void> {
// Delete a phase draft (only drafts)
async deletePhaseDraft(id: string): Promise<void> {
const { error } = await supabase
.from('experiment_data_entries')
.from('experiment_phase_drafts')
.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' })
// Submit a phase draft (change status from draft to submitted)
async submitPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
return this.updatePhaseDraft(id, { status: 'submitted' })
},
// Get phase data for a data entry
async getPhaseDataForEntry(dataEntryId: string): Promise<ExperimentPhaseData[]> {
// Withdraw a phase draft (change status from submitted to withdrawn)
async withdrawPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
return this.updatePhaseDraft(id, { status: 'withdrawn' })
},
// Get phase data for a phase draft
async getPhaseDataForDraft(phaseDraftId: string): Promise<ExperimentPhaseData | null> {
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)
.eq('phase_draft_id', phaseDraftId)
.single()
if (error) {
if (error.code === 'PGRST116') return null // Not found
if (error.code === 'PGRST116') return null // No rows found
throw error
}
return data
},
// Create or update phase data
async upsertPhaseData(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
// Create or update phase data for a draft
async upsertPhaseData(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
const { data, error } = await supabase
.from('experiment_phase_data')
.upsert({
data_entry_id: dataEntryId,
phase_name: phaseName,
phase_draft_id: phaseDraftId,
...phaseData
}, {
onConflict: 'data_entry_id,phase_name'
onConflict: 'phase_draft_id,phase_name'
})
.select()
.single()
@@ -641,9 +832,9 @@ export const dataEntryManagement = {
},
// Auto-save draft data (for periodic saves)
async autoSaveDraft(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
async autoSaveDraft(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
try {
await this.upsertPhaseData(dataEntryId, phaseName, phaseData)
await this.upsertPhaseData(phaseDraftId, phaseData)
} catch (error) {
console.warn('Auto-save failed:', error)
// Don't throw error for auto-save failures

336
src/lib/visionApi.ts Normal file
View File

@@ -0,0 +1,336 @@
// Vision System API Client
// Base URL for the vision system API
const VISION_API_BASE_URL = 'http://localhost:8000'
// Types based on the API documentation
export interface SystemStatus {
system_started: boolean
mqtt_connected: boolean
last_mqtt_message: string
machines: Record<string, MachineStatus>
cameras: Record<string, CameraStatus>
active_recordings: number
total_recordings: number
uptime_seconds: number
}
export interface MachineStatus {
name: string
state: string
last_updated: string
last_message?: string
mqtt_topic?: string
}
export interface CameraStatus {
name: string
status: string
is_recording: boolean
last_checked: string
last_error: string | null
device_info?: {
friendly_name: string
serial_number: string
}
current_recording_file: string | null
recording_start_time: string | null
}
export interface RecordingInfo {
camera_name: string
filename: string
start_time: string
state: string
end_time?: string
file_size_bytes?: number
frame_count?: number
duration_seconds?: number
error_message?: string | null
}
export interface StorageStats {
base_path: string
total_files: number
total_size_bytes: number
cameras: Record<string, {
file_count: number
total_size_bytes: number
}>
disk_usage: {
total: number
used: number
free: number
}
}
export interface RecordingFile {
filename: string
camera_name: string
file_size_bytes: number
created_date: string
duration_seconds?: number
}
export interface StartRecordingRequest {
filename?: string
exposure_ms?: number
gain?: number
fps?: number
}
export interface StartRecordingResponse {
success: boolean
message: string
filename: string
}
export interface StopRecordingResponse {
success: boolean
message: string
duration_seconds: number
}
export interface CameraTestResponse {
success: boolean
message: string
camera_name: string
timestamp: string
}
export interface CameraRecoveryResponse {
success: boolean
message: string
camera_name: string
operation: string
timestamp: string
}
export interface MqttMessage {
timestamp: string
topic: string
message: string
source: string
}
export interface MqttStatus {
connected: boolean
broker_host: string
broker_port: number
subscribed_topics: string[]
last_message_time: string
message_count: number
error_count: number
uptime_seconds: number
}
export interface MqttEvent {
machine_name: string
topic: string
payload: string
normalized_state: string
timestamp: string
message_number: number
}
export interface MqttEventsResponse {
events: MqttEvent[]
total_events: number
last_updated: string
}
export interface FileListRequest {
camera_name?: string
start_date?: string
end_date?: string
limit?: number
}
export interface FileListResponse {
files: RecordingFile[]
total_count: number
}
export interface CleanupRequest {
max_age_days?: number
}
export interface CleanupResponse {
files_removed: number
bytes_freed: number
errors: string[]
}
// API Client Class
class VisionApiClient {
private baseUrl: string
constructor(baseUrl: string = VISION_API_BASE_URL) {
this.baseUrl = baseUrl
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
}
return response.json()
}
// System endpoints
async getHealth(): Promise<{ status: string; timestamp: string }> {
return this.request('/health')
}
async getSystemStatus(): Promise<SystemStatus> {
return this.request('/system/status')
}
// Machine endpoints
async getMachines(): Promise<Record<string, MachineStatus>> {
return this.request('/machines')
}
// MQTT endpoints
async getMqttStatus(): Promise<MqttStatus> {
return this.request('/mqtt/status')
}
async getMqttEvents(limit: number = 10): Promise<MqttEventsResponse> {
return this.request(`/mqtt/events?limit=${limit}`)
}
// Camera endpoints
async getCameras(): Promise<Record<string, CameraStatus>> {
return this.request('/cameras')
}
async getCameraStatus(cameraName: string): Promise<CameraStatus> {
return this.request(`/cameras/${cameraName}/status`)
}
// Recording control
async startRecording(cameraName: string, params: StartRecordingRequest = {}): Promise<StartRecordingResponse> {
return this.request(`/cameras/${cameraName}/start-recording`, {
method: 'POST',
body: JSON.stringify(params),
})
}
async stopRecording(cameraName: string): Promise<StopRecordingResponse> {
return this.request(`/cameras/${cameraName}/stop-recording`, {
method: 'POST',
})
}
// Camera diagnostics
async testCameraConnection(cameraName: string): Promise<CameraTestResponse> {
return this.request(`/cameras/${cameraName}/test-connection`, {
method: 'POST',
})
}
async reconnectCamera(cameraName: string): Promise<CameraRecoveryResponse> {
return this.request(`/cameras/${cameraName}/reconnect`, {
method: 'POST',
})
}
async restartCameraGrab(cameraName: string): Promise<CameraRecoveryResponse> {
return this.request(`/cameras/${cameraName}/restart-grab`, {
method: 'POST',
})
}
async resetCameraTimestamp(cameraName: string): Promise<CameraRecoveryResponse> {
return this.request(`/cameras/${cameraName}/reset-timestamp`, {
method: 'POST',
})
}
async fullCameraReset(cameraName: string): Promise<CameraRecoveryResponse> {
return this.request(`/cameras/${cameraName}/full-reset`, {
method: 'POST',
})
}
async reinitializeCamera(cameraName: string): Promise<CameraRecoveryResponse> {
return this.request(`/cameras/${cameraName}/reinitialize`, {
method: 'POST',
})
}
// Recording sessions
async getRecordings(): Promise<Record<string, RecordingInfo>> {
return this.request('/recordings')
}
// Storage endpoints
async getStorageStats(): Promise<StorageStats> {
return this.request('/storage/stats')
}
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
return this.request('/storage/files', {
method: 'POST',
body: JSON.stringify(params),
})
}
async cleanupStorage(params: CleanupRequest = {}): Promise<CleanupResponse> {
return this.request('/storage/cleanup', {
method: 'POST',
body: JSON.stringify(params),
})
}
}
// Export a singleton instance
export const visionApi = new VisionApiClient()
// Utility functions
export const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`
} else if (minutes > 0) {
return `${minutes}m ${secs}s`
} else {
return `${secs}s`
}
}
export const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}d ${hours}h ${minutes}m`
} else if (hours > 0) {
return `${hours}h ${minutes}m`
} else {
return `${minutes}m`
}
}

View File

@@ -0,0 +1,51 @@
// Simple test file to verify vision API client functionality
// This is not a formal test suite, just a manual verification script
import { visionApi, formatBytes, formatDuration, formatUptime } from '../lib/visionApi'
// Test utility functions
console.log('Testing utility functions:')
console.log('formatBytes(1024):', formatBytes(1024)) // Should be "1 KB"
console.log('formatBytes(1048576):', formatBytes(1048576)) // Should be "1 MB"
console.log('formatDuration(65):', formatDuration(65)) // Should be "1m 5s"
console.log('formatUptime(3661):', formatUptime(3661)) // Should be "1h 1m"
// Test API endpoints (these will fail if vision system is not running)
export async function testVisionApi() {
try {
console.log('Testing vision API endpoints...')
// Test health endpoint
const health = await visionApi.getHealth()
console.log('Health check:', health)
// Test system status
const status = await visionApi.getSystemStatus()
console.log('System status:', status)
// Test cameras
const cameras = await visionApi.getCameras()
console.log('Cameras:', cameras)
// Test machines
const machines = await visionApi.getMachines()
console.log('Machines:', machines)
// Test storage stats
const storage = await visionApi.getStorageStats()
console.log('Storage stats:', storage)
// Test recordings
const recordings = await visionApi.getRecordings()
console.log('Recordings:', recordings)
console.log('All API tests passed!')
return true
} catch (error) {
console.error('API test failed:', error)
return false
}
}
// Uncomment the line below to run the test when this file is imported
// testVisionApi()