Add scheduling-remote service to docker-compose and enhance camera error handling

- Introduced a new service for scheduling-remote in docker-compose.yml, allowing for better management of scheduling functionalities.
- Enhanced error handling in CameraMonitor and CameraStreamer classes to improve robustness during camera initialization and streaming processes.
- Updated various components in the management dashboard to support dark mode and improve user experience with consistent styling.
- Implemented feature flags for enabling/disabling modules, including the new scheduling module.
This commit is contained in:
salirezav
2025-11-02 19:33:13 -05:00
parent f6a37ca1ba
commit 868aa3f036
33 changed files with 7471 additions and 136 deletions

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scheduling Module</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4494
scheduling-remote/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "scheduling-remote",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:watch": "vite build --watch",
"serve:dist": "serve -s dist -l 3003",
"preview": "vite preview --port 3003",
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3003 --cors -c-1"
},
"dependencies": {
"@supabase/supabase-js": "^2.52.0",
"moment": "^2.30.1",
"react": "^19.1.0",
"react-big-calendar": "^1.19.4",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { Scheduling } from './components/Scheduling'
import type { User } from './services/supabase'
interface AppProps {
user?: User
currentRoute?: string
}
export default function App(props: AppProps) {
// Get user and route from props or try to get from window (for standalone testing)
const user = props.user || (window as any).__SCHEDULING_USER__
const currentRoute = props.currentRoute || window.location.pathname
if (!user) {
return (
<div className="p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
User information is required to use the scheduling module. Please ensure you are logged in.
</div>
</div>
</div>
)
}
return <Scheduling user={user} currentRoute={currentRoute} />
}

View File

@@ -0,0 +1,250 @@
/* Custom styles for React Big Calendar to match dashboard theme */
.rbc-calendar {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* Dark mode support */
.dark .rbc-calendar {
background: #1f2937;
color: #f9fafb;
}
/* Header styling */
.rbc-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
color: #374151;
font-weight: 600;
padding: 12px 8px;
}
.dark .rbc-header {
background: #374151;
border-bottom: 1px solid #4b5563;
color: #f9fafb;
}
/* Today styling */
.rbc-today {
background: #eff6ff;
}
.dark .rbc-today {
background: #1e3a8a;
}
/* Date cells */
.rbc-date-cell {
color: #374151;
font-weight: 500;
}
.dark .rbc-date-cell {
color: #f9fafb;
}
/* Event styling */
.rbc-event {
border-radius: 4px;
border: none;
font-size: 12px;
font-weight: 500;
padding: 2px 4px;
}
.rbc-event-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Month view specific */
.rbc-month-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.dark .rbc-month-view {
border: 1px solid #4b5563;
}
.rbc-month-row {
border-bottom: 1px solid #e2e8f0;
}
.dark .rbc-month-row {
border-bottom: 1px solid #4b5563;
}
.rbc-date-cell {
padding: 8px;
}
/* Week and day view specific */
.rbc-time-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.dark .rbc-time-view {
border: 1px solid #4b5563;
}
.rbc-time-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.dark .rbc-time-header {
background: #374151;
border-bottom: 1px solid #4b5563;
}
.rbc-time-content {
background: white;
}
.dark .rbc-time-content {
background: #1f2937;
}
/* Time slots */
.rbc-time-slot {
border-top: 1px solid #f1f5f9;
color: #64748b;
}
.dark .rbc-time-slot {
border-top: 1px solid #374151;
color: #9ca3af;
}
/* Toolbar */
.rbc-toolbar {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
margin-bottom: 0;
}
.dark .rbc-toolbar {
background: #374151;
border-bottom: 1px solid #4b5563;
}
.rbc-toolbar button {
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #374151;
font-weight: 500;
padding: 6px 12px;
transition: all 0.2s;
}
.rbc-toolbar button:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.rbc-toolbar button:active,
.rbc-toolbar button.rbc-active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.dark .rbc-toolbar button {
background: #1f2937;
border: 1px solid #4b5563;
color: #f9fafb;
}
.dark .rbc-toolbar button:hover {
background: #374151;
border-color: #6b7280;
}
.dark .rbc-toolbar button:active,
.dark .rbc-toolbar button.rbc-active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Labels */
.rbc-toolbar-label {
color: #111827;
font-size: 18px;
font-weight: 600;
}
.dark .rbc-toolbar-label {
color: #f9fafb;
}
/* Drag and drop improvements */
.rbc-event {
cursor: grab !important;
user-select: none;
}
.rbc-event:active {
cursor: grabbing !important;
transform: scale(1.05);
z-index: 1000 !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event spacing and visibility */
.rbc-event-content {
pointer-events: none;
}
/* Better visual feedback for dragging */
.rbc-addons-dnd-dragging {
opacity: 0.8;
transform: rotate(2deg);
z-index: 1000 !important;
}
.rbc-addons-dnd-drag-preview {
background: rgba(255, 255, 255, 0.9) !important;
border: 2px dashed #3b82f6 !important;
border-radius: 8px !important;
padding: 8px 12px !important;
font-weight: bold !important;
color: #1f2937 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event hover states */
.rbc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
}
/* Better spacing between events */
.rbc-time-slot {
min-height: 24px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.rbc-toolbar {
flex-direction: column;
gap: 8px;
}
.rbc-toolbar button {
font-size: 14px;
padding: 8px 12px;
}
.rbc-toolbar-label {
font-size: 16px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,415 @@
import { createClient } from '@supabase/supabase-js'
// Supabase configuration from environment
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase is not configured. Please set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your environment (.env)')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Type definitions
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
export type UserStatus = 'active' | 'disabled'
export interface User {
id: string
email: string
first_name?: string
last_name?: string
roles: RoleName[]
status: UserStatus
created_at: string
updated_at: string
}
export interface ExperimentPhase {
id: string
name: string
description?: string | null
has_soaking: boolean
has_airdrying: boolean
has_cracking: boolean
has_shelling: boolean
cracking_machine_type_id?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface Experiment {
id: string
experiment_number: number
reps_required: number
weight_per_repetition_lbs: number
phase_id?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface ExperimentRepetition {
id: string
experiment_id: string
repetition_number: number
scheduled_date?: string | null
completion_status: boolean
created_at: string
updated_at: string
created_by: string
}
export interface Soaking {
id: string
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
actual_start_time?: string | null
soaking_duration_minutes: number
scheduled_end_time: string
actual_end_time?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface Airdrying {
id: string
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
actual_start_time?: string | null
duration_minutes: number
scheduled_end_time: string
actual_end_time?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface ConductorAvailability {
id: string
user_id: string
available_from: string
available_to: string
notes?: string | null
status: 'active' | 'cancelled'
created_at: string
updated_at: string
created_by: string
}
export interface CreateAvailabilityRequest {
available_from: string
available_to: string
notes?: string
}
export interface CreateRepetitionRequest {
experiment_id: string
repetition_number: number
scheduled_date?: string | null
}
export interface UpdateRepetitionRequest {
scheduled_date?: string | null
completion_status?: boolean
}
export interface CreateSoakingRequest {
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
soaking_duration_minutes: number
}
export interface CreateAirdryingRequest {
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
duration_minutes: number
}
export interface CreateCrackingRequest {
experiment_id: string
repetition_id?: string | null
machine_type_id: string
scheduled_start_time: string
}
// Service APIs
export const userManagement = {
async getAllUsers(): Promise<User[]> {
const { data: profiles, error: profilesError } = await supabase
.from('user_profiles')
.select(`
id,
email,
first_name,
last_name,
status,
created_at,
updated_at
`)
if (profilesError) throw profilesError
const usersWithRoles = await Promise.all(
profiles.map(async (profile) => {
const { data: userRoles, error: rolesError } = await supabase
.from('user_roles')
.select(`
roles!inner (
name
)
`)
.eq('user_id', profile.id)
if (rolesError) throw rolesError
return {
...profile,
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
}
})
)
return usersWithRoles
},
}
export const experimentPhaseManagement = {
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
const { data, error } = await supabase
.from('experiment_phases')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
},
}
export const experimentManagement = {
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
},
}
export const repetitionManagement = {
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data, error } = await supabase
.from('experiment_repetitions')
.select('*')
.eq('experiment_id', experimentId)
.order('repetition_number', { ascending: true })
if (error) throw error
return data
},
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
// Get experiment to find reps_required
const { data: experiment, error: expError } = await supabase
.from('experiments')
.select('reps_required')
.eq('id', experimentId)
.single()
if (expError || !experiment) throw new Error('Experiment not found')
// Get existing repetitions to determine next repetition number
const existingReps = await this.getExperimentRepetitions(experimentId)
const nextRepNumber = existingReps.length > 0
? Math.max(...existingReps.map(r => r.repetition_number)) + 1
: 1
// Create all remaining repetitions
const repsToCreate = experiment.reps_required - existingReps.length
const newReps: ExperimentRepetition[] = []
for (let i = 0; i < repsToCreate; i++) {
const { data, error } = await supabase
.from('experiment_repetitions')
.insert({
experiment_id: experimentId,
repetition_number: nextRepNumber + i,
completion_status: false,
created_by: user.id
})
.select()
.single()
if (error) throw error
if (data) newReps.push(data)
}
return [...existingReps, ...newReps]
},
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
},
}
export const phaseManagement = {
async createSoaking(request: CreateSoakingRequest): Promise<Soaking> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.soaking_duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('soaking')
.upsert({
...request,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async createAirdrying(request: CreateAirdryingRequest): Promise<Airdrying> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('airdrying')
.upsert({
...request,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async createCracking(request: CreateCrackingRequest): Promise<any> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('cracking')
.upsert({
...request,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
const { data, error } = await supabase
.from('soaking')
.select('*')
.eq('experiment_id', experimentId)
.is('repetition_id', null)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return data
},
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
const { data, error } = await supabase
.from('airdrying')
.select('*')
.eq('experiment_id', experimentId)
.is('repetition_id', null)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return data
},
}
export const availabilityManagement = {
async getMyAvailability(): Promise<ConductorAvailability[]> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('conductor_availability')
.select('*')
.eq('user_id', user.id)
.eq('status', 'active')
.order('available_from', { ascending: true })
if (error) throw error
return data
},
async createAvailability(request: CreateAvailabilityRequest): Promise<ConductorAvailability> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('conductor_availability')
.insert({
user_id: user.id,
available_from: request.available_from,
available_to: request.available_to,
notes: request.notes,
created_by: user.id
})
.select()
.single()
if (error) throw error
return data
},
async deleteAvailability(id: string): Promise<void> {
const { error } = await supabase
.from('conductor_availability')
.update({ status: 'cancelled' })
.eq('id', id)
if (error) throw error
},
}

11
scheduling-remote/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL?: string
readonly VITE_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "src/vite-env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
federation({
name: 'schedulingRemote',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
},
}),
],
server: {
port: 3003,
host: '0.0.0.0',
allowedHosts: ['exp-dash', 'localhost'],
cors: true
},
build: {
target: 'esnext',
},
})