nusaibah0110 commited on
Commit
1c68fe6
Β·
1 Parent(s): 03513b4

Update application with new features and components

Browse files
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 backend.inference import infer_aw_contour, analyze_frame, analyze_video_frame
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 = () => setCurrentPage('guidedcapture');
 
 
 
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(undefined)} onSelectExisting={(id: string) => goToPatientHistory(id)} onBackToHome={goToHome} onNext={goToGuidedCapture} />;
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
- // Category-based findings structure
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={runAIAssist}
853
  disabled={isAILoading}
854
- className="px-5 py-2.5 text-sm md:text-base font-bold text-white bg-blue-600 border border-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-md hover:shadow-lg"
 
 
 
 
 
855
  >
856
- {isAILoading ? 'Analyzing...' : '✨ AI Assist'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
  </button>
858
  </div>
859
 
860
- {/* Annotate Tab */}
861
- {activeTab === 'annotate' && (
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: 'Hello! I\'m your AI assistant for colposcopy examinations. How can I help you today?',
17
  sender: 'bot',
18
- timestamp: new Date()
19
- }
20
  ]);
21
  const [inputMessage, setInputMessage] = useState('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- const dummyResponses = [
24
- 'I can help you with colposcopy examination procedures and best practices.',
25
- 'For acetic acid application, wait 1-3 minutes before capturing images.',
26
- 'Lugol\'s iodine staining helps identify abnormal cervical tissue.',
27
- 'Green filter enhances vascular patterns in cervical images.',
28
- 'Biopsy should be taken from the most suspicious areas identified during examination.',
29
- 'Regular follow-up is important for patients with abnormal findings.',
30
- 'I can assist with image annotation and lesion identification.',
31
- 'Would you like me to explain any specific examination technique?'
32
- ];
33
-
34
- const handleSendMessage = () => {
35
- if (!inputMessage.trim()) return;
36
-
37
- const userMessage: Message = {
 
 
 
 
 
 
 
 
38
  id: Date.now().toString(),
39
- text: inputMessage,
40
  sender: 'user',
41
- timestamp: new Date()
42
  };
43
 
44
- setMessages(prev => [...prev, userMessage]);
45
  setInputMessage('');
 
46
 
47
- // Simulate bot response after a short delay
48
- setTimeout(() => {
49
- const randomResponse = dummyResponses[Math.floor(Math.random() * dummyResponses.length)];
50
- const botMessage: Message = {
51
- id: (Date.now() + 1).toString(),
52
- text: randomResponse,
53
- sender: 'bot',
54
- timestamp: new Date()
55
- };
56
- setMessages(prev => [...prev, botMessage]);
57
- }, 1000);
 
 
 
 
 
 
 
 
 
58
  };
59
 
60
- const handleKeyPress = (e: React.KeyboardEvent) => {
61
  if (e.key === 'Enter' && !e.shiftKey) {
62
  e.preventDefault();
63
  handleSendMessage();
64
  }
65
  };
66
 
 
67
  return (
68
  <>
69
- {/* Chat Button */}
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 Window */}
79
  {isOpen && (
80
- <div className="fixed bottom-20 right-6 w-80 h-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-40 flex flex-col">
81
  {/* Header */}
82
- <div className="bg-[#05998c] text-white p-4 rounded-t-lg flex items-center gap-3">
83
  <Bot className="w-6 h-6" />
84
  <div>
85
- <h3 className="font-semibold">AI Assistant</h3>
86
- <p className="text-sm opacity-90">Colposcopy Expert</p>
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
- <div
98
- className={`max-w-[80%] p-3 rounded-lg text-sm ${
99
- message.sender === 'user'
100
- ? 'bg-[#05998c] text-white'
101
- : 'bg-gray-100 text-gray-800'
102
- }`}
103
- >
104
- {message.text}
105
- </div>
 
 
 
 
 
 
106
  </div>
107
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
 
110
  {/* Input */}
111
- <div className="p-4 border-t border-gray-200">
112
  <div className="flex gap-2">
113
  <input
114
  type="text"
115
  value={inputMessage}
116
  onChange={(e) => setInputMessage(e.target.value)}
117
- onKeyPress={handleKeyPress}
118
- placeholder="Ask me anything about colposcopy..."
119
- className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent text-sm"
 
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, Zap, Loader } from 'lucide-react';
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 [annotationIdentified, setAnnotationIdentified] = useState<Record<string, boolean>>({});
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
- ctx.fillStyle = annotation.color + '20';
365
- ctx.fillRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
 
 
 
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
- ctx.fillStyle = annotation.color + '20';
374
- ctx.fill();
 
 
 
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
- ctx.fillStyle = annotation.color + '20';
387
- ctx.fill();
 
 
 
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 md:flex-row md:items-center gap-2">
464
- <div className="relative">
465
- <input
466
- aria-label="Annotation label"
467
- placeholder="Search or select label"
468
- value={labelInput}
469
- onChange={e => setLabelInput(e.target.value)}
470
- onFocus={() => setIsLabelDropdownOpen(true)}
471
- onBlur={() => setTimeout(() => setIsLabelDropdownOpen(false), 200)}
472
- className="px-3 py-1 border rounded text-xs md:text-sm w-48"
473
- />
474
- {isLabelDropdownOpen && filteredLabels.length > 0 && (
475
- <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">
476
- {filteredLabels.map((label, idx) => (
477
- <button
478
- key={idx}
479
- type="button"
480
- onMouseDown={(e) => {
481
- e.preventDefault();
482
- setLabelInput(label);
483
- setIsLabelDropdownOpen(false);
484
- }}
485
- className="w-full text-left px-3 py-2 text-xs md:text-sm hover:bg-gray-100 transition-colors"
486
- >
487
- {label}
488
- </button>
489
- ))}
490
- </div>
 
 
 
 
491
  )}
492
  </div>
493
- <input aria-label="Annotation color" type="color" value={color} onChange={e => setColor(e.target.value)} className="w-10 h-8 p-0 border rounded" />
494
- {onAIAssist && (
495
- <button
496
- onClick={onAIAssist}
497
- disabled={isAILoading}
498
- className="px-4 md:px-6 py-2 md:py-3 text-sm md:text-base font-bold text-white bg-gradient-to-r from-blue-600 to-blue-700 border border-blue-700 rounded-lg hover:from-blue-700 hover:to-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl flex items-center justify-center gap-2"
499
- title="Run AI model to automatically detect annotations"
500
- >
501
- {isAILoading ? (
502
- <>
503
- <Loader className="w-5 h-5 animate-spin" />
504
- <span>Analyzing...</span>
505
- </>
506
- ) : (
507
- <>
508
- <Zap className="w-5 h-5" />
509
- <span>AI Assist</span>
510
- </>
 
 
 
 
 
 
 
 
 
 
 
 
511
  )}
 
 
 
 
 
 
512
  </button>
513
- )}
514
- <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">
515
- <Trash2 className="w-4 h-4" />
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
- name,
50
- value
51
- } = e.target;
52
- setFormData(prev => ({
53
- ...prev,
54
- [name]: value
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 ?? 'New - unsaved'}
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
- {['Pre-menopausal', 'Post-menopausal', 'Pregnant'].map(status => <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">
149
- <input type="radio" name="menstrualStatus" value={status} className="w-4 h-4 text-[#4ECDC4] focus:ring-[#4ECDC4]" />
150
- <span className="text-base text-gray-700">{status}</span>
151
- </label>)}
 
 
152
  </div>
153
- </div>
154
 
155
- <div>
156
- <label className="block text-sm font-semibold text-gray-500 uppercase mb-2">
157
- HPV Status
158
- </label>
159
- <div className="flex flex-wrap gap-2">
160
- {['Positive', 'Negative', 'Unknown'].map(status => <label key={status} className="flex-1 min-w-[80px] text-center cursor-pointer">
161
- <input type="radio" name="hpvStatus" value={status} className="peer sr-only" />
162
- <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">
163
- {status}
164
- </div>
165
- </label>)}
166
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  </div>
168
 
169
  <div>
170
- <label className="block text-sm font-semibold text-gray-500 uppercase mb-2">
171
- HPV Vaccination
172
- </label>
173
- <div className="flex gap-4">
174
- {['Yes', 'No', 'Unknown'].map(opt => <label key={opt} className="flex items-center gap-2 cursor-pointer">
175
- <input type="radio" name="hpvVaccination" value={opt} className="w-4 h-4 text-[#4ECDC4] focus:ring-[#4ECDC4]" />
176
- <span className="text-base text-gray-700">{opt}</span>
177
- </label>)}
178
- </div>
 
 
 
 
 
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 type="checkbox" name="postCoitalBleeding" className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
 
 
 
 
 
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 type="checkbox" name="interMenstrualBleeding" className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
 
 
 
 
 
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 type="checkbox" name="persistentDischarge" className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
 
 
 
 
 
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 => <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">
267
- <span className="text-sm text-gray-700 group-hover:text-[#0A2540]">
268
- {result}
269
- </span>
270
- <input type="radio" name="papSmearResult" value={result} className="w-4 h-4 text-[#4ECDC4] focus:ring-[#4ECDC4]" />
271
- </label>)}
272
  </div>
273
  </div>
274
 
275
  <div>
276
- <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">
277
- HPV DNA (High-risk)
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
- // Live Feed View
266
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
 
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={handleLiveAIAssist}
491
  disabled={isLiveAILoading}
492
- className="w-full flex items-center justify-center gap-2 px-6 py-4 rounded-lg bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed text-base"
 
 
 
 
 
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
- <div>
628
- <div className="mb-4 flex items-center justify-between">
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={handleSaveAnnotations}
643
- className="px-6 py-2 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors"
 
 
 
 
644
  >
645
- Save Annotations
646
- </button>
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
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
655
- <div className="lg:col-span-2">
656
- {selectedItem && (
657
- <AceticAnnotator
658
- ref={annotatorRef}
659
- imageUrl={selectedItem.url}
660
- onAnnotationsChange={setAnnotations}
 
 
 
 
 
 
 
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-blue-600 text-white hover:bg-blue-700 rounded transition-colors flex items-center gap-2"
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
- <div className="flex items-center justify-between mb-4">
492
- <h3 className="font-bold text-[#0A2540]">Images</h3>
493
- <button
494
- onClick={handleUpload}
495
- className="h-8 px-3 bg-blue-600 text-white hover:bg-blue-700 rounded transition-colors flex items-center gap-2 text-xs"
496
- >
497
- <Upload className="h-3.5 w-3.5" />
498
- Upload
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, useEffect, useRef } from 'react';
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-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md font-semibold"
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={handleGreenFilterMainAIAssist}
400
  disabled={isLiveAILoading}
401
- className="w-full flex items-center justify-center gap-2 px-6 py-4 rounded-lg bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed text-base"
 
 
 
 
 
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
- setIsContinuousAIEnabled(prev => !prev);
512
- if (!isContinuousAIEnabled) {
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 (isCompareMode) {
 
 
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 && currentStep !== 'report') ||
583
- (stage === 'Annotate' && (selectedImage || isAnnotatingMode) && !isCompareMode && currentStep !== 'report') ||
584
- (stage === 'Compare' && isCompareMode && currentStep !== 'report') ||
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={isLiveAILoading}
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-green-600 text-white hover:bg-green-700 rounded transition-colors flex items-center gap-2"
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 space-y-4">
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={handleLugolMainAIAssist}
457
  disabled={isLiveAILoading}
458
- className="w-full flex items-center justify-center gap-2 px-6 py-4 rounded-lg bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed text-base"
 
 
 
 
 
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 = mockPatients.filter(p => p.id.toLowerCase() === q.toLowerCase());
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(mockPatients);
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={onNewPatient} 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>
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={() => onSelectExisting(r.id)} className="p-1.5 md:p-2 rounded hover:bg-gray-50">
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={() => onSelectExisting(r.id)} className="p-1.5 md:p-2 rounded hover:bg-gray-50">
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
- findings?: any;
20
  };
21
 
22
  export function ReportPage({
@@ -26,7 +29,7 @@ export function ReportPage({
26
  patientData = {},
27
  capturedImages = [],
28
  biopsyData = {},
29
- findings = {}
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: patientData.regNo || 'CP-2026-001',
42
- name: patientData.name || 'Jane Doe',
43
- opdNo: patientData.opdNo || 'OPD-45892',
44
- age: patientData.age || '35',
45
- parity: patientData.parity || 'P2L2',
46
- wo: patientData.wo || 'Married',
47
- indication: patientData.indication || 'VIA+ / PAP+ / HPV-DNA+',
48
- complaint: patientData.reason || 'Abnormal bleeding, post-menopausal',
49
- examQuality: 'Adequate',
50
- transformationZone: 'II',
51
- acetowL: 'Present',
52
- diagnosis: findings.diagnosis || 'Cervical intraepithelial neoplasia (CIN) Grade II with acetowhite lesion',
53
- treatmentPlan: findings.plan || 'LEEP procedure recommended, HPV testing, follow-up in 3 months',
54
- followUp: findings.followUp || 'Return after 3 months for post-LEEP assessment',
55
- biopsySites: biopsyData?.sites || '12 o\'clock position - CIN II',
56
- biopsyNotes: biopsyData?.notes || 'Multiple biopsies taken from affected areas',
 
 
 
 
 
 
 
 
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: [10, 10, 10, 10] as [number, number, number, number],
87
  filename: `Colposcopy_Report_${patientName}_${reportDate}.pdf`,
88
  image: { type: 'jpeg' as const, quality: 0.98 },
89
- html2canvas: { scale: 2 },
90
- jsPDF: { orientation: 'portrait' as const, unit: 'mm', format: 'a4' }
 
 
 
 
 
 
 
 
91
  };
92
-
93
- html2pdf().set(options).from(element).save();
 
 
 
 
 
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
- <p className="text-sm text-gray-500 mt-1 ml-1">Professional Medical Documentation</p>
 
 
 
 
 
 
 
 
230
  </div>
231
  </div>
232
- <div className="flex gap-3">
233
- <button
234
- onClick={handleExportPDF}
235
- 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"
236
- >
237
- <Download className="w-4 h-4" />
238
- Export PDF
239
- </button>
240
- <button
241
- onClick={() => setShowCompletionModal(true)}
242
- 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"
243
- >
244
- Complete
245
- <CheckCircle className="w-4 h-4" />
246
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div className="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden print:shadow-none print:border-0 print:rounded-none print:max-h-[297mm]">
256
-
257
- {/* Report Header with Gradient */}
258
- <div className="bg-white border-b-4 border-gray-800 p-6 text-center print:p-3 print:border-b-2">
259
- <h1 className="text-2xl font-bold text-gray-900 mb-1 print:text-lg">COLPOSCOPY EXAMINATION REPORT</h1>
260
- <p className="text-sm text-gray-600 print:text-xs">Medical Documentation</p>
261
- <div className="flex items-center justify-center gap-2 text-gray-600 text-sm mt-3 print:mt-1.5 print:text-xs">
262
- <span>Date: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
263
- </div>
264
- </div>
265
 
266
- <div className="p-8 space-y-6 print:p-3 print:space-y-2 overflow-y-auto print:overflow-visible">
267
 
268
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
269
- <h3 className="text-sm font-bold text-gray-900 mb-3 pb-2 border-b border-gray-400 print:mb-1.5 print:pb-1 print:text-xs">PATIENT INFORMATION</h3>
270
- <div className="grid grid-cols-2 md:grid-cols-3 gap-3 print:gap-1.5 text-sm">
271
- <div>
272
- <span className="text-xs font-semibold text-gray-600">Reg. No.</span>
273
- <input
274
- type="text"
275
- value={formData.regNo}
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
- <span className="text-xs font-semibold text-gray-600">Parity</span>
309
- <input
310
- type="text"
311
- value={formData.parity}
312
- onChange={(e) => handleFormChange('parity', e.target.value)}
313
- 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"
314
- />
315
  </div>
316
- <div>
317
- <span className="text-xs font-semibold text-gray-600">W/O</span>
318
- <input
319
- type="text"
320
- value={formData.wo}
321
- onChange={(e) => handleFormChange('wo', e.target.value)}
322
- 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"
323
  />
324
  </div>
325
  </div>
326
  </div>
327
 
328
- {/* Medical History */}
329
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
330
- <h3 className="text-sm font-bold text-gray-900 mb-3 pb-2 border-b border-gray-400 print:mb-1.5 print:pb-1 print:text-xs">CLINICAL HISTORY</h3>
331
- <div className="space-y-3 print:space-y-1 text-sm">
332
- <div>
333
- <span className="text-xs font-semibold text-gray-600">Indication for Colposcopy:</span>
334
- <input
335
- type="text"
336
- value={formData.indication}
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
- <span className="text-xs font-semibold text-gray-600">Chief Complaint:</span>
343
- <textarea
344
- value={formData.complaint}
345
- onChange={(e) => handleFormChange('complaint', e.target.value)}
346
- 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"
347
- rows={2}
348
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  </div>
350
  </div>
351
- </div>
352
 
353
- {/* Examination Details */}
354
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
355
- <h3 className="text-sm font-bold text-gray-900 mb-3 pb-2 border-b border-gray-400 print:mb-1.5 print:pb-1 print:text-xs">EXAMINATION FINDINGS</h3>
356
- <div className="grid grid-cols-2 md:grid-cols-3 gap-3 print:gap-1.5 text-sm">
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
- <span className="text-xs font-semibold text-gray-600">Transformation Zone</span>
370
- <select
371
- value={formData.transformationZone}
372
- onChange={(e) => handleFormChange('transformationZone', e.target.value)}
373
- 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"
374
- >
375
- <option>I</option>
376
- <option>II</option>
377
- <option>III</option>
378
- </select>
379
- </div>
380
- <div>
381
- <span className="text-xs font-semibold text-gray-600">Acetowhite Lesion</span>
382
- <select
383
- value={formData.acetowL}
384
- onChange={(e) => handleFormChange('acetowL', e.target.value)}
385
- 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"
386
- >
387
- <option>Present</option>
388
- <option>Absent</option>
389
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  </div>
391
  </div>
392
- </div>
393
 
394
- {/* Image Sections */}
395
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
396
- <h3 className="text-sm font-bold text-gray-900 mb-3 pb-2 border-b border-gray-400 print:mb-1.5 print:pb-1 print:text-xs">EXAMINATION IMAGES</h3>
397
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4 print:gap-2">
398
- {(
399
- [
400
- { key: 'native', label: 'Native Image', color: 'from-blue-500 to-blue-600' },
401
- { key: 'acetic', label: 'Acetic Acid', color: 'from-purple-500 to-purple-600' },
402
- { key: 'lugol', label: 'Lugol\'s Iodine', color: 'from-amber-500 to-amber-600' },
403
- { key: 'biopsy', label: 'Biopsy Site', color: 'from-red-500 to-red-600' }
404
- ] as { key: ImageBucket; label: string; color: string }[]
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
- <div className="grid grid-cols-2 gap-2 print:gap-1">
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
- {/* Biopsy Marking Section */}
442
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
443
- <h3 className="text-sm font-bold text-gray-900 mb-3 pb-2 border-b border-gray-400 print:mb-1.5 print:pb-1 print:text-xs">BIOPSY MARKING</h3>
444
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 print:gap-2">
445
- {/* Biopsy Image */}
446
- <div className="border-2 border-dashed border-gray-300 hover:border-red-500 p-3 print:border-solid print:border-gray-400 min-h-[180px] print:min-h-[120px] bg-gray-50">
447
- <div className="text-xs font-semibold text-gray-700 mb-2 print:mb-1 print:text-xs">Biopsy Marking Image</div>
448
- {biopsyData?.markedImage ? (
449
- <div className="relative w-full h-full flex items-center justify-center bg-black rounded">
450
- <img
451
- src={biopsyData.markedImage}
452
- alt="Biopsy Marking"
453
- className="w-full h-40 print:h-24 object-contain"
454
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  </div>
456
- ) : (
457
- <div className="text-center text-gray-400 text-xs py-10 print:py-4">
458
- <p>No biopsy marking</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  </div>
460
- )}
 
 
 
 
 
 
 
 
 
 
 
461
  </div>
 
462
 
463
- {/* Biopsy Information */}
464
- <div className="space-y-3 print:space-y-1.5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  <div>
466
- <span className="text-xs font-semibold text-gray-600">Biopsy Sites:</span>
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-xs font-semibold text-gray-600">Biopsy Notes:</span>
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
- </div>
486
-
487
- {/* Diagnosis and Plan */}
488
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
489
- <h3 className="text-sm font-bold text-gray-900 mb-3 pb-2 border-b border-gray-400 print:mb-1.5 print:pb-1 print:text-xs">DIAGNOSIS & PLAN</h3>
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
- <span className="text-xs font-semibold text-gray-600">Follow-up Plan:</span>
511
- <textarea
512
- value={formData.followUp}
513
- onChange={(e) => handleFormChange('followUp', e.target.value)}
514
- 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"
515
- rows={2}
516
- />
 
 
 
 
 
517
  </div>
518
  </div>
519
- </div>
520
 
521
- {/* Signature Section */}
522
- <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200 mt-6 print:mt-2">
523
- <h3 className="text-sm font-bold text-gray-900 mb-4 pb-2 border-b border-gray-400 print:mb-2 print:pb-1 print:text-xs">SIGNATURES</h3>
524
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6 print:gap-2 text-sm print:text-xs">
525
- <div>
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-300 rounded-2xl shadow-lg p-4 sticky top-[829px]">
558
- <h3 className="text-sm font-bold text-gray-900 mb-4 pb-2 border-b border-gray-400">IMAGE LIBRARY</h3>
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
- stepId === 'greenFilter' ? 'bg-green-500' :
575
- stepId === 'lugol' ? 'bg-amber-500' : 'bg-gray-500'
576
- }`}></div>
577
  <span className="text-xs font-bold text-gray-700 uppercase">
578
- {stepId === 'native' ? 'Native' :
579
- stepId === 'acetowhite' ? 'Acetic Acid' :
580
- stepId === 'greenFilter' ? 'Green Filter' :
581
- stepId === 'lugol' ? 'Lugol' : stepId}
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-blue-500 hover:shadow-md transition-all"
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 &quot;Generate with AI&quot; 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 &quot;Generate with AI&quot; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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
+ }