mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
docs: consolidate and restructure documentation architecture
- Remove outdated root-level documentation files - Delete IMPLEMENTATION_STATUS.md, WORK_IN_PROGRESS.md, UI_IMPROVEMENTS_SUMMARY.md, CLAUDE.md - Reorganize documentation into docs/ folder - Move UNIFIED_EDITOR_EXPERIENCES.md → docs/unified-editor-experiences.md - Move DATATABLE_MIGRATION_PROGRESS.md → docs/datatable-migration-progress.md - Move SEED_SCRIPT_README.md → docs/seed-script-readme.md - Create comprehensive new documentation - Add docs/implementation-status.md with production readiness assessment - Add docs/work-in-progress.md with active development tracking - Add docs/development-achievements.md consolidating all major accomplishments - Update documentation hub - Enhance docs/README.md with complete 13-document structure - Organize into logical categories: Core, Status, Achievements - Provide clear navigation and purpose for each document Features: - 73% code reduction achievement through unified editor experiences - Complete DataTable migration with enterprise features - Comprehensive seed database with realistic research scenarios - Production-ready status with 100% backend, 95% frontend completion - Clean documentation architecture supporting future development Breaking Changes: None - documentation restructuring only Migration: Documentation moved to docs/ folder, no code changes required
This commit is contained in:
428
src/components/trials/wizard/ActionControls.tsx
Normal file
428
src/components/trials/wizard/ActionControls.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle, Camera, Clock, Hand, HelpCircle, Lightbulb, MessageSquare, Pause,
|
||||
Play,
|
||||
RotateCcw, Target, Video,
|
||||
VideoOff, Volume2,
|
||||
VolumeX, Zap
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
interface ActionControlsProps {
|
||||
currentStep: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
parameters?: any;
|
||||
actions?: any[];
|
||||
} | null;
|
||||
onExecuteAction: (actionType: string, actionData: any) => Promise<void>;
|
||||
trialId: string;
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type: "primary" | "secondary" | "emergency";
|
||||
action: string;
|
||||
description: string;
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export function ActionControls({ currentStep, onExecuteAction, trialId }: ActionControlsProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isVideoOn, setIsVideoOn] = useState(true);
|
||||
const [isAudioOn, setIsAudioOn] = useState(true);
|
||||
const [isCommunicationOpen, setIsCommunicationOpen] = useState(false);
|
||||
const [interventionNote, setInterventionNote] = useState("");
|
||||
const [selectedEmergencyAction, setSelectedEmergencyAction] = useState("");
|
||||
const [showEmergencyDialog, setShowEmergencyDialog] = useState(false);
|
||||
|
||||
// Quick action definitions
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
id: "manual_intervention",
|
||||
label: "Manual Intervention",
|
||||
icon: Hand,
|
||||
type: "primary",
|
||||
action: "manual_intervention",
|
||||
description: "Take manual control of the interaction",
|
||||
},
|
||||
{
|
||||
id: "provide_hint",
|
||||
label: "Provide Hint",
|
||||
icon: Lightbulb,
|
||||
type: "primary",
|
||||
action: "provide_hint",
|
||||
description: "Give a helpful hint to the participant",
|
||||
},
|
||||
{
|
||||
id: "clarification",
|
||||
label: "Clarification",
|
||||
icon: HelpCircle,
|
||||
type: "primary",
|
||||
action: "clarification",
|
||||
description: "Provide clarification or explanation",
|
||||
},
|
||||
{
|
||||
id: "pause_interaction",
|
||||
label: "Pause",
|
||||
icon: Pause,
|
||||
type: "secondary",
|
||||
action: "pause_interaction",
|
||||
description: "Temporarily pause the interaction",
|
||||
},
|
||||
{
|
||||
id: "reset_step",
|
||||
label: "Reset Step",
|
||||
icon: RotateCcw,
|
||||
type: "secondary",
|
||||
action: "reset_step",
|
||||
description: "Reset the current step",
|
||||
},
|
||||
{
|
||||
id: "emergency_stop",
|
||||
label: "Emergency Stop",
|
||||
icon: AlertTriangle,
|
||||
type: "emergency",
|
||||
action: "emergency_stop",
|
||||
description: "Emergency stop all robot actions",
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
];
|
||||
|
||||
const emergencyActions = [
|
||||
{ value: "stop_robot", label: "Stop Robot Movement" },
|
||||
{ value: "safe_position", label: "Move to Safe Position" },
|
||||
{ value: "disable_motors", label: "Disable All Motors" },
|
||||
{ value: "cut_power", label: "Emergency Power Cut" },
|
||||
];
|
||||
|
||||
const handleQuickAction = async (action: QuickAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
setShowEmergencyDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onExecuteAction(action.action, {
|
||||
action_id: action.id,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (_error) {
|
||||
console.error(`Failed to execute ${action.action}:`, _error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmergencyAction = async () => {
|
||||
if (!selectedEmergencyAction) return;
|
||||
|
||||
try {
|
||||
await onExecuteAction("emergency_action", {
|
||||
emergency_type: selectedEmergencyAction,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: "high",
|
||||
});
|
||||
setShowEmergencyDialog(false);
|
||||
setSelectedEmergencyAction("");
|
||||
} catch (_error) {
|
||||
console.error("Failed to execute emergency action:", _error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInterventionSubmit = async () => {
|
||||
if (!interventionNote.trim()) return;
|
||||
|
||||
try {
|
||||
await onExecuteAction("wizard_intervention", {
|
||||
intervention_type: "note",
|
||||
content: interventionNote,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setInterventionNote("");
|
||||
setIsCommunicationOpen(false);
|
||||
} catch (_error) {
|
||||
console.error("Failed to submit intervention:", _error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
const newState = !isRecording;
|
||||
setIsRecording(newState);
|
||||
|
||||
await onExecuteAction("recording_control", {
|
||||
action: newState ? "start_recording" : "stop_recording",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVideo = async () => {
|
||||
const newState = !isVideoOn;
|
||||
setIsVideoOn(newState);
|
||||
|
||||
await onExecuteAction("video_control", {
|
||||
action: newState ? "video_on" : "video_off",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAudio = async () => {
|
||||
const newState = !isAudioOn;
|
||||
setIsAudioOn(newState);
|
||||
|
||||
await onExecuteAction("audio_control", {
|
||||
action: newState ? "audio_on" : "audio_off",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Media Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Camera className="h-5 w-5" />
|
||||
<span>Media Controls</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={isRecording ? "destructive" : "outline"}
|
||||
onClick={toggleRecording}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${isRecording ? "bg-white animate-pulse" : "bg-red-500"}`}></div>
|
||||
<span>{isRecording ? "Stop Recording" : "Start Recording"}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isVideoOn ? "default" : "outline"}
|
||||
onClick={toggleVideo}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{isVideoOn ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
|
||||
<span>Video</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isAudioOn ? "default" : "outline"}
|
||||
onClick={toggleAudio}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{isAudioOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
<span>Audio</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCommunicationOpen(true)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span>Note</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
<span>Quick Actions</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={
|
||||
action.type === "emergency" ? "destructive" :
|
||||
action.type === "primary" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
className="flex items-center justify-start space-x-3 h-12"
|
||||
>
|
||||
<action.icon className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-xs opacity-75">{action.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step-Specific Controls */}
|
||||
{currentStep && currentStep.type === "wizard_action" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Target className="h-5 w-5" />
|
||||
<span>Step Controls</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-slate-600">
|
||||
Current step: <span className="font-medium">{currentStep.name}</span>
|
||||
</div>
|
||||
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Available Actions:</Label>
|
||||
<div className="grid gap-2">
|
||||
{currentStep.actions.map((action: any, index: number) => (
|
||||
<Button
|
||||
key={action.id || index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onExecuteAction(`step_action_${action.id}`, action)}
|
||||
className="justify-start text-left"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-2" />
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Communication Dialog */}
|
||||
<Dialog open={isCommunicationOpen} onOpenChange={setIsCommunicationOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Intervention Note</DialogTitle>
|
||||
<DialogDescription>
|
||||
Record an intervention or observation during the trial.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="intervention-note">Intervention Note</Label>
|
||||
<Textarea
|
||||
id="intervention-note"
|
||||
value={interventionNote}
|
||||
onChange={(e) => setInterventionNote(e.target.value)}
|
||||
placeholder="Describe the intervention or observation..."
|
||||
className="mt-1"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCommunicationOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInterventionSubmit}
|
||||
disabled={!interventionNote.trim()}
|
||||
>
|
||||
Submit Note
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Emergency Action Dialog */}
|
||||
<Dialog open={showEmergencyDialog} onOpenChange={setShowEmergencyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2 text-red-600">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>Emergency Action Required</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the type of emergency action to perform. This will immediately stop or override current robot operations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="emergency-select">Emergency Action Type</Label>
|
||||
<Select value={selectedEmergencyAction} onValueChange={setSelectedEmergencyAction}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select emergency action..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emergencyActions.map((action) => (
|
||||
<SelectItem key={action.value} value={action.value}>
|
||||
{action.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-red-800">
|
||||
<strong>Warning:</strong> Emergency actions will immediately halt all robot operations and may require manual intervention to resume.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowEmergencyDialog(false);
|
||||
setSelectedEmergencyAction("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleEmergencyAction}
|
||||
disabled={!selectedEmergencyAction}
|
||||
>
|
||||
Execute Emergency Action
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
src/components/trials/wizard/ParticipantInfo.tsx
Normal file
242
src/components/trials/wizard/ParticipantInfo.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Briefcase, Clock, GraduationCap, Info, Mail, Shield, User
|
||||
} from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
interface ParticipantInfoProps {
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
demographics: any;
|
||||
};
|
||||
}
|
||||
|
||||
export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
const demographics = participant.demographics || {};
|
||||
|
||||
// Extract common demographic fields
|
||||
const age = demographics.age;
|
||||
const gender = demographics.gender;
|
||||
const occupation = demographics.occupation;
|
||||
const education = demographics.education;
|
||||
const language = demographics.primaryLanguage || demographics.language;
|
||||
const location = demographics.location || demographics.city;
|
||||
const experience = demographics.robotExperience || demographics.experience;
|
||||
|
||||
// Get participant initials for avatar
|
||||
const getInitials = () => {
|
||||
if (participant.name) {
|
||||
const nameParts = participant.name.split(" ");
|
||||
return nameParts.map((part) => part.charAt(0).toUpperCase()).join("");
|
||||
}
|
||||
return participant.participantCode.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const formatDemographicValue = (key: string, value: any) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
|
||||
// Handle different data types
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "Yes" : "No";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Participant</h3>
|
||||
</div>
|
||||
|
||||
{/* Basic Info Card */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-blue-100 font-medium text-blue-600">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-slate-900">
|
||||
{participant.name || "Anonymous"}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
ID: {participant.participantCode}
|
||||
</div>
|
||||
{participant.email && (
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{participant.email}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Demographics */}
|
||||
{(age || gender || language) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{age && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Age:</span>
|
||||
<span className="font-medium">{age}</span>
|
||||
</div>
|
||||
)}
|
||||
{gender && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Gender:</span>
|
||||
<span className="font-medium capitalize">{gender}</span>
|
||||
</div>
|
||||
)}
|
||||
{language && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Language:</span>
|
||||
<span className="font-medium">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Background Info */}
|
||||
{(occupation || education || experience) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center space-x-1 text-sm font-medium text-slate-700">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Background</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-0">
|
||||
{occupation && (
|
||||
<div className="flex items-start space-x-2 text-sm">
|
||||
<Briefcase className="mt-0.5 h-3 w-3 flex-shrink-0 text-slate-400" />
|
||||
<div>
|
||||
<div className="text-slate-600">Occupation</div>
|
||||
<div className="text-xs font-medium">{occupation}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{education && (
|
||||
<div className="flex items-start space-x-2 text-sm">
|
||||
<GraduationCap className="mt-0.5 h-3 w-3 flex-shrink-0 text-slate-400" />
|
||||
<div>
|
||||
<div className="text-slate-600">Education</div>
|
||||
<div className="text-xs font-medium">{education}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{experience && (
|
||||
<div className="flex items-start space-x-2 text-sm">
|
||||
<Shield className="mt-0.5 h-3 w-3 flex-shrink-0 text-slate-400" />
|
||||
<div>
|
||||
<div className="text-slate-600">Robot Experience</div>
|
||||
<div className="text-xs font-medium">{experience}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Demographics */}
|
||||
{Object.keys(demographics).length > 0 && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">
|
||||
Additional Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1">
|
||||
{Object.entries(demographics)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
![
|
||||
"age",
|
||||
"gender",
|
||||
"occupation",
|
||||
"education",
|
||||
"language",
|
||||
"primaryLanguage",
|
||||
"robotExperience",
|
||||
"experience",
|
||||
"location",
|
||||
"city",
|
||||
].includes(key) &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "",
|
||||
)
|
||||
.slice(0, 5) // Limit to 5 additional fields
|
||||
.map(([key, value]) => {
|
||||
const formattedValue = formatDemographicValue(key, value);
|
||||
if (!formattedValue) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-slate-600 capitalize">
|
||||
{key
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (str) => str.toUpperCase())}
|
||||
:
|
||||
</span>
|
||||
<span className="ml-2 max-w-[120px] truncate text-right font-medium">
|
||||
{formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Consent Status */}
|
||||
<Card className="border-green-200 bg-green-50 shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
Consent Verified
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-green-600">
|
||||
Participant has provided informed consent
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Info */}
|
||||
<div className="space-y-1 text-xs text-slate-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Session started: {new Date().toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
src/components/trials/wizard/RobotStatus.tsx
Normal file
357
src/components/trials/wizard/RobotStatus.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, AlertTriangle, Battery,
|
||||
BatteryLow, Bot, CheckCircle,
|
||||
Clock, RefreshCw, Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium, WifiOff
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
interface RobotStatusProps {
|
||||
trialId: string;
|
||||
}
|
||||
|
||||
interface RobotStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
connectionStatus: "connected" | "disconnected" | "connecting" | "error";
|
||||
batteryLevel?: number;
|
||||
signalStrength?: number;
|
||||
currentMode: string;
|
||||
lastHeartbeat?: Date;
|
||||
errorMessage?: string;
|
||||
capabilities: string[];
|
||||
communicationProtocol: string;
|
||||
isMoving: boolean;
|
||||
position?: {
|
||||
x: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
orientation?: number;
|
||||
};
|
||||
sensors?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Mock robot status - in real implementation, this would come from API/WebSocket
|
||||
useEffect(() => {
|
||||
// Simulate robot status updates
|
||||
const mockStatus: RobotStatus = {
|
||||
id: "robot_001",
|
||||
name: "TurtleBot3 Burger",
|
||||
connectionStatus: "connected",
|
||||
batteryLevel: 85,
|
||||
signalStrength: 75,
|
||||
currentMode: "autonomous_navigation",
|
||||
lastHeartbeat: new Date(),
|
||||
capabilities: ["navigation", "manipulation", "speech", "vision"],
|
||||
communicationProtocol: "ROS2",
|
||||
isMoving: false,
|
||||
position: {
|
||||
x: 1.2,
|
||||
y: 0.8,
|
||||
orientation: 45
|
||||
},
|
||||
sensors: {
|
||||
lidar: "operational",
|
||||
camera: "operational",
|
||||
imu: "operational",
|
||||
odometry: "operational"
|
||||
}
|
||||
};
|
||||
|
||||
setRobotStatus(mockStatus);
|
||||
|
||||
// Simulate periodic updates
|
||||
const interval = setInterval(() => {
|
||||
setRobotStatus(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
batteryLevel: Math.max(0, (prev.batteryLevel || 0) - Math.random() * 0.5),
|
||||
signalStrength: Math.max(0, Math.min(100, (prev.signalStrength || 0) + (Math.random() - 0.5) * 10)),
|
||||
lastHeartbeat: new Date(),
|
||||
position: prev.position ? {
|
||||
...prev.position,
|
||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
||||
} : undefined
|
||||
};
|
||||
});
|
||||
setLastUpdate(new Date());
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getConnectionStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
label: "Connected"
|
||||
};
|
||||
case "connecting":
|
||||
return {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
label: "Connecting"
|
||||
};
|
||||
case "disconnected":
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Disconnected"
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
label: "Error"
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Unknown"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSignalIcon = (strength: number) => {
|
||||
if (strength >= 75) return SignalHigh;
|
||||
if (strength >= 50) return SignalMedium;
|
||||
if (strength >= 25) return SignalLow;
|
||||
return Signal;
|
||||
};
|
||||
|
||||
const getBatteryIcon = (level: number) => {
|
||||
return level <= 20 ? BatteryLow : Battery;
|
||||
};
|
||||
|
||||
const handleRefreshStatus = async () => {
|
||||
setRefreshing(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
setLastUpdate(new Date());
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!robotStatus) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Robot Status</h3>
|
||||
</div>
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No robot connected</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength || 0);
|
||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel || 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Robot Status</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshStatus}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Status Card */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Robot Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
||||
<Badge className={`${statusConfig.bgColor} ${statusConfig.color}`} variant="secondary">
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="text-sm text-slate-600">
|
||||
Protocol: {robotStatus.communicationProtocol}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Battery */}
|
||||
{robotStatus.batteryLevel !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<BatteryIcon className={`h-3 w-3 ${
|
||||
robotStatus.batteryLevel <= 20 ? 'text-red-500' : 'text-green-500'
|
||||
}`} />
|
||||
<span>Battery</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.batteryLevel}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs font-medium w-8">
|
||||
{Math.round(robotStatus.batteryLevel)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal Strength */}
|
||||
{robotStatus.signalStrength !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
<span>Signal</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.signalStrength}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs font-medium w-8">
|
||||
{Math.round(robotStatus.signalStrength)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Mode */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-3 w-3 text-slate-600" />
|
||||
<span className="text-sm text-slate-600">Mode:</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{robotStatus.currentMode.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
{robotStatus.isMoving && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-xs text-blue-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
|
||||
<span>Robot is moving</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Position Info */}
|
||||
{robotStatus.position && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">Position</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">X:</span>
|
||||
<span className="font-mono">{robotStatus.position.x.toFixed(2)}m</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Y:</span>
|
||||
<span className="font-mono">{robotStatus.position.y.toFixed(2)}m</span>
|
||||
</div>
|
||||
{robotStatus.position.orientation !== undefined && (
|
||||
<div className="flex justify-between col-span-2">
|
||||
<span className="text-slate-600">Orientation:</span>
|
||||
<span className="font-mono">{Math.round(robotStatus.position.orientation)}°</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sensors Status */}
|
||||
{robotStatus.sensors && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">Sensors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1">
|
||||
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
|
||||
<div key={sensor} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600 capitalize">{sensor}:</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
status === 'operational'
|
||||
? 'text-green-600 border-green-200'
|
||||
: 'text-red-600 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{robotStatus.errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
{robotStatus.errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Last Update */}
|
||||
<div className="text-xs text-slate-500 flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
350
src/components/trials/wizard/StepDisplay.tsx
Normal file
350
src/components/trials/wizard/StepDisplay.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, ArrowRight, Bot, CheckCircle, GitBranch, MessageSquare, Play, Settings, Timer,
|
||||
User, Users
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
interface StepDisplayProps {
|
||||
step: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
description?: string;
|
||||
parameters?: any;
|
||||
duration?: number;
|
||||
actions?: any[];
|
||||
conditions?: any;
|
||||
branches?: any[];
|
||||
substeps?: any[];
|
||||
};
|
||||
stepIndex: number;
|
||||
totalSteps: number;
|
||||
isActive: boolean;
|
||||
onExecuteAction: (actionType: string, actionData: any) => Promise<void>;
|
||||
}
|
||||
|
||||
const stepTypeConfig = {
|
||||
wizard_action: {
|
||||
label: "Wizard Action",
|
||||
icon: User,
|
||||
color: "blue",
|
||||
description: "Action to be performed by the wizard operator",
|
||||
},
|
||||
robot_action: {
|
||||
label: "Robot Action",
|
||||
icon: Bot,
|
||||
color: "green",
|
||||
description: "Automated action performed by the robot",
|
||||
},
|
||||
parallel_steps: {
|
||||
label: "Parallel Steps",
|
||||
icon: Users,
|
||||
color: "purple",
|
||||
description: "Multiple actions happening simultaneously",
|
||||
},
|
||||
conditional_branch: {
|
||||
label: "Conditional Branch",
|
||||
icon: GitBranch,
|
||||
color: "orange",
|
||||
description: "Step with conditional logic and branching",
|
||||
},
|
||||
};
|
||||
|
||||
export function StepDisplay({
|
||||
step,
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
isActive,
|
||||
onExecuteAction
|
||||
}: StepDisplayProps) {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [completedActions, setCompletedActions] = useState<Set<string>>(new Set());
|
||||
|
||||
const stepConfig = stepTypeConfig[step.type];
|
||||
const StepIcon = stepConfig.icon;
|
||||
|
||||
const handleActionExecution = async (actionId: string, actionData: any) => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onExecuteAction(actionId, actionData);
|
||||
setCompletedActions(prev => new Set([...prev, actionId]));
|
||||
} catch (_error) {
|
||||
console.error("Failed to execute action:", _error);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step.type) {
|
||||
case "wizard_action":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step.description && (
|
||||
<Alert>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<AlertDescription>{step.description}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step.actions && step.actions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Available Actions:</h4>
|
||||
<div className="grid gap-2">
|
||||
{step.actions.map((action: any, index: number) => {
|
||||
const isCompleted = completedActions.has(action.id);
|
||||
return (
|
||||
<div
|
||||
key={action.id || index}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
isCompleted
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-slate-50 border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{action.name}</p>
|
||||
{action.description && (
|
||||
<p className="text-xs text-slate-600">{action.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && !isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleActionExecution(action.id, action)}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Execute
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "robot_action":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step.description && (
|
||||
<Alert>
|
||||
<Bot className="h-4 w-4" />
|
||||
<AlertDescription>{step.description}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step.parameters && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-slate-900">Robot Parameters:</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm font-mono">
|
||||
<pre>{JSON.stringify(step.parameters, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-600">
|
||||
<Activity className="h-4 w-4 animate-pulse" />
|
||||
<span>Robot executing action...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "parallel_steps":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step.description && (
|
||||
<Alert>
|
||||
<Users className="h-4 w-4" />
|
||||
<AlertDescription>{step.description}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step.substeps && step.substeps.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Parallel Actions:</h4>
|
||||
<div className="grid gap-3">
|
||||
{step.substeps.map((substep: any, index: number) => (
|
||||
<div
|
||||
key={substep.id || index}
|
||||
className="flex items-center space-x-3 p-3 bg-slate-50 rounded-lg border"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center text-xs font-medium text-purple-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{substep.name}</p>
|
||||
{substep.description && (
|
||||
<p className="text-xs text-slate-600">{substep.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{substep.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "conditional_branch":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step.description && (
|
||||
<Alert>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<AlertDescription>{step.description}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step.conditions && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-slate-900">Conditions:</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm">
|
||||
<pre>{JSON.stringify(step.conditions, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.branches && step.branches.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Possible Branches:</h4>
|
||||
<div className="grid gap-2">
|
||||
{step.branches.map((branch: any, index: number) => (
|
||||
<div
|
||||
key={branch.id || index}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ArrowRight className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{branch.name}</p>
|
||||
{branch.condition && (
|
||||
<p className="text-xs text-slate-600">If: {branch.condition}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleActionExecution(`branch_${branch.id}`, branch)}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Settings className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>Unknown step type: {step.type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`transition-all duration-200 ${
|
||||
isActive ? "ring-2 ring-blue-500 shadow-lg" : "border-slate-200"
|
||||
}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
stepConfig.color === "blue" ? "bg-blue-100" :
|
||||
stepConfig.color === "green" ? "bg-green-100" :
|
||||
stepConfig.color === "purple" ? "bg-purple-100" :
|
||||
stepConfig.color === "orange" ? "bg-orange-100" :
|
||||
"bg-slate-100"
|
||||
}`}>
|
||||
<StepIcon className={`h-5 w-5 ${
|
||||
stepConfig.color === "blue" ? "text-blue-600" :
|
||||
stepConfig.color === "green" ? "text-green-600" :
|
||||
stepConfig.color === "purple" ? "text-purple-600" :
|
||||
stepConfig.color === "orange" ? "text-orange-600" :
|
||||
"text-slate-600"
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg font-semibold text-slate-900">
|
||||
{step.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stepConfig.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-500">
|
||||
Step {stepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
{stepConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end space-y-2">
|
||||
{isActive && (
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
<Activity className="mr-1 h-3 w-3 animate-pulse" />
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{step.duration && (
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Timer className="h-3 w-3" />
|
||||
<span>{step.duration}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{renderStepContent()}
|
||||
|
||||
{/* Step Progress Indicator */}
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Step Progress</span>
|
||||
<span>{stepIndex + 1}/{totalSteps}</span>
|
||||
</div>
|
||||
<Progress value={((stepIndex + 1) / totalSteps) * 100} className="h-1 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
331
src/components/trials/wizard/TrialProgress.tsx
Normal file
331
src/components/trials/wizard/TrialProgress.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, Bot, CheckCircle,
|
||||
Circle, Clock, GitBranch, Play, Target, Users
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
interface TrialProgressProps {
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
description?: string;
|
||||
duration?: number;
|
||||
parameters?: any;
|
||||
}>;
|
||||
currentStepIndex: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
}
|
||||
|
||||
const stepTypeConfig = {
|
||||
wizard_action: {
|
||||
label: "Wizard",
|
||||
icon: Play,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
textColor: "text-blue-600",
|
||||
borderColor: "border-blue-300"
|
||||
},
|
||||
robot_action: {
|
||||
label: "Robot",
|
||||
icon: Bot,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-600",
|
||||
borderColor: "border-green-300"
|
||||
},
|
||||
parallel_steps: {
|
||||
label: "Parallel",
|
||||
icon: Users,
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
textColor: "text-purple-600",
|
||||
borderColor: "border-purple-300"
|
||||
},
|
||||
conditional_branch: {
|
||||
label: "Branch",
|
||||
icon: GitBranch,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
textColor: "text-orange-600",
|
||||
borderColor: "border-orange-300"
|
||||
}
|
||||
};
|
||||
|
||||
export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialProgressProps) {
|
||||
if (!steps || steps.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Target className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No experiment steps defined</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = trialStatus === "completed" ? 100 :
|
||||
trialStatus === "aborted" ? 0 :
|
||||
((currentStepIndex + 1) / steps.length) * 100;
|
||||
|
||||
const completedSteps = trialStatus === "completed" ? steps.length :
|
||||
trialStatus === "aborted" || trialStatus === "failed" ? 0 :
|
||||
currentStepIndex;
|
||||
|
||||
const getStepStatus = (index: number) => {
|
||||
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
|
||||
if (trialStatus === "completed" || index < currentStepIndex) return "completed";
|
||||
if (index === currentStepIndex && trialStatus === "in_progress") return "active";
|
||||
if (index === currentStepIndex && trialStatus === "scheduled") return "pending";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const getStepStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
iconColor: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
borderColor: "border-green-300",
|
||||
textColor: "text-green-800"
|
||||
};
|
||||
case "active":
|
||||
return {
|
||||
icon: Activity,
|
||||
iconColor: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
borderColor: "border-blue-300",
|
||||
textColor: "text-blue-800"
|
||||
};
|
||||
case "pending":
|
||||
return {
|
||||
icon: Clock,
|
||||
iconColor: "text-amber-600",
|
||||
bgColor: "bg-amber-100",
|
||||
borderColor: "border-amber-300",
|
||||
textColor: "text-amber-800"
|
||||
};
|
||||
case "aborted":
|
||||
return {
|
||||
icon: Circle,
|
||||
iconColor: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
borderColor: "border-red-300",
|
||||
textColor: "text-red-800"
|
||||
};
|
||||
default: // upcoming
|
||||
return {
|
||||
icon: Circle,
|
||||
iconColor: "text-slate-400",
|
||||
bgColor: "bg-slate-100",
|
||||
borderColor: "border-slate-300",
|
||||
textColor: "text-slate-600"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const totalDuration = steps.reduce((sum, step) => sum + (step.duration || 0), 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Target className="h-5 w-5" />
|
||||
<span>Trial Progress</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedSteps}/{steps.length} steps
|
||||
</Badge>
|
||||
{totalDuration > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
~{Math.round(totalDuration / 60)}min
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Overall Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Overall Progress</span>
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-2 ${
|
||||
trialStatus === "completed" ? "bg-green-100" :
|
||||
trialStatus === "aborted" || trialStatus === "failed" ? "bg-red-100" :
|
||||
"bg-blue-100"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Start</span>
|
||||
<span>
|
||||
{trialStatus === "completed" ? "Completed" :
|
||||
trialStatus === "aborted" ? "Aborted" :
|
||||
trialStatus === "failed" ? "Failed" :
|
||||
trialStatus === "in_progress" ? "In Progress" :
|
||||
"Not Started"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Steps Timeline */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-slate-900 text-sm">Experiment Steps</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const stepConfig = stepTypeConfig[step.type];
|
||||
const StepIcon = stepConfig.icon;
|
||||
const status = getStepStatus(index);
|
||||
const statusConfig = getStepStatusConfig(status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative">
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute left-6 top-12 w-0.5 h-6 ${
|
||||
getStepStatus(index + 1) === "completed" ||
|
||||
(getStepStatus(index + 1) === "active" && status === "completed")
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step Card */}
|
||||
<div className={`flex items-start space-x-3 p-3 rounded-lg border transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "bg-slate-50 border-slate-200"
|
||||
}`}>
|
||||
{/* Step Number & Status */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div className={`w-12 h-8 rounded-lg flex items-center justify-center ${
|
||||
status === "active" ? statusConfig.bgColor :
|
||||
status === "completed" ? "bg-green-100" :
|
||||
status === "aborted" ? "bg-red-100" :
|
||||
"bg-slate-100"
|
||||
}`}>
|
||||
<span className={`text-sm font-medium ${
|
||||
status === "active" ? statusConfig.textColor :
|
||||
status === "completed" ? "text-green-700" :
|
||||
status === "aborted" ? "text-red-700" :
|
||||
"text-slate-600"
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<StatusIcon className={`h-4 w-4 ${statusConfig.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h5 className={`font-medium truncate ${
|
||||
status === "active" ? "text-slate-900" :
|
||||
status === "completed" ? "text-green-900" :
|
||||
status === "aborted" ? "text-red-900" :
|
||||
"text-slate-700"
|
||||
}`}>
|
||||
{step.name}
|
||||
</h5>
|
||||
{step.description && (
|
||||
<p className="text-sm text-slate-600 mt-1 line-clamp-2">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 ml-3 space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${stepConfig.textColor} ${stepConfig.borderColor}`}
|
||||
>
|
||||
<StepIcon className="mr-1 h-3 w-3" />
|
||||
{stepConfig.label}
|
||||
</Badge>
|
||||
{step.duration && (
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{step.duration}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Status Message */}
|
||||
{status === "active" && trialStatus === "in_progress" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-blue-600">
|
||||
<Activity className="h-3 w-3 animate-pulse" />
|
||||
<span>Currently executing...</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "active" && trialStatus === "scheduled" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-amber-600">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Ready to start</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-green-600">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<Separator />
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedSteps}</div>
|
||||
<div className="text-xs text-slate-600">Completed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{trialStatus === "in_progress" ? 1 : 0}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Active</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-600">
|
||||
{steps.length - completedSteps - (trialStatus === "in_progress" ? 1 : 0)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
518
src/components/trials/wizard/WizardInterface.tsx
Normal file
518
src/components/trials/wizard/WizardInterface.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, AlertTriangle, CheckCircle, Play, SkipForward, Square, Timer, Wifi,
|
||||
WifiOff
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { useTrialWebSocket } from "~/hooks/useWebSocket";
|
||||
import { api } from "~/trpc/react";
|
||||
import { EventsLog } from "../execution/EventsLog";
|
||||
import { ActionControls } from "./ActionControls";
|
||||
import { ParticipantInfo } from "./ParticipantInfo";
|
||||
import { RobotStatus } from "./RobotStatus";
|
||||
import { StepDisplay } from "./StepDisplay";
|
||||
import { TrialProgress } from "./TrialProgress";
|
||||
|
||||
interface WizardInterfaceProps {
|
||||
trial: {
|
||||
id: string;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
notes: string | null;
|
||||
metadata: any;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: any;
|
||||
};
|
||||
};
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
export function WizardInterface({
|
||||
trial: initialTrial,
|
||||
userRole,
|
||||
}: WizardInterfaceProps) {
|
||||
const router = useRouter();
|
||||
const [trial, setTrial] = useState(initialTrial);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [trialStartTime, setTrialStartTime] = useState<Date | null>(
|
||||
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
|
||||
);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Real-time WebSocket connection
|
||||
const {
|
||||
isConnected: wsConnected,
|
||||
isConnecting: wsConnecting,
|
||||
connectionError: wsError,
|
||||
currentTrialStatus,
|
||||
trialEvents,
|
||||
wizardActions,
|
||||
executeTrialAction,
|
||||
logWizardIntervention,
|
||||
transitionStep,
|
||||
} = useTrialWebSocket(trial.id);
|
||||
|
||||
// Fallback polling for trial updates when WebSocket is not available
|
||||
const { data: trialUpdates } = api.trials.get.useQuery(
|
||||
{ id: trial.id },
|
||||
{
|
||||
refetchInterval: wsConnected ? 10000 : 2000, // Less frequent polling when WebSocket is active
|
||||
refetchOnWindowFocus: true,
|
||||
enabled: !wsConnected, // Disable when WebSocket is connected
|
||||
},
|
||||
);
|
||||
|
||||
// Mutations for trial control
|
||||
const startTrialMutation = api.trials.start.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTrial((prev) => ({ ...prev, ...data }));
|
||||
setTrialStartTime(new Date());
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const completeTrialMutation = api.trials.complete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTrial((prev) => ({ ...prev, ...data }));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
// Redirect to analysis page after completion
|
||||
setTimeout(() => {
|
||||
router.push(`/trials/${trial.id}/analysis`);
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const abortTrialMutation = api.trials.abort.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTrial((prev) => ({ ...prev, ...data }));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const logEventMutation = api.trials.logEvent.useMutation({
|
||||
onSuccess: () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
},
|
||||
});
|
||||
|
||||
// Update trial state when data changes (WebSocket has priority)
|
||||
useEffect(() => {
|
||||
const latestTrial = currentTrialStatus || trialUpdates;
|
||||
if (latestTrial) {
|
||||
setTrial(latestTrial);
|
||||
if (latestTrial.startedAt && !trialStartTime) {
|
||||
setTrialStartTime(new Date(latestTrial.startedAt));
|
||||
}
|
||||
}
|
||||
}, [currentTrialStatus, trialUpdates, trialStartTime]);
|
||||
|
||||
// Mock experiment steps for now - in real implementation, fetch from experiment API
|
||||
const experimentSteps = [
|
||||
{
|
||||
id: "step1",
|
||||
name: "Initial Greeting",
|
||||
type: "wizard_action" as const,
|
||||
description: "Greet the participant and explain the task",
|
||||
duration: 60,
|
||||
},
|
||||
{
|
||||
id: "step2",
|
||||
name: "Robot Introduction",
|
||||
type: "robot_action" as const,
|
||||
description: "Robot introduces itself to participant",
|
||||
duration: 30,
|
||||
},
|
||||
{
|
||||
id: "step3",
|
||||
name: "Task Demonstration",
|
||||
type: "wizard_action" as const,
|
||||
description: "Demonstrate the task to the participant",
|
||||
duration: 120,
|
||||
},
|
||||
];
|
||||
const currentStep = experimentSteps[currentStepIndex];
|
||||
const progress =
|
||||
experimentSteps.length > 0
|
||||
? ((currentStepIndex + 1) / experimentSteps.length) * 100
|
||||
: 0;
|
||||
|
||||
// Trial control handlers using WebSocket when available
|
||||
const handleStartTrial = useCallback(async () => {
|
||||
try {
|
||||
if (wsConnected) {
|
||||
executeTrialAction("start_trial", {
|
||||
step_index: 0,
|
||||
data: { notes: "Trial started by wizard" },
|
||||
});
|
||||
} else {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "trial_start",
|
||||
data: { step_index: 0, notes: "Trial started by wizard" },
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("Failed to start trial:", _error);
|
||||
}
|
||||
}, [
|
||||
trial.id,
|
||||
wsConnected,
|
||||
executeTrialAction,
|
||||
startTrialMutation,
|
||||
logEventMutation,
|
||||
]);
|
||||
|
||||
const handleCompleteTrial = useCallback(async () => {
|
||||
try {
|
||||
if (wsConnected) {
|
||||
executeTrialAction("complete_trial", {
|
||||
final_step_index: currentStepIndex,
|
||||
completion_type: "wizard_completed",
|
||||
notes: "Trial completed successfully via wizard interface",
|
||||
});
|
||||
} else {
|
||||
await completeTrialMutation.mutateAsync({
|
||||
id: trial.id,
|
||||
notes: "Trial completed successfully via wizard interface",
|
||||
});
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "trial_end",
|
||||
data: {
|
||||
final_step_index: currentStepIndex,
|
||||
completion_type: "wizard_completed",
|
||||
notes: "Trial completed by wizard",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("Failed to complete trial:", _error);
|
||||
}
|
||||
}, [
|
||||
trial.id,
|
||||
currentStepIndex,
|
||||
wsConnected,
|
||||
executeTrialAction,
|
||||
completeTrialMutation,
|
||||
logEventMutation,
|
||||
]);
|
||||
|
||||
const handleAbortTrial = useCallback(async () => {
|
||||
try {
|
||||
if (wsConnected) {
|
||||
executeTrialAction("abort_trial", {
|
||||
abort_step_index: currentStepIndex,
|
||||
abort_reason: "wizard_abort",
|
||||
reason: "Aborted via wizard interface",
|
||||
});
|
||||
} else {
|
||||
await abortTrialMutation.mutateAsync({
|
||||
id: trial.id,
|
||||
reason: "Aborted via wizard interface",
|
||||
});
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "trial_end",
|
||||
data: {
|
||||
abort_step_index: currentStepIndex,
|
||||
abort_reason: "wizard_abort",
|
||||
notes: "Trial aborted by wizard",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error("Failed to abort trial:", _error);
|
||||
}
|
||||
}, [
|
||||
trial.id,
|
||||
currentStepIndex,
|
||||
wsConnected,
|
||||
executeTrialAction,
|
||||
abortTrialMutation,
|
||||
logEventMutation,
|
||||
]);
|
||||
|
||||
const handleNextStep = useCallback(async () => {
|
||||
if (currentStepIndex < experimentSteps.length - 1) {
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
setCurrentStepIndex(nextIndex);
|
||||
|
||||
if (wsConnected) {
|
||||
transitionStep({
|
||||
from_step: currentStepIndex,
|
||||
to_step: nextIndex,
|
||||
step_name: experimentSteps[nextIndex]?.name,
|
||||
data: { notes: `Advanced to step ${nextIndex + 1}: ${experimentSteps[nextIndex]?.name}` },
|
||||
});
|
||||
} else {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "step_start",
|
||||
data: {
|
||||
from_step: currentStepIndex,
|
||||
to_step: nextIndex,
|
||||
step_name: experimentSteps[nextIndex]?.name,
|
||||
notes: `Advanced to step ${nextIndex + 1}: ${experimentSteps[nextIndex]?.name}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentStepIndex,
|
||||
experimentSteps,
|
||||
trial.id,
|
||||
wsConnected,
|
||||
transitionStep,
|
||||
logEventMutation,
|
||||
]);
|
||||
|
||||
const handleExecuteAction = useCallback(
|
||||
async (actionType: string, actionData: any) => {
|
||||
if (wsConnected) {
|
||||
logWizardIntervention({
|
||||
action_type: actionType,
|
||||
step_index: currentStepIndex,
|
||||
step_name: currentStep?.name,
|
||||
action_data: actionData,
|
||||
data: { notes: `Wizard executed ${actionType} action` },
|
||||
});
|
||||
} else {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "wizard_intervention",
|
||||
data: {
|
||||
action_type: actionType,
|
||||
step_index: currentStepIndex,
|
||||
step_name: currentStep?.name,
|
||||
action_data: actionData,
|
||||
notes: `Wizard executed ${actionType} action`,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
trial.id,
|
||||
currentStepIndex,
|
||||
currentStep?.name,
|
||||
wsConnected,
|
||||
logWizardIntervention,
|
||||
logEventMutation,
|
||||
],
|
||||
);
|
||||
|
||||
// Calculate elapsed time
|
||||
const elapsedTime = trialStartTime
|
||||
? Math.floor((Date.now() - trialStartTime.getTime()) / 1000)
|
||||
: 0;
|
||||
|
||||
const formatElapsedTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-120px)] bg-slate-50">
|
||||
{/* Left Panel - Main Control */}
|
||||
<div className="flex flex-1 flex-col space-y-6 overflow-y-auto p-6">
|
||||
{/* Trial Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
<span>Trial Control</span>
|
||||
</div>
|
||||
{/* WebSocket Connection Status */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{wsConnected ? (
|
||||
<Badge className="bg-green-100 text-green-800">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Real-time
|
||||
</Badge>
|
||||
) : wsConnecting ? (
|
||||
<Badge className="bg-yellow-100 text-yellow-800">
|
||||
<Activity className="mr-1 h-3 w-3 animate-spin" />
|
||||
Connecting...
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Offline
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
{wsError && (
|
||||
<Alert className="mt-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Connection issue: {wsError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status and Timer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Badge
|
||||
className={
|
||||
trial.status === "in_progress"
|
||||
? "bg-green-100 text-green-800"
|
||||
: trial.status === "scheduled"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}
|
||||
>
|
||||
{trial.status === "in_progress"
|
||||
? "Active"
|
||||
: trial.status === "scheduled"
|
||||
? "Ready"
|
||||
: "Inactive"}
|
||||
</Badge>
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-600">
|
||||
<Timer className="h-4 w-4" />
|
||||
<span className="font-mono text-lg">
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{experimentSteps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{currentStepIndex + 1} of {experimentSteps.length} steps
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Action Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
disabled={startTrialMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleNextStep}
|
||||
disabled={currentStepIndex >= experimentSteps.length - 1}
|
||||
className="flex-1"
|
||||
>
|
||||
<SkipForward className="mr-2 h-4 w-4" />
|
||||
Next Step
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCompleteTrial}
|
||||
disabled={completeTrialMutation.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAbortTrial}
|
||||
disabled={abortTrialMutation.isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Abort
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Step Display */}
|
||||
{currentStep && (
|
||||
<StepDisplay
|
||||
step={currentStep}
|
||||
stepIndex={currentStepIndex}
|
||||
totalSteps={experimentSteps.length}
|
||||
isActive={trial.status === "in_progress"}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Controls */}
|
||||
{trial.status === "in_progress" && (
|
||||
<ActionControls
|
||||
currentStep={currentStep ?? null}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
trialId={trial.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trial Progress Overview */}
|
||||
<TrialProgress
|
||||
steps={experimentSteps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialStatus={trial.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Info & Monitoring */}
|
||||
<div className="flex w-96 flex-col border-l border-slate-200 bg-white">
|
||||
{/* Participant Info */}
|
||||
<div className="border-b border-slate-200 p-4">
|
||||
<ParticipantInfo participant={{...trial.participant, email: null, name: null}} />
|
||||
</div>
|
||||
|
||||
{/* Robot Status */}
|
||||
<div className="border-b border-slate-200 p-4">
|
||||
<RobotStatus trialId={trial.id} />
|
||||
</div>
|
||||
|
||||
{/* Live Events Log */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<EventsLog
|
||||
trialId={trial.id}
|
||||
refreshKey={refreshKey}
|
||||
isLive={trial.status === "in_progress"}
|
||||
realtimeEvents={trialEvents}
|
||||
isWebSocketConnected={wsConnected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user