573 lines
18 KiB
TypeScript
573 lines
18 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|