Commit Β·
1c68fe6
1
Parent(s): 03513b4
Update application with new features and components
Browse files- backend/app.py +65 -2
- backend/inference.py +92 -0
- src/App.tsx +6 -5
- src/components/AceticAnnotator.tsx +41 -149
- src/components/AceticFindingsForm.tsx +121 -0
- src/components/ChatBot.tsx +149 -58
- src/components/ImageAnnotator.tsx +113 -62
- src/components/ImagingObservations.tsx +38 -2
- src/components/PatientHistoryForm.tsx +202 -64
- src/config/geminiConfig.ts +239 -0
- src/pages/AcetowhiteExamPage.tsx +81 -62
- src/pages/BiopsyMarking.tsx +62 -19
- src/pages/Compare.tsx +23 -1
- src/pages/GreenFilterPage.tsx +27 -6
- src/pages/GuidedCapturePage.tsx +184 -73
- src/pages/LugolExamPage.tsx +33 -4
- src/pages/PatientHistoryPage.tsx +1 -1
- src/pages/PatientRegistry.tsx +29 -6
- src/pages/ReportPage.tsx +832 -317
- src/store/patientIdStore.ts +77 -0
- src/store/sessionStore.ts +130 -0
- src/vite-env.d.ts +9 -0
backend/app.py
CHANGED
|
@@ -10,7 +10,7 @@ from io import BytesIO
|
|
| 10 |
from PIL import Image
|
| 11 |
import uvicorn
|
| 12 |
import traceback
|
| 13 |
-
from
|
| 14 |
|
| 15 |
app = FastAPI(title="Pathora Colposcopy API", version="1.0.0")
|
| 16 |
|
|
@@ -235,6 +235,69 @@ async def infer_video(file: UploadFile = File(...)):
|
|
| 235 |
raise HTTPException(status_code=500, detail=str(e))
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
# Serve the built frontend if present (Space/Docker runtime)
|
| 239 |
frontend_dist = os.path.join(os.path.dirname(__file__), "..", "dist")
|
| 240 |
if os.path.isdir(frontend_dist):
|
|
@@ -242,4 +305,4 @@ if os.path.isdir(frontend_dist):
|
|
| 242 |
|
| 243 |
|
| 244 |
if __name__ == "__main__":
|
| 245 |
-
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|
|
| 10 |
from PIL import Image
|
| 11 |
import uvicorn
|
| 12 |
import traceback
|
| 13 |
+
from inference import infer_aw_contour, analyze_frame, analyze_video_frame, infer_cervix_bbox
|
| 14 |
|
| 15 |
app = FastAPI(title="Pathora Colposcopy API", version="1.0.0")
|
| 16 |
|
|
|
|
| 235 |
raise HTTPException(status_code=500, detail=str(e))
|
| 236 |
|
| 237 |
|
| 238 |
+
@app.post("/api/infer-cervix-bbox")
|
| 239 |
+
async def infer_cervix_bbox_endpoint(file: UploadFile = File(...), conf_threshold: float = 0.4):
|
| 240 |
+
"""
|
| 241 |
+
Cervix bounding box detection endpoint for annotation.
|
| 242 |
+
Detects cervix location and returns bounding boxes.
|
| 243 |
+
|
| 244 |
+
Args:
|
| 245 |
+
file: Image file (jpg, png, etc.)
|
| 246 |
+
conf_threshold: Confidence threshold for YOLO model (0.0-1.0)
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
JSON with base64 encoded annotated image and bounding box coordinates
|
| 250 |
+
"""
|
| 251 |
+
try:
|
| 252 |
+
# Read image file
|
| 253 |
+
image_data = await file.read()
|
| 254 |
+
|
| 255 |
+
# Try to open image
|
| 256 |
+
try:
|
| 257 |
+
image = Image.open(BytesIO(image_data))
|
| 258 |
+
except Exception as e:
|
| 259 |
+
raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}")
|
| 260 |
+
|
| 261 |
+
# Convert to numpy array and BGR format (OpenCV uses BGR)
|
| 262 |
+
if image.mode == 'RGBA':
|
| 263 |
+
image = image.convert('RGB')
|
| 264 |
+
elif image.mode != 'RGB':
|
| 265 |
+
image = image.convert('RGB')
|
| 266 |
+
|
| 267 |
+
frame = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
|
| 268 |
+
|
| 269 |
+
# Run inference
|
| 270 |
+
result = infer_cervix_bbox(frame, conf_threshold=conf_threshold)
|
| 271 |
+
|
| 272 |
+
# Convert result overlay back to RGB for JSON serialization
|
| 273 |
+
if result["overlay"] is not None:
|
| 274 |
+
result_rgb = cv2.cvtColor(result["overlay"], cv2.COLOR_BGR2RGB)
|
| 275 |
+
result_image = Image.fromarray(result_rgb)
|
| 276 |
+
|
| 277 |
+
# Encode to base64
|
| 278 |
+
buffer = BytesIO()
|
| 279 |
+
result_image.save(buffer, format="PNG")
|
| 280 |
+
buffer.seek(0)
|
| 281 |
+
import base64
|
| 282 |
+
image_base64 = base64.b64encode(buffer.getvalue()).decode()
|
| 283 |
+
else:
|
| 284 |
+
image_base64 = None
|
| 285 |
+
|
| 286 |
+
return JSONResponse({
|
| 287 |
+
"status": "success",
|
| 288 |
+
"message": "Cervix bounding box detection completed",
|
| 289 |
+
"result_image": image_base64,
|
| 290 |
+
"bounding_boxes": result["bounding_boxes"],
|
| 291 |
+
"detections": result["detections"],
|
| 292 |
+
"frame_width": result["frame_width"],
|
| 293 |
+
"frame_height": result["frame_height"],
|
| 294 |
+
"confidence_threshold": conf_threshold
|
| 295 |
+
})
|
| 296 |
+
|
| 297 |
+
except Exception as e:
|
| 298 |
+
raise HTTPException(status_code=500, detail=f"Error during cervix bbox inference: {str(e)}")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
# Serve the built frontend if present (Space/Docker runtime)
|
| 302 |
frontend_dist = os.path.join(os.path.dirname(__file__), "..", "dist")
|
| 303 |
if os.path.isdir(frontend_dist):
|
|
|
|
| 305 |
|
| 306 |
|
| 307 |
if __name__ == "__main__":
|
| 308 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
backend/inference.py
CHANGED
|
@@ -275,3 +275,95 @@ def analyze_video_frame(frame, conf_threshold=0.3):
|
|
| 275 |
result["status"] = "Searching Cervix"
|
| 276 |
|
| 277 |
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
result["status"] = "Searching Cervix"
|
| 276 |
|
| 277 |
return result
|
| 278 |
+
|
| 279 |
+
#-----------------------------------------------
|
| 280 |
+
# Cervix Bounding Box Detection for Annotations
|
| 281 |
+
# -----------------------------------------------
|
| 282 |
+
|
| 283 |
+
def infer_cervix_bbox(frame, conf_threshold=0.4):
|
| 284 |
+
"""
|
| 285 |
+
Detect cervix bounding boxes using YOLO model.
|
| 286 |
+
Returns bounding boxes and annotated frame.
|
| 287 |
+
|
| 288 |
+
Args:
|
| 289 |
+
frame: Input image frame (BGR)
|
| 290 |
+
conf_threshold: Confidence threshold for detection
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
Dictionary with annotated overlay and bounding boxes
|
| 294 |
+
"""
|
| 295 |
+
|
| 296 |
+
if frame is None or cervix_model is None:
|
| 297 |
+
return {
|
| 298 |
+
"overlay": None,
|
| 299 |
+
"bounding_boxes": [],
|
| 300 |
+
"detections": 0,
|
| 301 |
+
"frame_width": 0,
|
| 302 |
+
"frame_height": 0
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
results = cervix_model.predict(
|
| 307 |
+
frame,
|
| 308 |
+
conf=conf_threshold,
|
| 309 |
+
imgsz=640,
|
| 310 |
+
verbose=False,
|
| 311 |
+
device='cpu'
|
| 312 |
+
)[0]
|
| 313 |
+
|
| 314 |
+
overlay = frame.copy()
|
| 315 |
+
bounding_boxes = []
|
| 316 |
+
detection_count = 0
|
| 317 |
+
|
| 318 |
+
if results.boxes is not None and len(results.boxes) > 0:
|
| 319 |
+
boxes = results.boxes.xyxy.cpu().numpy()
|
| 320 |
+
confidences = results.boxes.conf.cpu().numpy()
|
| 321 |
+
|
| 322 |
+
for idx, box in enumerate(boxes):
|
| 323 |
+
x1, y1, x2, y2 = box.astype(int)
|
| 324 |
+
confidence = float(confidences[idx])
|
| 325 |
+
|
| 326 |
+
# Draw bounding box
|
| 327 |
+
cv2.rectangle(
|
| 328 |
+
overlay,
|
| 329 |
+
(x1, y1),
|
| 330 |
+
(x2, y2),
|
| 331 |
+
(255, 0, 0), # Blue color
|
| 332 |
+
3
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# Store bounding box info
|
| 336 |
+
bounding_boxes.append({
|
| 337 |
+
"x1": int(x1),
|
| 338 |
+
"y1": int(y1),
|
| 339 |
+
"x2": int(x2),
|
| 340 |
+
"y2": int(y2),
|
| 341 |
+
"width": int(x2 - x1),
|
| 342 |
+
"height": int(y2 - y1),
|
| 343 |
+
"confidence": round(confidence, 3),
|
| 344 |
+
"center_x": int((x1 + x2) / 2),
|
| 345 |
+
"center_y": int((y1 + y2) / 2)
|
| 346 |
+
})
|
| 347 |
+
|
| 348 |
+
detection_count += 1
|
| 349 |
+
|
| 350 |
+
return {
|
| 351 |
+
"overlay": overlay if detection_count > 0 else None,
|
| 352 |
+
"bounding_boxes": bounding_boxes,
|
| 353 |
+
"detections": detection_count,
|
| 354 |
+
"frame_width": frame.shape[1],
|
| 355 |
+
"frame_height": frame.shape[0]
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
except Exception as e:
|
| 359 |
+
print(f"β Cervix bounding box detection error: {e}")
|
| 360 |
+
import traceback
|
| 361 |
+
traceback.print_exc()
|
| 362 |
+
return {
|
| 363 |
+
"overlay": None,
|
| 364 |
+
"bounding_boxes": [],
|
| 365 |
+
"detections": 0,
|
| 366 |
+
"frame_width": frame.shape[1],
|
| 367 |
+
"frame_height": frame.shape[0]
|
| 368 |
+
}
|
| 369 |
+
|
src/App.tsx
CHANGED
|
@@ -43,7 +43,10 @@ export function App() {
|
|
| 43 |
const goToAcetowhite = () => setCurrentPage('acetowhite');
|
| 44 |
const goToGreenFilter = () => setCurrentPage('greenfilter');
|
| 45 |
const goToLugol = () => setCurrentPage('lugol');
|
| 46 |
-
const goToGuidedCapture = () =>
|
|
|
|
|
|
|
|
|
|
| 47 |
const goToBiopsyMarking = () => setCurrentPage('biopsymarking');
|
| 48 |
const goToCapture = () => {
|
| 49 |
setCurrentPage('capture');
|
|
@@ -64,7 +67,7 @@ export function App() {
|
|
| 64 |
case 'home':
|
| 65 |
return <HomePage onNavigateToPatients={goToPatientRegistry} onNext={goToPatientRegistry} />;
|
| 66 |
case 'patientinfo':
|
| 67 |
-
return <PatientRegistry onNewPatient={() => goToPatientHistory(
|
| 68 |
case 'patienthistory':
|
| 69 |
return <PatientHistoryPage goToImaging={goToGuidedCapture} goBackToRegistry={goToPatientRegistry} patientID={currentPatientId} goToGuidedCapture={isNewPatientFlow ? goToGuidedCapture : undefined} />;
|
| 70 |
|
|
@@ -74,8 +77,6 @@ export function App() {
|
|
| 74 |
return <GreenFilterPage goBack={goToAcetowhite} onNext={goToLugol} />;
|
| 75 |
case 'lugol':
|
| 76 |
return <LugolExamPage goBack={goToGreenFilter} onNext={goToGuidedCapture} />;
|
| 77 |
-
case 'guidedcapture':
|
| 78 |
-
return <GuidedCapturePage onNext={goToBiopsyMarking} onGoToPatientRecords={goToPatientRegistry} onCapturedImagesChange={setCapturedImages} onModeChange={setGuidanceMode} />;
|
| 79 |
case 'biopsymarking':
|
| 80 |
return <BiopsyMarking onBack={goToGuidedCapture} onNext={goToReport} capturedImages={capturedImages} />;
|
| 81 |
case 'capture':
|
|
@@ -93,7 +94,7 @@ export function App() {
|
|
| 93 |
|
| 94 |
// Sidebar is shown on all pages except home
|
| 95 |
const showSidebar = currentPage !== 'home';
|
| 96 |
-
const sidebarKey = currentPage === 'patienthistory' ? 'patientinfo' : ['capture', 'annotation', 'compare', 'guidedcapture'].includes(currentPage) ? guidanceMode : currentPage;
|
| 97 |
|
| 98 |
return (
|
| 99 |
<div className="flex flex-col min-h-screen">
|
|
|
|
| 43 |
const goToAcetowhite = () => setCurrentPage('acetowhite');
|
| 44 |
const goToGreenFilter = () => setCurrentPage('greenfilter');
|
| 45 |
const goToLugol = () => setCurrentPage('lugol');
|
| 46 |
+
const goToGuidedCapture = () => {
|
| 47 |
+
setCurrentPage('capture');
|
| 48 |
+
setGuidanceMode('capture');
|
| 49 |
+
};
|
| 50 |
const goToBiopsyMarking = () => setCurrentPage('biopsymarking');
|
| 51 |
const goToCapture = () => {
|
| 52 |
setCurrentPage('capture');
|
|
|
|
| 67 |
case 'home':
|
| 68 |
return <HomePage onNavigateToPatients={goToPatientRegistry} onNext={goToPatientRegistry} />;
|
| 69 |
case 'patientinfo':
|
| 70 |
+
return <PatientRegistry onNewPatient={(newPatientId) => goToPatientHistory(newPatientId, true)} onSelectExisting={(id: string) => goToPatientHistory(id)} onBackToHome={goToHome} onNext={goToGuidedCapture} />;
|
| 71 |
case 'patienthistory':
|
| 72 |
return <PatientHistoryPage goToImaging={goToGuidedCapture} goBackToRegistry={goToPatientRegistry} patientID={currentPatientId} goToGuidedCapture={isNewPatientFlow ? goToGuidedCapture : undefined} />;
|
| 73 |
|
|
|
|
| 77 |
return <GreenFilterPage goBack={goToAcetowhite} onNext={goToLugol} />;
|
| 78 |
case 'lugol':
|
| 79 |
return <LugolExamPage goBack={goToGreenFilter} onNext={goToGuidedCapture} />;
|
|
|
|
|
|
|
| 80 |
case 'biopsymarking':
|
| 81 |
return <BiopsyMarking onBack={goToGuidedCapture} onNext={goToReport} capturedImages={capturedImages} />;
|
| 82 |
case 'capture':
|
|
|
|
| 94 |
|
| 95 |
// Sidebar is shown on all pages except home
|
| 96 |
const showSidebar = currentPage !== 'home';
|
| 97 |
+
const sidebarKey = currentPage === 'patienthistory' ? 'patientinfo' : (currentPage === 'guidedcapture' && guidanceMode === 'report') ? 'report' : ['capture', 'annotation', 'compare', 'guidedcapture'].includes(currentPage) ? guidanceMode : currentPage;
|
| 98 |
|
| 99 |
return (
|
| 100 |
<div className="flex flex-col min-h-screen">
|
src/components/AceticAnnotator.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react';
|
| 2 |
-
import { Wrench, Trash2, Circle as CircleIcon, Hexagon, Square as SquareIcon, ChevronDown, ChevronUp } from 'lucide-react';
|
| 3 |
|
| 4 |
type ShapeType = 'rect' | 'circle' | 'polygon';
|
| 5 |
|
|
@@ -64,10 +64,6 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 64 |
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
| 65 |
const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true);
|
| 66 |
const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false);
|
| 67 |
-
const [activeTab, setActiveTab] = useState<'annotate' | 'findings'>('annotate');
|
| 68 |
-
const [selectedCategories, setSelectedCategories] = useState<Record<string, boolean>>({});
|
| 69 |
-
const [selectedFindings, setSelectedFindings] = useState<Record<string, boolean>>({});
|
| 70 |
-
const [additionalNotes, setAdditionalNotes] = useState('');
|
| 71 |
|
| 72 |
// Edit state for annotation list
|
| 73 |
const [editingId, setEditingId] = useState<string | null>(null);
|
|
@@ -81,6 +77,7 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 81 |
// AI state
|
| 82 |
const [isAILoading, setIsAILoading] = useState(false);
|
| 83 |
const [aiError, setAIError] = useState<string | null>(null);
|
|
|
|
| 84 |
|
| 85 |
// Get annotations for current image
|
| 86 |
const annotations = annotationsByImage[selectedImageIndex] ?? emptyAnnotationsRef.current;
|
|
@@ -106,53 +103,14 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 106 |
'Border'
|
| 107 |
];
|
| 108 |
|
| 109 |
-
|
| 110 |
-
const findingsCategories = [
|
| 111 |
-
{
|
| 112 |
-
name: 'Thin Acetowhite Epithelium',
|
| 113 |
-
features: ['Dull white', 'Appears slowly', 'Fades quickly']
|
| 114 |
-
},
|
| 115 |
-
{
|
| 116 |
-
name: 'Borders',
|
| 117 |
-
features: ['Irregular', 'Geographic', 'Feathered edges', 'Sharp', 'Raised', 'Rolled edges', 'Inner border sign']
|
| 118 |
-
},
|
| 119 |
-
{
|
| 120 |
-
name: 'Vascular Pattern',
|
| 121 |
-
features: ['Fine punctation', 'Fine mosaic', 'Coarse punctation', 'Coarse mosaic']
|
| 122 |
-
},
|
| 123 |
-
{
|
| 124 |
-
name: 'Dense Acetowhite Epithelium',
|
| 125 |
-
features: ['Chalky white', 'Oyster white', 'Greyish white', 'Rapid onset', 'Persists']
|
| 126 |
-
},
|
| 127 |
-
{
|
| 128 |
-
name: 'Gland Openings',
|
| 129 |
-
features: ['Cuffed', 'Enlarged crypt openings']
|
| 130 |
-
},
|
| 131 |
-
{
|
| 132 |
-
name: 'Non-Specific Abnormal Findings',
|
| 133 |
-
features: ['Leukoplakia (keratosis)', 'Hyperkeratosis', 'Erosion']
|
| 134 |
-
}
|
| 135 |
-
];
|
| 136 |
|
| 137 |
// Filter labels based on input
|
| 138 |
const filteredLabels = labelOptions.filter(label =>
|
| 139 |
label.toLowerCase().includes(labelInput.toLowerCase())
|
| 140 |
);
|
| 141 |
|
| 142 |
-
// Finding checkboxes
|
| 143 |
-
const toggleFinding = (label: string) => {
|
| 144 |
-
setSelectedFindings(prev => ({
|
| 145 |
-
...prev,
|
| 146 |
-
[label]: !prev[label]
|
| 147 |
-
}));
|
| 148 |
-
};
|
| 149 |
|
| 150 |
-
const toggleCategory = (name: string) => {
|
| 151 |
-
setSelectedCategories(prev => ({
|
| 152 |
-
...prev,
|
| 153 |
-
[name]: !prev[name]
|
| 154 |
-
}));
|
| 155 |
-
};
|
| 156 |
|
| 157 |
// Helper function to get bounding box from polygon points
|
| 158 |
const getBoundsFromPoints = (points: any[]) => {
|
|
@@ -210,7 +168,7 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 210 |
|
| 211 |
console.log('π Sending to backend with image dimensions:', imageDimensions);
|
| 212 |
|
| 213 |
-
const backendResponse = await fetch('/api/infer-aw-contour', {
|
| 214 |
method: 'POST',
|
| 215 |
body: formData,
|
| 216 |
});
|
|
@@ -775,6 +733,16 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 775 |
};
|
| 776 |
const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
|
| 777 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
const getShapeTypeName = (type: ShapeType): string => {
|
| 779 |
const typeMap: Record<ShapeType, string> = {
|
| 780 |
'rect': 'Rectangle',
|
|
@@ -826,40 +794,40 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 826 |
<div className="flex justify-between items-center">
|
| 827 |
<div className="flex justify-center flex-1">
|
| 828 |
<div className="inline-flex bg-gray-100 rounded-lg p-1 border border-gray-300">
|
| 829 |
-
<button
|
| 830 |
-
onClick={() => setActiveTab('annotate')}
|
| 831 |
-
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all ${
|
| 832 |
-
activeTab === 'annotate'
|
| 833 |
-
? 'bg-[#05998c] text-white shadow-sm'
|
| 834 |
-
: 'bg-transparent text-gray-700 hover:text-gray-900'
|
| 835 |
-
}`}
|
| 836 |
-
>
|
| 837 |
-
Annotate
|
| 838 |
-
</button>
|
| 839 |
-
<button
|
| 840 |
-
onClick={() => setActiveTab('findings')}
|
| 841 |
-
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all ${
|
| 842 |
-
activeTab === 'findings'
|
| 843 |
-
? 'bg-[#05998c] text-white shadow-sm'
|
| 844 |
-
: 'bg-transparent text-gray-700 hover:text-gray-900'
|
| 845 |
-
}`}
|
| 846 |
-
>
|
| 847 |
-
Findings
|
| 848 |
-
</button>
|
| 849 |
</div>
|
| 850 |
</div>
|
| 851 |
<button
|
| 852 |
-
onClick={
|
| 853 |
disabled={isAILoading}
|
| 854 |
-
className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
>
|
| 856 |
-
{isAILoading ?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
</button>
|
| 858 |
</div>
|
| 859 |
|
| 860 |
-
{/* Annotate
|
| 861 |
-
|
| 862 |
-
<>
|
| 863 |
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-3">
|
| 864 |
<div className="flex items-center gap-2">
|
| 865 |
<div className="flex items-center gap-2 text-xs md:text-sm text-gray-600 bg-blue-50 px-3 py-1.5 rounded-lg border border-blue-100 whitespace-nowrap">
|
|
@@ -1141,83 +1109,7 @@ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotat
|
|
| 1141 |
</div>
|
| 1142 |
)}
|
| 1143 |
</div>
|
| 1144 |
-
|
| 1145 |
-
)}
|
| 1146 |
-
|
| 1147 |
-
{/* Findings Tab */}
|
| 1148 |
-
{activeTab === 'findings' && (
|
| 1149 |
-
<div className="flex flex-col md:flex-row gap-4 items-start">
|
| 1150 |
-
{/* Left Side - Image Viewer */}
|
| 1151 |
-
<div className="flex-1">
|
| 1152 |
-
<div ref={containerRef} className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700">
|
| 1153 |
-
<canvas ref={canvasRef} className="w-full cursor-default" style={{ pointerEvents: 'none' }} />
|
| 1154 |
-
</div>
|
| 1155 |
-
</div>
|
| 1156 |
-
|
| 1157 |
-
{/* Right Side - Clinical Findings Form */}
|
| 1158 |
-
<div className="w-full md:w-96 bg-gradient-to-b from-white to-blue-50 border-2 border-[#05998c] rounded-xl shadow-lg p-5 md:p-6 max-h-[600px] overflow-y-auto">
|
| 1159 |
-
{/* Header */}
|
| 1160 |
-
<div className="mb-5 pb-4 border-b-2 border-[#05998c]">
|
| 1161 |
-
<div className="flex items-center gap-2 mb-1">
|
| 1162 |
-
<div className="w-1 h-5 bg-[#05998c] rounded-full"></div>
|
| 1163 |
-
<p className="text-sm uppercase tracking-wider font-bold text-[#05998c]">Clinical Findings</p>
|
| 1164 |
-
</div>
|
| 1165 |
-
<p className="text-xs text-gray-600 ml-3">Select findings for each category</p>
|
| 1166 |
-
</div>
|
| 1167 |
-
|
| 1168 |
-
<div className="space-y-5 text-sm text-gray-800">
|
| 1169 |
-
{/* Render all categories with their features */}
|
| 1170 |
-
{findingsCategories.map(category => (
|
| 1171 |
-
<div key={category.name} className="space-y-3 pb-4 border-b border-gray-300">
|
| 1172 |
-
{/* Category as Checkbox (multi-select) */}
|
| 1173 |
-
<label className="flex items-center gap-3 cursor-pointer hover:bg-blue-50 p-2 rounded transition-colors -ml-2">
|
| 1174 |
-
<input
|
| 1175 |
-
type="checkbox"
|
| 1176 |
-
checked={!!selectedCategories[category.name]}
|
| 1177 |
-
onChange={() => toggleCategory(category.name)}
|
| 1178 |
-
className="w-4 h-4 cursor-pointer accent-[#05998c] flex-shrink-0"
|
| 1179 |
-
/>
|
| 1180 |
-
<div className="flex items-center gap-2 flex-1">
|
| 1181 |
-
<span className="inline-block w-2 h-2 bg-[#05998c] rounded-full flex-shrink-0"></span>
|
| 1182 |
-
<p className="font-bold text-gray-900">{category.name}</p>
|
| 1183 |
-
</div>
|
| 1184 |
-
</label>
|
| 1185 |
-
|
| 1186 |
-
{/* Features as Checkboxes */}
|
| 1187 |
-
<div className="space-y-2 pl-8">
|
| 1188 |
-
{category.features.map(feature => (
|
| 1189 |
-
<label key={feature} className="flex items-center gap-3 text-gray-700 hover:text-[#05998c] cursor-pointer transition-colors">
|
| 1190 |
-
<input
|
| 1191 |
-
type="checkbox"
|
| 1192 |
-
checked={!!selectedFindings[feature]}
|
| 1193 |
-
onChange={() => toggleFinding(feature)}
|
| 1194 |
-
className="w-4 h-4 rounded border-[#05998c] cursor-pointer accent-[#05998c] flex-shrink-0"
|
| 1195 |
-
/>
|
| 1196 |
-
<span className="text-sm">{feature}</span>
|
| 1197 |
-
</label>
|
| 1198 |
-
))}
|
| 1199 |
-
</div>
|
| 1200 |
-
</div>
|
| 1201 |
-
))}
|
| 1202 |
-
|
| 1203 |
-
{/* Additional Notes Section */}
|
| 1204 |
-
<div className="space-y-2 pt-1">
|
| 1205 |
-
<label className="flex items-center gap-2">
|
| 1206 |
-
<span className="inline-block w-2 h-2 bg-[#05998c] rounded-full flex-shrink-0"></span>
|
| 1207 |
-
<p className="font-bold text-gray-900">Additional Notes</p>
|
| 1208 |
-
</label>
|
| 1209 |
-
<textarea
|
| 1210 |
-
value={additionalNotes}
|
| 1211 |
-
onChange={e => setAdditionalNotes(e.target.value)}
|
| 1212 |
-
placeholder="Add any clinical observations..."
|
| 1213 |
-
className="w-full border-2 border-[#05998c] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#05998c] focus:border-transparent bg-white/80"
|
| 1214 |
-
rows={3}
|
| 1215 |
-
/>
|
| 1216 |
-
</div>
|
| 1217 |
-
</div>
|
| 1218 |
-
</div>
|
| 1219 |
-
</div>
|
| 1220 |
-
)}
|
| 1221 |
</div>
|
| 1222 |
);
|
| 1223 |
});
|
|
|
|
| 1 |
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react';
|
| 2 |
+
import { Wrench, Trash2, Circle as CircleIcon, Hexagon, Square as SquareIcon, ChevronDown, ChevronUp, Sparkles, Loader } from 'lucide-react';
|
| 3 |
|
| 4 |
type ShapeType = 'rect' | 'circle' | 'polygon';
|
| 5 |
|
|
|
|
| 64 |
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
| 65 |
const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true);
|
| 66 |
const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
// Edit state for annotation list
|
| 69 |
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
| 77 |
// AI state
|
| 78 |
const [isAILoading, setIsAILoading] = useState(false);
|
| 79 |
const [aiError, setAIError] = useState<string | null>(null);
|
| 80 |
+
const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false);
|
| 81 |
|
| 82 |
// Get annotations for current image
|
| 83 |
const annotations = annotationsByImage[selectedImageIndex] ?? emptyAnnotationsRef.current;
|
|
|
|
| 103 |
'Border'
|
| 104 |
];
|
| 105 |
|
| 106 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
// Filter labels based on input
|
| 109 |
const filteredLabels = labelOptions.filter(label =>
|
| 110 |
label.toLowerCase().includes(labelInput.toLowerCase())
|
| 111 |
);
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
// Helper function to get bounding box from polygon points
|
| 116 |
const getBoundsFromPoints = (points: any[]) => {
|
|
|
|
| 168 |
|
| 169 |
console.log('π Sending to backend with image dimensions:', imageDimensions);
|
| 170 |
|
| 171 |
+
const backendResponse = await fetch('http://localhost:8000/api/infer-aw-contour', {
|
| 172 |
method: 'POST',
|
| 173 |
body: formData,
|
| 174 |
});
|
|
|
|
| 733 |
};
|
| 734 |
const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
|
| 735 |
|
| 736 |
+
const handleAIAssistToggle = async () => {
|
| 737 |
+
if (isAILoading) return;
|
| 738 |
+
if (isAIAssistEnabled) {
|
| 739 |
+
setIsAIAssistEnabled(false);
|
| 740 |
+
return;
|
| 741 |
+
}
|
| 742 |
+
setIsAIAssistEnabled(true);
|
| 743 |
+
await runAIAssist();
|
| 744 |
+
};
|
| 745 |
+
|
| 746 |
const getShapeTypeName = (type: ShapeType): string => {
|
| 747 |
const typeMap: Record<ShapeType, string> = {
|
| 748 |
'rect': 'Rectangle',
|
|
|
|
| 794 |
<div className="flex justify-between items-center">
|
| 795 |
<div className="flex justify-center flex-1">
|
| 796 |
<div className="inline-flex bg-gray-100 rounded-lg p-1 border border-gray-300">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
</div>
|
| 798 |
</div>
|
| 799 |
<button
|
| 800 |
+
onClick={handleAIAssistToggle}
|
| 801 |
disabled={isAILoading}
|
| 802 |
+
className={`px-4 md:px-6 py-2 md:py-3 text-sm md:text-base font-bold text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl flex items-center justify-center gap-2 ${
|
| 803 |
+
isAIAssistEnabled
|
| 804 |
+
? 'bg-gradient-to-r from-green-500 to-green-600 border border-green-600 hover:from-green-600 hover:to-green-700'
|
| 805 |
+
: 'bg-gradient-to-r from-blue-600 to-blue-700 border border-blue-700 hover:from-blue-700 hover:to-blue-800'
|
| 806 |
+
}`}
|
| 807 |
+
title={isAIAssistEnabled ? 'AI Assist is ON' : 'Run AI model to automatically detect annotations'}
|
| 808 |
>
|
| 809 |
+
{isAILoading ? (
|
| 810 |
+
<>
|
| 811 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 812 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 813 |
+
</div>
|
| 814 |
+
<Loader className="w-5 h-5 animate-spin" />
|
| 815 |
+
<span>Analyzing...</span>
|
| 816 |
+
</>
|
| 817 |
+
) : (
|
| 818 |
+
<>
|
| 819 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 820 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 821 |
+
</div>
|
| 822 |
+
<Sparkles className="h-5 w-5" />
|
| 823 |
+
<span>{isAIAssistEnabled ? 'AI Assist On' : 'AI Assist'}</span>
|
| 824 |
+
</>
|
| 825 |
+
)}
|
| 826 |
</button>
|
| 827 |
</div>
|
| 828 |
|
| 829 |
+
{/* Annotate Section */}
|
| 830 |
+
<>
|
|
|
|
| 831 |
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-3">
|
| 832 |
<div className="flex items-center gap-2">
|
| 833 |
<div className="flex items-center gap-2 text-xs md:text-sm text-gray-600 bg-blue-50 px-3 py-1.5 rounded-lg border border-blue-100 whitespace-nowrap">
|
|
|
|
| 1109 |
</div>
|
| 1110 |
)}
|
| 1111 |
</div>
|
| 1112 |
+
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1113 |
</div>
|
| 1114 |
);
|
| 1115 |
});
|
src/components/AceticFindingsForm.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { sessionStore } from '../store/sessionStore';
|
| 3 |
+
|
| 4 |
+
const findingsCategories = [
|
| 5 |
+
{
|
| 6 |
+
name: 'Thin Acetowhite Epithelium',
|
| 7 |
+
features: ['Dull white', 'Appears slowly', 'Fades quickly']
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
name: 'Borders',
|
| 11 |
+
features: ['Irregular', 'Geographic', 'Feathered edges', 'Sharp', 'Raised', 'Rolled edges', 'Inner border sign']
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
name: 'Vascular Pattern',
|
| 15 |
+
features: ['Fine punctation', 'Fine mosaic', 'Coarse punctation', 'Coarse mosaic']
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
name: 'Dense Acetowhite Epithelium',
|
| 19 |
+
features: ['Chalky white', 'Oyster white', 'Greyish white', 'Rapid onset', 'Persists']
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
name: 'Gland Openings',
|
| 23 |
+
features: ['Cuffed', 'Enlarged crypt openings']
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
name: 'Non-Specific Abnormal Findings',
|
| 27 |
+
features: ['Leukoplakia (keratosis)', 'Hyperkeratosis', 'Erosion']
|
| 28 |
+
}
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
export function AceticFindingsForm() {
|
| 32 |
+
// Restore previously saved findings from sessionStore
|
| 33 |
+
const savedAcetic = sessionStore.get().aceticFindings ?? {};
|
| 34 |
+
|
| 35 |
+
const [selectedCategories, setSelectedCategories] = useState<Record<string, boolean>>(
|
| 36 |
+
savedAcetic.selectedCategories ?? {}
|
| 37 |
+
);
|
| 38 |
+
const [selectedFindings, setSelectedFindings] = useState<Record<string, boolean>>(
|
| 39 |
+
savedAcetic.selectedFindings ?? {}
|
| 40 |
+
);
|
| 41 |
+
const [additionalNotes, setAdditionalNotes] = useState<string>(
|
| 42 |
+
savedAcetic.additionalNotes ?? ''
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
// Persist to sessionStore whenever selections change
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
sessionStore.merge({
|
| 48 |
+
aceticFindings: { selectedCategories, selectedFindings, additionalNotes }
|
| 49 |
+
});
|
| 50 |
+
}, [selectedCategories, selectedFindings, additionalNotes]);
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
const toggleCategory = (name: string) =>
|
| 54 |
+
setSelectedCategories(prev => ({ ...prev, [name]: !prev[name] }));
|
| 55 |
+
|
| 56 |
+
const toggleFinding = (label: string) =>
|
| 57 |
+
setSelectedFindings(prev => ({ ...prev, [label]: !prev[label] }));
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<div className="bg-gradient-to-b from-white to-blue-50 border-2 border-[#05998c] rounded-xl shadow-lg p-5 md:p-6">
|
| 61 |
+
{/* Header */}
|
| 62 |
+
<div className="mb-6 pb-5 border-b-2 border-[#05998c]">
|
| 63 |
+
<div className="flex items-center gap-2 mb-2">
|
| 64 |
+
<div className="w-1 h-6 bg-[#05998c] rounded-full" />
|
| 65 |
+
<p className="text-lg uppercase tracking-wider font-bold text-[#05998c]">Clinical Findings</p>
|
| 66 |
+
</div>
|
| 67 |
+
<p className="text-sm text-gray-600 ml-3">Select findings observed during acetic acid examination</p>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6 text-base text-gray-800">
|
| 71 |
+
{findingsCategories.map((category, index) => (
|
| 72 |
+
<div key={category.name} className={`space-y-3 ${index < findingsCategories.length - 1 && 'lg:border-r lg:border-gray-300 lg:pr-8'}`}>
|
| 73 |
+
{/* Category header */}
|
| 74 |
+
<label className="flex items-center gap-3 cursor-pointer hover:bg-blue-50 p-2 rounded transition-colors -ml-2">
|
| 75 |
+
<input
|
| 76 |
+
type="checkbox"
|
| 77 |
+
checked={!!selectedCategories[category.name]}
|
| 78 |
+
onChange={() => toggleCategory(category.name)}
|
| 79 |
+
className="w-5 h-5 cursor-pointer accent-[#05998c] flex-shrink-0"
|
| 80 |
+
/>
|
| 81 |
+
<div className="flex items-center gap-2 flex-1">
|
| 82 |
+
<span className="inline-block w-2 h-2 bg-[#05998c] rounded-full flex-shrink-0" />
|
| 83 |
+
<p className="font-bold text-gray-900 text-sm md:text-base">{category.name}</p>
|
| 84 |
+
</div>
|
| 85 |
+
</label>
|
| 86 |
+
|
| 87 |
+
{/* Feature checkboxes */}
|
| 88 |
+
<div className="space-y-2 pl-8">
|
| 89 |
+
{category.features.map(feature => (
|
| 90 |
+
<label key={feature} className="flex items-center gap-2 text-gray-700 hover:text-[#05998c] cursor-pointer transition-colors">
|
| 91 |
+
<input
|
| 92 |
+
type="checkbox"
|
| 93 |
+
checked={!!selectedFindings[feature]}
|
| 94 |
+
onChange={() => toggleFinding(feature)}
|
| 95 |
+
className="w-4 h-4 rounded border-[#05998c] cursor-pointer accent-[#05998c] flex-shrink-0"
|
| 96 |
+
/>
|
| 97 |
+
<span className="text-sm">{feature}</span>
|
| 98 |
+
</label>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* Additional Notes */}
|
| 106 |
+
<div className="mt-6 pt-5 border-t border-gray-300">
|
| 107 |
+
<label className="flex items-center gap-2 mb-3">
|
| 108 |
+
<span className="inline-block w-2 h-2 bg-[#05998c] rounded-full flex-shrink-0" />
|
| 109 |
+
<p className="font-bold text-gray-900 text-base">Additional Notes</p>
|
| 110 |
+
</label>
|
| 111 |
+
<textarea
|
| 112 |
+
value={additionalNotes}
|
| 113 |
+
onChange={e => setAdditionalNotes(e.target.value)}
|
| 114 |
+
placeholder="Add any clinical observations from the acetic acid examination..."
|
| 115 |
+
className="w-full border-2 border-[#05998c] rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#05998c] focus:border-transparent bg-white/80 resize-none"
|
| 116 |
+
rows={4}
|
| 117 |
+
/>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
src/components/ChatBot.tsx
CHANGED
|
@@ -1,72 +1,144 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
-
import { MessageCircle, X, Send, Bot } from 'lucide-react';
|
|
|
|
| 3 |
|
| 4 |
interface Message {
|
| 5 |
id: string;
|
| 6 |
text: string;
|
| 7 |
-
sender: 'user' | 'bot';
|
| 8 |
timestamp: Date;
|
| 9 |
}
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
export function ChatBot() {
|
| 12 |
const [isOpen, setIsOpen] = useState(false);
|
| 13 |
const [messages, setMessages] = useState<Message[]>([
|
| 14 |
{
|
| 15 |
id: '1',
|
| 16 |
-
text:
|
| 17 |
sender: 'bot',
|
| 18 |
-
timestamp: new Date()
|
| 19 |
-
}
|
| 20 |
]);
|
| 21 |
const [inputMessage, setInputMessage] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
id: Date.now().toString(),
|
| 39 |
-
text:
|
| 40 |
sender: 'user',
|
| 41 |
-
timestamp: new Date()
|
| 42 |
};
|
| 43 |
|
| 44 |
-
setMessages(prev => [...prev,
|
| 45 |
setInputMessage('');
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
const
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
text:
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
};
|
| 59 |
|
| 60 |
-
const
|
| 61 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 62 |
e.preventDefault();
|
| 63 |
handleSendMessage();
|
| 64 |
}
|
| 65 |
};
|
| 66 |
|
|
|
|
| 67 |
return (
|
| 68 |
<>
|
| 69 |
-
{/*
|
| 70 |
<button
|
| 71 |
onClick={() => setIsOpen(!isOpen)}
|
| 72 |
className="fixed bottom-6 right-6 bg-[#05998c] hover:bg-[#047569] text-white p-4 rounded-full shadow-lg transition-all duration-300 hover:scale-110 z-50"
|
|
@@ -75,15 +147,15 @@ export function ChatBot() {
|
|
| 75 |
{isOpen ? <X className="w-6 h-6" /> : <MessageCircle className="w-6 h-6" />}
|
| 76 |
</button>
|
| 77 |
|
| 78 |
-
{/* Chat
|
| 79 |
{isOpen && (
|
| 80 |
-
<div className="fixed bottom-20 right-6 w-80 h-
|
| 81 |
{/* Header */}
|
| 82 |
-
<div className="bg-[#05998c] text-white p-4
|
| 83 |
<Bot className="w-6 h-6" />
|
| 84 |
<div>
|
| 85 |
-
<h3 className="font-semibold">
|
| 86 |
-
<p className="text-
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
|
|
@@ -94,37 +166,56 @@ export function ChatBot() {
|
|
| 94 |
key={message.id}
|
| 95 |
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 96 |
>
|
| 97 |
-
|
| 98 |
-
className=
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
|
| 110 |
{/* Input */}
|
| 111 |
-
<div className="p-
|
| 112 |
<div className="flex gap-2">
|
| 113 |
<input
|
| 114 |
type="text"
|
| 115 |
value={inputMessage}
|
| 116 |
onChange={(e) => setInputMessage(e.target.value)}
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
| 120 |
/>
|
| 121 |
<button
|
| 122 |
onClick={handleSendMessage}
|
| 123 |
-
disabled={!inputMessage.trim()}
|
| 124 |
-
className="bg-[#05998c] hover:bg-[#047569] disabled:opacity-50 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors"
|
| 125 |
aria-label="Send message"
|
| 126 |
>
|
| 127 |
-
<Send className="w-4 h-4" />
|
| 128 |
</button>
|
| 129 |
</div>
|
| 130 |
</div>
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { MessageCircle, X, Send, Bot, Loader2, AlertCircle } from 'lucide-react';
|
| 3 |
+
import { CHAT_SYSTEM_PROMPT } from '../config/geminiConfig';
|
| 4 |
|
| 5 |
interface Message {
|
| 6 |
id: string;
|
| 7 |
text: string;
|
| 8 |
+
sender: 'user' | 'bot' | 'error';
|
| 9 |
timestamp: Date;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
// Shape of a single turn sent to Gemini's REST API
|
| 13 |
+
interface GeminiPart { text: string; }
|
| 14 |
+
interface GeminiContent { role: 'user' | 'model'; parts: GeminiPart[]; }
|
| 15 |
+
|
| 16 |
export function ChatBot() {
|
| 17 |
const [isOpen, setIsOpen] = useState(false);
|
| 18 |
const [messages, setMessages] = useState<Message[]>([
|
| 19 |
{
|
| 20 |
id: '1',
|
| 21 |
+
text: "Hello! I'm Pathora AI β your colposcopy expert assistant. Ask me anything about examination techniques, findings interpretation, or management guidelines.",
|
| 22 |
sender: 'bot',
|
| 23 |
+
timestamp: new Date(),
|
| 24 |
+
},
|
| 25 |
]);
|
| 26 |
const [inputMessage, setInputMessage] = useState('');
|
| 27 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 28 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 29 |
+
|
| 30 |
+
// Auto-scroll to bottom when a new message arrives
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 33 |
+
}, [messages, isLoading]);
|
| 34 |
+
|
| 35 |
+
// ββ Call Gemini REST API βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
+
const callGemini = async (history: Message[], userText: string): Promise<string> => {
|
| 37 |
+
const apiKey = import.meta.env.VITE_GEMINI_API_KEY as string | undefined;
|
| 38 |
+
if (!apiKey) {
|
| 39 |
+
throw new Error('API key missing β add VITE_GEMINI_API_KEY to your .env file and restart the dev server.');
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
| 43 |
+
|
| 44 |
+
// Auto-discover the best available model
|
| 45 |
+
const listRes = await fetch(`${baseUrl}/models?key=${apiKey}`);
|
| 46 |
+
if (!listRes.ok) {
|
| 47 |
+
const e = await listRes.json();
|
| 48 |
+
throw new Error(e?.error?.message || `Model list failed: HTTP ${listRes.status}`);
|
| 49 |
+
}
|
| 50 |
+
const allModels: any[] = (await listRes.json()).models || [];
|
| 51 |
+
const model =
|
| 52 |
+
allModels.find(m => m.name?.includes('flash') && m.supportedGenerationMethods?.includes('generateContent')) ||
|
| 53 |
+
allModels.find(m => m.supportedGenerationMethods?.includes('generateContent'));
|
| 54 |
+
if (!model) throw new Error('No generateContent-capable model available for this API key.');
|
| 55 |
+
|
| 56 |
+
const modelId = (model.name as string).replace(/^models\//, '');
|
| 57 |
+
|
| 58 |
+
// Build conversation history for context (bot turns labelled as 'model')
|
| 59 |
+
const conversationHistory: GeminiContent[] = history
|
| 60 |
+
.filter(m => m.sender === 'user' || m.sender === 'bot')
|
| 61 |
+
.map(m => ({
|
| 62 |
+
role: m.sender === 'user' ? 'user' : 'model',
|
| 63 |
+
parts: [{ text: m.text }],
|
| 64 |
+
}));
|
| 65 |
+
|
| 66 |
+
// Append the new user turn
|
| 67 |
+
conversationHistory.push({ role: 'user', parts: [{ text: userText }] });
|
| 68 |
+
|
| 69 |
+
const endpoint = `${baseUrl}/models/${modelId}:generateContent?key=${apiKey}`;
|
| 70 |
+
const body = {
|
| 71 |
+
system_instruction: { parts: [{ text: CHAT_SYSTEM_PROMPT }] },
|
| 72 |
+
contents: conversationHistory,
|
| 73 |
+
generationConfig: { temperature: 0.4, maxOutputTokens: 2048 },
|
| 74 |
+
};
|
| 75 |
|
| 76 |
+
const res = await fetch(endpoint, {
|
| 77 |
+
method: 'POST',
|
| 78 |
+
headers: { 'Content-Type': 'application/json' },
|
| 79 |
+
body: JSON.stringify(body),
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
if (!res.ok) {
|
| 83 |
+
const errData = await res.json();
|
| 84 |
+
throw new Error(errData?.error?.message || `HTTP ${res.status}`);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const data = await res.json();
|
| 88 |
+
const text = (data.candidates?.[0]?.content?.parts?.[0]?.text ?? '').trim();
|
| 89 |
+
if (!text) throw new Error('Gemini returned an empty response.');
|
| 90 |
+
return text;
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// ββ Send handler βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
const handleSendMessage = async () => {
|
| 95 |
+
const trimmed = inputMessage.trim();
|
| 96 |
+
if (!trimmed || isLoading) return;
|
| 97 |
+
|
| 98 |
+
const userMsg: Message = {
|
| 99 |
id: Date.now().toString(),
|
| 100 |
+
text: trimmed,
|
| 101 |
sender: 'user',
|
| 102 |
+
timestamp: new Date(),
|
| 103 |
};
|
| 104 |
|
| 105 |
+
setMessages(prev => [...prev, userMsg]);
|
| 106 |
setInputMessage('');
|
| 107 |
+
setIsLoading(true);
|
| 108 |
|
| 109 |
+
try {
|
| 110 |
+
// Pass current messages (before adding userMsg) as history for context
|
| 111 |
+
const reply = await callGemini(messages, trimmed);
|
| 112 |
+
setMessages(prev => [
|
| 113 |
+
...prev,
|
| 114 |
+
{ id: (Date.now() + 1).toString(), text: reply, sender: 'bot', timestamp: new Date() },
|
| 115 |
+
]);
|
| 116 |
+
} catch (err: any) {
|
| 117 |
+
setMessages(prev => [
|
| 118 |
+
...prev,
|
| 119 |
+
{
|
| 120 |
+
id: (Date.now() + 1).toString(),
|
| 121 |
+
text: err?.message || 'Something went wrong. Please try again.',
|
| 122 |
+
sender: 'error',
|
| 123 |
+
timestamp: new Date(),
|
| 124 |
+
},
|
| 125 |
+
]);
|
| 126 |
+
} finally {
|
| 127 |
+
setIsLoading(false);
|
| 128 |
+
}
|
| 129 |
};
|
| 130 |
|
| 131 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 132 |
if (e.key === 'Enter' && !e.shiftKey) {
|
| 133 |
e.preventDefault();
|
| 134 |
handleSendMessage();
|
| 135 |
}
|
| 136 |
};
|
| 137 |
|
| 138 |
+
// ββ Render ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
return (
|
| 140 |
<>
|
| 141 |
+
{/* Toggle button */}
|
| 142 |
<button
|
| 143 |
onClick={() => setIsOpen(!isOpen)}
|
| 144 |
className="fixed bottom-6 right-6 bg-[#05998c] hover:bg-[#047569] text-white p-4 rounded-full shadow-lg transition-all duration-300 hover:scale-110 z-50"
|
|
|
|
| 147 |
{isOpen ? <X className="w-6 h-6" /> : <MessageCircle className="w-6 h-6" />}
|
| 148 |
</button>
|
| 149 |
|
| 150 |
+
{/* Chat window */}
|
| 151 |
{isOpen && (
|
| 152 |
+
<div className="fixed bottom-20 right-6 w-80 h-[480px] bg-white rounded-xl shadow-2xl border border-gray-200 z-40 flex flex-col overflow-hidden">
|
| 153 |
{/* Header */}
|
| 154 |
+
<div className="bg-[#05998c] text-white p-4 flex items-center gap-3 shrink-0">
|
| 155 |
<Bot className="w-6 h-6" />
|
| 156 |
<div>
|
| 157 |
+
<h3 className="font-semibold leading-tight">Pathora AI</h3>
|
| 158 |
+
<p className="text-xs opacity-80">Colposcopy Expert Assistant</p>
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
|
|
|
|
| 166 |
key={message.id}
|
| 167 |
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 168 |
>
|
| 169 |
+
{message.sender === 'error' ? (
|
| 170 |
+
<div className="flex items-start gap-2 max-w-[85%] bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs">
|
| 171 |
+
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
| 172 |
+
<span>{message.text}</span>
|
| 173 |
+
</div>
|
| 174 |
+
) : (
|
| 175 |
+
<div
|
| 176 |
+
className={`max-w-[85%] p-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${message.sender === 'user'
|
| 177 |
+
? 'bg-[#05998c] text-white rounded-br-sm'
|
| 178 |
+
: 'bg-gray-100 text-gray-800 rounded-bl-sm'
|
| 179 |
+
}`}
|
| 180 |
+
>
|
| 181 |
+
{message.text}
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
</div>
|
| 185 |
))}
|
| 186 |
+
|
| 187 |
+
{/* Typing indicator */}
|
| 188 |
+
{isLoading && (
|
| 189 |
+
<div className="flex justify-start">
|
| 190 |
+
<div className="bg-gray-100 rounded-xl rounded-bl-sm p-3 flex items-center gap-2 text-gray-500 text-sm">
|
| 191 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 192 |
+
<span>Pathora AI is thinkingβ¦</span>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
<div ref={messagesEndRef} />
|
| 198 |
</div>
|
| 199 |
|
| 200 |
{/* Input */}
|
| 201 |
+
<div className="p-3 border-t border-gray-200 shrink-0">
|
| 202 |
<div className="flex gap-2">
|
| 203 |
<input
|
| 204 |
type="text"
|
| 205 |
value={inputMessage}
|
| 206 |
onChange={(e) => setInputMessage(e.target.value)}
|
| 207 |
+
onKeyDown={handleKeyDown}
|
| 208 |
+
disabled={isLoading}
|
| 209 |
+
placeholder="Ask about colposcopy findingsβ¦"
|
| 210 |
+
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent text-sm outline-none disabled:opacity-60"
|
| 211 |
/>
|
| 212 |
<button
|
| 213 |
onClick={handleSendMessage}
|
| 214 |
+
disabled={!inputMessage.trim() || isLoading}
|
| 215 |
+
className="bg-[#05998c] hover:bg-[#047569] disabled:opacity-50 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors shrink-0"
|
| 216 |
aria-label="Send message"
|
| 217 |
>
|
| 218 |
+
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
| 219 |
</button>
|
| 220 |
</div>
|
| 221 |
</div>
|
src/components/ImageAnnotator.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react';
|
| 2 |
-
import { Wrench, Trash2, Circle as CircleIcon, Hexagon, Square as SquareIcon, ChevronDown, ChevronUp,
|
| 3 |
|
| 4 |
type ShapeType = 'rect' | 'circle' | 'polygon';
|
| 5 |
|
|
@@ -40,7 +40,7 @@ export interface ImageAnnotatorHandle {
|
|
| 40 |
waitForImageReady: () => Promise<void>;
|
| 41 |
}
|
| 42 |
|
| 43 |
-
const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorProps>(({ imageUrl, imageUrls, onAnnotationsChange, onAIAssist, isAILoading = false }, ref) => {
|
| 44 |
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
| 45 |
const containerRef = useRef<HTMLDivElement | null>(null);
|
| 46 |
const imageReadyResolveRef = useRef<(() => void) | null>(null);
|
|
@@ -60,6 +60,11 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 60 |
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
| 61 |
const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true);
|
| 62 |
const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
// Edit state for annotation list
|
| 65 |
const [editingId, setEditingId] = useState<string | null>(null);
|
|
@@ -67,7 +72,7 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 67 |
const [editColor, setEditColor] = useState('#05998c');
|
| 68 |
|
| 69 |
// Annotation metadata state
|
| 70 |
-
const [
|
| 71 |
const [annotationAccepted, setAnnotationAccepted] = useState<Record<string, boolean>>({});
|
| 72 |
// Predefined label options
|
| 73 |
const labelOptions = [
|
|
@@ -361,8 +366,11 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 361 |
ctx.setLineDash([]);
|
| 362 |
if (annotation.type === 'rect') {
|
| 363 |
ctx.strokeRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
| 366 |
} else if (annotation.type === 'circle') {
|
| 367 |
const cx = (annotation.x + annotation.width / 2) * scaleX;
|
| 368 |
const cy = (annotation.y + annotation.height / 2) * scaleY;
|
|
@@ -370,8 +378,11 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 370 |
ctx.beginPath();
|
| 371 |
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
| 372 |
ctx.stroke();
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
| 375 |
} else if (annotation.type === 'polygon' && annotation.points && Array.isArray(annotation.points) && annotation.points.length > 0) {
|
| 376 |
ctx.beginPath();
|
| 377 |
annotation.points.forEach((p, i) => {
|
|
@@ -383,8 +394,11 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 383 |
});
|
| 384 |
ctx.closePath();
|
| 385 |
ctx.stroke();
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
| 388 |
}
|
| 389 |
};
|
| 390 |
|
|
@@ -400,6 +414,25 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 400 |
};
|
| 401 |
const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
const getShapeTypeName = (type: ShapeType): string => {
|
| 404 |
const typeMap: Record<ShapeType, string> = {
|
| 405 |
'rect': 'Rectangle',
|
|
@@ -460,63 +493,81 @@ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorP
|
|
| 460 |
</div>
|
| 461 |
</div>
|
| 462 |
|
| 463 |
-
<div className="flex flex-col
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
>
|
| 487 |
-
{
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
)}
|
| 492 |
</div>
|
| 493 |
-
|
| 494 |
-
{
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
<>
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
</button>
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
<span className="hidden md:inline">Undo</span>
|
| 517 |
-
<span className="inline md:hidden">Undo</span>
|
| 518 |
-
</button>
|
| 519 |
-
<button onClick={clearAnnotations} disabled={annotations.length === 0} className="px-3 md:px-4 py-1 md:py-2 text-xs md:text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Clear All</button>
|
| 520 |
</div>
|
| 521 |
</div>
|
| 522 |
|
|
|
|
| 1 |
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react';
|
| 2 |
+
import { Wrench, Trash2, Circle as CircleIcon, Hexagon, Square as SquareIcon, ChevronDown, ChevronUp, Sparkles, Loader } from 'lucide-react';
|
| 3 |
|
| 4 |
type ShapeType = 'rect' | 'circle' | 'polygon';
|
| 5 |
|
|
|
|
| 40 |
waitForImageReady: () => Promise<void>;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorProps>(({ imageUrl, imageUrls, onAnnotationsChange, onAIAssist, isAILoading: externalAILoading = false }, ref) => {
|
| 44 |
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
| 45 |
const containerRef = useRef<HTMLDivElement | null>(null);
|
| 46 |
const imageReadyResolveRef = useRef<(() => void) | null>(null);
|
|
|
|
| 60 |
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
| 61 |
const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true);
|
| 62 |
const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false);
|
| 63 |
+
const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false);
|
| 64 |
+
const [internalAILoading, setInternalAILoading] = useState(false);
|
| 65 |
+
|
| 66 |
+
// Use internal loading state or external prop
|
| 67 |
+
const isAILoading = externalAILoading || internalAILoading;
|
| 68 |
|
| 69 |
// Edit state for annotation list
|
| 70 |
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
| 72 |
const [editColor, setEditColor] = useState('#05998c');
|
| 73 |
|
| 74 |
// Annotation metadata state
|
| 75 |
+
const [_annotationIdentified, setAnnotationIdentified] = useState<Record<string, boolean>>({});
|
| 76 |
const [annotationAccepted, setAnnotationAccepted] = useState<Record<string, boolean>>({});
|
| 77 |
// Predefined label options
|
| 78 |
const labelOptions = [
|
|
|
|
| 366 |
ctx.setLineDash([]);
|
| 367 |
if (annotation.type === 'rect') {
|
| 368 |
ctx.strokeRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
|
| 369 |
+
// Only fill for manual annotations, not for AI-detected bounding boxes
|
| 370 |
+
if (annotation.source !== 'ai') {
|
| 371 |
+
ctx.fillStyle = annotation.color + '20';
|
| 372 |
+
ctx.fillRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
|
| 373 |
+
}
|
| 374 |
} else if (annotation.type === 'circle') {
|
| 375 |
const cx = (annotation.x + annotation.width / 2) * scaleX;
|
| 376 |
const cy = (annotation.y + annotation.height / 2) * scaleY;
|
|
|
|
| 378 |
ctx.beginPath();
|
| 379 |
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
| 380 |
ctx.stroke();
|
| 381 |
+
// Only fill for manual annotations, not for AI-detected shapes
|
| 382 |
+
if (annotation.source !== 'ai') {
|
| 383 |
+
ctx.fillStyle = annotation.color + '20';
|
| 384 |
+
ctx.fill();
|
| 385 |
+
}
|
| 386 |
} else if (annotation.type === 'polygon' && annotation.points && Array.isArray(annotation.points) && annotation.points.length > 0) {
|
| 387 |
ctx.beginPath();
|
| 388 |
annotation.points.forEach((p, i) => {
|
|
|
|
| 394 |
});
|
| 395 |
ctx.closePath();
|
| 396 |
ctx.stroke();
|
| 397 |
+
// Only fill for manual annotations, not for AI-detected shapes
|
| 398 |
+
if (annotation.source !== 'ai') {
|
| 399 |
+
ctx.fillStyle = annotation.color + '20';
|
| 400 |
+
ctx.fill();
|
| 401 |
+
}
|
| 402 |
}
|
| 403 |
};
|
| 404 |
|
|
|
|
| 414 |
};
|
| 415 |
const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
|
| 416 |
|
| 417 |
+
const handleAIAssistToggle = async () => {
|
| 418 |
+
if (isAILoading) return;
|
| 419 |
+
if (isAIAssistEnabled) {
|
| 420 |
+
setIsAIAssistEnabled(false);
|
| 421 |
+
return;
|
| 422 |
+
}
|
| 423 |
+
setIsAIAssistEnabled(true);
|
| 424 |
+
if (onAIAssist) {
|
| 425 |
+
setInternalAILoading(true);
|
| 426 |
+
try {
|
| 427 |
+
await onAIAssist();
|
| 428 |
+
} catch (error) {
|
| 429 |
+
console.error('AI Assist error:', error);
|
| 430 |
+
} finally {
|
| 431 |
+
setInternalAILoading(false);
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
};
|
| 435 |
+
|
| 436 |
const getShapeTypeName = (type: ShapeType): string => {
|
| 437 |
const typeMap: Record<ShapeType, string> = {
|
| 438 |
'rect': 'Rectangle',
|
|
|
|
| 493 |
</div>
|
| 494 |
</div>
|
| 495 |
|
| 496 |
+
<div className="flex flex-col gap-2">
|
| 497 |
+
{/* First row: AI Assist on right side */}
|
| 498 |
+
<div className="flex justify-end">
|
| 499 |
+
{onAIAssist && (
|
| 500 |
+
<button
|
| 501 |
+
onClick={handleAIAssistToggle}
|
| 502 |
+
disabled={isAILoading}
|
| 503 |
+
className={`px-4 md:px-6 py-2 md:py-3 text-sm md:text-base font-bold text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl flex items-center justify-center gap-2 ${
|
| 504 |
+
isAIAssistEnabled
|
| 505 |
+
? 'bg-gradient-to-r from-green-500 to-green-600 border border-green-600 hover:from-green-600 hover:to-green-700'
|
| 506 |
+
: 'bg-gradient-to-r from-blue-600 to-blue-700 border border-blue-700 hover:from-blue-700 hover:to-blue-800'
|
| 507 |
+
}`}
|
| 508 |
+
title={isAIAssistEnabled ? 'AI Assist is ON' : 'Run AI model to automatically detect annotations'}
|
| 509 |
+
>
|
| 510 |
+
{isAILoading ? (
|
| 511 |
+
<>
|
| 512 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 513 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 514 |
+
</div>
|
| 515 |
+
<Loader className="w-5 h-5 animate-spin" />
|
| 516 |
+
<span>Analyzing...</span>
|
| 517 |
+
</>
|
| 518 |
+
) : (
|
| 519 |
+
<>
|
| 520 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 521 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 522 |
+
</div>
|
| 523 |
+
<Sparkles className="h-5 w-5" />
|
| 524 |
+
<span>{isAIAssistEnabled ? 'AI Assist On' : 'AI Assist'}</span>
|
| 525 |
+
</>
|
| 526 |
+
)}
|
| 527 |
+
</button>
|
| 528 |
)}
|
| 529 |
</div>
|
| 530 |
+
|
| 531 |
+
{/* Second row: Label, Color, Undo on left, Clear All on right */}
|
| 532 |
+
<div className="flex flex-col md:flex-row md:items-center gap-2">
|
| 533 |
+
<div className="relative">
|
| 534 |
+
<input
|
| 535 |
+
aria-label="Annotation label"
|
| 536 |
+
placeholder="Search or select label"
|
| 537 |
+
value={labelInput}
|
| 538 |
+
onChange={e => setLabelInput(e.target.value)}
|
| 539 |
+
onFocus={() => setIsLabelDropdownOpen(true)}
|
| 540 |
+
onBlur={() => setTimeout(() => setIsLabelDropdownOpen(false), 200)}
|
| 541 |
+
className="px-3 py-1 border rounded text-xs md:text-sm w-48"
|
| 542 |
+
/>
|
| 543 |
+
{isLabelDropdownOpen && filteredLabels.length > 0 && (
|
| 544 |
+
<div className="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto z-50">
|
| 545 |
+
{filteredLabels.map((label, idx) => (
|
| 546 |
+
<button
|
| 547 |
+
key={idx}
|
| 548 |
+
type="button"
|
| 549 |
+
onMouseDown={(e) => {
|
| 550 |
+
e.preventDefault();
|
| 551 |
+
setLabelInput(label);
|
| 552 |
+
setIsLabelDropdownOpen(false);
|
| 553 |
+
}}
|
| 554 |
+
className="w-full text-left px-3 py-2 text-xs md:text-sm hover:bg-gray-100 transition-colors"
|
| 555 |
+
>
|
| 556 |
+
{label}
|
| 557 |
+
</button>
|
| 558 |
+
))}
|
| 559 |
+
</div>
|
| 560 |
)}
|
| 561 |
+
</div>
|
| 562 |
+
<input aria-label="Annotation color" type="color" value={color} onChange={e => setColor(e.target.value)} className="w-10 h-8 p-0 border rounded" />
|
| 563 |
+
<button onClick={deleteLastAnnotation} disabled={annotations.length === 0} className="px-3 md:px-4 py-1 md:py-2 text-xs md:text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-1 md:gap-2">
|
| 564 |
+
<Trash2 className="w-4 h-4" />
|
| 565 |
+
<span className="hidden md:inline">Undo</span>
|
| 566 |
+
<span className="inline md:hidden">Undo</span>
|
| 567 |
</button>
|
| 568 |
+
<div className="flex-1"></div>
|
| 569 |
+
<button onClick={clearAnnotations} disabled={annotations.length === 0} className="px-3 md:px-4 py-1 md:py-2 text-xs md:text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Clear All</button>
|
| 570 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
</div>
|
| 572 |
</div>
|
| 573 |
|
src/components/ImagingObservations.tsx
CHANGED
|
@@ -1,12 +1,16 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
import { CheckSquare, FileText } from 'lucide-react';
|
|
|
|
|
|
|
| 3 |
interface ImagingObservationsProps {
|
| 4 |
onObservationsChange?: (observations: any) => void;
|
| 5 |
layout?: 'vertical' | 'horizontal';
|
|
|
|
| 6 |
}
|
| 7 |
export function ImagingObservations({
|
| 8 |
onObservationsChange,
|
| 9 |
-
layout = 'vertical'
|
|
|
|
| 10 |
}: ImagingObservationsProps) {
|
| 11 |
const [observations, setObservations] = useState({
|
| 12 |
obviousGrowths: false,
|
|
@@ -73,6 +77,38 @@ export function ImagingObservations({
|
|
| 73 |
onObservationsChange(updated);
|
| 74 |
}
|
| 75 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
return <div className={`bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden ${layout === 'vertical' ? 'h-full flex flex-col' : ''}`}>
|
| 77 |
<div className="bg-teal-50/50 p-3 md:p-4 border-b border-teal-100 flex items-center gap-2 md:gap-3">
|
| 78 |
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center flex-shrink-0">
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
import { CheckSquare, FileText } from 'lucide-react';
|
| 3 |
+
import { sessionStore } from '../store/sessionStore';
|
| 4 |
+
|
| 5 |
interface ImagingObservationsProps {
|
| 6 |
onObservationsChange?: (observations: any) => void;
|
| 7 |
layout?: 'vertical' | 'horizontal';
|
| 8 |
+
stepId?: 'native' | 'acetowhite' | 'greenFilter' | 'lugol' | 'biopsyMarking';
|
| 9 |
}
|
| 10 |
export function ImagingObservations({
|
| 11 |
onObservationsChange,
|
| 12 |
+
layout = 'vertical',
|
| 13 |
+
stepId
|
| 14 |
}: ImagingObservationsProps) {
|
| 15 |
const [observations, setObservations] = useState({
|
| 16 |
obviousGrowths: false,
|
|
|
|
| 77 |
onObservationsChange(updated);
|
| 78 |
}
|
| 79 |
};
|
| 80 |
+
|
| 81 |
+
// Load previously saved findings for this step from sessionStore on mount
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
if (!stepId) return;
|
| 84 |
+
const session = sessionStore.get();
|
| 85 |
+
if (session.stepFindings?.[stepId]) {
|
| 86 |
+
setObservations(prev => ({
|
| 87 |
+
...prev,
|
| 88 |
+
...session.stepFindings[stepId]
|
| 89 |
+
}));
|
| 90 |
+
console.log(`[ImagingObservations] Loaded saved findings for step: ${stepId}`, session.stepFindings[stepId]);
|
| 91 |
+
}
|
| 92 |
+
}, [stepId]);
|
| 93 |
+
|
| 94 |
+
// Save observations to sessionStore for this step on every change with debouncing
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
if (!stepId) return;
|
| 97 |
+
|
| 98 |
+
// Debounce: save after 500ms of inactivity
|
| 99 |
+
const timer = setTimeout(() => {
|
| 100 |
+
const session = sessionStore.get();
|
| 101 |
+
const newStepFindings = {
|
| 102 |
+
...(session.stepFindings || {}),
|
| 103 |
+
[stepId]: observations
|
| 104 |
+
};
|
| 105 |
+
sessionStore.merge({ stepFindings: newStepFindings });
|
| 106 |
+
console.log(`[ImagingObservations] Saved observations for step: ${stepId}`, observations);
|
| 107 |
+
}, 500);
|
| 108 |
+
|
| 109 |
+
return () => clearTimeout(timer);
|
| 110 |
+
}, [stepId, JSON.stringify(observations)]);
|
| 111 |
+
|
| 112 |
return <div className={`bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden ${layout === 'vertical' ? 'h-full flex flex-col' : ''}`}>
|
| 113 |
<div className="bg-teal-50/50 p-3 md:p-4 border-b border-teal-100 flex items-center gap-2 md:gap-3">
|
| 114 |
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center flex-shrink-0">
|
src/components/PatientHistoryForm.tsx
CHANGED
|
@@ -1,20 +1,30 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { User, Calendar, Stethoscope, AlertTriangle, Save, ChevronRight, ArrowLeft } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
| 3 |
interface PatientHistoryFormProps {
|
| 4 |
onContinue: () => void;
|
| 5 |
onBack: () => void;
|
| 6 |
patientID?: string | undefined;
|
|
|
|
| 7 |
}
|
| 8 |
export function PatientHistoryForm({
|
| 9 |
onContinue,
|
| 10 |
onBack,
|
| 11 |
-
patientID
|
|
|
|
| 12 |
}: PatientHistoryFormProps) {
|
| 13 |
const [formData, setFormData] = useState({
|
| 14 |
// Patient Profile
|
|
|
|
| 15 |
age: '',
|
| 16 |
bloodGroup: '',
|
| 17 |
parity: '',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
menstrualStatus: '',
|
| 19 |
sexualHistory: '',
|
| 20 |
hpvStatus: '',
|
|
@@ -45,14 +55,30 @@ export function PatientHistoryForm({
|
|
| 45 |
riskFactorsNotes: ''
|
| 46 |
});
|
| 47 |
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
| 48 |
-
const {
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
};
|
| 57 |
const handleNestedCheckboxChange = (category: 'pastProcedures' | 'immunosuppression', field: string) => {
|
| 58 |
if (category === 'pastProcedures') {
|
|
@@ -77,11 +103,45 @@ export function PatientHistoryForm({
|
|
| 77 |
};
|
| 78 |
const handleSave = () => {
|
| 79 |
console.log('Saving patient history:', formData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
};
|
| 81 |
const handleSaveAndContinue = () => {
|
| 82 |
handleSave();
|
| 83 |
onContinue();
|
| 84 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
return <div className="w-full max-w-8xl mx-auto p-4 md:p-6 lg:p-10">
|
| 86 |
<div className="mb-4 md:mb-6 lg:mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
|
| 87 |
<h2 className="text-xl md:text-2xl lg:text-3xl font-bold text-[#0A2540]">
|
|
@@ -90,7 +150,7 @@ export function PatientHistoryForm({
|
|
| 90 |
<div className="flex items-center gap-2 text-xs md:text-sm text-gray-500 bg-white px-3 md:px-4 py-2 rounded-lg shadow-sm whitespace-nowrap">
|
| 91 |
<span>Patient ID:</span>
|
| 92 |
<span className="font-mono font-bold text-[#0A2540]">
|
| 93 |
-
{patientID
|
| 94 |
</span>
|
| 95 |
</div>
|
| 96 |
</div>
|
|
@@ -103,7 +163,7 @@ export function PatientHistoryForm({
|
|
| 103 |
<User className="w-4 h-4 md:w-5 md:h-5" />
|
| 104 |
</div>
|
| 105 |
<h3 className="font-bold text-sm md:text-base text-[#0A2540]">Patient Name</h3>
|
| 106 |
-
<input type="text" name="name" className="w-full px-3 md:px-4 py-2 md:py-3 text-sm md:text-base bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent outline-none transition-all" placeholder="Full name" />
|
| 107 |
</div>
|
| 108 |
|
| 109 |
<div className="p-4 md:p-6 lg:p-8 space-y-4 md:space-y-5 lg:space-y-6 flex-1">
|
|
@@ -141,47 +201,78 @@ export function PatientHistoryForm({
|
|
| 141 |
</div>
|
| 142 |
|
| 143 |
<div>
|
| 144 |
-
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">
|
| 145 |
-
Menstrual Status
|
| 146 |
-
</label>
|
| 147 |
<div className="space-y-2">
|
| 148 |
-
{['
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
<
|
|
|
|
|
|
|
| 152 |
</div>
|
| 153 |
-
</div>
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
</div>
|
| 168 |
|
| 169 |
<div>
|
| 170 |
-
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
</div>
|
| 180 |
|
| 181 |
<div className="pt-4 border-t border-gray-100">
|
| 182 |
-
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">
|
| 183 |
-
Additional Notes
|
| 184 |
-
</label>
|
| 185 |
<textarea name="patientProfileNotes" value={formData.patientProfileNotes} onChange={handleInputChange} rows={3} className="w-full px-4 py-3 text-base bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all resize-none" placeholder="Add any additional notes about patient profile..." />
|
| 186 |
</div>
|
| 187 |
</div>
|
|
@@ -197,9 +288,52 @@ export function PatientHistoryForm({
|
|
| 197 |
</div>
|
| 198 |
|
| 199 |
<div className="p-8 space-y-4 flex-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
<label className="flex items-start gap-3 p-4 rounded-lg border border-gray-100 hover:border-[#4ECDC4]/50 hover:bg-teal-50/10 cursor-pointer transition-all">
|
| 201 |
<div className="flex items-center h-5">
|
| 202 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</div>
|
| 204 |
<div>
|
| 205 |
<span className="block text-base font-medium text-gray-900">
|
|
@@ -213,7 +347,12 @@ export function PatientHistoryForm({
|
|
| 213 |
|
| 214 |
<label className="flex items-start gap-3 p-4 rounded-lg border border-gray-100 hover:border-[#4ECDC4]/50 hover:bg-teal-50/10 cursor-pointer transition-all">
|
| 215 |
<div className="flex items-center h-5">
|
| 216 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
</div>
|
| 218 |
<div>
|
| 219 |
<span className="block text-base font-medium text-gray-900">
|
|
@@ -227,7 +366,12 @@ export function PatientHistoryForm({
|
|
| 227 |
|
| 228 |
<label className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:border-[#4ECDC4]/50 hover:bg-teal-50/10 cursor-pointer transition-all">
|
| 229 |
<div className="flex items-center h-5">
|
| 230 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
</div>
|
| 232 |
<div>
|
| 233 |
<span className="block text-sm font-medium text-gray-900">
|
|
@@ -259,24 +403,20 @@ export function PatientHistoryForm({
|
|
| 259 |
|
| 260 |
<div className="p-8 space-y-6 flex-1">
|
| 261 |
<div>
|
| 262 |
-
<label className="block text-xs font-semibold text-gray-500 uppercase mb-2">
|
| 263 |
-
Pap Smear Result
|
| 264 |
-
</label>
|
| 265 |
<div className="space-y-2">
|
| 266 |
-
{['ASC-US', 'LSIL', 'HSIL', 'Normal', 'Not Done'].map(result =>
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
</div>
|
| 273 |
</div>
|
| 274 |
|
| 275 |
<div>
|
| 276 |
-
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">
|
| 277 |
-
|
| 278 |
-
</label>
|
| 279 |
-
<input type="text" name="hpvDnaTypes" className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all" placeholder="e.g. Type 16, 18" />
|
| 280 |
</div>
|
| 281 |
|
| 282 |
<div>
|
|
@@ -311,18 +451,16 @@ export function PatientHistoryForm({
|
|
| 311 |
|
| 312 |
<div className="p-8 space-y-6 flex-1">
|
| 313 |
<div>
|
| 314 |
-
<label className="block text-xs font-semibold text-gray-500 uppercase mb-3">
|
| 315 |
-
Smoking History
|
| 316 |
-
</label>
|
| 317 |
<div className="flex gap-2">
|
| 318 |
<label className="flex-1 cursor-pointer">
|
| 319 |
-
<input type="radio" name="smoking" value="Yes" className="peer sr-only" />
|
| 320 |
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 peer-checked:bg-rose-50 peer-checked:border-rose-200 peer-checked:text-rose-700 transition-all">
|
| 321 |
<span className="font-medium">Yes</span>
|
| 322 |
</div>
|
| 323 |
</label>
|
| 324 |
<label className="flex-1 cursor-pointer">
|
| 325 |
-
<input type="radio" name="smoking" value="No" className="peer sr-only" />
|
| 326 |
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 peer-checked:bg-green-50 peer-checked:border-green-200 peer-checked:text-green-700 transition-all">
|
| 327 |
<span className="font-medium">No</span>
|
| 328 |
</div>
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { User, Calendar, Stethoscope, AlertTriangle, Save, ChevronRight, ArrowLeft } from 'lucide-react';
|
| 3 |
+
import { sessionStore } from '../store/sessionStore';
|
| 4 |
+
import { savePatientRecord } from '../store/patientIdStore';
|
| 5 |
+
|
| 6 |
interface PatientHistoryFormProps {
|
| 7 |
onContinue: () => void;
|
| 8 |
onBack: () => void;
|
| 9 |
patientID?: string | undefined;
|
| 10 |
+
autoGeneratedPatientId?: string | undefined;
|
| 11 |
}
|
| 12 |
export function PatientHistoryForm({
|
| 13 |
onContinue,
|
| 14 |
onBack,
|
| 15 |
+
patientID,
|
| 16 |
+
autoGeneratedPatientId
|
| 17 |
}: PatientHistoryFormProps) {
|
| 18 |
const [formData, setFormData] = useState({
|
| 19 |
// Patient Profile
|
| 20 |
+
name: '',
|
| 21 |
age: '',
|
| 22 |
bloodGroup: '',
|
| 23 |
parity: '',
|
| 24 |
+
pregnancyStatus: '',
|
| 25 |
+
gestationalAgeWeeks: '',
|
| 26 |
+
monthsSinceLastDelivery: '',
|
| 27 |
+
monthsSinceAbortion: '',
|
| 28 |
menstrualStatus: '',
|
| 29 |
sexualHistory: '',
|
| 30 |
hpvStatus: '',
|
|
|
|
| 55 |
riskFactorsNotes: ''
|
| 56 |
});
|
| 57 |
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
| 58 |
+
const { name, value, type } = e.target as any;
|
| 59 |
+
|
| 60 |
+
// Handle checkboxes separately
|
| 61 |
+
if (type === 'checkbox') {
|
| 62 |
+
const checked = (e.target as HTMLInputElement).checked;
|
| 63 |
+
setFormData(prev => ({
|
| 64 |
+
...prev,
|
| 65 |
+
[name]: checked
|
| 66 |
+
}));
|
| 67 |
+
} else if (name === 'pregnancyStatus') {
|
| 68 |
+
setFormData(prev => ({
|
| 69 |
+
...prev,
|
| 70 |
+
pregnancyStatus: value,
|
| 71 |
+
gestationalAgeWeeks: value === 'Pregnant' ? prev.gestationalAgeWeeks : '',
|
| 72 |
+
monthsSinceLastDelivery: value === 'Postpartum' ? prev.monthsSinceLastDelivery : '',
|
| 73 |
+
monthsSinceAbortion: value === 'Post-abortion' ? prev.monthsSinceAbortion : ''
|
| 74 |
+
}));
|
| 75 |
+
} else {
|
| 76 |
+
// Handle text inputs, textareas, and selects
|
| 77 |
+
setFormData(prev => ({
|
| 78 |
+
...prev,
|
| 79 |
+
[name]: value
|
| 80 |
+
}));
|
| 81 |
+
}
|
| 82 |
};
|
| 83 |
const handleNestedCheckboxChange = (category: 'pastProcedures' | 'immunosuppression', field: string) => {
|
| 84 |
if (category === 'pastProcedures') {
|
|
|
|
| 103 |
};
|
| 104 |
const handleSave = () => {
|
| 105 |
console.log('Saving patient history:', formData);
|
| 106 |
+
// Save patient record if auto-generated ID is available
|
| 107 |
+
if (autoGeneratedPatientId && formData.name) {
|
| 108 |
+
const examDate = new Date().toISOString().split('T')[0];
|
| 109 |
+
savePatientRecord({
|
| 110 |
+
id: autoGeneratedPatientId,
|
| 111 |
+
name: formData.name,
|
| 112 |
+
examDate: examDate
|
| 113 |
+
});
|
| 114 |
+
// Also update sessionStore with patient info
|
| 115 |
+
sessionStore.merge({
|
| 116 |
+
patientInfo: {
|
| 117 |
+
id: autoGeneratedPatientId,
|
| 118 |
+
name: formData.name,
|
| 119 |
+
examDate: examDate
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
};
|
| 124 |
const handleSaveAndContinue = () => {
|
| 125 |
handleSave();
|
| 126 |
onContinue();
|
| 127 |
};
|
| 128 |
+
|
| 129 |
+
// Load previously saved patient history from sessionStore on mount
|
| 130 |
+
React.useEffect(() => {
|
| 131 |
+
const session = sessionStore.get();
|
| 132 |
+
if (session.patientHistory) {
|
| 133 |
+
setFormData(prev => ({
|
| 134 |
+
...prev,
|
| 135 |
+
...session.patientHistory
|
| 136 |
+
}));
|
| 137 |
+
}
|
| 138 |
+
}, []);
|
| 139 |
+
|
| 140 |
+
// Save formData to sessionStore on every change
|
| 141 |
+
React.useEffect(() => {
|
| 142 |
+
sessionStore.merge({ patientHistory: formData as any });
|
| 143 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 144 |
+
}, [JSON.stringify(formData)]);
|
| 145 |
return <div className="w-full max-w-8xl mx-auto p-4 md:p-6 lg:p-10">
|
| 146 |
<div className="mb-4 md:mb-6 lg:mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
|
| 147 |
<h2 className="text-xl md:text-2xl lg:text-3xl font-bold text-[#0A2540]">
|
|
|
|
| 150 |
<div className="flex items-center gap-2 text-xs md:text-sm text-gray-500 bg-white px-3 md:px-4 py-2 rounded-lg shadow-sm whitespace-nowrap">
|
| 151 |
<span>Patient ID:</span>
|
| 152 |
<span className="font-mono font-bold text-[#0A2540]">
|
| 153 |
+
{autoGeneratedPatientId || patientID || 'New - unsaved'}
|
| 154 |
</span>
|
| 155 |
</div>
|
| 156 |
</div>
|
|
|
|
| 163 |
<User className="w-4 h-4 md:w-5 md:h-5" />
|
| 164 |
</div>
|
| 165 |
<h3 className="font-bold text-sm md:text-base text-[#0A2540]">Patient Name</h3>
|
| 166 |
+
<input type="text" name="name" value={formData.name} onChange={handleInputChange} className="w-full px-3 md:px-4 py-2 md:py-3 text-sm md:text-base bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent outline-none transition-all" placeholder="Full name" />
|
| 167 |
</div>
|
| 168 |
|
| 169 |
<div className="p-4 md:p-6 lg:p-8 space-y-4 md:space-y-5 lg:space-y-6 flex-1">
|
|
|
|
| 201 |
</div>
|
| 202 |
|
| 203 |
<div>
|
| 204 |
+
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">Pregnancy / Recent Pregnancy Status</label>
|
|
|
|
|
|
|
| 205 |
<div className="space-y-2">
|
| 206 |
+
{['Pregnant', 'Not pregnant', 'Postpartum', 'Post-abortion', 'Unknown'].map(status => (
|
| 207 |
+
<label key={status} className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer border border-transparent hover:border-gray-100 transition-all">
|
| 208 |
+
<input type="radio" name="pregnancyStatus" value={status} checked={formData.pregnancyStatus === status} onChange={handleInputChange} className="w-4 h-4 text-[#4ECDC4] focus:ring-[#4ECDC4]" />
|
| 209 |
+
<span className="text-base text-gray-700">{status}</span>
|
| 210 |
+
</label>
|
| 211 |
+
))}
|
| 212 |
</div>
|
|
|
|
| 213 |
|
| 214 |
+
{formData.pregnancyStatus === 'Pregnant' && (
|
| 215 |
+
<div className="mt-3">
|
| 216 |
+
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Gestational age (weeks)</label>
|
| 217 |
+
<input
|
| 218 |
+
type="number"
|
| 219 |
+
name="gestationalAgeWeeks"
|
| 220 |
+
value={formData.gestationalAgeWeeks}
|
| 221 |
+
onChange={handleInputChange}
|
| 222 |
+
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all text-sm"
|
| 223 |
+
placeholder="Enter weeks"
|
| 224 |
+
/>
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
|
| 228 |
+
{formData.pregnancyStatus === 'Postpartum' && (
|
| 229 |
+
<div className="mt-3">
|
| 230 |
+
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Months since last delivery</label>
|
| 231 |
+
<input
|
| 232 |
+
type="number"
|
| 233 |
+
name="monthsSinceLastDelivery"
|
| 234 |
+
value={formData.monthsSinceLastDelivery}
|
| 235 |
+
onChange={handleInputChange}
|
| 236 |
+
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all text-sm"
|
| 237 |
+
placeholder="Enter months"
|
| 238 |
+
/>
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
+
|
| 242 |
+
{formData.pregnancyStatus === 'Post-abortion' && (
|
| 243 |
+
<div className="mt-3">
|
| 244 |
+
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Months since abortion</label>
|
| 245 |
+
<input
|
| 246 |
+
type="number"
|
| 247 |
+
name="monthsSinceAbortion"
|
| 248 |
+
value={formData.monthsSinceAbortion}
|
| 249 |
+
onChange={handleInputChange}
|
| 250 |
+
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all text-sm"
|
| 251 |
+
placeholder="Enter months"
|
| 252 |
+
/>
|
| 253 |
+
</div>
|
| 254 |
+
)}
|
| 255 |
</div>
|
| 256 |
|
| 257 |
<div>
|
| 258 |
+
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">Menstrual Status</label>
|
| 259 |
+
<select
|
| 260 |
+
name="menstrualStatus"
|
| 261 |
+
value={formData.menstrualStatus}
|
| 262 |
+
onChange={handleInputChange}
|
| 263 |
+
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all text-sm"
|
| 264 |
+
>
|
| 265 |
+
<option value="">Select</option>
|
| 266 |
+
<option value="Premenarchal">Premenarchal</option>
|
| 267 |
+
<option value="Reproductive age">Reproductive age</option>
|
| 268 |
+
<option value="Currently menstruating">Currently menstruating</option>
|
| 269 |
+
<option value="Perimenopausal">Perimenopausal</option>
|
| 270 |
+
<option value="Postmenopausal">Postmenopausal</option>
|
| 271 |
+
</select>
|
| 272 |
</div>
|
| 273 |
|
| 274 |
<div className="pt-4 border-t border-gray-100">
|
| 275 |
+
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">Additional Notes</label>
|
|
|
|
|
|
|
| 276 |
<textarea name="patientProfileNotes" value={formData.patientProfileNotes} onChange={handleInputChange} rows={3} className="w-full px-4 py-3 text-base bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all resize-none" placeholder="Add any additional notes about patient profile..." />
|
| 277 |
</div>
|
| 278 |
</div>
|
|
|
|
| 288 |
</div>
|
| 289 |
|
| 290 |
<div className="p-8 space-y-4 flex-1">
|
| 291 |
+
<div>
|
| 292 |
+
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">Sexual History</label>
|
| 293 |
+
<input
|
| 294 |
+
type="text"
|
| 295 |
+
name="sexualHistory"
|
| 296 |
+
value={formData.sexualHistory}
|
| 297 |
+
onChange={handleInputChange}
|
| 298 |
+
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all text-sm"
|
| 299 |
+
placeholder="e.g. Sexually active, Duration of relationship, etc."
|
| 300 |
+
/>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<div>
|
| 304 |
+
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">HPV Status</label>
|
| 305 |
+
<div className="flex flex-wrap gap-2">
|
| 306 |
+
{['Positive', 'Negative', 'Unknown'].map(status => (
|
| 307 |
+
<label key={status} className="flex-1 min-w-[80px] text-center cursor-pointer">
|
| 308 |
+
<input type="radio" name="hpvStatus" value={status} checked={formData.hpvStatus === status} onChange={handleInputChange} className="peer sr-only" />
|
| 309 |
+
<div className="px-3 py-1.5 rounded-md text-sm font-medium bg-gray-50 text-gray-600 border border-gray-200 peer-checked:bg-[#0A2540] peer-checked:text-white peer-checked:border-[#0A2540] transition-all">
|
| 310 |
+
{status}
|
| 311 |
+
</div>
|
| 312 |
+
</label>
|
| 313 |
+
))}
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<div>
|
| 318 |
+
<label className="block text-sm font-semibold text-gray-500 uppercase mb-2">HPV Vaccination</label>
|
| 319 |
+
<div className="flex gap-4">
|
| 320 |
+
{['Yes', 'No', 'Unknown'].map(opt => (
|
| 321 |
+
<label key={opt} className="flex items-center gap-2 cursor-pointer">
|
| 322 |
+
<input type="radio" name="hpvVaccination" value={opt} checked={formData.hpvVaccination === opt} onChange={handleInputChange} className="w-4 h-4 text-[#4ECDC4] focus:ring-[#4ECDC4]" />
|
| 323 |
+
<span className="text-base text-gray-700">{opt}</span>
|
| 324 |
+
</label>
|
| 325 |
+
))}
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
<label className="flex items-start gap-3 p-4 rounded-lg border border-gray-100 hover:border-[#4ECDC4]/50 hover:bg-teal-50/10 cursor-pointer transition-all">
|
| 330 |
<div className="flex items-center h-5">
|
| 331 |
+
<input
|
| 332 |
+
type="checkbox"
|
| 333 |
+
name="postCoitalBleeding"
|
| 334 |
+
checked={formData.postCoitalBleeding}
|
| 335 |
+
onChange={handleInputChange}
|
| 336 |
+
className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
|
| 337 |
</div>
|
| 338 |
<div>
|
| 339 |
<span className="block text-base font-medium text-gray-900">
|
|
|
|
| 347 |
|
| 348 |
<label className="flex items-start gap-3 p-4 rounded-lg border border-gray-100 hover:border-[#4ECDC4]/50 hover:bg-teal-50/10 cursor-pointer transition-all">
|
| 349 |
<div className="flex items-center h-5">
|
| 350 |
+
<input
|
| 351 |
+
type="checkbox"
|
| 352 |
+
name="interMenstrualBleeding"
|
| 353 |
+
checked={formData.interMenstrualBleeding}
|
| 354 |
+
onChange={handleInputChange}
|
| 355 |
+
className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
|
| 356 |
</div>
|
| 357 |
<div>
|
| 358 |
<span className="block text-base font-medium text-gray-900">
|
|
|
|
| 366 |
|
| 367 |
<label className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:border-[#4ECDC4]/50 hover:bg-teal-50/10 cursor-pointer transition-all">
|
| 368 |
<div className="flex items-center h-5">
|
| 369 |
+
<input
|
| 370 |
+
type="checkbox"
|
| 371 |
+
name="persistentDischarge"
|
| 372 |
+
checked={formData.persistentDischarge}
|
| 373 |
+
onChange={handleInputChange}
|
| 374 |
+
className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
|
| 375 |
</div>
|
| 376 |
<div>
|
| 377 |
<span className="block text-sm font-medium text-gray-900">
|
|
|
|
| 403 |
|
| 404 |
<div className="p-8 space-y-6 flex-1">
|
| 405 |
<div>
|
| 406 |
+
<label className="block text-xs font-semibold text-gray-500 uppercase mb-2">Pap Smear Result</label>
|
|
|
|
|
|
|
| 407 |
<div className="space-y-2">
|
| 408 |
+
{['ASC-US', 'LSIL', 'HSIL', 'Normal', 'Not Done'].map(result => (
|
| 409 |
+
<label key={result} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 cursor-pointer border border-transparent hover:border-gray-100 transition-all group">
|
| 410 |
+
<span className="text-sm text-gray-700 group-hover:text-[#0A2540]">{result}</span>
|
| 411 |
+
<input type="radio" name="papSmearResult" value={result} checked={formData.papSmearResult === result} onChange={handleInputChange} className="w-4 h-4 text-[#4ECDC4] focus:ring-[#4ECDC4]" />
|
| 412 |
+
</label>
|
| 413 |
+
))}
|
| 414 |
</div>
|
| 415 |
</div>
|
| 416 |
|
| 417 |
<div>
|
| 418 |
+
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">HPV DNA (High-risk)</label>
|
| 419 |
+
<input type="text" name="hpvDnaTypes" value={formData.hpvDnaTypes} onChange={handleInputChange} className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#4ECDC4] focus:border-transparent outline-none transition-all" placeholder="e.g. Type 16, 18" />
|
|
|
|
|
|
|
| 420 |
</div>
|
| 421 |
|
| 422 |
<div>
|
|
|
|
| 451 |
|
| 452 |
<div className="p-8 space-y-6 flex-1">
|
| 453 |
<div>
|
| 454 |
+
<label className="block text-xs font-semibold text-gray-500 uppercase mb-3">Smoking History</label>
|
|
|
|
|
|
|
| 455 |
<div className="flex gap-2">
|
| 456 |
<label className="flex-1 cursor-pointer">
|
| 457 |
+
<input type="radio" name="smoking" value="Yes" checked={formData.smoking === 'Yes'} onChange={handleInputChange} className="peer sr-only" />
|
| 458 |
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 peer-checked:bg-rose-50 peer-checked:border-rose-200 peer-checked:text-rose-700 transition-all">
|
| 459 |
<span className="font-medium">Yes</span>
|
| 460 |
</div>
|
| 461 |
</label>
|
| 462 |
<label className="flex-1 cursor-pointer">
|
| 463 |
+
<input type="radio" name="smoking" value="No" checked={formData.smoking === 'No'} onChange={handleInputChange} className="peer sr-only" />
|
| 464 |
<div className="flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 peer-checked:bg-green-50 peer-checked:border-green-200 peer-checked:text-green-700 transition-all">
|
| 465 |
<span className="font-medium">No</span>
|
| 466 |
</div>
|
src/config/geminiConfig.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
/**
|
| 3 |
+
* ============================================================
|
| 4 |
+
* GEMINI AI CONFIG β Edit this file to tune the LLM behaviour
|
| 5 |
+
* ============================================================
|
| 6 |
+
*
|
| 7 |
+
* SYSTEM_PROMPT : The instruction sent to Gemini before any patient data.
|
| 8 |
+
* Change it to alter the AI's tone, style, or focus.
|
| 9 |
+
*
|
| 10 |
+
* DEMO_STEP_FINDINGS : Sample clinical data used automatically when no real
|
| 11 |
+
* observations have been filled in during the exam.
|
| 12 |
+
* Edit these to match a typical case at your clinic.
|
| 13 |
+
* ============================================================
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
// βββ 1. SYSTEM PROMPT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
// This is the high-level instruction that tells the LLM what role to play
|
| 18 |
+
// and how to format its output. Edit freely.
|
| 19 |
+
export const SYSTEM_PROMPT = `You are an expert colposcopy AI assistant acting as a specialist gynaecologist.
|
| 20 |
+
Analyse ALL the clinical data and the attached colposcopy images (native / acetic acid / green filter / Lugol) to generate a professional, evidence-based colposcopy report conclusion.
|
| 21 |
+
|
| 22 |
+
Key instructions:
|
| 23 |
+
- Integrate findings from ALL steps and images to reach a single clinical conclusion.
|
| 24 |
+
- Reference specific colposcopic features (e.g., acetowhite changes, iodine uptake, SCJ type, lesion grade) where relevant.
|
| 25 |
+
- Adhere to IFCPC (International Federation for Cervical Pathology and Colposcopy) terminology.
|
| 26 |
+
- Be concise but clinically precise; avoid vague language.
|
| 27 |
+
- Include Swede score interpretation in the colposcopic findings (if available).
|
| 28 |
+
- Return ONLY a valid JSON object with exactly these ten keys β no markdown, no extra text:
|
| 29 |
+
{
|
| 30 |
+
"examQuality": "<Adequate or Inadequate>",
|
| 31 |
+
"transformationZone": "<I, II, or III β based on SCJ type and visibility>",
|
| 32 |
+
"acetowL": "<Present or Absent β based on acetowhite reaction>",
|
| 33 |
+
"nativeFindings": "<summarized native examination findings in 2-3 sentences, include cervix visibility, SCJ, TZ type, and any suspicious features>",
|
| 34 |
+
"aceticFindings": "<summarized acetic acid findings in 2-3 sentences, include acetowhite categories and features observed>",
|
| 35 |
+
"biopsySites": "<recommend biopsy sites by clock position, e.g. 12 o'clock, 6 o'clock>",
|
| 36 |
+
"biopsyNotes": "<brief notes on biopsy: lesion grade, punch vs LLETZ, number of samples>",
|
| 37 |
+
"colposcopicFindings": "<professional colposcopic findings citing key features, grades, and Swede score interpretation if available, 3-4 sentences>",
|
| 38 |
+
"treatmentPlan": "<evidence-based treatment plan referencing current guidelines, 2-3 sentences>",
|
| 39 |
+
"followUp": "<follow-up schedule with specific timeframes and recommendations, 1-2 sentences>"
|
| 40 |
+
}`;
|
| 41 |
+
|
| 42 |
+
// βββ 2. DEMO / SAMPLE FINDINGS ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
// These are used when no real observations have been recorded during the exam.
|
| 44 |
+
// They let you test the AI-generated report without filling in every form field.
|
| 45 |
+
// Edit to match typical cases at your clinic.
|
| 46 |
+
export const DEMO_STEP_FINDINGS: Record<string, any> = {
|
| 47 |
+
native: {
|
| 48 |
+
cervixFullyVisible: 'Yes',
|
| 49 |
+
obscuredBy: { blood: false, inflammation: false, discharge: false, scarring: false },
|
| 50 |
+
adequacyNotes: '',
|
| 51 |
+
scjVisibility: 'Completely visible',
|
| 52 |
+
scjNotes: 'SCJ well-visualised; squamocolumnar junction at ectocervix',
|
| 53 |
+
tzType: 'TZ 2',
|
| 54 |
+
suspiciousAtNativeView: false,
|
| 55 |
+
obviousGrowths: false,
|
| 56 |
+
contactBleeding: false,
|
| 57 |
+
irregularSurface: true,
|
| 58 |
+
other: false,
|
| 59 |
+
additionalNotes: 'Irregular surface contour noted at anterior lip of cervix',
|
| 60 |
+
},
|
| 61 |
+
acetowhite: {
|
| 62 |
+
cervixFullyVisible: 'Yes',
|
| 63 |
+
scjVisibility: 'Completely visible',
|
| 64 |
+
tzType: 'TZ 2',
|
| 65 |
+
obviousGrowths: false,
|
| 66 |
+
contactBleeding: true,
|
| 67 |
+
irregularSurface: true,
|
| 68 |
+
other: false,
|
| 69 |
+
additionalNotes:
|
| 70 |
+
'Dense acetowhite lesion at 12 and 3 o\'clock positions with well-defined margin. Fine punctation present. Lesion extends to the endocervical canal.',
|
| 71 |
+
},
|
| 72 |
+
greenFilter: {
|
| 73 |
+
additionalNotes:
|
| 74 |
+
'Fine punctation and mosaic vascular pattern visible under green filter at the acetowhite area. No atypical vessels observed.',
|
| 75 |
+
},
|
| 76 |
+
lugol: {
|
| 77 |
+
additionalNotes:
|
| 78 |
+
'Iodine non-staining (mustard-yellow) area corresponding precisely to the acetowhite lesion region β consistent with loss of glycogen in abnormal epithelium (Schiller positive).',
|
| 79 |
+
},
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// βββ 3. CHATBOT SYSTEM PROMPT βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
+
// Governs how the AI assistant chat widget responds to clinician questions.
|
| 84 |
+
// Unlike SYSTEM_PROMPT above (which must return JSON), this one responds in
|
| 85 |
+
// plain conversational prose. Edit freely.
|
| 86 |
+
export const CHAT_SYSTEM_PROMPT = `You are Pathora AI β an expert colposcopy assistant embedded in the Pathora AI Colposcopy System.
|
| 87 |
+
|
| 88 |
+
== YOUR ROLE ==
|
| 89 |
+
You assist gynaecologists and colposcopy clinicians with:
|
| 90 |
+
- Interpreting colposcopic findings (acetowhite changes, vascular patterns, TZ type, IFCPC grading)
|
| 91 |
+
- Explaining examination techniques (acetic acid, Lugol's iodine, green filter, biopsy)
|
| 92 |
+
- Summarising evidence-based management guidelines (BSCCP, ASCCP, IFCPC)
|
| 93 |
+
- Answering questions about the Pathora software workflow and how to use any feature
|
| 94 |
+
|
| 95 |
+
== RESPONSE RULES ==
|
| 96 |
+
- Respond in clear, professional clinical language appropriate for a specialist gynaecologist.
|
| 97 |
+
- Keep answers concise β prefer bullet points using plain hyphens (-) for lists of findings or criteria.
|
| 98 |
+
- If asked something outside colposcopy / gynaecology / the Pathora software, politely redirect.
|
| 99 |
+
- Never fabricate patient data or give diagnoses without the clinician providing findings.
|
| 100 |
+
- Do not return JSON; respond in plain conversational text.
|
| 101 |
+
- NEVER use any markdown formatting. This means: no **bold**, no *italic*, no __underline__, no # headings, no backticks, no triple backticks. Write everything as plain text only. Emphasis should be expressed through word choice, not formatting symbols.
|
| 102 |
+
|
| 103 |
+
== PATHORA APPLICATION β COMPLETE WORKING CONTEXT ==
|
| 104 |
+
|
| 105 |
+
--- OVERVIEW ---
|
| 106 |
+
Pathora AI is a clinical-grade single-page web application (React + TypeScript + Vite + TailwindCSS) that guides gynaecologists through a structured cervical examination workflow. It captures, annotates, and compares colposcopy images, records step-by-step clinical observations, and uses Google Gemini AI to auto-generate a professional colposcopy report. All data is stored in localStorage (key: pathora_colpo_session). There is no backend server. Created by Team ManaLife AI Pvt Limited (www.manalifeai.com).
|
| 107 |
+
|
| 108 |
+
--- FULL USER WORKFLOW ---
|
| 109 |
+
Home β Patient Registry β Patient History β Guided Capture (5 exam steps) β Compare β Report β Complete
|
| 110 |
+
|
| 111 |
+
The 5 exam steps inside Guided Capture are:
|
| 112 |
+
Step 1 β Native (untreated cervix baseline)
|
| 113 |
+
Step 2 β Acetowhite / Acetic Acid (3-5% acetic acid applied)
|
| 114 |
+
Step 3 β Green Filter (vascular pattern assessment)
|
| 115 |
+
Step 4 β Lugol Iodine (Schiller test for iodine uptake)
|
| 116 |
+
Step 5 β Biopsy Marking (cervical clock + Swede Score)
|
| 117 |
+
|
| 118 |
+
Within Guided Capture there are also 4 horizontal stages shown at the top:
|
| 119 |
+
Capture β Annotate β Compare β Report
|
| 120 |
+
|
| 121 |
+
--- PAGES & WHAT EACH ONE DOES ---
|
| 122 |
+
|
| 123 |
+
HomePage: Landing page with a "Get Started" button navigating to Patient Registry.
|
| 124 |
+
|
| 125 |
+
PatientRegistry: Search for an existing patient by ID or add a new patient. Currently uses 3 in-memory demo patients (PT-2025-8492 Aisha Khan, P-10234 Sarah Johnson, P-10235 Emily Chen). Real patient selection writes patientInfo to sessionStore.
|
| 126 |
+
|
| 127 |
+
PatientHistoryPage / PatientHistoryForm: 4-section intake form:
|
| 128 |
+
1. Patient Profile β name, age, blood group, parity, menstrual status (Pre/Post-menopausal/Pregnant), HPV status (Positive/Negative/Unknown), HPV vaccination (Yes/No/Unknown), notes
|
| 129 |
+
2. Presenting Symptoms β checkboxes: post-coital bleeding, inter-menstrual/post-menopausal bleeding, persistent discharge; free-text notes
|
| 130 |
+
3. Screening History β PAP smear result (ASC-US/LSIL/HSIL/Normal/Not Done), HPV DNA types (high-risk), past procedures (Biopsy/LEEP/Cryotherapy/None), notes
|
| 131 |
+
4. Risk Factors β smoking (Yes/No), immunosuppression (HIV/Steroids/None), notes
|
| 132 |
+
Auto-saves to sessionStore.patientHistory on every keystroke.
|
| 133 |
+
|
| 134 |
+
GuidedCapturePage: The main examination hub. Switches between exam steps (native/acetowhite/greenFilter/lugol/biopsyMarking). Shows a live video feed (/live.mp4 demo). In each step, clinicians can:
|
| 135 |
+
- Capture images (screenshot of current frame or selected image)
|
| 136 |
+
- Record video clips
|
| 137 |
+
- Upload images/videos from disk
|
| 138 |
+
- Enter clinical observations in the ImagingObservations panel on the right
|
| 139 |
+
- For acetowhite step: also fill in the AceticFindingsForm (category+feature checkboxes)
|
| 140 |
+
- Switch to Annotate mode to draw on captured images
|
| 141 |
+
- Switch to Compare mode to compare images side-by-side
|
| 142 |
+
|
| 143 |
+
Acetowhite Timer: When the clinician clicks "I have applied acetic acid", a countdown timer starts (counts up in seconds). The UI flashes an overlay alert at 1 minute and 3 minutes. Timer resets when leaving the acetowhite step.
|
| 144 |
+
|
| 145 |
+
Lugol Timer: Same behaviour β starts when "I have applied Lugol's iodine" is clicked. Alerts at 1 minute and 3 minutes.
|
| 146 |
+
|
| 147 |
+
Green Filter: When the green filter toggle is ON, the live video gets CSS filter: saturate(0.3) hue-rotate(120deg) brightness(1.1). Captured images also have this same pixel-level transform applied via Canvas API to match exactly.
|
| 148 |
+
|
| 149 |
+
AI Assist (annotation mode): Clicking "AI Assist" loads a demo image (/AI_Demo_img.png) with 3 pre-placed AI annotations β green rectangle (Cervix), purple circle (SCJ), yellow polygon (OS). Clinicians can then confirm or edit these.
|
| 150 |
+
|
| 151 |
+
BiopsyMarking: Renders a cervical clock SVG overlay on top of the selected image. Steps:
|
| 152 |
+
1. Select a lesion type from a searchable dropdown (EC=Ectopy, TTZ=T.Trans Zone, ATZ=At.Trans Zone, L=Leukoplakia, AB=Abn.Vessel, NF=Nabo.Fol, XB=Biopsy Site, C=Condylomata, AW=Acetowhite, MO=Mosaic, GO=Gland Opening, PN=Punctation)
|
| 153 |
+
2. Click clock hour positions (1β12) to place or remove marks
|
| 154 |
+
3. Marks appear as colored arc segments on the SVG
|
| 155 |
+
4. Overlay is draggable and scalable via sliders
|
| 156 |
+
Also contains Swede Score Assessment (5 features Γ 0-2 score each = 0β10 total):
|
| 157 |
+
- Aceto Uptake (0=transparent, 1=shady/milky, 2=distinct opaque white)
|
| 158 |
+
- Margins & Surface (0=diffuse, 1=sharp irregular, 2=sharp even with surface level difference)
|
| 159 |
+
- Vessels (0=fine regular, 1=absent, 2=coarse or atypical)
|
| 160 |
+
- Lesion Size (0=<5mm, 1=5-15mm/2quadrants, 2=>15mm/3-4quadrants)
|
| 161 |
+
- Iodine Staining (0=brown, 1=faintly yellow, 2=distinct yellow)
|
| 162 |
+
Risk interpretation: β€4 Low Risk (routine follow-up), 5β7 Intermediate (consider colposcopy), β₯8 High Risk (recommend biopsy).
|
| 163 |
+
All data saved to sessionStore.biopsyMarkings.
|
| 164 |
+
|
| 165 |
+
Compare: Side-by-side image viewer. Images grouped by exam step in a sidebar. Clinicians drag images from sidebar into left/right drop zones. Supports zoom (50%β300%), pan, and downloading the comparison as a PNG file.
|
| 166 |
+
|
| 167 |
+
ReportPage: Final report as a printable A4 form. Sections:
|
| 168 |
+
- Patient Information (regNo, name, opdNo, age, parity, w/o)
|
| 169 |
+
- Clinical History (indication, complaint)
|
| 170 |
+
- Examination Findings (examQuality, transformationZone, acetowhite lesion)
|
| 171 |
+
- Examination Images (4 drag-and-drop buckets: native, acetic, lugol, biopsy)
|
| 172 |
+
- Diagnosis, Treatment Plan, Biopsy Sites/Notes, Follow-Up
|
| 173 |
+
- Doctor signature + date
|
| 174 |
+
On mount, automatically pre-fills from sessionStore (patient history, registry info, previous report data).
|
| 175 |
+
"Generate with AI" button sends all session data + up to 4 captured images to Gemini and fills the form with AI-generated clinical conclusions.
|
| 176 |
+
"Export PDF" button uses html2pdf.js to export the report as a PDF file.
|
| 177 |
+
|
| 178 |
+
--- SESSION DATA SCHEMA ---
|
| 179 |
+
Everything is stored in localStorage under key 'pathora_colpo_session':
|
| 180 |
+
patientInfo β { id, name, examDate }
|
| 181 |
+
patientHistory β name, age, bloodGroup, parity, menstrualStatus, sexualHistory, hpvStatus, hpvVaccination, patientProfileNotes, postCoitalBleeding, interMenstrualBleeding, persistentDischarge, symptomsNotes, papSmearResult, hpvDnaTypes, pastProcedures{biopsy/leep/cryotherapy/none}, screeningNotes, smoking, immunosuppression{hiv/steroids/none}, riskFactorsNotes
|
| 182 |
+
nativeFindings β cervixFullyVisible, obscuredBy{blood/inflammation/discharge/scarring}, adequacyNotes, scjVisibility, scjNotes, tzType, suspiciousAtNativeView, obviousGrowths, contactBleeding, irregularSurface, other, additionalNotes
|
| 183 |
+
aceticFindings β selectedCategories, selectedFindings, additionalNotes
|
| 184 |
+
stepFindings β { native: {...}, acetowhite: {...}, greenFilter: {...}, lugol: {...} }
|
| 185 |
+
biopsyMarkings β lesionMarks[{type,typeCode,clockHour,color}], swedeScores{acetoUptake,marginsAndSurface,vessels,lesionSize,iodineStaining}, totalSwedeScore, marksByType
|
| 186 |
+
reportFormData β all text/select fields from ReportPage form
|
| 187 |
+
sessionStarted β ISO timestamp
|
| 188 |
+
|
| 189 |
+
--- ImagingObservations FIELDS PER STEP ---
|
| 190 |
+
Shared fields (all steps): cervixFullyVisible (Yes/No), obscuredBy (blood/inflammation/discharge/scarring checkboxes), adequacyNotes, scjVisibility (Completely/Partially/Not visible), scjNotes, tzType (TZ1/TZ2/TZ3), additionalNotes
|
| 191 |
+
|
| 192 |
+
Native-specific: obviousGrowths, contactBleeding, irregularSurface, suspiciousAtNativeView, other
|
| 193 |
+
|
| 194 |
+
Acetowhite-specific: acetowhiteDensity (Dense/Faint/Absent), acetowhiteType (Geographic/Satellite/Cuffed glands/Other), acetowhiteMargins (Well-defined/Irregular/Indistinct), lesionExtent (<5mm/5-15mm/>15mm), vascularPattern (Mosaic/Punctation/Atypical vessels/None visible), contactBleeding, obviousGrowths
|
| 195 |
+
|
| 196 |
+
GreenFilter-specific: mosaicType (Fine/Coarse/Absent), punctationType (Fine/Coarse/Absent), atypicalVessels (checkbox), vascularNotes
|
| 197 |
+
|
| 198 |
+
Lugol-specific: iodineSatining (Brown-staining normal / Mustard-yellow abnormal / Partial mixed), schillerTest (Positive abnormal / Negative normal), lesionExtentLugol (<1 quadrant / 1-2 quadrants / >2 quadrants / Entire TZ)
|
| 199 |
+
|
| 200 |
+
--- AceticFindingsForm CATEGORIES ---
|
| 201 |
+
Thin Acetowhite Epithelium: Dull white, Appears slowly, Fades quickly
|
| 202 |
+
Borders: Irregular, Geographic, Feathered edges, Sharp, Raised, Rolled edges, Inner border sign
|
| 203 |
+
Vascular Pattern: Fine punctation, Fine mosaic, Coarse punctation, Coarse mosaic
|
| 204 |
+
Dense Acetowhite Epithelium: Chalky white, Oyster white, Greyish white, Rapid onset, Persists
|
| 205 |
+
Gland Openings: Cuffed, Enlarged crypt openings
|
| 206 |
+
Non-Specific Abnormal Findings: Leukoplakia (keratosis), Hyperkeratosis, Erosion
|
| 207 |
+
|
| 208 |
+
--- SIDEBAR NAVIGATION ---
|
| 209 |
+
The collapsible left sidebar has 6 items: Home, Patients, Capture, Annotate, Compare, Report. The sidebar is hidden only on the Home page. Clicking the arrow icon collapses/expands it.
|
| 210 |
+
|
| 211 |
+
--- CHATBOT (This Widget) ---
|
| 212 |
+
A floating chat widget in the bottom-right corner (teal button with chat icon). Clicking it opens a 480px-tall chat panel. The chatbot uses the Gemini REST API with full multi-turn conversation history. System prompt is this very document. The chatbot answers questions about: colposcopy findings interpretation, examination techniques, IFCPC grading, management guidelines, and the Pathora software workflow.
|
| 213 |
+
|
| 214 |
+
--- AI REPORT GENERATION ---
|
| 215 |
+
Triggered by "Generate with AI" on the ReportPage. Sends to Gemini:
|
| 216 |
+
- Full patient demographics
|
| 217 |
+
- Complete patient history (symptoms, screening, risk factors)
|
| 218 |
+
- Step-by-step clinical observations (native/acetowhite/greenFilter/lugol) β formatted as structured text
|
| 219 |
+
- Acetic acid clinical findings (category + feature checkboxes)
|
| 220 |
+
- Biopsy marking data and Swede Score breakdown
|
| 221 |
+
- Up to 4 captured images (one per exam step, as base64 inline data)
|
| 222 |
+
- If no real findings β uses demo sample data automatically
|
| 223 |
+
Returns strict JSON with 8 keys: examQuality, transformationZone, acetowL, biopsySites, biopsyNotes, diagnosis, treatmentPlan, followUp.
|
| 224 |
+
|
| 225 |
+
--- GREEN FILTER PROCESSING ---
|
| 226 |
+
Live feed: CSS filter saturate(0.3) hue-rotate(120deg) brightness(1.1) applied to the video element.
|
| 227 |
+
Captured images: same transform applied pixel-by-pixel via Canvas API (composed 3Γ3 color matrix: saturate Γ hue-rotate, then brightness Γ 1.1). This ensures the saved image exactly matches what the clinician saw on screen.
|
| 228 |
+
|
| 229 |
+
--- KEY DESIGN DECISIONS ---
|
| 230 |
+
1. No backend β all data in localStorage. New session = sessionStore.clear().
|
| 231 |
+
2. Gemini model auto-discovered at runtime by calling GET /v1beta/models β prefers any model name containing 'flash'.
|
| 232 |
+
3. Green filter is mathematically identical between live CSS and saved Canvas to ensure what-you-see = what-you-save.
|
| 233 |
+
4. Swede Score thresholds: β€4 low risk, 5-7 intermediate, β₯8 high risk.
|
| 234 |
+
5. Annotation AI Assist injects 3 demo annotations (Cervix rect, SCJ circle, OS polygon) to demonstrate AI capability.
|
| 235 |
+
6. PDF export replaces all form controls with plain divs before rendering to avoid html2canvas clipping issues, then restores them after.
|
| 236 |
+
|
| 237 |
+
== END OF APPLICATION CONTEXT ==`;
|
| 238 |
+
|
| 239 |
+
|
src/pages/AcetowhiteExamPage.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|
| 2 |
import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, Save, ChevronRight, Sparkles } from 'lucide-react';
|
| 3 |
import { AceticAnnotator, AceticAnnotatorHandle } from '../components/AceticAnnotator';
|
| 4 |
import { ImagingObservations } from '../components/ImagingObservations';
|
|
|
|
| 5 |
|
| 6 |
type CapturedItem = {
|
| 7 |
id: string;
|
|
@@ -27,6 +28,7 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 27 |
const [observations, setObservations] = useState({});
|
| 28 |
const [showExitWarning, setShowExitWarning] = useState(false);
|
| 29 |
const [isLiveAILoading, setIsLiveAILoading] = useState(false);
|
|
|
|
| 30 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; quality: string; confidence: number } | null>(null);
|
| 31 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 32 |
|
|
@@ -136,10 +138,6 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 136 |
}
|
| 137 |
};
|
| 138 |
|
| 139 |
-
const handleUploadClick = () => {
|
| 140 |
-
fileInputRef.current?.click();
|
| 141 |
-
};
|
| 142 |
-
|
| 143 |
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 144 |
const files = e.target.files;
|
| 145 |
if (files && files.length > 0) {
|
|
@@ -178,7 +176,7 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 178 |
|
| 179 |
const formData = new FormData();
|
| 180 |
formData.append('file', blob, 'image.jpg');
|
| 181 |
-
const backendResponse = await fetch('/infer/image', {
|
| 182 |
method: 'POST',
|
| 183 |
body: formData,
|
| 184 |
});
|
|
@@ -206,6 +204,18 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 206 |
}
|
| 207 |
};
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
const selectedItem = selectedImage
|
| 210 |
? capturedItems.find(item => item.id === selectedImage)
|
| 211 |
: null;
|
|
@@ -262,8 +272,9 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 262 |
</div>
|
| 263 |
|
| 264 |
{!selectedImage ? (
|
| 265 |
-
|
| 266 |
-
|
|
|
|
| 267 |
{/* Main Live Feed */}
|
| 268 |
<div className="lg:col-span-2 space-y-4">
|
| 269 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
|
@@ -487,31 +498,30 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 487 |
|
| 488 |
{/* Centered AI Assist Button */}
|
| 489 |
<button
|
| 490 |
-
onClick={
|
| 491 |
disabled={isLiveAILoading}
|
| 492 |
-
className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
>
|
|
|
|
|
|
|
|
|
|
| 494 |
<Sparkles className="w-6 h-6" />
|
| 495 |
-
{isLiveAILoading ? 'Checking...' : 'AI Assist'}
|
| 496 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
|
| 498 |
-
<div className="flex gap-2">
|
| 499 |
-
<button
|
| 500 |
-
onClick={handleUploadClick}
|
| 501 |
-
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-green-600 text-white font-semibold hover:bg-green-700 transition-colors"
|
| 502 |
-
>
|
| 503 |
-
Upload Image
|
| 504 |
-
</button>
|
| 505 |
-
<input
|
| 506 |
-
ref={fileInputRef}
|
| 507 |
-
type="file"
|
| 508 |
-
accept="image/*,video/*"
|
| 509 |
-
multiple
|
| 510 |
-
className="hidden"
|
| 511 |
-
onChange={handleFileUpload}
|
| 512 |
-
/>
|
| 513 |
-
</div>
|
| 514 |
-
|
| 515 |
{/* Live AI Results Panel */}
|
| 516 |
{liveAIResults && (
|
| 517 |
<div className="p-4 bg-green-50 border border-green-300 rounded-lg">
|
|
@@ -529,7 +539,7 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 529 |
</div>
|
| 530 |
</div>
|
| 531 |
)}
|
| 532 |
-
|
| 533 |
{liveAIError && (
|
| 534 |
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3 text-center">
|
| 535 |
{liveAIError}
|
|
@@ -621,51 +631,60 @@ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
|
|
| 621 |
</div>
|
| 622 |
</div>
|
| 623 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
) : (
|
| 625 |
// Selected Image Annotation View
|
| 626 |
selectedImage && (
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
<button
|
| 630 |
-
onClick={() => {
|
| 631 |
-
setSelectedImage(null);
|
| 632 |
-
setAnnotations([]);
|
| 633 |
-
setObservations({});
|
| 634 |
-
}}
|
| 635 |
-
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
| 636 |
-
>
|
| 637 |
-
<ArrowLeft className="w-4 h-4" />
|
| 638 |
-
Back to Live Feed
|
| 639 |
-
</button>
|
| 640 |
-
<div className="flex items-center gap-3">
|
| 641 |
<button
|
| 642 |
-
onClick={
|
| 643 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
>
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
<button onClick={onNext} className="px-6 py-2 bg-gray-600 text-white rounded-lg font-semibold hover:bg-slate-700 transition-colors flex items-center gap-2">
|
| 648 |
-
Next
|
| 649 |
-
<ArrowRight className="w-4 h-4" />
|
| 650 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
</div>
|
| 652 |
-
</div>
|
| 653 |
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
/>
|
| 662 |
-
|
| 663 |
-
</div>
|
| 664 |
-
<div className="lg:col-span-1">
|
| 665 |
-
<ImagingObservations onObservationsChange={setObservations} />
|
| 666 |
</div>
|
| 667 |
</div>
|
| 668 |
-
</div>
|
| 669 |
)
|
| 670 |
)}
|
| 671 |
|
|
|
|
| 2 |
import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, Save, ChevronRight, Sparkles } from 'lucide-react';
|
| 3 |
import { AceticAnnotator, AceticAnnotatorHandle } from '../components/AceticAnnotator';
|
| 4 |
import { ImagingObservations } from '../components/ImagingObservations';
|
| 5 |
+
import { AceticFindingsForm } from '../components/AceticFindingsForm';
|
| 6 |
|
| 7 |
type CapturedItem = {
|
| 8 |
id: string;
|
|
|
|
| 28 |
const [observations, setObservations] = useState({});
|
| 29 |
const [showExitWarning, setShowExitWarning] = useState(false);
|
| 30 |
const [isLiveAILoading, setIsLiveAILoading] = useState(false);
|
| 31 |
+
const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false);
|
| 32 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; quality: string; confidence: number } | null>(null);
|
| 33 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 34 |
|
|
|
|
| 138 |
}
|
| 139 |
};
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 142 |
const files = e.target.files;
|
| 143 |
if (files && files.length > 0) {
|
|
|
|
| 176 |
|
| 177 |
const formData = new FormData();
|
| 178 |
formData.append('file', blob, 'image.jpg');
|
| 179 |
+
const backendResponse = await fetch('http://localhost:8000/infer/image', {
|
| 180 |
method: 'POST',
|
| 181 |
body: formData,
|
| 182 |
});
|
|
|
|
| 204 |
}
|
| 205 |
};
|
| 206 |
|
| 207 |
+
const handleAIAssistToggle = async () => {
|
| 208 |
+
if (isLiveAILoading) return;
|
| 209 |
+
if (isAIAssistEnabled) {
|
| 210 |
+
setIsAIAssistEnabled(false);
|
| 211 |
+
setLiveAIResults(null);
|
| 212 |
+
setLiveAIError(null);
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
setIsAIAssistEnabled(true);
|
| 216 |
+
await handleLiveAIAssist();
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
const selectedItem = selectedImage
|
| 220 |
? capturedItems.find(item => item.id === selectedImage)
|
| 221 |
: null;
|
|
|
|
| 272 |
</div>
|
| 273 |
|
| 274 |
{!selectedImage ? (
|
| 275 |
+
<>
|
| 276 |
+
{/* Live Feed View */}
|
| 277 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 278 |
{/* Main Live Feed */}
|
| 279 |
<div className="lg:col-span-2 space-y-4">
|
| 280 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
|
|
|
| 498 |
|
| 499 |
{/* Centered AI Assist Button */}
|
| 500 |
<button
|
| 501 |
+
onClick={handleAIAssistToggle}
|
| 502 |
disabled={isLiveAILoading}
|
| 503 |
+
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-lg text-white font-bold transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed text-base ${
|
| 504 |
+
isAIAssistEnabled
|
| 505 |
+
? 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700'
|
| 506 |
+
: 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800'
|
| 507 |
+
}`}
|
| 508 |
+
title={isAIAssistEnabled ? 'AI Assist is ON' : 'Run AI model to check quality'}
|
| 509 |
>
|
| 510 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 511 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 512 |
+
</div>
|
| 513 |
<Sparkles className="w-6 h-6" />
|
| 514 |
+
{isLiveAILoading ? 'Checking...' : (isAIAssistEnabled ? 'AI Assist On' : 'AI Assist')}
|
| 515 |
</button>
|
| 516 |
+
<input
|
| 517 |
+
ref={fileInputRef}
|
| 518 |
+
type="file"
|
| 519 |
+
accept="image/*,video/*"
|
| 520 |
+
multiple
|
| 521 |
+
className="hidden"
|
| 522 |
+
onChange={handleFileUpload}
|
| 523 |
+
/>
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
{/* Live AI Results Panel */}
|
| 526 |
{liveAIResults && (
|
| 527 |
<div className="p-4 bg-green-50 border border-green-300 rounded-lg">
|
|
|
|
| 539 |
</div>
|
| 540 |
</div>
|
| 541 |
)}
|
| 542 |
+
|
| 543 |
{liveAIError && (
|
| 544 |
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3 text-center">
|
| 545 |
{liveAIError}
|
|
|
|
| 631 |
</div>
|
| 632 |
</div>
|
| 633 |
</div>
|
| 634 |
+
|
| 635 |
+
{/* Clinical Findings Section */}
|
| 636 |
+
<div className="w-full mt-6">
|
| 637 |
+
<AceticFindingsForm />
|
| 638 |
+
</div>
|
| 639 |
+
</>
|
| 640 |
) : (
|
| 641 |
// Selected Image Annotation View
|
| 642 |
selectedImage && (
|
| 643 |
+
<div>
|
| 644 |
+
<div className="mb-4 flex items-center justify-between">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
<button
|
| 646 |
+
onClick={() => {
|
| 647 |
+
setSelectedImage(null);
|
| 648 |
+
setAnnotations([]);
|
| 649 |
+
setObservations({});
|
| 650 |
+
}}
|
| 651 |
+
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
| 652 |
>
|
| 653 |
+
<ArrowLeft className="w-4 h-4" />
|
| 654 |
+
Back to Live Feed
|
|
|
|
|
|
|
|
|
|
| 655 |
</button>
|
| 656 |
+
<div className="flex items-center gap-3">
|
| 657 |
+
<button
|
| 658 |
+
onClick={handleSaveAnnotations}
|
| 659 |
+
className="px-6 py-2 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors"
|
| 660 |
+
>
|
| 661 |
+
Save Annotations
|
| 662 |
+
</button>
|
| 663 |
+
<button onClick={onNext} className="px-6 py-2 bg-gray-600 text-white rounded-lg font-semibold hover:bg-slate-700 transition-colors flex items-center gap-2">
|
| 664 |
+
Next
|
| 665 |
+
<ArrowRight className="w-4 h-4" />
|
| 666 |
+
</button>
|
| 667 |
+
</div>
|
| 668 |
</div>
|
|
|
|
| 669 |
|
| 670 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 671 |
+
<div className="lg:col-span-2">
|
| 672 |
+
{selectedItem && (
|
| 673 |
+
<AceticAnnotator
|
| 674 |
+
ref={annotatorRef}
|
| 675 |
+
imageUrl={selectedItem.url}
|
| 676 |
+
onAnnotationsChange={setAnnotations}
|
| 677 |
+
/>
|
| 678 |
+
)}
|
| 679 |
+
</div>
|
| 680 |
+
<div className="lg:col-span-1">
|
| 681 |
+
<ImagingObservations
|
| 682 |
+
onObservationsChange={setObservations}
|
| 683 |
+
stepId="acetowhite"
|
| 684 |
/>
|
| 685 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 686 |
</div>
|
| 687 |
</div>
|
|
|
|
| 688 |
)
|
| 689 |
)}
|
| 690 |
|
src/pages/BiopsyMarking.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import React, { useState, useRef } from 'react';
|
| 2 |
import { ArrowLeft, ArrowRight, Undo2, Trash2, Upload, Image, Video, ZoomIn, ZoomOut } from 'lucide-react';
|
|
|
|
| 3 |
|
| 4 |
// Simple UI Component replacements
|
| 5 |
const Button: React.FC<any> = ({ children, onClick, disabled, variant, size, className, ...props }) => {
|
|
@@ -91,6 +92,49 @@ const BiopsyMarking: React.FC<BiopsyMarkingProps> = ({ onBack, onNext, capturedI
|
|
| 91 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 92 |
const imageContainerRef = useRef<HTMLDivElement>(null);
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
const filteredLesionTypes = LESION_TYPES.filter(type =>
|
| 95 |
type.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 96 |
type.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
@@ -253,6 +297,8 @@ const BiopsyMarking: React.FC<BiopsyMarkingProps> = ({ onBack, onNext, capturedI
|
|
| 253 |
|
| 254 |
const scoredFeatures = Object.values(swedeScores).filter(score => score !== null).length;
|
| 255 |
|
|
|
|
|
|
|
| 256 |
return (
|
| 257 |
<div className="h-full flex flex-col bg-gradient-to-br from-slate-50 to-slate-100 overflow-hidden">
|
| 258 |
<div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm shrink-0">
|
|
@@ -261,6 +307,12 @@ const BiopsyMarking: React.FC<BiopsyMarkingProps> = ({ onBack, onNext, capturedI
|
|
| 261 |
Back
|
| 262 |
</Button>
|
| 263 |
<h2 className="text-lg font-semibold text-slate-800">Biopsy Marking</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
<div className="ml-auto flex items-center gap-2">
|
| 265 |
<Button
|
| 266 |
variant="outline"
|
|
@@ -284,7 +336,7 @@ const BiopsyMarking: React.FC<BiopsyMarkingProps> = ({ onBack, onNext, capturedI
|
|
| 284 |
</Button>
|
| 285 |
<button
|
| 286 |
onClick={handleUpload}
|
| 287 |
-
className="h-8 px-3 bg-
|
| 288 |
>
|
| 289 |
<Upload className="h-4 w-4" />
|
| 290 |
Upload
|
|
@@ -488,23 +540,14 @@ const BiopsyMarking: React.FC<BiopsyMarkingProps> = ({ onBack, onNext, capturedI
|
|
| 488 |
|
| 489 |
{/* Images Card */}
|
| 490 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 491 |
-
<
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
</button>
|
| 500 |
-
<input
|
| 501 |
-
ref={fileInputRef}
|
| 502 |
-
type="file"
|
| 503 |
-
accept="image/*,video/*"
|
| 504 |
-
className="hidden"
|
| 505 |
-
onChange={handleFileChange}
|
| 506 |
-
/>
|
| 507 |
-
</div>
|
| 508 |
|
| 509 |
<div className="space-y-3">
|
| 510 |
{Object.entries(groupedImages).map(([stepId, images]) => (
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
import { ArrowLeft, ArrowRight, Undo2, Trash2, Upload, Image, Video, ZoomIn, ZoomOut } from 'lucide-react';
|
| 3 |
+
import { sessionStore } from '../store/sessionStore';
|
| 4 |
|
| 5 |
// Simple UI Component replacements
|
| 6 |
const Button: React.FC<any> = ({ children, onClick, disabled, variant, size, className, ...props }) => {
|
|
|
|
| 92 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 93 |
const imageContainerRef = useRef<HTMLDivElement>(null);
|
| 94 |
|
| 95 |
+
// Load previously saved biopsy markings and Swede scores from sessionStore on mount
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
const session = sessionStore.get();
|
| 98 |
+
if (session.biopsyMarkings) {
|
| 99 |
+
const bm = session.biopsyMarkings;
|
| 100 |
+
if (bm.lesionMarks && bm.lesionMarks.length > 0) {
|
| 101 |
+
setLesionMarks(bm.lesionMarks as LesionMark[]);
|
| 102 |
+
}
|
| 103 |
+
if (bm.swedeScores) {
|
| 104 |
+
setSwedeScores(bm.swedeScores);
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
// Calculate total Swede score
|
| 110 |
+
const calculateTotalSwedeScore = (scores: SwedeScoreFeatures): number => {
|
| 111 |
+
return Object.values(scores).reduce((sum, score) => sum + (score !== null ? score : 0), 0);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
// Save biopsy markings and Swede scores to sessionStore whenever they change
|
| 115 |
+
useEffect(() => {
|
| 116 |
+
const totalSwedeScore = calculateTotalSwedeScore(swedeScores);
|
| 117 |
+
const marksByTypeLocal = lesionMarks.reduce((acc, mark) => {
|
| 118 |
+
const key = mark.typeCode;
|
| 119 |
+
if (!acc[key]) {
|
| 120 |
+
acc[key] = { type: mark.type, hours: [] };
|
| 121 |
+
}
|
| 122 |
+
if (!acc[key].hours.includes(mark.clockHour)) {
|
| 123 |
+
acc[key].hours.push(mark.clockHour);
|
| 124 |
+
}
|
| 125 |
+
return acc;
|
| 126 |
+
}, {} as Record<string, { type: string; hours: number[] }>);
|
| 127 |
+
|
| 128 |
+
sessionStore.merge({
|
| 129 |
+
biopsyMarkings: {
|
| 130 |
+
lesionMarks,
|
| 131 |
+
swedeScores,
|
| 132 |
+
totalSwedeScore,
|
| 133 |
+
marksByType: marksByTypeLocal
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
}, [lesionMarks, swedeScores]);
|
| 137 |
+
|
| 138 |
const filteredLesionTypes = LESION_TYPES.filter(type =>
|
| 139 |
type.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 140 |
type.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
|
|
| 297 |
|
| 298 |
const scoredFeatures = Object.values(swedeScores).filter(score => score !== null).length;
|
| 299 |
|
| 300 |
+
const patientId = sessionStore.get().patientInfo?.id;
|
| 301 |
+
|
| 302 |
return (
|
| 303 |
<div className="h-full flex flex-col bg-gradient-to-br from-slate-50 to-slate-100 overflow-hidden">
|
| 304 |
<div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm shrink-0">
|
|
|
|
| 307 |
Back
|
| 308 |
</Button>
|
| 309 |
<h2 className="text-lg font-semibold text-slate-800">Biopsy Marking</h2>
|
| 310 |
+
{patientId && (
|
| 311 |
+
<div className="flex items-center gap-2 px-3 py-1 bg-blue-50 border border-blue-200 rounded-full text-xs font-mono font-semibold text-[#0A2540]">
|
| 312 |
+
<span>ID:</span>
|
| 313 |
+
<span>{patientId}</span>
|
| 314 |
+
</div>
|
| 315 |
+
)}
|
| 316 |
<div className="ml-auto flex items-center gap-2">
|
| 317 |
<Button
|
| 318 |
variant="outline"
|
|
|
|
| 336 |
</Button>
|
| 337 |
<button
|
| 338 |
onClick={handleUpload}
|
| 339 |
+
className="h-8 px-3 bg-[#05998c] text-white hover:bg-[#047569] rounded transition-colors flex items-center gap-2"
|
| 340 |
>
|
| 341 |
<Upload className="h-4 w-4" />
|
| 342 |
Upload
|
|
|
|
| 540 |
|
| 541 |
{/* Images Card */}
|
| 542 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 543 |
+
<h3 className="font-bold text-[#0A2540] mb-4">Images</h3>
|
| 544 |
+
<input
|
| 545 |
+
ref={fileInputRef}
|
| 546 |
+
type="file"
|
| 547 |
+
accept="image/*,video/*"
|
| 548 |
+
className="hidden"
|
| 549 |
+
onChange={handleFileChange}
|
| 550 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
<div className="space-y-3">
|
| 553 |
{Object.entries(groupedImages).map(([stepId, images]) => (
|
src/pages/Compare.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import { useState,
|
| 2 |
import { ArrowLeft, X, ZoomIn, ZoomOut, Download, RotateCcw } from 'lucide-react';
|
|
|
|
| 3 |
|
| 4 |
type CapturedImage = {
|
| 5 |
id: string;
|
|
@@ -38,6 +39,7 @@ const stepColors: Record<string, string> = {
|
|
| 38 |
export function Compare({ onBack, onNext, capturedImages }: Props) {
|
| 39 |
const [leftImage, setLeftImage] = useState<CompareImage | null>(null);
|
| 40 |
const [rightImage, setRightImage] = useState<CompareImage | null>(null);
|
|
|
|
| 41 |
const [zoomLevel, setZoomLevel] = useState(1);
|
| 42 |
const [draggedImageData, setDraggedImageData] = useState<string | null>(null);
|
| 43 |
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
|
@@ -45,6 +47,7 @@ export function Compare({ onBack, onNext, capturedImages }: Props) {
|
|
| 45 |
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
| 46 |
const leftImageRef = useRef<HTMLDivElement>(null);
|
| 47 |
const rightImageRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 48 |
|
| 49 |
// Group images by step
|
| 50 |
const imagesByStep = capturedImages.reduce((acc, img) => {
|
|
@@ -193,6 +196,12 @@ export function Compare({ onBack, onNext, capturedImages }: Props) {
|
|
| 193 |
<ArrowLeft className="w-5 h-5 text-gray-700" />
|
| 194 |
</button>
|
| 195 |
<h1 className="text-2xl font-bold text-gray-900">Image Comparison</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
<div className="flex items-center gap-3">
|
| 198 |
<button
|
|
@@ -413,6 +422,19 @@ export function Compare({ onBack, onNext, capturedImages }: Props) {
|
|
| 413 |
</div>
|
| 414 |
)}
|
| 415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
{/* Info Box */}
|
| 417 |
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
| 418 |
<p className="text-xs text-blue-700">
|
|
|
|
| 1 |
+
import { useState, useRef } from 'react';
|
| 2 |
import { ArrowLeft, X, ZoomIn, ZoomOut, Download, RotateCcw } from 'lucide-react';
|
| 3 |
+
import { sessionStore } from '../store/sessionStore';
|
| 4 |
|
| 5 |
type CapturedImage = {
|
| 6 |
id: string;
|
|
|
|
| 39 |
export function Compare({ onBack, onNext, capturedImages }: Props) {
|
| 40 |
const [leftImage, setLeftImage] = useState<CompareImage | null>(null);
|
| 41 |
const [rightImage, setRightImage] = useState<CompareImage | null>(null);
|
| 42 |
+
const [additionalNotes, setAdditionalNotes] = useState('');
|
| 43 |
const [zoomLevel, setZoomLevel] = useState(1);
|
| 44 |
const [draggedImageData, setDraggedImageData] = useState<string | null>(null);
|
| 45 |
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
|
|
|
| 47 |
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
| 48 |
const leftImageRef = useRef<HTMLDivElement>(null);
|
| 49 |
const rightImageRef = useRef<HTMLDivElement>(null);
|
| 50 |
+
const patientId = sessionStore.get().patientInfo?.id;
|
| 51 |
|
| 52 |
// Group images by step
|
| 53 |
const imagesByStep = capturedImages.reduce((acc, img) => {
|
|
|
|
| 196 |
<ArrowLeft className="w-5 h-5 text-gray-700" />
|
| 197 |
</button>
|
| 198 |
<h1 className="text-2xl font-bold text-gray-900">Image Comparison</h1>
|
| 199 |
+
{patientId && (
|
| 200 |
+
<div className="flex items-center gap-2 px-4 py-1.5 bg-purple-50 border border-purple-200 rounded-full text-sm font-mono font-semibold text-[#0A2540]">
|
| 201 |
+
<span>ID:</span>
|
| 202 |
+
<span>{patientId}</span>
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
</div>
|
| 206 |
<div className="flex items-center gap-3">
|
| 207 |
<button
|
|
|
|
| 422 |
</div>
|
| 423 |
)}
|
| 424 |
|
| 425 |
+
<div className="mt-4 pt-4 border-t border-gray-200">
|
| 426 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 427 |
+
Additional Notes
|
| 428 |
+
</label>
|
| 429 |
+
<textarea
|
| 430 |
+
value={additionalNotes}
|
| 431 |
+
onChange={(e) => setAdditionalNotes(e.target.value)}
|
| 432 |
+
rows={4}
|
| 433 |
+
placeholder="Add comparison notes..."
|
| 434 |
+
className="w-full px-3 py-2 text-sm bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent outline-none transition-all resize-none"
|
| 435 |
+
/>
|
| 436 |
+
</div>
|
| 437 |
+
|
| 438 |
{/* Info Box */}
|
| 439 |
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
| 440 |
<p className="text-xs text-blue-700">
|
src/pages/GreenFilterPage.tsx
CHANGED
|
@@ -28,6 +28,7 @@ export function GreenFilterPage({ goBack, onNext }: Props) {
|
|
| 28 |
const [showExitWarning, setShowExitWarning] = useState(false);
|
| 29 |
const [greenApplied, setGreenApplied] = useState(false);
|
| 30 |
const [isLiveAILoading, setIsLiveAILoading] = useState(false);
|
|
|
|
| 31 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; quality: string; confidence: number } | null>(null);
|
| 32 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 33 |
const [displayedImageUrl, setDisplayedImageUrl] = useState<string>("/C87Aceto_(1).jpg");
|
|
@@ -231,7 +232,7 @@ export function GreenFilterPage({ goBack, onNext }: Props) {
|
|
| 231 |
|
| 232 |
const formData = new FormData();
|
| 233 |
formData.append('file', blob, 'image.jpg');
|
| 234 |
-
const backendResponse = await fetch('/infer/image', {
|
| 235 |
method: 'POST',
|
| 236 |
body: formData,
|
| 237 |
});
|
|
@@ -259,6 +260,18 @@ export function GreenFilterPage({ goBack, onNext }: Props) {
|
|
| 259 |
}
|
| 260 |
};
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
return (
|
| 263 |
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50 p-4 md:p-6">
|
| 264 |
{/* Header */}
|
|
@@ -379,7 +392,7 @@ export function GreenFilterPage({ goBack, onNext }: Props) {
|
|
| 379 |
|
| 380 |
<button
|
| 381 |
onClick={handleUploadClick}
|
| 382 |
-
className="flex items-center gap-2 px-6 py-3 bg-
|
| 383 |
>
|
| 384 |
<Upload className="w-5 h-5" />
|
| 385 |
Upload
|
|
@@ -396,12 +409,20 @@ export function GreenFilterPage({ goBack, onNext }: Props) {
|
|
| 396 |
|
| 397 |
{/* Centered AI Assist Button */}
|
| 398 |
<button
|
| 399 |
-
onClick={
|
| 400 |
disabled={isLiveAILoading}
|
| 401 |
-
className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
>
|
|
|
|
|
|
|
|
|
|
| 403 |
<Sparkles className="w-6 h-6" />
|
| 404 |
-
{isLiveAILoading ? 'Checking...' : 'AI Assist'}
|
| 405 |
</button>
|
| 406 |
|
| 407 |
{/* Live AI Results Panel */}
|
|
@@ -454,7 +475,7 @@ export function GreenFilterPage({ goBack, onNext }: Props) {
|
|
| 454 |
imageUrl={displayedImageUrl}
|
| 455 |
onAnnotationsChange={handleAnnotationsChange}
|
| 456 |
/>
|
| 457 |
-
<ImagingObservations />
|
| 458 |
</div>
|
| 459 |
)}
|
| 460 |
</div>
|
|
|
|
| 28 |
const [showExitWarning, setShowExitWarning] = useState(false);
|
| 29 |
const [greenApplied, setGreenApplied] = useState(false);
|
| 30 |
const [isLiveAILoading, setIsLiveAILoading] = useState(false);
|
| 31 |
+
const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false);
|
| 32 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; quality: string; confidence: number } | null>(null);
|
| 33 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 34 |
const [displayedImageUrl, setDisplayedImageUrl] = useState<string>("/C87Aceto_(1).jpg");
|
|
|
|
| 232 |
|
| 233 |
const formData = new FormData();
|
| 234 |
formData.append('file', blob, 'image.jpg');
|
| 235 |
+
const backendResponse = await fetch('http://localhost:8000/infer/image', {
|
| 236 |
method: 'POST',
|
| 237 |
body: formData,
|
| 238 |
});
|
|
|
|
| 260 |
}
|
| 261 |
};
|
| 262 |
|
| 263 |
+
const handleAIAssistToggle = async () => {
|
| 264 |
+
if (isLiveAILoading) return;
|
| 265 |
+
if (isAIAssistEnabled) {
|
| 266 |
+
setIsAIAssistEnabled(false);
|
| 267 |
+
setLiveAIResults(null);
|
| 268 |
+
setLiveAIError(null);
|
| 269 |
+
return;
|
| 270 |
+
}
|
| 271 |
+
setIsAIAssistEnabled(true);
|
| 272 |
+
await handleGreenFilterMainAIAssist();
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
return (
|
| 276 |
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50 p-4 md:p-6">
|
| 277 |
{/* Header */}
|
|
|
|
| 392 |
|
| 393 |
<button
|
| 394 |
onClick={handleUploadClick}
|
| 395 |
+
className="flex items-center gap-2 px-6 py-3 bg-[#05998c] text-white rounded-lg hover:bg-[#047569] transition-colors shadow-md font-semibold"
|
| 396 |
>
|
| 397 |
<Upload className="w-5 h-5" />
|
| 398 |
Upload
|
|
|
|
| 409 |
|
| 410 |
{/* Centered AI Assist Button */}
|
| 411 |
<button
|
| 412 |
+
onClick={handleAIAssistToggle}
|
| 413 |
disabled={isLiveAILoading}
|
| 414 |
+
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-lg text-white font-bold transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed text-base ${
|
| 415 |
+
isAIAssistEnabled
|
| 416 |
+
? 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700'
|
| 417 |
+
: 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800'
|
| 418 |
+
}`}
|
| 419 |
+
title={isAIAssistEnabled ? 'AI Assist is ON' : 'Run AI model to check quality'}
|
| 420 |
>
|
| 421 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 422 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 423 |
+
</div>
|
| 424 |
<Sparkles className="w-6 h-6" />
|
| 425 |
+
{isLiveAILoading ? 'Checking...' : (isAIAssistEnabled ? 'AI Assist On' : 'AI Assist')}
|
| 426 |
</button>
|
| 427 |
|
| 428 |
{/* Live AI Results Panel */}
|
|
|
|
| 475 |
imageUrl={displayedImageUrl}
|
| 476 |
onAnnotationsChange={handleAnnotationsChange}
|
| 477 |
/>
|
| 478 |
+
<ImagingObservations stepId="greenFilter" />
|
| 479 |
</div>
|
| 480 |
)}
|
| 481 |
</div>
|
src/pages/GuidedCapturePage.tsx
CHANGED
|
@@ -3,10 +3,12 @@ import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edi
|
|
| 3 |
import { ImageAnnotator, type ImageAnnotatorHandle } from '../components/ImageAnnotator';
|
| 4 |
import { AceticAnnotator, type AceticAnnotatorHandle } from '../components/AceticAnnotator';
|
| 5 |
import { ImagingObservations } from '../components/ImagingObservations';
|
|
|
|
| 6 |
import { BiopsyMarking, type BiopsyCapturedImage } from './BiopsyMarking';
|
| 7 |
import { Compare } from './Compare';
|
| 8 |
import { ReportPage } from './ReportPage';
|
| 9 |
import { applyGreenFilter } from '../utils/filterUtils';
|
|
|
|
| 10 |
|
| 11 |
// Simple UI Component replacements
|
| 12 |
const Button: React.FC<any> = ({ children, onClick, disabled, variant, size, className, ...props }) => {
|
|
@@ -57,7 +59,6 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 57 |
const [_observations, setObservations] = useState({});
|
| 58 |
const [isAnnotatingMode, setIsAnnotatingMode] = useState(false);
|
| 59 |
const [isCompareMode, setIsCompareMode] = useState(false);
|
| 60 |
-
const [isLiveAILoading] = useState(false);
|
| 61 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; detectionConfidence: number; quality: string; qualityConfidence: number } | null>(null);
|
| 62 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 63 |
const [isContinuousAIEnabled, setIsContinuousAIEnabled] = useState(false);
|
|
@@ -243,7 +244,7 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 243 |
const formData = new FormData();
|
| 244 |
formData.append('file', blob, 'frame.jpg');
|
| 245 |
|
| 246 |
-
const response = await fetch('/infer/image', {
|
| 247 |
method: 'POST',
|
| 248 |
body: formData,
|
| 249 |
});
|
|
@@ -507,11 +508,85 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 507 |
};
|
| 508 |
|
| 509 |
const handleMainAIAssist = () => {
|
| 510 |
-
// Toggle continuous AI assist for live feed
|
| 511 |
-
|
| 512 |
-
|
| 513 |
setLiveAIError(null);
|
| 514 |
setLiveAIResults(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
}
|
| 516 |
};
|
| 517 |
|
|
@@ -553,10 +628,22 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 553 |
}
|
| 554 |
}, [capturedItems, onCapturedImagesChange]);
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
// Notify parent component when mode changes
|
| 557 |
useEffect(() => {
|
| 558 |
if (onModeChange) {
|
| 559 |
-
if (
|
|
|
|
|
|
|
| 560 |
onModeChange('compare');
|
| 561 |
} else if (isAnnotatingMode) {
|
| 562 |
onModeChange('annotation');
|
|
@@ -564,14 +651,26 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 564 |
onModeChange('capture');
|
| 565 |
}
|
| 566 |
}
|
| 567 |
-
}, [isCompareMode, isAnnotatingMode, onModeChange]);
|
|
|
|
|
|
|
| 568 |
|
| 569 |
return (
|
| 570 |
<div className="w-full bg-white/95 relative">
|
| 571 |
<div className="relative z-10 py-4 md:py-6 lg:py-8">
|
| 572 |
<div className="w-full max-w-7xl mx-auto px-4 md:px-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
|
| 574 |
{/* Page Header */}
|
|
|
|
| 575 |
<div className="mb-4 md:mb-6">
|
| 576 |
{/* Progress Bar - Capture / Annotate / Compare / Report */}
|
| 577 |
<div className="mb-4 flex gap-1 md:gap-2">
|
|
@@ -579,10 +678,9 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 579 |
<div key={stage} className="flex items-center flex-1">
|
| 580 |
<div
|
| 581 |
className={`flex-1 py-2 px-2 md:px-3 rounded-3xl font-medium text-sm md:text-base transition-all border-2 border-[#05998c] cursor-default pointer-events-none ${
|
| 582 |
-
(stage === 'Capture' && !selectedImage && !isAnnotatingMode && !isCompareMode
|
| 583 |
-
(stage === 'Annotate' && (selectedImage || isAnnotatingMode) && !isCompareMode
|
| 584 |
-
(stage === 'Compare' && isCompareMode
|
| 585 |
-
(stage === 'Report' && currentStep === 'report')
|
| 586 |
? 'bg-[#05998c] text-white shadow-md'
|
| 587 |
: 'bg-gray-100 text-gray-600'
|
| 588 |
}`}
|
|
@@ -633,9 +731,10 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 633 |
</div>
|
| 634 |
)}
|
| 635 |
</div>
|
|
|
|
| 636 |
|
| 637 |
{/* Back and Next Navigation for Guided Capture Steps */}
|
| 638 |
-
{currentStep !== 'biopsyMarking' && !isAnnotatingMode && !isCompareMode && (
|
| 639 |
<div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm mb-4">
|
| 640 |
<Button
|
| 641 |
variant="ghost"
|
|
@@ -657,21 +756,24 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 657 |
</h2>
|
| 658 |
<button
|
| 659 |
onClick={handleMainAIAssist}
|
| 660 |
-
disabled={
|
| 661 |
className={`px-6 py-3 rounded-lg transition-all font-semibold flex items-center justify-center gap-2 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed min-w-max ${
|
| 662 |
-
isContinuousAIEnabled
|
| 663 |
? 'bg-gradient-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 animate-pulse'
|
| 664 |
: 'bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800'
|
| 665 |
}`}
|
| 666 |
-
title={isContinuousAIEnabled ? 'Continuous AI quality checking is ON' : 'Enable continuous AI quality checking for live feed'}
|
| 667 |
>
|
|
|
|
|
|
|
|
|
|
| 668 |
<Sparkles className="h-5 w-5" />
|
| 669 |
{isContinuousAIEnabled ? 'AI Live β' : 'AI Assist'}
|
| 670 |
</button>
|
| 671 |
<div className="flex items-center gap-2">
|
| 672 |
<button
|
| 673 |
onClick={handleUploadClick}
|
| 674 |
-
className="h-8 px-3 bg-
|
| 675 |
>
|
| 676 |
<Upload className="h-4 w-4" />
|
| 677 |
Upload
|
|
@@ -701,61 +803,6 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 701 |
</div>
|
| 702 |
)}
|
| 703 |
|
| 704 |
-
{/* Quality Results Display */}
|
| 705 |
-
{liveAIResults && !isAnnotatingMode && !isCompareMode && currentStep !== 'report' && currentStep !== 'biopsyMarking' && (
|
| 706 |
-
<div className={`mb-4 p-4 rounded-lg border-2 transition-all ${
|
| 707 |
-
isContinuousAIEnabled
|
| 708 |
-
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300'
|
| 709 |
-
: 'bg-green-50 border-green-200'
|
| 710 |
-
}`}>
|
| 711 |
-
<div className="flex items-start gap-3">
|
| 712 |
-
<div className="flex-1">
|
| 713 |
-
<h3 className="font-semibold text-green-900 mb-3 flex items-center gap-2">
|
| 714 |
-
{isContinuousAIEnabled ? 'π₯ Live Quality Monitoring' : 'Quality Check Results'}
|
| 715 |
-
{isContinuousAIEnabled && <span className="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>}
|
| 716 |
-
</h3>
|
| 717 |
-
<div className="space-y-3">
|
| 718 |
-
<div>
|
| 719 |
-
<div className="flex items-center justify-between mb-1">
|
| 720 |
-
<p className="text-sm text-gray-600">Cervix Detected</p>
|
| 721 |
-
<p className="text-sm font-medium text-green-700">
|
| 722 |
-
{liveAIResults.cervixDetected ? 'Yes' : 'No'} ({(liveAIResults.detectionConfidence * 100).toFixed(1)}%)
|
| 723 |
-
</p>
|
| 724 |
-
</div>
|
| 725 |
-
</div>
|
| 726 |
-
<div>
|
| 727 |
-
<div className="flex items-center justify-between mb-2">
|
| 728 |
-
<p className="text-sm text-gray-600">Image Quality</p>
|
| 729 |
-
<p className="text-sm font-semibold">
|
| 730 |
-
{liveAIResults.quality} ({(liveAIResults.qualityConfidence * 100).toFixed(1)}%)
|
| 731 |
-
</p>
|
| 732 |
-
</div>
|
| 733 |
-
{/* Progress Bar */}
|
| 734 |
-
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden shadow-inner">
|
| 735 |
-
<div
|
| 736 |
-
className={`h-full rounded-full transition-all duration-500 ${
|
| 737 |
-
liveAIResults.qualityConfidence >= 0.8
|
| 738 |
-
? 'bg-gradient-to-r from-green-500 to-green-600'
|
| 739 |
-
: liveAIResults.qualityConfidence >= 0.6
|
| 740 |
-
? 'bg-gradient-to-r from-orange-400 to-orange-500'
|
| 741 |
-
: 'bg-gradient-to-r from-red-500 to-red-600'
|
| 742 |
-
}`}
|
| 743 |
-
style={{ width: `${Math.min(liveAIResults.qualityConfidence * 100, 100)}%` }}
|
| 744 |
-
/>
|
| 745 |
-
</div>
|
| 746 |
-
</div>
|
| 747 |
-
</div>
|
| 748 |
-
</div>
|
| 749 |
-
</div>
|
| 750 |
-
</div>
|
| 751 |
-
)}
|
| 752 |
-
|
| 753 |
-
{liveAIError && !isAnnotatingMode && !isCompareMode && currentStep !== 'report' && currentStep !== 'biopsyMarking' && (
|
| 754 |
-
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
| 755 |
-
<p className="text-red-700 font-medium">{liveAIError}</p>
|
| 756 |
-
</div>
|
| 757 |
-
)}
|
| 758 |
-
|
| 759 |
{currentStep === 'report' ? (
|
| 760 |
<ReportPage
|
| 761 |
onBack={() => {
|
|
@@ -817,7 +864,8 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 817 |
<ImageAnnotator
|
| 818 |
ref={imageAnnotatorRef}
|
| 819 |
imageUrls={imageCaptures.map(item => item.url)}
|
| 820 |
-
onAnnotationsChange={setAnnotations}
|
|
|
|
| 821 |
/>
|
| 822 |
)}
|
| 823 |
</div>
|
|
@@ -827,7 +875,7 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 827 |
<>
|
| 828 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
| 829 |
{/* Main Live Feed */}
|
| 830 |
-
<div className="lg:col-span-2
|
| 831 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 832 |
{/* Live Video Feed */}
|
| 833 |
<div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700 mb-4">
|
|
@@ -941,6 +989,61 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 941 |
<span>{(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted) ? (currentStep === 'acetowhite' ? 'Apply acetic acid to start' : 'Apply Lugol iodine to start') : 'Capture Required'}</span>
|
| 942 |
</div>
|
| 943 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 944 |
</div>
|
| 945 |
</div>
|
| 946 |
|
|
@@ -1306,12 +1409,20 @@ export function GuidedCapturePage({ onNext, onGoToPatientRecords, initialMode, o
|
|
| 1306 |
</div>
|
| 1307 |
</div>
|
| 1308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1309 |
{/* Visual Observations - Full Width on Native Step */}
|
| 1310 |
{currentStep === 'native' && (
|
| 1311 |
<div className="w-full">
|
| 1312 |
<ImagingObservations
|
| 1313 |
onObservationsChange={setObservations}
|
| 1314 |
layout="horizontal"
|
|
|
|
| 1315 |
/>
|
| 1316 |
</div>
|
| 1317 |
)}
|
|
|
|
| 3 |
import { ImageAnnotator, type ImageAnnotatorHandle } from '../components/ImageAnnotator';
|
| 4 |
import { AceticAnnotator, type AceticAnnotatorHandle } from '../components/AceticAnnotator';
|
| 5 |
import { ImagingObservations } from '../components/ImagingObservations';
|
| 6 |
+
import { AceticFindingsForm } from '../components/AceticFindingsForm';
|
| 7 |
import { BiopsyMarking, type BiopsyCapturedImage } from './BiopsyMarking';
|
| 8 |
import { Compare } from './Compare';
|
| 9 |
import { ReportPage } from './ReportPage';
|
| 10 |
import { applyGreenFilter } from '../utils/filterUtils';
|
| 11 |
+
import { sessionStore } from '../store/sessionStore';
|
| 12 |
|
| 13 |
// Simple UI Component replacements
|
| 14 |
const Button: React.FC<any> = ({ children, onClick, disabled, variant, size, className, ...props }) => {
|
|
|
|
| 59 |
const [_observations, setObservations] = useState({});
|
| 60 |
const [isAnnotatingMode, setIsAnnotatingMode] = useState(false);
|
| 61 |
const [isCompareMode, setIsCompareMode] = useState(false);
|
|
|
|
| 62 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; detectionConfidence: number; quality: string; qualityConfidence: number } | null>(null);
|
| 63 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 64 |
const [isContinuousAIEnabled, setIsContinuousAIEnabled] = useState(false);
|
|
|
|
| 244 |
const formData = new FormData();
|
| 245 |
formData.append('file', blob, 'frame.jpg');
|
| 246 |
|
| 247 |
+
const response = await fetch('http://localhost:8000/infer/image', {
|
| 248 |
method: 'POST',
|
| 249 |
body: formData,
|
| 250 |
});
|
|
|
|
| 508 |
};
|
| 509 |
|
| 510 |
const handleMainAIAssist = () => {
|
| 511 |
+
// Toggle continuous AI assist for live feed (now as a toggle button)
|
| 512 |
+
if (isContinuousAIEnabled) {
|
| 513 |
+
setIsContinuousAIEnabled(false);
|
| 514 |
setLiveAIError(null);
|
| 515 |
setLiveAIResults(null);
|
| 516 |
+
} else {
|
| 517 |
+
setIsContinuousAIEnabled(true);
|
| 518 |
+
setLiveAIError(null);
|
| 519 |
+
setLiveAIResults(null);
|
| 520 |
+
}
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
// Native Annotation Page - Cervix Bounding Box Detection
|
| 524 |
+
const handleNativeAnnotationAIAssist = async () => {
|
| 525 |
+
if (!imageAnnotatorRef.current) return;
|
| 526 |
+
|
| 527 |
+
try {
|
| 528 |
+
// Get the current image URL from the annotator
|
| 529 |
+
const imageUrls = imageCaptures.map(item => item.url);
|
| 530 |
+
if (imageUrls.length === 0) {
|
| 531 |
+
console.warn('β οΈ No images available for cervix detection');
|
| 532 |
+
return;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
console.log('π Starting cervix bounding box detection...');
|
| 536 |
+
|
| 537 |
+
// Get the current image (first one or from annotator state)
|
| 538 |
+
const currentImageUrl = imageUrls[0];
|
| 539 |
+
|
| 540 |
+
// Fetch the image and convert to blob
|
| 541 |
+
const response = await fetch(currentImageUrl);
|
| 542 |
+
const blob = await response.blob();
|
| 543 |
+
console.log(`β
Image loaded, size: ${blob.size} bytes`);
|
| 544 |
+
|
| 545 |
+
// Prepare form data for backend
|
| 546 |
+
const formData = new FormData();
|
| 547 |
+
formData.append('file', blob, 'image.jpg');
|
| 548 |
+
|
| 549 |
+
console.log('π Sending request to backend...');
|
| 550 |
+
// Call cervix bbox detection endpoint
|
| 551 |
+
const backendResponse = await fetch('http://localhost:8000/api/infer-cervix-bbox', {
|
| 552 |
+
method: 'POST',
|
| 553 |
+
body: formData,
|
| 554 |
+
});
|
| 555 |
+
|
| 556 |
+
if (!backendResponse.ok) {
|
| 557 |
+
throw new Error(`Backend error: ${backendResponse.statusText}`);
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
const result = await backendResponse.json();
|
| 561 |
+
console.log('β
Backend response received:', result);
|
| 562 |
+
|
| 563 |
+
// Convert bounding boxes to annotation format
|
| 564 |
+
if (result.bounding_boxes && result.bounding_boxes.length > 0) {
|
| 565 |
+
console.log(`π¦ Found ${result.bounding_boxes.length} cervix bounding box(es)`);
|
| 566 |
+
|
| 567 |
+
const aiAnnotations: any[] = result.bounding_boxes.map((bbox: any, idx: number) => ({
|
| 568 |
+
id: `ai-cervix-${Date.now()}-${idx}`,
|
| 569 |
+
type: 'rect',
|
| 570 |
+
x: bbox.x1,
|
| 571 |
+
y: bbox.y1,
|
| 572 |
+
width: bbox.width,
|
| 573 |
+
height: bbox.height,
|
| 574 |
+
color: '#FF6B6B',
|
| 575 |
+
label: `Cervix (${(bbox.confidence * 100).toFixed(1)}%)`,
|
| 576 |
+
source: 'ai',
|
| 577 |
+
identified: true,
|
| 578 |
+
accepted: false
|
| 579 |
+
}));
|
| 580 |
+
|
| 581 |
+
console.log('π¨ Adding annotations to canvas:', aiAnnotations);
|
| 582 |
+
// Add AI annotations to the image annotator
|
| 583 |
+
imageAnnotatorRef.current.addAIAnnotations(aiAnnotations);
|
| 584 |
+
console.log('β
Cervix bounding boxes detected and displayed:', result.detections);
|
| 585 |
+
} else {
|
| 586 |
+
console.warn('β οΈ No cervix detected in image');
|
| 587 |
+
}
|
| 588 |
+
} catch (error) {
|
| 589 |
+
console.error('β Native annotation AI assist error:', error);
|
| 590 |
}
|
| 591 |
};
|
| 592 |
|
|
|
|
| 628 |
}
|
| 629 |
}, [capturedItems, onCapturedImagesChange]);
|
| 630 |
|
| 631 |
+
// Initialize session and persist captured images to sessionStore
|
| 632 |
+
useEffect(() => {
|
| 633 |
+
const session = sessionStore.get();
|
| 634 |
+
if (!session.sessionStarted) {
|
| 635 |
+
sessionStore.merge({ sessionStarted: new Date().toISOString() });
|
| 636 |
+
}
|
| 637 |
+
}, []);
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
|
| 641 |
// Notify parent component when mode changes
|
| 642 |
useEffect(() => {
|
| 643 |
if (onModeChange) {
|
| 644 |
+
if (currentStep === 'report') {
|
| 645 |
+
onModeChange('report');
|
| 646 |
+
} else if (isCompareMode) {
|
| 647 |
onModeChange('compare');
|
| 648 |
} else if (isAnnotatingMode) {
|
| 649 |
onModeChange('annotation');
|
|
|
|
| 651 |
onModeChange('capture');
|
| 652 |
}
|
| 653 |
}
|
| 654 |
+
}, [currentStep, isCompareMode, isAnnotatingMode, onModeChange]);
|
| 655 |
+
|
| 656 |
+
const patientId = sessionStore.get().patientInfo?.id;
|
| 657 |
|
| 658 |
return (
|
| 659 |
<div className="w-full bg-white/95 relative">
|
| 660 |
<div className="relative z-10 py-4 md:py-6 lg:py-8">
|
| 661 |
<div className="w-full max-w-7xl mx-auto px-4 md:px-6">
|
| 662 |
+
{/* Patient ID Badge */}
|
| 663 |
+
{patientId && (
|
| 664 |
+
<div className="mb-4 flex justify-end">
|
| 665 |
+
<div className="flex items-center gap-2 px-3 md:px-4 py-1.5 md:py-2 bg-teal-50 border border-teal-200 rounded-full text-xs md:text-sm font-mono font-semibold text-[#0A2540]">
|
| 666 |
+
<span>Patient ID:</span>
|
| 667 |
+
<span>{patientId}</span>
|
| 668 |
+
</div>
|
| 669 |
+
</div>
|
| 670 |
+
)}
|
| 671 |
|
| 672 |
{/* Page Header */}
|
| 673 |
+
{currentStep !== 'report' && (
|
| 674 |
<div className="mb-4 md:mb-6">
|
| 675 |
{/* Progress Bar - Capture / Annotate / Compare / Report */}
|
| 676 |
<div className="mb-4 flex gap-1 md:gap-2">
|
|
|
|
| 678 |
<div key={stage} className="flex items-center flex-1">
|
| 679 |
<div
|
| 680 |
className={`flex-1 py-2 px-2 md:px-3 rounded-3xl font-medium text-sm md:text-base transition-all border-2 border-[#05998c] cursor-default pointer-events-none ${
|
| 681 |
+
(stage === 'Capture' && !selectedImage && !isAnnotatingMode && !isCompareMode) ||
|
| 682 |
+
(stage === 'Annotate' && (selectedImage || isAnnotatingMode) && !isCompareMode) ||
|
| 683 |
+
(stage === 'Compare' && isCompareMode)
|
|
|
|
| 684 |
? 'bg-[#05998c] text-white shadow-md'
|
| 685 |
: 'bg-gray-100 text-gray-600'
|
| 686 |
}`}
|
|
|
|
| 731 |
</div>
|
| 732 |
)}
|
| 733 |
</div>
|
| 734 |
+
)}
|
| 735 |
|
| 736 |
{/* Back and Next Navigation for Guided Capture Steps */}
|
| 737 |
+
{currentStep !== 'biopsyMarking' && currentStep !== 'report' && !isAnnotatingMode && !isCompareMode && (
|
| 738 |
<div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm mb-4">
|
| 739 |
<Button
|
| 740 |
variant="ghost"
|
|
|
|
| 756 |
</h2>
|
| 757 |
<button
|
| 758 |
onClick={handleMainAIAssist}
|
| 759 |
+
disabled={false}
|
| 760 |
className={`px-6 py-3 rounded-lg transition-all font-semibold flex items-center justify-center gap-2 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed min-w-max ${
|
| 761 |
+
isContinuousAIEnabled
|
| 762 |
? 'bg-gradient-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 animate-pulse'
|
| 763 |
: 'bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800'
|
| 764 |
}`}
|
| 765 |
+
title={isContinuousAIEnabled ? 'Continuous AI quality checking is ON - Click to turn OFF' : 'Enable continuous AI quality checking for live feed'}
|
| 766 |
>
|
| 767 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isContinuousAIEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 768 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isContinuousAIEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 769 |
+
</div>
|
| 770 |
<Sparkles className="h-5 w-5" />
|
| 771 |
{isContinuousAIEnabled ? 'AI Live β' : 'AI Assist'}
|
| 772 |
</button>
|
| 773 |
<div className="flex items-center gap-2">
|
| 774 |
<button
|
| 775 |
onClick={handleUploadClick}
|
| 776 |
+
className="h-8 px-3 bg-[#05998c] text-white hover:bg-[#047569] rounded transition-colors flex items-center gap-2"
|
| 777 |
>
|
| 778 |
<Upload className="h-4 w-4" />
|
| 779 |
Upload
|
|
|
|
| 803 |
</div>
|
| 804 |
)}
|
| 805 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
{currentStep === 'report' ? (
|
| 807 |
<ReportPage
|
| 808 |
onBack={() => {
|
|
|
|
| 864 |
<ImageAnnotator
|
| 865 |
ref={imageAnnotatorRef}
|
| 866 |
imageUrls={imageCaptures.map(item => item.url)}
|
| 867 |
+
onAnnotationsChange={setAnnotations}
|
| 868 |
+
onAIAssist={handleNativeAnnotationAIAssist}
|
| 869 |
/>
|
| 870 |
)}
|
| 871 |
</div>
|
|
|
|
| 875 |
<>
|
| 876 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
| 877 |
{/* Main Live Feed */}
|
| 878 |
+
<div className="lg:col-span-2">
|
| 879 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 880 |
{/* Live Video Feed */}
|
| 881 |
<div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700 mb-4">
|
|
|
|
| 989 |
<span>{(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted) ? (currentStep === 'acetowhite' ? 'Apply acetic acid to start' : 'Apply Lugol iodine to start') : 'Capture Required'}</span>
|
| 990 |
</div>
|
| 991 |
)}
|
| 992 |
+
|
| 993 |
+
{/* Quality Results Display */}
|
| 994 |
+
{liveAIResults && !isAnnotatingMode && !isCompareMode && (
|
| 995 |
+
<div className={`mt-4 p-3 rounded-lg border-2 transition-all ${
|
| 996 |
+
isContinuousAIEnabled
|
| 997 |
+
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300'
|
| 998 |
+
: 'bg-green-50 border-green-200'
|
| 999 |
+
}`}>
|
| 1000 |
+
<div className="flex items-start gap-2">
|
| 1001 |
+
<div className="flex-1">
|
| 1002 |
+
<h3 className="font-semibold text-green-900 mb-2 text-sm flex items-center gap-2">
|
| 1003 |
+
{isContinuousAIEnabled ? 'π₯ Quality Check' : 'Quality Check'}
|
| 1004 |
+
{isContinuousAIEnabled && <span className="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>}
|
| 1005 |
+
</h3>
|
| 1006 |
+
<div className="space-y-2">
|
| 1007 |
+
<div>
|
| 1008 |
+
<div className="flex items-center justify-between mb-1">
|
| 1009 |
+
<p className="text-sm text-gray-600 font-medium">Cervix</p>
|
| 1010 |
+
<p className="text-sm font-semibold text-green-700">
|
| 1011 |
+
{liveAIResults.cervixDetected ? 'β' : 'β'} ({(liveAIResults.detectionConfidence * 100).toFixed(0)}%)
|
| 1012 |
+
</p>
|
| 1013 |
+
</div>
|
| 1014 |
+
</div>
|
| 1015 |
+
<div>
|
| 1016 |
+
<div className="flex items-center justify-between mb-1">
|
| 1017 |
+
<p className="text-sm text-gray-600 font-medium">Quality</p>
|
| 1018 |
+
<p className="text-sm font-semibold">
|
| 1019 |
+
{liveAIResults.quality} ({(liveAIResults.qualityConfidence * 100).toFixed(0)}%)
|
| 1020 |
+
</p>
|
| 1021 |
+
</div>
|
| 1022 |
+
{/* Progress Bar */}
|
| 1023 |
+
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden shadow-inner">
|
| 1024 |
+
<div
|
| 1025 |
+
className={`h-full rounded-full transition-all duration-500 ${
|
| 1026 |
+
liveAIResults.qualityConfidence >= 0.65
|
| 1027 |
+
? 'bg-gradient-to-r from-green-500 to-green-600'
|
| 1028 |
+
: liveAIResults.qualityConfidence >= 0.5
|
| 1029 |
+
? 'bg-gradient-to-r from-orange-400 to-orange-500'
|
| 1030 |
+
: 'bg-gradient-to-r from-red-500 to-red-600'
|
| 1031 |
+
}`}
|
| 1032 |
+
style={{ width: `${Math.min(liveAIResults.qualityConfidence * 100, 100)}%` }}
|
| 1033 |
+
/>
|
| 1034 |
+
</div>
|
| 1035 |
+
</div>
|
| 1036 |
+
</div>
|
| 1037 |
+
</div>
|
| 1038 |
+
</div>
|
| 1039 |
+
</div>
|
| 1040 |
+
)}
|
| 1041 |
+
|
| 1042 |
+
{liveAIError && !isAnnotatingMode && !isCompareMode && (
|
| 1043 |
+
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
| 1044 |
+
<p className="text-red-700 font-medium text-sm">{liveAIError}</p>
|
| 1045 |
+
</div>
|
| 1046 |
+
)}
|
| 1047 |
</div>
|
| 1048 |
</div>
|
| 1049 |
|
|
|
|
| 1409 |
</div>
|
| 1410 |
</div>
|
| 1411 |
|
| 1412 |
+
{/* Acetic Acid Clinical Findings β shown on acetowhite capture page */}
|
| 1413 |
+
{currentStep === 'acetowhite' && (
|
| 1414 |
+
<div className="w-full mt-4">
|
| 1415 |
+
<AceticFindingsForm />
|
| 1416 |
+
</div>
|
| 1417 |
+
)}
|
| 1418 |
+
|
| 1419 |
{/* Visual Observations - Full Width on Native Step */}
|
| 1420 |
{currentStep === 'native' && (
|
| 1421 |
<div className="w-full">
|
| 1422 |
<ImagingObservations
|
| 1423 |
onObservationsChange={setObservations}
|
| 1424 |
layout="horizontal"
|
| 1425 |
+
stepId="native"
|
| 1426 |
/>
|
| 1427 |
</div>
|
| 1428 |
)}
|
src/pages/LugolExamPage.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, Save, ChevronRight, Sparkles } from 'lucide-react';
|
| 3 |
import { ImageAnnotator } from '../components/ImageAnnotator';
|
|
|
|
| 4 |
|
| 5 |
type CapturedItem = {
|
| 6 |
id: string;
|
|
@@ -20,6 +21,7 @@ export function LugolExamPage({ goBack, onNext }: Props) {
|
|
| 20 |
const [isRecording, setIsRecording] = useState(false);
|
| 21 |
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
| 22 |
const [annotations, setAnnotations] = useState<any[]>([]);
|
|
|
|
| 23 |
const [showExitWarning, setShowExitWarning] = useState(false);
|
| 24 |
|
| 25 |
// Timer states
|
|
@@ -30,6 +32,7 @@ export function LugolExamPage({ goBack, onNext }: Props) {
|
|
| 30 |
const audibleAlert = true;
|
| 31 |
const [timerPaused, setTimerPaused] = useState(false);
|
| 32 |
const [isLiveAILoading, setIsLiveAILoading] = useState(false);
|
|
|
|
| 33 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; quality: string; confidence: number } | null>(null);
|
| 34 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 35 |
|
|
@@ -135,6 +138,18 @@ export function LugolExamPage({ goBack, onNext }: Props) {
|
|
| 135 |
return 'Bad';
|
| 136 |
};
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
const handleLugolMainAIAssist = async () => {
|
| 139 |
setLiveAIError(null);
|
| 140 |
setLiveAIResults(null);
|
|
@@ -149,7 +164,7 @@ export function LugolExamPage({ goBack, onNext }: Props) {
|
|
| 149 |
|
| 150 |
const formData = new FormData();
|
| 151 |
formData.append('file', blob, 'image.jpg');
|
| 152 |
-
const backendResponse = await fetch('/infer/image', {
|
| 153 |
method: 'POST',
|
| 154 |
body: formData,
|
| 155 |
});
|
|
@@ -453,12 +468,20 @@ export function LugolExamPage({ goBack, onNext }: Props) {
|
|
| 453 |
|
| 454 |
{/* Centered AI Assist Button */}
|
| 455 |
<button
|
| 456 |
-
onClick={
|
| 457 |
disabled={isLiveAILoading}
|
| 458 |
-
className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
>
|
|
|
|
|
|
|
|
|
|
| 460 |
<Sparkles className="w-6 h-6" />
|
| 461 |
-
{isLiveAILoading ? 'Checking...' : 'AI Assist'}
|
| 462 |
</button>
|
| 463 |
|
| 464 |
{/* Live AI Results Panel */}
|
|
@@ -603,6 +626,12 @@ export function LugolExamPage({ goBack, onNext }: Props) {
|
|
| 603 |
onAnnotationsChange={setAnnotations}
|
| 604 |
/>
|
| 605 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
</div>
|
| 607 |
</div>
|
| 608 |
)}
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, Save, ChevronRight, Sparkles } from 'lucide-react';
|
| 3 |
import { ImageAnnotator } from '../components/ImageAnnotator';
|
| 4 |
+
import { ImagingObservations } from '../components/ImagingObservations';
|
| 5 |
|
| 6 |
type CapturedItem = {
|
| 7 |
id: string;
|
|
|
|
| 21 |
const [isRecording, setIsRecording] = useState(false);
|
| 22 |
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
| 23 |
const [annotations, setAnnotations] = useState<any[]>([]);
|
| 24 |
+
const [observations, setObservations] = useState({});
|
| 25 |
const [showExitWarning, setShowExitWarning] = useState(false);
|
| 26 |
|
| 27 |
// Timer states
|
|
|
|
| 32 |
const audibleAlert = true;
|
| 33 |
const [timerPaused, setTimerPaused] = useState(false);
|
| 34 |
const [isLiveAILoading, setIsLiveAILoading] = useState(false);
|
| 35 |
+
const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false);
|
| 36 |
const [liveAIResults, setLiveAIResults] = useState<{ cervixDetected: boolean; quality: string; confidence: number } | null>(null);
|
| 37 |
const [liveAIError, setLiveAIError] = useState<string | null>(null);
|
| 38 |
|
|
|
|
| 138 |
return 'Bad';
|
| 139 |
};
|
| 140 |
|
| 141 |
+
const handleAIAssistToggle = async () => {
|
| 142 |
+
if (isLiveAILoading) return;
|
| 143 |
+
if (isAIAssistEnabled) {
|
| 144 |
+
setIsAIAssistEnabled(false);
|
| 145 |
+
setLiveAIResults(null);
|
| 146 |
+
setLiveAIError(null);
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
setIsAIAssistEnabled(true);
|
| 150 |
+
await handleLugolMainAIAssist();
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
const handleLugolMainAIAssist = async () => {
|
| 154 |
setLiveAIError(null);
|
| 155 |
setLiveAIResults(null);
|
|
|
|
| 164 |
|
| 165 |
const formData = new FormData();
|
| 166 |
formData.append('file', blob, 'image.jpg');
|
| 167 |
+
const backendResponse = await fetch('http://localhost:8000/infer/image', {
|
| 168 |
method: 'POST',
|
| 169 |
body: formData,
|
| 170 |
});
|
|
|
|
| 468 |
|
| 469 |
{/* Centered AI Assist Button */}
|
| 470 |
<button
|
| 471 |
+
onClick={handleAIAssistToggle}
|
| 472 |
disabled={isLiveAILoading}
|
| 473 |
+
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-lg text-white font-bold transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed text-base ${
|
| 474 |
+
isAIAssistEnabled
|
| 475 |
+
? 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700'
|
| 476 |
+
: 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800'
|
| 477 |
+
}`}
|
| 478 |
+
title={isAIAssistEnabled ? 'AI Assist is ON' : 'Run AI model to check quality'}
|
| 479 |
>
|
| 480 |
+
<div className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isAIAssistEnabled ? 'bg-white' : 'bg-gray-300'}`}>
|
| 481 |
+
<span className={`inline-block h-4 w-4 transform rounded-full bg-gradient-to-br transition-transform ${isAIAssistEnabled ? 'translate-x-6 from-green-400 to-green-600' : 'translate-x-1 from-blue-400 to-blue-600'}`} />
|
| 482 |
+
</div>
|
| 483 |
<Sparkles className="w-6 h-6" />
|
| 484 |
+
{isLiveAILoading ? 'Checking...' : (isAIAssistEnabled ? 'AI Assist On' : 'AI Assist')}
|
| 485 |
</button>
|
| 486 |
|
| 487 |
{/* Live AI Results Panel */}
|
|
|
|
| 626 |
onAnnotationsChange={setAnnotations}
|
| 627 |
/>
|
| 628 |
</div>
|
| 629 |
+
<div className="lg:col-span-1">
|
| 630 |
+
<ImagingObservations
|
| 631 |
+
onObservationsChange={setObservations}
|
| 632 |
+
stepId="lugol"
|
| 633 |
+
/>
|
| 634 |
+
</div>
|
| 635 |
</div>
|
| 636 |
</div>
|
| 637 |
)}
|
src/pages/PatientHistoryPage.tsx
CHANGED
|
@@ -14,7 +14,7 @@ export function PatientHistoryPage({
|
|
| 14 |
}: Props) {
|
| 15 |
return <div className="w-full bg-white/95 relative p-8 font-sans">
|
| 16 |
<div className="relative z-10">
|
| 17 |
-
<PatientHistoryForm onContinue={goToGuidedCapture || goToImaging} onBack={goBackToRegistry} patientID={patientID} />
|
| 18 |
</div>
|
| 19 |
</div>;
|
| 20 |
}
|
|
|
|
| 14 |
}: Props) {
|
| 15 |
return <div className="w-full bg-white/95 relative p-8 font-sans">
|
| 16 |
<div className="relative z-10">
|
| 17 |
+
<PatientHistoryForm onContinue={goToGuidedCapture || goToImaging} onBack={goBackToRegistry} patientID={patientID} autoGeneratedPatientId={patientID} />
|
| 18 |
</div>
|
| 19 |
</div>;
|
| 20 |
}
|
src/pages/PatientRegistry.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { Eye, Edit2, ArrowLeft } from 'lucide-react';
|
|
|
|
|
|
|
| 3 |
|
| 4 |
type Props = {
|
| 5 |
-
onNewPatient: () => void;
|
| 6 |
onSelectExisting: (id: string) => void;
|
| 7 |
onBackToHome: () => void;
|
| 8 |
onNext: () => void;
|
|
@@ -21,6 +23,9 @@ export function PatientRegistry({ onNewPatient, onSelectExisting, onBackToHome,
|
|
| 21 |
{ id: 'P-10235', name: 'Emily Chen', examDate: '2024-01-15' }
|
| 22 |
];
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
const handleSearch = () => {
|
| 25 |
const q = patientId.trim();
|
| 26 |
setSearched(true);
|
|
@@ -30,10 +35,28 @@ export function PatientRegistry({ onNewPatient, onSelectExisting, onBackToHome,
|
|
| 30 |
return;
|
| 31 |
}
|
| 32 |
setError(null);
|
| 33 |
-
const found =
|
| 34 |
setResults(found);
|
| 35 |
};
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return (
|
| 38 |
<div className="flex-1 flex flex-col items-stretch justify-start py-4 md:py-6 lg:py-8">
|
| 39 |
<div className="w-full px-4 py-2">
|
|
@@ -57,7 +80,7 @@ export function PatientRegistry({ onNewPatient, onSelectExisting, onBackToHome,
|
|
| 57 |
<select onChange={e => {
|
| 58 |
const v = e.target.value;
|
| 59 |
if (v === 'all') {
|
| 60 |
-
setResults(
|
| 61 |
setSearched(true);
|
| 62 |
} else if (v === 'clear') {
|
| 63 |
setResults([]);
|
|
@@ -72,7 +95,7 @@ export function PatientRegistry({ onNewPatient, onSelectExisting, onBackToHome,
|
|
| 72 |
<div className="flex flex-col md:flex-row gap-3 md:gap-4 w-full lg:w-auto lg:max-w-2xl">
|
| 73 |
<input aria-label="Search by Patient ID" value={patientId} onChange={e => setPatientId(e.target.value)} placeholder="e.g. PT-2025-8492" className="px-3 py-2 md:py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] outline-none text-sm md:text-base flex-1 lg:flex-none lg:w-48" />
|
| 74 |
<button onClick={handleSearch} className="px-4 md:px-6 py-2 md:py-3 bg-[#0A2540] text-white rounded-lg text-sm md:text-base font-medium hover:bg-[#051B30] transition-colors whitespace-nowrap">Search</button>
|
| 75 |
-
<button onClick={
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
{error && <p className="text-sm md:text-base text-red-500 mt-2">{error}</p>}
|
|
@@ -103,10 +126,10 @@ export function PatientRegistry({ onNewPatient, onSelectExisting, onBackToHome,
|
|
| 103 |
<td className="px-3 md:px-4 py-2 md:py-3 text-gray-600 text-xs md:text-sm">{r.examDate}</td>
|
| 104 |
<td className="px-3 md:px-4 py-2 md:py-3 text-gray-600">
|
| 105 |
<div className="flex items-center gap-2 md:gap-3">
|
| 106 |
-
<button aria-label={`View ${r.id}`} title="View" onClick={() =>
|
| 107 |
<Eye className="w-4 h-4 md:w-5 md:h-5 text-gray-600" />
|
| 108 |
</button>
|
| 109 |
-
<button aria-label={`Edit ${r.id}`} title="Edit" onClick={() =>
|
| 110 |
<Edit2 className="w-4 h-4 md:w-5 md:h-5 text-gray-600" />
|
| 111 |
</button>
|
| 112 |
</div>
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { Eye, Edit2, ArrowLeft } from 'lucide-react';
|
| 3 |
+
import { sessionStore } from '../store/sessionStore';
|
| 4 |
+
import { generatePatientId, getPatientHistory, savePatientRecord } from '../store/patientIdStore';
|
| 5 |
|
| 6 |
type Props = {
|
| 7 |
+
onNewPatient: (newPatientId: string) => void;
|
| 8 |
onSelectExisting: (id: string) => void;
|
| 9 |
onBackToHome: () => void;
|
| 10 |
onNext: () => void;
|
|
|
|
| 23 |
{ id: 'P-10235', name: 'Emily Chen', examDate: '2024-01-15' }
|
| 24 |
];
|
| 25 |
|
| 26 |
+
// Get all available patients (demo + generated)
|
| 27 |
+
const allPatients = [...mockPatients, ...getPatientHistory()];
|
| 28 |
+
|
| 29 |
const handleSearch = () => {
|
| 30 |
const q = patientId.trim();
|
| 31 |
setSearched(true);
|
|
|
|
| 35 |
return;
|
| 36 |
}
|
| 37 |
setError(null);
|
| 38 |
+
const found = allPatients.filter(p => p.id.toLowerCase() === q.toLowerCase());
|
| 39 |
setResults(found);
|
| 40 |
};
|
| 41 |
|
| 42 |
+
const handleAddNewPatient = () => {
|
| 43 |
+
const newId = generatePatientId();
|
| 44 |
+
onNewPatient(newId);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const handleSelectPatient = (patientRecord: { id: string; name: string; examDate: string }) => {
|
| 48 |
+
// Save patient info to sessionStore
|
| 49 |
+
sessionStore.merge({
|
| 50 |
+
patientInfo: {
|
| 51 |
+
id: patientRecord.id,
|
| 52 |
+
name: patientRecord.name,
|
| 53 |
+
examDate: patientRecord.examDate
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
// Call the parent handler
|
| 57 |
+
onSelectExisting(patientRecord.id);
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
return (
|
| 61 |
<div className="flex-1 flex flex-col items-stretch justify-start py-4 md:py-6 lg:py-8">
|
| 62 |
<div className="w-full px-4 py-2">
|
|
|
|
| 80 |
<select onChange={e => {
|
| 81 |
const v = e.target.value;
|
| 82 |
if (v === 'all') {
|
| 83 |
+
setResults(allPatients);
|
| 84 |
setSearched(true);
|
| 85 |
} else if (v === 'clear') {
|
| 86 |
setResults([]);
|
|
|
|
| 95 |
<div className="flex flex-col md:flex-row gap-3 md:gap-4 w-full lg:w-auto lg:max-w-2xl">
|
| 96 |
<input aria-label="Search by Patient ID" value={patientId} onChange={e => setPatientId(e.target.value)} placeholder="e.g. PT-2025-8492" className="px-3 py-2 md:py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] outline-none text-sm md:text-base flex-1 lg:flex-none lg:w-48" />
|
| 97 |
<button onClick={handleSearch} className="px-4 md:px-6 py-2 md:py-3 bg-[#0A2540] text-white rounded-lg text-sm md:text-base font-medium hover:bg-[#051B30] transition-colors whitespace-nowrap">Search</button>
|
| 98 |
+
<button onClick={handleAddNewPatient} className="px-4 md:px-6 py-2 md:py-3 bg-[#05998c] text-white font-semibold rounded-lg text-sm md:text-base hover:bg-[#047569] transition-colors whitespace-nowrap">Add New Patient</button>
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
{error && <p className="text-sm md:text-base text-red-500 mt-2">{error}</p>}
|
|
|
|
| 126 |
<td className="px-3 md:px-4 py-2 md:py-3 text-gray-600 text-xs md:text-sm">{r.examDate}</td>
|
| 127 |
<td className="px-3 md:px-4 py-2 md:py-3 text-gray-600">
|
| 128 |
<div className="flex items-center gap-2 md:gap-3">
|
| 129 |
+
<button aria-label={`View ${r.id}`} title="View" onClick={() => handleSelectPatient(r)} className="p-1.5 md:p-2 rounded hover:bg-gray-50">
|
| 130 |
<Eye className="w-4 h-4 md:w-5 md:h-5 text-gray-600" />
|
| 131 |
</button>
|
| 132 |
+
<button aria-label={`Edit ${r.id}`} title="Edit" onClick={() => handleSelectPatient(r)} className="p-1.5 md:p-2 rounded hover:bg-gray-50">
|
| 133 |
<Edit2 className="w-4 h-4 md:w-5 md:h-5 text-gray-600" />
|
| 134 |
</button>
|
| 135 |
</div>
|
src/pages/ReportPage.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
import { useEffect, useRef, useState } from 'react';
|
| 2 |
-
import { ArrowLeft, Download, FileText, CheckCircle, Camera } from 'lucide-react';
|
| 3 |
import html2pdf from 'html2pdf.js';
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
type CapturedImage = {
|
| 6 |
id: string;
|
|
@@ -16,7 +19,7 @@ type Props = {
|
|
| 16 |
patientData?: any;
|
| 17 |
capturedImages?: CapturedImage[];
|
| 18 |
biopsyData?: any;
|
| 19 |
-
|
| 20 |
};
|
| 21 |
|
| 22 |
export function ReportPage({
|
|
@@ -26,7 +29,7 @@ export function ReportPage({
|
|
| 26 |
patientData = {},
|
| 27 |
capturedImages = [],
|
| 28 |
biopsyData = {},
|
| 29 |
-
|
| 30 |
}: Props) {
|
| 31 |
type ImageBucket = 'native' | 'acetic' | 'lugol' | 'biopsy';
|
| 32 |
type ImageItem = {
|
|
@@ -38,26 +41,35 @@ export function ReportPage({
|
|
| 38 |
|
| 39 |
// Form state with dummy data
|
| 40 |
const [formData, setFormData] = useState({
|
| 41 |
-
regNo:
|
| 42 |
-
name:
|
| 43 |
-
opdNo:
|
| 44 |
-
age:
|
| 45 |
-
parity:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
doctorSignature: '',
|
| 58 |
signatureDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
| 59 |
});
|
| 60 |
|
|
|
|
| 61 |
const [imageBuckets, setImageBuckets] = useState<Record<ImageBucket, ImageItem[]>>({
|
| 62 |
native: [],
|
| 63 |
acetic: [],
|
|
@@ -65,8 +77,11 @@ export function ReportPage({
|
|
| 65 |
biopsy: []
|
| 66 |
});
|
| 67 |
const [showCompletionModal, setShowCompletionModal] = useState(false);
|
|
|
|
|
|
|
| 68 |
const imageBucketsRef = useRef(imageBuckets);
|
| 69 |
const reportContentRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 70 |
|
| 71 |
const handleFormChange = (field: keyof typeof formData, value: string) => {
|
| 72 |
setFormData(prev => ({
|
|
@@ -75,24 +90,442 @@ export function ReportPage({
|
|
| 75 |
}));
|
| 76 |
};
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
const handleExportPDF = () => {
|
| 79 |
if (!reportContentRef.current) return;
|
| 80 |
-
|
| 81 |
const element = reportContentRef.current;
|
| 82 |
-
const patientName = patientData?.name || 'Patient';
|
| 83 |
const reportDate = new Date().toISOString().split('T')[0];
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
const options = {
|
| 86 |
-
margin: [
|
| 87 |
filename: `Colposcopy_Report_${patientName}_${reportDate}.pdf`,
|
| 88 |
image: { type: 'jpeg' as const, quality: 0.98 },
|
| 89 |
-
html2canvas: {
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
};
|
| 92 |
-
|
| 93 |
-
html2pdf()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
};
|
| 95 |
|
|
|
|
| 96 |
const addFilesToBucket = (bucket: ImageBucket, files: File[]) => {
|
| 97 |
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
| 98 |
if (!imageFiles.length) return;
|
|
@@ -213,8 +646,8 @@ export function ReportPage({
|
|
| 213 |
<div className="mb-8 bg-white rounded-2xl shadow-lg border border-gray-200 p-6 no-print print:hidden">
|
| 214 |
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 215 |
<div className="flex items-center gap-4">
|
| 216 |
-
<button
|
| 217 |
-
onClick={onBack}
|
| 218 |
className="p-3 hover:bg-gradient-to-br from-gray-100 to-gray-50 rounded-xl transition-all duration-200 text-gray-700 shadow-sm hover:shadow-md"
|
| 219 |
>
|
| 220 |
<ArrowLeft className="w-5 h-5" />
|
|
@@ -226,24 +659,47 @@ export function ReportPage({
|
|
| 226 |
</div>
|
| 227 |
Colposcopy Examination Report
|
| 228 |
</h2>
|
| 229 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
</div>
|
| 232 |
-
<div className="flex gap-3">
|
| 233 |
-
<
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
</div>
|
| 248 |
</div>
|
| 249 |
</div>
|
|
@@ -252,310 +708,368 @@ export function ReportPage({
|
|
| 252 |
|
| 253 |
{/* Report Content */}
|
| 254 |
<div className="lg:col-span-2 w-full" ref={reportContentRef}>
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
<span>Date: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
| 263 |
-
</div>
|
| 264 |
-
</div>
|
| 265 |
|
| 266 |
-
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
onChange={(e) => handleFormChange('regNo', e.target.value)}
|
| 277 |
-
className="w-full font-medium text-gray-900 mt-0.5 print:text-xs print:mt-0 border-b border-gray-300 focus:border-blue-500 outline-none bg-transparent print:border-b-2 print:border-gray-400"
|
| 278 |
-
/>
|
| 279 |
-
</div>
|
| 280 |
-
<div>
|
| 281 |
-
<span className="text-xs font-semibold text-gray-600">Name</span>
|
| 282 |
-
<input
|
| 283 |
-
type="text"
|
| 284 |
-
value={formData.name}
|
| 285 |
-
onChange={(e) => handleFormChange('name', e.target.value)}
|
| 286 |
-
className="w-full font-medium text-gray-900 mt-0.5 print:text-xs print:mt-0 border-b border-gray-300 focus:border-blue-500 outline-none bg-transparent print:border-b-2 print:border-gray-400"
|
| 287 |
-
/>
|
| 288 |
-
</div>
|
| 289 |
-
<div>
|
| 290 |
-
<span className="text-xs font-semibold text-gray-600">OPD No.</span>
|
| 291 |
-
<input
|
| 292 |
-
type="text"
|
| 293 |
-
value={formData.opdNo}
|
| 294 |
-
onChange={(e) => handleFormChange('opdNo', e.target.value)}
|
| 295 |
-
className="w-full font-medium text-gray-900 mt-0.5 print:text-xs print:mt-0 border-b border-gray-300 focus:border-blue-500 outline-none bg-transparent print:border-b-2 print:border-gray-400"
|
| 296 |
-
/>
|
| 297 |
-
</div>
|
| 298 |
-
<div>
|
| 299 |
-
<span className="text-xs font-semibold text-gray-600">Age</span>
|
| 300 |
-
<input
|
| 301 |
-
type="text"
|
| 302 |
-
value={formData.age}
|
| 303 |
-
onChange={(e) => handleFormChange('age', e.target.value)}
|
| 304 |
-
className="w-full font-medium text-gray-900 mt-0.5 print:text-xs print:mt-0 border-b border-gray-300 focus:border-blue-500 outline-none bg-transparent print:border-b-2 print:border-gray-400"
|
| 305 |
-
/>
|
| 306 |
-
</div>
|
| 307 |
<div>
|
| 308 |
-
<
|
| 309 |
-
<
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
/>
|
| 315 |
</div>
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
className="
|
| 323 |
/>
|
| 324 |
</div>
|
| 325 |
</div>
|
| 326 |
</div>
|
| 327 |
|
| 328 |
-
{/*
|
| 329 |
-
<div className="
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
onChange={(e) => handleFormChange('indication', e.target.value)}
|
| 338 |
-
className="w-full text-gray-900 mt-1 print:mt-0.5 print:text-xs border-b border-gray-300 focus:border-blue-500 outline-none bg-transparent print:border-b-2 print:border-gray-400"
|
| 339 |
-
/>
|
| 340 |
</div>
|
| 341 |
-
<div>
|
| 342 |
-
<
|
| 343 |
-
|
| 344 |
-
value={formData.
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
</div>
|
| 350 |
</div>
|
| 351 |
-
</div>
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
<div>
|
| 358 |
-
<span className="text-xs font-semibold text-gray-600">Exam Quality</span>
|
| 359 |
-
<select
|
| 360 |
-
value={formData.examQuality}
|
| 361 |
-
onChange={(e) => handleFormChange('examQuality', e.target.value)}
|
| 362 |
-
className="w-full font-medium text-gray-900 mt-0.5 print:text-xs print:mt-0 border-b border-gray-300 focus:border-blue-500 outline-none bg-transparent print:border-b-2 print:border-gray-400"
|
| 363 |
-
>
|
| 364 |
-
<option>Adequate</option>
|
| 365 |
-
<option>Inadequate</option>
|
| 366 |
-
</select>
|
| 367 |
</div>
|
| 368 |
-
<div>
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
</div>
|
| 391 |
</div>
|
| 392 |
-
</div>
|
| 393 |
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
{
|
| 404 |
-
|
| 405 |
-
).map(section => (
|
| 406 |
-
<div
|
| 407 |
-
key={section.key}
|
| 408 |
-
onDrop={(e) => handleSectionDrop(e, section.key)}
|
| 409 |
-
onDragOver={handleDragOver}
|
| 410 |
-
className="bg-white border-2 border-dashed border-gray-300 hover:border-blue-500 p-3 print:border-solid print:border-gray-400 min-h-[140px] print:min-h-[80px]"
|
| 411 |
-
>
|
| 412 |
-
<div className="text-xs font-semibold text-gray-700 mb-2 print:mb-1 print:text-xs">{section.label}</div>
|
| 413 |
-
{imageBuckets[section.key].length === 0 ? (
|
| 414 |
-
<div className="text-center text-gray-400 text-xs py-6 print:py-2 border border-dashed border-gray-300 rounded print:text-xs">
|
| 415 |
-
<p>Drag & drop</p>
|
| 416 |
-
</div>
|
| 417 |
) : (
|
| 418 |
-
<
|
| 419 |
-
{imageBuckets[section.key].map(item => (
|
| 420 |
-
<div key={item.id} className="relative group">
|
| 421 |
-
<img
|
| 422 |
-
src={item.url}
|
| 423 |
-
alt={item.name}
|
| 424 |
-
className="w-full h-16 print:h-12 object-cover border border-gray-300"
|
| 425 |
-
/>
|
| 426 |
-
<button
|
| 427 |
-
onClick={() => removeImage(section.key, item.id)}
|
| 428 |
-
className="absolute top-0.5 right-0.5 text-xs px-1 py-0 bg-red-600 text-white opacity-0 group-hover:opacity-100 transition-all no-print"
|
| 429 |
-
>
|
| 430 |
-
Γ
|
| 431 |
-
</button>
|
| 432 |
-
</div>
|
| 433 |
-
))}
|
| 434 |
-
</div>
|
| 435 |
)}
|
| 436 |
</div>
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
</div>
|
| 439 |
-
</div>
|
| 440 |
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
<div className="
|
| 447 |
-
<
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
</div>
|
| 456 |
-
)
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
</div>
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
</div>
|
|
|
|
| 462 |
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
<div>
|
| 466 |
-
<span className="text-
|
| 467 |
-
<textarea
|
| 468 |
-
value={formData.biopsySites}
|
| 469 |
-
onChange={(e) => handleFormChange('biopsySites', e.target.value)}
|
| 470 |
-
className="w-full text-gray-900 mt-1 print:mt-0.5 print:text-xs border border-gray-300 focus:border-red-500 outline-none bg-transparent p-2 print:p-0 print:border-0 print:border-b-2 print:border-gray-400 resize-none text-sm"
|
| 471 |
-
rows={3}
|
| 472 |
-
/>
|
| 473 |
</div>
|
|
|
|
|
|
|
| 474 |
<div>
|
| 475 |
-
<span className="text-
|
| 476 |
-
<textarea
|
| 477 |
-
value={formData.biopsyNotes}
|
| 478 |
-
onChange={(e) => handleFormChange('biopsyNotes', e.target.value)}
|
| 479 |
-
className="w-full text-gray-900 mt-1 print:mt-0.5 print:text-xs border border-gray-300 focus:border-red-500 outline-none bg-transparent p-2 print:p-0 print:border-0 print:border-b-2 print:border-gray-400 resize-none text-sm"
|
| 480 |
-
rows={3}
|
| 481 |
-
/>
|
| 482 |
</div>
|
| 483 |
</div>
|
| 484 |
</div>
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
<div className="space-y-2 text-sm print:space-y-0.5 print:text-xs">
|
| 491 |
-
<div>
|
| 492 |
-
<span className="text-xs font-semibold text-gray-600">Colposcopic Diagnosis:</span>
|
| 493 |
-
<textarea
|
| 494 |
-
value={formData.diagnosis}
|
| 495 |
-
onChange={(e) => handleFormChange('diagnosis', e.target.value)}
|
| 496 |
-
className="w-full text-gray-900 mt-1 print:mt-0.5 print:text-xs border border-gray-300 focus:border-blue-500 outline-none bg-transparent p-2 print:p-0 print:border-0 print:border-b-2 print:border-gray-400 resize-none"
|
| 497 |
-
rows={2}
|
| 498 |
-
/>
|
| 499 |
-
</div>
|
| 500 |
-
<div>
|
| 501 |
-
<span className="text-xs font-semibold text-gray-600">Treatment Plan:</span>
|
| 502 |
-
<textarea
|
| 503 |
-
value={formData.treatmentPlan}
|
| 504 |
-
onChange={(e) => handleFormChange('treatmentPlan', e.target.value)}
|
| 505 |
-
className="w-full text-gray-900 mt-1 print:mt-0.5 print:text-xs border border-gray-300 focus:border-blue-500 outline-none bg-transparent p-2 print:p-0 print:border-0 print:border-b-2 print:border-gray-400 resize-none"
|
| 506 |
-
rows={2}
|
| 507 |
-
/>
|
| 508 |
</div>
|
| 509 |
-
<div>
|
| 510 |
-
<
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
</div>
|
| 518 |
</div>
|
| 519 |
-
</div>
|
| 520 |
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
<p className="text-xs font-semibold text-gray-600 mb-8 print:mb-3">Patient Signature</p>
|
| 527 |
-
<div className="border-b-2 border-gray-700 w-full h-12 print:h-6"></div>
|
| 528 |
-
</div>
|
| 529 |
-
<div>
|
| 530 |
-
<p className="text-xs font-semibold text-gray-600 mb-8 print:mb-3">Doctor Signature</p>
|
| 531 |
-
<input
|
| 532 |
-
type="text"
|
| 533 |
-
value={formData.doctorSignature}
|
| 534 |
-
onChange={(e) => handleFormChange('doctorSignature', e.target.value)}
|
| 535 |
-
placeholder="Dr. Name"
|
| 536 |
-
className="w-full border-b-2 border-gray-700 outline-none bg-transparent text-xs print:text-xs print:border-b-2 print:border-gray-700"
|
| 537 |
-
/>
|
| 538 |
-
</div>
|
| 539 |
-
<div>
|
| 540 |
-
<p className="text-xs font-semibold text-gray-600 mb-2 print:mb-1">Date</p>
|
| 541 |
-
<input
|
| 542 |
-
type="text"
|
| 543 |
-
value={formData.signatureDate}
|
| 544 |
-
onChange={(e) => handleFormChange('signatureDate', e.target.value)}
|
| 545 |
-
className="w-full border-b-2 border-gray-700 outline-none bg-transparent text-xs print:text-xs print:border-b-2 print:border-gray-700"
|
| 546 |
-
/>
|
| 547 |
</div>
|
|
|
|
| 548 |
</div>
|
| 549 |
-
</div>
|
| 550 |
|
| 551 |
</div>
|
| 552 |
</div>
|
|
|
|
| 553 |
</div>
|
| 554 |
|
| 555 |
{/* Image Library Sidebar */}
|
| 556 |
-
<div className="lg:col-span-1 print:hidden">
|
| 557 |
-
<div className="bg-white border border-gray-
|
| 558 |
-
<h3 className="text-sm font-bold text-
|
| 559 |
{capturedImages.length > 0 ? (
|
| 560 |
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
|
| 561 |
{Object.entries(
|
|
@@ -568,17 +1082,16 @@ export function ReportPage({
|
|
| 568 |
).map(([stepId, images]) => (
|
| 569 |
<div key={stepId} className="bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200 rounded-lg p-3 shadow-sm">
|
| 570 |
<div className="flex items-center gap-2 mb-2">
|
| 571 |
-
<div className={`w-2 h-2 rounded-full ${
|
| 572 |
-
stepId === 'native' ? 'bg-blue-500' :
|
| 573 |
stepId === 'acetowhite' ? 'bg-purple-500' :
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
<span className="text-xs font-bold text-gray-700 uppercase">
|
| 578 |
-
{stepId === 'native' ? 'Native' :
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
</span>
|
| 583 |
</div>
|
| 584 |
<div className="grid grid-cols-2 gap-2">
|
|
@@ -594,7 +1107,7 @@ export function ReportPage({
|
|
| 594 |
alt={`Captured ${stepId}`}
|
| 595 |
draggable
|
| 596 |
onDragStart={(e) => handleCapturedImageDragStart(e, img)}
|
| 597 |
-
className="w-full h-20 object-cover border border-gray-200 cursor-grab hover:border-
|
| 598 |
/>
|
| 599 |
)}
|
| 600 |
</div>
|
|
@@ -626,13 +1139,15 @@ export function ReportPage({
|
|
| 626 |
</div>
|
| 627 |
<h3 className="text-2xl font-bold text-gray-900 mb-2 text-center">Save Report</h3>
|
| 628 |
<p className="text-gray-600 text-center mb-6">
|
| 629 |
-
Your colposcopy examination report is ready. Click "Save & Continue" to export the PDF and proceed to the patient registry.
|
| 630 |
</p>
|
| 631 |
<div className="space-y-3">
|
| 632 |
<button
|
| 633 |
onClick={() => {
|
| 634 |
handleExportPDF();
|
| 635 |
setTimeout(() => {
|
|
|
|
|
|
|
| 636 |
setShowCompletionModal(false);
|
| 637 |
(onGoToPatientRecords || onNext)();
|
| 638 |
}, 1000);
|
|
@@ -640,7 +1155,7 @@ export function ReportPage({
|
|
| 640 |
className="w-full px-6 py-3 bg-gradient-to-r from-[#05998c] to-[#047569] text-white rounded-xl hover:from-[#047569] hover:to-[#036356] transition-all duration-200 font-semibold shadow-md hover:shadow-lg flex items-center justify-center gap-2"
|
| 641 |
>
|
| 642 |
<Download className="w-4 h-4" />
|
| 643 |
-
Save & Continue
|
| 644 |
</button>
|
| 645 |
<button
|
| 646 |
onClick={() => setShowCompletionModal(false)}
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from 'react';
|
| 2 |
+
import { ArrowLeft, Download, FileText, CheckCircle, Camera, Sparkles, Loader2 } from 'lucide-react';
|
| 3 |
import html2pdf from 'html2pdf.js';
|
| 4 |
+
import { SYSTEM_PROMPT, DEMO_STEP_FINDINGS } from '../config/geminiConfig';
|
| 5 |
+
import { sessionStore } from '../store/sessionStore';
|
| 6 |
+
|
| 7 |
|
| 8 |
type CapturedImage = {
|
| 9 |
id: string;
|
|
|
|
| 19 |
patientData?: any;
|
| 20 |
capturedImages?: CapturedImage[];
|
| 21 |
biopsyData?: any;
|
| 22 |
+
stepFindings?: Record<string, any>;
|
| 23 |
};
|
| 24 |
|
| 25 |
export function ReportPage({
|
|
|
|
| 29 |
patientData = {},
|
| 30 |
capturedImages = [],
|
| 31 |
biopsyData = {},
|
| 32 |
+
stepFindings = {}
|
| 33 |
}: Props) {
|
| 34 |
type ImageBucket = 'native' | 'acetic' | 'lugol' | 'biopsy';
|
| 35 |
type ImageItem = {
|
|
|
|
| 41 |
|
| 42 |
// Form state with dummy data
|
| 43 |
const [formData, setFormData] = useState({
|
| 44 |
+
regNo: '',
|
| 45 |
+
name: '',
|
| 46 |
+
opdNo: '',
|
| 47 |
+
age: '',
|
| 48 |
+
parity: '',
|
| 49 |
+
bloodGroup: '',
|
| 50 |
+
lmp: '',
|
| 51 |
+
pregnancyStatus: '',
|
| 52 |
+
gestationalAgeWeeks: '',
|
| 53 |
+
monthsSinceLastDelivery: '',
|
| 54 |
+
monthsSinceAbortion: '',
|
| 55 |
+
wo: '',
|
| 56 |
+
indication: '',
|
| 57 |
+
complaint: '',
|
| 58 |
+
examQuality: '',
|
| 59 |
+
transformationZone: '',
|
| 60 |
+
acetowL: '',
|
| 61 |
+
nativeFindings: '',
|
| 62 |
+
aceticFindings: '',
|
| 63 |
+
colposcopicFindings: '',
|
| 64 |
+
treatmentPlan: '',
|
| 65 |
+
followUp: '',
|
| 66 |
+
biopsySites: '',
|
| 67 |
+
biopsyNotes: '',
|
| 68 |
doctorSignature: '',
|
| 69 |
signatureDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
| 70 |
});
|
| 71 |
|
| 72 |
+
|
| 73 |
const [imageBuckets, setImageBuckets] = useState<Record<ImageBucket, ImageItem[]>>({
|
| 74 |
native: [],
|
| 75 |
acetic: [],
|
|
|
|
| 77 |
biopsy: []
|
| 78 |
});
|
| 79 |
const [showCompletionModal, setShowCompletionModal] = useState(false);
|
| 80 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 81 |
+
const [aiError, setAiError] = useState<string | null>(null);
|
| 82 |
const imageBucketsRef = useRef(imageBuckets);
|
| 83 |
const reportContentRef = useRef<HTMLDivElement>(null);
|
| 84 |
+
const patientId = sessionStore.get().patientInfo?.id;
|
| 85 |
|
| 86 |
const handleFormChange = (field: keyof typeof formData, value: string) => {
|
| 87 |
setFormData(prev => ({
|
|
|
|
| 90 |
}));
|
| 91 |
};
|
| 92 |
|
| 93 |
+
// Persist all report form fields to sessionStore on every change
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
sessionStore.merge({ reportFormData: formData as unknown as Record<string, string> });
|
| 96 |
+
// formData intentionally triggers this; don't add extra deps
|
| 97 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 98 |
+
}, [JSON.stringify(formData)]);
|
| 99 |
+
|
| 100 |
+
/** Convert a data-URL or object-URL to a Gemini-compatible base64 image part */
|
| 101 |
+
const toImagePart = async (src: string): Promise<{ inlineData: { data: string; mimeType: string } } | null> => {
|
| 102 |
+
try {
|
| 103 |
+
const res = await fetch(src);
|
| 104 |
+
const blob = await res.blob();
|
| 105 |
+
return new Promise(resolve => {
|
| 106 |
+
const reader = new FileReader();
|
| 107 |
+
reader.onloadend = () => {
|
| 108 |
+
const dataUrl = reader.result as string;
|
| 109 |
+
const [header, data] = dataUrl.split(',');
|
| 110 |
+
const mimeType = header.match(/:(.*?);/)?.[1] ?? 'image/png';
|
| 111 |
+
resolve({ inlineData: { data, mimeType } });
|
| 112 |
+
};
|
| 113 |
+
reader.onerror = () => resolve(null);
|
| 114 |
+
reader.readAsDataURL(blob);
|
| 115 |
+
});
|
| 116 |
+
} catch {
|
| 117 |
+
return null;
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
// On mount: restore all previously saved session data into the form
|
| 122 |
+
useEffect(() => {
|
| 123 |
+
const session = sessionStore.get();
|
| 124 |
+
console.log('[ReportPage] Session data loaded:', session);
|
| 125 |
+
|
| 126 |
+
setFormData(prev => {
|
| 127 |
+
let next = { ...prev } as any;
|
| 128 |
+
|
| 129 |
+
// 1. Overlay with any previously saved report form state
|
| 130 |
+
if (session.reportFormData) {
|
| 131 |
+
next = { ...next, ...session.reportFormData };
|
| 132 |
+
console.log('[ReportPage] Loaded reportFormData:', session.reportFormData);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// 2. Overlay with patient history data (name, age, parity, etc.)
|
| 136 |
+
const ph = session.patientHistory;
|
| 137 |
+
if (ph) {
|
| 138 |
+
if (ph.name) next.name = ph.name;
|
| 139 |
+
if (ph.age) next.age = ph.age;
|
| 140 |
+
if (ph.parity) next.parity = ph.parity;
|
| 141 |
+
if (ph.bloodGroup) next.bloodGroup = ph.bloodGroup;
|
| 142 |
+
if (ph.pregnancyStatus) next.pregnancyStatus = ph.pregnancyStatus;
|
| 143 |
+
if (ph.gestationalAgeWeeks) next.gestationalAgeWeeks = ph.gestationalAgeWeeks;
|
| 144 |
+
if (ph.monthsSinceLastDelivery) next.monthsSinceLastDelivery = ph.monthsSinceLastDelivery;
|
| 145 |
+
if (ph.monthsSinceAbortion) next.monthsSinceAbortion = ph.monthsSinceAbortion;
|
| 146 |
+
// Build indication from screening results if present
|
| 147 |
+
const indicationParts: string[] = [];
|
| 148 |
+
if (ph.papSmearResult) indicationParts.push(`PAP: ${ph.papSmearResult}`);
|
| 149 |
+
if (ph.hpvStatus) indicationParts.push(`HPV: ${ph.hpvStatus}`);
|
| 150 |
+
if (ph.hpvDnaTypes) indicationParts.push(`HPV types: ${ph.hpvDnaTypes}`);
|
| 151 |
+
if (indicationParts.length > 0) next.indication = indicationParts.join(' / ');
|
| 152 |
+
// Chief complaint from symptoms
|
| 153 |
+
const sx: string[] = [];
|
| 154 |
+
if (ph.postCoitalBleeding) sx.push('post-coital bleeding');
|
| 155 |
+
if (ph.interMenstrualBleeding) sx.push('inter-menstrual bleeding');
|
| 156 |
+
if (ph.persistentDischarge) sx.push('persistent discharge');
|
| 157 |
+
if (ph.symptomsNotes) sx.push(ph.symptomsNotes);
|
| 158 |
+
if (sx.length > 0) next.complaint = sx.join(', ');
|
| 159 |
+
console.log('[ReportPage] Loaded patientHistory:', ph);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// 3. Overlay with basic registry info
|
| 163 |
+
if (session.patientInfo) {
|
| 164 |
+
if (session.patientInfo.id) next.regNo = session.patientInfo.id;
|
| 165 |
+
if (session.patientInfo.name) next.name = session.patientInfo.name;
|
| 166 |
+
console.log('[ReportPage] Loaded patientInfo:', session.patientInfo);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
console.log('[ReportPage] Final formData:', next);
|
| 170 |
+
return next;
|
| 171 |
+
});
|
| 172 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 173 |
+
}, []);
|
| 174 |
+
|
| 175 |
+
const generateWithAI = async () => {
|
| 176 |
+
const apiKey = import.meta.env.VITE_GEMINI_API_KEY as string | undefined;
|
| 177 |
+
if (!apiKey) {
|
| 178 |
+
setAiError('API key missing β add VITE_GEMINI_API_KEY to your .env file and restart the dev server.');
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
setIsGenerating(true);
|
| 183 |
+
setAiError(null);
|
| 184 |
+
|
| 185 |
+
try {
|
| 186 |
+
// Helper: format one step's ImagingObservations into readable text
|
| 187 |
+
const fmtStep = (label: string, obs: any): string => {
|
| 188 |
+
if (!obs || !Object.keys(obs).length) return `${label}: No observations recorded.`;
|
| 189 |
+
const lines: string[] = [];
|
| 190 |
+
// Common fields
|
| 191 |
+
if (obs.cervixFullyVisible) lines.push(`Cervix fully visible: ${obs.cervixFullyVisible}`);
|
| 192 |
+
const obscured = obs.obscuredBy
|
| 193 |
+
? Object.entries(obs.obscuredBy as Record<string, boolean>).filter(([, v]) => v).map(([k]) => k)
|
| 194 |
+
: [];
|
| 195 |
+
if (obscured.length) lines.push(`Obscured by: ${obscured.join(', ')}`);
|
| 196 |
+
if (obs.adequacyNotes) lines.push(`Adequacy notes: ${obs.adequacyNotes}`);
|
| 197 |
+
if (obs.scjVisibility) lines.push(`SCJ visibility: ${obs.scjVisibility}`);
|
| 198 |
+
if (obs.scjNotes) lines.push(`SCJ notes: ${obs.scjNotes}`);
|
| 199 |
+
if (obs.tzType) lines.push(`Transformation zone type: ${obs.tzType}`);
|
| 200 |
+
// Native-specific
|
| 201 |
+
if (obs.suspiciousAtNativeView) lines.push(`Suspicious at native view: Yes`);
|
| 202 |
+
const cfindings: string[] = [];
|
| 203 |
+
if (obs.obviousGrowths) cfindings.push('obvious growths/ulcers');
|
| 204 |
+
if (obs.contactBleeding) cfindings.push('contact bleeding');
|
| 205 |
+
if (obs.irregularSurface) cfindings.push('irregular surface');
|
| 206 |
+
if (obs.other) cfindings.push('other findings');
|
| 207 |
+
if (cfindings.length) lines.push(`Clinical findings: ${cfindings.join(', ')}`);
|
| 208 |
+
// Acetowhite-specific
|
| 209 |
+
if (obs.acetowhiteDensity) lines.push(`Acetowhite density: ${obs.acetowhiteDensity}`);
|
| 210 |
+
if (obs.acetowhiteType) lines.push(`Acetowhite type: ${obs.acetowhiteType}`);
|
| 211 |
+
if (obs.acetowhiteMargins) lines.push(`Lesion margins: ${obs.acetowhiteMargins}`);
|
| 212 |
+
if (obs.lesionExtent) lines.push(`Lesion extent: ${obs.lesionExtent}`);
|
| 213 |
+
if (obs.vascularPattern) lines.push(`Vascular pattern: ${obs.vascularPattern}`);
|
| 214 |
+
// Green-filter-specific
|
| 215 |
+
if (obs.mosaicType) lines.push(`Mosaic pattern: ${obs.mosaicType}`);
|
| 216 |
+
if (obs.punctationType) lines.push(`Punctation pattern: ${obs.punctationType}`);
|
| 217 |
+
if (obs.atypicalVessels) lines.push(`Atypical vessels: Yes`);
|
| 218 |
+
if (obs.vascularNotes) lines.push(`Vascular notes: ${obs.vascularNotes}`);
|
| 219 |
+
// Lugol-specific
|
| 220 |
+
if (obs.iodineSatining) lines.push(`Iodine staining: ${obs.iodineSatining}`);
|
| 221 |
+
if (obs.schillerTest) lines.push(`Schiller test: ${obs.schillerTest}`);
|
| 222 |
+
if (obs.lesionExtentLugol) lines.push(`Iodine-negative area extent: ${obs.lesionExtentLugol}`);
|
| 223 |
+
// Free-text notes (always last)
|
| 224 |
+
if (obs.additionalNotes) lines.push(`Additional notes: ${obs.additionalNotes}`);
|
| 225 |
+
return `${label}:\n${lines.map(l => ' ' + l).join('\n')}`;
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
// Merge session step findings with prop findings (prop takes precedence for real-time data)
|
| 229 |
+
const session = sessionStore.get();
|
| 230 |
+
const mergedFindings = { ...(session.stepFindings ?? {}), ...(stepFindings as Record<string, any>) };
|
| 231 |
+
|
| 232 |
+
// Use real findings if available (from props or session), otherwise fall back to demo data
|
| 233 |
+
const hasRealFindings = Object.values(mergedFindings).some(obs => obs && Object.keys(obs).length > 0);
|
| 234 |
+
const sf = hasRealFindings ? mergedFindings : DEMO_STEP_FINDINGS;
|
| 235 |
+
|
| 236 |
+
const stepFindingsText = [
|
| 237 |
+
fmtStep('NATIVE EXAMINATION', sf.native),
|
| 238 |
+
fmtStep('ACETOWHITE EXAMINATION (3-5% acetic acid applied)', sf.acetowhite),
|
| 239 |
+
fmtStep('GREEN FILTER EXAMINATION', sf.greenFilter),
|
| 240 |
+
fmtStep('LUGOL EXAMINATION (Schiller iodine test)', sf.lugol),
|
| 241 |
+
].join('\n\n');
|
| 242 |
+
|
| 243 |
+
const usingDemoData = !hasRealFindings;
|
| 244 |
+
|
| 245 |
+
// Full prompt = system instruction (from geminiConfig.ts) + patient data + observations
|
| 246 |
+
let context = `${SYSTEM_PROMPT}
|
| 247 |
+
|
| 248 |
+
== PATIENT DEMOGRAPHICS ==
|
| 249 |
+
Name: ${formData.name} | Age: ${formData.age} | Parity: ${formData.parity} | Marital status: ${formData.wo}
|
| 250 |
+
OPD No: ${formData.opdNo} | Reg No: ${formData.regNo}
|
| 251 |
+
|
| 252 |
+
== CLINICAL HISTORY (from intake form) ==
|
| 253 |
+
Indication for colposcopy: ${formData.indication}
|
| 254 |
+
Chief complaint: ${formData.complaint}`;
|
| 255 |
+
|
| 256 |
+
// Enrich with the full patientHistory data from the intake form
|
| 257 |
+
const ph = session.patientHistory;
|
| 258 |
+
if (ph) {
|
| 259 |
+
const pastProc = ph.pastProcedures
|
| 260 |
+
? Object.entries(ph.pastProcedures).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ') || 'None'
|
| 261 |
+
: 'Not recorded';
|
| 262 |
+
const immunoList = ph.immunosuppression
|
| 263 |
+
? Object.entries(ph.immunosuppression).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ') || 'None'
|
| 264 |
+
: 'Not recorded';
|
| 265 |
+
const symptoms = [
|
| 266 |
+
ph.postCoitalBleeding && 'Post-coital bleeding',
|
| 267 |
+
ph.interMenstrualBleeding && 'Inter-menstrual / post-menopausal bleeding',
|
| 268 |
+
ph.persistentDischarge && 'Persistent discharge',
|
| 269 |
+
].filter(Boolean).join(', ') || 'None reported';
|
| 270 |
+
context += `
|
| 271 |
+
Blood group: ${ph.bloodGroup || 'Not recorded'} | Menstrual status: ${ph.menstrualStatus || 'Not recorded'}
|
| 272 |
+
HPV status: ${ph.hpvStatus || 'Not recorded'} | HPV vaccination: ${ph.hpvVaccination || 'Not recorded'}
|
| 273 |
+
Smoking: ${ph.smoking || 'Not recorded'} | Immunosuppression: ${immunoList}
|
| 274 |
+
PAP smear result: ${ph.papSmearResult || 'Not done'} | HPV DNA types: ${ph.hpvDnaTypes || 'Not recorded'}
|
| 275 |
+
Presenting symptoms: ${symptoms}
|
| 276 |
+
Past cervical procedures: ${pastProc}
|
| 277 |
+
Screening notes: ${ph.screeningNotes || 'None'}
|
| 278 |
+
Risk factor notes: ${ph.riskFactorsNotes || 'None'}`;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
context += `
|
| 282 |
+
|
| 283 |
+
== EXAMINATION PARAMETERS ==
|
| 284 |
+
Examination quality: ${formData.examQuality}
|
| 285 |
+
Transformation zone type: ${formData.transformationZone}
|
| 286 |
+
Acetowhite lesion present: ${formData.acetowL}
|
| 287 |
+
|
| 288 |
+
== BIOPSY INFORMATION ==
|
| 289 |
+
Sites biopsied: ${formData.biopsySites}
|
| 290 |
+
Biopsy notes: ${formData.biopsyNotes}
|
| 291 |
+
|
| 292 |
+
== STEP-BY-STEP CLINICAL OBSERVATIONS${usingDemoData ? ' [DEMO DATA β real observations not yet recorded]' : ''} ==
|
| 293 |
+
${stepFindingsText}`;
|
| 294 |
+
// Append acetic acid clinical findings (category + feature checkboxes)
|
| 295 |
+
const af = session.aceticFindings;
|
| 296 |
+
if (af && (Object.values(af.selectedCategories ?? {}).some(Boolean) ||
|
| 297 |
+
Object.values(af.selectedFindings ?? {}).some(Boolean))) {
|
| 298 |
+
const checkedCategories = Object.entries(af.selectedCategories ?? {})
|
| 299 |
+
.filter(([, v]) => v).map(([k]) => k);
|
| 300 |
+
const checkedFeatures = Object.entries(af.selectedFindings ?? {})
|
| 301 |
+
.filter(([, v]) => v).map(([k]) => k);
|
| 302 |
+
context += `
|
| 303 |
+
|
| 304 |
+
== ACETOWHITE CLINICAL FINDINGS (from acetic acid examination form) ==
|
| 305 |
+
Checked categories: ${checkedCategories.join(', ') || 'None'}
|
| 306 |
+
Checked features:\n${checkedFeatures.map(f => ' - ' + f).join('\n') || ' (none selected)'}`;
|
| 307 |
+
if (af.additionalNotes) {
|
| 308 |
+
context += `\nClinician notes: ${af.additionalNotes}`;
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// Append biopsy marking data from session if available
|
| 313 |
+
const bm = session.biopsyMarkings;
|
| 314 |
+
if (bm && (bm.lesionMarks.length > 0 || bm.totalSwedeScore > 0)) {
|
| 315 |
+
// Prefer stored marksByType; fall back to deriving it from lesionMarks
|
| 316 |
+
// (guards against old sessions where marksByType was wiped on mount)
|
| 317 |
+
const effectiveByType =
|
| 318 |
+
bm.marksByType && Object.keys(bm.marksByType).length > 0
|
| 319 |
+
? bm.marksByType
|
| 320 |
+
: bm.lesionMarks.reduce((acc, m) => {
|
| 321 |
+
if (!acc[m.typeCode]) acc[m.typeCode] = { type: m.type, hours: [] };
|
| 322 |
+
if (!acc[m.typeCode].hours.includes(m.clockHour)) acc[m.typeCode].hours.push(m.clockHour);
|
| 323 |
+
return acc;
|
| 324 |
+
}, {} as Record<string, { type: string; hours: number[] }>);
|
| 325 |
+
|
| 326 |
+
const marksText = Object.entries(effectiveByType)
|
| 327 |
+
.map(([code, d]) => ` ${d.type} (${code}): clock positions ${d.hours.sort((a, b) => a - b).map(h => h + 'h').join(', ')}`)
|
| 328 |
+
.join('\n');
|
| 329 |
+
|
| 330 |
+
context += `
|
| 331 |
+
|
| 332 |
+
== BIOPSY LESION MAPPING (from BiopsyMarking tool) ==
|
| 333 |
+
Swede Score Total: ${bm.totalSwedeScore}/10
|
| 334 |
+
Lesion marks on cervical clock:
|
| 335 |
+
${marksText || ' (no lesions marked)'}
|
| 336 |
+
Swede Score breakdown:
|
| 337 |
+
Aceto Uptake: ${bm.swedeScores.acetoUptake ?? 'not scored'}/2
|
| 338 |
+
Margins & Surface: ${bm.swedeScores.marginsAndSurface ?? 'not scored'}/2
|
| 339 |
+
Vessels: ${bm.swedeScores.vessels ?? 'not scored'}/2
|
| 340 |
+
Lesion Size: ${bm.swedeScores.lesionSize ?? 'not scored'}/2
|
| 341 |
+
Iodine Staining: ${bm.swedeScores.iodineStaining ?? 'not scored'}/2`;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
// Collect up to 4 representative captured images (one per step)
|
| 346 |
+
const imageParts: any[] = [];
|
| 347 |
+
const stepOrder: Record<string, number> = { native: 0, acetowhite: 1, greenFilter: 2, lugol: 3, biopsyMarking: 4 };
|
| 348 |
+
const sorted = [...capturedImages].filter(i => i.type === 'image').sort((a, b) => (stepOrder[a.stepId] ?? 99) - (stepOrder[b.stepId] ?? 99));
|
| 349 |
+
const seen = new Set<string>();
|
| 350 |
+
for (const img of sorted) {
|
| 351 |
+
if (seen.has(img.stepId)) continue;
|
| 352 |
+
seen.add(img.stepId);
|
| 353 |
+
const part = await toImagePart(img.src);
|
| 354 |
+
if (part) imageParts.push(part);
|
| 355 |
+
if (imageParts.length >= 4) break;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// Step 1: discover which models this key has access to
|
| 359 |
+
const baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
| 360 |
+
const listRes = await fetch(`${baseUrl}/models?key=${apiKey}`);
|
| 361 |
+
if (!listRes.ok) {
|
| 362 |
+
const e = await listRes.json();
|
| 363 |
+
throw new Error(e?.error?.message || `ListModels failed: HTTP ${listRes.status}`);
|
| 364 |
+
}
|
| 365 |
+
const listData = await listRes.json();
|
| 366 |
+
const allModels: any[] = listData.models || [];
|
| 367 |
+
|
| 368 |
+
// Prefer flash models that support generateContent; fall back to any that do
|
| 369 |
+
const pick =
|
| 370 |
+
allModels.find(m => m.name?.includes('flash') && m.supportedGenerationMethods?.includes('generateContent')) ||
|
| 371 |
+
allModels.find(m => m.supportedGenerationMethods?.includes('generateContent'));
|
| 372 |
+
|
| 373 |
+
if (!pick) throw new Error('No generateContent-capable model found for this API key. Check aistudio.google.com');
|
| 374 |
+
|
| 375 |
+
// pick.name is like "models/gemini-1.5-flash-001" β strip the "models/" prefix for the URL
|
| 376 |
+
const modelId = (pick.name as string).replace(/^models\//, '');
|
| 377 |
+
console.log('[Gemini] using model:', modelId);
|
| 378 |
+
|
| 379 |
+
// Step 2: call generateContent on the discovered model
|
| 380 |
+
const endpoint = `${baseUrl}/models/${modelId}:generateContent?key=${apiKey}`;
|
| 381 |
+
const reqBody = {
|
| 382 |
+
contents: [{
|
| 383 |
+
parts: [
|
| 384 |
+
{ text: context },
|
| 385 |
+
...imageParts.map((p: any) => ({
|
| 386 |
+
inlineData: { data: p.inlineData.data, mimeType: p.inlineData.mimeType }
|
| 387 |
+
}))
|
| 388 |
+
]
|
| 389 |
+
}],
|
| 390 |
+
generationConfig: { temperature: 0.2, maxOutputTokens: 8192 }
|
| 391 |
+
};
|
| 392 |
+
|
| 393 |
+
const res = await fetch(endpoint, {
|
| 394 |
+
method: 'POST',
|
| 395 |
+
headers: { 'Content-Type': 'application/json' },
|
| 396 |
+
body: JSON.stringify(reqBody)
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
if (!res.ok) {
|
| 400 |
+
const errData = await res.json();
|
| 401 |
+
throw new Error(errData?.error?.message || `HTTP ${res.status}`);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
const data = await res.json();
|
| 405 |
+
const text = (data.candidates?.[0]?.content?.parts?.[0]?.text ?? '').trim();
|
| 406 |
+
|
| 407 |
+
// Parse JSON β strip any markdown fences if present
|
| 408 |
+
let jsonStr = text.replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '').trim();
|
| 409 |
+
|
| 410 |
+
// Attempt to recover truncated JSON: find the last complete "key": "value", pair and close the object
|
| 411 |
+
let parsed: {
|
| 412 |
+
examQuality?: string; transformationZone?: string; acetowL?: string;
|
| 413 |
+
biopsySites?: string; biopsyNotes?: string;
|
| 414 |
+
nativeFindings?: string; aceticFindings?: string;
|
| 415 |
+
colposcopicFindings?: string; treatmentPlan?: string; followUp?: string;
|
| 416 |
+
};
|
| 417 |
+
try {
|
| 418 |
+
parsed = JSON.parse(jsonStr);
|
| 419 |
+
} catch {
|
| 420 |
+
// Try to salvage partial JSON by chopping at the last complete value
|
| 421 |
+
const lastGoodPos = Math.max(
|
| 422 |
+
jsonStr.lastIndexOf('",\n'),
|
| 423 |
+
jsonStr.lastIndexOf('", \n'),
|
| 424 |
+
jsonStr.lastIndexOf('"\n}'),
|
| 425 |
+
);
|
| 426 |
+
if (lastGoodPos > 0) {
|
| 427 |
+
const candidate = jsonStr.substring(0, lastGoodPos + 1) + '\n}';
|
| 428 |
+
try { parsed = JSON.parse(candidate); }
|
| 429 |
+
catch { throw new Error('AI returned malformed JSON even after recovery attempt'); }
|
| 430 |
+
} else {
|
| 431 |
+
throw new Error('AI returned malformed JSON and recovery was not possible');
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
setFormData(prev => ({
|
| 436 |
+
...prev,
|
| 437 |
+
// Select fields β only apply if value matches allowed options
|
| 438 |
+
...(parsed.examQuality && ['Adequate', 'Inadequate'].includes(parsed.examQuality)
|
| 439 |
+
? { examQuality: parsed.examQuality } : {}),
|
| 440 |
+
...(parsed.transformationZone && ['I', 'II', 'III'].includes(parsed.transformationZone)
|
| 441 |
+
? { transformationZone: parsed.transformationZone } : {}),
|
| 442 |
+
...(parsed.acetowL && ['Present', 'Absent'].includes(parsed.acetowL)
|
| 443 |
+
? { acetowL: parsed.acetowL } : {}),
|
| 444 |
+
// Free-text fields
|
| 445 |
+
...(parsed.biopsySites ? { biopsySites: parsed.biopsySites } : {}),
|
| 446 |
+
...(parsed.biopsyNotes ? { biopsyNotes: parsed.biopsyNotes } : {}),
|
| 447 |
+
...(parsed.nativeFindings ? { nativeFindings: parsed.nativeFindings } : {}),
|
| 448 |
+
...(parsed.aceticFindings ? { aceticFindings: parsed.aceticFindings } : {}),
|
| 449 |
+
...(parsed.colposcopicFindings ? { colposcopicFindings: parsed.colposcopicFindings } : {}),
|
| 450 |
+
...(parsed.treatmentPlan ? { treatmentPlan: parsed.treatmentPlan } : {}),
|
| 451 |
+
...(parsed.followUp ? { followUp: parsed.followUp } : {}),
|
| 452 |
+
}));
|
| 453 |
+
} catch (err: any) {
|
| 454 |
+
console.error('Gemini error:', err);
|
| 455 |
+
const msg = err?.message || err?.toString() || 'Unknown error';
|
| 456 |
+
setAiError(`AI generation failed: ${msg}`);
|
| 457 |
+
} finally {
|
| 458 |
+
setIsGenerating(false);
|
| 459 |
+
}
|
| 460 |
+
};
|
| 461 |
+
|
| 462 |
const handleExportPDF = () => {
|
| 463 |
if (!reportContentRef.current) return;
|
| 464 |
+
|
| 465 |
const element = reportContentRef.current;
|
| 466 |
+
const patientName = formData.name || patientData?.name || 'Patient';
|
| 467 |
const reportDate = new Date().toISOString().split('T')[0];
|
| 468 |
+
|
| 469 |
+
// ββ Temporarily replace form controls with plain-text divs ββββββββββββ
|
| 470 |
+
// html2canvas clips text inside inputs/textareas when parent has overflow-hidden.
|
| 471 |
+
// We hide each control and insert a matching div overlay so the snapshot only
|
| 472 |
+
// sees normal DOM text β fully compatible with overflow-hidden parents.
|
| 473 |
+
type Replaced = { ctrl: HTMLElement; overlay: HTMLDivElement };
|
| 474 |
+
const replaced: Replaced[] = [];
|
| 475 |
+
|
| 476 |
+
element
|
| 477 |
+
.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
| 478 |
+
'input, textarea, select'
|
| 479 |
+
)
|
| 480 |
+
.forEach((ctrl) => {
|
| 481 |
+
const overlay = document.createElement('div');
|
| 482 |
+
overlay.textContent = (ctrl as HTMLInputElement).value;
|
| 483 |
+
if (ctrl.tagName === 'TEXTAREA') {
|
| 484 |
+
overlay.style.cssText =
|
| 485 |
+
'border:1px solid #e5e7eb;border-radius:6px;padding:8px;' +
|
| 486 |
+
'font-size:13px;color:#111827;white-space:pre-wrap;word-break:break-word;min-height:36px;';
|
| 487 |
+
} else {
|
| 488 |
+
overlay.style.cssText =
|
| 489 |
+
'border-bottom:2px solid #9ca3af;padding:3px 0 5px;' +
|
| 490 |
+
'font-size:13px;color:#111827;font-weight:500;min-height:22px;word-break:break-word;';
|
| 491 |
+
}
|
| 492 |
+
ctrl.parentNode!.insertBefore(overlay, ctrl);
|
| 493 |
+
ctrl.style.display = 'none';
|
| 494 |
+
replaced.push({ ctrl: ctrl as HTMLElement, overlay });
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
const restore = () => {
|
| 498 |
+
replaced.forEach(({ ctrl, overlay }) => {
|
| 499 |
+
overlay.remove();
|
| 500 |
+
ctrl.style.display = '';
|
| 501 |
+
});
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
const options = {
|
| 505 |
+
margin: [12, 12, 12, 12] as [number, number, number, number],
|
| 506 |
filename: `Colposcopy_Report_${patientName}_${reportDate}.pdf`,
|
| 507 |
image: { type: 'jpeg' as const, quality: 0.98 },
|
| 508 |
+
html2canvas: {
|
| 509 |
+
scale: 2,
|
| 510 |
+
useCORS: true,
|
| 511 |
+
letterRendering: true,
|
| 512 |
+
scrollX: 0,
|
| 513 |
+
scrollY: -window.scrollY,
|
| 514 |
+
windowWidth: 794,
|
| 515 |
+
},
|
| 516 |
+
jsPDF: { orientation: 'portrait' as const, unit: 'mm', format: 'a4', compress: true },
|
| 517 |
+
pagebreak: { mode: ['css', 'legacy'] as string[] },
|
| 518 |
};
|
| 519 |
+
|
| 520 |
+
html2pdf()
|
| 521 |
+
.set(options)
|
| 522 |
+
.from(element)
|
| 523 |
+
.save()
|
| 524 |
+
.then(restore)
|
| 525 |
+
.catch(restore);
|
| 526 |
};
|
| 527 |
|
| 528 |
+
|
| 529 |
const addFilesToBucket = (bucket: ImageBucket, files: File[]) => {
|
| 530 |
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
| 531 |
if (!imageFiles.length) return;
|
|
|
|
| 646 |
<div className="mb-8 bg-white rounded-2xl shadow-lg border border-gray-200 p-6 no-print print:hidden">
|
| 647 |
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 648 |
<div className="flex items-center gap-4">
|
| 649 |
+
<button
|
| 650 |
+
onClick={onBack}
|
| 651 |
className="p-3 hover:bg-gradient-to-br from-gray-100 to-gray-50 rounded-xl transition-all duration-200 text-gray-700 shadow-sm hover:shadow-md"
|
| 652 |
>
|
| 653 |
<ArrowLeft className="w-5 h-5" />
|
|
|
|
| 659 |
</div>
|
| 660 |
Colposcopy Examination Report
|
| 661 |
</h2>
|
| 662 |
+
<div className="flex items-center gap-3 mt-2 ml-1">
|
| 663 |
+
<p className="text-sm text-gray-500">Professional Medical Documentation</p>
|
| 664 |
+
{patientId && (
|
| 665 |
+
<div className="flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-full text-xs font-mono font-semibold text-[#0A2540]">
|
| 666 |
+
<span>ID:</span>
|
| 667 |
+
<span>{patientId}</span>
|
| 668 |
+
</div>
|
| 669 |
+
)}
|
| 670 |
+
</div>
|
| 671 |
</div>
|
| 672 |
</div>
|
| 673 |
+
<div className="flex flex-col gap-3">
|
| 674 |
+
<div className="flex gap-3">
|
| 675 |
+
<button
|
| 676 |
+
onClick={generateWithAI}
|
| 677 |
+
disabled={isGenerating}
|
| 678 |
+
className="px-5 py-3 bg-gradient-to-r from-violet-600 to-purple-700 text-white rounded-xl hover:from-violet-700 hover:to-purple-800 transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg font-medium disabled:opacity-60 disabled:cursor-not-allowed"
|
| 679 |
+
>
|
| 680 |
+
{isGenerating
|
| 681 |
+
? <><Loader2 className="w-4 h-4 animate-spin" />Generating...</>
|
| 682 |
+
: <><Sparkles className="w-4 h-4" />Generate with AI</>}
|
| 683 |
+
</button>
|
| 684 |
+
<button
|
| 685 |
+
onClick={handleExportPDF}
|
| 686 |
+
className="px-5 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg font-medium"
|
| 687 |
+
>
|
| 688 |
+
<Download className="w-4 h-4" />
|
| 689 |
+
Export PDF
|
| 690 |
+
</button>
|
| 691 |
+
<button
|
| 692 |
+
onClick={() => setShowCompletionModal(true)}
|
| 693 |
+
className="px-5 py-3 bg-gradient-to-r from-[#05998c] to-[#047569] text-white rounded-xl hover:from-[#047569] hover:to-[#036356] transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg font-medium"
|
| 694 |
+
>
|
| 695 |
+
<CheckCircle className="w-4 h-6" />
|
| 696 |
+
Save & Continue
|
| 697 |
+
|
| 698 |
+
</button>
|
| 699 |
+
</div>
|
| 700 |
+
{aiError && (
|
| 701 |
+
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{aiError}</p>
|
| 702 |
+
)}
|
| 703 |
</div>
|
| 704 |
</div>
|
| 705 |
</div>
|
|
|
|
| 708 |
|
| 709 |
{/* Report Content */}
|
| 710 |
<div className="lg:col-span-2 w-full" ref={reportContentRef}>
|
| 711 |
+
{/* A4 page rules β applied only when printing */}
|
| 712 |
+
<style>{`
|
| 713 |
+
@media print {
|
| 714 |
+
@page { size: A4 portrait; margin: 12mm; }
|
| 715 |
+
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
| 716 |
+
}
|
| 717 |
+
`}</style>
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
+
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden print:shadow-none print:border-0 print:rounded-none">
|
| 720 |
|
| 721 |
+
{/* ββ HEADER ββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 722 |
+
<div className="bg-gradient-to-r from-[#05998c] via-[#047569] to-[#035e55] p-6 print:p-4 relative overflow-hidden">
|
| 723 |
+
{/* decorative circles */}
|
| 724 |
+
<div className="absolute -top-8 -right-8 w-40 h-40 rounded-full bg-white/5 pointer-events-none" />
|
| 725 |
+
<div className="absolute -bottom-10 -left-6 w-28 h-28 rounded-full bg-white/5 pointer-events-none" />
|
| 726 |
+
|
| 727 |
+
<div className="relative flex items-center justify-between gap-4">
|
| 728 |
+
{/* Left β title */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
<div>
|
| 730 |
+
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1 print:text-[10px]">Colposcopy Examination</p>
|
| 731 |
+
<h1 className="text-2xl print:text-lg font-bold text-white leading-tight">COLPOSCOPY REPORT</h1>
|
| 732 |
+
<div className="flex items-center gap-3 mt-2 text-white/80 text-xs print:text-[10px]">
|
| 733 |
+
<span>Date: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
| 734 |
+
<span className="w-1 h-1 rounded-full bg-white/50 inline-block" />
|
| 735 |
+
<span>Pathora AI Colposcopy System</span>
|
| 736 |
+
</div>
|
| 737 |
</div>
|
| 738 |
+
|
| 739 |
+
{/* Right β logo */}
|
| 740 |
+
<div className="flex-shrink-0 bg-white/10 rounded-xl p-3 print:p-1.5">
|
| 741 |
+
<img
|
| 742 |
+
src="/white_logo.png"
|
| 743 |
+
alt="Pathora Logo"
|
| 744 |
+
className="h-16 w-16 print:h-10 print:w-10 object-contain"
|
| 745 |
/>
|
| 746 |
</div>
|
| 747 |
</div>
|
| 748 |
</div>
|
| 749 |
|
| 750 |
+
{/* thin accent bar */}
|
| 751 |
+
<div className="h-1 bg-gradient-to-r from-[#05998c] via-teal-300 to-transparent" />
|
| 752 |
+
|
| 753 |
+
<div className="p-8 space-y-5 print:p-3 print:space-y-2 overflow-y-auto print:overflow-visible print:text-[11px]">
|
| 754 |
+
|
| 755 |
+
{/* ββ PATIENT INFORMATION βββββββββββββββββββββββββββ */}
|
| 756 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300">
|
| 757 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 758 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Patient Information</h3>
|
|
|
|
|
|
|
|
|
|
| 759 |
</div>
|
| 760 |
+
<div className="p-4 print:p-2 grid grid-cols-2 md:grid-cols-3 gap-4 print:gap-2 text-sm">
|
| 761 |
+
<div>
|
| 762 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px]">Patient ID</span>
|
| 763 |
+
<input type="text" value={formData.regNo} onChange={(e) => handleFormChange('regNo', e.target.value)} className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400" />
|
| 764 |
+
</div>
|
| 765 |
+
<div>
|
| 766 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px]">Patient Name</span>
|
| 767 |
+
<input type="text" value={formData.name} onChange={(e) => handleFormChange('name', e.target.value)} className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400" />
|
| 768 |
+
</div>
|
| 769 |
+
<div>
|
| 770 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px]">Age</span>
|
| 771 |
+
<input type="text" value={formData.age} onChange={(e) => handleFormChange('age', e.target.value)} className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400" />
|
| 772 |
+
</div>
|
| 773 |
+
<div>
|
| 774 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px]">Parity</span>
|
| 775 |
+
<input type="text" value={formData.parity} onChange={(e) => handleFormChange('parity', e.target.value)} className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400" />
|
| 776 |
+
</div>
|
| 777 |
+
<div>
|
| 778 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px]">Blood Group</span>
|
| 779 |
+
<input type="text" value={formData.bloodGroup} onChange={(e) => handleFormChange('bloodGroup', e.target.value)} className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400" />
|
| 780 |
+
</div>
|
| 781 |
+
<div>
|
| 782 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px]">LMP</span>
|
| 783 |
+
<input
|
| 784 |
+
type="text"
|
| 785 |
+
value={formData.lmp}
|
| 786 |
+
onChange={(e) => handleFormChange('lmp', e.target.value)}
|
| 787 |
+
placeholder="Date or month"
|
| 788 |
+
className="block w-full font-medium text-gray-900 py-1 print:py-0.5 print:text-xs border-b-2 border-gray-200 focus:border-[#05998c] outline-none bg-transparent transition-colors print:border-gray-400"
|
| 789 |
+
/>
|
| 790 |
+
</div>
|
| 791 |
</div>
|
| 792 |
</div>
|
|
|
|
| 793 |
|
| 794 |
+
{/* ββ CLINICAL HISTORY ββββββββββββββββββββββββββββββ */}
|
| 795 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300">
|
| 796 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 797 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Clinical History</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
</div>
|
| 799 |
+
<div className="p-4 print:p-2 space-y-2 text-sm">
|
| 800 |
+
{(() => {
|
| 801 |
+
const session = sessionStore.get();
|
| 802 |
+
const ph = session.patientHistory;
|
| 803 |
+
if (!ph) return <p className="text-gray-500 italic text-xs">No patient history recorded</p>;
|
| 804 |
+
|
| 805 |
+
const symptoms = [
|
| 806 |
+
ph.postCoitalBleeding && 'Post-coital bleeding',
|
| 807 |
+
ph.interMenstrualBleeding && 'Inter-menstrual bleeding',
|
| 808 |
+
ph.persistentDischarge && 'Persistent discharge',
|
| 809 |
+
].filter(Boolean).join(', ');
|
| 810 |
+
|
| 811 |
+
const pastProc = ph.pastProcedures
|
| 812 |
+
? Object.entries(ph.pastProcedures).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ')
|
| 813 |
+
: '';
|
| 814 |
+
|
| 815 |
+
const immunoList = ph.immunosuppression
|
| 816 |
+
? Object.entries(ph.immunosuppression).filter(([, v]) => v).map(([k]) => k.toUpperCase()).join(', ')
|
| 817 |
+
: '';
|
| 818 |
+
|
| 819 |
+
return (
|
| 820 |
+
<div className="space-y-1.5 text-xs">
|
| 821 |
+
{/* Pregnancy Status */}
|
| 822 |
+
{ph.pregnancyStatus && <p><strong>Pregnancy Status:</strong> {ph.pregnancyStatus}</p>}
|
| 823 |
+
{ph.pregnancyStatus === 'Pregnant' && ph.gestationalAgeWeeks && <p><strong>Gestational Age:</strong> {ph.gestationalAgeWeeks} weeks</p>}
|
| 824 |
+
{ph.pregnancyStatus === 'Postpartum' && ph.monthsSinceLastDelivery && <p><strong>Months Since Last Delivery:</strong> {ph.monthsSinceLastDelivery}</p>}
|
| 825 |
+
{ph.pregnancyStatus === 'Post-abortion' && ph.monthsSinceAbortion && <p><strong>Months Since Abortion:</strong> {ph.monthsSinceAbortion}</p>}
|
| 826 |
+
|
| 827 |
+
{/* Demographics & Lifestyle */}
|
| 828 |
+
{ph.menstrualStatus && <p><strong>Menstrual Status:</strong> {ph.menstrualStatus}</p>}
|
| 829 |
+
{ph.sexualHistory && <p><strong>Sexual History:</strong> {ph.sexualHistory}</p>}
|
| 830 |
+
|
| 831 |
+
{/* HPV Information */}
|
| 832 |
+
{ph.hpvStatus && <p><strong>HPV Status:</strong> {ph.hpvStatus}</p>}
|
| 833 |
+
{ph.hpvVaccination && <p><strong>HPV Vaccination:</strong> {ph.hpvVaccination}</p>}
|
| 834 |
+
{ph.hpvDnaTypes && <p><strong>HPV DNA Types:</strong> {ph.hpvDnaTypes}</p>}
|
| 835 |
+
|
| 836 |
+
{/* Presenting Symptoms */}
|
| 837 |
+
{symptoms && <p><strong>Presenting Symptoms:</strong> {symptoms}</p>}
|
| 838 |
+
{ph.symptomsNotes && <p><strong>Additional Symptoms:</strong> {ph.symptomsNotes}</p>}
|
| 839 |
+
|
| 840 |
+
{/* Screening History */}
|
| 841 |
+
{ph.papSmearResult && <p><strong>PAP Smear Result:</strong> {ph.papSmearResult}</p>}
|
| 842 |
+
{ph.screeningNotes && <p><strong>Screening Notes:</strong> {ph.screeningNotes}</p>}
|
| 843 |
+
|
| 844 |
+
{/* Past Procedures */}
|
| 845 |
+
{pastProc && <p><strong>Past Cervical Procedures:</strong> {pastProc}</p>}
|
| 846 |
+
|
| 847 |
+
{/* Risk Factors */}
|
| 848 |
+
{ph.smoking && <p><strong>Smoking History:</strong> {ph.smoking}</p>}
|
| 849 |
+
{immunoList && <p><strong>Immunosuppression:</strong> {immunoList}</p>}
|
| 850 |
+
{ph.riskFactorsNotes && <p><strong>Risk Factors Notes:</strong> {ph.riskFactorsNotes}</p>}
|
| 851 |
+
|
| 852 |
+
{/* Additional Patient Notes */}
|
| 853 |
+
{ph.patientProfileNotes && <p><strong>Patient Profile Notes:</strong> {ph.patientProfileNotes}</p>}
|
| 854 |
+
</div>
|
| 855 |
+
);
|
| 856 |
+
})()}
|
| 857 |
</div>
|
| 858 |
</div>
|
|
|
|
| 859 |
|
| 860 |
+
{/* ββ EXAMINATION FINDINGS ββββββββββββββββββββββββββ */}
|
| 861 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300">
|
| 862 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 863 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Examination Findings</h3>
|
| 864 |
+
</div>
|
| 865 |
+
<div className="p-4 print:p-2 grid grid-cols-1 md:grid-cols-2 gap-4 print:gap-3">
|
| 866 |
+
{/* Native Examination Findings */}
|
| 867 |
+
<div className="border border-gray-200 rounded-lg p-3 print:border-gray-300">
|
| 868 |
+
<h4 className="text-xs font-bold text-[#047569] mb-2 print:mb-1">π· Native Examination</h4>
|
| 869 |
+
{formData.nativeFindings ? (
|
| 870 |
+
<div className="text-xs whitespace-pre-wrap">{formData.nativeFindings}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
) : (
|
| 872 |
+
<p className="text-gray-500 italic text-xs">Click "Generate with AI" to populate findings</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
)}
|
| 874 |
</div>
|
| 875 |
+
|
| 876 |
+
{/* Acetic Acid Findings */}
|
| 877 |
+
<div className="border border-gray-200 rounded-lg p-3 print:border-gray-300">
|
| 878 |
+
<h4 className="text-xs font-bold text-[#047569] mb-2 print:mb-1">π¬ Acetic Acid Findings</h4>
|
| 879 |
+
{formData.aceticFindings ? (
|
| 880 |
+
<div className="text-xs whitespace-pre-wrap">{formData.aceticFindings}</div>
|
| 881 |
+
) : (
|
| 882 |
+
<p className="text-gray-500 italic text-xs">Click "Generate with AI" to populate findings</p>
|
| 883 |
+
)}
|
| 884 |
+
</div>
|
| 885 |
+
</div>
|
| 886 |
</div>
|
|
|
|
| 887 |
|
| 888 |
+
{/* ββ EXAMINATION IMAGES ββββββββββββββββββββββββββββ */}
|
| 889 |
+
<div
|
| 890 |
+
className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300"
|
| 891 |
+
style={{ marginTop: '150px' }}
|
| 892 |
+
>
|
| 893 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 894 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Examination Images</h3>
|
| 895 |
+
</div>
|
| 896 |
+
<div className="p-4 print:p-2 grid grid-cols-1 md:grid-cols-2 gap-4 print:gap-2">
|
| 897 |
+
{(
|
| 898 |
+
[
|
| 899 |
+
{ key: 'native', label: 'Native Image', dot: 'bg-blue-500' },
|
| 900 |
+
{ key: 'acetic', label: 'Acetic Acid', dot: 'bg-purple-500' },
|
| 901 |
+
{ key: 'lugol', label: "Lugol's Iodine", dot: 'bg-amber-500' },
|
| 902 |
+
{ key: 'biopsy', label: 'Biopsy Site', dot: 'bg-red-500' }
|
| 903 |
+
] as { key: ImageBucket; label: string; dot: string }[]
|
| 904 |
+
).map(section => (
|
| 905 |
+
<div
|
| 906 |
+
key={section.key}
|
| 907 |
+
onDrop={(e) => handleSectionDrop(e, section.key)}
|
| 908 |
+
onDragOver={handleDragOver}
|
| 909 |
+
className="bg-gray-50 border-2 border-dashed border-gray-200 hover:border-[#05998c] rounded-lg p-3 print:border-solid print:border-gray-300 min-h-[100px] print:min-h-0 transition-colors"
|
| 910 |
+
>
|
| 911 |
+
<div className="flex items-center gap-1.5 text-xs font-semibold text-gray-700 mb-2 print:mb-1">
|
| 912 |
+
<span className={`w-2 h-2 rounded-full ${section.dot}`} />
|
| 913 |
+
{section.label}
|
| 914 |
+
</div>
|
| 915 |
+
{imageBuckets[section.key].length === 0 ? (
|
| 916 |
+
<div className="text-center text-gray-400 text-xs py-3 print:hidden">
|
| 917 |
+
<p>Drag & drop image here</p>
|
| 918 |
+
</div>
|
| 919 |
+
) : (
|
| 920 |
+
<div className="grid grid-cols-2 gap-2 print:gap-1">
|
| 921 |
+
{imageBuckets[section.key].map(item => (
|
| 922 |
+
<div key={item.id} className="relative group">
|
| 923 |
+
<img src={item.url} alt={item.name} className="w-full h-16 print:h-auto print:max-h-[80px] object-cover rounded border border-gray-200" />
|
| 924 |
+
<button onClick={() => removeImage(section.key, item.id)} className="absolute top-0.5 right-0.5 text-xs px-1 py-0 bg-red-600 text-white rounded opacity-0 group-hover:opacity-100 transition-all no-print">Γ</button>
|
| 925 |
+
</div>
|
| 926 |
+
))}
|
| 927 |
+
</div>
|
| 928 |
+
)}
|
| 929 |
</div>
|
| 930 |
+
))}
|
| 931 |
+
</div>
|
| 932 |
+
</div>
|
| 933 |
+
|
| 934 |
+
{/* ββ BIOPSY MARKING ββββββββββββββββββββββββββββββββ */}
|
| 935 |
+
<div
|
| 936 |
+
className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300"
|
| 937 |
+
>
|
| 938 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 939 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Biopsy Marking</h3>
|
| 940 |
+
</div>
|
| 941 |
+
<div className="p-4 print:p-2 flex flex-col lg:flex-row gap-4 print:gap-3">
|
| 942 |
+
{/* Biopsy image */}
|
| 943 |
+
<div className="lg:w-1/2">
|
| 944 |
+
<div className="bg-gray-50 border-2 border-dashed border-gray-200 hover:border-red-400 rounded-lg p-3 print:border-solid print:border-gray-300 min-h-[200px] print:min-h-0 transition-colors flex items-center justify-center">
|
| 945 |
+
{biopsyData?.markedImage ? (
|
| 946 |
+
<img src={biopsyData.markedImage} alt="Biopsy Marking" className="w-full h-auto max-h-[300px] print:max-h-[200px] object-contain" />
|
| 947 |
+
) : (
|
| 948 |
+
<p className="text-gray-400 text-xs print:hidden">No biopsy marking image</p>
|
| 949 |
+
)}
|
| 950 |
</div>
|
| 951 |
+
</div>
|
| 952 |
+
{/* Biopsy findings */}
|
| 953 |
+
<div className="lg:w-1/2 space-y-3 print:space-y-2">
|
| 954 |
+
<div>
|
| 955 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Biopsy Sites</span>
|
| 956 |
+
<textarea value={formData.biopsySites} onChange={(e) => handleFormChange('biopsySites', e.target.value)} className="w-full text-gray-900 text-sm print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors" rows={4} />
|
| 957 |
+
</div>
|
| 958 |
+
<div>
|
| 959 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Biopsy Notes</span>
|
| 960 |
+
<textarea value={formData.biopsyNotes} onChange={(e) => handleFormChange('biopsyNotes', e.target.value)} className="w-full text-gray-900 text-sm print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors" rows={4} />
|
| 961 |
+
</div>
|
| 962 |
+
</div>
|
| 963 |
</div>
|
| 964 |
+
</div>
|
| 965 |
|
| 966 |
+
{/* ββ SWEDE SCORE ββββββββββββββββββββββββββββββββ */}
|
| 967 |
+
{(() => {
|
| 968 |
+
const session = sessionStore.get();
|
| 969 |
+
const bm = session.biopsyMarkings;
|
| 970 |
+
if (bm && bm.totalSwedeScore > 0) {
|
| 971 |
+
return (
|
| 972 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300">
|
| 973 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 974 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">SWEDE Score</h3>
|
| 975 |
+
</div>
|
| 976 |
+
<div className="p-4 print:p-2">
|
| 977 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print:gap-3">
|
| 978 |
+
{/* Score Breakdown */}
|
| 979 |
+
<div className="bg-teal-50 border border-teal-100 rounded-lg p-3 print:p-2">
|
| 980 |
+
<p className="text-xs font-bold text-[#05998c] uppercase mb-2 print:mb-1">Score Breakdown</p>
|
| 981 |
+
<div className="text-xs space-y-1 print:space-y-0.5">
|
| 982 |
+
<p><strong>Aceto Uptake:</strong> {bm.swedeScores.acetoUptake ?? 0}/2</p>
|
| 983 |
+
<p><strong>Margins & Surface:</strong> {bm.swedeScores.marginsAndSurface ?? 0}/2</p>
|
| 984 |
+
<p><strong>Vessels:</strong> {bm.swedeScores.vessels ?? 0}/2</p>
|
| 985 |
+
<p><strong>Lesion Size:</strong> {bm.swedeScores.lesionSize ?? 0}/2</p>
|
| 986 |
+
<p><strong>Iodine Staining:</strong> {bm.swedeScores.iodineStaining ?? 0}/2</p>
|
| 987 |
+
</div>
|
| 988 |
+
</div>
|
| 989 |
+
|
| 990 |
+
{/* Total Score & Risk */}
|
| 991 |
+
<div className="bg-gradient-to-br from-[#05998c] to-[#047569] rounded-lg p-3 print:bg-white print:border print:border-teal-200 text-white print:text-gray-900">
|
| 992 |
+
<p className="text-[10px] font-bold uppercase tracking-wide mb-2 print:mb-1 opacity-90 print:opacity-100">Total Score</p>
|
| 993 |
+
<div className="text-2xl print:text-lg font-bold mb-2 print:mb-1">{bm.totalSwedeScore}/10</div>
|
| 994 |
+
<p className="text-xs font-semibold print:text-gray-700">
|
| 995 |
+
{bm.totalSwedeScore <= 4 && 'β Low Risk'}
|
| 996 |
+
{bm.totalSwedeScore >= 5 && bm.totalSwedeScore <= 7 && 'β Intermediate Risk'}
|
| 997 |
+
{bm.totalSwedeScore >= 8 && 'β High Risk'}
|
| 998 |
+
</p>
|
| 999 |
+
</div>
|
| 1000 |
+
</div>
|
| 1001 |
+
</div>
|
| 1002 |
+
</div>
|
| 1003 |
+
);
|
| 1004 |
+
}
|
| 1005 |
+
return null;
|
| 1006 |
+
})()}
|
| 1007 |
+
|
| 1008 |
+
{/* ββ DIAGNOSIS & MANAGEMENT PLAN ββββββββββββββββββββββββββββββ */}
|
| 1009 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden print:rounded-none print:border-gray-300">
|
| 1010 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 1011 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Diagnosis & Management Plan</h3>
|
| 1012 |
+
</div>
|
| 1013 |
+
<div className="p-4 print:p-2 space-y-3 print:space-y-2 text-sm">
|
| 1014 |
+
{/* Colposcopic Findings including Swede Score */}
|
| 1015 |
+
<div className="bg-teal-50 border border-teal-100 rounded-lg p-3 print:bg-transparent print:border print:border-gray-300 print:p-2">
|
| 1016 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Colposcopic Findings (Including Swede Score)</span>
|
| 1017 |
+
<textarea value={formData.colposcopicFindings} onChange={(e) => handleFormChange('colposcopicFindings', e.target.value)} placeholder="Describe colposcopic findings and include Swede score interpretation..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-white p-2 print:p-1 resize-none rounded transition-colors" rows={3} />
|
| 1018 |
+
</div>
|
| 1019 |
+
|
| 1020 |
+
{/* Treatment Plan */}
|
| 1021 |
<div>
|
| 1022 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Treatment Plan</span>
|
| 1023 |
+
<textarea value={formData.treatmentPlan} onChange={(e) => handleFormChange('treatmentPlan', e.target.value)} placeholder="Recommended treatment plan..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors" rows={3} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
</div>
|
| 1025 |
+
|
| 1026 |
+
{/* Follow-up Plan */}
|
| 1027 |
<div>
|
| 1028 |
+
<span className="text-[10px] font-bold text-[#05998c] uppercase tracking-wide print:text-[9px] block mb-1">Follow-up Plan</span>
|
| 1029 |
+
<textarea value={formData.followUp} onChange={(e) => handleFormChange('followUp', e.target.value)} placeholder="Follow-up recommendations..." className="w-full text-gray-900 print:text-xs border border-gray-200 focus:border-[#05998c] outline-none bg-transparent p-2 print:p-1 resize-none rounded transition-colors" rows={2} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
</div>
|
| 1031 |
</div>
|
| 1032 |
</div>
|
| 1033 |
+
|
| 1034 |
+
{/* ββ SIGNATURES ββββββββββββββββββββββββββββββββββββ */}
|
| 1035 |
+
<div className="border border-gray-200 rounded-xl print:rounded-none print:border-gray-300 mt-2">
|
| 1036 |
+
<div className="bg-[#05998c]/8 border-l-4 border-[#05998c] px-4 py-2.5 print:py-1.5">
|
| 1037 |
+
<h3 className="text-xs font-bold text-[#05998c] uppercase tracking-wider print:text-[10px]">Signatures</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
</div>
|
| 1039 |
+
<div className="p-4 print:p-2 grid grid-cols-1 md:grid-cols-3 gap-6 print:gap-3 text-sm print:text-xs">
|
| 1040 |
+
<div>
|
| 1041 |
+
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wide mb-10 print:mb-5">Patient Signature</p>
|
| 1042 |
+
<div className="border-b-2 border-gray-400 w-full" />
|
| 1043 |
+
</div>
|
| 1044 |
+
<div>
|
| 1045 |
+
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wide mb-2 print:mb-1">Doctor / Clinician</p>
|
| 1046 |
+
<input type="text" value={formData.doctorSignature} onChange={(e) => handleFormChange('doctorSignature', e.target.value)} placeholder="Dr. Name & Designation" className="w-full border-b-2 border-gray-400 outline-none bg-transparent text-sm print:text-xs focus:border-[#05998c] transition-colors" />
|
| 1047 |
+
</div>
|
| 1048 |
+
<div>
|
| 1049 |
+
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wide mb-2 print:mb-1">Date</p>
|
| 1050 |
+
<input type="text" value={formData.signatureDate} onChange={(e) => handleFormChange('signatureDate', e.target.value)} className="w-full border-b-2 border-gray-400 outline-none bg-transparent text-sm print:text-xs focus:border-[#05998c] transition-colors" />
|
| 1051 |
+
</div>
|
| 1052 |
</div>
|
| 1053 |
</div>
|
|
|
|
| 1054 |
|
| 1055 |
+
{/* ββ FOOTER ββββββββββββββββββββββββββββββββββββββββ */}
|
| 1056 |
+
<div className="mt-4 pt-3 border-t border-gray-200 flex items-center justify-between text-[10px] text-gray-400 print:mt-2 print:pt-1">
|
| 1057 |
+
<div className="flex items-center gap-2">
|
| 1058 |
+
<img src="/white_logo.png" alt="Pathora" className="h-5 w-5 object-contain opacity-50 invert print:invert" />
|
| 1059 |
+
<span>Powered by <span className="font-semibold text-[#05998c]">Pathora AI</span> Colposcopy System</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
</div>
|
| 1061 |
+
<span>Generated: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
|
| 1062 |
</div>
|
|
|
|
| 1063 |
|
| 1064 |
</div>
|
| 1065 |
</div>
|
| 1066 |
+
|
| 1067 |
</div>
|
| 1068 |
|
| 1069 |
{/* Image Library Sidebar */}
|
| 1070 |
+
<div className="lg:col-span-1 print:hidden mt-[949px]">
|
| 1071 |
+
<div className="bg-white border border-gray-200 rounded-2xl shadow-lg p-4 sticky top-24">
|
| 1072 |
+
<h3 className="text-sm font-bold text-[#05998c] mb-4 pb-2 border-b border-gray-200 uppercase tracking-wider text-xs">Image Library</h3>
|
| 1073 |
{capturedImages.length > 0 ? (
|
| 1074 |
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
|
| 1075 |
{Object.entries(
|
|
|
|
| 1082 |
).map(([stepId, images]) => (
|
| 1083 |
<div key={stepId} className="bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200 rounded-lg p-3 shadow-sm">
|
| 1084 |
<div className="flex items-center gap-2 mb-2">
|
| 1085 |
+
<div className={`w-2 h-2 rounded-full ${stepId === 'native' ? 'bg-blue-500' :
|
|
|
|
| 1086 |
stepId === 'acetowhite' ? 'bg-purple-500' :
|
| 1087 |
+
stepId === 'greenFilter' ? 'bg-green-500' :
|
| 1088 |
+
stepId === 'lugol' ? 'bg-amber-500' : 'bg-gray-500'
|
| 1089 |
+
}`}></div>
|
| 1090 |
<span className="text-xs font-bold text-gray-700 uppercase">
|
| 1091 |
+
{stepId === 'native' ? 'Native' :
|
| 1092 |
+
stepId === 'acetowhite' ? 'Acetic Acid' :
|
| 1093 |
+
stepId === 'greenFilter' ? 'Green Filter' :
|
| 1094 |
+
stepId === 'lugol' ? 'Lugol' : stepId}
|
| 1095 |
</span>
|
| 1096 |
</div>
|
| 1097 |
<div className="grid grid-cols-2 gap-2">
|
|
|
|
| 1107 |
alt={`Captured ${stepId}`}
|
| 1108 |
draggable
|
| 1109 |
onDragStart={(e) => handleCapturedImageDragStart(e, img)}
|
| 1110 |
+
className="w-full h-20 object-cover border border-gray-200 cursor-grab hover:border-[#05998c] hover:shadow-md transition-all rounded"
|
| 1111 |
/>
|
| 1112 |
)}
|
| 1113 |
</div>
|
|
|
|
| 1139 |
</div>
|
| 1140 |
<h3 className="text-2xl font-bold text-gray-900 mb-2 text-center">Save Report</h3>
|
| 1141 |
<p className="text-gray-600 text-center mb-6">
|
| 1142 |
+
Your colposcopy examination report is ready. Click "Save & Continue" to export the PDF and proceed to the patient registry.
|
| 1143 |
</p>
|
| 1144 |
<div className="space-y-3">
|
| 1145 |
<button
|
| 1146 |
onClick={() => {
|
| 1147 |
handleExportPDF();
|
| 1148 |
setTimeout(() => {
|
| 1149 |
+
// Clear the full session so the next patient starts with blank forms
|
| 1150 |
+
sessionStore.clear();
|
| 1151 |
setShowCompletionModal(false);
|
| 1152 |
(onGoToPatientRecords || onNext)();
|
| 1153 |
}, 1000);
|
|
|
|
| 1155 |
className="w-full px-6 py-3 bg-gradient-to-r from-[#05998c] to-[#047569] text-white rounded-xl hover:from-[#047569] hover:to-[#036356] transition-all duration-200 font-semibold shadow-md hover:shadow-lg flex items-center justify-center gap-2"
|
| 1156 |
>
|
| 1157 |
<Download className="w-4 h-4" />
|
| 1158 |
+
Save & Continue
|
| 1159 |
</button>
|
| 1160 |
<button
|
| 1161 |
onClick={() => setShowCompletionModal(false)}
|
src/store/patientIdStore.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Patient ID Store - Manages auto-generation and persistence of patient IDs
|
| 3 |
+
* Format: PT-YYYY-XXXX (e.g., PT-2026-0001)
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface PatientRecord {
|
| 7 |
+
id: string;
|
| 8 |
+
name: string;
|
| 9 |
+
examDate: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const PATIENT_HISTORY_KEY = 'pathora_patient_history';
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Generate the next patient ID in format PT-YYYY-XXXX
|
| 16 |
+
* Increments counter for current year, resets on new year
|
| 17 |
+
*/
|
| 18 |
+
export function generatePatientId(): string {
|
| 19 |
+
const currentYear = new Date().getFullYear();
|
| 20 |
+
const history = getPatientHistory();
|
| 21 |
+
|
| 22 |
+
// Find the highest sequence number for the current year
|
| 23 |
+
let maxSequence = 0;
|
| 24 |
+
history.forEach(record => {
|
| 25 |
+
const match = record.id.match(/PT-(\d{4})-(\d{4})/);
|
| 26 |
+
if (match) {
|
| 27 |
+
const year = parseInt(match[1], 10);
|
| 28 |
+
const sequence = parseInt(match[2], 10);
|
| 29 |
+
if (year === currentYear && sequence > maxSequence) {
|
| 30 |
+
maxSequence = sequence;
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
const nextSequence = maxSequence + 1;
|
| 36 |
+
return `PT-${currentYear}-${String(nextSequence).padStart(4, '0')}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Get all stored patient records
|
| 41 |
+
*/
|
| 42 |
+
export function getPatientHistory(): PatientRecord[] {
|
| 43 |
+
try {
|
| 44 |
+
return JSON.parse(localStorage.getItem(PATIENT_HISTORY_KEY) ?? '[]') as PatientRecord[];
|
| 45 |
+
} catch {
|
| 46 |
+
return [];
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Save a new patient record to history
|
| 52 |
+
*/
|
| 53 |
+
export function savePatientRecord(record: PatientRecord): void {
|
| 54 |
+
const history = getPatientHistory();
|
| 55 |
+
// Avoid duplicates
|
| 56 |
+
const exists = history.some(p => p.id === record.id);
|
| 57 |
+
if (!exists) {
|
| 58 |
+
history.push(record);
|
| 59 |
+
localStorage.setItem(PATIENT_HISTORY_KEY, JSON.stringify(history));
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Find a patient record by ID
|
| 65 |
+
*/
|
| 66 |
+
export function findPatientById(id: string): PatientRecord | undefined {
|
| 67 |
+
return getPatientHistory().find(p => p.id.toLowerCase() === id.toLowerCase());
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Search patients by ID (partial or exact match)
|
| 72 |
+
*/
|
| 73 |
+
export function searchPatients(query: string): PatientRecord[] {
|
| 74 |
+
const q = query.trim().toLowerCase();
|
| 75 |
+
if (!q) return [];
|
| 76 |
+
return getPatientHistory().filter(p => p.id.toLowerCase().includes(q));
|
| 77 |
+
}
|
src/store/sessionStore.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* sessionStore β localStorage-based session DB for the Pathora colposcopy assistant.
|
| 3 |
+
* Every examination page writes here; ReportPage reads the full record for AI generation.
|
| 4 |
+
*
|
| 5 |
+
* Data schema:
|
| 6 |
+
* patientInfo β registry id, name, exam date
|
| 7 |
+
* patientHistory β PatientHistoryForm (demographics, symptoms, screening, risks)
|
| 8 |
+
* nativeFindings β ImagingObservations for the native (untreated) step
|
| 9 |
+
* aceticFindings β AceticFindingsForm category+feature checkboxes for the acetic acid step
|
| 10 |
+
* stepFindings β generic per-step observations bucket (kept for backwards compat)
|
| 11 |
+
* biopsyMarkings β lesion marks with clock hours + Swede score from BiopsyMarking
|
| 12 |
+
* reportFormData β all free-text / select fields on ReportPage
|
| 13 |
+
* sessionStarted β ISO timestamp
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
// ββ Patient history form (PatientHistoryForm.tsx) βββββββββββββββββββββββββββββ
|
| 17 |
+
export interface PatientHistory {
|
| 18 |
+
name: string;
|
| 19 |
+
age: string;
|
| 20 |
+
bloodGroup: string;
|
| 21 |
+
parity: string;
|
| 22 |
+
pregnancyStatus: string;
|
| 23 |
+
gestationalAgeWeeks: string;
|
| 24 |
+
monthsSinceLastDelivery: string;
|
| 25 |
+
monthsSinceAbortion: string;
|
| 26 |
+
menstrualStatus: string;
|
| 27 |
+
sexualHistory: string;
|
| 28 |
+
hpvStatus: string;
|
| 29 |
+
hpvVaccination: string;
|
| 30 |
+
patientProfileNotes: string;
|
| 31 |
+
postCoitalBleeding: boolean;
|
| 32 |
+
interMenstrualBleeding: boolean;
|
| 33 |
+
persistentDischarge: boolean;
|
| 34 |
+
symptomsNotes: string;
|
| 35 |
+
papSmearResult: string;
|
| 36 |
+
hpvDnaTypes: string;
|
| 37 |
+
pastProcedures: { biopsy: boolean; leep: boolean; cryotherapy: boolean; none: boolean };
|
| 38 |
+
screeningNotes: string;
|
| 39 |
+
smoking: string;
|
| 40 |
+
immunosuppression: { hiv: boolean; steroids: boolean; none: boolean };
|
| 41 |
+
riskFactorsNotes: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// ββ Native step visual observations (ImagingObservations.tsx, native layout) ββ
|
| 45 |
+
export interface NativeFindings {
|
| 46 |
+
cervixFullyVisible: 'Yes' | 'No' | null;
|
| 47 |
+
obscuredBy: { blood: boolean; inflammation: boolean; discharge: boolean; scarring: boolean };
|
| 48 |
+
adequacyNotes: string;
|
| 49 |
+
scjVisibility: string;
|
| 50 |
+
scjNotes: string;
|
| 51 |
+
tzType: string;
|
| 52 |
+
suspiciousAtNativeView: boolean;
|
| 53 |
+
obviousGrowths: boolean;
|
| 54 |
+
contactBleeding: boolean;
|
| 55 |
+
irregularSurface: boolean;
|
| 56 |
+
other: boolean;
|
| 57 |
+
additionalNotes: string;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// ββ Acetic acid clinical findings (AceticFindingsForm.tsx) ββββββββββββββββββββ
|
| 61 |
+
export interface AceticFindings {
|
| 62 |
+
/** Top-level categories that were checked */
|
| 63 |
+
selectedCategories: Record<string, boolean>;
|
| 64 |
+
/** Individual features within each category that were checked */
|
| 65 |
+
selectedFindings: Record<string, boolean>;
|
| 66 |
+
additionalNotes: string;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ββ Biopsy marking (BiopsyMarking.tsx) ββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
export interface BiopsyMarkings {
|
| 71 |
+
lesionMarks: Array<{ type: string; typeCode: string; clockHour: number; color: string }>;
|
| 72 |
+
swedeScores: {
|
| 73 |
+
acetoUptake: number | null;
|
| 74 |
+
marginsAndSurface: number | null;
|
| 75 |
+
vessels: number | null;
|
| 76 |
+
lesionSize: number | null;
|
| 77 |
+
iodineStaining: number | null;
|
| 78 |
+
};
|
| 79 |
+
totalSwedeScore: number;
|
| 80 |
+
marksByType: Record<string, { type: string; hours: number[] }>;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// ββ Full session record ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
export interface PatientSession {
|
| 85 |
+
/** Basic registry info set when a patient is selected */
|
| 86 |
+
patientInfo?: { id: string; name: string; examDate?: string };
|
| 87 |
+
/** Full patient history / intake form */
|
| 88 |
+
patientHistory?: Partial<PatientHistory>;
|
| 89 |
+
/** Native (untreated) examination visual observations */
|
| 90 |
+
nativeFindings?: Partial<NativeFindings>;
|
| 91 |
+
/** Acetic acid clinical findings checkboxes */
|
| 92 |
+
aceticFindings?: Partial<AceticFindings>;
|
| 93 |
+
/** Generic per-step observation bucket (backwards compat / future steps) */
|
| 94 |
+
stepFindings?: Record<string, any>;
|
| 95 |
+
/** Lesion marks and Swede score from BiopsyMarking */
|
| 96 |
+
biopsyMarkings?: BiopsyMarkings;
|
| 97 |
+
/** All text/select fields from the ReportPage form */
|
| 98 |
+
reportFormData?: Record<string, string>;
|
| 99 |
+
/** ISO timestamp when this session was started */
|
| 100 |
+
sessionStarted?: string;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const KEY = 'pathora_colpo_session';
|
| 104 |
+
|
| 105 |
+
export const sessionStore = {
|
| 106 |
+
/** Read the full session from localStorage. Returns {} on error. */
|
| 107 |
+
get(): PatientSession {
|
| 108 |
+
try {
|
| 109 |
+
return JSON.parse(localStorage.getItem(KEY) ?? '{}') as PatientSession;
|
| 110 |
+
} catch {
|
| 111 |
+
return {};
|
| 112 |
+
}
|
| 113 |
+
},
|
| 114 |
+
|
| 115 |
+
/** Shallow-merge a partial update into the stored session. */
|
| 116 |
+
merge(partial: Partial<PatientSession>): void {
|
| 117 |
+
const current = sessionStore.get();
|
| 118 |
+
localStorage.setItem(KEY, JSON.stringify({ ...current, ...partial }));
|
| 119 |
+
},
|
| 120 |
+
|
| 121 |
+
/** Wipe the session (call when starting a new patient). */
|
| 122 |
+
clear(): void {
|
| 123 |
+
localStorage.removeItem(KEY);
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
+
/** Return the full session as pretty-printed JSON (for download / debugging). */
|
| 127 |
+
export(): string {
|
| 128 |
+
return JSON.stringify(sessionStore.get(), null, 2);
|
| 129 |
+
},
|
| 130 |
+
};
|
src/vite-env.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
| 2 |
+
|
| 3 |
+
interface ImportMetaEnv {
|
| 4 |
+
readonly VITE_GEMINI_API_KEY?: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
interface ImportMeta {
|
| 8 |
+
readonly env: ImportMetaEnv;
|
| 9 |
+
}
|