Refactor: enhance dashboard layout and experiment management features
- Added functionality to save and retrieve the current dashboard view in localStorage for improved user experience. - Updated DashboardLayout component to handle view changes with access control based on user roles. - Renamed Experiments component to ExperimentManagement for clarity. - Introduced new ExperimentPhase interface and related utility functions for managing experiment phases. - Updated seed data to include initial roles and experiment phases for testing. - Cleaned up unnecessary blank lines in various files for better code readability.
This commit is contained in:
@@ -71,6 +71,7 @@ function App() {
|
||||
|
||||
// Clear any local storage items
|
||||
localStorage.removeItem('supabase.auth.token')
|
||||
localStorage.removeItem('dashboard-current-view')
|
||||
|
||||
// Reset state
|
||||
setIsAuthenticated(false)
|
||||
@@ -79,6 +80,7 @@ function App() {
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
// Still reset state even if there's an error
|
||||
localStorage.removeItem('dashboard-current-view')
|
||||
setIsAuthenticated(false)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
|
||||
@@ -25,3 +25,9 @@ export function CameraRoute({ cameraNumber }: CameraRouteProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Sidebar } from './Sidebar'
|
||||
import { TopNavbar } from './TopNavbar'
|
||||
import { DashboardHome } from './DashboardHome'
|
||||
import { UserManagement } from './UserManagement'
|
||||
import { Experiments } from './Experiments'
|
||||
import { ExperimentManagement } from './ExperimentManagement'
|
||||
import { DataEntry } from './DataEntry'
|
||||
import { VisionSystem } from './VisionSystem'
|
||||
import { Scheduling } from './Scheduling'
|
||||
import { VideoStreamingPage } from '../features/video-streaming'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
@@ -22,10 +23,68 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Valid dashboard views
|
||||
const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library']
|
||||
|
||||
// Save current view to localStorage
|
||||
const saveCurrentView = (view: string) => {
|
||||
try {
|
||||
localStorage.setItem('dashboard-current-view', view)
|
||||
} catch (error) {
|
||||
console.warn('Failed to save current view to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get saved view from localStorage
|
||||
const getSavedView = (): string => {
|
||||
try {
|
||||
const savedView = localStorage.getItem('dashboard-current-view')
|
||||
return savedView && validViews.includes(savedView) ? savedView : 'dashboard'
|
||||
} catch (error) {
|
||||
console.warn('Failed to get saved view from localStorage:', error)
|
||||
return 'dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has access to a specific view
|
||||
const hasAccessToView = (view: string): boolean => {
|
||||
if (!user) return false
|
||||
|
||||
// Admin-only views
|
||||
if (view === 'user-management') {
|
||||
return user.roles.includes('admin')
|
||||
}
|
||||
|
||||
// All other views are accessible to authenticated users
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle view change with persistence
|
||||
const handleViewChange = (view: string) => {
|
||||
if (validViews.includes(view) && hasAccessToView(view)) {
|
||||
setCurrentView(view)
|
||||
saveCurrentView(view)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile()
|
||||
}, [])
|
||||
|
||||
// Restore saved view when user is loaded
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
const savedView = getSavedView()
|
||||
if (hasAccessToView(savedView)) {
|
||||
setCurrentView(savedView)
|
||||
} else {
|
||||
// If user doesn't have access to saved view, default to dashboard
|
||||
setCurrentView('dashboard')
|
||||
saveCurrentView('dashboard')
|
||||
}
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -46,9 +105,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Navigate to signout route which will handle the actual logout
|
||||
window.history.pushState({}, '', '/signout')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
// Call the logout function passed from parent
|
||||
onLogout()
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
@@ -88,7 +146,7 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
)
|
||||
}
|
||||
case 'experiments':
|
||||
return <Experiments />
|
||||
return <ExperimentManagement />
|
||||
case 'analytics':
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -104,6 +162,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
return <DataEntry />
|
||||
case 'vision-system':
|
||||
return <VisionSystem />
|
||||
case 'scheduling':
|
||||
return <Scheduling user={user} />
|
||||
case 'video-library':
|
||||
return <VideoStreamingPage />
|
||||
default:
|
||||
@@ -162,7 +222,7 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
<Sidebar
|
||||
user={user}
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
onViewChange={handleViewChange}
|
||||
isExpanded={isExpanded}
|
||||
isMobileOpen={isMobileOpen}
|
||||
isHovered={isHovered}
|
||||
|
||||
@@ -7,9 +7,10 @@ interface ExperimentFormProps {
|
||||
onCancel: () => void
|
||||
isEditing?: boolean
|
||||
loading?: boolean
|
||||
phaseId?: string
|
||||
}
|
||||
|
||||
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) {
|
||||
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false, phaseId }: ExperimentFormProps) {
|
||||
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
|
||||
experiment_number: initialData?.experiment_number || 0,
|
||||
reps_required: initialData?.reps_required || 1,
|
||||
@@ -21,7 +22,8 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
|
||||
entry_exit_height_diff_in: initialData?.entry_exit_height_diff_in || 0,
|
||||
schedule_status: initialData?.schedule_status || 'pending schedule',
|
||||
results_status: initialData?.results_status || 'valid',
|
||||
completion_status: initialData?.completion_status || false
|
||||
completion_status: initialData?.completion_status || false,
|
||||
phase_id: initialData?.phase_id || phaseId
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react'
|
||||
import { ExperimentPhases } from './ExperimentPhases'
|
||||
import { PhaseExperiments } from './PhaseExperiments'
|
||||
import type { ExperimentPhase } from '../lib/supabase'
|
||||
|
||||
type ViewState = 'phases' | 'experiments'
|
||||
|
||||
export function ExperimentManagement() {
|
||||
const [currentView, setCurrentView] = useState<ViewState>('phases')
|
||||
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
|
||||
|
||||
const handlePhaseSelect = (phase: ExperimentPhase) => {
|
||||
setSelectedPhase(phase)
|
||||
setCurrentView('experiments')
|
||||
}
|
||||
|
||||
const handleBackToPhases = () => {
|
||||
setSelectedPhase(null)
|
||||
setCurrentView('phases')
|
||||
}
|
||||
|
||||
if (currentView === 'experiments' && selectedPhase) {
|
||||
return (
|
||||
<PhaseExperiments
|
||||
phase={selectedPhase}
|
||||
onBack={handleBackToPhases}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ExperimentPhases
|
||||
onPhaseSelect={handlePhaseSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,10 @@ interface ExperimentModalProps {
|
||||
experiment?: Experiment
|
||||
onClose: () => void
|
||||
onExperimentSaved: (experiment: Experiment) => void
|
||||
phaseId?: string
|
||||
}
|
||||
|
||||
export function ExperimentModal({ experiment, onClose, onExperimentSaved }: ExperimentModalProps) {
|
||||
export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseId }: ExperimentModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -119,6 +120,7 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved }: Expe
|
||||
onCancel={handleCancel}
|
||||
isEditing={isEditing}
|
||||
loading={loading}
|
||||
phaseId={phaseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
156
management-dashboard-web-app/src/components/ExperimentPhases.tsx
Normal file
156
management-dashboard-web-app/src/components/ExperimentPhases.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { experimentPhaseManagement, userManagement } from '../lib/supabase'
|
||||
import type { ExperimentPhase, User } from '../lib/supabase'
|
||||
|
||||
interface ExperimentPhasesProps {
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
const [phases, setPhases] = useState<ExperimentPhase[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [phasesData, userData] = await Promise.all([
|
||||
experimentPhaseManagement.getAllExperimentPhases(),
|
||||
userManagement.getCurrentUser()
|
||||
])
|
||||
|
||||
setPhases(phasesData)
|
||||
setCurrentUser(userData)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load experiment phases')
|
||||
console.error('Load experiment phases error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canManagePhases = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Experiment Phases</h1>
|
||||
<p className="mt-2 text-gray-600">Select an experiment phase to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
|
||||
</div>
|
||||
{canManagePhases && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement create phase modal
|
||||
alert('Create phase functionality will be implemented')
|
||||
}}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ New Phase
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phases Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{phases.map((phase) => (
|
||||
<div
|
||||
key={phase.id}
|
||||
onClick={() => onPhaseSelect(phase)}
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{phase.name}
|
||||
</h3>
|
||||
|
||||
{phase.description && (
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{phase.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Created {new Date(phase.created_at).toLocaleDateString()}</span>
|
||||
<div className="flex items-center text-blue-600 hover:text-blue-800">
|
||||
<span className="mr-1">View Experiments</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{phases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiment phases found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first experiment phase.
|
||||
</p>
|
||||
{canManagePhases && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement create phase modal
|
||||
alert('Create phase functionality will be implemented')
|
||||
}}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Create First Phase
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,3 +134,9 @@ export function LiveCameraView({ cameraName }: LiveCameraViewProps) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
460
management-dashboard-web-app/src/components/PhaseExperiments.tsx
Normal file
460
management-dashboard-web-app/src/components/PhaseExperiments.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExperimentModal } from './ExperimentModal'
|
||||
import { RepetitionScheduleModal } from './RepetitionScheduleModal'
|
||||
import { experimentManagement, repetitionManagement, userManagement } from '../lib/supabase'
|
||||
import type { Experiment, ExperimentRepetition, User, ScheduleStatus, ResultsStatus, ExperimentPhase } from '../lib/supabase'
|
||||
|
||||
interface PhaseExperimentsProps {
|
||||
phase: ExperimentPhase
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
|
||||
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 [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false)
|
||||
const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [phase.id])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [experimentsData, userData] = await Promise.all([
|
||||
experimentManagement.getExperimentsByPhaseId(phase.id),
|
||||
userManagement.getCurrentUser()
|
||||
])
|
||||
|
||||
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)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canManageExperiments = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
|
||||
|
||||
const handleCreateExperiment = () => {
|
||||
setEditingExperiment(undefined)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEditExperiment = (experiment: Experiment) => {
|
||||
setEditingExperiment(experiment)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleExperimentSaved = async (experiment: Experiment) => {
|
||||
if (editingExperiment) {
|
||||
// Update existing experiment
|
||||
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp))
|
||||
} else {
|
||||
// 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 handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSchedulingRepetition({ experiment, repetition })
|
||||
setShowRepetitionScheduleModal(true)
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!currentUser?.roles.includes('admin')) {
|
||||
alert('Only administrators can delete experiments.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete Experiment #${experiment.experiment_number}? This action cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await experimentManagement.deleteExperiment(experiment.id)
|
||||
setExperiments(prev => prev.filter(exp => exp.id !== experiment.id))
|
||||
} catch (err: any) {
|
||||
alert(`Failed to delete experiment: ${err.message}`)
|
||||
console.error('Delete experiment error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const 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) => {
|
||||
switch (status) {
|
||||
case 'pending schedule':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'scheduled':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'canceled':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'aborted':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'valid':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'invalid':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 mr-4"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Phases
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{phase.name}</h1>
|
||||
{phase.description && (
|
||||
<p className="mt-2 text-gray-600">{phase.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-gray-600">Manage experiments within this phase</p>
|
||||
</div>
|
||||
{canManageExperiments && (
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ New Experiment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experiments Table */}
|
||||
<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">
|
||||
Experiments ({experiments.length})
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
{canManageExperiments ? 'Click on any experiment to edit details' : 'View experiment definitions and status'}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
Experiment #
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reps Required
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Experiment Parameters
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Repetitions Status
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Manage Repetitions
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Results Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Completion
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{experiments.map((experiment) => (
|
||||
<tr
|
||||
key={experiment.id}
|
||||
className={canManageExperiments ? "hover:bg-gray-50 cursor-pointer" : ""}
|
||||
onClick={canManageExperiments ? () => handleEditExperiment(experiment) : undefined}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
#{experiment.experiment_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{experiment.reps_required}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="space-y-1">
|
||||
<div>Soaking: {experiment.soaking_duration_hr}h</div>
|
||||
<div>Drying: {experiment.air_drying_time_min}min</div>
|
||||
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{(() => {
|
||||
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="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>
|
||||
)}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(experiment.results_status)}`}>
|
||||
{experiment.results_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${experiment.completion_status
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(experiment.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
{canManageExperiments && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditExperiment(experiment)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{currentUser?.roles.includes('admin') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteExperiment(experiment)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{experiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found in this phase</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first experiment in this phase.
|
||||
</p>
|
||||
{canManageExperiments && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Create First Experiment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Experiment Modal */}
|
||||
{showModal && (
|
||||
<ExperimentModal
|
||||
experiment={editingExperiment}
|
||||
onClose={() => setShowModal(false)}
|
||||
onExperimentSaved={handleExperimentSaved}
|
||||
phaseId={phase.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Repetition Schedule Modal */}
|
||||
{showRepetitionScheduleModal && schedulingRepetition && (
|
||||
<RepetitionScheduleModal
|
||||
experiment={schedulingRepetition.experiment}
|
||||
repetition={schedulingRepetition.repetition}
|
||||
onClose={() => setShowRepetitionScheduleModal(false)}
|
||||
onScheduleUpdated={handleRepetitionScheduleUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
management-dashboard-web-app/src/components/Scheduling.tsx
Normal file
37
management-dashboard-web-app/src/components/Scheduling.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface SchedulingProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export function Scheduling({ user }: SchedulingProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Scheduling
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This is the scheduling module of the dashboard. Here you can indicate your availability for upcoming experiment runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-16 h-16 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600 dark:text-blue-400" 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>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Scheduling Module
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
This module will allow you to manage your availability and schedule for upcoming experiment runs.
|
||||
Features will include calendar integration, availability settings, and experiment scheduling.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -100,6 +100,15 @@ export function Sidebar({
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'scheduling',
|
||||
name: 'Scheduling',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" 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>
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
@@ -289,8 +298,8 @@ export function Sidebar({
|
||||
<div>
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
|
||||
@@ -32,6 +32,15 @@ export interface Role {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ExperimentPhase {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Experiment {
|
||||
id: string
|
||||
experiment_number: number
|
||||
@@ -44,6 +53,7 @@ export interface Experiment {
|
||||
entry_exit_height_diff_in: number
|
||||
results_status: ResultsStatus
|
||||
completion_status: boolean
|
||||
phase_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
@@ -51,6 +61,16 @@ export interface Experiment {
|
||||
|
||||
|
||||
|
||||
export interface CreateExperimentPhaseRequest {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UpdateExperimentPhaseRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
@@ -62,6 +82,7 @@ export interface CreateExperimentRequest {
|
||||
entry_exit_height_diff_in: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
phase_id?: string
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
@@ -75,6 +96,7 @@ export interface UpdateExperimentRequest {
|
||||
entry_exit_height_diff_in?: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
phase_id?: string
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
@@ -378,6 +400,76 @@ export const userManagement = {
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment phase management utility functions
|
||||
export const experimentPhaseManagement = {
|
||||
// Get all experiment phases
|
||||
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiment phase by ID
|
||||
async getExperimentPhaseById(id: string): Promise<ExperimentPhase | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new experiment phase
|
||||
async createExperimentPhase(phaseData: CreateExperimentPhaseRequest): Promise<ExperimentPhase> {
|
||||
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_phases')
|
||||
.insert({
|
||||
...phaseData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update an experiment phase
|
||||
async updateExperimentPhase(id: string, updates: UpdateExperimentPhaseRequest): Promise<ExperimentPhase> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete an experiment phase
|
||||
async deleteExperimentPhase(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment management utility functions
|
||||
export const experimentManagement = {
|
||||
// Get all experiments
|
||||
@@ -391,6 +483,18 @@ export const experimentManagement = {
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiments by phase ID
|
||||
async getExperimentsByPhaseId(phaseId: string): Promise<Experiment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.eq('phase_id', phaseId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiment by ID
|
||||
async getExperimentById(id: string): Promise<Experiment | null> {
|
||||
const { data, error } = await supabase
|
||||
|
||||
Reference in New Issue
Block a user