1881 lines
54 KiB
TypeScript
Executable File
1881 lines
54 KiB
TypeScript
Executable File
import { createClient } from '@supabase/supabase-js'
|
|
|
|
// Supabase configuration from environment (Vite)
|
|
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)
|
|
|
|
// Database types for TypeScript
|
|
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
|
export type UserStatus = 'active' | 'disabled'
|
|
export type ScheduleStatus = 'pending schedule' | 'scheduled' | 'canceled' | 'aborted'
|
|
export type ResultsStatus = 'valid' | 'invalid'
|
|
|
|
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 Role {
|
|
id: string
|
|
name: RoleName
|
|
description: string
|
|
created_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
|
|
results_status: ResultsStatus
|
|
completion_status: boolean
|
|
phase_id?: string | null
|
|
soaking_id?: string | null
|
|
airdrying_id?: string | null
|
|
cracking_id?: string | null
|
|
shelling_id?: string | null
|
|
ring_gap_inches?: number | null
|
|
drum_rpm?: number | null
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
|
|
|
|
// Machine Types
|
|
export interface MachineType {
|
|
id: string
|
|
name: string
|
|
description?: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
// Phase-specific interfaces
|
|
export interface Soaking {
|
|
id: string
|
|
repetition_id: string
|
|
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
|
|
repetition_id: string
|
|
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 Cracking {
|
|
id: string
|
|
experiment_id: string
|
|
repetition_id?: string | null
|
|
machine_type_id: string
|
|
scheduled_start_time: string
|
|
actual_start_time?: string | null
|
|
actual_end_time?: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
export interface Shelling {
|
|
id: string
|
|
experiment_id: string
|
|
repetition_id?: string | null
|
|
scheduled_start_time: string
|
|
actual_start_time?: string | null
|
|
actual_end_time?: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
// Machine-specific parameter interfaces
|
|
export interface JCCrackerParameters {
|
|
id: string
|
|
cracking_id: string
|
|
plate_contact_frequency_hz: number
|
|
throughput_rate_pecans_sec: number
|
|
crush_amount_in: number
|
|
entry_exit_height_diff_in: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface MeyerCrackerParameters {
|
|
id: string
|
|
cracking_id: string
|
|
motor_speed_hz: number
|
|
jig_displacement_inches: number
|
|
spring_stiffness_nm: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface CreateExperimentPhaseRequest {
|
|
name: string
|
|
description?: string
|
|
has_soaking: boolean
|
|
has_airdrying: boolean
|
|
has_cracking: boolean
|
|
has_shelling: boolean
|
|
cracking_machine_type_id?: string
|
|
}
|
|
|
|
export interface UpdateExperimentPhaseRequest {
|
|
name?: string
|
|
description?: string
|
|
has_soaking?: boolean
|
|
has_airdrying?: boolean
|
|
has_cracking?: boolean
|
|
has_shelling?: boolean
|
|
}
|
|
|
|
// Experiment-level phase config (one row per experiment per phase; stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
|
|
export interface ExperimentSoakingConfig {
|
|
id: string
|
|
experiment_id: string
|
|
soaking_duration_hr: number
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
export interface ExperimentAirdryingConfig {
|
|
id: string
|
|
experiment_id: string
|
|
duration_minutes: number
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
export interface ExperimentCrackingConfig {
|
|
id: string
|
|
experiment_id: string
|
|
machine_type_id: string
|
|
plate_contact_frequency_hz?: number | null
|
|
throughput_rate_pecans_sec?: number | null
|
|
crush_amount_in?: number | null
|
|
entry_exit_height_diff_in?: number | null
|
|
motor_speed_hz?: number | null
|
|
jig_displacement_inches?: number | null
|
|
spring_stiffness_nm?: number | null
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
export interface ExperimentShellingConfig {
|
|
id: string
|
|
experiment_id: string
|
|
ring_gap_inches?: number | null
|
|
drum_rpm?: number | null
|
|
created_at: string
|
|
updated_at: string
|
|
created_by: string
|
|
}
|
|
|
|
export interface CreateExperimentRequest {
|
|
experiment_number: number
|
|
reps_required: number
|
|
weight_per_repetition_lbs: number
|
|
results_status?: ResultsStatus
|
|
completion_status?: boolean
|
|
phase_id?: string
|
|
// Phase config (stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
|
|
soaking_duration_hr?: number
|
|
air_drying_time_min?: number
|
|
// Cracking: machine_type comes from book; params below are JC or Meyer specific
|
|
plate_contact_frequency_hz?: number
|
|
throughput_rate_pecans_sec?: number
|
|
crush_amount_in?: number
|
|
entry_exit_height_diff_in?: number
|
|
motor_speed_hz?: number
|
|
jig_displacement_inches?: number
|
|
spring_stiffness_nm?: number
|
|
ring_gap_inches?: number | null
|
|
drum_rpm?: number | null
|
|
}
|
|
|
|
export interface UpdateExperimentRequest {
|
|
experiment_number?: number
|
|
reps_required?: number
|
|
weight_per_repetition_lbs?: number
|
|
results_status?: ResultsStatus
|
|
completion_status?: boolean
|
|
phase_id?: string
|
|
soaking_duration_hr?: number
|
|
air_drying_time_min?: number
|
|
plate_contact_frequency_hz?: number
|
|
throughput_rate_pecans_sec?: number
|
|
crush_amount_in?: number
|
|
entry_exit_height_diff_in?: number
|
|
motor_speed_hz?: number
|
|
jig_displacement_inches?: number
|
|
spring_stiffness_nm?: number
|
|
ring_gap_inches?: number | null
|
|
drum_rpm?: number | null
|
|
}
|
|
|
|
export interface CreateRepetitionRequest {
|
|
experiment_id: string
|
|
repetition_number: number
|
|
scheduled_date?: string | null
|
|
}
|
|
|
|
export interface UpdateRepetitionRequest {
|
|
scheduled_date?: string | null
|
|
completion_status?: boolean
|
|
}
|
|
|
|
// Data Entry System Interfaces
|
|
export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
|
|
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
|
|
|
|
export interface ExperimentPhaseDraft {
|
|
id: string
|
|
experiment_id: string
|
|
repetition_id: string
|
|
user_id: string
|
|
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
|
|
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 {
|
|
id: string
|
|
phase_data_id: string
|
|
measurement_number: number
|
|
diameter_in: number
|
|
created_at: string
|
|
}
|
|
|
|
export interface ExperimentPhaseData {
|
|
id: string
|
|
phase_draft_id: string
|
|
phase_name: ExperimentPhase
|
|
|
|
// Pre-soaking phase
|
|
batch_initial_weight_lbs?: number | null
|
|
initial_shell_moisture_pct?: number | null
|
|
initial_kernel_moisture_pct?: number | null
|
|
soaking_start_time?: string | null
|
|
|
|
// Air-drying phase
|
|
airdrying_start_time?: string | null
|
|
post_soak_weight_lbs?: number | null
|
|
post_soak_kernel_moisture_pct?: number | null
|
|
post_soak_shell_moisture_pct?: number | null
|
|
avg_pecan_diameter_in?: number | null
|
|
|
|
// Cracking phase
|
|
cracking_start_time?: string | null
|
|
|
|
// Shelling phase
|
|
shelling_start_time?: string | null
|
|
bin_1_weight_lbs?: number | null
|
|
bin_2_weight_lbs?: number | null
|
|
bin_3_weight_lbs?: number | null
|
|
discharge_bin_weight_lbs?: number | null
|
|
bin_1_full_yield_oz?: number | null
|
|
bin_2_full_yield_oz?: number | null
|
|
bin_3_full_yield_oz?: number | null
|
|
bin_1_half_yield_oz?: number | null
|
|
bin_2_half_yield_oz?: number | null
|
|
bin_3_half_yield_oz?: number | null
|
|
|
|
created_at: string
|
|
updated_at: string
|
|
|
|
// Related data
|
|
diameter_measurements?: PecanDiameterMeasurement[]
|
|
}
|
|
|
|
export interface CreatePhaseDraftRequest {
|
|
experiment_id: string
|
|
repetition_id: string
|
|
phase_name: ExperimentPhase
|
|
draft_name?: string
|
|
status?: PhaseDraftStatus
|
|
}
|
|
|
|
export interface UpdatePhaseDraftRequest {
|
|
draft_name?: string
|
|
status?: PhaseDraftStatus
|
|
}
|
|
|
|
export interface CreatePhaseDataRequest {
|
|
data_entry_id: string
|
|
phase_name: ExperimentPhase
|
|
[key: string]: any // For phase-specific data fields
|
|
}
|
|
|
|
export interface UpdatePhaseDataRequest {
|
|
[key: string]: any // For phase-specific data fields
|
|
}
|
|
|
|
// Phase creation request interfaces
|
|
export interface CreateSoakingRequest {
|
|
repetition_id: string
|
|
scheduled_start_time: string
|
|
soaking_duration_minutes: number
|
|
actual_start_time?: string
|
|
actual_end_time?: string
|
|
}
|
|
|
|
export interface CreateAirdryingRequest {
|
|
repetition_id: string
|
|
scheduled_start_time: string
|
|
duration_minutes: number
|
|
actual_start_time?: string
|
|
actual_end_time?: string
|
|
}
|
|
|
|
export interface CreateCrackingRequest {
|
|
repetition_id: string
|
|
machine_type_id: string
|
|
scheduled_start_time: string
|
|
actual_start_time?: string
|
|
actual_end_time?: string
|
|
}
|
|
|
|
export interface CreateShellingRequest {
|
|
experiment_id: string
|
|
repetition_id?: string
|
|
scheduled_start_time: string
|
|
actual_start_time?: string
|
|
actual_end_time?: string
|
|
}
|
|
|
|
// Machine parameter creation request interfaces
|
|
export interface CreateJCCrackerParametersRequest {
|
|
cracking_id: string
|
|
plate_contact_frequency_hz: number
|
|
throughput_rate_pecans_sec: number
|
|
crush_amount_in: number
|
|
entry_exit_height_diff_in: number
|
|
}
|
|
|
|
export interface CreateMeyerCrackerParametersRequest {
|
|
cracking_id: string
|
|
motor_speed_hz: number
|
|
jig_displacement_inches: number
|
|
spring_stiffness_nm: number
|
|
}
|
|
|
|
export interface UserRole {
|
|
id: string
|
|
user_id: string
|
|
role_id: string
|
|
assigned_at: string
|
|
assigned_by?: string
|
|
}
|
|
|
|
export interface UserProfile {
|
|
id: string
|
|
email: string
|
|
status: UserStatus
|
|
created_at: string
|
|
updated_at: string
|
|
role_id?: string // Legacy field, will be deprecated
|
|
}
|
|
|
|
export interface CreateUserRequest {
|
|
email: string
|
|
roles: RoleName[]
|
|
tempPassword?: string
|
|
}
|
|
|
|
export interface CreateUserResponse {
|
|
user_id: string
|
|
email: string
|
|
temp_password: string
|
|
roles: RoleName[]
|
|
status: UserStatus
|
|
}
|
|
|
|
// User management utility functions
|
|
export const userManagement = {
|
|
// Get all users with their roles
|
|
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
|
|
|
|
// Get roles for each user
|
|
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
|
|
},
|
|
|
|
// Get all available roles
|
|
async getAllRoles(): Promise<Role[]> {
|
|
const { data, error } = await supabase
|
|
.from('roles')
|
|
.select('*')
|
|
.order('name')
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Create a new user with roles
|
|
async createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
|
|
const { data, error } = await supabase.rpc('create_user_with_roles', {
|
|
user_email: userData.email,
|
|
role_names: userData.roles,
|
|
temp_password: userData.tempPassword
|
|
})
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Update user status (enable/disable)
|
|
async updateUserStatus(userId: string, status: UserStatus): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('user_profiles')
|
|
.update({ status })
|
|
.eq('id', userId)
|
|
|
|
if (error) throw error
|
|
},
|
|
|
|
// Update user roles
|
|
async updateUserRoles(userId: string, roleNames: RoleName[]): Promise<void> {
|
|
// First, remove all existing roles for the user
|
|
const { error: deleteError } = await supabase
|
|
.from('user_roles')
|
|
.delete()
|
|
.eq('user_id', userId)
|
|
|
|
if (deleteError) throw deleteError
|
|
|
|
// Get role IDs for the new roles
|
|
const { data: roles, error: rolesError } = await supabase
|
|
.from('roles')
|
|
.select('id, name')
|
|
.in('name', roleNames)
|
|
|
|
if (rolesError) throw rolesError
|
|
|
|
// Insert new role assignments
|
|
const roleAssignments = roles.map(role => ({
|
|
user_id: userId,
|
|
role_id: role.id
|
|
}))
|
|
|
|
const { error: insertError } = await supabase
|
|
.from('user_roles')
|
|
.insert(roleAssignments)
|
|
|
|
if (insertError) throw insertError
|
|
},
|
|
|
|
// Update user email
|
|
async updateUserEmail(userId: string, email: string): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('user_profiles')
|
|
.update({ email })
|
|
.eq('id', userId)
|
|
|
|
if (error) throw error
|
|
},
|
|
|
|
// Get current user with roles
|
|
async getCurrentUser(): Promise<User | null> {
|
|
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
|
|
|
|
if (authError || !authUser) return null
|
|
|
|
const { data: profile, error: profileError } = await supabase
|
|
.from('user_profiles')
|
|
.select(`
|
|
id,
|
|
email,
|
|
first_name,
|
|
last_name,
|
|
status,
|
|
created_at,
|
|
updated_at
|
|
`)
|
|
.eq('id', authUser.id)
|
|
.single()
|
|
|
|
if (profileError) throw profileError
|
|
|
|
const { data: userRoles, error: rolesError } = await supabase
|
|
.from('user_roles')
|
|
.select(`
|
|
roles!inner (
|
|
name
|
|
)
|
|
`)
|
|
.eq('user_id', authUser.id)
|
|
|
|
if (rolesError) throw rolesError
|
|
|
|
return {
|
|
...profile,
|
|
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
|
}
|
|
},
|
|
|
|
// Reset user password to default (admin only)
|
|
async resetUserPassword(userId: string): Promise<{ user_id: string; email: string; new_password: string; reset_at: string }> {
|
|
const { data, error } = await supabase.rpc('reset_user_password', {
|
|
target_user_id: userId
|
|
})
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Change user password (user can only change their own password)
|
|
async changeUserPassword(currentPassword: string, newPassword: string): Promise<{ user_id: string; email: string; password_changed_at: string }> {
|
|
const { data, error } = await supabase.rpc('change_user_password', {
|
|
current_password: currentPassword,
|
|
new_password: newPassword
|
|
})
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Sync OAuth user - ensures user profile exists for OAuth-authenticated users
|
|
async syncOAuthUser(): Promise<void> {
|
|
try {
|
|
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
|
|
|
|
if (authError || !authUser) {
|
|
console.warn('No authenticated user found for OAuth sync')
|
|
return
|
|
}
|
|
|
|
// Check if user profile already exists
|
|
const { data: existingProfile, error: checkError } = await supabase
|
|
.from('user_profiles')
|
|
.select('id')
|
|
.eq('id', authUser.id)
|
|
.single()
|
|
|
|
// If profile already exists, no need to create it
|
|
if (existingProfile && !checkError) {
|
|
console.log('User profile already exists for user:', authUser.id)
|
|
return
|
|
}
|
|
|
|
// If error is not "no rows returned", it's a real error
|
|
if (checkError && checkError.code !== 'PGRST116') {
|
|
console.error('Error checking for existing profile:', checkError)
|
|
return
|
|
}
|
|
|
|
// Create user profile for new OAuth user
|
|
const { error: insertError } = await supabase
|
|
.from('user_profiles')
|
|
.insert({
|
|
id: authUser.id,
|
|
email: authUser.email || '',
|
|
status: 'active'
|
|
})
|
|
|
|
if (insertError) {
|
|
// Ignore "duplicate key value" errors in case of race condition
|
|
if (insertError.code === '23505') {
|
|
console.log('User profile was already created (race condition handled)')
|
|
return
|
|
}
|
|
console.error('Error creating user profile for OAuth user:', insertError)
|
|
return
|
|
}
|
|
|
|
console.log('Successfully created user profile for OAuth user:', authUser.id)
|
|
} catch (error) {
|
|
console.error('Unexpected error in syncOAuthUser:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Experiment book management (table: experiment_books)
|
|
export const experimentPhaseManagement = {
|
|
// Get all experiment books
|
|
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
|
|
const { data, error } = await supabase
|
|
.from('experiment_books')
|
|
.select('*')
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Get experiment book by ID
|
|
async getExperimentPhaseById(id: string): Promise<ExperimentPhase | null> {
|
|
const { data, error } = await supabase
|
|
.from('experiment_books')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// Create a new experiment book
|
|
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_books')
|
|
.insert({
|
|
...phaseData,
|
|
created_by: user.id
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Update an experiment book
|
|
async updateExperimentPhase(id: string, updates: UpdateExperimentPhaseRequest): Promise<ExperimentPhase> {
|
|
const { data, error } = await supabase
|
|
.from('experiment_books')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Delete an experiment book
|
|
async deleteExperimentPhase(id: string): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('experiment_books')
|
|
.delete()
|
|
.eq('id', id)
|
|
|
|
if (error) throw error
|
|
}
|
|
}
|
|
|
|
// Experiment management utility functions
|
|
export const experimentManagement = {
|
|
// Get all experiments
|
|
async getAllExperiments(): Promise<Experiment[]> {
|
|
const { data, error } = await supabase
|
|
.from('experiments')
|
|
.select('*')
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (error) throw error
|
|
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
|
|
.from('experiments')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// Get experiment with its phase config (soaking, airdrying, cracking, shelling) for edit form
|
|
async getExperimentWithPhaseConfig(id: string): Promise<(Experiment & {
|
|
soaking?: ExperimentSoakingConfig | null
|
|
airdrying?: ExperimentAirdryingConfig | null
|
|
cracking?: ExperimentCrackingConfig | null
|
|
shelling?: ExperimentShellingConfig | null
|
|
}) | null> {
|
|
const experiment = await this.getExperimentById(id)
|
|
if (!experiment) return null
|
|
|
|
const [soakingRes, airdryingRes, crackingRes, shellingRes] = await Promise.all([
|
|
supabase.from('experiment_soaking').select('*').eq('experiment_id', id).maybeSingle(),
|
|
supabase.from('experiment_airdrying').select('*').eq('experiment_id', id).maybeSingle(),
|
|
supabase.from('experiment_cracking').select('*').eq('experiment_id', id).maybeSingle(),
|
|
supabase.from('experiment_shelling').select('*').eq('experiment_id', id).maybeSingle()
|
|
])
|
|
if (soakingRes.error) throw soakingRes.error
|
|
if (airdryingRes.error) throw airdryingRes.error
|
|
if (crackingRes.error) throw crackingRes.error
|
|
if (shellingRes.error) throw shellingRes.error
|
|
|
|
return {
|
|
...experiment,
|
|
soaking: soakingRes.data ?? null,
|
|
airdrying: airdryingRes.data ?? null,
|
|
cracking: crackingRes.data ?? null,
|
|
shelling: shellingRes.data ?? null
|
|
}
|
|
},
|
|
|
|
// Create a new experiment and its phase config rows (experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
|
|
async createExperiment(experimentData: CreateExperimentRequest): Promise<Experiment> {
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) throw new Error('User not authenticated')
|
|
|
|
const phaseId = experimentData.phase_id
|
|
const corePayload = {
|
|
experiment_number: experimentData.experiment_number,
|
|
reps_required: experimentData.reps_required,
|
|
weight_per_repetition_lbs: experimentData.weight_per_repetition_lbs,
|
|
results_status: experimentData.results_status ?? 'valid',
|
|
completion_status: experimentData.completion_status ?? false,
|
|
phase_id: phaseId,
|
|
created_by: user.id
|
|
}
|
|
// phase_id required for phase configs
|
|
if (!phaseId) {
|
|
const { data, error } = await supabase.from('experiments').insert(corePayload).select().single()
|
|
if (error) throw error
|
|
return data
|
|
}
|
|
|
|
const { data: experiment, error } = await supabase
|
|
.from('experiments')
|
|
.insert(corePayload)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
|
|
const book = await experimentPhaseManagement.getExperimentPhaseById(phaseId)
|
|
if (!book) return experiment
|
|
|
|
if (book.has_soaking && experimentData.soaking_duration_hr != null) {
|
|
await supabase.from('experiment_soaking').insert({
|
|
experiment_id: experiment.id,
|
|
soaking_duration_hr: experimentData.soaking_duration_hr,
|
|
created_by: user.id
|
|
})
|
|
}
|
|
if (book.has_airdrying && experimentData.air_drying_time_min != null) {
|
|
await supabase.from('experiment_airdrying').insert({
|
|
experiment_id: experiment.id,
|
|
duration_minutes: experimentData.air_drying_time_min,
|
|
created_by: user.id
|
|
})
|
|
}
|
|
if (book.has_cracking && book.cracking_machine_type_id) {
|
|
const crackPayload: Record<string, unknown> = {
|
|
experiment_id: experiment.id,
|
|
machine_type_id: book.cracking_machine_type_id,
|
|
created_by: user.id
|
|
}
|
|
if (experimentData.plate_contact_frequency_hz != null) crackPayload.plate_contact_frequency_hz = experimentData.plate_contact_frequency_hz
|
|
if (experimentData.throughput_rate_pecans_sec != null) crackPayload.throughput_rate_pecans_sec = experimentData.throughput_rate_pecans_sec
|
|
if (experimentData.crush_amount_in != null) crackPayload.crush_amount_in = experimentData.crush_amount_in
|
|
if (experimentData.entry_exit_height_diff_in != null) crackPayload.entry_exit_height_diff_in = experimentData.entry_exit_height_diff_in
|
|
if (experimentData.motor_speed_hz != null) crackPayload.motor_speed_hz = experimentData.motor_speed_hz
|
|
if (experimentData.jig_displacement_inches != null) crackPayload.jig_displacement_inches = experimentData.jig_displacement_inches
|
|
if (experimentData.spring_stiffness_nm != null) crackPayload.spring_stiffness_nm = experimentData.spring_stiffness_nm
|
|
await supabase.from('experiment_cracking').insert(crackPayload)
|
|
}
|
|
if (book.has_shelling && (experimentData.ring_gap_inches != null || experimentData.drum_rpm != null)) {
|
|
await supabase.from('experiment_shelling').insert({
|
|
experiment_id: experiment.id,
|
|
ring_gap_inches: experimentData.ring_gap_inches ?? null,
|
|
drum_rpm: experimentData.drum_rpm ?? null,
|
|
created_by: user.id
|
|
})
|
|
}
|
|
|
|
return experiment
|
|
},
|
|
|
|
// Update an experiment and upsert its phase config rows
|
|
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) throw new Error('User not authenticated')
|
|
|
|
const coreKeys = ['experiment_number', 'reps_required', 'weight_per_repetition_lbs', 'results_status', 'completion_status', 'phase_id'] as const
|
|
const coreUpdates: Partial<UpdateExperimentRequest> = {}
|
|
for (const k of coreKeys) {
|
|
if (updates[k] !== undefined) coreUpdates[k] = updates[k]
|
|
}
|
|
if (Object.keys(coreUpdates).length > 0) {
|
|
const { data, error } = await supabase.from('experiments').update(coreUpdates).eq('id', id).select().single()
|
|
if (error) throw error
|
|
}
|
|
|
|
if (updates.soaking_duration_hr !== undefined) {
|
|
const { data: existing } = await supabase.from('experiment_soaking').select('id').eq('experiment_id', id).maybeSingle()
|
|
if (existing) {
|
|
await supabase.from('experiment_soaking').update({ soaking_duration_hr: updates.soaking_duration_hr, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
|
} else {
|
|
await supabase.from('experiment_soaking').insert({ experiment_id: id, soaking_duration_hr: updates.soaking_duration_hr, created_by: user.id })
|
|
}
|
|
}
|
|
if (updates.air_drying_time_min !== undefined) {
|
|
const { data: existing } = await supabase.from('experiment_airdrying').select('id').eq('experiment_id', id).maybeSingle()
|
|
if (existing) {
|
|
await supabase.from('experiment_airdrying').update({ duration_minutes: updates.air_drying_time_min, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
|
} else {
|
|
await supabase.from('experiment_airdrying').insert({ experiment_id: id, duration_minutes: updates.air_drying_time_min, created_by: user.id })
|
|
}
|
|
}
|
|
const crackKeys = ['plate_contact_frequency_hz', 'throughput_rate_pecans_sec', 'crush_amount_in', 'entry_exit_height_diff_in', 'motor_speed_hz', 'jig_displacement_inches', 'spring_stiffness_nm'] as const
|
|
const hasCrackUpdates = crackKeys.some(k => updates[k] !== undefined)
|
|
if (hasCrackUpdates) {
|
|
const { data: existing } = await supabase.from('experiment_cracking').select('id').eq('experiment_id', id).maybeSingle()
|
|
const crackPayload: Record<string, unknown> = {}
|
|
crackKeys.forEach(k => { if (updates[k] !== undefined) crackPayload[k] = updates[k] })
|
|
if (Object.keys(crackPayload).length > 0) {
|
|
if (existing) {
|
|
await supabase.from('experiment_cracking').update({ ...crackPayload, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
|
} else {
|
|
const exp = await this.getExperimentById(id)
|
|
const book = exp?.phase_id ? await experimentPhaseManagement.getExperimentPhaseById(exp.phase_id) : null
|
|
if (book?.has_cracking && book.cracking_machine_type_id) {
|
|
await supabase.from('experiment_cracking').insert({ experiment_id: id, machine_type_id: book.cracking_machine_type_id, ...crackPayload, created_by: user.id })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (updates.ring_gap_inches !== undefined || updates.drum_rpm !== undefined) {
|
|
const { data: existing } = await supabase.from('experiment_shelling').select('id').eq('experiment_id', id).maybeSingle()
|
|
const shellPayload = { ring_gap_inches: updates.ring_gap_inches ?? null, drum_rpm: updates.drum_rpm ?? null }
|
|
if (existing) {
|
|
await supabase.from('experiment_shelling').update({ ...shellPayload, updated_at: new Date().toISOString() }).eq('experiment_id', id)
|
|
} else {
|
|
await supabase.from('experiment_shelling').insert({ experiment_id: id, ...shellPayload, created_by: user.id })
|
|
}
|
|
}
|
|
|
|
const { data, error } = await supabase.from('experiments').select('*').eq('id', id).single()
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Delete an experiment (admin only)
|
|
async deleteExperiment(id: string): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('experiments')
|
|
.delete()
|
|
.eq('id', id)
|
|
|
|
if (error) throw error
|
|
},
|
|
|
|
// Update experiment status
|
|
async updateExperimentStatus(id: string, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment> {
|
|
const updates: Partial<UpdateExperimentRequest> = {}
|
|
if (scheduleStatus) updates.schedule_status = scheduleStatus
|
|
if (resultsStatus) updates.results_status = resultsStatus
|
|
|
|
return this.updateExperiment(id, updates)
|
|
},
|
|
|
|
// Get experiments by status
|
|
async getExperimentsByStatus(scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment[]> {
|
|
let query = supabase.from('experiments').select('*')
|
|
|
|
if (scheduleStatus) {
|
|
query = query.eq('schedule_status', scheduleStatus)
|
|
}
|
|
if (resultsStatus) {
|
|
query = query.eq('results_status', resultsStatus)
|
|
}
|
|
|
|
const { data, error } = await query.order('created_at', { ascending: false })
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
|
|
|
|
// Check if experiment number is unique within the same phase (experiment_number + phase_id must be unique)
|
|
async isExperimentNumberUnique(experimentNumber: number, phaseId?: string, excludeId?: string): Promise<boolean> {
|
|
let query = supabase
|
|
.from('experiments')
|
|
.select('id')
|
|
.eq('experiment_number', experimentNumber)
|
|
|
|
if (phaseId) {
|
|
query = query.eq('phase_id', phaseId)
|
|
}
|
|
if (excludeId) {
|
|
query = query.neq('id', excludeId)
|
|
}
|
|
|
|
const { data, error } = await query
|
|
|
|
if (error) throw error
|
|
return data.length === 0
|
|
}
|
|
}
|
|
|
|
// Machine Type Management
|
|
export const machineTypeManagement = {
|
|
// Get all machine types
|
|
async getAllMachineTypes(): Promise<MachineType[]> {
|
|
const { data, error } = await supabase
|
|
.from('machine_types')
|
|
.select('*')
|
|
.order('name')
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Get machine type by ID
|
|
async getMachineTypeById(id: string): Promise<MachineType | null> {
|
|
const { data, error } = await supabase
|
|
.from('machine_types')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
}
|
|
}
|
|
|
|
// Phase Management
|
|
export const phaseManagement = {
|
|
// Soaking management
|
|
async createSoaking(request: CreateSoakingRequest): Promise<Soaking> {
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) throw new Error('User not authenticated')
|
|
|
|
if (!request.repetition_id) {
|
|
throw new Error('repetition_id is required')
|
|
}
|
|
|
|
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({
|
|
repetition_id: request.repetition_id,
|
|
scheduled_start_time: request.scheduled_start_time,
|
|
soaking_duration_minutes: request.soaking_duration_minutes,
|
|
scheduled_end_time: scheduledEndTime,
|
|
created_by: user.id
|
|
}, {
|
|
onConflict: 'repetition_id'
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async updateSoaking(id: string, updates: Partial<Soaking>): Promise<Soaking> {
|
|
const { data, error } = await supabase
|
|
.from('soaking')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
|
|
// Get the first repetition for this experiment
|
|
const { data: repetitions, error: repsError } = await supabase
|
|
.from('experiment_repetitions')
|
|
.select('id')
|
|
.eq('experiment_id', experimentId)
|
|
.order('repetition_number', { ascending: true })
|
|
.limit(1)
|
|
|
|
if (repsError || !repetitions || repetitions.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Get soaking from unified experiment_phase_executions table
|
|
const { data, error } = await supabase
|
|
.from('experiment_phase_executions')
|
|
.select('*')
|
|
.eq('repetition_id', repetitions[0].id)
|
|
.eq('phase_type', 'soaking')
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
|
|
// Map unified table data to Soaking interface format
|
|
if (data) {
|
|
return {
|
|
id: data.id,
|
|
repetition_id: data.repetition_id,
|
|
scheduled_start_time: data.scheduled_start_time,
|
|
actual_start_time: data.actual_start_time || null,
|
|
soaking_duration_minutes: data.soaking_duration_minutes || 0,
|
|
scheduled_end_time: data.scheduled_end_time || '',
|
|
actual_end_time: data.actual_end_time || null,
|
|
created_at: data.created_at,
|
|
updated_at: data.updated_at,
|
|
created_by: data.created_by
|
|
} as Soaking
|
|
}
|
|
|
|
return null
|
|
},
|
|
|
|
async getSoakingByRepetitionId(repetitionId: string): Promise<Soaking | null> {
|
|
const { data, error } = await supabase
|
|
.from('soaking')
|
|
.select('*')
|
|
.eq('repetition_id', repetitionId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// Airdrying management
|
|
async createAirdrying(request: CreateAirdryingRequest): Promise<Airdrying> {
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) throw new Error('User not authenticated')
|
|
|
|
if (!request.repetition_id) {
|
|
throw new Error('repetition_id is required')
|
|
}
|
|
|
|
if (!request.scheduled_start_time) {
|
|
throw new Error('scheduled_start_time is required')
|
|
}
|
|
|
|
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.duration_minutes * 60000).toISOString()
|
|
|
|
const { data, error } = await supabase
|
|
.from('airdrying')
|
|
.upsert({
|
|
repetition_id: request.repetition_id,
|
|
scheduled_start_time: request.scheduled_start_time,
|
|
duration_minutes: request.duration_minutes,
|
|
scheduled_end_time: scheduledEndTime,
|
|
created_by: user.id
|
|
}, {
|
|
onConflict: 'repetition_id'
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async updateAirdrying(id: string, updates: Partial<Airdrying>): Promise<Airdrying> {
|
|
const { data, error } = await supabase
|
|
.from('airdrying')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
|
|
// Get the first repetition for this experiment
|
|
const { data: repetitions, error: repsError } = await supabase
|
|
.from('experiment_repetitions')
|
|
.select('id')
|
|
.eq('experiment_id', experimentId)
|
|
.order('repetition_number', { ascending: true })
|
|
.limit(1)
|
|
|
|
if (repsError || !repetitions || repetitions.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Get airdrying from unified experiment_phase_executions table
|
|
const { data, error } = await supabase
|
|
.from('experiment_phase_executions')
|
|
.select('*')
|
|
.eq('repetition_id', repetitions[0].id)
|
|
.eq('phase_type', 'airdrying')
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
|
|
// Map unified table data to Airdrying interface format
|
|
if (data) {
|
|
return {
|
|
id: data.id,
|
|
repetition_id: data.repetition_id,
|
|
scheduled_start_time: data.scheduled_start_time,
|
|
actual_start_time: data.actual_start_time || null,
|
|
duration_minutes: data.duration_minutes || 0,
|
|
scheduled_end_time: data.scheduled_end_time || '',
|
|
actual_end_time: data.actual_end_time || null,
|
|
created_at: data.created_at,
|
|
updated_at: data.updated_at,
|
|
created_by: data.created_by
|
|
} as Airdrying
|
|
}
|
|
|
|
return null
|
|
},
|
|
|
|
async getAirdryingByRepetitionId(repetitionId: string): Promise<Airdrying | null> {
|
|
const { data, error } = await supabase
|
|
.from('airdrying')
|
|
.select('*')
|
|
.eq('repetition_id', repetitionId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// Cracking management
|
|
async createCracking(request: CreateCrackingRequest): Promise<Cracking> {
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) throw new Error('User not authenticated')
|
|
|
|
if (!request.repetition_id) {
|
|
throw new Error('repetition_id is required')
|
|
}
|
|
|
|
if (!request.scheduled_start_time) {
|
|
throw new Error('scheduled_start_time is required')
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('cracking')
|
|
.upsert({
|
|
repetition_id: request.repetition_id,
|
|
machine_type_id: request.machine_type_id,
|
|
scheduled_start_time: request.scheduled_start_time,
|
|
created_by: user.id
|
|
}, {
|
|
onConflict: 'repetition_id'
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async updateCracking(id: string, updates: Partial<Cracking>): Promise<Cracking> {
|
|
const { data, error } = await supabase
|
|
.from('cracking')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async getCrackingByExperimentId(experimentId: string): Promise<Cracking | null> {
|
|
const { data, error } = await supabase
|
|
.from('cracking')
|
|
.select('*')
|
|
.eq('experiment_id', experimentId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
async getCrackingByRepetitionId(repetitionId: string): Promise<Cracking | null> {
|
|
const { data, error } = await supabase
|
|
.from('cracking')
|
|
.select('*')
|
|
.eq('repetition_id', repetitionId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// Shelling management
|
|
async createShelling(request: CreateShellingRequest): Promise<Shelling> {
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) throw new Error('User not authenticated')
|
|
|
|
const { data, error } = await supabase
|
|
.from('shelling')
|
|
.insert({
|
|
...request,
|
|
created_by: user.id
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async updateShelling(id: string, updates: Partial<Shelling>): Promise<Shelling> {
|
|
const { data, error } = await supabase
|
|
.from('shelling')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async getShellingByExperimentId(experimentId: string): Promise<Shelling | null> {
|
|
const { data, error } = await supabase
|
|
.from('shelling')
|
|
.select('*')
|
|
.eq('experiment_id', experimentId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
async getShellingByRepetitionId(repetitionId: string): Promise<Shelling | null> {
|
|
const { data, error } = await supabase
|
|
.from('shelling')
|
|
.select('*')
|
|
.eq('repetition_id', repetitionId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
}
|
|
}
|
|
|
|
// Machine Parameter Management
|
|
export const machineParameterManagement = {
|
|
// JC Cracker parameters
|
|
async createJCCrackerParameters(request: CreateJCCrackerParametersRequest): Promise<JCCrackerParameters> {
|
|
const { data, error } = await supabase
|
|
.from('jc_cracker_parameters')
|
|
.insert(request)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async updateJCCrackerParameters(id: string, updates: Partial<JCCrackerParameters>): Promise<JCCrackerParameters> {
|
|
const { data, error } = await supabase
|
|
.from('jc_cracker_parameters')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async getJCCrackerParametersByCrackingId(crackingId: string): Promise<JCCrackerParameters | null> {
|
|
const { data, error } = await supabase
|
|
.from('jc_cracker_parameters')
|
|
.select('*')
|
|
.eq('cracking_id', crackingId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// Meyer Cracker parameters
|
|
async createMeyerCrackerParameters(request: CreateMeyerCrackerParametersRequest): Promise<MeyerCrackerParameters> {
|
|
const { data, error } = await supabase
|
|
.from('meyer_cracker_parameters')
|
|
.insert(request)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async updateMeyerCrackerParameters(id: string, updates: Partial<MeyerCrackerParameters>): Promise<MeyerCrackerParameters> {
|
|
const { data, error } = await supabase
|
|
.from('meyer_cracker_parameters')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
async getMeyerCrackerParametersByCrackingId(crackingId: string): Promise<MeyerCrackerParameters | null> {
|
|
const { data, error } = await supabase
|
|
.from('meyer_cracker_parameters')
|
|
.select('*')
|
|
.eq('cracking_id', crackingId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // Not found
|
|
throw error
|
|
}
|
|
return data
|
|
}
|
|
}
|
|
|
|
// 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_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,
|
|
}
|
|
|
|
return this.updateRepetition(id, updates)
|
|
},
|
|
|
|
// Remove repetition schedule
|
|
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
|
|
const updates: UpdateRepetitionRequest = {
|
|
scheduled_date: null,
|
|
}
|
|
|
|
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(isScheduled?: boolean): Promise<ExperimentRepetition[]> {
|
|
let query = supabase.from('experiment_repetitions').select('*')
|
|
if (isScheduled === true) {
|
|
query = query.not('scheduled_date', 'is', null)
|
|
} else if (isScheduled === false) {
|
|
query = query.is('scheduled_date', null)
|
|
}
|
|
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
|
|
},
|
|
|
|
// 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
|
|
})
|
|
}
|
|
|
|
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(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_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('repetition_id', repetitionId)
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// 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_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
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Update a phase draft
|
|
async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
|
const { data, error } = await supabase
|
|
.from('experiment_phase_drafts')
|
|
.update(updates)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Delete a phase draft (only drafts)
|
|
async deletePhaseDraft(id: string): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('experiment_phase_drafts')
|
|
.delete()
|
|
.eq('id', id)
|
|
|
|
if (error) throw error
|
|
},
|
|
|
|
// Submit a phase draft (change status from draft to submitted)
|
|
async submitPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
|
return this.updatePhaseDraft(id, { status: 'submitted' })
|
|
},
|
|
|
|
// 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('phase_draft_id', phaseDraftId)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') return null // No rows found
|
|
throw error
|
|
}
|
|
return data
|
|
},
|
|
|
|
// 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({
|
|
phase_draft_id: phaseDraftId,
|
|
...phaseData
|
|
}, {
|
|
onConflict: 'phase_draft_id,phase_name'
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Save diameter measurements
|
|
async saveDiameterMeasurements(phaseDataId: string, measurements: number[]): Promise<PecanDiameterMeasurement[]> {
|
|
// First, delete existing measurements
|
|
await supabase
|
|
.from('pecan_diameter_measurements')
|
|
.delete()
|
|
.eq('phase_data_id', phaseDataId)
|
|
|
|
// Then insert new measurements
|
|
const measurementData = measurements.map((diameter, index) => ({
|
|
phase_data_id: phaseDataId,
|
|
measurement_number: index + 1,
|
|
diameter_in: diameter
|
|
}))
|
|
|
|
const { data, error } = await supabase
|
|
.from('pecan_diameter_measurements')
|
|
.insert(measurementData)
|
|
.select()
|
|
|
|
if (error) throw error
|
|
return data
|
|
},
|
|
|
|
// Calculate average diameter from measurements
|
|
calculateAverageDiameter(measurements: number[]): number {
|
|
if (measurements.length === 0) return 0
|
|
const validMeasurements = measurements.filter(m => m > 0)
|
|
if (validMeasurements.length === 0) return 0
|
|
return validMeasurements.reduce((sum, m) => sum + m, 0) / validMeasurements.length
|
|
},
|
|
|
|
// Auto-save draft data (for periodic saves)
|
|
async autoSaveDraft(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
|
|
try {
|
|
await this.upsertPhaseData(phaseDraftId, phaseData)
|
|
} catch (error) {
|
|
console.warn('Auto-save failed:', error)
|
|
// Don't throw error for auto-save failures
|
|
}
|
|
}
|
|
}
|
|
|
|
// Conductor Availability Management
|
|
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 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
|
|
}
|
|
}
|