/** * Pipeline Case Detail Page * 3-stage pipeline: Landmarks → Analysis → Brace */ import { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { fetchCase, uploadXrayForCase, detectLandmarks, updateLandmarks, approveLandmarks, recalculateAnalysis, generateBraceFromLandmarks, generateBothBraces, updateMarkers, getCaseAssets, uploadBodyScan, getBodyScan, deleteBodyScan, skipBodyScan, } from '../api/braceflowApi'; import type { CaseRecord, LandmarksResult, RecalculationResult, GenerateBraceResponse, VertebraeStructure, BodyScanResponse, } from '../api/braceflowApi'; import PipelineSteps, { type PipelineStage } from '../components/pipeline/PipelineSteps'; import LandmarkDetectionStage from '../components/pipeline/LandmarkDetectionStage'; import SpineAnalysisStage from '../components/pipeline/SpineAnalysisStage'; import BodyScanUploadStage from '../components/pipeline/BodyScanUploadStage'; import BraceGenerationStage from '../components/pipeline/BraceGenerationStage'; import BraceFittingStage from '../components/pipeline/BraceFittingStage'; export default function PipelineCaseDetail() { const { caseId } = useParams<{ caseId: string }>(); const nav = useNavigate(); // Case data const [caseData, setCaseData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Stage data const [xrayUrl, setXrayUrl] = useState(null); const [landmarksData, setLandmarksData] = useState(null); const [analysisData, setAnalysisData] = useState(null); const [bodyScanData, setBodyScanData] = useState(null); const [braceData, setBraceData] = useState(null); // Current stage and loading states const [currentStage, setCurrentStage] = useState('upload'); const [detectingLandmarks, setDetectingLandmarks] = useState(false); const [recalculating, setRecalculating] = useState(false); const [uploadingBodyScan, setUploadingBodyScan] = useState(false); const [generatingBrace, setGeneratingBrace] = useState(false); // Load case data const loadCase = useCallback(async () => { if (!caseId) return; setLoading(true); setError(null); try { const data = await fetchCase(caseId); setCaseData(data); // Load assets const assets = await getCaseAssets(caseId).catch((err) => { console.error('Failed to get case assets:', err); return null; }); console.log('Case assets:', assets); const xray = assets?.assets?.uploads?.find((f) => f.filename.match(/\.(jpg|jpeg|png)$/i) ); console.log('Found xray:', xray); if (xray) { console.log('Setting xrayUrl to:', xray.url); setXrayUrl(xray.url); } // Check existing state from case data if (data?.landmarks_data) { // Handle both formats - full LandmarksResult or just VertebraeStructure const ld = data.landmarks_data as any; if (ld.vertebrae_structure) { // Full LandmarksResult format setLandmarksData(ld as LandmarksResult); } else if (ld.vertebrae || ld.all_levels) { // Just VertebraeStructure - wrap it setLandmarksData({ case_id: caseId || '', status: 'landmarks_detected', input: { image_dimensions: { width: 0, height: 0 }, pixel_spacing_mm: null }, detection_quality: { vertebrae_count: ld.detected_count || ld.vertebrae?.filter((v: any) => v.detected).length || 0, average_confidence: 0 }, cobb_angles: ld.cobb_angles || { PT: 0, MT: 0, TL: 0, max: 0, PT_severity: 'N/A', MT_severity: 'N/A', TL_severity: 'N/A' }, rigo_classification: ld.rigo_classification || { type: 'Unknown', description: '' }, curve_type: ld.curve_type || 'Unknown', vertebrae_structure: ld as VertebraeStructure, processing_time_ms: 0 } as LandmarksResult); } } // Load saved analysis data if available (from previous recalculate) if (data?.analysis_data) { setAnalysisData(data.analysis_data as RecalculationResult); } // Load body scan data if available if (data?.body_scan_path || data?.body_scan_url) { setBodyScanData({ caseId: caseId || '', has_body_scan: true, body_scan: { path: data.body_scan_path || '', url: data.body_scan_url || '', metadata: data.body_scan_metadata || {} } }); } // Load brace data if available let hasBraceData = false; if (data?.analysis_result) { const analysisResult = data.analysis_result as any; // Check for both braces format (regular + vase) if (analysisResult.braces) { setBraceData({ rigo_classification: { type: analysisResult.rigoType || 'A1' }, cobb_angles: analysisResult.cobbAngles, braces: analysisResult.braces, } as any); hasBraceData = true; } // Check for single brace format (legacy) else if (analysisResult.brace) { setBraceData(analysisResult.brace as GenerateBraceResponse); hasBraceData = true; } } // Determine current stage - prioritize actual data over status // This ensures the UI reflects reality even if status is out of sync if (hasBraceData) { // Brace exists - go straight to brace stage setCurrentStage('brace'); } else { // Determine stage from status switch (data?.status) { case 'created': setCurrentStage(xray ? 'landmarks' : 'upload'); break; case 'landmarks_detected': // Landmarks detected but not approved - stay on landmarks stage setCurrentStage('landmarks'); break; case 'landmarks_approved': // Landmarks approved - move to analysis stage setCurrentStage('analysis'); break; case 'analysis_complete': // Analysis complete - move to body scan stage setCurrentStage('bodyscan'); break; case 'body_scan_uploaded': // Body scan uploaded - still on body scan stage (need to continue) setCurrentStage('bodyscan'); break; case 'processing_brace': // Currently generating brace setCurrentStage('brace'); break; case 'brace_generated': case 'completed': // Brace already generated - show brace stage setCurrentStage('brace'); break; default: setCurrentStage(xray ? 'landmarks' : 'upload'); } } } catch (e: any) { setError(e?.message || 'Failed to load case'); } finally { setLoading(false); } }, [caseId]); useEffect(() => { loadCase(); }, [loadCase]); // Handle file upload const handleUpload = async (file: File) => { if (!caseId) return; try { await uploadXrayForCase(caseId, file); // Reload to get new X-ray URL await loadCase(); } catch (e: any) { setError(e?.message || 'Upload failed'); } }; // Stage 1: Detect landmarks const handleDetectLandmarks = async () => { if (!caseId) return; setDetectingLandmarks(true); setError(null); try { const result = await detectLandmarks(caseId); setLandmarksData(result); setCurrentStage('landmarks'); } catch (e: any) { setError(e?.message || 'Landmark detection failed'); } finally { setDetectingLandmarks(false); } }; // Stage 1: Update landmarks const handleUpdateLandmarks = async (landmarks: VertebraeStructure) => { if (!caseId) return; try { await updateLandmarks(caseId, landmarks); // Update local state if (landmarksData) { setLandmarksData({ ...landmarksData, vertebrae_structure: landmarks, }); } } catch (e: any) { setError(e?.message || 'Failed to save landmarks'); } }; // Stage 1 -> 2: Approve landmarks const handleApproveLandmarks = async (updatedLandmarks?: VertebraeStructure) => { if (!caseId) return; try { await approveLandmarks(caseId, updatedLandmarks); setCurrentStage('analysis'); } catch (e: any) { setError(e?.message || 'Failed to approve landmarks'); } }; // Stage 2: Recalculate analysis const handleRecalculate = async () => { if (!caseId) return; setRecalculating(true); setError(null); try { const result = await recalculateAnalysis(caseId); setAnalysisData(result); } catch (e: any) { setError(e?.message || 'Recalculation failed'); } finally { setRecalculating(false); } }; // Stage 2 -> 3: Continue to body scan const handleContinueToBodyScan = () => { setCurrentStage('bodyscan'); }; // Stage 3: Upload body scan const handleUploadBodyScan = async (file: File) => { if (!caseId) return; setUploadingBodyScan(true); setError(null); try { const result = await uploadBodyScan(caseId, file); setBodyScanData({ caseId, has_body_scan: true, body_scan: result.body_scan }); } catch (e: any) { setError(e?.message || 'Body scan upload failed'); } finally { setUploadingBodyScan(false); } }; // Stage 3: Skip body scan const handleSkipBodyScan = async () => { if (!caseId) return; try { await skipBodyScan(caseId); setBodyScanData({ caseId, has_body_scan: false, body_scan: null }); setCurrentStage('brace'); } catch (e: any) { setError(e?.message || 'Failed to skip body scan'); } }; // Stage 3: Delete body scan const handleDeleteBodyScan = async () => { if (!caseId) return; try { await deleteBodyScan(caseId); setBodyScanData({ caseId, has_body_scan: false, body_scan: null }); } catch (e: any) { setError(e?.message || 'Failed to delete body scan'); } }; // Stage 3 -> 4: Continue to brace generation const handleContinueToBrace = () => { setCurrentStage('brace'); }; // Stage 4 -> 5: Continue to fitting inspection const handleContinueToFitting = () => { setCurrentStage('fitting'); }; // Handle clicking on pipeline step to navigate const handleStageClick = (stage: PipelineStage) => { setCurrentStage(stage); }; // Stage 3: Generate brace (both regular and vase types) const handleGenerateBrace = async () => { if (!caseId) return; setGeneratingBrace(true); setError(null); let standardResult: any = null; try { // First try to generate the standard brace (for backward compatibility) try { standardResult = await generateBraceFromLandmarks(caseId, { experiment: 'experiment_9', }); } catch (stdErr) { console.warn('Standard brace generation failed, trying both braces:', stdErr); } // Generate both brace types (regular + vase) with markers const bothBraces = await generateBothBraces(caseId, { rigoType: standardResult?.rigo_classification?.type, }); // Merge both braces data into the result setBraceData({ ...(standardResult || {}), rigo_classification: standardResult?.rigo_classification || { type: bothBraces.rigoType }, cobb_angles: standardResult?.cobb_angles || bothBraces.cobbAngles, braces: bothBraces.braces, } as any); } catch (e: any) { // If both fail, show error if (standardResult) { // At least we have the standard result setBraceData(standardResult); } else { setError(e?.message || 'Brace generation failed'); } } finally { setGeneratingBrace(false); } }; // Stage 3: Update markers const handleUpdateMarkers = async (markers: Record) => { if (!caseId) return; try { await updateMarkers(caseId, markers); } catch (e: any) { setError(e?.message || 'Failed to save markers'); } }; // Loading state if (loading) { return (

Loading case...

); } // Error state if (error && !caseData) { return (

{error}

); } const visualizationUrl = landmarksData?.visualization_url || (landmarksData as any)?.visualization_path?.replace(/^.*[\\\/]/, '/files/outputs/' + caseId + '/'); return (
{/* Header */}
{caseData?.patient && (

{caseData.patient.fullName} {caseData.patient.mrn && ( MRN: {caseData.patient.mrn} )}

)}
{caseId} {caseData?.visit_date && ( Visit: {new Date(caseData.visit_date).toLocaleDateString()} )} {caseData?.status?.replace(/_/g, ' ') || 'Created'}
{/* Pipeline Steps Indicator */} {/* Error Banner */} {error && (
{error}
)} {/* Upload Section (if no X-ray) */} {!xrayUrl && currentStage === 'upload' && (

Upload X-ray Image

Upload a frontal (AP) X-ray image to begin analysis.

{ const file = e.target.files?.[0]; if (file) handleUpload(file); }} />
)} {/* Stage Components */}
{(currentStage === 'landmarks' || xrayUrl) && ( )} {(currentStage === 'analysis' || currentStage === 'bodyscan' || currentStage === 'brace') && ( )} {(currentStage === 'bodyscan' || currentStage === 'brace') && ( )} {currentStage === 'brace' && ( )} {/* Stage 5: Brace Fitting - shows when brace is generated AND body scan exists */} {(currentStage === 'brace' || currentStage === 'fitting') && braceData && bodyScanData?.has_body_scan && ( )}
); }