Spaces:
Running
Running
| import { useState } from 'react'; | |
| import { Check, X, Send, MessageSquare, HelpCircle } from 'lucide-react'; | |
| import { FETAL_VIEW_LABELS, submitFeedback, FeedbackCreate, ClassificationResult } from '../lib/api'; | |
| interface FeedbackSectionProps { | |
| sessionId: string; | |
| filename: string; | |
| fileType: 'dicom' | 'image'; | |
| predictions: ClassificationResult[]; | |
| topPrediction: ClassificationResult | null; | |
| patientId?: string; | |
| imageHash?: string; | |
| preprocessedImageBase64?: string; | |
| onFeedbackSubmitted?: () => void; | |
| onViewCorrected?: (correctedLabel: string) => void; | |
| disabled?: boolean; | |
| } | |
| export function FeedbackSection({ | |
| sessionId, | |
| filename, | |
| fileType, | |
| predictions, | |
| topPrediction, | |
| patientId, | |
| imageHash, | |
| preprocessedImageBase64, | |
| onFeedbackSubmitted, | |
| onViewCorrected, | |
| disabled = false, | |
| }: FeedbackSectionProps) { | |
| const [feedbackState, setFeedbackState] = useState<'none' | 'correct' | 'incorrect' | 'not_sure'>('none'); | |
| const [correctLabel, setCorrectLabel] = useState<string>(''); | |
| const [notes, setNotes] = useState<string>(''); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [submitted, setSubmitted] = useState(false); | |
| const handleFeedback = async (isCorrect: boolean | null) => { | |
| if (disabled || !topPrediction) return; | |
| if (isCorrect === true) { | |
| // Submit immediately for correct predictions | |
| setFeedbackState('correct'); | |
| await submitFeedbackData(true); | |
| } else if (isCorrect === null) { | |
| // Show notes form for uncertain predictions | |
| setFeedbackState('not_sure'); | |
| } else { | |
| // Show correction form for incorrect predictions | |
| setFeedbackState('incorrect'); | |
| } | |
| }; | |
| const submitFeedbackData = async (isCorrect: boolean | null, overrideLabel?: string, overrideNotes?: string) => { | |
| if (!topPrediction || isSubmitting) return; | |
| setIsSubmitting(true); | |
| try { | |
| const feedbackData: FeedbackCreate = { | |
| session_id: sessionId, | |
| filename, | |
| file_type: fileType, | |
| predicted_label: topPrediction.label, | |
| predicted_confidence: topPrediction.confidence, | |
| all_predictions: predictions.map(p => ({ | |
| label: p.label, | |
| probability: p.confidence | |
| })), | |
| is_correct: isCorrect, | |
| correct_label: overrideLabel || correctLabel || undefined, | |
| reviewer_notes: overrideNotes || notes || undefined, | |
| patient_id: patientId, | |
| image_hash: imageHash, | |
| preprocessed_image_base64: preprocessedImageBase64, | |
| }; | |
| await submitFeedback(feedbackData); | |
| setSubmitted(true); | |
| onFeedbackSubmitted?.(); | |
| } catch (error) { | |
| console.error('Failed to submit feedback:', error); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const handleSubmitCorrection = async () => { | |
| if (!correctLabel) return; | |
| await submitFeedbackData(false, correctLabel, notes); | |
| onViewCorrected?.(correctLabel); | |
| }; | |
| const handleSubmitNotSure = async () => { | |
| await submitFeedbackData(null, undefined, notes); | |
| }; | |
| if (submitted) { | |
| return ( | |
| <div className="bg-primary/10 border border-primary/30 rounded-lg p-3"> | |
| <div className="flex items-center gap-2 text-primary"> | |
| <Check className="w-4 h-4" /> | |
| <span className="text-sm font-medium">Feedback recorded</span> | |
| {feedbackState === 'correct' ? ( | |
| <span className="text-xs text-text-muted ml-auto">Confirmed correct</span> | |
| ) : feedbackState === 'not_sure' ? ( | |
| <span className="text-xs text-text-muted ml-auto">Marked as uncertain</span> | |
| ) : ( | |
| <span className="text-xs text-text-muted ml-auto">Corrected to: {correctLabel}</span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (disabled || !topPrediction) { | |
| return ( | |
| <div className="bg-surface-secondary rounded-lg p-3 opacity-50"> | |
| <div className="flex items-center gap-2 text-text-muted"> | |
| <MessageSquare className="w-4 h-4" /> | |
| <span className="text-sm">Run classification to provide feedback</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="bg-surface-secondary rounded-lg p-3 space-y-3"> | |
| {/* Feedback header */} | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2 text-text-muted"> | |
| <MessageSquare className="w-4 h-4" /> | |
| <span className="text-sm font-medium">Is this prediction correct?</span> | |
| </div> | |
| {feedbackState === 'none' && ( | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={() => handleFeedback(true)} | |
| className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/20 hover:bg-green-500/30 text-green-600 rounded-md text-sm font-medium transition-colors" | |
| > | |
| <Check className="w-3.5 h-3.5" /> | |
| Correct | |
| </button> | |
| <button | |
| onClick={() => handleFeedback(null)} | |
| className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/20 hover:bg-amber-500/30 text-amber-600 rounded-md text-sm font-medium transition-colors" | |
| > | |
| <HelpCircle className="w-3.5 h-3.5" /> | |
| Not Sure | |
| </button> | |
| <button | |
| onClick={() => handleFeedback(false)} | |
| className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-600 rounded-md text-sm font-medium transition-colors" | |
| > | |
| <X className="w-3.5 h-3.5" /> | |
| Incorrect | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Correction form */} | |
| {feedbackState === 'incorrect' && ( | |
| <div className="space-y-3 pt-2 border-t border-border"> | |
| {/* Native select - handles viewport boundaries automatically */} | |
| <div> | |
| <label className="block text-xs font-medium text-gray-600 mb-1.5">Correct Label</label> | |
| <select | |
| value={correctLabel} | |
| onChange={(e) => setCorrectLabel(e.target.value)} | |
| className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all cursor-pointer" | |
| > | |
| <option value="" disabled>Select correct view...</option> | |
| {FETAL_VIEW_LABELS.map((label) => ( | |
| <option | |
| key={label} | |
| value={label} | |
| disabled={label === topPrediction?.label} | |
| > | |
| {label}{label === topPrediction?.label ? ' (predicted)' : ''} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {/* Notes input */} | |
| <div> | |
| <label className="block text-xs font-medium text-gray-600 mb-1.5">Notes (optional)</label> | |
| <textarea | |
| value={notes} | |
| onChange={(e) => setNotes(e.target.value)} | |
| placeholder="Any additional notes..." | |
| className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-sm text-gray-800 resize-none h-14 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all" | |
| /> | |
| </div> | |
| {/* Action buttons - inline */} | |
| <div className="flex gap-2 items-center"> | |
| <button | |
| onClick={() => { | |
| setFeedbackState('none'); | |
| setCorrectLabel(''); | |
| setNotes(''); | |
| }} | |
| className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors font-medium" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={handleSubmitCorrection} | |
| disabled={!correctLabel || isSubmitting} | |
| className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${!correctLabel || isSubmitting | |
| ? 'bg-gray-200 text-gray-400 cursor-not-allowed' | |
| : 'bg-green-600 hover:bg-green-700 text-white' | |
| }`} | |
| > | |
| {isSubmitting ? ( | |
| <span>Submitting...</span> | |
| ) : ( | |
| <> | |
| <Send className="w-3.5 h-3.5" /> | |
| <span>Submit Correction</span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Not Sure form */} | |
| {feedbackState === 'not_sure' && ( | |
| <div className="space-y-3 pt-2 border-t border-border"> | |
| <p className="text-xs text-amber-600"> | |
| You can add optional notes to explain your uncertainty. | |
| </p> | |
| {/* Notes input */} | |
| <div> | |
| <label className="block text-xs font-medium text-gray-600 mb-1.5">Notes (optional)</label> | |
| <textarea | |
| value={notes} | |
| onChange={(e) => setNotes(e.target.value)} | |
| placeholder="Why are you unsure about this prediction?" | |
| className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-sm text-gray-800 resize-none h-14 focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/50 transition-all" | |
| /> | |
| </div> | |
| {/* Action buttons - inline */} | |
| <div className="flex gap-2 items-center"> | |
| <button | |
| onClick={() => { | |
| setFeedbackState('none'); | |
| setNotes(''); | |
| }} | |
| className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors font-medium" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={handleSubmitNotSure} | |
| disabled={isSubmitting} | |
| className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors" | |
| > | |
| {isSubmitting ? ( | |
| <span>Submitting...</span> | |
| ) : ( | |
| <> | |
| <Send className="w-3.5 h-3.5" /> | |
| <span>Submit</span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |