Files
braceiqmed/frontend/src/pages/PipelineCaseDetail.tsx

573 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<CaseRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Stage data
const [xrayUrl, setXrayUrl] = useState<string | null>(null);
const [landmarksData, setLandmarksData] = useState<LandmarksResult | null>(null);
const [analysisData, setAnalysisData] = useState<RecalculationResult | null>(null);
const [bodyScanData, setBodyScanData] = useState<BodyScanResponse | null>(null);
const [braceData, setBraceData] = useState<GenerateBraceResponse | null>(null);
// Current stage and loading states
const [currentStage, setCurrentStage] = useState<PipelineStage>('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<string, unknown>) => {
if (!caseId) return;
try {
await updateMarkers(caseId, markers);
} catch (e: any) {
setError(e?.message || 'Failed to save markers');
}
};
// Loading state
if (loading) {
return (
<div className="pipeline-page">
<div className="pipeline-loading">
<div className="spinner"></div>
<p>Loading case...</p>
</div>
</div>
);
}
// Error state
if (error && !caseData) {
return (
<div className="pipeline-page">
<div className="pipeline-error">
<p className="error">{error}</p>
<button className="btn secondary" onClick={() => nav('/')}>
Back to Dashboard
</button>
</div>
</div>
);
}
const visualizationUrl =
landmarksData?.visualization_url ||
(landmarksData as any)?.visualization_path?.replace(/^.*[\\\/]/, '/files/outputs/' + caseId + '/');
return (
<div className="pipeline-page">
{/* Header */}
<header className="pipeline-header">
<div className="header-left">
<button
className="back-btn"
onClick={() => caseData?.patient_id ? nav(`/patients/${caseData.patient_id}`) : nav('/patients')}
>
Back
</button>
<div className="case-header-info">
{caseData?.patient && (
<h1 className="patient-name-header">
{caseData.patient.fullName}
{caseData.patient.mrn && (
<span className="patient-mrn-badge">MRN: {caseData.patient.mrn}</span>
)}
</h1>
)}
<div className="case-meta-row">
<span className="case-id-label">{caseId}</span>
{caseData?.visit_date && (
<span className="visit-date-label">
Visit: {new Date(caseData.visit_date).toLocaleDateString()}
</span>
)}
<span className={`status-badge status-${caseData?.status || 'created'}`}>
{caseData?.status?.replace(/_/g, ' ') || 'Created'}
</span>
</div>
</div>
</div>
</header>
{/* Pipeline Steps Indicator */}
<PipelineSteps
currentStage={currentStage}
landmarksApproved={
caseData?.status === 'landmarks_approved' ||
caseData?.status === 'analysis_complete' ||
caseData?.status === 'body_scan_uploaded' ||
caseData?.status === 'brace_generated'
}
analysisComplete={
caseData?.status === 'analysis_complete' ||
caseData?.status === 'body_scan_uploaded' ||
caseData?.status === 'brace_generated'
}
bodyScanComplete={
caseData?.status === 'body_scan_uploaded' ||
caseData?.status === 'brace_generated'
}
braceGenerated={caseData?.status === 'brace_generated'}
onStageClick={handleStageClick}
/>
{/* Error Banner */}
{error && (
<div className="error-banner">
<span>{error}</span>
<button onClick={() => setError(null)}>×</button>
</div>
)}
{/* Upload Section (if no X-ray) */}
{!xrayUrl && currentStage === 'upload' && (
<div className="upload-section">
<div className="upload-box">
<h2>Upload X-ray Image</h2>
<p>Upload a frontal (AP) X-ray image to begin analysis.</p>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
}}
/>
</div>
</div>
)}
{/* Stage Components */}
<div className="pipeline-stages">
{(currentStage === 'landmarks' || xrayUrl) && (
<LandmarkDetectionStage
caseId={caseId || ''}
landmarksData={landmarksData}
xrayUrl={xrayUrl}
visualizationUrl={visualizationUrl}
isLoading={detectingLandmarks}
onDetect={handleDetectLandmarks}
onApprove={handleApproveLandmarks}
onUpdateLandmarks={handleUpdateLandmarks}
/>
)}
{(currentStage === 'analysis' || currentStage === 'bodyscan' || currentStage === 'brace') && (
<SpineAnalysisStage
landmarksData={landmarksData}
analysisData={analysisData}
isLoading={recalculating}
onRecalculate={handleRecalculate}
onContinue={handleContinueToBodyScan}
/>
)}
{(currentStage === 'bodyscan' || currentStage === 'brace') && (
<BodyScanUploadStage
caseId={caseId || ''}
bodyScanData={bodyScanData}
isLoading={uploadingBodyScan}
onUpload={handleUploadBodyScan}
onSkip={handleSkipBodyScan}
onContinue={handleContinueToBrace}
onDelete={handleDeleteBodyScan}
/>
)}
{currentStage === 'brace' && (
<BraceGenerationStage
caseId={caseId || ''}
braceData={braceData}
isLoading={generatingBrace}
onGenerate={handleGenerateBrace}
onUpdateMarkers={handleUpdateMarkers}
/>
)}
{/* Stage 5: Brace Fitting - shows when brace is generated AND body scan exists */}
{(currentStage === 'brace' || currentStage === 'fitting') && braceData && bodyScanData?.has_body_scan && (
<BraceFittingStage
caseId={caseId || ''}
bodyScanUrl={bodyScanData?.body_scan?.url || null}
regularBraceUrl={
(braceData as any)?.braces?.regular?.outputs?.stl ||
(braceData as any)?.braces?.regular?.outputs?.glb ||
braceData?.outputs?.stl ||
null
}
vaseBraceUrl={
(braceData as any)?.braces?.vase?.outputs?.stl ||
(braceData as any)?.braces?.vase?.outputs?.glb ||
null
}
braceData={braceData}
/>
)}
</div>
</div>
);
}