nusaibah0110 commited on
Commit
bab7e89
·
0 Parent(s):

Initial deployment of Pathora Colposcopy Assistant

Browse files
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log
3
+ Dockerfile*
4
+ .dockerignore
5
+ .git
6
+ .gitignore
7
+ .vscode
8
+ **/dist
9
+ **/.DS_Store
10
+ *.swp
11
+ *.local
12
+ *.env*
.eslintrc.cjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ root: true,
3
+ env: { browser: true, es2020: true },
4
+ extends: [
5
+ 'eslint:recommended',
6
+ 'plugin:@typescript-eslint/recommended',
7
+ 'plugin:react-hooks/recommended',
8
+ ],
9
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
10
+ parser: '@typescript-eslint/parser',
11
+ plugins: ['react-refresh'],
12
+ rules: {
13
+ 'react-refresh/only-export-components': [
14
+ 'warn',
15
+ { allowConstantExport: true },
16
+ ],
17
+ },
18
+ }
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.gif filter=lfs diff=lfs merge=lfs -text
5
+ *.bmp filter=lfs diff=lfs merge=lfs -text
6
+ *.webp filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for Vite React app served as static site
2
+
3
+ FROM node:18-alpine AS build
4
+ WORKDIR /app
5
+
6
+ # Install dependencies
7
+ COPY package*.json ./
8
+ RUN npm ci || npm install
9
+
10
+ # Copy source
11
+ COPY . .
12
+
13
+ # Build static assets
14
+ RUN npm run build
15
+
16
+ FROM node:18-alpine AS runtime
17
+ WORKDIR /app
18
+
19
+ # Install a lightweight static file server
20
+ RUN npm install -g serve
21
+
22
+ # Copy build output from previous stage
23
+ COPY --from=build /app/dist ./dist
24
+
25
+ # Spaces sets PORT; default to 7860 for local runs
26
+ ENV PORT=7860
27
+ EXPOSE 7860
28
+
29
+ # Serve the built app
30
+ CMD ["sh", "-c", "serve -s dist -l ${PORT}"]
README.md ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pathora Colposcopy Assistant
2
+
3
+ A React-based colposcopy assistant application with image annotation, patient history tracking, and AI-powered chatbot features.
4
+
5
+ ## Local Development
6
+
7
+ 1. Install dependencies:
8
+ ```bash
9
+ npm install
10
+ ```
11
+
12
+ 2. Run development server:
13
+ ```bash
14
+ npm run dev
15
+ ```
16
+
17
+ 3. Build for production:
18
+ ```bash
19
+ npm run build
20
+ ```
21
+
22
+ ## Deploy to Hugging Face Spaces
23
+
24
+ This project is configured for deployment on Hugging Face Spaces using Docker runtime.
25
+
26
+ ### Step-by-Step Deployment Instructions
27
+
28
+ #### Step 1: Install Git and Git LFS
29
+
30
+ If you don't have Git installed:
31
+ - Download and install Git from https://git-scm.com/download/win
32
+ - Restart your terminal after installation
33
+
34
+ Install Git LFS (Large File Storage):
35
+ ```bash
36
+ git lfs install
37
+ ```
38
+
39
+ #### Step 2: Initialize Git Repository
40
+
41
+ Navigate to your project folder and initialize Git:
42
+ ```bash
43
+ cd "c:\Users\FARRUKH SAYEED\OneDrive\Desktop\Manalife Internship\Pathora_colpo"
44
+ git init
45
+ ```
46
+
47
+ #### Step 3: Add Hugging Face Remote
48
+
49
+ Add your Hugging Face Space as the remote repository:
50
+ ```bash
51
+ git remote add origin https://huggingface.co/spaces/ManalifeAI/Pathora_Colposcopy_Assistant
52
+ ```
53
+
54
+ #### Step 4: Stage All Files
55
+
56
+ Add all files to Git:
57
+ ```bash
58
+ git add .
59
+ ```
60
+
61
+ #### Step 5: Commit Your Changes
62
+
63
+ Create your first commit:
64
+ ```bash
65
+ git commit -m "Initial deployment of Pathora Colposcopy Assistant"
66
+ ```
67
+
68
+ #### Step 6: Push to Hugging Face
69
+
70
+ Push your code to Hugging Face Spaces:
71
+ ```bash
72
+ git push -u origin main
73
+ ```
74
+
75
+ **Note:** You'll be prompted for your Hugging Face credentials:
76
+ - Username: Your Hugging Face username
77
+ - Password: Use a **Hugging Face Access Token** (not your password)
78
+ - Get token from: https://huggingface.co/settings/tokens
79
+ - Create a new token with "write" access if you don't have one
80
+
81
+ #### Step 7: Wait for Build
82
+
83
+ After pushing:
84
+ 1. Go to https://huggingface.co/spaces/ManalifeAI/Pathora_Colposcopy_Assistant
85
+ 2. The Space will automatically start building from the Dockerfile
86
+ 3. Wait 2-5 minutes for the build to complete
87
+ 4. Your app will be live at the Space URL
88
+
89
+ ### Updating Your Space
90
+
91
+ To push updates after making changes:
92
+ ```bash
93
+ git add .
94
+ git commit -m "Description of your changes"
95
+ git push
96
+ ```
97
+
98
+ ## Local Docker Testing (Optional)
99
+
100
+ Test the Docker build locally before deploying:
101
+
102
+ ```bash
103
+ # Build the image
104
+ docker build -t pathora-colposcopy .
105
+
106
+ # Run the container
107
+ docker run --rm -e PORT=7860 -p 7860:7860 pathora-colposcopy
108
+
109
+ # Open http://localhost:7860 in your browser
110
+ ```
111
+
112
+ ## Project Structure
113
+
114
+ - `src/` - React TypeScript source code
115
+ - `components/` - Reusable UI components
116
+ - `pages/` - Page components
117
+ - `public/` - Static assets
118
+ - `Dockerfile` - Docker configuration for Hugging Face Spaces
119
+ - `vite.config.ts` - Vite build configuration
120
+
121
+ ## Technologies Used
122
+
123
+ - React 18
124
+ - TypeScript
125
+ - Vite
126
+ - Tailwind CSS
127
+ - Lucide React Icons
128
+ - html2pdf.js
129
+
130
+ ## Space URL
131
+
132
+ 🚀 Live at: https://huggingface.co/spaces/ManalifeAI/Pathora_Colposcopy_Assistant
index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover" />
7
+ <meta name="apple-mobile-web-app-capable" content="yes" />
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
9
+ <title>Pathora | Manalife's AI Pathology Assistant </title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/index.tsx"></script>
14
+ </body>
15
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "magic-patterns-vite-template",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "npx vite",
8
+ "build": "npx vite build",
9
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
10
+ "preview": "npx vite preview"
11
+ },
12
+ "dependencies": {
13
+ "html2pdf.js": "^0.14.0",
14
+ "lucide-react": "0.522.0",
15
+ "react": "^18.3.1",
16
+ "react-dom": "^18.3.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.11.18",
20
+ "@types/react": "^18.3.1",
21
+ "@types/react-dom": "^18.3.1",
22
+ "@typescript-eslint/eslint-plugin": "^5.54.0",
23
+ "@typescript-eslint/parser": "^5.54.0",
24
+ "@vitejs/plugin-react": "^4.2.1",
25
+ "autoprefixer": "latest",
26
+ "eslint": "^8.50.0",
27
+ "eslint-plugin-react-hooks": "^4.6.0",
28
+ "eslint-plugin-react-refresh": "^0.4.1",
29
+ "postcss": "latest",
30
+ "tailwindcss": "3.4.17",
31
+ "typescript": "^5.5.4",
32
+ "vite": "^5.2.0"
33
+ }
34
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
public/AI_Demo_img.png ADDED

Git LFS Details

  • SHA256: 8718521d66ab7299d39d16b128764f1ddc95201a9504c5a76aee3e1ecaacbeb3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.52 MB
public/C87Aceto_(1).jpg ADDED

Git LFS Details

  • SHA256: 7906a99aa8d75594e324cec494e5481be7bff593bdde4c1e442bc3ddf535a38d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.59 MB
public/banner.jpeg ADDED

Git LFS Details

  • SHA256: fb5b916a12530d4f45c38c35ca397553b9c6cb3d8d1cc74bdb00e45b7c2f5460
  • Pointer size: 130 Bytes
  • Size of remote file: 27 kB
public/greenC87Aceto_(1).jpg ADDED

Git LFS Details

  • SHA256: 9b7141595a3b79220beeaf68ebbd9dffff3692744d45b75c620e4cec31f7fa4d
  • Pointer size: 131 Bytes
  • Size of remote file: 260 kB
public/white_logo.png ADDED

Git LFS Details

  • SHA256: 5f9d2999bd543bcd19238d828b82af30e6fdc2b42ab93bd7b0ccd998fa9a29fd
  • Pointer size: 130 Bytes
  • Size of remote file: 75.7 kB
src/App.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { PatientHistoryPage } from './pages/PatientHistoryPage';
3
+
4
+ import HomePage from './pages/HomePage.tsx';
5
+ import { PatientRegistry } from './pages/PatientRegistry.tsx';
6
+ import { GuidedCapturePage } from './pages/GuidedCapturePage';
7
+ import { AcetowhiteExamPage } from './pages/AcetowhiteExamPage';
8
+ import { GreenFilterPage } from './pages/GreenFilterPage';
9
+ import { LugolExamPage } from './pages/LugolExamPage';
10
+ import { BiopsyMarking } from './pages/BiopsyMarking';
11
+ import { ReportPage } from './pages/ReportPage';
12
+ import { Sidebar } from './components/Sidebar';
13
+ import { Header } from './components/Header';
14
+ import { Footer } from './components/Footer';
15
+ import { ChatBot } from './components/ChatBot';
16
+
17
+ export function App() {
18
+ const [currentPage, setCurrentPage] = useState<'home' | 'patientinfo' | 'patienthistory' | 'colposcopyimaging' | 'acetowhite' | 'greenfilter' | 'lugol' | 'guidedcapture' | 'biopsymarking' | 'capture' | 'annotation' | 'compare' | 'report' | 'settings'>('home');
19
+ const [currentPatientId, setCurrentPatientId] = useState<string | undefined>(undefined);
20
+ const [isNewPatientFlow, setIsNewPatientFlow] = useState(false);
21
+ const [capturedImages, setCapturedImages] = useState<any[]>([]);
22
+ const [guidanceMode, setGuidanceMode] = useState<'capture' | 'annotation' | 'compare' | 'report'>('capture');
23
+
24
+ const goToPatientRegistry = () => {
25
+ setCurrentPatientId(undefined);
26
+ setIsNewPatientFlow(false);
27
+ setCurrentPage('patientinfo');
28
+ };
29
+
30
+ const goToPatientHistory = (patientId?: string, isNewPatient: boolean = false) => {
31
+ setCurrentPatientId(patientId);
32
+ setIsNewPatientFlow(isNewPatient);
33
+ setCurrentPage('patienthistory');
34
+ };
35
+
36
+ const goToHome = () => {
37
+ setCurrentPatientId(undefined);
38
+ setIsNewPatientFlow(false);
39
+ setCurrentPage('home');
40
+ };
41
+
42
+ const goToColposcopyImaging = () => setCurrentPage('colposcopyimaging');
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');
50
+ setGuidanceMode('capture');
51
+ };
52
+ const goToAnnotation = () => {
53
+ setCurrentPage('annotation');
54
+ setGuidanceMode('annotation');
55
+ };
56
+ const goToCompare = () => {
57
+ setCurrentPage('compare');
58
+ setGuidanceMode('compare');
59
+ };
60
+ const goToReport = () => setCurrentPage('report');
61
+
62
+ const renderMain = () => {
63
+ switch (currentPage) {
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
+
71
+ case 'acetowhite':
72
+ return <AcetowhiteExamPage goBack={goToColposcopyImaging} onNext={goToGreenFilter} />;
73
+ case 'greenfilter':
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} onCapturedImagesChange={setCapturedImages} onModeChange={setGuidanceMode} />;
79
+ case 'biopsymarking':
80
+ return <BiopsyMarking onBack={goToGuidedCapture} onNext={goToReport} capturedImages={capturedImages} />;
81
+ case 'capture':
82
+ return <GuidedCapturePage onNext={goToBiopsyMarking} initialMode="capture" onCapturedImagesChange={setCapturedImages} onModeChange={setGuidanceMode} />;
83
+ case 'annotation':
84
+ return <GuidedCapturePage onNext={goToBiopsyMarking} initialMode="annotation" onCapturedImagesChange={setCapturedImages} onModeChange={setGuidanceMode} />;
85
+ case 'compare':
86
+ return <GuidedCapturePage onNext={goToBiopsyMarking} initialMode="compare" onCapturedImagesChange={setCapturedImages} onModeChange={setGuidanceMode} />;
87
+ case 'report':
88
+ return <ReportPage onBack={goToCompare} onNext={goToHome} capturedImages={capturedImages} />;
89
+ default:
90
+ return <div className="p-8">Page "{currentPage}" not implemented yet.</div>;
91
+ }
92
+ };
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">
100
+ <Header compact={currentPage === 'guidedcapture'} />
101
+
102
+ <div className="flex-1 flex min-h-0">
103
+ {showSidebar && <Sidebar current={sidebarKey as 'home' | 'patientinfo' | 'capture' | 'annotation' | 'compare' | 'report' | 'settings'} onNavigate={k => {
104
+ if (k === 'home') goToHome();
105
+ else if (k === 'patientinfo') goToPatientRegistry();
106
+ else if (k === 'capture') goToCapture();
107
+ else if (k === 'annotation') goToAnnotation();
108
+ else if (k === 'compare') goToCompare();
109
+ else if (k === 'report') goToReport();
110
+ else setCurrentPage(k as any);
111
+ }} />}
112
+ <main className="flex-1 bg-white overflow-auto">
113
+ {renderMain()}
114
+ </main>
115
+ </div>
116
+
117
+ <Footer compact={currentPage === 'guidedcapture'} />
118
+ <ChatBot />
119
+ </div>
120
+ );
121
+ }
src/components/AceticAnnotator.tsx ADDED
@@ -0,0 +1,767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
6
+ interface Point {
7
+ x: number;
8
+ y: number;
9
+ }
10
+
11
+ interface Annotation {
12
+ id: string;
13
+ type: ShapeType;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ color: string;
19
+ label?: string;
20
+ points?: Point[];
21
+ source?: 'manual' | 'ai';
22
+ identified?: boolean;
23
+ accepted?: boolean;
24
+ }
25
+
26
+ interface AceticAnnotatorProps {
27
+ imageUrl?: string;
28
+ imageUrls?: string[];
29
+ onAnnotationsChange?: (annotations: Annotation[]) => void;
30
+ }
31
+
32
+ export interface AceticAnnotatorHandle {
33
+ addAIAnnotations: (aiAnnotations: Annotation[]) => void;
34
+ }
35
+
36
+ const AceticAnnotatorComponent = forwardRef<AceticAnnotatorHandle, AceticAnnotatorProps>(({ imageUrl, imageUrls, onAnnotationsChange }, ref) => {
37
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
38
+ const containerRef = useRef<HTMLDivElement | null>(null);
39
+ const [annotations, setAnnotations] = useState<Annotation[]>([]);
40
+ const [tool, setTool] = useState<ShapeType>('rect');
41
+ const [color, setColor] = useState('#05998c');
42
+ const [labelInput, setLabelInput] = useState('');
43
+ const [isDrawing, setIsDrawing] = useState(false);
44
+ const [startPoint, setStartPoint] = useState<Point | null>(null);
45
+ const [currentAnnotation, setCurrentAnnotation] = useState<Annotation | null>(null);
46
+ const [imageLoaded, setImageLoaded] = useState(false);
47
+ const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
48
+ const [polygonPoints, setPolygonPoints] = useState<Point[]>([]);
49
+ const [selectedImageIndex, setSelectedImageIndex] = useState(0);
50
+ const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true);
51
+ const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false);
52
+ const [activeTab, setActiveTab] = useState<'annotate' | 'findings'>('annotate');
53
+ const [selectedCategories, setSelectedCategories] = useState<Record<string, boolean>>({});
54
+ const [selectedFindings, setSelectedFindings] = useState<Record<string, boolean>>({});
55
+ const [additionalNotes, setAdditionalNotes] = useState('');
56
+
57
+ // Edit state for annotation list
58
+ const [editingId, setEditingId] = useState<string | null>(null);
59
+ const [editLabel, setEditLabel] = useState('');
60
+ const [editColor, setEditColor] = useState('#05998c');
61
+
62
+ // Annotation metadata state
63
+ const [annotationIdentified, setAnnotationIdentified] = useState<Record<string, boolean>>({});
64
+ const [annotationAccepted, setAnnotationAccepted] = useState<Record<string, boolean>>({});
65
+
66
+ // Predefined label options for Acetic Acid step (ONLY CHANGE from ImageAnnotator)
67
+ const labelOptions = [
68
+ 'Acetowhite',
69
+ 'Mosaic',
70
+ 'Punctation',
71
+ 'Blood Vessel',
72
+ 'Border'
73
+ ];
74
+
75
+ // Category-based findings structure
76
+ const findingsCategories = [
77
+ {
78
+ name: 'Thin Acetowhite Epithelium',
79
+ features: ['Dull white', 'Appears slowly', 'Fades quickly']
80
+ },
81
+ {
82
+ name: 'Borders',
83
+ features: ['Irregular', 'Geographic', 'Feathered edges', 'Sharp', 'Raised', 'Rolled edges', 'Inner border sign']
84
+ },
85
+ {
86
+ name: 'Vascular Pattern',
87
+ features: ['Fine punctation', 'Fine mosaic', 'Coarse punctation', 'Coarse mosaic']
88
+ },
89
+ {
90
+ name: 'Dense Acetowhite Epithelium',
91
+ features: ['Chalky white', 'Oyster white', 'Greyish white', 'Rapid onset', 'Persists']
92
+ },
93
+ {
94
+ name: 'Gland Openings',
95
+ features: ['Cuffed', 'Enlarged crypt openings']
96
+ },
97
+ {
98
+ name: 'Non-Specific Abnormal Findings',
99
+ features: ['Leukoplakia (keratosis)', 'Hyperkeratosis', 'Erosion']
100
+ }
101
+ ];
102
+
103
+ // Filter labels based on input
104
+ const filteredLabels = labelOptions.filter(label =>
105
+ label.toLowerCase().includes(labelInput.toLowerCase())
106
+ );
107
+
108
+ // Finding checkboxes
109
+ const toggleFinding = (label: string) => {
110
+ setSelectedFindings(prev => ({
111
+ ...prev,
112
+ [label]: !prev[label]
113
+ }));
114
+ };
115
+
116
+ const toggleCategory = (name: string) => {
117
+ setSelectedCategories(prev => ({
118
+ ...prev,
119
+ [name]: !prev[name]
120
+ }));
121
+ };
122
+
123
+ // Expose addAIAnnotations method via ref
124
+ useImperativeHandle(ref, () => ({
125
+ addAIAnnotations: (aiAnnotations: Annotation[]) => {
126
+ setAnnotations(prev => [...prev, ...aiAnnotations]);
127
+ }
128
+ }));
129
+
130
+ const images = imageUrls || (imageUrl ? [imageUrl] : []);
131
+ const currentImageUrl = images[selectedImageIndex];
132
+
133
+ useEffect(() => {
134
+ const img = new Image();
135
+ img.src = currentImageUrl;
136
+ img.onload = () => {
137
+ setImageDimensions({ width: img.width, height: img.height });
138
+ setImageLoaded(true);
139
+ drawCanvas();
140
+ };
141
+ }, [currentImageUrl]);
142
+
143
+ useEffect(() => {
144
+ if (imageLoaded) drawCanvas();
145
+ }, [annotations, currentAnnotation, polygonPoints, imageLoaded]);
146
+
147
+ useEffect(() => {
148
+ if (onAnnotationsChange) onAnnotationsChange(annotations);
149
+ }, [annotations, onAnnotationsChange]);
150
+
151
+ const clearAnnotations = () => setAnnotations([]);
152
+ const deleteLastAnnotation = () => setAnnotations(prev => prev.slice(0, -1));
153
+
154
+ const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>) => {
155
+ const canvas = canvasRef.current;
156
+ if (!canvas) return { x: 0, y: 0 };
157
+ const rect = canvas.getBoundingClientRect();
158
+ const scaleX = imageDimensions.width / canvas.width || 1;
159
+ const scaleY = imageDimensions.height / canvas.height || 1;
160
+ return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
161
+ };
162
+
163
+ const startDrawing = (point: Point) => {
164
+ setStartPoint(point);
165
+ setIsDrawing(true);
166
+ };
167
+
168
+ const finishDrawingRectOrCircle = () => {
169
+ if (currentAnnotation && (currentAnnotation.width > 5 || currentAnnotation.height > 5)) {
170
+ const ann: Annotation = { ...currentAnnotation, id: Date.now().toString(), label: labelInput, source: 'manual', identified: false };
171
+ setAnnotations(prev => [...prev, ann]);
172
+ }
173
+ setIsDrawing(false);
174
+ setStartPoint(null);
175
+ setCurrentAnnotation(null);
176
+ };
177
+
178
+ const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
179
+ const p = getCanvasCoordinates(e);
180
+ if (tool === 'polygon') {
181
+ // add point to polygon
182
+ setPolygonPoints(prev => [...prev, p]);
183
+ return;
184
+ }
185
+ startDrawing(p);
186
+ };
187
+
188
+ const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
189
+ if (tool === 'polygon') return; // polygon preview handled by polygonPoints
190
+ if (!isDrawing || !startPoint) return;
191
+ const p = getCanvasCoordinates(e);
192
+ const w = p.x - startPoint.x;
193
+ const h = p.y - startPoint.y;
194
+ const ann: Annotation = {
195
+ id: 'temp',
196
+ type: tool,
197
+ x: w > 0 ? startPoint.x : p.x,
198
+ y: h > 0 ? startPoint.y : p.y,
199
+ width: Math.abs(w),
200
+ height: Math.abs(h),
201
+ color,
202
+ label: labelInput
203
+ };
204
+ setCurrentAnnotation(ann);
205
+ };
206
+
207
+ const handleMouseUp = () => {
208
+ if (tool === 'polygon') return;
209
+ if (isDrawing) finishDrawingRectOrCircle();
210
+ };
211
+
212
+ const finishPolygon = () => {
213
+ if (polygonPoints.length < 3) return;
214
+ const bounds = getBoundsFromPoints(polygonPoints);
215
+ const ann: Annotation = {
216
+ id: Date.now().toString(),
217
+ type: 'polygon',
218
+ x: bounds.x,
219
+ y: bounds.y,
220
+ width: bounds.width,
221
+ height: bounds.height,
222
+ color,
223
+ label: labelInput,
224
+ points: polygonPoints,
225
+ source: 'manual',
226
+ identified: false
227
+ };
228
+ setAnnotations(prev => [...prev, ann]);
229
+ setPolygonPoints([]);
230
+ setLabelInput('');
231
+ };
232
+
233
+ const cancelPolygon = () => setPolygonPoints([]);
234
+
235
+ const getBoundsFromPoints = (pts: Point[]) => {
236
+ const xs = pts.map(p => p.x);
237
+ const ys = pts.map(p => p.y);
238
+ const minX = Math.min(...xs);
239
+ const minY = Math.min(...ys);
240
+ const maxX = Math.max(...xs);
241
+ const maxY = Math.max(...ys);
242
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
243
+ };
244
+
245
+ const drawCanvas = () => {
246
+ const canvas = canvasRef.current;
247
+ const container = containerRef.current;
248
+ if (!canvas || !container) return;
249
+ const ctx = canvas.getContext('2d');
250
+ if (!ctx) return;
251
+ const img = new Image();
252
+ img.src = currentImageUrl;
253
+ img.onload = () => {
254
+ const containerWidth = container.clientWidth;
255
+ const aspectRatio = img.height / img.width || 1;
256
+ const canvasHeight = containerWidth * aspectRatio;
257
+ canvas.width = containerWidth;
258
+ canvas.height = canvasHeight;
259
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
260
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
261
+
262
+ // draw saved annotations
263
+ annotations.forEach(a => drawAnnotation(ctx, a, canvas.width, canvas.height));
264
+
265
+ // draw polygon being created
266
+ if (polygonPoints.length > 0) {
267
+ drawPreviewPolygon(ctx, polygonPoints, canvas.width, canvas.height, color);
268
+ }
269
+
270
+ // draw current temp annotation
271
+ if (currentAnnotation) drawAnnotation(ctx, currentAnnotation, canvas.width, canvas.height);
272
+ };
273
+ };
274
+
275
+ const drawPreviewPolygon = (ctx: CanvasRenderingContext2D, pts: Point[], canvasWidth: number, canvasHeight: number, col: string) => {
276
+ const scaleX = canvasWidth / imageDimensions.width || 1;
277
+ const scaleY = canvasHeight / imageDimensions.height || 1;
278
+ ctx.strokeStyle = col;
279
+ ctx.lineWidth = 2;
280
+ ctx.setLineDash([6, 4]);
281
+ ctx.beginPath();
282
+ pts.forEach((p, i) => {
283
+ const x = p.x * scaleX;
284
+ const y = p.y * scaleY;
285
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
286
+ });
287
+ ctx.stroke();
288
+ ctx.setLineDash([]);
289
+ };
290
+
291
+ const drawAnnotation = (ctx: CanvasRenderingContext2D, annotation: Annotation, canvasWidth: number, canvasHeight: number) => {
292
+ const scaleX = canvasWidth / imageDimensions.width || 1;
293
+ const scaleY = canvasHeight / imageDimensions.height || 1;
294
+ ctx.strokeStyle = annotation.color;
295
+ ctx.lineWidth = 3;
296
+ ctx.setLineDash([]);
297
+ if (annotation.type === 'rect') {
298
+ ctx.strokeRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
299
+ ctx.fillStyle = annotation.color + '20';
300
+ ctx.fillRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
301
+ } else if (annotation.type === 'circle') {
302
+ const cx = (annotation.x + annotation.width / 2) * scaleX;
303
+ const cy = (annotation.y + annotation.height / 2) * scaleY;
304
+ const r = Math.max(annotation.width * scaleX, annotation.height * scaleY) / 2;
305
+ ctx.beginPath();
306
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
307
+ ctx.stroke();
308
+ ctx.fillStyle = annotation.color + '20';
309
+ ctx.fill();
310
+ } else if (annotation.type === 'polygon' && annotation.points) {
311
+ ctx.beginPath();
312
+ annotation.points.forEach((p, i) => {
313
+ const x = p.x * scaleX;
314
+ const y = p.y * scaleY;
315
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
316
+ });
317
+ ctx.closePath();
318
+ ctx.stroke();
319
+ ctx.fillStyle = annotation.color + '20';
320
+ ctx.fill();
321
+ }
322
+ };
323
+
324
+ // list editing
325
+ const startEdit = (ann: Annotation) => {
326
+ setEditingId(ann.id);
327
+ setEditLabel(ann.label || '');
328
+ setEditColor(ann.color || '#05998c');
329
+ };
330
+ const saveEdit = () => {
331
+ setAnnotations(prev => prev.map(a => a.id === editingId ? { ...a, label: editLabel, color: editColor } : a));
332
+ setEditingId(null);
333
+ };
334
+ const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
335
+
336
+ const getShapeTypeName = (type: ShapeType): string => {
337
+ const typeMap: Record<ShapeType, string> = {
338
+ 'rect': 'Rectangle',
339
+ 'circle': 'Circle',
340
+ 'polygon': 'Polygon'
341
+ };
342
+ return typeMap[type] || type;
343
+ };
344
+
345
+ return (
346
+ <div className="space-y-3 md:space-y-4">
347
+ {/* Image Selection - Show if multiple images */}
348
+ {images.length > 1 && (
349
+ <div>
350
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2 md:mb-3">
351
+ Select Image
352
+ </label>
353
+ <div className="flex gap-2 overflow-x-auto pb-2">
354
+ {images.map((imgUrl, idx) => (
355
+ <button
356
+ key={idx}
357
+ onClick={() => setSelectedImageIndex(idx)}
358
+ className={`relative flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-lg overflow-hidden border-2 transition-all ${
359
+ selectedImageIndex === idx
360
+ ? 'border-[#05998c] ring-2 ring-[#05998c]/50'
361
+ : 'border-gray-300'
362
+ }`}
363
+ >
364
+ <img
365
+ src={imgUrl}
366
+ alt={`Image ${idx + 1}`}
367
+ className="w-full h-full object-cover"
368
+ />
369
+ {/* Grey overlay for selected image */}
370
+ {selectedImageIndex === idx && (
371
+ <div className="absolute inset-0 bg-black/30 flex items-center justify-center">
372
+ <div className="w-5 h-5 rounded-full bg-[#05998c] flex items-center justify-center">
373
+ <div className="w-2 h-2 bg-white rounded-full" />
374
+ </div>
375
+ </div>
376
+ )}
377
+ </button>
378
+ ))}
379
+ </div>
380
+ </div>
381
+ )}
382
+
383
+ {/* Tab Switcher */}
384
+ <div className="flex justify-center">
385
+ <div className="inline-flex bg-gray-100 rounded-lg p-1 border border-gray-300">
386
+ <button
387
+ onClick={() => setActiveTab('annotate')}
388
+ className={`px-6 py-2 rounded-md font-semibold text-sm transition-all ${
389
+ activeTab === 'annotate'
390
+ ? 'bg-[#05998c] text-white shadow-sm'
391
+ : 'bg-transparent text-gray-700 hover:text-gray-900'
392
+ }`}
393
+ >
394
+ Annotate
395
+ </button>
396
+ <button
397
+ onClick={() => setActiveTab('findings')}
398
+ className={`px-6 py-2 rounded-md font-semibold text-sm transition-all ${
399
+ activeTab === 'findings'
400
+ ? 'bg-[#05998c] text-white shadow-sm'
401
+ : 'bg-transparent text-gray-700 hover:text-gray-900'
402
+ }`}
403
+ >
404
+ Findings
405
+ </button>
406
+ </div>
407
+ </div>
408
+
409
+ {/* Annotate Tab */}
410
+ {activeTab === 'annotate' && (
411
+ <>
412
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-3">
413
+ <div className="flex items-center gap-2">
414
+ <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">
415
+ <Wrench className="w-4 h-4 text-[#05998c]" />
416
+ <span className="font-medium">Tools</span>
417
+ </div>
418
+ <div className="flex items-center gap-1 md:gap-2">
419
+ <button onClick={() => setTool('rect')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'rect' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><SquareIcon className="inline w-4 h-4" /></button>
420
+ <button onClick={() => setTool('circle')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'circle' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><CircleIcon className="inline w-4 h-4" /></button>
421
+ <button onClick={() => setTool('polygon')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'polygon' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><Hexagon className="inline w-4 h-4" /></button>
422
+ </div>
423
+ </div>
424
+
425
+ <div className="flex flex-col md:flex-row md:items-center gap-2">
426
+ <div className="relative">
427
+ <input
428
+ aria-label="Annotation label"
429
+ placeholder="Search or select label"
430
+ value={labelInput}
431
+ onChange={e => setLabelInput(e.target.value)}
432
+ onFocus={() => setIsLabelDropdownOpen(true)}
433
+ onBlur={() => setTimeout(() => setIsLabelDropdownOpen(false), 200)}
434
+ className="px-3 py-1 border rounded text-xs md:text-sm w-48"
435
+ />
436
+ {isLabelDropdownOpen && filteredLabels.length > 0 && (
437
+ <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">
438
+ {filteredLabels.map((label, idx) => (
439
+ <button
440
+ key={idx}
441
+ type="button"
442
+ onMouseDown={(e) => {
443
+ e.preventDefault();
444
+ setLabelInput(label);
445
+ setIsLabelDropdownOpen(false);
446
+ }}
447
+ className="w-full text-left px-3 py-2 text-xs md:text-sm hover:bg-gray-100 transition-colors"
448
+ >
449
+ {label}
450
+ </button>
451
+ ))}
452
+ </div>
453
+ )}
454
+ </div>
455
+ <input aria-label="Annotation color" type="color" value={color} onChange={e => setColor(e.target.value)} className="w-10 h-8 p-0 border rounded" />
456
+ <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">
457
+ <Trash2 className="w-4 h-4" />
458
+ <span className="hidden md:inline">Undo</span>
459
+ <span className="inline md:hidden">Undo</span>
460
+ </button>
461
+ <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>
462
+ </div>
463
+ </div>
464
+
465
+ <div className="flex flex-col">
466
+ <div>
467
+ <div ref={containerRef} className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700">
468
+ <canvas ref={canvasRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} className="w-full cursor-crosshair" />
469
+ </div>
470
+ </div>
471
+ </div>
472
+
473
+ {tool === 'polygon' && (
474
+ <div className="flex flex-col md:flex-row md:items-center gap-2">
475
+ <span className="text-xs md:text-sm text-gray-600">Polygon points: {polygonPoints.length}</span>
476
+ <button onClick={finishPolygon} disabled={polygonPoints.length < 3} className="px-3 py-1 text-xs md:text-sm bg-green-600 text-white rounded disabled:opacity-50">Finish Polygon</button>
477
+ <button onClick={cancelPolygon} disabled={polygonPoints.length === 0} className="px-3 py-1 text-xs md:text-sm bg-gray-200 rounded">Cancel</button>
478
+ </div>
479
+ )}
480
+
481
+ {/* Annotations Table */}
482
+ <div className="border border-gray-200 rounded-lg overflow-hidden">
483
+ <button
484
+ onClick={() => setIsAnnotationsOpen(!isAnnotationsOpen)}
485
+ className="w-full flex items-center justify-between p-3 md:p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
486
+ >
487
+ <div className="flex items-center gap-2">
488
+ <span className="text-xs md:text-sm font-semibold text-gray-700">
489
+ Annotations ({annotations.length})
490
+ </span>
491
+ </div>
492
+ {isAnnotationsOpen ? (
493
+ <ChevronUp className="w-4 h-4 text-gray-600" />
494
+ ) : (
495
+ <ChevronDown className="w-4 h-4 text-gray-600" />
496
+ )}
497
+ </button>
498
+
499
+ {isAnnotationsOpen && (
500
+ <div className="overflow-x-auto border-t border-gray-200 max-h-96 overflow-y-auto">
501
+ {annotations.length === 0 ? (
502
+ <div className="p-3 md:p-4 bg-white text-center">
503
+ <p className="text-xs text-gray-500">No annotations yet. Draw on the image to create annotations.</p>
504
+ </div>
505
+ ) : (
506
+ <table className="w-full">
507
+ <thead className="bg-gray-50 sticky top-0 border-b border-gray-200">
508
+ <tr>
509
+ <th className="px-3 md:px-4 py-2 text-left text-xs font-semibold text-gray-700">Annotation</th>
510
+ <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Identified</th>
511
+ <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Source</th>
512
+ <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Action</th>
513
+ </tr>
514
+ </thead>
515
+ <tbody>
516
+ {annotations.map((a) => (
517
+ <tr key={a.id} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
518
+ {/* Annotation Column - shown in color it was drawn */}
519
+ <td className="px-3 md:px-4 py-3">
520
+ {editingId === a.id ? (
521
+ <div className="flex items-center gap-2">
522
+ <input
523
+ type="color"
524
+ value={editColor}
525
+ onChange={e => setEditColor(e.target.value)}
526
+ className="w-6 h-6 rounded p-0 border cursor-pointer"
527
+ />
528
+ <input
529
+ value={editLabel}
530
+ onChange={e => setEditLabel(e.target.value)}
531
+ className="px-2 py-1 border rounded text-xs flex-1"
532
+ placeholder="Label"
533
+ />
534
+ </div>
535
+ ) : (
536
+ <div className="flex items-center gap-3">
537
+ <div className="w-6 h-6 rounded-sm flex-shrink-0" style={{ backgroundColor: a.color }} />
538
+ <div>
539
+ <div className="text-sm font-medium text-gray-900">{a.label || '(no label)'}</div>
540
+ <div className="text-xs text-gray-500">{getShapeTypeName(a.type)}</div>
541
+ </div>
542
+ </div>
543
+ )}
544
+ </td>
545
+
546
+ {/* Identified Checkbox Column */}
547
+ <td className="px-3 md:px-4 py-3 text-center">
548
+ <input
549
+ type="checkbox"
550
+ checked={annotationIdentified[a.id] || false}
551
+ onChange={(e) => {
552
+ setAnnotationIdentified(prev => ({
553
+ ...prev,
554
+ [a.id]: e.target.checked
555
+ }));
556
+ }}
557
+ className="w-4 h-4 rounded border-gray-300 cursor-pointer"
558
+ />
559
+ </td>
560
+
561
+ {/* Source Column (Manual/AI) */}
562
+ <td className="px-3 md:px-4 py-3 text-center">
563
+ <span className={`inline-block px-2 py-1 rounded text-xs font-medium ${
564
+ a.source === 'ai'
565
+ ? 'bg-blue-100 text-blue-800'
566
+ : 'bg-gray-100 text-gray-800'
567
+ }`}>
568
+ {a.source === 'ai' ? 'AI' : 'Manual'}
569
+ </span>
570
+ </td>
571
+
572
+ {/* Action Column - Accept/Reject for AI, Edit/Delete for Manual */}
573
+ <td className="px-3 md:px-4 py-3">
574
+ <div className="flex items-center justify-center gap-2">
575
+ {editingId === a.id ? (
576
+ <>
577
+ <button
578
+ onClick={saveEdit}
579
+ className="px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
580
+ >
581
+ Save
582
+ </button>
583
+ <button
584
+ onClick={() => setEditingId(null)}
585
+ className="px-2 py-1 text-xs font-medium bg-gray-300 text-gray-800 rounded hover:bg-gray-400 transition-colors"
586
+ >
587
+ Cancel
588
+ </button>
589
+ </>
590
+ ) : a.source === 'ai' ? (
591
+ <>
592
+ {annotationAccepted[a.id] === true ? (
593
+ <div className="flex items-center gap-2">
594
+ <span className="px-3 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
595
+ ✓ Accepted
596
+ </span>
597
+ <button
598
+ onClick={() => {
599
+ setAnnotationAccepted(prev => {
600
+ const next = { ...prev };
601
+ delete next[a.id];
602
+ return next;
603
+ });
604
+ setAnnotationIdentified(prev => {
605
+ const next = { ...prev };
606
+ delete next[a.id];
607
+ return next;
608
+ });
609
+ }}
610
+ className="px-2 py-1 text-[11px] font-medium bg-white border border-gray-200 text-gray-700 rounded hover:bg-gray-50 transition-colors"
611
+ >
612
+ Undo
613
+ </button>
614
+ </div>
615
+ ) : annotationAccepted[a.id] === false ? (
616
+ <span className="px-3 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
617
+ ✕ Rejected
618
+ </span>
619
+ ) : (
620
+ <>
621
+ <button
622
+ onClick={() => {
623
+ setAnnotationAccepted(prev => ({
624
+ ...prev,
625
+ [a.id]: true
626
+ }));
627
+ setAnnotationIdentified(prev => ({
628
+ ...prev,
629
+ [a.id]: true
630
+ }));
631
+ }}
632
+ className="px-2 py-1 text-xs font-medium bg-green-50 text-green-700 border border-green-200 rounded hover:bg-green-100 transition-colors"
633
+ >
634
+ ✓ Accept
635
+ </button>
636
+ <button
637
+ onClick={() => {
638
+ setAnnotations(prev => prev.filter(item => item.id !== a.id));
639
+ setAnnotationAccepted(prev => {
640
+ const next = { ...prev };
641
+ delete next[a.id];
642
+ return next;
643
+ });
644
+ setAnnotationIdentified(prev => {
645
+ const next = { ...prev };
646
+ delete next[a.id];
647
+ return next;
648
+ });
649
+ }}
650
+ className="px-2 py-1 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded hover:bg-red-100 transition-colors"
651
+ >
652
+ ✕ Reject
653
+ </button>
654
+ </>
655
+ )}
656
+ </>
657
+ ) : (
658
+ <>
659
+ <button
660
+ onClick={() => startEdit(a)}
661
+ className="px-2 py-1 text-xs font-medium bg-white border border-gray-300 rounded hover:bg-gray-50 transition-colors"
662
+ >
663
+ Edit
664
+ </button>
665
+ <button
666
+ onClick={() => deleteAnnotation(a.id)}
667
+ className="px-2 py-1 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded hover:bg-red-100 transition-colors"
668
+ >
669
+ Delete
670
+ </button>
671
+ </>
672
+ )}
673
+ </div>
674
+ </td>
675
+ </tr>
676
+ ))}
677
+ </tbody>
678
+ </table>
679
+ )}
680
+ </div>
681
+ )}
682
+ </div>
683
+ </>
684
+ )}
685
+
686
+ {/* Findings Tab */}
687
+ {activeTab === 'findings' && (
688
+ <div className="flex flex-col md:flex-row gap-4 items-start">
689
+ {/* Left Side - Image Viewer */}
690
+ <div className="flex-1">
691
+ <div ref={containerRef} className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700">
692
+ <canvas ref={canvasRef} className="w-full cursor-default" style={{ pointerEvents: 'none' }} />
693
+ </div>
694
+ </div>
695
+
696
+ {/* Right Side - Clinical Findings Form */}
697
+ <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">
698
+ {/* Header */}
699
+ <div className="mb-5 pb-4 border-b-2 border-[#05998c]">
700
+ <div className="flex items-center gap-2 mb-1">
701
+ <div className="w-1 h-5 bg-[#05998c] rounded-full"></div>
702
+ <p className="text-sm uppercase tracking-wider font-bold text-[#05998c]">Clinical Findings</p>
703
+ </div>
704
+ <p className="text-xs text-gray-600 ml-3">Select findings for each category</p>
705
+ </div>
706
+
707
+ <div className="space-y-5 text-sm text-gray-800">
708
+ {/* Render all categories with their features */}
709
+ {findingsCategories.map(category => (
710
+ <div key={category.name} className="space-y-3 pb-4 border-b border-gray-300">
711
+ {/* Category as Checkbox (multi-select) */}
712
+ <label className="flex items-center gap-3 cursor-pointer hover:bg-blue-50 p-2 rounded transition-colors -ml-2">
713
+ <input
714
+ type="checkbox"
715
+ checked={!!selectedCategories[category.name]}
716
+ onChange={() => toggleCategory(category.name)}
717
+ className="w-4 h-4 cursor-pointer accent-[#05998c] flex-shrink-0"
718
+ />
719
+ <div className="flex items-center gap-2 flex-1">
720
+ <span className="inline-block w-2 h-2 bg-[#05998c] rounded-full flex-shrink-0"></span>
721
+ <p className="font-bold text-gray-900">{category.name}</p>
722
+ </div>
723
+ </label>
724
+
725
+ {/* Features as Checkboxes */}
726
+ <div className="space-y-2 pl-8">
727
+ {category.features.map(feature => (
728
+ <label key={feature} className="flex items-center gap-3 text-gray-700 hover:text-[#05998c] cursor-pointer transition-colors">
729
+ <input
730
+ type="checkbox"
731
+ checked={!!selectedFindings[feature]}
732
+ onChange={() => toggleFinding(feature)}
733
+ className="w-4 h-4 rounded border-[#05998c] cursor-pointer accent-[#05998c] flex-shrink-0"
734
+ />
735
+ <span className="text-sm">{feature}</span>
736
+ </label>
737
+ ))}
738
+ </div>
739
+ </div>
740
+ ))}
741
+
742
+ {/* Additional Notes Section */}
743
+ <div className="space-y-2 pt-1">
744
+ <label className="flex items-center gap-2">
745
+ <span className="inline-block w-2 h-2 bg-[#05998c] rounded-full flex-shrink-0"></span>
746
+ <p className="font-bold text-gray-900">Additional Notes</p>
747
+ </label>
748
+ <textarea
749
+ value={additionalNotes}
750
+ onChange={e => setAdditionalNotes(e.target.value)}
751
+ placeholder="Add any clinical observations..."
752
+ 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"
753
+ rows={3}
754
+ />
755
+ </div>
756
+ </div>
757
+ </div>
758
+ </div>
759
+ )}
760
+ </div>
761
+ );
762
+ });
763
+
764
+ AceticAnnotatorComponent.displayName = 'AceticAnnotator';
765
+
766
+ export const AceticAnnotator = AceticAnnotatorComponent;
767
+ export type { AceticAnnotatorProps, Annotation };
src/components/ChatBot.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"
73
+ aria-label="Open AI Chat Assistant"
74
+ >
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
+
90
+ {/* Messages */}
91
+ <div className="flex-1 overflow-y-auto p-4 space-y-3">
92
+ {messages.map((message) => (
93
+ <div
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>
131
+ </div>
132
+ )}
133
+ </>
134
+ );
135
+ }
src/components/Footer.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ type Props = {
4
+ compact?: boolean
5
+ }
6
+
7
+ export function Footer({ compact = false }: Props) {
8
+ const heightClass = compact ? 'h-12 md:h-14 lg:h-16' : 'h-16 md:h-20 lg:h-24'
9
+
10
+ return (
11
+ <footer className={`relative w-full ${heightClass} overflow-hidden`}>
12
+ {/* Background image – untouched */}
13
+ <img
14
+ src="/banner.jpeg"
15
+ alt="Footer background"
16
+ className="absolute inset-0 w-full h-full object-cover"
17
+ />
18
+
19
+ {/* Content */}
20
+ <div className="relative z-10 h-full flex items-center justify-center text-white text-xs px-3 md:px-4">
21
+ {/* Center Text */}
22
+ <div className="text-center leading-tight">
23
+ <div className="text-xs md:text-sm">© 2025 Manalife. All rights reserved.</div>
24
+ <div className="text-xs hidden md:block">
25
+ Advancing innovation in women's health and digital pathology.
26
+ </div>
27
+ </div>
28
+
29
+ {/* Logo Bottom Right */}
30
+ <div className="absolute right-2 md:right-3 lg:right-4 bottom-1 md:bottom-2">
31
+ <img
32
+ src="/white_logo.png"
33
+ alt="Manalife logo"
34
+ className={compact ? 'h-5 md:h-6 lg:h-6' : 'h-6 md:h-7 lg:h-8'}
35
+ />
36
+ </div>
37
+ </div>
38
+ </footer>
39
+ )
40
+ }
src/components/Header.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ type Props = {
4
+ compact?: boolean
5
+ }
6
+
7
+ export function Header({ compact = false }: Props) {
8
+ const heightClass = compact ? 'h-16 md:h-20 lg:h-20' : 'h-24 md:h-28 lg:h-32'
9
+
10
+ return (
11
+ <header className={`relative w-full ${heightClass} overflow-hidden`}>
12
+ {/* Background image */}
13
+ <img
14
+ src="/banner.jpeg"
15
+ alt="Header background"
16
+ className="absolute inset-0 w-full h-full object-cover"
17
+ />
18
+
19
+ {/* Content */}
20
+ <div className="relative z-10 h-full flex items-center px-3 md:px-6">
21
+ {/* Logo */}
22
+ <img
23
+ src="/white_logo.png"
24
+ alt="Manalife logo"
25
+ className={compact ? 'h-10 md:h-11 lg:h-12 mr-2 md:mr-3 flex-shrink-0' : 'h-12 md:h-14 lg:h-16 mr-2 md:mr-4 flex-shrink-0'}
26
+ />
27
+
28
+ {/* Title */}
29
+ <h1 className="text-white text-sm md:text-lg lg:text-xl font-semibold tracking-wide line-clamp-2">
30
+ <span className="font-bold">Pathora</span>{' '}
31
+ <span className="text-white/90 hidden md:inline">
32
+ | Manalife&apos;s Colposcopy Assistant
33
+ </span>
34
+ </h1>
35
+ </div>
36
+ </header>
37
+ )
38
+ }
src/components/ImageAnnotator.tsx ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
6
+ interface Point {
7
+ x: number;
8
+ y: number;
9
+ }
10
+
11
+ interface Annotation {
12
+ id: string;
13
+ type: ShapeType;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ color: string;
19
+ label?: string;
20
+ points?: Point[];
21
+ source?: 'manual' | 'ai';
22
+ identified?: boolean;
23
+ accepted?: boolean;
24
+ }
25
+
26
+ interface ImageAnnotatorProps {
27
+ imageUrl?: string;
28
+ imageUrls?: string[];
29
+ onAnnotationsChange?: (annotations: Annotation[]) => void;
30
+ }
31
+
32
+ export interface ImageAnnotatorHandle {
33
+ addAIAnnotations: (aiAnnotations: Annotation[]) => void;
34
+ }
35
+
36
+ const ImageAnnotatorComponent = forwardRef<ImageAnnotatorHandle, ImageAnnotatorProps>(({ imageUrl, imageUrls, onAnnotationsChange }, ref) => {
37
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
38
+ const containerRef = useRef<HTMLDivElement | null>(null);
39
+ const [annotations, setAnnotations] = useState<Annotation[]>([]);
40
+ const [tool, setTool] = useState<ShapeType>('rect');
41
+ const [color, setColor] = useState('#05998c');
42
+ const [labelInput, setLabelInput] = useState('');
43
+ const [isDrawing, setIsDrawing] = useState(false);
44
+ const [startPoint, setStartPoint] = useState<Point | null>(null);
45
+ const [currentAnnotation, setCurrentAnnotation] = useState<Annotation | null>(null);
46
+ const [imageLoaded, setImageLoaded] = useState(false);
47
+ const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
48
+ const [polygonPoints, setPolygonPoints] = useState<Point[]>([]);
49
+ const [selectedImageIndex, setSelectedImageIndex] = useState(0);
50
+ const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true);
51
+ const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false);
52
+
53
+ // Edit state for annotation list
54
+ const [editingId, setEditingId] = useState<string | null>(null);
55
+ const [editLabel, setEditLabel] = useState('');
56
+ const [editColor, setEditColor] = useState('#05998c');
57
+
58
+ // Annotation metadata state
59
+ const [annotationIdentified, setAnnotationIdentified] = useState<Record<string, boolean>>({});
60
+ const [annotationAccepted, setAnnotationAccepted] = useState<Record<string, boolean>>({});
61
+ // Predefined label options
62
+ const labelOptions = [
63
+ 'Cervix',
64
+ 'SCJ',
65
+ 'OS',
66
+ 'TZ',
67
+ 'Blood Discharge',
68
+ 'Scarring',
69
+ 'Growth',
70
+ 'Ulcer',
71
+ 'Inflammation',
72
+ 'Polypoidal Growth',
73
+ 'Cauliflower-like Growth',
74
+ 'Fungating Mass'
75
+ ];
76
+
77
+ // Filter labels based on input
78
+ const filteredLabels = labelOptions.filter(label =>
79
+ label.toLowerCase().includes(labelInput.toLowerCase())
80
+ );
81
+
82
+ // Expose addAIAnnotations method via ref
83
+ useImperativeHandle(ref, () => ({
84
+ addAIAnnotations: (aiAnnotations: Annotation[]) => {
85
+ setAnnotations(prev => [...prev, ...aiAnnotations]);
86
+ }
87
+ }));
88
+
89
+ const images = imageUrls || (imageUrl ? [imageUrl] : []);
90
+ const currentImageUrl = images[selectedImageIndex];
91
+
92
+ useEffect(() => {
93
+ const img = new Image();
94
+ img.src = currentImageUrl;
95
+ img.onload = () => {
96
+ setImageDimensions({ width: img.width, height: img.height });
97
+ setImageLoaded(true);
98
+ drawCanvas();
99
+ };
100
+ }, [currentImageUrl]);
101
+
102
+ useEffect(() => {
103
+ if (imageLoaded) drawCanvas();
104
+ }, [annotations, currentAnnotation, polygonPoints, imageLoaded]);
105
+
106
+ useEffect(() => {
107
+ if (onAnnotationsChange) onAnnotationsChange(annotations);
108
+ }, [annotations, onAnnotationsChange]);
109
+
110
+ const clearAnnotations = () => setAnnotations([]);
111
+ const deleteLastAnnotation = () => setAnnotations(prev => prev.slice(0, -1));
112
+
113
+ const getCanvasCoordinates = (e: React.MouseEvent<HTMLCanvasElement>) => {
114
+ const canvas = canvasRef.current;
115
+ if (!canvas) return { x: 0, y: 0 };
116
+ const rect = canvas.getBoundingClientRect();
117
+ const scaleX = imageDimensions.width / canvas.width || 1;
118
+ const scaleY = imageDimensions.height / canvas.height || 1;
119
+ return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
120
+ };
121
+
122
+ const startDrawing = (point: Point) => {
123
+ setStartPoint(point);
124
+ setIsDrawing(true);
125
+ };
126
+
127
+ const finishDrawingRectOrCircle = () => {
128
+ if (currentAnnotation && (currentAnnotation.width > 5 || currentAnnotation.height > 5)) {
129
+ const ann: Annotation = { ...currentAnnotation, id: Date.now().toString(), label: labelInput, source: 'manual', identified: true };
130
+ setAnnotations(prev => [...prev, ann]);
131
+ }
132
+ setIsDrawing(false);
133
+ setStartPoint(null);
134
+ setCurrentAnnotation(null);
135
+ };
136
+
137
+ const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
138
+ const p = getCanvasCoordinates(e);
139
+ if (tool === 'polygon') {
140
+ // add point to polygon
141
+ setPolygonPoints(prev => [...prev, p]);
142
+ return;
143
+ }
144
+ startDrawing(p);
145
+ };
146
+
147
+ const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
148
+ if (tool === 'polygon') return; // polygon preview handled by polygonPoints
149
+ if (!isDrawing || !startPoint) return;
150
+ const p = getCanvasCoordinates(e);
151
+ const w = p.x - startPoint.x;
152
+ const h = p.y - startPoint.y;
153
+ const ann: Annotation = {
154
+ id: 'temp',
155
+ type: tool,
156
+ x: w > 0 ? startPoint.x : p.x,
157
+ y: h > 0 ? startPoint.y : p.y,
158
+ width: Math.abs(w),
159
+ height: Math.abs(h),
160
+ color,
161
+ label: labelInput
162
+ };
163
+ setCurrentAnnotation(ann);
164
+ };
165
+
166
+ const handleMouseUp = () => {
167
+ if (tool === 'polygon') return;
168
+ if (isDrawing) finishDrawingRectOrCircle();
169
+ };
170
+
171
+ const finishPolygon = () => {
172
+ if (polygonPoints.length < 3) return;
173
+ const bounds = getBoundsFromPoints(polygonPoints);
174
+ const ann: Annotation = {
175
+ id: Date.now().toString(),
176
+ type: 'polygon',
177
+ x: bounds.x,
178
+ y: bounds.y,
179
+ width: bounds.width,
180
+ height: bounds.height,
181
+ color,
182
+ label: labelInput,
183
+ points: polygonPoints,
184
+ source: 'manual',
185
+ identified: true
186
+ };
187
+ setAnnotations(prev => [...prev, ann]);
188
+ setPolygonPoints([]);
189
+ setLabelInput('');
190
+ };
191
+
192
+ const cancelPolygon = () => setPolygonPoints([]);
193
+
194
+ const getBoundsFromPoints = (pts: Point[]) => {
195
+ const xs = pts.map(p => p.x);
196
+ const ys = pts.map(p => p.y);
197
+ const minX = Math.min(...xs);
198
+ const minY = Math.min(...ys);
199
+ const maxX = Math.max(...xs);
200
+ const maxY = Math.max(...ys);
201
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
202
+ };
203
+
204
+ const drawCanvas = () => {
205
+ const canvas = canvasRef.current;
206
+ const container = containerRef.current;
207
+ if (!canvas || !container) return;
208
+ const ctx = canvas.getContext('2d');
209
+ if (!ctx) return;
210
+ const img = new Image();
211
+ img.src = currentImageUrl;
212
+ img.onload = () => {
213
+ const containerWidth = container.clientWidth;
214
+ const aspectRatio = img.height / img.width || 1;
215
+ const canvasHeight = containerWidth * aspectRatio;
216
+ canvas.width = containerWidth;
217
+ canvas.height = canvasHeight;
218
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
219
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
220
+
221
+ // draw saved annotations
222
+ annotations.forEach(a => drawAnnotation(ctx, a, canvas.width, canvas.height));
223
+
224
+ // draw polygon being created
225
+ if (polygonPoints.length > 0) {
226
+ drawPreviewPolygon(ctx, polygonPoints, canvas.width, canvas.height, color);
227
+ }
228
+
229
+ // draw current temp annotation
230
+ if (currentAnnotation) drawAnnotation(ctx, currentAnnotation, canvas.width, canvas.height);
231
+ };
232
+ };
233
+
234
+ const drawPreviewPolygon = (ctx: CanvasRenderingContext2D, pts: Point[], canvasWidth: number, canvasHeight: number, col: string) => {
235
+ const scaleX = canvasWidth / imageDimensions.width || 1;
236
+ const scaleY = canvasHeight / imageDimensions.height || 1;
237
+ ctx.strokeStyle = col;
238
+ ctx.lineWidth = 2;
239
+ ctx.setLineDash([6, 4]);
240
+ ctx.beginPath();
241
+ pts.forEach((p, i) => {
242
+ const x = p.x * scaleX;
243
+ const y = p.y * scaleY;
244
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
245
+ });
246
+ ctx.stroke();
247
+ ctx.setLineDash([]);
248
+ };
249
+
250
+ const drawAnnotation = (ctx: CanvasRenderingContext2D, annotation: Annotation, canvasWidth: number, canvasHeight: number) => {
251
+ const scaleX = canvasWidth / imageDimensions.width || 1;
252
+ const scaleY = canvasHeight / imageDimensions.height || 1;
253
+ ctx.strokeStyle = annotation.color;
254
+ ctx.lineWidth = 3;
255
+ ctx.setLineDash([]);
256
+ if (annotation.type === 'rect') {
257
+ ctx.strokeRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
258
+ ctx.fillStyle = annotation.color + '20';
259
+ ctx.fillRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY);
260
+ } else if (annotation.type === 'circle') {
261
+ const cx = (annotation.x + annotation.width / 2) * scaleX;
262
+ const cy = (annotation.y + annotation.height / 2) * scaleY;
263
+ const r = Math.max(annotation.width * scaleX, annotation.height * scaleY) / 2;
264
+ ctx.beginPath();
265
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
266
+ ctx.stroke();
267
+ ctx.fillStyle = annotation.color + '20';
268
+ ctx.fill();
269
+ } else if (annotation.type === 'polygon' && annotation.points) {
270
+ ctx.beginPath();
271
+ annotation.points.forEach((p, i) => {
272
+ const x = p.x * scaleX;
273
+ const y = p.y * scaleY;
274
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
275
+ });
276
+ ctx.closePath();
277
+ ctx.stroke();
278
+ ctx.fillStyle = annotation.color + '20';
279
+ ctx.fill();
280
+ }
281
+ };
282
+
283
+ // list editing
284
+ const startEdit = (ann: Annotation) => {
285
+ setEditingId(ann.id);
286
+ setEditLabel(ann.label || '');
287
+ setEditColor(ann.color || '#05998c');
288
+ };
289
+ const saveEdit = () => {
290
+ setAnnotations(prev => prev.map(a => a.id === editingId ? { ...a, label: editLabel, color: editColor } : a));
291
+ setEditingId(null);
292
+ };
293
+ const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id));
294
+
295
+ const getShapeTypeName = (type: ShapeType): string => {
296
+ const typeMap: Record<ShapeType, string> = {
297
+ 'rect': 'Rectangle',
298
+ 'circle': 'Circle',
299
+ 'polygon': 'Polygon'
300
+ };
301
+ return typeMap[type] || type;
302
+ };
303
+
304
+ return (
305
+ <div className="space-y-3 md:space-y-4">
306
+ {/* Image Selection - Show if multiple images */}
307
+ {images.length > 1 && (
308
+ <div>
309
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2 md:mb-3">
310
+ Select Image
311
+ </label>
312
+ <div className="flex gap-2 overflow-x-auto pb-2">
313
+ {images.map((imgUrl, idx) => (
314
+ <button
315
+ key={idx}
316
+ onClick={() => setSelectedImageIndex(idx)}
317
+ className={`relative flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-lg overflow-hidden border-2 transition-all ${
318
+ selectedImageIndex === idx
319
+ ? 'border-[#05998c] ring-2 ring-[#05998c]/50'
320
+ : 'border-gray-300'
321
+ }`}
322
+ >
323
+ <img
324
+ src={imgUrl}
325
+ alt={`Image ${idx + 1}`}
326
+ className="w-full h-full object-cover"
327
+ />
328
+ {/* Grey overlay for selected image */}
329
+ {selectedImageIndex === idx && (
330
+ <div className="absolute inset-0 bg-black/30 flex items-center justify-center">
331
+ <div className="w-5 h-5 rounded-full bg-[#05998c] flex items-center justify-center">
332
+ <div className="w-2 h-2 bg-white rounded-full" />
333
+ </div>
334
+ </div>
335
+ )}
336
+ </button>
337
+ ))}
338
+ </div>
339
+ </div>
340
+ )}
341
+
342
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-3">
343
+ <div className="flex items-center gap-2">
344
+ <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">
345
+ <Wrench className="w-4 h-4 text-[#05998c]" />
346
+ <span className="font-medium">Tools</span>
347
+ </div>
348
+ <div className="flex items-center gap-1 md:gap-2">
349
+ <button onClick={() => setTool('rect')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'rect' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><SquareIcon className="inline w-4 h-4" /></button>
350
+ <button onClick={() => setTool('circle')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'circle' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><CircleIcon className="inline w-4 h-4" /></button>
351
+ <button onClick={() => setTool('polygon')} className={`px-2 md:px-3 py-1 rounded text-xs md:text-sm ${tool === 'polygon' ? 'bg-gray-800 text-white' : 'bg-white text-gray-700'} border`}><Hexagon className="inline w-4 h-4" /></button>
352
+ </div>
353
+ </div>
354
+
355
+ <div className="flex flex-col md:flex-row md:items-center gap-2">
356
+ <div className="relative">
357
+ <input
358
+ aria-label="Annotation label"
359
+ placeholder="Search or select label"
360
+ value={labelInput}
361
+ onChange={e => setLabelInput(e.target.value)}
362
+ onFocus={() => setIsLabelDropdownOpen(true)}
363
+ onBlur={() => setTimeout(() => setIsLabelDropdownOpen(false), 200)}
364
+ className="px-3 py-1 border rounded text-xs md:text-sm w-48"
365
+ />
366
+ {isLabelDropdownOpen && filteredLabels.length > 0 && (
367
+ <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">
368
+ {filteredLabels.map((label, idx) => (
369
+ <button
370
+ key={idx}
371
+ type="button"
372
+ onMouseDown={(e) => {
373
+ e.preventDefault();
374
+ setLabelInput(label);
375
+ setIsLabelDropdownOpen(false);
376
+ }}
377
+ className="w-full text-left px-3 py-2 text-xs md:text-sm hover:bg-gray-100 transition-colors"
378
+ >
379
+ {label}
380
+ </button>
381
+ ))}
382
+ </div>
383
+ )}
384
+ </div>
385
+ <input aria-label="Annotation color" type="color" value={color} onChange={e => setColor(e.target.value)} className="w-10 h-8 p-0 border rounded" />
386
+ <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">
387
+ <Trash2 className="w-4 h-4" />
388
+ <span className="hidden md:inline">Undo</span>
389
+ <span className="inline md:hidden">Undo</span>
390
+ </button>
391
+ <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>
392
+ </div>
393
+ </div>
394
+
395
+ <div ref={containerRef} className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700">
396
+ <canvas ref={canvasRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} className="w-full cursor-crosshair" />
397
+ {/* show guide */}
398
+ {annotations.length === 0 && polygonPoints.length === 0 && !isDrawing && (
399
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none text-white/70 text-xs md:text-sm px-4">
400
+
401
+ </div>
402
+ )}
403
+ </div>
404
+
405
+ {tool === 'polygon' && (
406
+ <div className="flex flex-col md:flex-row md:items-center gap-2">
407
+ <span className="text-xs md:text-sm text-gray-600">Polygon points: {polygonPoints.length}</span>
408
+ <button onClick={finishPolygon} disabled={polygonPoints.length < 3} className="px-3 py-1 text-xs md:text-sm bg-green-600 text-white rounded disabled:opacity-50">Finish Polygon</button>
409
+ <button onClick={cancelPolygon} disabled={polygonPoints.length === 0} className="px-3 py-1 text-xs md:text-sm bg-gray-200 rounded">Cancel</button>
410
+ </div>
411
+ )}
412
+
413
+ {/* Annotations Table */}
414
+ <div className="border border-gray-200 rounded-lg overflow-hidden">
415
+ <button
416
+ onClick={() => setIsAnnotationsOpen(!isAnnotationsOpen)}
417
+ className="w-full flex items-center justify-between p-3 md:p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
418
+ >
419
+ <div className="flex items-center gap-2">
420
+ <span className="text-xs md:text-sm font-semibold text-gray-700">
421
+ Annotations ({annotations.length})
422
+ </span>
423
+ </div>
424
+ {isAnnotationsOpen ? (
425
+ <ChevronUp className="w-4 h-4 text-gray-600" />
426
+ ) : (
427
+ <ChevronDown className="w-4 h-4 text-gray-600" />
428
+ )}
429
+ </button>
430
+
431
+ {isAnnotationsOpen && (
432
+ <div className="overflow-x-auto border-t border-gray-200 max-h-96 overflow-y-auto">
433
+ {annotations.length === 0 ? (
434
+ <div className="p-3 md:p-4 bg-white text-center">
435
+ <p className="text-xs text-gray-500">No annotations yet. Draw on the image to create annotations.</p>
436
+ </div>
437
+ ) : (
438
+ <table className="w-full">
439
+ <thead className="bg-gray-50 sticky top-0 border-b border-gray-200">
440
+ <tr>
441
+ <th className="px-3 md:px-4 py-2 text-left text-xs font-semibold text-gray-700">Annotation</th>
442
+ <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Identified</th>
443
+ <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Source</th>
444
+ <th className="px-3 md:px-4 py-2 text-center text-xs font-semibold text-gray-700">Action</th>
445
+ </tr>
446
+ </thead>
447
+ <tbody>
448
+ {annotations.map((a) => (
449
+ <tr key={a.id} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
450
+ {/* Annotation Column - shown in color it was drawn */}
451
+ <td className="px-3 md:px-4 py-3">
452
+ {editingId === a.id ? (
453
+ <div className="flex items-center gap-2">
454
+ <input
455
+ type="color"
456
+ value={editColor}
457
+ onChange={e => setEditColor(e.target.value)}
458
+ className="w-6 h-6 rounded p-0 border cursor-pointer"
459
+ />
460
+ <input
461
+ value={editLabel}
462
+ onChange={e => setEditLabel(e.target.value)}
463
+ className="px-2 py-1 border rounded text-xs flex-1"
464
+ placeholder="Label"
465
+ />
466
+ </div>
467
+ ) : (
468
+ <div className="flex items-center gap-3">
469
+ <div className="w-6 h-6 rounded-sm flex-shrink-0" style={{ backgroundColor: a.color }} />
470
+ <div>
471
+ <div className="text-sm font-medium text-gray-900">{a.label || '(no label)'}</div>
472
+ <div className="text-xs text-gray-500">{getShapeTypeName(a.type)}</div>
473
+ </div>
474
+ </div>
475
+ )}
476
+ </td>
477
+
478
+ {/* Identified Checkbox Column */}
479
+ <td className="px-3 md:px-4 py-3 text-center">
480
+ <input
481
+ type="checkbox"
482
+ checked={a.identified || false}
483
+ onChange={(e) => {
484
+ setAnnotations(prev => prev.map(ann =>
485
+ ann.id === a.id ? { ...ann, identified: e.target.checked } : ann
486
+ ));
487
+ }}
488
+ className="w-4 h-4 rounded border-gray-300 cursor-pointer"
489
+ />
490
+ </td>
491
+
492
+ {/* Source Column (Manual/AI) */}
493
+ <td className="px-3 md:px-4 py-3 text-center">
494
+ <span className={`inline-block px-2 py-1 rounded text-xs font-medium ${
495
+ a.source === 'ai'
496
+ ? 'bg-blue-100 text-blue-800'
497
+ : 'bg-gray-100 text-gray-800'
498
+ }`}>
499
+ {a.source === 'ai' ? 'AI' : 'Manual'}
500
+ </span>
501
+ </td>
502
+
503
+ {/* Action Column - Accept/Reject for AI, Edit/Delete for Manual */}
504
+ <td className="px-3 md:px-4 py-3">
505
+ <div className="flex items-center justify-center gap-2">
506
+ {editingId === a.id ? (
507
+ <>
508
+ <button
509
+ onClick={saveEdit}
510
+ className="px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
511
+ >
512
+ Save
513
+ </button>
514
+ <button
515
+ onClick={() => setEditingId(null)}
516
+ className="px-2 py-1 text-xs font-medium bg-gray-300 text-gray-800 rounded hover:bg-gray-400 transition-colors"
517
+ >
518
+ Cancel
519
+ </button>
520
+ </>
521
+ ) : a.source === 'ai' ? (
522
+ <>
523
+ {annotationAccepted[a.id] === true ? (
524
+ <div className="flex items-center gap-2">
525
+ <span className="px-3 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
526
+ ✓ Accepted
527
+ </span>
528
+ <button
529
+ onClick={() => {
530
+ setAnnotationAccepted(prev => {
531
+ const next = { ...prev };
532
+ delete next[a.id];
533
+ return next;
534
+ });
535
+ setAnnotationIdentified(prev => {
536
+ const next = { ...prev };
537
+ delete next[a.id];
538
+ return next;
539
+ });
540
+ }}
541
+ className="px-2 py-1 text-[11px] font-medium bg-white border border-gray-200 text-gray-700 rounded hover:bg-gray-50 transition-colors"
542
+ >
543
+ Undo
544
+ </button>
545
+ </div>
546
+ ) : annotationAccepted[a.id] === false ? (
547
+ <span className="px-3 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
548
+ ✕ Rejected
549
+ </span>
550
+ ) : (
551
+ <>
552
+ <button
553
+ onClick={() => {
554
+ setAnnotationAccepted(prev => ({
555
+ ...prev,
556
+ [a.id]: true
557
+ }));
558
+ setAnnotationIdentified(prev => ({
559
+ ...prev,
560
+ [a.id]: true
561
+ }));
562
+ }}
563
+ className="px-2 py-1 text-xs font-medium bg-green-50 text-green-700 border border-green-200 rounded hover:bg-green-100 transition-colors"
564
+ >
565
+ ✓ Accept
566
+ </button>
567
+ <button
568
+ onClick={() => {
569
+ setAnnotations(prev => prev.filter(item => item.id !== a.id));
570
+ setAnnotationAccepted(prev => {
571
+ const next = { ...prev };
572
+ delete next[a.id];
573
+ return next;
574
+ });
575
+ setAnnotationIdentified(prev => {
576
+ const next = { ...prev };
577
+ delete next[a.id];
578
+ return next;
579
+ });
580
+ }}
581
+ className="px-2 py-1 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded hover:bg-red-100 transition-colors"
582
+ >
583
+ ✕ Reject
584
+ </button>
585
+ </>
586
+ )}
587
+ </>
588
+ ) : (
589
+ <>
590
+ <button
591
+ onClick={() => startEdit(a)}
592
+ className="px-2 py-1 text-xs font-medium bg-white border border-gray-300 rounded hover:bg-gray-50 transition-colors"
593
+ >
594
+ Edit
595
+ </button>
596
+ <button
597
+ onClick={() => deleteAnnotation(a.id)}
598
+ className="px-2 py-1 text-xs font-medium bg-red-50 text-red-700 border border-red-200 rounded hover:bg-red-100 transition-colors"
599
+ >
600
+ Delete
601
+ </button>
602
+ </>
603
+ )}
604
+ </div>
605
+ </td>
606
+ </tr>
607
+ ))}
608
+ </tbody>
609
+ </table>
610
+ )}
611
+ </div>
612
+ )}
613
+ </div>
614
+ </div>
615
+ );
616
+ });
617
+
618
+ ImageAnnotatorComponent.displayName = 'ImageAnnotator';
619
+
620
+ export const ImageAnnotator = ImageAnnotatorComponent;
src/components/ImagingObservations.tsx ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
13
+ contactBleeding: false,
14
+ irregularSurface: false,
15
+ other: false,
16
+ additionalNotes: '',
17
+ // Examination Adequacy
18
+ cervixFullyVisible: null as null | 'Yes' | 'No',
19
+ obscuredBy: {
20
+ blood: false,
21
+ inflammation: false,
22
+ discharge: false,
23
+ scarring: false
24
+ },
25
+ adequacyNotes: '',
26
+ // SCJ & TZ
27
+ scjVisibility: 'Completely visible',
28
+ scjNotes: '',
29
+ tzType: 'TZ 1',
30
+ // Native exam
31
+ suspiciousAtNativeView: false,
32
+ skipStainInterpretation: false
33
+ });
34
+ const handleCheckboxChange = (field: string) => {
35
+ const updated = {
36
+ ...observations,
37
+ [field]: !observations[field as keyof typeof observations]
38
+ };
39
+ setObservations(updated);
40
+ if (onObservationsChange) {
41
+ onObservationsChange(updated);
42
+ }
43
+ };
44
+ const handleFieldChange = (field: string, value: any) => {
45
+ const updated = {
46
+ ...observations,
47
+ [field]: value
48
+ };
49
+ setObservations(updated);
50
+ if (onObservationsChange) {
51
+ onObservationsChange(updated);
52
+ }
53
+ };
54
+ const handleObscuredChange = (key: string) => {
55
+ const updatedObscured = {
56
+ ...observations.obscuredBy,
57
+ [key]: !observations.obscuredBy[key as keyof typeof observations.obscuredBy]
58
+ };
59
+ const updated = {
60
+ ...observations,
61
+ obscuredBy: updatedObscured
62
+ };
63
+ setObservations(updated);
64
+ if (onObservationsChange) onObservationsChange(updated);
65
+ };
66
+ const handleNotesChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
67
+ const updated = {
68
+ ...observations,
69
+ additionalNotes: e.target.value
70
+ };
71
+ setObservations(updated);
72
+ if (onObservationsChange) {
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">
79
+ <CheckSquare className="w-4 h-4 md:w-5 md:h-5" />
80
+ </div>
81
+ <h3 className="font-bold text-sm md:text-base text-[#0A2540]">Visual Observations</h3>
82
+ </div>
83
+
84
+ <div className={`p-4 md:p-6 ${layout === 'horizontal' ? 'space-y-6 flex-1 overflow-y-auto' : 'space-y-4 md:space-y-6 flex-1 overflow-y-auto'}`}>
85
+ {layout === 'horizontal' && (
86
+ <div className="space-y-6">
87
+ {/* Row: Cervix fully visible */}
88
+ <div className="pb-4 border-b border-gray-100">
89
+ <div className="flex flex-col md:flex-row md:items-center gap-3 md:gap-6">
90
+ <label className="text-sm md:text-base font-semibold text-gray-900 min-w-fit">Cervix fully visible?</label>
91
+ <div className="flex items-center gap-2">
92
+ {['Yes', 'No'].map(opt => (
93
+ <label key={opt} className={`px-4 py-2 rounded-lg border cursor-pointer transition-all text-xs md:text-sm font-medium ${observations.cervixFullyVisible === opt ? 'border-teal-300 bg-teal-50 text-teal-700' : 'border-gray-200 bg-gray-50 text-gray-600'}`}>
94
+ <input type="radio" name="cervixVisible" checked={observations.cervixFullyVisible === opt} onChange={() => handleFieldChange('cervixFullyVisible', opt)} className="mr-2" />
95
+ {opt}
96
+ </label>
97
+ ))}
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ {/* Row: Obscured by */}
103
+ <div className="pb-4 border-b border-gray-100">
104
+ <label className="block text-sm md:text-base font-semibold text-gray-900 mb-3">Obscured by:</label>
105
+ <div className="flex flex-wrap gap-2">
106
+ {['blood', 'inflammation', 'discharge', 'scarring'].map(k => <label key={k} className="flex items-center gap-2 px-4 py-2 rounded-lg border bg-gray-50 border-gray-200 cursor-pointer hover:border-teal-200 transition-all">
107
+ <input type="checkbox" checked={observations.obscuredBy[k as keyof typeof observations.obscuredBy]} onChange={() => handleObscuredChange(k)} className="w-4 h-4" />
108
+ <span className="text-sm capitalize">{k}</span>
109
+ </label>)}
110
+ </div>
111
+ </div>
112
+
113
+ {/* Row: Adequacy notes */}
114
+ <div className="pb-4 border-b border-gray-100">
115
+ <label className="block text-sm font-semibold text-gray-900 mb-2">Adequacy Notes</label>
116
+ <textarea value={observations.adequacyNotes} onChange={e => handleFieldChange('adequacyNotes', e.target.value)} rows={2} className="w-full px-3 py-2 bg-gray-50 border-2 border-gray-200 rounded-lg text-sm focus:border-teal-300 focus:ring-2 focus:ring-teal-100 outline-none transition-all" placeholder="Notes about adequacy..." />
117
+ </div>
118
+
119
+ {/* Row: SCJ Visibility */}
120
+ <div className="pb-4 border-b border-gray-100">
121
+ <label className="block text-sm md:text-base font-semibold text-gray-900 mb-3">SCJ Visibility</label>
122
+ <div className="flex flex-wrap items-center gap-2">
123
+ {['Completely visible', 'Partially visible', 'Not visible'].map(opt => <label key={opt} className={`px-4 py-2 rounded-lg border cursor-pointer transition-all text-xs md:text-sm font-medium ${observations.scjVisibility === opt ? 'border-teal-300 bg-teal-50 text-teal-700' : 'border-gray-200 bg-gray-50 text-gray-600'}`}>
124
+ <input type="radio" name="scj" checked={observations.scjVisibility === opt} onChange={() => handleFieldChange('scjVisibility', opt)} className="mr-2" />
125
+ {opt}
126
+ </label>)}
127
+ </div>
128
+ <div className="mt-3">
129
+ <textarea value={observations.scjNotes} onChange={e => handleFieldChange('scjNotes', e.target.value)} rows={2} className="w-full px-3 py-2 bg-gray-50 border-2 border-gray-200 rounded-lg text-sm focus:border-teal-300 focus:ring-2 focus:ring-teal-100 outline-none transition-all" placeholder="SCJ notes..." />
130
+ </div>
131
+ </div>
132
+
133
+ {/* Row: Transformation Zone Type */}
134
+ <div className="pb-4 border-b border-gray-100">
135
+ <label className="block text-sm md:text-base font-semibold text-gray-900 mb-3">Transformation Zone (TZ) Type</label>
136
+ <div className="flex items-center gap-2">
137
+ {['TZ 1', 'TZ 2', 'TZ 3'].map(tz => (
138
+ <label key={tz} className={`px-4 py-2 rounded-lg border cursor-pointer transition-all text-xs md:text-sm font-medium ${observations.tzType === tz ? 'border-teal-300 bg-teal-50 text-teal-700' : 'border-gray-200 bg-gray-50 text-gray-600'}`}>
139
+ <input type="radio" name="tzType" checked={observations.tzType === tz} onChange={() => handleFieldChange('tzType', tz)} className="mr-2" />
140
+ {tz}
141
+ </label>
142
+ ))}
143
+ </div>
144
+ </div>
145
+
146
+ {/* Row: Native (Untreated) Examination */}
147
+ <div className="pb-4 border-b border-gray-100">
148
+ <label className="block text-sm md:text-base font-semibold text-gray-900 mb-3">Native (Untreated) Examination</label>
149
+ <label className="flex items-center gap-3 cursor-pointer">
150
+ <input type="checkbox" checked={observations.suspiciousAtNativeView} onChange={() => {
151
+ const next = !observations.suspiciousAtNativeView;
152
+ const updated = {
153
+ ...observations,
154
+ suspiciousAtNativeView: next,
155
+ skipStainInterpretation: next
156
+ };
157
+ setObservations(updated);
158
+ if (onObservationsChange) onObservationsChange(updated);
159
+ }} className="w-5 h-5 rounded text-teal-600 border-gray-300" />
160
+ <span className="text-sm font-medium text-gray-900">Suspicious at Native View</span>
161
+ </label>
162
+ {observations.suspiciousAtNativeView && <div className="mt-3">
163
+ <textarea value={observations.adequacyNotes} onChange={e => handleFieldChange('adequacyNotes', e.target.value)} rows={2} className="w-full px-3 py-2 bg-gray-50 border-2 border-gray-200 rounded-lg text-sm focus:border-teal-300 focus:ring-2 focus:ring-teal-100 outline-none transition-all" placeholder="Additional notes..." />
164
+ </div>}
165
+ </div>
166
+ </div>
167
+ )}
168
+
169
+ {layout !== 'horizontal' && (
170
+ <>
171
+ {/* Default Vertical Layout - kept for non-native pages */}
172
+ <div>
173
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2 md:mb-3">
174
+ Examination Adequacy Checklist
175
+ </label>
176
+ <div className="space-y-2 md:space-y-3">
177
+ <div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
178
+ <label className="text-xs md:text-sm font-medium whitespace-nowrap">Cervix fully visible?</label>
179
+ <div className="flex items-center gap-2">
180
+ {['Yes', 'No'].map(opt => (
181
+ <label key={opt} className={`px-3 py-1 rounded-lg border cursor-pointer text-xs md:text-sm ${observations.cervixFullyVisible === opt ? 'bg-teal-50 border-teal-200' : 'bg-gray-50 border-gray-200'}`}>
182
+ <input type="radio" name="cervixVisibleVertical" checked={observations.cervixFullyVisible === opt} onChange={() => handleFieldChange('cervixFullyVisible', opt)} className="mr-2" />
183
+ {opt}
184
+ </label>
185
+ ))}
186
+ </div>
187
+ </div>
188
+
189
+ <div>
190
+ <span className="block text-sm font-semibold text-gray-900">Obscured by:</span>
191
+ <div className="mt-2 grid grid-cols-2 gap-2">
192
+ {['blood', 'inflammation', 'discharge', 'scarring'].map(k => <label key={k} className="flex items-center gap-2 p-3 rounded-lg border bg-gray-50 border-gray-200 cursor-pointer">
193
+ <input type="checkbox" checked={observations.obscuredBy[k as keyof typeof observations.obscuredBy]} onChange={() => handleObscuredChange(k)} className="w-4 h-4" />
194
+ <span className="text-sm capitalize">{k}</span>
195
+ </label>)}
196
+ </div>
197
+ </div>
198
+
199
+ <div className="pt-2">
200
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2">Additional notes</label>
201
+ <textarea value={observations.adequacyNotes} onChange={e => handleFieldChange('adequacyNotes', e.target.value)} rows={3} className="w-full px-3 py-2 bg-gray-50 border-2 border-gray-200 rounded-lg text-sm" placeholder="Notes about adequacy..." />
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ {/* SCJ Visibility and TZ */}
207
+ <div className="pt-4 border-t border-gray-100">
208
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-3">SCJ Visibility</label>
209
+ <div className="flex items-center gap-3">
210
+ {['Completely visible', 'Partially visible', 'Not visible'].map(opt => <label key={opt} className={`p-3 rounded-lg border ${observations.scjVisibility === opt ? 'border-teal-200 bg-teal-50' : 'border-gray-200 bg-gray-50'} cursor-pointer`}>
211
+ <input type="radio" name="scj" checked={observations.scjVisibility === opt} onChange={() => handleFieldChange('scjVisibility', opt)} className="mr-2" />
212
+ <span className="text-sm">{opt}</span>
213
+ </label>)}
214
+ </div>
215
+ <div className="mt-3">
216
+ <textarea value={observations.scjNotes} onChange={e => handleFieldChange('scjNotes', e.target.value)} rows={2} className="w-full px-3 py-2 bg-gray-50 border-2 border-gray-200 rounded-lg text-sm" placeholder="SCJ notes..." />
217
+ </div>
218
+
219
+ <div className="mt-4">
220
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2">Transformation Zone (TZ) Type</label>
221
+ <div className="flex items-center gap-2">
222
+ {['TZ 1', 'TZ 2', 'TZ 3'].map(tz => (
223
+ <label key={tz} className={`px-3 py-2 rounded-lg border cursor-pointer text-sm ${observations.tzType === tz ? 'border-teal-200 bg-teal-50' : 'border-gray-200 bg-gray-50'}`}>
224
+ <input type="radio" name="tzTypeVertical" checked={observations.tzType === tz} onChange={() => handleFieldChange('tzType', tz)} className="mr-2" />
225
+ {tz}
226
+ </label>
227
+ ))}
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ {/* Native (Untreated) Examination */}
233
+ <div className="pt-4 border-t border-gray-100">
234
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-3">Native (Untreated) Examination (STEP 2)</label>
235
+ <div className="flex items-center gap-3">
236
+ <label className="flex items-center gap-2">
237
+ <input type="checkbox" checked={observations.suspiciousAtNativeView} onChange={() => {
238
+ const next = !observations.suspiciousAtNativeView;
239
+ const updated = {
240
+ ...observations,
241
+ suspiciousAtNativeView: next,
242
+ skipStainInterpretation: next
243
+ };
244
+ setObservations(updated);
245
+ if (onObservationsChange) onObservationsChange(updated);
246
+ }} className="w-4 h-4" />
247
+ <span className="text-sm font-medium">Suspicious at Native View</span>
248
+ </label>
249
+ </div>
250
+ {observations.suspiciousAtNativeView && <div className="mt-3">
251
+ <textarea value={observations.adequacyNotes} onChange={e => handleFieldChange('adequacyNotes', e.target.value)} rows={2} className="w-full px-3 py-2 bg-gray-50 border-2 border-gray-200 rounded-lg text-sm" placeholder="Additional notes..." />
252
+ </div>}
253
+ </div>
254
+ </>
255
+ )}
256
+
257
+ <div>
258
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-3">
259
+ Clinical Findings
260
+ </label>
261
+ <div className="space-y-3">
262
+ <label className="flex items-start p-4 rounded-lg border-2 border-gray-200 hover:border-[#05998c]/50 cursor-pointer transition-all bg-gray-50/50 group">
263
+ <input type="checkbox" checked={observations.obviousGrowths} onChange={() => handleCheckboxChange('obviousGrowths')} className="w-5 h-5 rounded text-[#05998c] focus:ring-[#05998c] border-gray-300 mt-0.5" />
264
+ <div className="ml-3">
265
+ <span className="block text-sm font-semibold text-gray-900 group-hover:text-[#0A2540]">
266
+ Obvious growths / ulcers
267
+ </span>
268
+ <span className="block text-xs text-gray-500 mt-0.5">
269
+ Visible abnormal tissue growth or ulceration
270
+ </span>
271
+ </div>
272
+ </label>
273
+
274
+ <label className="flex items-start p-4 rounded-lg border-2 border-gray-200 hover:border-[#05998c]/50 cursor-pointer transition-all bg-gray-50/50 group">
275
+ <input type="checkbox" checked={observations.contactBleeding} onChange={() => handleCheckboxChange('contactBleeding')} className="w-5 h-5 rounded text-[#05998c] focus:ring-[#05998c] border-gray-300 mt-0.5" />
276
+ <div className="ml-3">
277
+ <span className="block text-sm font-semibold text-gray-900 group-hover:text-[#0A2540]">
278
+ Contact bleeding
279
+ </span>
280
+ <span className="block text-xs text-gray-500 mt-0.5">
281
+ Bleeding upon contact with instrument
282
+ </span>
283
+ </div>
284
+ </label>
285
+
286
+ <label className="flex items-start p-4 rounded-lg border-2 border-gray-200 hover:border-[#05998c]/50 cursor-pointer transition-all bg-gray-50/50 group">
287
+ <input type="checkbox" checked={observations.irregularSurface} onChange={() => handleCheckboxChange('irregularSurface')} className="w-5 h-5 rounded text-[#05998c] focus:ring-[#05998c] border-gray-300 mt-0.5" />
288
+ <div className="ml-3">
289
+ <span className="block text-sm font-semibold text-gray-900 group-hover:text-[#0A2540]">
290
+ Irregular surface
291
+ </span>
292
+ <span className="block text-xs text-gray-500 mt-0.5">
293
+ Uneven or abnormal surface texture
294
+ </span>
295
+ </div>
296
+ </label>
297
+
298
+ <label className="flex items-start p-4 rounded-lg border-2 border-gray-200 hover:border-[#05998c]/50 cursor-pointer transition-all bg-gray-50/50 group">
299
+ <input type="checkbox" checked={observations.other} onChange={() => handleCheckboxChange('other')} className="w-5 h-5 rounded text-[#05998c] focus:ring-[#05998c] border-gray-300 mt-0.5" />
300
+ <div className="ml-3">
301
+ <span className="block text-sm font-semibold text-gray-900 group-hover:text-[#0A2540]">
302
+ Other findings
303
+ </span>
304
+ <span className="block text-xs text-gray-500 mt-0.5">
305
+ Additional observations not listed above
306
+ </span>
307
+ </div>
308
+ </label>
309
+ </div>
310
+ </div>
311
+
312
+ <div className="pt-6 border-t border-gray-100">
313
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-3 flex items-center gap-2">
314
+ <FileText className="w-4 h-4" />
315
+ Additional Notes
316
+ </label>
317
+ <textarea value={observations.additionalNotes} onChange={handleNotesChange} rows={6} className="w-full px-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-[#05998c] outline-none transition-all resize-none text-sm" placeholder="Document any additional observations, measurements, or clinical notes..." />
318
+ </div>
319
+
320
+ <div className="bg-blue-50 rounded-lg p-4 border border-blue-100">
321
+ <p className="text-xs text-blue-800 leading-relaxed">
322
+ <strong>Tip:</strong> Use the annotation tool to mark specific areas
323
+ of concern on the image, then document your findings here.
324
+ </p>
325
+ </div>
326
+ </div>
327
+ </div>;
328
+ }
src/components/PatientHistoryForm.tsx ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: '',
21
+ hpvVaccination: '',
22
+ patientProfileNotes: '',
23
+ // Symptoms
24
+ postCoitalBleeding: false,
25
+ interMenstrualBleeding: false,
26
+ persistentDischarge: false,
27
+ symptomsNotes: '',
28
+ // Screening
29
+ papSmearResult: '',
30
+ hpvDnaTypes: '',
31
+ pastProcedures: {
32
+ biopsy: false,
33
+ leep: false,
34
+ cryotherapy: false,
35
+ none: false
36
+ },
37
+ screeningNotes: '',
38
+ // Risk Factors
39
+ smoking: '',
40
+ immunosuppression: {
41
+ hiv: false,
42
+ steroids: false,
43
+ none: false
44
+ },
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') {
59
+ setFormData(prev => ({
60
+ ...prev,
61
+ pastProcedures: {
62
+ ...prev.pastProcedures,
63
+ [field]: !prev.pastProcedures[field as keyof typeof prev.pastProcedures]
64
+ }
65
+ }));
66
+ return;
67
+ }
68
+
69
+ // category === 'immunosuppression'
70
+ setFormData(prev => ({
71
+ ...prev,
72
+ immunosuppression: {
73
+ ...prev.immunosuppression,
74
+ [field]: !prev.immunosuppression[field as keyof typeof prev.immunosuppression]
75
+ }
76
+ }));
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]">
88
+ Patient Information & Clinical History
89
+ </h2>
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>
97
+
98
+ <form className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6 lg:gap-8">
99
+ {/* SECTION 1: Patient Profile */}
100
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden flex flex-col h-full">
101
+ <div className="bg-blue-50/50 p-3 md:p-4 border-b border-blue-100 flex items-center gap-2 md:gap-3">
102
+ <div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center flex-shrink-0">
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">
110
+ <div className="grid grid-cols-3 gap-2 md:gap-3 lg:gap-4">
111
+ <div>
112
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">
113
+ Age
114
+ </label>
115
+ <input value={formData.age} onChange={handleInputChange} type="number" name="age" 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-[#4ECDC4] focus:border-transparent outline-none transition-all" placeholder="Yrs" />
116
+ </div>
117
+
118
+ <div>
119
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">
120
+ Blood Group
121
+ </label>
122
+ <select value={formData.bloodGroup} onChange={handleInputChange} name="bloodGroup" 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">
123
+ <option value="">Select</option>
124
+ <option value="A+">A+</option>
125
+ <option value="A-">A-</option>
126
+ <option value="B+">B+</option>
127
+ <option value="B-">B-</option>
128
+ <option value="AB+">AB+</option>
129
+ <option value="AB-">AB-</option>
130
+ <option value="O+">O+</option>
131
+ <option value="O-">O-</option>
132
+ </select>
133
+ </div>
134
+
135
+ <div>
136
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-1">
137
+ Parity
138
+ </label>
139
+ <input value={formData.parity} onChange={handleInputChange} type="text" name="parity" 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" placeholder="G P A" />
140
+ </div>
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>
188
+ </div>
189
+
190
+ {/* SECTION 2: Presenting Symptoms */}
191
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden flex flex-col h-full">
192
+ <div className="bg-indigo-50/50 p-4 border-b border-indigo-100 flex items-center gap-3">
193
+ <div className="w-10 h-10 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center">
194
+ <Calendar className="w-5 h-5" />
195
+ </div>
196
+ <h3 className="font-bold text-[#0A2540]">Presenting Symptoms</h3>
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">
206
+ Post-coital bleeding
207
+ </span>
208
+ <span className="block text-sm text-gray-500 mt-0.5">
209
+ Bleeding after intercourse
210
+ </span>
211
+ </div>
212
+ </label>
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">
220
+ Inter-menstrual / Post-menopausal
221
+ </span>
222
+ <span className="block text-sm text-gray-500 mt-0.5">
223
+ Irregular bleeding patterns
224
+ </span>
225
+ </div>
226
+ </label>
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">
234
+ Persistent discharge
235
+ </span>
236
+ <span className="block text-xs text-gray-500 mt-0.5">
237
+ Unusual color or odor
238
+ </span>
239
+ </div>
240
+ </label>
241
+
242
+ <div className="pt-4 border-t border-gray-100">
243
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2">
244
+ Additional Notes
245
+ </label>
246
+ <textarea name="symptomsNotes" value={formData.symptomsNotes} onChange={handleInputChange} rows={3} 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 resize-none text-sm" placeholder="Add any additional notes about symptoms..." />
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ {/* SECTION 3: Screening History */}
252
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden flex flex-col h-full">
253
+ <div className="bg-teal-50/50 p-4 border-b border-teal-100 flex items-center gap-3">
254
+ <div className="w-10 h-10 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center">
255
+ <Stethoscope className="w-5 h-5" />
256
+ </div>
257
+ <h3 className="font-bold text-[#0A2540]">Screening History</h3>
258
+ </div>
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>
283
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2">
284
+ Past Procedures
285
+ </label>
286
+ <div className="grid grid-cols-2 gap-2">
287
+ {['Biopsy', 'LEEP', 'Cryotherapy', 'None'].map(proc => <label key={proc} className="flex items-center gap-2 cursor-pointer">
288
+ <input type="checkbox" checked={formData.pastProcedures[proc.toLowerCase() as keyof typeof formData.pastProcedures]} onChange={() => handleNestedCheckboxChange('pastProcedures', proc.toLowerCase())} className="w-4 h-4 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
289
+ <span className="text-sm text-gray-700">{proc}</span>
290
+ </label>)}
291
+ </div>
292
+ </div>
293
+
294
+ <div className="pt-4 border-t border-gray-100">
295
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2">
296
+ Additional Notes
297
+ </label>
298
+ <textarea name="screeningNotes" value={formData.screeningNotes} onChange={handleInputChange} rows={3} 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 resize-none text-sm" placeholder="Add any additional notes about screening history..." />
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ {/* SECTION 4: Risk Factors */}
304
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden flex flex-col h-full">
305
+ <div className="bg-rose-50/50 p-4 border-b border-rose-100 flex items-center gap-3">
306
+ <div className="w-10 h-10 rounded-full bg-rose-100 text-rose-600 flex items-center justify-center">
307
+ <AlertTriangle className="w-5 h-5" />
308
+ </div>
309
+ <h3 className="font-bold text-[#0A2540]">Risk Factors</h3>
310
+ </div>
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>
329
+ </label>
330
+ </div>
331
+ </div>
332
+
333
+ <div>
334
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-3">
335
+ Immunosuppression
336
+ </label>
337
+ <div className="space-y-3">
338
+ <label className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-gray-300 cursor-pointer transition-all bg-gray-50/50">
339
+ <input type="checkbox" checked={formData.immunosuppression.hiv} onChange={() => handleNestedCheckboxChange('immunosuppression', 'hiv')} className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
340
+ <span className="ml-3 text-sm font-medium text-gray-700">
341
+ HIV Positive
342
+ </span>
343
+ </label>
344
+
345
+ <label className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-gray-300 cursor-pointer transition-all bg-gray-50/50">
346
+ <input type="checkbox" checked={formData.immunosuppression.steroids} onChange={() => handleNestedCheckboxChange('immunosuppression', 'steroids')} className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
347
+ <span className="ml-3 text-sm font-medium text-gray-700">
348
+ Chronic Steroid Use
349
+ </span>
350
+ </label>
351
+
352
+ <label className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-gray-300 cursor-pointer transition-all bg-gray-50/50">
353
+ <input type="checkbox" checked={formData.immunosuppression.none} onChange={() => handleNestedCheckboxChange('immunosuppression', 'none')} className="w-5 h-5 rounded text-[#4ECDC4] focus:ring-[#4ECDC4] border-gray-300" />
354
+ <span className="ml-3 text-sm font-medium text-gray-700">
355
+ None known
356
+ </span>
357
+ </label>
358
+ </div>
359
+ </div>
360
+
361
+ <div className="pt-4 border-t border-gray-100">
362
+ <label className="block text-xs font-semibold text-gray-500 uppercase mb-2">
363
+ Additional Notes
364
+ </label>
365
+ <textarea name="riskFactorsNotes" value={formData.riskFactorsNotes} onChange={handleInputChange} rows={3} 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 resize-none text-sm" placeholder="Add any additional notes about risk factors..." />
366
+ </div>
367
+ </div>
368
+ </div>
369
+ </form>
370
+
371
+ <div className="mt-6 md:mt-8 flex flex-col md:flex-row md:justify-between gap-3 md:gap-4">
372
+ <button onClick={onBack} className="px-4 md:px-6 py-2 md:py-3 rounded-xl text-gray-600 font-medium hover:bg-gray-100 transition-colors text-sm md:text-base flex items-center justify-center md:justify-start gap-2">
373
+ <ArrowLeft className="w-4 h-4 md:w-5 md:h-5" />
374
+ <span className="hidden md:inline">Back to Registry</span>
375
+ <span className="inline md:hidden">Back</span>
376
+ </button>
377
+
378
+ <div className="flex flex-col md:flex-row gap-3 md:gap-4">
379
+ <button onClick={handleSave} className="px-4 md:px-6 py-2 md:py-3 rounded-xl text-gray-600 font-medium hover:bg-gray-100 transition-colors text-sm md:text-base">
380
+ Save Draft
381
+ </button>
382
+
383
+ <button onClick={handleSaveAndContinue} className="px-6 md:px-8 py-2 md:py-3 rounded-xl bg-[#05998c] text-white font-bold shadow-lg shadow-teal-500/20 hover:bg-[#047569] hover:shadow-teal-500/30 transition-all flex items-center justify-center gap-2 text-sm md:text-base">
384
+ <Save className="w-4 h-4 md:w-5 md:h-5" />
385
+ Save & Continue
386
+ <ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
387
+ </button>
388
+ </div>
389
+ </div>
390
+ </div>;
391
+ }
src/components/Sidebar.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Home, User, Camera, Edit2, Info, FileText, Settings, ChevronLeft, ChevronRight } from 'lucide-react';
3
+
4
+ type MenuKey = 'home' | 'patientinfo' | 'capture' | 'annotation' | 'compare' | 'report' | 'settings';
5
+
6
+ type Props = {
7
+ current: MenuKey;
8
+ onNavigate: (key: MenuKey) => void;
9
+ };
10
+
11
+ export function Sidebar({ current, onNavigate }: Props) {
12
+ const [collapsed, setCollapsed] = useState(false);
13
+ const items: { key: MenuKey; label: string; icon: React.ReactNode }[] = [
14
+ { key: 'home', label: 'Home', icon: <Home className="w-5 h-5" /> },
15
+ { key: 'patientinfo', label: 'Patients', icon: <User className="w-5 h-5" /> },
16
+ { key: 'capture', label: 'Capture', icon: <Camera className="w-5 h-5" /> },
17
+ { key: 'annotation', label: 'Annotate', icon: <Edit2 className="w-5 h-5" /> },
18
+ { key: 'compare', label: 'Compare', icon: <Info className="w-5 h-5" /> },
19
+ { key: 'report', label: 'Report', icon: <FileText className="w-5 h-5" /> }
20
+ ];
21
+
22
+ return (
23
+ <aside className={`bg-white border-r border-gray-100 transition-all flex-shrink-0 ${collapsed ? 'w-16 md:w-20' : 'w-56 md:w-64'}`}>
24
+ <div className="flex flex-col h-full">
25
+ <div className="px-2 md:px-3 py-3 md:py-4 flex items-center justify-between">
26
+ {!collapsed && <div className="font-bold text-sm md:text-lg text-[#0A2540]">Menu</div>}
27
+ <button onClick={() => setCollapsed(c => !c)} className="p-1 rounded hover:bg-gray-100">
28
+ {collapsed ? <ChevronRight className="w-4 h-4 md:w-5 md:h-5" /> : <ChevronLeft className="w-4 h-4 md:w-5 md:h-5" />}
29
+ </button>
30
+ </div>
31
+
32
+ <nav className="flex-1 px-1 md:px-2 py-2 md:py-4 space-y-0.5 md:space-y-1">
33
+ {items.map(item => {
34
+ const active = current === item.key;
35
+ return (
36
+ <button key={item.key} onClick={() => onNavigate(item.key)} className={`w-full flex items-center gap-2 md:gap-3 px-2 md:px-3 py-1.5 md:py-2 rounded-lg text-left hover:bg-gray-50 transition-colors ${active ? 'bg-[#E0F2F1] border-l-4 border-[#05998c]' : ''}`}>
37
+ <div className={`text-gray-600 flex-shrink-0 ${active ? 'text-[#0A2540]' : ''}`}>{item.icon}</div>
38
+ {!collapsed && <span className={`text-xs md:text-sm truncate ${active ? 'text-[#0A2540] font-semibold' : 'text-gray-700'}`}>{item.label}</span>}
39
+ </button>
40
+ );
41
+ })}
42
+ </nav>
43
+
44
+ <div className="px-1 md:px-2 py-2 md:py-4 border-t border-gray-100">
45
+ <div className="px-0.5 md:px-1">
46
+ <button onClick={() => onNavigate('settings')} className={`w-full flex items-center gap-2 md:gap-3 px-2 md:px-3 py-1.5 md:py-2 rounded-lg hover:bg-gray-50 transition-colors ${current === 'settings' ? 'bg-[#E0F2F1] border-l-4 border-[#05998c]' : ''}`}>
47
+ <Settings className="w-4 h-4 md:w-5 md:h-5 text-gray-600 flex-shrink-0" />
48
+ {!collapsed && <span className="text-xs md:text-sm text-gray-700 truncate">Settings</span>}
49
+ </button>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </aside>
54
+ );
55
+ }
56
+
57
+ export default Sidebar;
src/index.css ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* URL IMPORTS (SUCH AS FONT IMPORTS) SHOULD BE KEPT ABOVE TAILWIND IMPORTS - DO NOT DELETE THIS COMMENT */
2
+
3
+ /* PLEASE NOTE: THESE TAILWIND IMPORTS SHOULD NEVER BE DELETED - DO NOT DELETE THIS COMMENT */
4
+ @import 'tailwindcss/base';
5
+ @import 'tailwindcss/components';
6
+ @import 'tailwindcss/utilities';
7
+ /* DO NOT DELETE THESE TAILWIND IMPORTS, OTHERWISE THE STYLING WILL NOT RENDER AT ALL - DO NOT DELETE THIS COMMENT */
8
+
9
+ /* Tablet Landscape Responsive Styles */
10
+ html {
11
+ font-size: clamp(12px, 2vw, 16px);
12
+ }
13
+
14
+ body {
15
+ -webkit-user-select: none;
16
+ user-select: none;
17
+ -webkit-touch-callout: none;
18
+ }
19
+
20
+ input, textarea, select, button {
21
+ -webkit-user-select: text;
22
+ user-select: text;
23
+ }
24
+
25
+ /* Tablet landscape optimization */
26
+ @media (min-width: 768px) and (orientation: landscape) {
27
+ body {
28
+ font-size: 14px;
29
+ }
30
+
31
+ html {
32
+ font-size: 14px;
33
+ }
34
+ }
35
+
36
+ /* Optimize for iPad landscape */
37
+ @media (min-width: 1024px) and (max-width: 1366px) and (orientation: landscape) {
38
+ body {
39
+ font-size: 15px;
40
+ }
41
+ }
42
+
43
+ /* Custom scrollbar for modern look */
44
+ .custom-scrollbar::-webkit-scrollbar {
45
+ width: 6px;
46
+ }
47
+
48
+ .custom-scrollbar::-webkit-scrollbar-track {
49
+ background: #f1f5f9;
50
+ border-radius: 10px;
51
+ }
52
+
53
+ .custom-scrollbar::-webkit-scrollbar-thumb {
54
+ background: linear-gradient(180deg, #05998c, #047569);
55
+ border-radius: 10px;
56
+ }
57
+
58
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
59
+ background: linear-gradient(180deg, #047569, #036356);
60
+ }
61
+ /* Print Styles for Report Page */
62
+ @media print {
63
+ body {
64
+ margin: 0;
65
+ padding: 0;
66
+ background: white;
67
+ }
68
+
69
+ /* Hide non-printable elements */
70
+ .no-print {
71
+ display: none !important;
72
+ }
73
+
74
+ /* Page break rules */
75
+ .page-break-after {
76
+ page-break-after: auto;
77
+ }
78
+
79
+ /* Optimize for printing */
80
+ .print-container {
81
+ width: 100%;
82
+ max-width: 210mm;
83
+ margin: 0 auto;
84
+ padding: 20mm;
85
+ background: white;
86
+ box-shadow: none;
87
+ }
88
+
89
+ /* Print friendly spacing */
90
+ .print-section {
91
+ page-break-inside: avoid;
92
+ margin-bottom: 0.5cm;
93
+ }
94
+
95
+ /* Image sizing for print */
96
+ .print-image {
97
+ max-width: 100%;
98
+ height: auto;
99
+ page-break-inside: avoid;
100
+ }
101
+
102
+ /* Remove shadows and borders for print */
103
+ .shadow-lg, .shadow-md, .shadow-sm {
104
+ box-shadow: none !important;
105
+ }
106
+
107
+ /* Adjust colors for print */
108
+ .bg-gradient-to-br {
109
+ background: linear-gradient(135deg, #f8f9fa 0%, #f0f4f8 100%) !important;
110
+ }
111
+
112
+ /* Font optimization */
113
+ body {
114
+ font-size: 10pt;
115
+ line-height: 1.4;
116
+ }
117
+
118
+ h1 {
119
+ font-size: 18pt;
120
+ margin: 0.2cm 0;
121
+ }
122
+
123
+ h3 {
124
+ font-size: 12pt;
125
+ margin: 0.15cm 0;
126
+ }
127
+
128
+ p {
129
+ margin: 0.1cm 0;
130
+ }
131
+ }
src/index.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import './index.css';
2
+ import React from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { App } from "./App";
5
+
6
+ const container = document.getElementById("root");
7
+ if (container) {
8
+ const root = createRoot(container);
9
+ root.render(<App />);
10
+ }
src/pages/AcetowhiteExamPage.tsx ADDED
@@ -0,0 +1,569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, Save, ChevronRight } from 'lucide-react';
3
+ import { ImageAnnotator } from '../components/ImageAnnotator';
4
+ import { ImagingObservations } from '../components/ImagingObservations';
5
+
6
+ type CapturedItem = {
7
+ id: string;
8
+ type: 'image' | 'video';
9
+ url: string;
10
+ timestamp: Date;
11
+ annotations?: any[];
12
+ observations?: any;
13
+ };
14
+
15
+ type Props = {
16
+ goBack: () => void;
17
+ onNext: () => void;
18
+ };
19
+
20
+ export function AcetowhiteExamPage({ goBack, onNext }: Props) {
21
+ const [capturedItems, setCapturedItems] = useState<CapturedItem[]>([]);
22
+ const [isRecording, setIsRecording] = useState(false);
23
+ const [selectedImage, setSelectedImage] = useState<string | null>(null);
24
+ const [annotations, setAnnotations] = useState<any[]>([]);
25
+ const [observations, setObservations] = useState({});
26
+ const [showExitWarning, setShowExitWarning] = useState(false);
27
+
28
+ // Timer states
29
+ const [timerStarted, setTimerStarted] = useState(false);
30
+ const [seconds, setSeconds] = useState(0);
31
+ const [aceticApplied, setAceticApplied] = useState(false);
32
+ const [showFlash, setShowFlash] = useState(false);
33
+ const audibleAlert = true;
34
+ const [timerPaused, setTimerPaused] = useState(false);
35
+
36
+ const cervixImageUrl = "/C87Aceto_(1).jpg";
37
+
38
+ // Timer effect
39
+ useEffect(() => {
40
+ if (!timerStarted || !aceticApplied || timerPaused) return;
41
+
42
+ const interval = setInterval(() => {
43
+ setSeconds(prev => prev + 1);
44
+ }, 1000);
45
+
46
+ return () => clearInterval(interval);
47
+ }, [timerStarted, aceticApplied, timerPaused]);
48
+
49
+ // Check for 1 minute and 3 minute marks
50
+ useEffect(() => {
51
+ if (seconds === 60) {
52
+ // 1 minute mark
53
+ setShowFlash(true);
54
+ if (audibleAlert) {
55
+ // Play beep sound (in real app)
56
+ console.log('BEEP - 1 minute mark');
57
+ }
58
+ setTimeout(() => setShowFlash(false), 3000);
59
+ } else if (seconds === 180) {
60
+ // 3 minute mark
61
+ setShowFlash(true);
62
+ if (audibleAlert) {
63
+ console.log('BEEP - 3 minute mark');
64
+ }
65
+ setTimeout(() => setShowFlash(false), 3000);
66
+ }
67
+ }, [seconds, audibleAlert]);
68
+
69
+ const formatTime = (totalSeconds: number) => {
70
+ const mins = Math.floor(totalSeconds / 60);
71
+ const secs = totalSeconds % 60;
72
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
73
+ };
74
+
75
+ const handleAceticApplied = () => {
76
+ setAceticApplied(true);
77
+ setTimerStarted(true);
78
+ setSeconds(0);
79
+ };
80
+
81
+ const handleRestartTimer = () => {
82
+ setSeconds(0);
83
+ setTimerStarted(false);
84
+ setAceticApplied(false);
85
+ setShowFlash(false);
86
+ setTimerPaused(false);
87
+ };
88
+
89
+ const handleCaptureImage = () => {
90
+ const newCapture: CapturedItem = {
91
+ id: Date.now().toString(),
92
+ type: 'image',
93
+ url: cervixImageUrl,
94
+ timestamp: new Date()
95
+ };
96
+ setCapturedItems(prev => [...prev, newCapture]);
97
+ };
98
+
99
+ const handleToggleRecording = () => {
100
+ if (!isRecording) {
101
+ setIsRecording(true);
102
+ } else {
103
+ setIsRecording(false);
104
+ const newCapture: CapturedItem = {
105
+ id: Date.now().toString(),
106
+ type: 'video',
107
+ url: cervixImageUrl,
108
+ timestamp: new Date()
109
+ };
110
+ setCapturedItems(prev => [...prev, newCapture]);
111
+ }
112
+ };
113
+
114
+ const handleSaveAnnotations = () => {
115
+ if (!selectedImage) return;
116
+
117
+ setCapturedItems(prev => prev.map(item =>
118
+ item.id === selectedImage
119
+ ? { ...item, annotations, observations }
120
+ : item
121
+ ));
122
+ setSelectedImage(null);
123
+ setAnnotations([]);
124
+ setObservations({});
125
+ };
126
+
127
+ const handleDeleteCapture = (id: string) => {
128
+ setCapturedItems(prev => prev.filter(item => item.id !== id));
129
+ if (selectedImage === id) {
130
+ setSelectedImage(null);
131
+ }
132
+ };
133
+
134
+ const selectedItem = selectedImage
135
+ ? capturedItems.find(item => item.id === selectedImage)
136
+ : null;
137
+
138
+ const totalCaptures = capturedItems.length;
139
+ const imageCaptures = capturedItems.filter(item => item.type === 'image');
140
+ const videoCaptures = capturedItems.filter(item => item.type === 'video');
141
+ const hasRequiredCapture = imageCaptures.length > 0;
142
+
143
+ return (
144
+ <div className="w-full bg-white/95 relative">
145
+ <div className="relative z-10 py-4 md:py-6 lg:py-8">
146
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6">
147
+
148
+ {/* Page Header */}
149
+ <div className="mb-4 md:mb-6">
150
+ <div className="flex items-center justify-between mb-4">
151
+ <div className="flex items-center gap-3">
152
+ <button onClick={() => setShowExitWarning(true)} className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600">
153
+ <ArrowLeft className="w-5 h-5" />
154
+ </button>
155
+ <h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-[#0A2540]">Acetowhite Examination</h1>
156
+ </div>
157
+ <div className="flex items-center gap-3">
158
+ </div>
159
+ </div>
160
+
161
+ {/* Progress Bar - Capture / Annotation / Comparison View / Report */}
162
+ <div className="mb-4 flex gap-1 md:gap-2 items-center">
163
+ <div className="flex gap-1 md:gap-2 flex-1">
164
+ {['Capture', 'Annotation', 'Comparison View', 'Report'].map((stage, idx) => (
165
+ <div key={stage} className="flex items-center flex-1">
166
+ <button
167
+ className={`flex-1 py-2 px-2 md:px-3 rounded-lg font-medium text-sm md:text-base transition-all border-2 border-[#0A2540] ${
168
+ (stage === 'Capture' && !selectedImage) ||
169
+ (stage === 'Annotation' && selectedImage)
170
+ ? 'bg-[#05998c] text-white shadow-md'
171
+ : 'bg-gray-100 text-gray-600'
172
+ }`}
173
+ >
174
+ {stage}
175
+ </button>
176
+ {idx < 3 && <div className="w-1.5 h-1.5 rounded-full bg-gray-300 mx-1" />}
177
+ </div>
178
+ ))}
179
+ </div>
180
+ <button onClick={onNext} className="ml-4 px-6 md:px-8 py-2 md:py-3 rounded-xl bg-gray-600 text-white font-bold shadow-lg shadow-gray-500/20 hover:bg-slate-700 hover:shadow-gray-500/30 transition-all flex items-center justify-center gap-2 text-sm md:text-base">
181
+ <Save className="w-4 h-4 md:w-5 md:h-5" />
182
+ <span className="hidden lg:inline">Next</span>
183
+ <span className="inline lg:hidden">Next</span>
184
+ <ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
185
+ </button>
186
+ </div>
187
+ </div>
188
+
189
+ {!selectedImage ? (
190
+ // Live Feed View
191
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
192
+ {/* Main Live Feed */}
193
+ <div className="lg:col-span-2 space-y-4">
194
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
195
+ <div className="mb-4">
196
+ <h2 className="text-2xl md:text-3xl font-bold text-[#0A2540] mb-2">
197
+ Acetowhite
198
+ </h2>
199
+ <p className="text-gray-600">
200
+ Acetic acid application and acetowhitening observation
201
+ </p>
202
+ </div>
203
+
204
+ {/* Live Video Feed */}
205
+ <div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700 mb-4">
206
+ <div className="aspect-video flex items-center justify-center">
207
+ <img src={cervixImageUrl} alt="Live Feed" className="w-full h-full object-cover" />
208
+
209
+ {/* Live indicator */}
210
+ <div className="absolute top-4 left-4 flex items-center gap-2 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
211
+ <div className={`w-2 h-2 rounded-full ${isRecording ? 'bg-white animate-pulse' : 'bg-white/70'}`} />
212
+ {isRecording ? 'Recording' : 'Live'}
213
+ </div>
214
+
215
+ {/* Timer overlay */}
216
+ {aceticApplied && (
217
+ <div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg">
218
+ <p className="text-2xl font-mono font-bold">{formatTime(seconds)}</p>
219
+ </div>
220
+ )}
221
+
222
+ {/* Flash overlay at 1 and 3 minutes */}
223
+ {showFlash && (
224
+ <div className="absolute inset-0 bg-[#05998c]/30 animate-pulse flex items-center justify-center">
225
+ <div className="bg-white/90 px-6 py-4 rounded-lg">
226
+ <p className="text-2xl font-bold text-[#0A2540]">
227
+ {seconds >= 180 ? '3 Minutes!' : '1 Minute!'}
228
+ </p>
229
+ </div>
230
+ </div>
231
+ )}
232
+ </div>
233
+ </div>
234
+
235
+ <div className="mt-4 flex items-center gap-2 text-sm text-gray-500">
236
+ <Camera className="w-4 h-4" />
237
+ <span>Captures: {totalCaptures} / 1 required (image)</span>
238
+ {hasRequiredCapture && <CheckCircle2 className="w-4 h-4 text-green-500 ml-2" />}
239
+ </div>
240
+
241
+ {!hasRequiredCapture && timerStarted && (
242
+ <div className="mt-4 flex items-center gap-2 text-sm text-amber-600 bg-amber-50 px-4 py-2 rounded-lg">
243
+ <Info className="w-4 h-4" />
244
+ <span>At least one image capture required</span>
245
+ </div>
246
+ )}
247
+
248
+ {/* Captured Images Selection for Annotation */}
249
+ {imageCaptures.length > 0 && (
250
+ <div className="mt-6 pt-6 border-t border-gray-200">
251
+ <h4 className="text-sm font-semibold text-gray-700 mb-3">Select Image to Annotate</h4>
252
+ <div className="grid grid-cols-3 gap-3">
253
+ {imageCaptures.map(item => (
254
+ <div
255
+ key={item.id}
256
+ onClick={() => setSelectedImage(item.id)}
257
+ className="relative group cursor-pointer"
258
+ >
259
+ <div className="aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 border-transparent hover:border-[#05998c] transition-all">
260
+ <img src={item.url} alt="Capture" className="w-full h-full object-cover" />
261
+ {item.annotations && item.annotations.length > 0 && (
262
+ <div className="absolute top-1 right-1 bg-green-500 text-white p-1 rounded">
263
+ <CheckCircle2 className="w-3 h-3" />
264
+ </div>
265
+ )}
266
+ </div>
267
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
268
+ <span className="text-white text-xs font-semibold">Annotate</span>
269
+ </div>
270
+ </div>
271
+ ))}
272
+ </div>
273
+ </div>
274
+ )}
275
+ </div>
276
+ </div>
277
+
278
+ {/* Captures Sidebar */}
279
+ <div className="lg:col-span-1">
280
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
281
+ {/* Instruction Card */}
282
+ {!timerStarted && (
283
+ <div className="mb-4">
284
+ {/* Apply Acetic Acid Message Box */}
285
+ <div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg p-4 mb-4 shadow-md">
286
+ <div className="flex items-center gap-3 mb-2">
287
+ <div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
288
+ <Info className="w-6 h-6 text-blue-600" />
289
+ </div>
290
+ <div>
291
+ <p className="text-white font-bold text-lg">Apply Acetic Acid Now</p>
292
+ <p className="text-blue-100 text-sm">Apply 3-5% acetic acid to the cervix</p>
293
+ </div>
294
+ </div>
295
+ </div>
296
+
297
+ <button
298
+ onClick={handleAceticApplied}
299
+ className="w-full px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-all shadow-md hover:shadow-lg"
300
+ >
301
+ Acetic acid applied — Start timer
302
+ </button>
303
+ </div>
304
+ )}
305
+
306
+ {/* Timer Display */}
307
+ {timerStarted && (
308
+ <div className={`mb-4 rounded-lg p-4 border-2 transition-all ${
309
+ seconds < 50
310
+ ? 'bg-gradient-to-r from-[#05998c]/10 to-[#0A2540]/10 border-[#05998c]'
311
+ : seconds < 55
312
+ ? 'bg-red-50 border-red-200'
313
+ : seconds < 60
314
+ ? 'bg-red-100 border-red-300'
315
+ : seconds >= 60 && seconds <= 60
316
+ ? 'bg-red-200 border-red-400'
317
+ : seconds > 60 && seconds < 170
318
+ ? 'bg-gradient-to-r from-green-50 to-green-50 border-green-300'
319
+ : seconds < 175
320
+ ? 'bg-red-50 border-red-200'
321
+ : seconds < 180
322
+ ? 'bg-red-100 border-red-300'
323
+ : 'bg-red-200 border-red-400'
324
+ }`}>
325
+ <div className="flex flex-col gap-3">
326
+ <div>
327
+ <p className="text-sm text-gray-600 mb-1">Timer</p>
328
+ <p className={`text-4xl font-bold font-mono ${
329
+ seconds >= 180 ? 'text-red-600' :
330
+ seconds >= 60 ? 'text-amber-500' :
331
+ seconds >= 50 ? 'text-amber-400' :
332
+ 'text-[#0A2540]'
333
+ }`}>{formatTime(seconds)}</p>
334
+ {seconds >= 60 && seconds < 180 && (
335
+ <p className="text-sm text-amber-600 mt-1">Approaching 3-minute mark...</p>
336
+ )}
337
+ {seconds >= 180 && (
338
+ <p className="text-sm text-green-600 mt-1">3-minute observation period complete</p>
339
+ )}
340
+ </div>
341
+ <div className="flex gap-2">
342
+ <button
343
+ onClick={() => setTimerPaused(!timerPaused)}
344
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
345
+ >
346
+ {timerPaused ? (
347
+ <>
348
+ <Video className="w-4 h-4" />
349
+ <span className="text-sm font-medium">Play</span>
350
+ </>
351
+ ) : (
352
+ <>
353
+ <Pause className="w-4 h-4" />
354
+ <span className="text-sm font-medium">Pause</span>
355
+ </>
356
+ )}
357
+ </button>
358
+ <button
359
+ onClick={handleRestartTimer}
360
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
361
+ >
362
+ <RotateCcw className="w-4 h-4" />
363
+ <span className="text-sm font-medium">Restart</span>
364
+ </button>
365
+ </div>
366
+ </div>
367
+ {seconds === 60 && (
368
+ <div className="mt-3 bg-amber-100 border border-amber-300 rounded-lg p-3">
369
+ <p className="text-amber-800 font-semibold text-sm">⏰ 1-minute mark - Capture recommended!</p>
370
+ </div>
371
+ )}
372
+ {seconds === 180 && (
373
+ <div className="mt-3 bg-green-100 border border-green-300 rounded-lg p-3">
374
+ <p className="text-green-800 font-semibold text-sm">⏰ 3-minute mark - Final capture recommended!</p>
375
+ </div>
376
+ )}
377
+ </div>
378
+ )}
379
+
380
+ {/* Capture Controls */}
381
+ <div className="space-y-3 mb-4">
382
+ <div className="flex gap-2">
383
+ <button
384
+ onClick={handleCaptureImage}
385
+ disabled={!timerStarted}
386
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${
387
+ showFlash && (seconds === 60 || seconds === 180)
388
+ ? 'bg-[#05998c] text-white animate-pulse'
389
+ : 'bg-[#05998c] text-white hover:bg-[#047569]'
390
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
391
+ >
392
+ <Camera className="w-4 h-4" />
393
+ Capture
394
+ </button>
395
+ <button
396
+ onClick={handleToggleRecording}
397
+ disabled={!timerStarted}
398
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${
399
+ isRecording
400
+ ? 'bg-red-500 text-white hover:bg-red-600'
401
+ : 'bg-[#05998c] text-white hover:bg-[#047569]'
402
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
403
+ >
404
+ {isRecording ? <Pause className="w-4 h-4" /> : <Video className="w-4 h-4" />}
405
+ {isRecording ? 'Stop' : 'Record'}
406
+ </button>
407
+ </div>
408
+ <button onClick={onNext} className="w-full flex items-center justify-center gap-2 px-6 md:px-8 py-2 md:py-3 rounded-xl bg-gray-600 text-white font-bold shadow-lg shadow-gray-500/20 hover:bg-slate-700 hover:shadow-gray-500/30 transition-all text-sm md:text-base">
409
+ <Save className="w-4 h-4 md:w-5 md:h-5" />
410
+ <span className="hidden lg:inline">Next</span>
411
+ <span className="inline lg:hidden">Next</span>
412
+ <ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
413
+ </button>
414
+ </div>
415
+ <h3 className="font-bold text-[#0A2540] mb-4">Captured Media</h3>
416
+
417
+ {totalCaptures === 0 ? (
418
+ <div className="flex flex-col items-center justify-center py-12 text-center">
419
+ <Camera className="w-16 h-16 text-gray-300 mb-3" />
420
+ <p className="text-gray-500 font-medium">No captures yet</p>
421
+ <p className="text-sm text-gray-400 mt-1">Apply acetic acid and start capturing</p>
422
+ </div>
423
+ ) : (
424
+ <div className="space-y-3">
425
+ {/* Image Thumbnails */}
426
+ {imageCaptures.length > 0 && (
427
+ <div>
428
+ <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Images ({imageCaptures.length})</h4>
429
+ <div className="grid grid-cols-2 gap-2">
430
+ {imageCaptures.map(item => (
431
+ <div key={item.id} className="relative group">
432
+ <div
433
+ onClick={() => setSelectedImage(item.id)}
434
+ className="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer border-2 border-transparent hover:border-[#05998c] transition-all"
435
+ >
436
+ <img src={item.url} alt="Capture" className="w-full h-full object-cover" />
437
+ {item.annotations && item.annotations.length > 0 && (
438
+ <div className="absolute top-1 right-1 bg-green-500 text-white p-1 rounded">
439
+ <CheckCircle2 className="w-3 h-3" />
440
+ </div>
441
+ )}
442
+ </div>
443
+ <button
444
+ onClick={(e) => {
445
+ e.stopPropagation();
446
+ handleDeleteCapture(item.id);
447
+ }}
448
+ className="absolute top-1 left-1 bg-red-500 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
449
+ >
450
+ <X className="w-3 h-3" />
451
+ </button>
452
+ <button
453
+ onClick={() => setSelectedImage(item.id)}
454
+ className="absolute bottom-1 right-1 bg-[#0A2540] text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
455
+ >
456
+ <Edit2 className="w-3 h-3" />
457
+ </button>
458
+ </div>
459
+ ))}
460
+ </div>
461
+ </div>
462
+ )}
463
+
464
+ {/* Video Items */}
465
+ {videoCaptures.length > 0 && (
466
+ <div className="mt-4">
467
+ <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Videos ({videoCaptures.length})</h4>
468
+ <div className="space-y-2">
469
+ {videoCaptures.map(item => (
470
+ <div key={item.id} className="relative group bg-gray-50 rounded-lg p-3 flex items-center gap-3">
471
+ <div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
472
+ <Video className="w-6 h-6 text-gray-500" />
473
+ </div>
474
+ <div className="flex-1">
475
+ <p className="text-sm font-medium text-gray-700">Video Recording</p>
476
+ <p className="text-xs text-gray-500">{item.timestamp.toLocaleTimeString()}</p>
477
+ </div>
478
+ <button
479
+ onClick={() => handleDeleteCapture(item.id)}
480
+ className="p-1 hover:bg-red-50 rounded text-red-500"
481
+ >
482
+ <X className="w-4 h-4" />
483
+ </button>
484
+ </div>
485
+ ))}
486
+ </div>
487
+ </div>
488
+ )}
489
+ </div>
490
+ )}
491
+ </div>
492
+ </div>
493
+ </div>
494
+ ) : (
495
+ // Annotation View
496
+ <div>
497
+ <div className="mb-4 flex items-center justify-between">
498
+ <button
499
+ onClick={() => {
500
+ setSelectedImage(null);
501
+ setAnnotations([]);
502
+ setObservations({});
503
+ }}
504
+ className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
505
+ >
506
+ <ArrowLeft className="w-4 h-4" />
507
+ Back to Live Feed
508
+ </button>
509
+ <div className="flex items-center gap-3">
510
+ <button
511
+ onClick={handleSaveAnnotations}
512
+ className="px-6 py-2 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors"
513
+ >
514
+ Save Annotations
515
+ </button>
516
+ <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">
517
+ Next
518
+ <ArrowRight className="w-4 h-4" />
519
+ </button>
520
+ </div>
521
+ </div>
522
+
523
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
524
+ <div className="lg:col-span-2">
525
+ <ImageAnnotator
526
+ imageUrl={selectedItem?.url || cervixImageUrl}
527
+ onAnnotationsChange={setAnnotations}
528
+ />
529
+ </div>
530
+ <div className="lg:col-span-1">
531
+ <ImagingObservations onObservationsChange={setObservations} />
532
+ </div>
533
+ </div>
534
+ </div>
535
+ )}
536
+
537
+ {/* Exit Warning Dialog */}
538
+ {showExitWarning && (
539
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
540
+ <div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4">
541
+ <h3 className="text-xl font-bold text-[#0A2540] mb-3">Leave Examination?</h3>
542
+ <p className="text-gray-600 mb-6">
543
+ If you go back now, all captures, timer data, and annotations will be lost. Are you sure you want to continue?
544
+ </p>
545
+ <div className="flex gap-3 justify-end">
546
+ <button
547
+ onClick={() => setShowExitWarning(false)}
548
+ className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
549
+ >
550
+ Cancel
551
+ </button>
552
+ <button
553
+ onClick={() => {
554
+ setShowExitWarning(false);
555
+ goBack();
556
+ }}
557
+ className="px-6 py-2 bg-red-500 text-white rounded-lg font-semibold hover:bg-red-600 transition-colors"
558
+ >
559
+ Leave Anyway
560
+ </button>
561
+ </div>
562
+ </div>
563
+ </div>
564
+ )}
565
+ </div>
566
+ </div>
567
+ </div>
568
+ );
569
+ }
src/pages/BiopsyMarking.tsx ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 }) => {
6
+ const baseClass = 'inline-flex items-center justify-center font-medium rounded transition-colors';
7
+ const variantClass = variant === 'ghost' ? 'hover:bg-gray-200 text-gray-700' : variant === 'outline' ? 'border border-gray-300 hover:bg-gray-50' : 'bg-blue-600 text-white hover:bg-blue-700';
8
+ const sizeClass = size === 'sm' ? 'px-2 py-1 text-sm' : 'px-4 py-2';
9
+ return <button className={`${baseClass} ${variantClass} ${sizeClass} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`} onClick={onClick} disabled={disabled} {...props}>{children}</button>;
10
+ };
11
+
12
+ export interface BiopsyCapturedImage {
13
+ id: string;
14
+ src: string;
15
+ stepId: string;
16
+ type: 'image' | 'video';
17
+ }
18
+
19
+ interface CapturedImage {
20
+ id: string;
21
+ src: string;
22
+ stepId: string;
23
+ type: 'image' | 'video';
24
+ }
25
+
26
+ interface BiopsyMarkingProps {
27
+ onBack: () => void;
28
+ onNext: () => void;
29
+ capturedImages?: CapturedImage[];
30
+ }
31
+
32
+ interface LesionMark {
33
+ id: string;
34
+ type: string;
35
+ typeCode: string;
36
+ clockHour: number;
37
+ color: string;
38
+ }
39
+
40
+ const LESION_TYPES = [
41
+ { code: 'EC', name: 'Ectopy', color: '#22c55e' },
42
+ { code: 'TTZ', name: 'T.Trans Zone', color: '#3b82f6' },
43
+ { code: 'ATZ', name: 'At. Trans Zone', color: '#8b5cf6' },
44
+ { code: 'L', name: 'Leukoplakia', color: '#f59e0b' },
45
+ { code: 'AB', name: 'Abn. Vessel', color: '#ef4444' },
46
+ { code: 'NF', name: 'Nabo.Fol', color: '#06b6d4' },
47
+ { code: 'XB', name: 'Biopsy Site', color: '#ec4899' },
48
+ { code: 'C', name: 'Condylomata', color: '#84cc16' },
49
+ { code: 'AW', name: 'Acetowhite', color: '#f97316' },
50
+ { code: 'MO', name: 'Mosaic', color: '#6366f1' },
51
+ { code: 'GO', name: 'Gland Opening', color: '#14b8a6' },
52
+ { code: 'PN', name: 'Punctation', color: '#e11d48' }
53
+ ];
54
+
55
+ const CLOCK_HOURS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
56
+
57
+ const stepLabels: Record<string, string> = {
58
+ native: 'Native Cervix',
59
+ acetowhite: 'Acetowhite',
60
+ iodine: "Lugol's Iodine"
61
+ };
62
+
63
+ const BiopsyMarking: React.FC<BiopsyMarkingProps> = ({ onBack, onNext, capturedImages = [] }) => {
64
+ const [selectedType, setSelectedType] = useState<typeof LESION_TYPES[0] | null>(null);
65
+ const [lesionMarks, setLesionMarks] = useState<LesionMark[]>([]);
66
+ const [selectedImage, setSelectedImage] = useState<CapturedImage | null>(
67
+ capturedImages.length > 0 ? capturedImages[0] : null
68
+ );
69
+ const [uploadedImages, setUploadedImages] = useState<CapturedImage[]>([]);
70
+ const [overlayScale, setOverlayScale] = useState(100);
71
+ const [overlayOffset, setOverlayOffset] = useState({ x: 0, y: 0 });
72
+ const [isDragging, setIsDragging] = useState(false);
73
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
74
+ const [searchQuery, setSearchQuery] = useState('');
75
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
76
+ const fileInputRef = useRef<HTMLInputElement>(null);
77
+ const imageContainerRef = useRef<HTMLDivElement>(null);
78
+
79
+ const filteredLesionTypes = LESION_TYPES.filter(type =>
80
+ type.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
81
+ type.name.toLowerCase().includes(searchQuery.toLowerCase())
82
+ );
83
+
84
+ const allImages = [...capturedImages, ...uploadedImages];
85
+
86
+ const handleUpload = () => {
87
+ fileInputRef.current?.click();
88
+ };
89
+
90
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
91
+ const file = e.target.files?.[0];
92
+ if (file) {
93
+ const isVideo = file.type.startsWith('video/');
94
+ const newImage: CapturedImage = {
95
+ id: `uploaded-${Date.now()}`,
96
+ src: URL.createObjectURL(file),
97
+ stepId: 'uploaded',
98
+ type: isVideo ? 'video' : 'image'
99
+ };
100
+ setUploadedImages(prev => [...prev, newImage]);
101
+ setSelectedImage(newImage);
102
+ }
103
+ e.target.value = '';
104
+ };
105
+
106
+ const handleClockClick = (hour: number, e: React.MouseEvent) => {
107
+ e.stopPropagation();
108
+ if (!selectedType) return;
109
+
110
+ const existingIndex = lesionMarks.findIndex(
111
+ mark => mark.clockHour === hour && mark.typeCode === selectedType.code
112
+ );
113
+
114
+ if (existingIndex >= 0) {
115
+ setLesionMarks(lesionMarks.filter((_, i) => i !== existingIndex));
116
+ } else {
117
+ const newMark: LesionMark = {
118
+ id: Date.now().toString(),
119
+ type: selectedType.name,
120
+ typeCode: selectedType.code,
121
+ clockHour: hour,
122
+ color: selectedType.color
123
+ };
124
+ setLesionMarks([...lesionMarks, newMark]);
125
+ }
126
+ };
127
+
128
+ const undoLastMark = () => {
129
+ setLesionMarks(lesionMarks.slice(0, -1));
130
+ };
131
+
132
+ const clearAllMarks = () => {
133
+ setLesionMarks([]);
134
+ };
135
+
136
+ const getClockPosition = (hour: number, radius: number, centerX: number, centerY: number) => {
137
+ const angle = ((hour - 3) * 30 * Math.PI) / 180;
138
+ return {
139
+ x: centerX + radius * Math.cos(angle),
140
+ y: centerY + radius * Math.sin(angle)
141
+ };
142
+ };
143
+
144
+ const handleMouseDown = (e: React.MouseEvent) => {
145
+ // Allow dragging with left click anywhere on the image/overlay area
146
+ if (e.button === 0) {
147
+ setIsDragging(true);
148
+ setDragStart({ x: e.clientX - overlayOffset.x, y: e.clientY - overlayOffset.y });
149
+ }
150
+ };
151
+
152
+ const handleMouseMove = (e: React.MouseEvent) => {
153
+ if (isDragging) {
154
+ setOverlayOffset({
155
+ x: e.clientX - dragStart.x,
156
+ y: e.clientY - dragStart.y
157
+ });
158
+ }
159
+ };
160
+
161
+ const handleMouseUp = () => {
162
+ setIsDragging(false);
163
+ };
164
+
165
+ const groupedImages = allImages.reduce((acc, img) => {
166
+ const key = img.stepId;
167
+ if (!acc[key]) acc[key] = [];
168
+ acc[key].push(img);
169
+ return acc;
170
+ }, {} as Record<string, CapturedImage[]>);
171
+
172
+ const markedHours = [...new Set(lesionMarks.map(mark => mark.clockHour))];
173
+ const coveragePercent = Math.round((markedHours.length / 12) * 100);
174
+
175
+ const marksByType = lesionMarks.reduce((acc, mark) => {
176
+ if (!acc[mark.typeCode]) {
177
+ acc[mark.typeCode] = { type: mark.type, color: mark.color, hours: [] as number[] };
178
+ }
179
+ if (!acc[mark.typeCode].hours.includes(mark.clockHour)) {
180
+ acc[mark.typeCode].hours.push(mark.clockHour);
181
+ }
182
+ return acc;
183
+ }, {} as Record<string, { type: string; color: string; hours: number[] }>);
184
+
185
+ const overlaySize = overlayScale * 2.5;
186
+
187
+ return (
188
+ <div className="h-full flex flex-col bg-gradient-to-br from-slate-50 to-slate-100 overflow-hidden">
189
+ <div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm shrink-0">
190
+ <Button variant="ghost" size="sm" className="h-8 px-2 text-slate-700" onClick={onBack}>
191
+ <ArrowLeft className="h-4 w-4 mr-2" />
192
+ Back
193
+ </Button>
194
+ <h2 className="text-lg font-semibold text-slate-800">Biopsy Marking</h2>
195
+ <div className="ml-auto flex items-center gap-2">
196
+ <Button
197
+ variant="outline"
198
+ size="sm"
199
+ className="h-8 px-3 text-slate-700 border-slate-300"
200
+ onClick={undoLastMark}
201
+ disabled={lesionMarks.length === 0}
202
+ >
203
+ <Undo2 className="h-4 w-4 mr-1" />
204
+ Undo
205
+ </Button>
206
+ <Button
207
+ variant="outline"
208
+ size="sm"
209
+ className="h-8 px-3 text-slate-700 border-slate-300"
210
+ onClick={clearAllMarks}
211
+ disabled={lesionMarks.length === 0}
212
+ >
213
+ <Trash2 className="h-4 w-4 mr-1" />
214
+ Clear
215
+ </Button>
216
+ <Button
217
+ size="sm"
218
+ className="h-8 px-3 bg-gray-600 text-white hover:bg-slate-700"
219
+ >
220
+ Next
221
+ <ArrowRight className="h-4 w-4 ml-1" />
222
+ </Button>
223
+ </div>
224
+ </div>
225
+
226
+ <div className="flex-1 flex gap-3 p-3 min-h-0 overflow-hidden">
227
+ {/* Center Image Area */}
228
+ <div
229
+ ref={imageContainerRef}
230
+ className="flex-1 relative overflow-hidden"
231
+ onMouseDown={handleMouseDown}
232
+ onMouseMove={handleMouseMove}
233
+ onMouseUp={handleMouseUp}
234
+ onMouseLeave={handleMouseUp}
235
+ >
236
+ {selectedImage ? (
237
+ selectedImage.type === 'video' ? (
238
+ <div className="w-full h-full flex items-center justify-center">
239
+ <Video className="h-16 w-16 text-blue-400" />
240
+ </div>
241
+ ) : (
242
+ <img src={selectedImage.src} alt="Cervix" className="w-full h-full object-contain" />
243
+ )
244
+ ) : (
245
+ <div className="w-full h-full flex items-center justify-center text-slate-600">
246
+ <div className="text-center">
247
+ <Image className="h-16 w-16 opacity-20 mx-auto mb-3" />
248
+ <p className="text-sm text-slate-500">Upload or select an image</p>
249
+ </div>
250
+ </div>
251
+ )}
252
+
253
+ <div
254
+ className="absolute inset-0 flex items-center justify-center pointer-events-none"
255
+ style={{ transform: `translate(${overlayOffset.x}px, ${overlayOffset.y}px)` }}
256
+ >
257
+ <svg
258
+ width={overlaySize}
259
+ height={overlaySize}
260
+ viewBox="0 0 300 300"
261
+ className={`pointer-events-auto ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
262
+ >
263
+ <circle cx="150" cy="150" r="140" fill="none" stroke="rgba(59,130,246,0.8)" strokeWidth="3" />
264
+ <line x1="150" y1="10" x2="150" y2="290" stroke="rgba(59,130,246,0.6)" strokeWidth="2" />
265
+ <line x1="10" y1="150" x2="290" y2="150" stroke="rgba(59,130,246,0.6)" strokeWidth="2" />
266
+ <ellipse cx="150" cy="150" rx="35" ry="20" fill="none" stroke="rgba(59,130,246,0.4)" strokeWidth="1.5" />
267
+
268
+ {lesionMarks.map(mark => {
269
+ const startAngle = ((mark.clockHour - 3.5) * 30 * Math.PI) / 180;
270
+ const endAngle = ((mark.clockHour - 2.5) * 30 * Math.PI) / 180;
271
+ const innerR = 25;
272
+ const outerR = 130;
273
+
274
+ const x1 = 150 + outerR * Math.cos(startAngle);
275
+ const y1 = 150 + outerR * Math.sin(startAngle);
276
+ const x2 = 150 + outerR * Math.cos(endAngle);
277
+ const y2 = 150 + outerR * Math.sin(endAngle);
278
+ const x3 = 150 + innerR * Math.cos(endAngle);
279
+ const y3 = 150 + innerR * Math.sin(endAngle);
280
+ const x4 = 150 + innerR * Math.cos(startAngle);
281
+ const y4 = 150 + innerR * Math.sin(startAngle);
282
+
283
+ const pathD = `M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`;
284
+
285
+ return (
286
+ <path key={mark.id} d={pathD} fill={mark.color} opacity={0.5} stroke={mark.color} strokeWidth="2" />
287
+ );
288
+ })}
289
+
290
+ {CLOCK_HOURS.map(hour => {
291
+ const pos = getClockPosition(hour, 130, 150, 150);
292
+ const marksAtHour = lesionMarks.filter(mark => mark.clockHour === hour);
293
+ const hasMarks = marksAtHour.length > 0;
294
+
295
+ return (
296
+ <g key={`clock-${hour}`}>
297
+ <circle
298
+ cx={pos.x}
299
+ cy={pos.y}
300
+ r={16}
301
+ fill={hasMarks ? marksAtHour[0].color : 'rgba(100,116,139,0.6)'}
302
+ stroke="white"
303
+ strokeWidth="2"
304
+ className={`${selectedType ? 'cursor-pointer hover:opacity-80' : ''} transition-all`}
305
+ onClick={event => handleClockClick(hour, event)}
306
+ />
307
+ <text
308
+ x={pos.x}
309
+ y={pos.y}
310
+ textAnchor="middle"
311
+ dominantBaseline="central"
312
+ fontSize="12"
313
+ fontWeight="bold"
314
+ fill="white"
315
+ className="pointer-events-none"
316
+ >
317
+ {hour}
318
+ </text>
319
+
320
+ {hasMarks && (
321
+ <text
322
+ x={pos.x}
323
+ y={pos.y + 22}
324
+ textAnchor="middle"
325
+ fontSize="9"
326
+ fontWeight="bold"
327
+ fill={marksAtHour[0].color}
328
+ stroke="black"
329
+ strokeWidth="0.5"
330
+ className="pointer-events-none"
331
+ >
332
+ {marksAtHour.map(mark => mark.typeCode).join(',')}
333
+ </text>
334
+ )}
335
+ </g>
336
+ );
337
+ })}
338
+
339
+ <text x="110" y="35" fill="white" fontSize="14" fontWeight="bold">
340
+ Q4
341
+ </text>
342
+ <text x="180" y="35" fill="white" fontSize="14" fontWeight="bold">
343
+ Q1
344
+ </text>
345
+ <text x="180" y="275" fill="white" fontSize="14" fontWeight="bold">
346
+ Q2
347
+ </text>
348
+ <text x="110" y="275" fill="white" fontSize="14" fontWeight="bold">
349
+ Q3
350
+ </text>
351
+ </svg>
352
+ </div>
353
+
354
+ </div>
355
+
356
+ {/* Right Sidebar */}
357
+ <div className="w-72 flex flex-col gap-4 shrink-0 overflow-y-auto">
358
+ {/* Select Marking Card */}
359
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
360
+ <h3 className="font-bold text-[#0A2540] mb-4">Marking Type</h3>
361
+ <div className="relative">
362
+ <button
363
+ onClick={() => setIsDropdownOpen(!isDropdownOpen)}
364
+ className="w-full h-10 px-3 rounded-lg bg-white border-2 border-[#05998c] text-sm text-gray-800 flex items-center gap-2 hover:bg-gray-50 transition-colors justify-between"
365
+ >
366
+ <span className="truncate">{selectedType ? `${selectedType.code} - ${selectedType.name}` : 'Select Marking'}</span>
367
+ <span className="text-xs shrink-0">▼</span>
368
+ </button>
369
+
370
+ {isDropdownOpen && (
371
+ <div className="absolute top-full left-0 right-0 mt-2 w-full bg-white border-2 border-[#05998c] rounded-lg shadow-lg z-50">
372
+ <input
373
+ type="text"
374
+ placeholder="Search by code or name..."
375
+ value={searchQuery}
376
+ onChange={(e) => setSearchQuery(e.target.value)}
377
+ autoFocus
378
+ className="w-full px-3 py-2 border-b-2 border-[#05998c]/30 text-sm text-gray-800 placeholder-gray-500 focus:outline-none bg-[#05998c]/5"
379
+ />
380
+ <div className="max-h-64 overflow-auto">
381
+ {filteredLesionTypes.length > 0 ? (
382
+ filteredLesionTypes.map(type => (
383
+ <div
384
+ key={type.code}
385
+ className={`flex items-center gap-2 p-3 cursor-pointer transition-colors ${
386
+ selectedType?.code === type.code
387
+ ? 'bg-[#05998c]/20 border-l-4 border-[#05998c]'
388
+ : 'hover:bg-[#05998c]/10 border-l-4 border-transparent'
389
+ }`}
390
+ onClick={() => {
391
+ setSelectedType(type);
392
+ setIsDropdownOpen(false);
393
+ setSearchQuery('');
394
+ }}
395
+ >
396
+ <div className="w-3 h-3 rounded shrink-0" style={{ backgroundColor: type.color }} />
397
+ <div className="flex-1 min-w-0">
398
+ <p className="text-sm font-semibold text-gray-800">{type.code}</p>
399
+ <p className="text-xs text-gray-600 truncate">{type.name}</p>
400
+ </div>
401
+ </div>
402
+ ))
403
+ ) : (
404
+ <p className="text-xs text-gray-500 text-center py-4">No features match</p>
405
+ )}
406
+ </div>
407
+ </div>
408
+ )}
409
+ </div>
410
+ </div>
411
+
412
+ {/* Images Card */}
413
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
414
+ <div className="flex items-center justify-between mb-4">
415
+ <h3 className="font-bold text-[#0A2540]">Images</h3>
416
+ <Button variant="outline" size="sm" className="h-8 text-xs px-2 border-[#05998c] text-[#05998c] hover:bg-teal-50" onClick={handleUpload}>
417
+ <Upload className="h-3.5 w-3.5 mr-1" />
418
+ Upload
419
+ </Button>
420
+ <input
421
+ ref={fileInputRef}
422
+ type="file"
423
+ accept="image/*,video/*"
424
+ className="hidden"
425
+ onChange={handleFileChange}
426
+ />
427
+ </div>
428
+
429
+ <div className="space-y-3">
430
+ {Object.entries(groupedImages).map(([stepId, images]) => (
431
+ <div key={stepId}>
432
+ <p className="text-sm font-semibold text-gray-700 mb-2">
433
+ {stepLabels[stepId] || (stepId === 'uploaded' ? 'Uploaded' : stepId)}
434
+ </p>
435
+ <div className="flex gap-2 flex-wrap">
436
+ {images.map(img => (
437
+ <button
438
+ key={img.id}
439
+ onClick={() => setSelectedImage(img)}
440
+ className={`w-14 h-14 rounded-lg overflow-hidden border-2 transition-all ${
441
+ selectedImage?.id === img.id
442
+ ? 'border-[#05998c] ring-2 ring-teal-200'
443
+ : 'border-gray-200 hover:border-[#05998c]'
444
+ }`}
445
+ >
446
+ {img.type === 'video' ? (
447
+ <div className="w-full h-full bg-gray-800 flex items-center justify-center">
448
+ <Video className="h-6 w-6 text-[#05998c]" />
449
+ </div>
450
+ ) : (
451
+ <img src={img.src} alt="Thumbnail" className="w-full h-full object-cover" />
452
+ )}
453
+ </button>
454
+ ))}
455
+ </div>
456
+ </div>
457
+ ))}
458
+ {allImages.length === 0 && (
459
+ <p className="text-sm text-gray-500 text-center py-3">No images</p>
460
+ )}
461
+ </div>
462
+ </div>
463
+
464
+ {/* Overlay Controls Card */}
465
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
466
+ <h3 className="font-bold text-[#0A2540] mb-4">Overlay Controls</h3>
467
+
468
+ <div className="space-y-4">
469
+ <div>
470
+ <label className="text-sm font-semibold text-gray-700 block mb-2">Size: {overlayScale}%</label>
471
+ <div className="flex items-center gap-2">
472
+ <Button
473
+ variant="outline"
474
+ size="sm"
475
+ className="h-8 w-8 p-0 border-[#05998c] text-[#05998c] hover:bg-teal-50"
476
+ onClick={() => setOverlayScale(Math.max(30, overlayScale - 10))}
477
+ >
478
+ <ZoomOut className="h-4 w-4" />
479
+ </Button>
480
+ <input
481
+ type="range"
482
+ min="30"
483
+ max="200"
484
+ value={overlayScale}
485
+ onChange={e => setOverlayScale(Number(e.target.value))}
486
+ className="flex-1 h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer"
487
+ />
488
+ <Button
489
+ variant="outline"
490
+ size="sm"
491
+ className="h-8 w-8 p-0 border-[#05998c] text-[#05998c] hover:bg-teal-50"
492
+ onClick={() => setOverlayScale(Math.min(200, overlayScale + 10))}
493
+ >
494
+ <ZoomIn className="h-4 w-4" />
495
+ </Button>
496
+ </div>
497
+ </div>
498
+
499
+ <div>
500
+ <label className="text-sm font-semibold text-gray-700 block mb-2">Position X: {overlayOffset.x}</label>
501
+ <div className="flex items-center gap-2">
502
+ <Button variant="outline" size="sm" className="h-8 px-2 text-xs border-[#05998c] text-[#05998c] hover:bg-teal-50" onClick={() => setOverlayOffset(prev => ({ ...prev, x: prev.x - 20 }))}>←</Button>
503
+ <input
504
+ type="range"
505
+ min="-200"
506
+ max="200"
507
+ value={overlayOffset.x}
508
+ onChange={e => setOverlayOffset(prev => ({ ...prev, x: Number(e.target.value) }))}
509
+ className="flex-1 h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer"
510
+ />
511
+ <Button variant="outline" size="sm" className="h-8 px-2 text-xs border-[#05998c] text-[#05998c] hover:bg-teal-50" onClick={() => setOverlayOffset(prev => ({ ...prev, x: prev.x + 20 }))}>→</Button>
512
+ </div>
513
+ </div>
514
+
515
+ <div>
516
+ <label className="text-sm font-semibold text-gray-700 block mb-2">Position Y: {overlayOffset.y}</label>
517
+ <div className="flex items-center gap-2">
518
+ <Button variant="outline" size="sm" className="h-8 px-2 text-xs border-[#05998c] text-[#05998c] hover:bg-teal-50" onClick={() => setOverlayOffset(prev => ({ ...prev, y: prev.y - 20 }))}>↑</Button>
519
+ <input
520
+ type="range"
521
+ min="-200"
522
+ max="200"
523
+ value={overlayOffset.y}
524
+ onChange={e => setOverlayOffset(prev => ({ ...prev, y: Number(e.target.value) }))}
525
+ className="flex-1 h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer"
526
+ />
527
+ <Button variant="outline" size="sm" className="h-8 px-2 text-xs border-[#05998c] text-[#05998c] hover:bg-teal-50" onClick={() => setOverlayOffset(prev => ({ ...prev, y: prev.y + 20 }))}>↓</Button>
528
+ </div>
529
+ </div>
530
+
531
+ <Button
532
+ className="w-full bg-[#05998c] text-white hover:bg-[#047569] rounded-lg"
533
+ onClick={() => {
534
+ setOverlayOffset({ x: 0, y: 0 });
535
+ setOverlayScale(100);
536
+ }}
537
+ >
538
+ Reset Position
539
+ </Button>
540
+ </div>
541
+ </div>
542
+
543
+ {/* Coverage Card */}
544
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex-1 min-h-0 flex flex-col">
545
+ <h3 className="font-bold text-[#0A2540] mb-3">
546
+ Coverage: <span className="text-[#05998c] font-bold">{coveragePercent}%</span>
547
+ </h3>
548
+ {Object.keys(marksByType).length === 0 ? (
549
+ <p className="text-sm text-gray-500">No lesions marked</p>
550
+ ) : (
551
+ <div className="space-y-2 overflow-auto flex-1">
552
+ {Object.entries(marksByType).map(([code, data]) => (
553
+ <div
554
+ key={code}
555
+ className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 border border-gray-200"
556
+ >
557
+ <div className="w-3 h-3 rounded shrink-0" style={{ backgroundColor: data.color }} />
558
+ <div className="flex-1 min-w-0">
559
+ <p className="text-xs font-semibold text-gray-800">{code}</p>
560
+ <p className="text-xs text-gray-600">{data.hours.sort((a, b) => a - b).join(', ')}h</p>
561
+ </div>
562
+ <span className="text-xs font-medium text-gray-700">
563
+ {Math.round((data.hours.length / 12) * 100)}%
564
+ </span>
565
+ </div>
566
+ ))}
567
+ </div>
568
+ )}
569
+ </div>
570
+ </div>
571
+ </div>
572
+ </div>
573
+ );
574
+ };
575
+
576
+ export { BiopsyMarking };
577
+ export default BiopsyMarking;
578
+
src/pages/Compare.tsx ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
6
+ src: string;
7
+ stepId: string;
8
+ type: 'image' | 'video';
9
+ };
10
+
11
+ type CompareImage = {
12
+ id: string;
13
+ src: string;
14
+ stepId: string;
15
+ label: string;
16
+ };
17
+
18
+ type Props = {
19
+ onBack: () => void;
20
+ capturedImages: CapturedImage[];
21
+ };
22
+
23
+ const stepLabels: Record<string, string> = {
24
+ native: 'Native',
25
+ acetowhite: 'Acetic Acid',
26
+ greenFilter: 'Green Filter',
27
+ lugol: 'Lugol'
28
+ };
29
+
30
+ const stepColors: Record<string, string> = {
31
+ native: 'from-blue-500 to-blue-600',
32
+ acetowhite: 'from-purple-500 to-purple-600',
33
+ greenFilter: 'from-green-500 to-green-600',
34
+ lugol: 'from-amber-500 to-amber-600'
35
+ };
36
+
37
+ export function Compare({ onBack, capturedImages }: Props) {
38
+ const [leftImage, setLeftImage] = useState<CompareImage | null>(null);
39
+ const [rightImage, setRightImage] = useState<CompareImage | null>(null);
40
+ const [zoomLevel, setZoomLevel] = useState(1);
41
+ const [draggedImageData, setDraggedImageData] = useState<string | null>(null);
42
+ const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
43
+ const [isPanning, setIsPanning] = useState(false);
44
+ const [panStart, setPanStart] = useState({ x: 0, y: 0 });
45
+ const leftImageRef = useRef<HTMLDivElement>(null);
46
+ const rightImageRef = useRef<HTMLDivElement>(null);
47
+
48
+ // Group images by step
49
+ const imagesByStep = capturedImages.reduce((acc, img) => {
50
+ if (img.type === 'image') {
51
+ if (!acc[img.stepId]) acc[img.stepId] = [];
52
+ acc[img.stepId].push(img);
53
+ }
54
+ return acc;
55
+ }, {} as Record<string, CapturedImage[]>);
56
+
57
+ const handleImageDragStart = (e: React.DragEvent, image: CapturedImage, side: 'left' | 'right') => {
58
+ const data = JSON.stringify({ ...image, side });
59
+ e.dataTransfer.setData('application/compare-image', data);
60
+ e.dataTransfer.effectAllowed = 'copy';
61
+ setDraggedImageData(data);
62
+ };
63
+
64
+ const handleImageDragOver = (e: React.DragEvent) => {
65
+ e.preventDefault();
66
+ e.dataTransfer.dropEffect = 'copy';
67
+ };
68
+
69
+ const handleImageDrop = (e: React.DragEvent, side: 'left' | 'right') => {
70
+ e.preventDefault();
71
+ const data = e.dataTransfer.getData('application/compare-image');
72
+ if (data) {
73
+ try {
74
+ const image = JSON.parse(data);
75
+ const compareImage: CompareImage = {
76
+ id: image.id,
77
+ src: image.src,
78
+ stepId: image.stepId,
79
+ label: stepLabels[image.stepId] || image.stepId
80
+ };
81
+ if (side === 'left') {
82
+ setLeftImage(compareImage);
83
+ } else {
84
+ setRightImage(compareImage);
85
+ }
86
+ } catch {
87
+ // ignore invalid drag data
88
+ }
89
+ }
90
+ setDraggedImageData(null);
91
+ };
92
+
93
+ const handleDownloadComparison = () => {
94
+ if (!leftImage || !rightImage) return;
95
+
96
+ // Create a canvas to combine both images
97
+ const canvas = document.createElement('canvas');
98
+ const ctx = canvas.getContext('2d');
99
+ if (!ctx) return;
100
+
101
+ // Set canvas size
102
+ canvas.width = 1600;
103
+ canvas.height = 800;
104
+
105
+ // Load and draw images
106
+ const leftImg = new Image();
107
+ const rightImg = new Image();
108
+ let imagesLoaded = 0;
109
+
110
+ const drawComparison = () => {
111
+ // Draw left image
112
+ ctx.drawImage(leftImg, 0, 0, 800, 800);
113
+
114
+ // Draw right image
115
+ ctx.drawImage(rightImg, 800, 0, 800, 800);
116
+
117
+ // Add labels
118
+ ctx.fillStyle = 'white';
119
+ ctx.font = 'bold 28px Arial';
120
+ ctx.fillText(leftImage.label, 20, 750);
121
+ ctx.fillText(rightImage.label, 820, 750);
122
+
123
+ // Download
124
+ canvas.toBlob(blob => {
125
+ if (blob) {
126
+ const url = URL.createObjectURL(blob);
127
+ const a = document.createElement('a');
128
+ a.href = url;
129
+ a.download = `comparison_${Date.now()}.png`;
130
+ a.click();
131
+ URL.revokeObjectURL(url);
132
+ }
133
+ });
134
+ };
135
+
136
+ leftImg.onload = () => {
137
+ imagesLoaded++;
138
+ if (imagesLoaded === 2) drawComparison();
139
+ };
140
+ rightImg.onload = () => {
141
+ imagesLoaded++;
142
+ if (imagesLoaded === 2) drawComparison();
143
+ };
144
+
145
+ leftImg.src = leftImage.src;
146
+ rightImg.src = rightImage.src;
147
+ };
148
+
149
+ const handleMouseDown = (e: React.MouseEvent) => {
150
+ if (zoomLevel > 1) {
151
+ setIsPanning(true);
152
+ setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
153
+ }
154
+ };
155
+
156
+ const handleMouseMove = (e: React.MouseEvent) => {
157
+ if (isPanning && zoomLevel > 1) {
158
+ setPanOffset({
159
+ x: e.clientX - panStart.x,
160
+ y: e.clientY - panStart.y
161
+ });
162
+ }
163
+ };
164
+
165
+ const handleMouseUp = () => {
166
+ setIsPanning(false);
167
+ };
168
+
169
+ const handleWheel = (e: React.WheelEvent) => {
170
+ e.preventDefault();
171
+ const delta = e.deltaY > 0 ? -0.1 : 0.1;
172
+ setZoomLevel(prev => Math.max(0.5, Math.min(3, prev + delta)));
173
+ };
174
+
175
+ const handleReset = () => {
176
+ setZoomLevel(1);
177
+ setPanOffset({ x: 0, y: 0 });
178
+ };
179
+
180
+ return (
181
+ <div className="w-full bg-white">
182
+ <div className="py-4 md:py-6">
183
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6">
184
+
185
+ {/* Header */}
186
+ <div className="flex items-center justify-between mb-6">
187
+ <div className="flex items-center gap-4">
188
+ <button
189
+ onClick={onBack}
190
+ className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
191
+ >
192
+ <ArrowLeft className="w-5 h-5 text-gray-700" />
193
+ </button>
194
+ <h1 className="text-2xl font-bold text-gray-900">Image Comparison</h1>
195
+ </div>
196
+ <button
197
+ onClick={handleDownloadComparison}
198
+ disabled={!leftImage || !rightImage}
199
+ className="flex items-center gap-2 px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
200
+ >
201
+ <Download className="w-4 h-4" />
202
+ Download Comparison
203
+ </button>
204
+ </div>
205
+
206
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
207
+
208
+ {/* Left Comparison View */}
209
+ <div className="lg:col-span-2">
210
+ <div className="grid grid-cols-2 gap-6">
211
+ {/* Left Side */}
212
+ <div
213
+ onDragOver={handleImageDragOver}
214
+ onDrop={(e) => handleImageDrop(e, 'left')}
215
+ className={`rounded-lg border-4 border-dashed transition-all ${
216
+ draggedImageData ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50'
217
+ }`}
218
+ >
219
+ {leftImage ? (
220
+ <div
221
+ ref={leftImageRef}
222
+ className="relative h-full flex items-center justify-center bg-black rounded-lg overflow-hidden"
223
+ onMouseDown={handleMouseDown}
224
+ onMouseMove={handleMouseMove}
225
+ onMouseUp={handleMouseUp}
226
+ onMouseLeave={handleMouseUp}
227
+ onWheel={handleWheel}
228
+ style={{ cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
229
+ >
230
+ <img
231
+ src={leftImage.src}
232
+ alt={leftImage.label}
233
+ style={{
234
+ transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
235
+ transformOrigin: 'center center'
236
+ }}
237
+ className="w-full h-96 object-contain transition-transform select-none"
238
+ draggable={false}
239
+ />
240
+ <button
241
+ onClick={() => setLeftImage(null)}
242
+ className="absolute top-3 right-3 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors z-10"
243
+ >
244
+ <X className="w-4 h-4" />
245
+ </button>
246
+ <div className="absolute bottom-3 left-3 bg-black/70 text-white px-3 py-2 rounded-lg z-10">
247
+ <p className="text-sm font-semibold">{leftImage.label}</p>
248
+ </div>
249
+ </div>
250
+ ) : (
251
+ <div className="h-96 flex flex-col items-center justify-center text-gray-500">
252
+ <div className="text-center">
253
+ <p className="text-lg font-semibold mb-2">Left Image</p>
254
+ <p className="text-sm">Drag and drop an image here</p>
255
+ </div>
256
+ </div>
257
+ )}
258
+ </div>
259
+
260
+ {/* Right Side */}
261
+ <div
262
+ onDragOver={handleImageDragOver}
263
+ onDrop={(e) => handleImageDrop(e, 'right')}
264
+ className={`rounded-lg border-4 border-dashed transition-all ${
265
+ draggedImageData ? 'border-green-500 bg-green-50' : 'border-gray-300 bg-gray-50'
266
+ }`}
267
+ >
268
+ {rightImage ? (
269
+ <div
270
+ ref={rightImageRef}
271
+ className="relative h-full flex items-center justify-center bg-black rounded-lg overflow-hidden"
272
+ onMouseDown={handleMouseDown}
273
+ onMouseMove={handleMouseMove}
274
+ onMouseUp={handleMouseUp}
275
+ onMouseLeave={handleMouseUp}
276
+ onWheel={handleWheel}
277
+ style={{ cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
278
+ >
279
+ <img
280
+ src={rightImage.src}
281
+ alt={rightImage.label}
282
+ style={{
283
+ transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
284
+ transformOrigin: 'center center'
285
+ }}
286
+ className="w-full h-96 object-contain transition-transform select-none"
287
+ draggable={false}
288
+ />
289
+ <button
290
+ onClick={() => setRightImage(null)}
291
+ className="absolute top-3 right-3 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors z-10"
292
+ >
293
+ <X className="w-4 h-4" />
294
+ </button>
295
+ <div className="absolute bottom-3 left-3 bg-black/70 text-white px-3 py-2 rounded-lg z-10">
296
+ <p className="text-sm font-semibold">{rightImage.label}</p>
297
+ </div>
298
+ </div>
299
+ ) : (
300
+ <div className="h-96 flex flex-col items-center justify-center text-gray-500">
301
+ <div className="text-center">
302
+ <p className="text-lg font-semibold mb-2">Right Image</p>
303
+ <p className="text-sm">Drag and drop an image here</p>
304
+ </div>
305
+ </div>
306
+ )}
307
+ </div>
308
+ </div>
309
+
310
+ {/* Zoom Controls */}
311
+ {(leftImage || rightImage) && (
312
+ <div className="mt-6 flex items-center justify-center gap-4 bg-gray-50 p-4 rounded-lg">
313
+ <button
314
+ onClick={() => setZoomLevel(prev => Math.max(0.5, prev - 0.1))}
315
+ className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
316
+ title="Zoom Out"
317
+ >
318
+ <ZoomOut className="w-5 h-5 text-gray-700" />
319
+ </button>
320
+ <span className="text-sm font-semibold text-gray-700 w-16 text-center">
321
+ {Math.round(zoomLevel * 100)}%
322
+ </span>
323
+ <button
324
+ onClick={() => setZoomLevel(prev => Math.min(3, prev + 0.1))}
325
+ className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
326
+ title="Zoom In"
327
+ >
328
+ <ZoomIn className="w-5 h-5 text-gray-700" />
329
+ </button>
330
+ <div className="flex-1 ml-4">
331
+ <input
332
+ type="range"
333
+ min="0.5"
334
+ max="3"
335
+ step="0.1"
336
+ value={zoomLevel}
337
+ onChange={(e) => setZoomLevel(parseFloat(e.target.value))}
338
+ className="w-full"
339
+ />
340
+ </div>
341
+ <button
342
+ onClick={handleReset}
343
+ className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
344
+ title="Reset Zoom & Pan"
345
+ >
346
+ <RotateCcw className="w-5 h-5 text-gray-700" />
347
+ </button>
348
+ </div>
349
+ )}
350
+
351
+ {/* Instructions */}
352
+ {(leftImage || rightImage) && zoomLevel > 1 && (
353
+ <div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
354
+ <p className="text-xs text-blue-700">
355
+ 🖱️ <strong>Click and drag</strong> to pan the image | <strong>Scroll</strong> to zoom | Use controls to fine-tune
356
+ </p>
357
+ </div>
358
+ )}
359
+ </div>
360
+
361
+ {/* Image Library Sidebar */}
362
+ <div className="lg:col-span-1">
363
+ <div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4 sticky top-20">
364
+ <h2 className="font-bold text-gray-900 mb-4 pb-2 border-b border-gray-300">
365
+ Image Library
366
+ </h2>
367
+
368
+ {Object.entries(imagesByStep).length === 0 ? (
369
+ <div className="text-center py-8 text-gray-400">
370
+ <p className="text-sm font-medium">No images available</p>
371
+ <p className="text-xs mt-1">Capture images to compare</p>
372
+ </div>
373
+ ) : (
374
+ <div className="space-y-4 max-h-[600px] overflow-y-auto">
375
+ {Object.entries(imagesByStep).map(([stepId, images]) => (
376
+ <div key={stepId} className={`bg-gradient-to-br ${stepColors[stepId] || 'from-gray-500 to-gray-600'} rounded-lg overflow-hidden shadow-sm`}>
377
+ <div className="bg-black/30 px-3 py-2">
378
+ <p className="text-white font-bold text-sm">{stepLabels[stepId] || stepId}</p>
379
+ </div>
380
+ <div className="p-3 space-y-2 bg-white">
381
+ {images.map((image, idx) => (
382
+ <div
383
+ key={image.id}
384
+ draggable
385
+ onDragStart={(e) => handleImageDragStart(e, image, 'left')}
386
+ className="relative group cursor-grab hover:cursor-grabbing"
387
+ >
388
+ <div className="bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-200 hover:border-[#05998c] transition-colors">
389
+ <img
390
+ src={image.src}
391
+ alt={`${stepLabels[stepId]} ${idx + 1}`}
392
+ className="w-full h-20 object-cover group-hover:opacity-80 transition-opacity"
393
+ />
394
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
395
+ <p className="text-white text-xs font-semibold">Drag to compare</p>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ ))}
400
+ </div>
401
+ </div>
402
+ ))}
403
+ </div>
404
+ )}
405
+
406
+ {/* Info Box */}
407
+ <div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
408
+ <p className="text-xs text-blue-700">
409
+ 💡 Drag images from the library to left or right side to compare them side by side.
410
+ </p>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ </div>
418
+ );
419
+ }
src/pages/ExaminationRecordsPage.tsx ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { Calendar, User, Eye, FileText, Clock, Camera, ArrowLeft, Search, Filter } from 'lucide-react';
3
+
4
+ // Types for examination records
5
+ interface ExamRecord {
6
+ id: string;
7
+ date: Date;
8
+ indication: string;
9
+ impression: string;
10
+ tzType: string;
11
+ biopsy: 'Taken' | 'Not Taken';
12
+ finalStatus: string;
13
+ performedBy?: string;
14
+ scjVisibility: 'Fully' | 'Partially' | 'Not visible';
15
+ media: MediaItem[];
16
+ findings: Findings;
17
+ outcome: Outcome;
18
+ }
19
+
20
+ interface MediaItem {
21
+ id: string;
22
+ step: string;
23
+ timestamp: Date;
24
+ thumbnail: string;
25
+ type: 'image' | 'video';
26
+ }
27
+
28
+ interface Findings {
29
+ acetowhite: string;
30
+ borders: string;
31
+ vascularPattern: string;
32
+ lugolsUptake: string;
33
+ }
34
+
35
+ interface Outcome {
36
+ impression: string;
37
+ biopsyTaken: boolean;
38
+ plan: string;
39
+ }
40
+
41
+ // Dummy data
42
+ const dummyExams: ExamRecord[] = [
43
+ {
44
+ id: 'COLPO-2025-001',
45
+ date: new Date('2025-01-10'),
46
+ indication: 'VIA positive',
47
+ impression: 'Low-grade lesion',
48
+ tzType: 'Type 2',
49
+ biopsy: 'Taken',
50
+ finalStatus: 'Follow-up advised',
51
+ performedBy: 'Dr. Sarah Johnson',
52
+ scjVisibility: 'Fully',
53
+ media: [
54
+ { id: '1', step: 'Native', timestamp: new Date('2025-01-10T09:00:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
55
+ { id: '2', step: 'Acetic Acid (1 min)', timestamp: new Date('2025-01-10T09:01:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
56
+ { id: '3', step: 'Acetic Acid (3 min)', timestamp: new Date('2025-01-10T09:03:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
57
+ { id: '4', step: 'Green Filter', timestamp: new Date('2025-01-10T09:05:00'), thumbnail: '/greenC87Aceto_(1).jpg', type: 'image' },
58
+ { id: '5', step: 'Lugol\'s Iodine', timestamp: new Date('2025-01-10T09:07:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
59
+ { id: '6', step: 'Biopsy Marking', timestamp: new Date('2025-01-10T09:10:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' }
60
+ ],
61
+ findings: {
62
+ acetowhite: 'Thin, rapidly fading',
63
+ borders: 'Irregular',
64
+ vascularPattern: 'Fine punctation',
65
+ lugolsUptake: 'Partial'
66
+ },
67
+ outcome: {
68
+ impression: 'Low-grade squamous intraepithelial lesion (LSIL)',
69
+ biopsyTaken: true,
70
+ plan: 'Follow-up in 6 months with repeat colposcopy'
71
+ }
72
+ },
73
+ {
74
+ id: 'COLPO-2025-002',
75
+ date: new Date('2025-02-15'),
76
+ indication: 'Follow-up',
77
+ impression: 'Normal',
78
+ tzType: 'Type 1',
79
+ biopsy: 'Not Taken',
80
+ finalStatus: 'Discharged',
81
+ performedBy: 'Dr. Sarah Johnson',
82
+ scjVisibility: 'Fully',
83
+ media: [
84
+ { id: '7', step: 'Native', timestamp: new Date('2025-02-15T10:30:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
85
+ { id: '8', step: 'Acetic Acid (1 min)', timestamp: new Date('2025-02-15T10:31:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
86
+ { id: '9', step: 'Acetic Acid (3 min)', timestamp: new Date('2025-02-15T10:33:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' }
87
+ ],
88
+ findings: {
89
+ acetowhite: 'None',
90
+ borders: 'Regular',
91
+ vascularPattern: 'Normal',
92
+ lugolsUptake: 'Complete'
93
+ },
94
+ outcome: {
95
+ impression: 'Normal colposcopy',
96
+ biopsyTaken: false,
97
+ plan: 'Return to routine screening'
98
+ }
99
+ },
100
+ {
101
+ id: 'COLPO-2025-003',
102
+ date: new Date('2025-03-20'),
103
+ indication: 'Abnormal cytology',
104
+ impression: 'High-grade lesion',
105
+ tzType: 'Type 3',
106
+ biopsy: 'Taken',
107
+ finalStatus: 'Referred to oncology',
108
+ performedBy: 'Dr. Sarah Johnson',
109
+ scjVisibility: 'Partially',
110
+ media: [
111
+ { id: '10', step: 'Native', timestamp: new Date('2025-03-20T11:00:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
112
+ { id: '11', step: 'Acetic Acid (1 min)', timestamp: new Date('2025-03-20T11:01:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
113
+ { id: '12', step: 'Acetic Acid (3 min)', timestamp: new Date('2025-03-20T11:03:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
114
+ { id: '13', step: 'Green Filter', timestamp: new Date('2025-03-20T11:05:00'), thumbnail: '/greenC87Aceto_(1).jpg', type: 'image' },
115
+ { id: '14', step: 'Lugol\'s Iodine', timestamp: new Date('2025-03-20T11:07:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' },
116
+ { id: '15', step: 'Biopsy Marking', timestamp: new Date('2025-03-20T11:10:00'), thumbnail: '/C87Aceto_(1).jpg', type: 'image' }
117
+ ],
118
+ findings: {
119
+ acetowhite: 'Dense, persistent',
120
+ borders: 'Irregular, jagged',
121
+ vascularPattern: 'Coarse mosaicism',
122
+ lugolsUptake: 'Negative staining'
123
+ },
124
+ outcome: {
125
+ impression: 'High-grade squamous intraepithelial lesion (HSIL)',
126
+ biopsyTaken: true,
127
+ plan: 'Referred to gynecologic oncology for further management'
128
+ }
129
+ }
130
+ ];
131
+
132
+ type Props = {
133
+ goBack: () => void;
134
+ };
135
+
136
+ export function ExaminationRecordsPage({ goBack }: Props) {
137
+ const [selectedExam, setSelectedExam] = useState<ExamRecord | null>(null);
138
+ const [searchTerm, setSearchTerm] = useState('');
139
+ const [filterStatus, setFilterStatus] = useState<string>('all');
140
+
141
+ const filteredExams = dummyExams.filter(exam => {
142
+ const matchesSearch = exam.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
143
+ exam.indication.toLowerCase().includes(searchTerm.toLowerCase()) ||
144
+ exam.impression.toLowerCase().includes(searchTerm.toLowerCase());
145
+
146
+ const matchesFilter = filterStatus === 'all' || exam.finalStatus.toLowerCase().includes(filterStatus.toLowerCase());
147
+
148
+ return matchesSearch && matchesFilter;
149
+ });
150
+
151
+ const formatDate = (date: Date) => {
152
+ return date.toLocaleDateString('en-US', {
153
+ year: 'numeric',
154
+ month: 'short',
155
+ day: 'numeric'
156
+ });
157
+ };
158
+
159
+ const formatTime = (date: Date) => {
160
+ return date.toLocaleTimeString('en-US', {
161
+ hour: '2-digit',
162
+ minute: '2-digit'
163
+ });
164
+ };
165
+
166
+ if (selectedExam) {
167
+ return (
168
+ <div className="w-full bg-white/95 relative">
169
+ <div className="relative z-10 py-4 md:py-6 lg:py-8">
170
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6">
171
+
172
+ {/* Header */}
173
+ <div className="mb-6 flex items-center gap-4">
174
+ <button
175
+ onClick={() => setSelectedExam(null)}
176
+ className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
177
+ >
178
+ <ArrowLeft className="w-5 h-5" />
179
+ </button>
180
+ <div>
181
+ <h1 className="text-2xl md:text-3xl font-bold text-[#0A2540]">
182
+ Examination Record: {selectedExam.id}
183
+ </h1>
184
+ <p className="text-gray-600 mt-1">
185
+ {formatDate(selectedExam.date)} • Patient ID: PT-2025-8492
186
+ </p>
187
+ </div>
188
+ </div>
189
+
190
+ <div className="space-y-6">
191
+
192
+ {/* Examination Summary */}
193
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
194
+ <h2 className="text-xl font-bold text-[#0A2540] mb-4 flex items-center gap-2">
195
+ <FileText className="w-5 h-5 text-[#05998c]" />
196
+ Examination Summary
197
+ </h2>
198
+
199
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
200
+ <div className="space-y-3">
201
+ <div className="flex items-center gap-2 text-sm">
202
+ <Calendar className="w-4 h-4 text-gray-500" />
203
+ <span className="font-medium text-gray-700">Date & Time:</span>
204
+ <span>{formatDate(selectedExam.date)} at {formatTime(selectedExam.date)}</span>
205
+ </div>
206
+ {selectedExam.performedBy && (
207
+ <div className="flex items-center gap-2 text-sm">
208
+ <User className="w-4 h-4 text-gray-500" />
209
+ <span className="font-medium text-gray-700">Performed by:</span>
210
+ <span>{selectedExam.performedBy}</span>
211
+ </div>
212
+ )}
213
+ <div className="flex items-center gap-2 text-sm">
214
+ <span className="font-medium text-gray-700">Indication:</span>
215
+ <span>{selectedExam.indication}</span>
216
+ </div>
217
+ </div>
218
+
219
+ <div className="space-y-3">
220
+ <div className="flex items-center gap-2 text-sm">
221
+ <span className="font-medium text-gray-700">SCJ Visibility:</span>
222
+ <span>{selectedExam.scjVisibility}</span>
223
+ </div>
224
+ <div className="flex items-center gap-2 text-sm">
225
+ <span className="font-medium text-gray-700">TZ Type:</span>
226
+ <span>{selectedExam.tzType}</span>
227
+ </div>
228
+ </div>
229
+
230
+ <div className="space-y-3">
231
+ <div className="flex items-center gap-2 text-sm">
232
+ <span className="font-medium text-gray-700">Impression:</span>
233
+ <span className="font-semibold text-[#05998c]">{selectedExam.impression}</span>
234
+ </div>
235
+ <div className="flex items-center gap-2 text-sm">
236
+ <span className="font-medium text-gray-700">Biopsy:</span>
237
+ <span className={selectedExam.biopsy === 'Taken' ? 'text-red-600 font-semibold' : 'text-green-600'}>
238
+ {selectedExam.biopsy}
239
+ </span>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ {/* Media Timeline */}
246
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
247
+ <h2 className="text-xl font-bold text-[#0A2540] mb-4 flex items-center gap-2">
248
+ <Camera className="w-5 h-5 text-[#05998c]" />
249
+ Media Timeline
250
+ </h2>
251
+
252
+ <div className="space-y-4">
253
+ {selectedExam.media.map((item, index) => (
254
+ <div key={item.id} className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
255
+ <div className="relative">
256
+ <img
257
+ src={item.thumbnail}
258
+ alt={item.step}
259
+ className="w-16 h-16 object-cover rounded-lg border-2 border-gray-200"
260
+ />
261
+ <div className="absolute -top-2 -right-2 bg-[#05998c] text-white text-xs px-2 py-1 rounded-full font-semibold">
262
+ {index + 1}
263
+ </div>
264
+ </div>
265
+
266
+ <div className="flex-1">
267
+ <h3 className="font-semibold text-[#0A2540]">{item.step}</h3>
268
+ <div className="flex items-center gap-2 text-sm text-gray-600 mt-1">
269
+ <Clock className="w-4 h-4" />
270
+ <span>{formatTime(item.timestamp)}</span>
271
+ <span className="text-gray-400">•</span>
272
+ <span className="capitalize">{item.type}</span>
273
+ </div>
274
+ </div>
275
+
276
+ <button className="px-4 py-2 bg-[#05998c] text-white rounded-lg hover:bg-[#047569] transition-colors flex items-center gap-2">
277
+ <Eye className="w-4 h-4" />
278
+ View
279
+ </button>
280
+ </div>
281
+ ))}
282
+ </div>
283
+ </div>
284
+
285
+ {/* Key Findings */}
286
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
287
+ <h2 className="text-xl font-bold text-[#0A2540] mb-4">Key Findings</h2>
288
+
289
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
290
+ <div className="space-y-3">
291
+ <div className="flex justify-between items-center py-2 border-b border-gray-100">
292
+ <span className="font-medium text-gray-700">Acetowhite:</span>
293
+ <span className="text-gray-900">{selectedExam.findings.acetowhite}</span>
294
+ </div>
295
+ <div className="flex justify-between items-center py-2 border-b border-gray-100">
296
+ <span className="font-medium text-gray-700">Borders:</span>
297
+ <span className="text-gray-900">{selectedExam.findings.borders}</span>
298
+ </div>
299
+ </div>
300
+
301
+ <div className="space-y-3">
302
+ <div className="flex justify-between items-center py-2 border-b border-gray-100">
303
+ <span className="font-medium text-gray-700">Vascular Pattern:</span>
304
+ <span className="text-gray-900">{selectedExam.findings.vascularPattern}</span>
305
+ </div>
306
+ <div className="flex justify-between items-center py-2 border-b border-gray-100">
307
+ <span className="font-medium text-gray-700">Lugol's Uptake:</span>
308
+ <span className="text-gray-900">{selectedExam.findings.lugolsUptake}</span>
309
+ </div>
310
+ </div>
311
+ </div>
312
+ </div>
313
+
314
+ {/* Outcome & Plan */}
315
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
316
+ <h2 className="text-xl font-bold text-[#0A2540] mb-4">Outcome & Plan</h2>
317
+
318
+ <div className="space-y-4">
319
+ <div>
320
+ <h3 className="font-semibold text-gray-700 mb-2">Colposcopic Impression</h3>
321
+ <p className="text-[#0A2540] font-medium">{selectedExam.outcome.impression}</p>
322
+ </div>
323
+
324
+ <div>
325
+ <h3 className="font-semibold text-gray-700 mb-2">Biopsy Status</h3>
326
+ <p className={`font-medium ${selectedExam.outcome.biopsyTaken ? 'text-red-600' : 'text-green-600'}`}>
327
+ {selectedExam.outcome.biopsyTaken ? 'Biopsy taken' : 'No biopsy taken'}
328
+ </p>
329
+ </div>
330
+
331
+ <div>
332
+ <h3 className="font-semibold text-gray-700 mb-2">Management Plan</h3>
333
+ <p className="text-gray-900 bg-gray-50 p-3 rounded-lg">{selectedExam.outcome.plan}</p>
334
+ </div>
335
+ </div>
336
+ </div>
337
+
338
+ </div>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ );
343
+ }
344
+
345
+ return (
346
+ <div className="w-full bg-white/95 relative">
347
+ <div className="relative z-10 py-4 md:py-6 lg:py-8">
348
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6">
349
+
350
+ {/* Header */}
351
+ <div className="mb-6 flex items-center justify-between">
352
+ <div className="flex items-center gap-4">
353
+ <button
354
+ onClick={goBack}
355
+ className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
356
+ >
357
+ <ArrowLeft className="w-5 h-5" />
358
+ </button>
359
+ <div>
360
+ <h1 className="text-2xl md:text-3xl font-bold text-[#0A2540]">Examination Records</h1>
361
+ <p className="text-gray-600 mt-1">Patient ID: PT-2025-8492 • {dummyExams.length} examinations</p>
362
+ </div>
363
+ </div>
364
+
365
+ {/* Search and Filter */}
366
+ <div className="flex items-center gap-4">
367
+ <div className="relative">
368
+ <Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
369
+ <input
370
+ type="text"
371
+ placeholder="Search exams..."
372
+ value={searchTerm}
373
+ onChange={(e) => setSearchTerm(e.target.value)}
374
+ className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent"
375
+ />
376
+ </div>
377
+
378
+ <div className="relative">
379
+ <Filter className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
380
+ <select
381
+ value={filterStatus}
382
+ onChange={(e) => setFilterStatus(e.target.value)}
383
+ className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent"
384
+ >
385
+ <option value="all">All Status</option>
386
+ <option value="follow-up">Follow-up</option>
387
+ <option value="discharged">Discharged</option>
388
+ <option value="referred">Referred</option>
389
+ </select>
390
+ </div>
391
+ </div>
392
+ </div>
393
+
394
+ {/* Examination List */}
395
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
396
+ <div className="overflow-x-auto">
397
+ <table className="w-full">
398
+ <thead className="bg-gray-50 border-b border-gray-200">
399
+ <tr>
400
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Exam ID</th>
401
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Date</th>
402
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Indication</th>
403
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Impression</th>
404
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">TZ Type</th>
405
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Biopsy</th>
406
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Status</th>
407
+ <th className="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
408
+ </tr>
409
+ </thead>
410
+ <tbody className="divide-y divide-gray-200">
411
+ {filteredExams.map((exam) => (
412
+ <tr key={exam.id} className="hover:bg-gray-50">
413
+ <td className="px-6 py-4 whitespace-nowrap">
414
+ <div className="font-mono font-semibold text-[#0A2540]">{exam.id}</div>
415
+ </td>
416
+ <td className="px-6 py-4 whitespace-nowrap">
417
+ <div className="text-sm text-gray-900">{formatDate(exam.date)}</div>
418
+ </td>
419
+ <td className="px-6 py-4 whitespace-nowrap">
420
+ <div className="text-sm text-gray-900">{exam.indication}</div>
421
+ </td>
422
+ <td className="px-6 py-4 whitespace-nowrap">
423
+ <div className="text-sm text-gray-900">{exam.impression}</div>
424
+ </td>
425
+ <td className="px-6 py-4 whitespace-nowrap">
426
+ <div className="text-sm text-gray-900">{exam.tzType}</div>
427
+ </td>
428
+ <td className="px-6 py-4 whitespace-nowrap">
429
+ <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
430
+ exam.biopsy === 'Taken'
431
+ ? 'bg-red-100 text-red-800'
432
+ : 'bg-green-100 text-green-800'
433
+ }`}>
434
+ {exam.biopsy}
435
+ </span>
436
+ </td>
437
+ <td className="px-6 py-4 whitespace-nowrap">
438
+ <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
439
+ exam.finalStatus.includes('Follow-up')
440
+ ? 'bg-yellow-100 text-yellow-800'
441
+ : exam.finalStatus.includes('Discharged')
442
+ ? 'bg-green-100 text-green-800'
443
+ : 'bg-red-100 text-red-800'
444
+ }`}>
445
+ {exam.finalStatus}
446
+ </span>
447
+ </td>
448
+ <td className="px-6 py-4 whitespace-nowrap">
449
+ <button
450
+ onClick={() => setSelectedExam(exam)}
451
+ className="text-[#05998c] hover:text-[#047569] font-medium text-sm flex items-center gap-1"
452
+ >
453
+ <Eye className="w-4 h-4" />
454
+ View
455
+ </button>
456
+ </td>
457
+ </tr>
458
+ ))}
459
+ </tbody>
460
+ </table>
461
+ </div>
462
+
463
+ {filteredExams.length === 0 && (
464
+ <div className="text-center py-12">
465
+ <FileText className="w-12 h-12 text-gray-300 mx-auto mb-4" />
466
+ <p className="text-gray-500">No examination records found</p>
467
+ </div>
468
+ )}
469
+ </div>
470
+
471
+ </div>
472
+ </div>
473
+ </div>
474
+ );
475
+ }
src/pages/GreenFilterPage.tsx ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { Camera, Video, ArrowLeft, ArrowRight, Info, X, Save, ChevronRight } from 'lucide-react';
3
+ import { ImageAnnotator } from '../components/ImageAnnotator';
4
+ import { ImagingObservations } from '../components/ImagingObservations';
5
+
6
+ type CapturedItem = {
7
+ id: string;
8
+ type: 'image' | 'video';
9
+ url: string;
10
+ timestamp: Date;
11
+ annotations?: any[];
12
+ };
13
+
14
+ type Props = {
15
+ goBack: () => void;
16
+ onNext: () => void;
17
+ };
18
+
19
+ export function GreenFilterPage({ goBack, onNext }: Props) {
20
+ const [capturedItems, setCapturedItems] = useState<CapturedItem[]>([]);
21
+ const [isRecording, setIsRecording] = useState(false);
22
+ const [selectedImage, setSelectedImage] = useState<string | null>(null);
23
+ const [showExitWarning, setShowExitWarning] = useState(false);
24
+ const [greenApplied, setGreenApplied] = useState(false);
25
+ const baseImageUrl = "/C87Aceto_(1).jpg";
26
+ const greenImageUrl = "/greenC87Aceto_(1).jpg";
27
+ const liveFeedImage = greenApplied ? greenImageUrl : baseImageUrl;
28
+
29
+ const handleCaptureImage = () => {
30
+ const newCapture: CapturedItem = {
31
+ id: Date.now().toString(),
32
+ type: 'image',
33
+ url: liveFeedImage,
34
+ timestamp: new Date()
35
+ };
36
+ setCapturedItems(prev => [...prev, newCapture]);
37
+ };
38
+
39
+ const handleToggleRecording = () => {
40
+ if (!isRecording) {
41
+ setIsRecording(true);
42
+ } else {
43
+ setIsRecording(false);
44
+ const newCapture: CapturedItem = {
45
+ id: Date.now().toString(),
46
+ type: 'video',
47
+ url: liveFeedImage,
48
+ timestamp: new Date()
49
+ };
50
+ setCapturedItems(prev => [...prev, newCapture]);
51
+ }
52
+ };
53
+
54
+ const handleSelectImage = (item: CapturedItem) => {
55
+ setSelectedImage(item.url);
56
+ };
57
+
58
+ const handleAnnotationsChange = (newAnnotations: any[]) => {
59
+ if (selectedImage) {
60
+ setCapturedItems(prev => prev.map(item =>
61
+ item.url === selectedImage ? { ...item, annotations: newAnnotations } : item
62
+ ));
63
+ }
64
+ };
65
+
66
+ const handleDeleteImage = (id: string) => {
67
+ setCapturedItems(prev => prev.filter(item => item.id !== id));
68
+ if (selectedImage === capturedItems.find(item => item.id === id)?.url) {
69
+ setSelectedImage(null);
70
+ }
71
+ };
72
+
73
+ const handleConfirmExit = () => {
74
+ if (capturedItems.length > 0) {
75
+ setShowExitWarning(true);
76
+ } else {
77
+ goBack();
78
+ }
79
+ };
80
+
81
+ return (
82
+ <div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50 p-4 md:p-6">
83
+ {/* Header */}
84
+ <div className="mb-6 flex items-center justify-between">
85
+ <button
86
+ onClick={handleConfirmExit}
87
+ className="flex items-center gap-2 px-4 py-2 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border border-gray-200"
88
+ >
89
+ <ArrowLeft className="w-5 h-5 text-gray-600" />
90
+ <span className="font-medium text-gray-700">Back</span>
91
+ </button>
92
+
93
+ <div className="flex items-center gap-3">
94
+ <div className="flex items-center gap-2 bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
95
+ <div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
96
+ <span className="font-semibold text-gray-800">Green Filter Exam</span>
97
+ </div>
98
+ <button onClick={onNext} className="px-6 md:px-8 py-2 md:py-3 rounded-xl bg-gray-600 text-white font-bold shadow-lg shadow-gray-500/20 hover:bg-slate-700 hover:shadow-gray-500/30 transition-all flex items-center justify-center gap-2 text-sm md:text-base">
99
+ <Save className="w-4 h-4 md:w-5 md:h-5" />
100
+ <span className="hidden lg:inline">Next</span>
101
+ <span className="inline lg:hidden">Next</span>
102
+ <ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
103
+ </button>
104
+ </div>
105
+ </div>
106
+
107
+ {/* Main Content Grid */}
108
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
109
+
110
+ {/* Left Column: Live Feed / Selected Image */}
111
+ <div className="lg:col-span-2">
112
+ <div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
113
+ <div className="flex items-center justify-between mb-4">
114
+ <h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
115
+ <Info className="w-5 h-5 text-[#05998c]" />
116
+ {selectedImage ? 'Selected Image' : 'Live Feed'}
117
+ </h2>
118
+ <div className="flex items-center gap-2">
119
+ {selectedImage && (
120
+ <button
121
+ onClick={() => setSelectedImage(null)}
122
+ className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
123
+ >
124
+ <X className="w-4 h-4" />
125
+ <span className="text-sm font-medium">Clear Selection</span>
126
+ </button>
127
+ )}
128
+ </div>
129
+ </div>
130
+
131
+ {/* Image Display */}
132
+ <div className="relative bg-gray-900 rounded-xl overflow-hidden mb-6 border-2 border-gray-700">
133
+ <img
134
+ src={selectedImage || liveFeedImage}
135
+ alt="Cervix examination"
136
+ className="w-full h-auto"
137
+ />
138
+ </div>
139
+
140
+ {/* Capture Controls */}
141
+ {!selectedImage && (
142
+ <div className="space-y-3">
143
+ <div className="flex flex-wrap items-center gap-3">
144
+ <button
145
+ onClick={handleCaptureImage}
146
+ className="flex items-center gap-2 px-6 py-3 bg-[#05998c] text-white rounded-lg hover:bg-[#048a7d] transition-colors shadow-md font-semibold"
147
+ >
148
+ <Camera className="w-5 h-5" />
149
+ Capture Image
150
+ </button>
151
+
152
+ <button
153
+ onClick={handleToggleRecording}
154
+ className={`flex items-center gap-2 px-6 py-3 rounded-lg transition-colors shadow-md font-semibold ${
155
+ isRecording
156
+ ? 'bg-red-600 hover:bg-red-700 text-white'
157
+ : 'bg-white hover:bg-gray-50 text-gray-700 border border-gray-300'
158
+ }`}
159
+ >
160
+ {isRecording ? (
161
+ <>
162
+ <div className="w-3 h-3 bg-white rounded-sm"></div>
163
+ Stop Recording
164
+ </>
165
+ ) : (
166
+ <>
167
+ <Video className="w-5 h-5" />
168
+ Record Video
169
+ </>
170
+ )}
171
+ </button>
172
+ </div>
173
+ <button className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-600 text-white rounded-lg font-semibold hover:bg-slate-700 transition-colors">
174
+ Next
175
+ <ArrowRight className="w-4 h-4" />
176
+ </button>
177
+ </div>
178
+ )}
179
+
180
+ {/* Selected Image Tools */}
181
+ {selectedImage && (
182
+ <div className="space-y-4">
183
+ <div className="flex justify-end">
184
+ <button 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">
185
+ Next
186
+ <ArrowRight className="w-4 h-4" />
187
+ </button>
188
+ </div>
189
+ <ImageAnnotator
190
+ imageUrl={selectedImage}
191
+ onAnnotationsChange={handleAnnotationsChange}
192
+ />
193
+ <ImagingObservations />
194
+ </div>
195
+ )}
196
+ </div>
197
+ </div>
198
+
199
+ {/* Right Column: Captured Items */}
200
+ <div className="space-y-6">
201
+ <div className="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
202
+ {/* Green Filter Toggle */}
203
+ {!selectedImage && (
204
+ <div className="mb-6 flex items-center justify-between gap-4">
205
+ <div className="flex-1 bg-[#05998c] text-white px-6 py-2 rounded-lg">
206
+ <span className="font-bold">Green Filter</span>
207
+ </div>
208
+ <button
209
+ onClick={() => setGreenApplied(prev => !prev)}
210
+ className={`relative inline-flex h-8 w-16 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
211
+ greenApplied ? 'bg-blue-500' : 'bg-gray-300'
212
+ }`}
213
+ >
214
+ <span
215
+ className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform ${
216
+ greenApplied ? 'translate-x-9' : 'translate-x-1'
217
+ }`}
218
+ />
219
+ <span className={`absolute text-xs font-semibold ${
220
+ greenApplied ? 'left-2 text-white' : 'right-2 text-gray-600'
221
+ }`}>
222
+ {greenApplied ? 'ON' : 'OFF'}
223
+ </span>
224
+ </button>
225
+ </div>
226
+ )}
227
+
228
+ <h3 className="text-lg font-bold text-gray-800 mb-4">
229
+ Captured Items ({capturedItems.length})
230
+ </h3>
231
+
232
+ {capturedItems.length === 0 ? (
233
+ <p className="text-gray-500 text-center py-8">No items captured yet</p>
234
+ ) : (
235
+ <div className="space-y-3 max-h-96 overflow-y-auto">
236
+ {capturedItems.map((item) => (
237
+ <div
238
+ key={item.id}
239
+ className={`relative group border-2 rounded-lg overflow-hidden cursor-pointer transition-all ${
240
+ selectedImage === item.url
241
+ ? 'border-[#05998c] ring-2 ring-[#05998c]/50'
242
+ : 'border-gray-200 hover:border-[#05998c]'
243
+ }`}
244
+ onClick={() => handleSelectImage(item)}
245
+ >
246
+ <img
247
+ src={item.url}
248
+ alt={`Captured ${item.type}`}
249
+ className="w-full h-32 object-cover"
250
+ />
251
+ <div className="absolute top-2 left-2">
252
+ <span className={`px-2 py-1 rounded text-xs font-semibold ${
253
+ item.type === 'image'
254
+ ? 'bg-blue-500 text-white'
255
+ : 'bg-purple-500 text-white'
256
+ }`}>
257
+ {item.type === 'image' ? 'Image' : 'Video'}
258
+ </span>
259
+ </div>
260
+ <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
261
+ <button
262
+ onClick={(e) => {
263
+ e.stopPropagation();
264
+ handleDeleteImage(item.id);
265
+ }}
266
+ className="p-1.5 bg-red-500 hover:bg-red-600 text-white rounded-full transition-colors"
267
+ >
268
+ <X className="w-4 h-4" />
269
+ </button>
270
+ </div>
271
+ <div className="p-2 bg-gray-50">
272
+ <p className="text-xs text-gray-600">
273
+ {item.timestamp.toLocaleTimeString()}
274
+ </p>
275
+ </div>
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
+ </div>
281
+ </div>
282
+ </div>
283
+
284
+ {/* Exit Warning Modal */}
285
+ {showExitWarning && (
286
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
287
+ <div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
288
+ <h3 className="text-xl font-bold text-gray-900 mb-3">Unsaved Changes</h3>
289
+ <p className="text-gray-600 mb-6">
290
+ You have captured items that haven't been saved. Are you sure you want to exit?
291
+ </p>
292
+ <div className="flex gap-3">
293
+ <button
294
+ onClick={() => setShowExitWarning(false)}
295
+ className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
296
+ >
297
+ Cancel
298
+ </button>
299
+ <button
300
+ onClick={() => {
301
+ setShowExitWarning(false);
302
+ goBack();
303
+ }}
304
+ className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
305
+ >
306
+ Exit Anyway
307
+ </button>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+ );
314
+ }
src/pages/GuidedCapturePage.tsx ADDED
@@ -0,0 +1,952 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, FileText, Sparkles } from 'lucide-react';
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
+
9
+ // Simple UI Component replacements
10
+ const Button: React.FC<any> = ({ children, onClick, disabled, variant, size, className, ...props }) => {
11
+ const baseClass = 'inline-flex items-center justify-center font-medium rounded transition-colors';
12
+ const variantClass = variant === 'ghost' ? 'hover:bg-gray-200 text-gray-700' : variant === 'outline' ? 'border border-gray-300 hover:bg-gray-50' : 'bg-blue-600 text-white hover:bg-blue-700';
13
+ const sizeClass = size === 'sm' ? 'px-2 py-1 text-sm' : 'px-4 py-2';
14
+ return <button className={`${baseClass} ${variantClass} ${sizeClass} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`} onClick={onClick} disabled={disabled} {...props}>{children}</button>;
15
+ };
16
+
17
+ type ExamStep = 'native' | 'acetowhite' | 'greenFilter' | 'lugol' | 'biopsyMarking' | 'report';
18
+
19
+ type CapturedItem = {
20
+ id: string;
21
+ type: 'image' | 'video';
22
+ url: string;
23
+ timestamp: Date;
24
+ annotations?: any[];
25
+ observations?: any;
26
+ };
27
+
28
+ type Props = {
29
+ onNext: () => void;
30
+ initialMode?: 'capture' | 'annotation' | 'compare' | 'report';
31
+ onCapturedImagesChange?: (images: any[]) => void;
32
+ onModeChange?: (mode: 'capture' | 'annotation' | 'compare' | 'report') => void;
33
+ };
34
+
35
+ export function GuidedCapturePage({ onNext, initialMode, onCapturedImagesChange, onModeChange }: Props) {
36
+ const imageAnnotatorRef = useRef<ImageAnnotatorHandle>(null);
37
+ const aceticAnnotatorRef = useRef<AceticAnnotatorHandle>(null);
38
+ const [currentStep, setCurrentStep] = useState<ExamStep>('native');
39
+ const [capturedItems, setCapturedItems] = useState<Record<ExamStep, CapturedItem[]>>({
40
+ native: [],
41
+ acetowhite: [],
42
+ greenFilter: [],
43
+ lugol: [],
44
+ biopsyMarking: [],
45
+ report: []
46
+ });
47
+ const [isRecording, setIsRecording] = useState(false);
48
+ const [selectedImage, setSelectedImage] = useState<string | null>(null);
49
+ const [_annotations, setAnnotations] = useState<any[]>([]);
50
+ const [_observations, setObservations] = useState({});
51
+ const [isAnnotatingMode, setIsAnnotatingMode] = useState(false);
52
+ const [isCompareMode, setIsCompareMode] = useState(false);
53
+ const [aiDemoImageUrl, setAiDemoImageUrl] = useState<string | null>(null);
54
+ const audibleAlert = true;
55
+
56
+ // Timer states for Acetowhite step
57
+ const [timerStarted, setTimerStarted] = useState(false);
58
+ const [seconds, setSeconds] = useState(0);
59
+ const [aceticApplied, setAceticApplied] = useState(false);
60
+ const [showFlash, setShowFlash] = useState(false);
61
+ const [timerPaused, setTimerPaused] = useState(false);
62
+ // Green filter state
63
+ const [greenApplied, setGreenApplied] = useState(false);
64
+
65
+ // Timer states for Lugol step
66
+ const [lugolTimerStarted, setLugolTimerStarted] = useState(false);
67
+ const [lugolSeconds, setLugolSeconds] = useState(0);
68
+ const [lugolApplied, setLugolApplied] = useState(false);
69
+ const [lugolShowFlash, setLugolShowFlash] = useState(false);
70
+ const [lugolTimerPaused, setLugolTimerPaused] = useState(false);
71
+
72
+ // Placeholder image URL for demo
73
+ const baseImageUrl = "/C87Aceto_(1).jpg";
74
+ const greenImageUrl = "/greenC87Aceto_(1).jpg";
75
+ const liveFeedImageUrl = currentStep === 'greenFilter' && greenApplied ? greenImageUrl : baseImageUrl;
76
+
77
+ // Timer effect for Acetowhite step
78
+ useEffect(() => {
79
+ if (!timerStarted || !aceticApplied || currentStep !== 'acetowhite' || timerPaused) return;
80
+
81
+ const interval = setInterval(() => {
82
+ setSeconds(prev => prev + 1);
83
+ }, 1000);
84
+
85
+ return () => clearInterval(interval);
86
+ }, [timerStarted, aceticApplied, currentStep, timerPaused]);
87
+
88
+ // Timer effect for Lugol step
89
+ useEffect(() => {
90
+ if (!lugolTimerStarted || !lugolApplied || currentStep !== 'lugol' || lugolTimerPaused) return;
91
+
92
+ const interval = setInterval(() => {
93
+ setLugolSeconds(prev => prev + 1);
94
+ }, 1000);
95
+
96
+ return () => clearInterval(interval);
97
+ }, [lugolTimerStarted, lugolApplied, currentStep, lugolTimerPaused]);
98
+
99
+ // Check for 1 minute and 3 minute marks for Lugol
100
+ useEffect(() => {
101
+ if (currentStep !== 'lugol') return;
102
+
103
+ if (lugolSeconds === 60) {
104
+ setLugolShowFlash(true);
105
+ if (audibleAlert) {
106
+ console.log('BEEP - 1 minute mark');
107
+ }
108
+ setTimeout(() => setLugolShowFlash(false), 3000);
109
+ } else if (lugolSeconds === 180) {
110
+ setLugolShowFlash(true);
111
+ if (audibleAlert) {
112
+ console.log('BEEP - 3 minute mark');
113
+ }
114
+ setTimeout(() => setLugolShowFlash(false), 3000);
115
+ }
116
+ }, [lugolSeconds, audibleAlert, currentStep]);
117
+
118
+ // Check for 1 minute and 3 minute marks for Acetowhite
119
+ useEffect(() => {
120
+ if (currentStep !== 'acetowhite') return;
121
+
122
+ if (seconds === 60) {
123
+ setShowFlash(true);
124
+ if (audibleAlert) {
125
+ console.log('BEEP - 1 minute mark');
126
+ }
127
+ setTimeout(() => setShowFlash(false), 3000);
128
+ } else if (seconds === 180) {
129
+ setShowFlash(true);
130
+ if (audibleAlert) {
131
+ console.log('BEEP - 3 minute mark');
132
+ }
133
+ setTimeout(() => setShowFlash(false), 3000);
134
+ }
135
+ }, [seconds, audibleAlert, currentStep]);
136
+
137
+ const formatTime = (totalSeconds: number) => {
138
+ const mins = Math.floor(totalSeconds / 60);
139
+ const secs = totalSeconds % 60;
140
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
141
+ };
142
+
143
+ // Reset annotation mode and clear selections when changing steps
144
+ useEffect(() => {
145
+ setSelectedImage(null);
146
+ setAnnotations([]);
147
+ setObservations({});
148
+
149
+ // Reset timer when leaving acetowhite step
150
+ if (currentStep !== 'acetowhite') {
151
+ setTimerStarted(false);
152
+ setSeconds(0);
153
+ setAceticApplied(false);
154
+ setShowFlash(false);
155
+ setTimerPaused(false);
156
+ }
157
+ // Reset green filter when leaving greenFilter step
158
+ if (currentStep !== 'greenFilter') {
159
+ setGreenApplied(false);
160
+ }
161
+ }, [currentStep]);
162
+
163
+ // Set initial mode based on initialMode prop
164
+ useEffect(() => {
165
+ if (initialMode) {
166
+ switch (initialMode) {
167
+ case 'capture':
168
+ setIsAnnotatingMode(false);
169
+ setIsCompareMode(false);
170
+ setSelectedImage(null);
171
+ break;
172
+ case 'annotation':
173
+ setIsAnnotatingMode(true);
174
+ setIsCompareMode(false);
175
+ break;
176
+ case 'compare':
177
+ setIsCompareMode(true);
178
+ setIsAnnotatingMode(false);
179
+ break;
180
+ case 'report':
181
+ setCurrentStep('report');
182
+ break;
183
+ }
184
+ }
185
+ }, [initialMode]);
186
+
187
+ const handleAceticApplied = () => {
188
+ setAceticApplied(true);
189
+ setTimerStarted(true);
190
+ setSeconds(0);
191
+ };
192
+
193
+ const handleRestartTimer = () => {
194
+ setSeconds(0);
195
+ setTimerStarted(false);
196
+ setAceticApplied(false);
197
+ setShowFlash(false);
198
+ setTimerPaused(false);
199
+ };
200
+
201
+ const handleLugolApplied = () => {
202
+ setLugolApplied(true);
203
+ setLugolTimerStarted(true);
204
+ setLugolSeconds(0);
205
+ };
206
+
207
+ const handleLugolRestartTimer = () => {
208
+ setLugolSeconds(0);
209
+ setLugolTimerStarted(false);
210
+ setLugolApplied(false);
211
+ setLugolShowFlash(false);
212
+ setLugolTimerPaused(false);
213
+ };
214
+
215
+ const steps: { key: ExamStep; label: string; stepNum: number }[] = [
216
+ { key: 'native', label: 'Native', stepNum: 1 },
217
+ { key: 'acetowhite', label: 'Acetic Acid', stepNum: 2 },
218
+ { key: 'greenFilter', label: 'Green Filter', stepNum: 3 },
219
+ { key: 'lugol', label: 'Lugol', stepNum: 4 },
220
+ { key: 'biopsyMarking', label: 'Biopsy Marking', stepNum: 5 }
221
+ ];
222
+
223
+
224
+
225
+ const handleCaptureImage = () => {
226
+ const newCapture: CapturedItem = {
227
+ id: Date.now().toString(),
228
+ type: 'image',
229
+ url: liveFeedImageUrl,
230
+ timestamp: new Date()
231
+ };
232
+ setCapturedItems(prev => ({
233
+ ...prev,
234
+ [currentStep]: [...prev[currentStep], newCapture]
235
+ }));
236
+ };
237
+
238
+ const handleToggleRecording = () => {
239
+ if (!isRecording) {
240
+ setIsRecording(true);
241
+ } else {
242
+ setIsRecording(false);
243
+ const newCapture: CapturedItem = {
244
+ id: Date.now().toString(),
245
+ type: 'video',
246
+ url: liveFeedImageUrl,
247
+ timestamp: new Date()
248
+ };
249
+ setCapturedItems(prev => ({
250
+ ...prev,
251
+ [currentStep]: [...prev[currentStep], newCapture]
252
+ }));
253
+ }
254
+ };
255
+
256
+ const handleAIAssist = () => {
257
+ // Set the AI demo image URL
258
+ setAiDemoImageUrl('/AI_Demo_img.png');
259
+
260
+ // Enter annotation mode
261
+ setIsAnnotatingMode(true);
262
+
263
+ // Demo AI Annotations for the image
264
+ const demoAIAnnotations = [
265
+ {
266
+ id: 'ai-cervix-' + Date.now(),
267
+ type: 'rect' as const,
268
+ x: 72,
269
+ y: 160,
270
+ width: 910,
271
+ height: 760,
272
+ color: '#22c55e',
273
+ label: 'Cervix',
274
+ source: 'ai' as const,
275
+ identified: false
276
+ },
277
+ {
278
+ id: 'ai-scj-' + Date.now(),
279
+ type: 'circle' as const,
280
+ x: 275,
281
+ y: 310,
282
+ width: 280,
283
+ height: 280,
284
+ color: '#6366f1',
285
+ label: 'SCJ',
286
+ source: 'ai' as const,
287
+ identified: false
288
+ },
289
+ {
290
+ id: 'ai-os-' + Date.now(),
291
+ type: 'polygon' as const,
292
+ x: 350,
293
+ y: 380,
294
+ width: 150,
295
+ height: 100,
296
+ color: '#eab308',
297
+ label: 'OS',
298
+ source: 'ai' as const,
299
+ identified: false,
300
+ points: [
301
+ { x: 350, y: 380 },
302
+ { x: 500, y: 380 },
303
+ { x: 500, y: 480 },
304
+ { x: 350, y: 480 }
305
+ ]
306
+ }
307
+ ];
308
+
309
+ // Add AI annotations to ImageAnnotator after a brief delay to ensure component is ready
310
+ setTimeout(() => {
311
+ if (imageAnnotatorRef.current) {
312
+ imageAnnotatorRef.current.addAIAnnotations(demoAIAnnotations);
313
+ }
314
+ }, 100);
315
+ };
316
+
317
+ const handleDeleteCapture = (id: string) => {
318
+ const newItems = capturedItems[currentStep].filter(item => item.id !== id);
319
+ setCapturedItems(prev => ({
320
+ ...prev,
321
+ [currentStep]: newItems
322
+ }));
323
+ if (selectedImage === id) {
324
+ setSelectedImage(null);
325
+ }
326
+ };
327
+
328
+ const selectedItem = selectedImage
329
+ ? capturedItems[currentStep].find(item => item.id === selectedImage)
330
+ : null;
331
+
332
+ const totalCaptures = capturedItems[currentStep].length;
333
+ const imageCaptures = capturedItems[currentStep].filter(item => item.type === 'image');
334
+ const videoCaptures = capturedItems[currentStep].filter(item => item.type === 'video');
335
+ const biopsyCapturedImages: BiopsyCapturedImage[] = Object.entries(capturedItems).flatMap(([stepId, items]) =>
336
+ items.map(item => ({ id: item.id, src: item.url, stepId, type: item.type }))
337
+ );
338
+
339
+ // Notify parent component when captured images change
340
+ useEffect(() => {
341
+ if (onCapturedImagesChange) {
342
+ onCapturedImagesChange(biopsyCapturedImages);
343
+ }
344
+ }, [capturedItems, onCapturedImagesChange]);
345
+
346
+ // Notify parent component when mode changes
347
+ useEffect(() => {
348
+ if (onModeChange) {
349
+ if (isCompareMode) {
350
+ onModeChange('compare');
351
+ } else if (isAnnotatingMode) {
352
+ onModeChange('annotation');
353
+ } else {
354
+ onModeChange('capture');
355
+ }
356
+ }
357
+ }, [isCompareMode, isAnnotatingMode, onModeChange]);
358
+
359
+ return (
360
+ <div className="w-full bg-white/95 relative">
361
+ <div className="relative z-10 py-4 md:py-6 lg:py-8">
362
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6">
363
+
364
+ {/* Page Header */}
365
+ <div className="mb-4 md:mb-6">
366
+ {/* Progress Bar - Capture / Annotate / Compare / Report */}
367
+ <div className="mb-4 flex gap-1 md:gap-2">
368
+ {['Capture', 'Annotate', 'Compare', 'Report'].map((stage, idx) => (
369
+ <div key={stage} className="flex items-center flex-1">
370
+ <div
371
+ 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 ${
372
+ (stage === 'Capture' && !selectedImage && !isAnnotatingMode && !isCompareMode) ||
373
+ (stage === 'Annotate' && (selectedImage || isAnnotatingMode) && !isCompareMode) ||
374
+ (stage === 'Compare' && isCompareMode) ||
375
+ (stage === 'Report' && currentStep === 'report')
376
+ ? 'bg-[#05998c] text-white shadow-md'
377
+ : 'bg-gray-100 text-gray-600'
378
+ }`}
379
+ >
380
+ <span className="flex items-center justify-center gap-2">
381
+ {stage === 'Capture' && <Camera className="w-4 h-4" />}
382
+ {stage === 'Annotate' && <Edit2 className="w-4 h-4" />}
383
+ {stage === 'Compare' && <Info className="w-4 h-4" />}
384
+ {stage === 'Report' && <FileText className="w-4 h-4" />}
385
+ <span>{stage}</span>
386
+ </span>
387
+ </div>
388
+ {idx < 3 && <div className="w-1.5 h-1.5 rounded-full bg-gray-300 mx-1" />}
389
+ </div>
390
+ ))}
391
+ </div>
392
+
393
+ {/* Step Navigation */}
394
+ {!isCompareMode && (
395
+ <div className="flex gap-2 flex-wrap">
396
+ {steps.map(step => (
397
+ <button
398
+ key={step.key}
399
+ onClick={() => setCurrentStep(step.key)}
400
+ className={`relative px-5 py-3 rounded-lg font-medium text-base transition-all ${
401
+ currentStep === step.key && !isCompareMode
402
+ ? 'bg-[#05998c] text-white shadow-md'
403
+ : 'bg-white text-gray-600 border border-gray-200 hover:border-[#05998c]'
404
+ }`}
405
+ >
406
+ <div className="flex items-center gap-1.5">
407
+ {capturedItems[step.key].length > 0 && (
408
+ <CheckCircle2 className="w-3 h-3" />
409
+ )}
410
+ <span>{step.label}</span>
411
+ </div>
412
+ </button>
413
+ ))}
414
+ </div>
415
+ )}
416
+ </div>
417
+
418
+ {/* Back and Next Navigation for Guided Capture Steps */}
419
+ {currentStep !== 'biopsyMarking' && !isAnnotatingMode && !isCompareMode && (
420
+ <div className="flex items-center gap-3 px-4 py-2.5 bg-white border-b border-slate-200 shadow-sm mb-4">
421
+ <Button
422
+ variant="ghost"
423
+ size="sm"
424
+ className="h-8 px-2 text-slate-700"
425
+ onClick={() => {
426
+ const currentIndex = steps.findIndex(s => s.key === currentStep);
427
+ if (currentIndex > 0) {
428
+ setCurrentStep(steps[currentIndex - 1].key);
429
+ }
430
+ }}
431
+ disabled={steps.findIndex(s => s.key === currentStep) === 0}
432
+ >
433
+ <ArrowLeft className="h-4 w-4 mr-2" />
434
+ Back
435
+ </Button>
436
+ <h2 className="text-lg font-semibold text-slate-800 flex-1">
437
+ {steps.find(s => s.key === currentStep)?.label || 'Guided Capture'}
438
+ </h2>
439
+ <button
440
+ onClick={() => {
441
+ const currentIndex = steps.findIndex(s => s.key === currentStep);
442
+ if (currentIndex < steps.length - 1) {
443
+ setCurrentStep(steps[currentIndex + 1].key);
444
+ }
445
+ }}
446
+ className="h-8 px-3 bg-gray-600 text-white hover:bg-slate-700 rounded transition-colors flex items-center gap-2"
447
+ disabled={steps.findIndex(s => s.key === currentStep) === steps.length - 1}
448
+ >
449
+ Next
450
+ <ArrowRight className="h-4 w-4" />
451
+ </button>
452
+ </div>
453
+ )}
454
+
455
+ {currentStep === 'biopsyMarking' && !isCompareMode && !isAnnotatingMode ? (
456
+ <BiopsyMarking
457
+ onBack={() => setCurrentStep('lugol')}
458
+ onNext={() => setCurrentStep('report')}
459
+ capturedImages={biopsyCapturedImages}
460
+ />
461
+ ) : isCompareMode ? (
462
+ <Compare
463
+ onBack={() => {
464
+ setIsCompareMode(false);
465
+ setIsAnnotatingMode(false);
466
+ setSelectedImage(null);
467
+ }}
468
+ capturedImages={biopsyCapturedImages}
469
+ />
470
+ ) : isAnnotatingMode ? (
471
+ // Multi-Image Annotation Mode
472
+ <div>
473
+ <div className="mb-4 flex items-center justify-between">
474
+ <button
475
+ onClick={() => {
476
+ setIsAnnotatingMode(false);
477
+ setAiDemoImageUrl(null);
478
+ }}
479
+ className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
480
+ >
481
+ <ArrowLeft className="w-4 h-4" />
482
+ Back to Live Feed
483
+ </button>
484
+ <button
485
+ onClick={handleAIAssist}
486
+ className="flex items-center gap-2 px-6 py-2 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors"
487
+ >
488
+ <Sparkles className="w-5 h-5" />
489
+ AI Assist
490
+ </button>
491
+ </div>
492
+
493
+ <div>
494
+ {currentStep === 'acetowhite' ? (
495
+ <AceticAnnotator
496
+ ref={aceticAnnotatorRef}
497
+ imageUrls={aiDemoImageUrl ? [aiDemoImageUrl] : imageCaptures.map(item => item.url)}
498
+ onAnnotationsChange={setAnnotations}
499
+ />
500
+ ) : (
501
+ <ImageAnnotator
502
+ ref={imageAnnotatorRef}
503
+ imageUrls={aiDemoImageUrl ? [aiDemoImageUrl] : imageCaptures.map(item => item.url)}
504
+ onAnnotationsChange={setAnnotations}
505
+ />
506
+ )}
507
+ </div>
508
+ </div>
509
+ ) : !selectedImage ? (
510
+ // Live Feed View
511
+ <>
512
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
513
+ {/* Main Live Feed */}
514
+ <div className="lg:col-span-2 space-y-4">
515
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
516
+ {/* Live Video Feed */}
517
+ <div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700 mb-4">
518
+ <div className="aspect-video flex items-center justify-center">
519
+ <img src={liveFeedImageUrl} alt="Live Feed" className="w-full h-full object-cover" />
520
+ <div className="absolute top-4 left-4 flex items-center gap-2 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
521
+ <div className={`w-2 h-2 rounded-full ${isRecording ? 'bg-white animate-pulse' : 'bg-white/70'}`} />
522
+ {isRecording ? 'Recording' : 'Live'}
523
+ </div>
524
+
525
+
526
+ {currentStep === 'acetowhite' && aceticApplied && (
527
+ <div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg">
528
+ <p className="text-2xl font-mono font-bold">{formatTime(seconds)}</p>
529
+ </div>
530
+ )}
531
+
532
+ {currentStep === 'lugol' && lugolApplied && (
533
+ <div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg">
534
+ <p className="text-2xl font-mono font-bold">{formatTime(lugolSeconds)}</p>
535
+ </div>
536
+ )}
537
+
538
+ {currentStep === 'acetowhite' && showFlash && (
539
+ <div className="absolute inset-0 bg-[#05998c]/30 animate-pulse flex items-center justify-center">
540
+ <div className="bg-white/90 px-6 py-4 rounded-lg">
541
+ <p className="text-2xl font-bold text-[#0A2540]">
542
+ {seconds >= 180 ? '3 Minutes!' : '1 Minute!'}
543
+ </p>
544
+ </div>
545
+ </div>
546
+ )}
547
+
548
+ {currentStep === 'lugol' && lugolShowFlash && (
549
+ <div className="absolute inset-0 bg-[#05998c]/30 animate-pulse flex items-center justify-center">
550
+ <div className="bg-white/90 px-6 py-4 rounded-lg">
551
+ <p className="text-2xl font-bold text-[#0A2540]">
552
+ {lugolSeconds >= 180 ? '3 Minutes!' : '1 Minute!'}
553
+ </p>
554
+ </div>
555
+ </div>
556
+ )}
557
+ </div>
558
+ </div>
559
+
560
+
561
+ {totalCaptures === 0 && (
562
+ <div className="mt-4 flex items-center gap-2 text-sm text-amber-600 bg-amber-50 px-4 py-2 rounded-lg">
563
+ <Info className="w-4 h-4" />
564
+ <span>{(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted) ? (currentStep === 'acetowhite' ? 'Apply acetic acid to start' : 'Apply Lugol iodine to start') : 'Capture Required'}</span>
565
+ </div>
566
+ )}
567
+ </div>
568
+ </div>
569
+
570
+ {/* Sidebar - Capture Controls and Media */}
571
+ <div className="lg:col-span-1">
572
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
573
+ {/* Acetowhite Timer Section */}
574
+ {currentStep === 'acetowhite' && !timerStarted && (
575
+ <div className="mb-4">
576
+ {/* Apply Acetic Acid Message Box */}
577
+ <div className="bg-cyan-500 rounded-lg p-4 mb-4 shadow-md">
578
+ <div className="flex items-center gap-3 mb-2">
579
+ <div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
580
+ <Info className="w-6 h-6 text-teal-600" />
581
+ </div>
582
+ <div>
583
+ <p className="text-white font-bold text-lg">Apply Acetic Acid Now</p>
584
+ <p className="text-teal-100 text-sm">Apply 3-5% acetic acid to the cervix</p>
585
+ </div>
586
+ </div>
587
+ </div>
588
+
589
+ <button
590
+ onClick={handleAceticApplied}
591
+ className="w-full px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-all shadow-md hover:shadow-lg"
592
+ >
593
+ Acetic acid applied — Start timer
594
+ </button>
595
+ </div>
596
+ )}
597
+
598
+ {currentStep === 'acetowhite' && timerStarted && (
599
+ <div className={`mb-4 rounded-lg p-4 border-2 transition-all ${
600
+ seconds < 50
601
+ ? 'bg-gradient-to-r from-[#05998c]/10 to-[#0A2540]/10 border-[#05998c]'
602
+ : seconds < 55
603
+ ? 'bg-red-50 border-red-200'
604
+ : seconds < 60
605
+ ? 'bg-red-100 border-red-300'
606
+ : seconds >= 60 && seconds <= 60
607
+ ? 'bg-red-200 border-red-400'
608
+ : seconds > 60 && seconds < 170
609
+ ? 'bg-gradient-to-r from-green-50 to-green-50 border-green-300'
610
+ : seconds < 175
611
+ ? 'bg-red-50 border-red-200'
612
+ : seconds < 180
613
+ ? 'bg-red-100 border-red-300'
614
+ : 'bg-red-200 border-red-400'
615
+ }`}>
616
+ <div className="flex flex-col gap-3">
617
+ <div>
618
+ <p className="text-sm text-gray-600 mb-1">Timer</p>
619
+ <p className={`text-4xl font-bold font-mono ${
620
+ seconds >= 180 ? 'text-red-600' :
621
+ seconds >= 60 ? 'text-amber-500' :
622
+ seconds >= 50 ? 'text-amber-400' :
623
+ 'text-[#0A2540]'
624
+ }`}>{formatTime(seconds)}</p>
625
+ {seconds >= 60 && seconds < 180 && (
626
+ <p className="text-sm text-amber-600 mt-1">Approaching 3-minute mark...</p>
627
+ )}
628
+ {seconds >= 180 && (
629
+ <p className="text-sm text-green-600 mt-1">3-minute observation period complete</p>
630
+ )}
631
+ </div>
632
+ <div className="flex gap-2">
633
+ <button
634
+ onClick={() => setTimerPaused(!timerPaused)}
635
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
636
+ >
637
+ {timerPaused ? (
638
+ <>
639
+ <Video className="w-4 h-4" />
640
+ <span className="text-sm font-medium">Play</span>
641
+ </>
642
+ ) : (
643
+ <>
644
+ <Pause className="w-4 h-4" />
645
+ <span className="text-sm font-medium">Pause</span>
646
+ </>
647
+ )}
648
+ </button>
649
+ <button
650
+ onClick={handleRestartTimer}
651
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
652
+ >
653
+ <RotateCcw className="w-4 h-4" />
654
+ <span className="text-sm font-medium">Restart</span>
655
+ </button>
656
+ </div>
657
+ </div>
658
+ {seconds === 60 && (
659
+ <div className="mt-3 bg-amber-100 border border-amber-300 rounded-lg p-3">
660
+ <p className="text-amber-800 font-semibold text-sm">⏰ 1-minute mark - Capture recommended!</p>
661
+ </div>
662
+ )}
663
+ {seconds === 180 && (
664
+ <div className="mt-3 bg-green-100 border border-green-300 rounded-lg p-3">
665
+ <p className="text-green-800 font-semibold text-sm">⏰ 3-minute mark - Final capture recommended!</p>
666
+ </div>
667
+ )}
668
+ </div>
669
+ )}
670
+
671
+ {/* Green Filter Toggle */}
672
+ {currentStep === 'greenFilter' && (
673
+ <div className="mb-4 flex items-center justify-between gap-4">
674
+ <div className="flex-1 bg-[#05998c] text-white px-6 py-2 rounded-lg">
675
+ <span className="font-bold">Green Filter</span>
676
+ </div>
677
+ <button
678
+ onClick={() => setGreenApplied(prev => !prev)}
679
+ className={`relative inline-flex h-8 w-16 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
680
+ greenApplied ? 'bg-blue-500' : 'bg-gray-300'
681
+ }`}
682
+ >
683
+ <span
684
+ className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform ${
685
+ greenApplied ? 'translate-x-9' : 'translate-x-1'
686
+ }`}
687
+ />
688
+ <span className={`absolute text-xs font-semibold ${
689
+ greenApplied ? 'left-2 text-white' : 'right-2 text-gray-600'
690
+ }`}>
691
+ {greenApplied ? 'ON' : 'OFF'}
692
+ </span>
693
+ </button>
694
+ </div>
695
+ )}
696
+
697
+ {/* Lugol Timer Section */}
698
+ {currentStep === 'lugol' && !lugolTimerStarted && (
699
+ <div className="mb-4">
700
+ {/* Apply Lugol Message Box */}
701
+ <div className="bg-gradient-to-r from-yellow-500 to-yellow-600 rounded-lg p-4 mb-4 shadow-md">
702
+ <div className="flex items-center gap-3 mb-2">
703
+ <div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
704
+ <Info className="w-6 h-6 text-yellow-600" />
705
+ </div>
706
+ <div>
707
+ <p className="text-white font-bold text-lg">Apply Lugol Iodine Now</p>
708
+ <p className="text-yellow-100 text-sm">Apply Lugol iodine solution to the cervix</p>
709
+ </div>
710
+ </div>
711
+ </div>
712
+
713
+ <button
714
+ onClick={handleLugolApplied}
715
+ className="w-full px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-all shadow-md hover:shadow-lg"
716
+ >
717
+ Lugol applied — Start timer
718
+ </button>
719
+ </div>
720
+ )}
721
+
722
+ {currentStep === 'lugol' && lugolTimerStarted && (
723
+ <div className={`mb-4 rounded-lg p-4 border-2 transition-all ${
724
+ lugolSeconds < 50
725
+ ? 'bg-gradient-to-r from-[#05998c]/10 to-[#0A2540]/10 border-[#05998c]'
726
+ : lugolSeconds < 55
727
+ ? 'bg-red-50 border-red-200'
728
+ : lugolSeconds < 60
729
+ ? 'bg-red-100 border-red-300'
730
+ : lugolSeconds >= 60 && lugolSeconds <= 60
731
+ ? 'bg-red-200 border-red-400'
732
+ : lugolSeconds > 60 && lugolSeconds < 170
733
+ ? 'bg-gradient-to-r from-green-50 to-green-50 border-green-300'
734
+ : lugolSeconds < 175
735
+ ? 'bg-red-50 border-red-200'
736
+ : lugolSeconds < 180
737
+ ? 'bg-red-100 border-red-300'
738
+ : 'bg-red-200 border-red-400'
739
+ }`}>
740
+ <div className="flex flex-col gap-3">
741
+ <div>
742
+ <p className="text-sm text-gray-600 mb-1">Timer</p>
743
+ <p className={`text-4xl font-bold font-mono ${
744
+ lugolSeconds >= 180 ? 'text-red-600' :
745
+ lugolSeconds >= 60 ? 'text-amber-500' :
746
+ lugolSeconds >= 50 ? 'text-amber-400' :
747
+ 'text-[#0A2540]'
748
+ }`}>{formatTime(lugolSeconds)}</p>
749
+ {lugolSeconds >= 60 && lugolSeconds < 180 && (
750
+ <p className="text-sm text-amber-600 mt-1">Approaching 3-minute mark...</p>
751
+ )}
752
+ {lugolSeconds >= 180 && (
753
+ <p className="text-sm text-green-600 mt-1">3-minute observation period complete</p>
754
+ )}
755
+ </div>
756
+ <div className="flex gap-2">
757
+ <button
758
+ onClick={() => setLugolTimerPaused(!lugolTimerPaused)}
759
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
760
+ >
761
+ {lugolTimerPaused ? (
762
+ <>
763
+ <Video className="w-4 h-4" />
764
+ <span className="text-sm font-medium">Play</span>
765
+ </>
766
+ ) : (
767
+ <>
768
+ <Pause className="w-4 h-4" />
769
+ <span className="text-sm font-medium">Pause</span>
770
+ </>
771
+ )}
772
+ </button>
773
+ <button
774
+ onClick={handleLugolRestartTimer}
775
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
776
+ >
777
+ <RotateCcw className="w-4 h-4" />
778
+ <span className="text-sm font-medium">Restart</span>
779
+ </button>
780
+ </div>
781
+ </div>
782
+ {lugolSeconds === 60 && (
783
+ <div className="mt-3 bg-amber-100 border border-amber-300 rounded-lg p-3">
784
+ <p className="text-amber-800 font-semibold text-sm">⏰ 1-minute mark - Capture recommended!</p>
785
+ </div>
786
+ )}
787
+ {lugolSeconds === 180 && (
788
+ <div className="mt-3 bg-green-100 border border-green-300 rounded-lg p-3">
789
+ <p className="text-green-800 font-semibold text-sm">⏰ 3-minute mark - Final capture recommended!</p>
790
+ </div>
791
+ )}
792
+ </div>
793
+ )}
794
+
795
+ {/* Capture Controls */}
796
+ <div className="flex gap-2 mb-4">
797
+ <button
798
+ onClick={handleCaptureImage}
799
+ disabled={(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted)}
800
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${
801
+ (currentStep === 'acetowhite' && showFlash && (seconds === 60 || seconds === 180)) ||
802
+ (currentStep === 'lugol' && lugolShowFlash && (lugolSeconds === 60 || lugolSeconds === 180))
803
+ ? 'bg-[#05998c] text-white animate-pulse'
804
+ : 'bg-[#05998c] text-white hover:bg-[#047569]'
805
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
806
+ >
807
+ <Camera className="w-4 h-4" />
808
+ Capture
809
+ </button>
810
+ <button
811
+ onClick={handleToggleRecording}
812
+ disabled={(currentStep === 'acetowhite' && !timerStarted) || (currentStep === 'lugol' && !lugolTimerStarted)}
813
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${
814
+ isRecording
815
+ ? 'bg-red-500 text-white hover:bg-red-600'
816
+ : 'bg-[#05998c] text-white hover:bg-[#047569]'
817
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
818
+ >
819
+ {isRecording ? <Pause className="w-4 h-4" /> : <Video className="w-4 h-4" />}
820
+ {isRecording ? 'Stop' : 'Record'}
821
+ </button>
822
+ </div>
823
+
824
+ <h3 className="font-bold text-[#0A2540] mb-4">Captured Media</h3>
825
+
826
+ {totalCaptures === 0 ? (
827
+ <div className="flex flex-col items-center justify-center py-12 text-center">
828
+ <Camera className="w-16 h-16 text-gray-300 mb-3" />
829
+ <p className="text-gray-500 font-medium">No captures yet</p>
830
+ <p className="text-sm text-gray-400 mt-1">Capture images or videos from live feed</p>
831
+ </div>
832
+ ) : (
833
+ <div className="space-y-4">
834
+ {/* Annotate Images Button */}
835
+ <button
836
+ onClick={() => setIsAnnotatingMode(true)}
837
+ className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-[#05998c] text-white font-semibold hover:bg-[#047569] transition-colors text-base"
838
+ >
839
+ <Edit2 className="w-5 h-5" />
840
+ Annotate Images
841
+ </button>
842
+
843
+ <div className="space-y-3">
844
+ {/* Image Thumbnails */}
845
+ {imageCaptures.length > 0 && (
846
+ <div>
847
+ <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Images ({imageCaptures.length})</h4>
848
+ <div className="grid grid-cols-2 gap-2">
849
+ {imageCaptures.map(item => (
850
+ <div key={item.id} className="relative group">
851
+ <div
852
+ className="aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-200 transition-all"
853
+ >
854
+ <img src={item.url} alt="Capture" className="w-full h-full object-cover" />
855
+ {item.annotations && item.annotations.length > 0 && (
856
+ <div className="absolute top-1 right-1 bg-green-500 text-white p-1 rounded">
857
+ <CheckCircle2 className="w-3 h-3" />
858
+ </div>
859
+ )}
860
+ </div>
861
+ <button
862
+ onClick={() => handleDeleteCapture(item.id)}
863
+ className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
864
+ >
865
+ <X className="w-3 h-3" />
866
+ </button>
867
+ </div>
868
+ ))}
869
+ </div>
870
+ </div>
871
+ )}
872
+
873
+ {/* Video Items */}
874
+ {videoCaptures.length > 0 && (
875
+ <div className="mt-4">
876
+ <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Videos ({videoCaptures.length})</h4>
877
+ <div className="space-y-2">
878
+ {videoCaptures.map(item => (
879
+ <div key={item.id} className="relative group bg-gray-50 rounded-lg p-3 flex items-center gap-3">
880
+ <div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
881
+ <Video className="w-6 h-6 text-gray-500" />
882
+ </div>
883
+ <div className="flex-1">
884
+ <p className="text-sm font-medium text-gray-700">Video Recording</p>
885
+ <p className="text-xs text-gray-500">{item.timestamp.toLocaleTimeString()}</p>
886
+ </div>
887
+ <button
888
+ onClick={() => handleDeleteCapture(item.id)}
889
+ className="p-1 hover:bg-red-50 rounded text-red-500"
890
+ >
891
+ <X className="w-4 h-4" />
892
+ </button>
893
+ </div>
894
+ ))}
895
+ </div>
896
+ </div>
897
+ )}
898
+ </div>
899
+ </div>
900
+ )}
901
+ </div>
902
+ </div>
903
+ </div>
904
+
905
+ {/* Visual Observations - Full Width on Native Step */}
906
+ {currentStep === 'native' && (
907
+ <div className="w-full">
908
+ <ImagingObservations
909
+ onObservationsChange={setObservations}
910
+ layout="horizontal"
911
+ />
912
+ </div>
913
+ )}
914
+ </>
915
+ ) : (
916
+ // Single Image Annotation View
917
+ <div>
918
+ <div className="mb-4 flex items-center justify-between">
919
+ <button
920
+ onClick={() => {
921
+ setSelectedImage(null);
922
+ setAnnotations([]);
923
+ setObservations({});
924
+ }}
925
+ className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
926
+ >
927
+ <ArrowLeft className="w-4 h-4" />
928
+ Back to Live Feed
929
+ </button>
930
+ <button
931
+ onClick={handleAIAssist}
932
+ className="flex items-center gap-2 px-6 py-2 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors"
933
+ >
934
+ <Sparkles className="w-5 h-5" />
935
+ AI Assist
936
+ </button>
937
+ </div>
938
+
939
+ <div>
940
+ <ImageAnnotator
941
+ ref={imageAnnotatorRef}
942
+ imageUrl={selectedItem?.url || liveFeedImageUrl}
943
+ onAnnotationsChange={setAnnotations}
944
+ />
945
+ </div>
946
+ </div>
947
+ )}
948
+ </div>
949
+ </div>
950
+ </div>
951
+ );
952
+ }
src/pages/HomePage.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Users, ArrowRight } from 'lucide-react';
2
+
3
+ type Props = {
4
+ onNavigateToPatients: () => void;
5
+ onNext: () => void;
6
+ };
7
+
8
+ export function HomePage({ onNavigateToPatients, onNext }: Props) {
9
+ return (
10
+ <div className="flex-1 flex flex-col items-center justify-center py-8 px-4">
11
+ <div className="w-full max-w-2xl mx-auto text-center space-y-8">
12
+ {/* Welcome Section */}
13
+ <div className="space-y-4">
14
+ <h1 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-[#0A2540]">
15
+ Welcome to Pathora
16
+ </h1>
17
+ <p className="text-base md:text-lg text-gray-600">
18
+ Manalife's AI-Powered Colposcopy Assistant
19
+ </p>
20
+ </div>
21
+
22
+ {/* Main CTA */}
23
+ <div className="mt-12">
24
+ <button
25
+ onClick={onNavigateToPatients}
26
+ className="inline-flex items-center gap-3 px-8 md:px-10 py-3 md:py-4 rounded-xl bg-[#05998c] text-white font-bold text-base md:text-lg shadow-lg shadow-teal-500/20 hover:bg-[#047569] hover:shadow-teal-500/30 transition-all"
27
+ >
28
+ <Users className="w-5 h-5 md:w-6 md:h-6" />
29
+ Manage Patients
30
+ <ArrowRight className="w-5 h-5 md:w-6 md:h-6" />
31
+ </button>
32
+ </div>
33
+
34
+ {/* Info Section */}
35
+ <div className="mt-16 pt-8 border-t border-gray-200">
36
+ <p className="text-sm md:text-base text-gray-600">
37
+ Select or create a patient to begin a colposcopy examination
38
+ </p>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export default HomePage;
src/pages/LugolExamPage.tsx ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Camera, Video, ArrowLeft, ArrowRight, CheckCircle2, Info, Pause, X, Edit2, RotateCcw, Save, ChevronRight } from 'lucide-react';
3
+ import { ImageAnnotator } from '../components/ImageAnnotator';
4
+
5
+ type CapturedItem = {
6
+ id: string;
7
+ type: 'image' | 'video';
8
+ url: string;
9
+ timestamp: Date;
10
+ annotations?: any[];
11
+ };
12
+
13
+ type Props = {
14
+ goBack: () => void;
15
+ onNext: () => void;
16
+ };
17
+
18
+ export function LugolExamPage({ goBack, onNext }: Props) {
19
+ const [capturedItems, setCapturedItems] = useState<CapturedItem[]>([]);
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
26
+ const [timerStarted, setTimerStarted] = useState(false);
27
+ const [seconds, setSeconds] = useState(0);
28
+ const [lugolApplied, setLugolApplied] = useState(false);
29
+ const [showFlash, setShowFlash] = useState(false);
30
+ const audibleAlert = true;
31
+ const [timerPaused, setTimerPaused] = useState(false);
32
+
33
+ const cervixImageUrl = "/C87Aceto_(1).jpg";
34
+
35
+ // Timer effect
36
+ useEffect(() => {
37
+ if (!timerStarted || !lugolApplied || timerPaused) return;
38
+
39
+ const interval = setInterval(() => {
40
+ setSeconds(prev => prev + 1);
41
+ }, 1000);
42
+
43
+ return () => clearInterval(interval);
44
+ }, [timerStarted, lugolApplied, timerPaused]);
45
+
46
+ // Check for 1 minute and 3 minute marks
47
+ useEffect(() => {
48
+ if (seconds === 60) {
49
+ // 1 minute mark
50
+ setShowFlash(true);
51
+ if (audibleAlert) {
52
+ console.log('BEEP - 1 minute mark');
53
+ }
54
+ setTimeout(() => setShowFlash(false), 3000);
55
+ } else if (seconds === 180) {
56
+ // 3 minute mark
57
+ setShowFlash(true);
58
+ if (audibleAlert) {
59
+ console.log('BEEP - 3 minute mark');
60
+ }
61
+ setTimeout(() => setShowFlash(false), 3000);
62
+ }
63
+ }, [seconds, audibleAlert]);
64
+
65
+ const formatTime = (totalSeconds: number) => {
66
+ const mins = Math.floor(totalSeconds / 60);
67
+ const secs = totalSeconds % 60;
68
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
69
+ };
70
+
71
+ const handleLugolApplied = () => {
72
+ setLugolApplied(true);
73
+ setTimerStarted(true);
74
+ setSeconds(0);
75
+ };
76
+
77
+ const handleRestartTimer = () => {
78
+ setSeconds(0);
79
+ setTimerStarted(false);
80
+ setLugolApplied(false);
81
+ setShowFlash(false);
82
+ setTimerPaused(false);
83
+ };
84
+
85
+ const handleCaptureImage = () => {
86
+ const newCapture: CapturedItem = {
87
+ id: Date.now().toString(),
88
+ type: 'image',
89
+ url: cervixImageUrl,
90
+ timestamp: new Date()
91
+ };
92
+ setCapturedItems(prev => [...prev, newCapture]);
93
+ };
94
+
95
+ const handleToggleRecording = () => {
96
+ if (!isRecording) {
97
+ setIsRecording(true);
98
+ } else {
99
+ setIsRecording(false);
100
+ const newCapture: CapturedItem = {
101
+ id: Date.now().toString(),
102
+ type: 'video',
103
+ url: cervixImageUrl,
104
+ timestamp: new Date()
105
+ };
106
+ setCapturedItems(prev => [...prev, newCapture]);
107
+ }
108
+ };
109
+
110
+ const handleSaveAnnotations = () => {
111
+ if (!selectedImage) return;
112
+
113
+ setCapturedItems(prev => prev.map(item =>
114
+ item.id === selectedImage
115
+ ? { ...item, annotations }
116
+ : item
117
+ ));
118
+ setSelectedImage(null);
119
+ setAnnotations([]);
120
+ };
121
+
122
+ const handleDeleteCapture = (id: string) => {
123
+ setCapturedItems(prev => prev.filter(item => item.id !== id));
124
+ if (selectedImage === id) {
125
+ setSelectedImage(null);
126
+ }
127
+ };
128
+
129
+ const selectedItem = selectedImage
130
+ ? capturedItems.find(item => item.id === selectedImage)
131
+ : null;
132
+
133
+ const totalCaptures = capturedItems.length;
134
+ const imageCaptures = capturedItems.filter(item => item.type === 'image');
135
+ const videoCaptures = capturedItems.filter(item => item.type === 'video');
136
+ const hasRequiredCapture = imageCaptures.length > 0;
137
+
138
+ return (
139
+ <div className="w-full bg-white/95 relative">
140
+ <div className="relative z-10 py-4 md:py-6 lg:py-8">
141
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6">
142
+
143
+ {/* Page Header */}
144
+ <div className="mb-4 md:mb-6">
145
+ <div className="flex items-center justify-between mb-4">
146
+ <div className="flex items-center gap-3">
147
+ <button onClick={() => setShowExitWarning(true)} className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600">
148
+ <ArrowLeft className="w-5 h-5" />
149
+ </button>
150
+ <h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-[#0A2540]">Lugol Examination</h1>
151
+ </div>
152
+ <div className="flex items-center gap-3">
153
+ </div>
154
+ </div>
155
+
156
+ {/* Progress Bar - Capture / Annotation / Comparison View / Report */}
157
+ <div className="mb-4 flex gap-1 md:gap-2 items-center">
158
+ <div className="flex gap-1 md:gap-2 flex-1">
159
+ {['Capture', 'Annotation', 'Comparison View', 'Report'].map((stage, idx) => (
160
+ <div key={stage} className="flex items-center flex-1">
161
+ <button
162
+ className={`flex-1 py-2 px-2 md:px-3 rounded-lg font-medium text-sm md:text-base transition-all border-2 border-[#0A2540] ${
163
+ (stage === 'Capture' && !selectedImage) ||
164
+ (stage === 'Annotation' && selectedImage)
165
+ ? 'bg-[#05998c] text-white shadow-md'
166
+ : 'bg-gray-100 text-gray-600'
167
+ }`}
168
+ >
169
+ {stage}
170
+ </button>
171
+ {idx < 3 && <div className="w-1.5 h-1.5 rounded-full bg-gray-300 mx-1" />}
172
+ </div>
173
+ ))}
174
+ </div>
175
+ <button onClick={onNext} className="ml-4 px-6 md:px-8 py-2 md:py-3 rounded-xl bg-gray-600 text-white font-bold shadow-lg shadow-gray-500/20 hover:bg-slate-700 hover:shadow-gray-500/30 transition-all flex items-center justify-center gap-2 text-sm md:text-base">
176
+ <Save className="w-4 h-4 md:w-5 md:h-5" />
177
+ <span className="hidden lg:inline">Next</span>
178
+ <span className="inline lg:hidden">Next</span>
179
+ <ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
180
+ </button>
181
+ </div>
182
+ </div>
183
+
184
+ {!selectedImage ? (
185
+ // Live Feed View
186
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
187
+ {/* Main Live Feed */}
188
+ <div className="lg:col-span-2 space-y-4">
189
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
190
+ <div className="mb-4">
191
+ <h2 className="text-2xl md:text-3xl font-bold text-[#0A2540] mb-2">
192
+ Lugol Iodine
193
+ </h2>
194
+ <p className="text-gray-600">
195
+ Lugol iodine application and observation
196
+ </p>
197
+ </div>
198
+
199
+ {/* Live Video Feed */}
200
+ <div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-2xl border-2 border-gray-700 mb-4">
201
+ <div className="aspect-video flex items-center justify-center">
202
+ <img src={cervixImageUrl} alt="Live Feed" className="w-full h-full object-cover" />
203
+
204
+ {/* Live indicator */}
205
+ <div className="absolute top-4 left-4 flex items-center gap-2 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
206
+ <div className={`w-2 h-2 rounded-full ${isRecording ? 'bg-white animate-pulse' : 'bg-white/70'}`} />
207
+ {isRecording ? 'Recording' : 'Live'}
208
+ </div>
209
+
210
+ {/* Timer overlay */}
211
+ {lugolApplied && (
212
+ <div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg">
213
+ <p className="text-2xl font-mono font-bold">{formatTime(seconds)}</p>
214
+ </div>
215
+ )}
216
+
217
+ {/* Flash overlay at 1 and 3 minutes */}
218
+ {showFlash && (
219
+ <div className="absolute inset-0 bg-[#05998c]/30 animate-pulse flex items-center justify-center">
220
+ <div className="bg-white/90 px-6 py-4 rounded-lg">
221
+ <p className="text-2xl font-bold text-[#0A2540]">
222
+ {seconds >= 180 ? '3 Minutes!' : '1 Minute!'}
223
+ </p>
224
+ </div>
225
+ </div>
226
+ )}
227
+ </div>
228
+ </div>
229
+
230
+ <div className="mt-4 flex items-center gap-2 text-sm text-gray-500">
231
+ <Camera className="w-4 h-4" />
232
+ <span>Captures: {totalCaptures} / 1 required (image)</span>
233
+ {hasRequiredCapture && <CheckCircle2 className="w-4 h-4 text-green-500 ml-2" />}
234
+ </div>
235
+
236
+ {!hasRequiredCapture && timerStarted && (
237
+ <div className="mt-4 flex items-center gap-2 text-sm text-amber-600 bg-amber-50 px-4 py-2 rounded-lg">
238
+ <Info className="w-4 h-4" />
239
+ <span>At least one image capture required</span>
240
+ </div>
241
+ )}
242
+
243
+ {/* Captured Images Selection for Annotation */}
244
+ {imageCaptures.length > 0 && (
245
+ <div className="mt-6 pt-6 border-t border-gray-200">
246
+ <h4 className="text-sm font-semibold text-gray-700 mb-3">Select Image to Annotate</h4>
247
+ <div className="grid grid-cols-3 gap-3">
248
+ {imageCaptures.map(item => (
249
+ <div
250
+ key={item.id}
251
+ onClick={() => setSelectedImage(item.id)}
252
+ className="relative group cursor-pointer"
253
+ >
254
+ <div className="aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 border-transparent hover:border-[#05998c] transition-all">
255
+ <img src={item.url} alt="Capture" className="w-full h-full object-cover" />
256
+ {item.annotations && item.annotations.length > 0 && (
257
+ <div className="absolute top-1 right-1 bg-green-500 text-white p-1 rounded">
258
+ <CheckCircle2 className="w-3 h-3" />
259
+ </div>
260
+ )}
261
+ </div>
262
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
263
+ <span className="text-white text-xs font-semibold">Annotate</span>
264
+ </div>
265
+ </div>
266
+ ))}
267
+ </div>
268
+ </div>
269
+ )}
270
+ </div>
271
+ </div>
272
+
273
+ {/* Captures Sidebar */}
274
+ <div className="lg:col-span-1">
275
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
276
+ {/* Lugol Timer Section */}
277
+ {!timerStarted && (
278
+ <div className="mb-6">
279
+ {/* Apply Lugol Message Box */}
280
+ <div className="bg-gradient-to-r from-yellow-500 to-yellow-600 rounded-lg p-4 mb-4 shadow-md">
281
+ <div className="flex items-center gap-3 mb-2">
282
+ <div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
283
+ <Info className="w-6 h-6 text-yellow-600" />
284
+ </div>
285
+ <div>
286
+ <p className="text-white font-bold text-lg">Apply Lugol Iodine Now</p>
287
+ <p className="text-yellow-100 text-sm">Apply Lugol iodine solution to the cervix</p>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <button
293
+ onClick={handleLugolApplied}
294
+ className="w-full px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-all shadow-md hover:shadow-lg"
295
+ >
296
+ Lugol applied — Start timer
297
+ </button>
298
+ </div>
299
+ )}
300
+
301
+ {timerStarted && (
302
+ <div className={`mb-6 rounded-lg p-4 border-2 transition-all ${
303
+ seconds < 50
304
+ ? 'bg-gradient-to-r from-[#05998c]/10 to-[#0A2540]/10 border-[#05998c]'
305
+ : seconds < 55
306
+ ? 'bg-red-50 border-red-200'
307
+ : seconds < 60
308
+ ? 'bg-red-100 border-red-300'
309
+ : seconds >= 60 && seconds <= 60
310
+ ? 'bg-red-200 border-red-400'
311
+ : seconds > 60 && seconds < 170
312
+ ? 'bg-gradient-to-r from-green-50 to-green-50 border-green-300'
313
+ : seconds < 175
314
+ ? 'bg-red-50 border-red-200'
315
+ : seconds < 180
316
+ ? 'bg-red-100 border-red-300'
317
+ : 'bg-red-200 border-red-400'
318
+ }`}>
319
+ <div className="flex flex-col gap-3">
320
+ <div>
321
+ <p className="text-sm text-gray-600 mb-1">Timer</p>
322
+ <p className={`text-4xl font-bold font-mono ${
323
+ seconds >= 180 ? 'text-red-600' :
324
+ seconds >= 60 ? 'text-amber-500' :
325
+ seconds >= 50 ? 'text-amber-400' :
326
+ 'text-[#0A2540]'
327
+ }`}>{formatTime(seconds)}</p>
328
+ {seconds >= 60 && seconds < 180 && (
329
+ <p className="text-sm text-amber-600 mt-1">Approaching 3-minute mark...</p>
330
+ )}
331
+ {seconds >= 180 && (
332
+ <p className="text-sm text-green-600 mt-1">3-minute observation period complete</p>
333
+ )}
334
+ </div>
335
+ <div className="flex gap-2">
336
+ <button
337
+ onClick={() => setTimerPaused(!timerPaused)}
338
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
339
+ >
340
+ {timerPaused ? (
341
+ <>
342
+ <Video className="w-4 h-4" />
343
+ <span className="text-sm font-medium">Play</span>
344
+ </>
345
+ ) : (
346
+ <>
347
+ <Pause className="w-4 h-4" />
348
+ <span className="text-sm font-medium">Pause</span>
349
+ </>
350
+ )}
351
+ </button>
352
+ <button
353
+ onClick={handleRestartTimer}
354
+ className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
355
+ >
356
+ <RotateCcw className="w-4 h-4" />
357
+ <span className="text-sm font-medium">Restart</span>
358
+ </button>
359
+ </div>
360
+ </div>
361
+ {seconds === 60 && (
362
+ <div className="mt-3 bg-amber-100 border border-amber-300 rounded-lg p-3">
363
+ <p className="text-amber-800 font-semibold text-sm">⏰ 1-minute mark - Capture recommended!</p>
364
+ </div>
365
+ )}
366
+ {seconds === 180 && (
367
+ <div className="mt-3 bg-green-100 border border-green-300 rounded-lg p-3">
368
+ <p className="text-green-800 font-semibold text-sm">⏰ 3-minute mark - Final capture recommended!</p>
369
+ </div>
370
+ )}
371
+ </div>
372
+ )}
373
+
374
+ {/* Capture Controls */}
375
+ <div className="space-y-3 mb-6">
376
+ <div className="flex gap-2">
377
+ <button
378
+ onClick={handleCaptureImage}
379
+ disabled={!timerStarted}
380
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${
381
+ showFlash && (seconds === 60 || seconds === 180)
382
+ ? 'bg-[#05998c] text-white animate-pulse'
383
+ : 'bg-[#05998c] text-white hover:bg-[#047569]'
384
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
385
+ >
386
+ <Camera className="w-4 h-4" />
387
+ Capture
388
+ </button>
389
+ <button
390
+ onClick={handleToggleRecording}
391
+ disabled={!timerStarted}
392
+ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-semibold transition-colors text-sm ${
393
+ isRecording
394
+ ? 'bg-red-500 text-white hover:bg-red-600'
395
+ : 'bg-[#05998c] text-white hover:bg-[#047569]'
396
+ } disabled:opacity-50 disabled:cursor-not-allowed`}
397
+ >
398
+ {isRecording ? <Pause className="w-4 h-4" /> : <Video className="w-4 h-4" />}
399
+ {isRecording ? 'Stop' : 'Record'}
400
+ </button>
401
+ </div>
402
+ <button className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-600 text-white rounded-lg font-semibold hover:bg-slate-700 transition-colors">
403
+ Next
404
+ <ArrowRight className="w-4 h-4" />
405
+ </button>
406
+ </div>
407
+ <h3 className="font-bold text-[#0A2540] mb-4">Captured Media</h3>
408
+
409
+ {totalCaptures === 0 ? (
410
+ <div className="flex flex-col items-center justify-center py-12 text-center">
411
+ <Camera className="w-16 h-16 text-gray-300 mb-3" />
412
+ <p className="text-gray-500 font-medium">No captures yet</p>
413
+ <p className="text-sm text-gray-400 mt-1">Apply Lugol iodine and start capturing</p>
414
+ </div>
415
+ ) : (
416
+ <div className="space-y-3">
417
+ {/* Image Thumbnails */}
418
+ {imageCaptures.length > 0 && (
419
+ <div>
420
+ <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Images ({imageCaptures.length})</h4>
421
+ <div className="grid grid-cols-2 gap-2">
422
+ {imageCaptures.map(item => (
423
+ <div key={item.id} className="relative group">
424
+ <div
425
+ onClick={() => setSelectedImage(item.id)}
426
+ className="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer border-2 border-transparent hover:border-[#05998c] transition-all"
427
+ >
428
+ <img src={item.url} alt="Capture" className="w-full h-full object-cover" />
429
+ {item.annotations && item.annotations.length > 0 && (
430
+ <div className="absolute top-1 right-1 bg-green-500 text-white p-1 rounded">
431
+ <CheckCircle2 className="w-3 h-3" />
432
+ </div>
433
+ )}
434
+ </div>
435
+ <button
436
+ onClick={(e) => {
437
+ e.stopPropagation();
438
+ handleDeleteCapture(item.id);
439
+ }}
440
+ className="absolute top-1 left-1 bg-red-500 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
441
+ >
442
+ <X className="w-3 h-3" />
443
+ </button>
444
+ <button
445
+ onClick={() => setSelectedImage(item.id)}
446
+ className="absolute bottom-1 right-1 bg-[#0A2540] text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
447
+ >
448
+ <Edit2 className="w-3 h-3" />
449
+ </button>
450
+ </div>
451
+ ))}
452
+ </div>
453
+ </div>
454
+ )}
455
+
456
+ {/* Video Items */}
457
+ {videoCaptures.length > 0 && (
458
+ <div className="mt-4">
459
+ <h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Videos ({videoCaptures.length})</h4>
460
+ <div className="space-y-2">
461
+ {videoCaptures.map(item => (
462
+ <div key={item.id} className="relative group bg-gray-50 rounded-lg p-3 flex items-center gap-3">
463
+ <div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
464
+ <Video className="w-6 h-6 text-gray-500" />
465
+ </div>
466
+ <div className="flex-1">
467
+ <p className="text-sm font-medium text-gray-700">Video Recording</p>
468
+ <p className="text-xs text-gray-500">{item.timestamp.toLocaleTimeString()}</p>
469
+ </div>
470
+ <button
471
+ onClick={() => handleDeleteCapture(item.id)}
472
+ className="p-1 hover:bg-red-50 rounded text-red-500"
473
+ >
474
+ <X className="w-4 h-4" />
475
+ </button>
476
+ </div>
477
+ ))}
478
+ </div>
479
+ </div>
480
+ )}
481
+ </div>
482
+ )}
483
+ </div>
484
+ </div>
485
+ </div>
486
+ ) : (
487
+ // Annotation View
488
+ <div>
489
+ <div className="mb-4 flex items-center justify-between">
490
+ <button
491
+ onClick={() => {
492
+ setSelectedImage(null);
493
+ setAnnotations([]);
494
+ }}
495
+ className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
496
+ >
497
+ <ArrowLeft className="w-4 h-4" />
498
+ Back to Live Feed
499
+ </button>
500
+ <div className="flex items-center gap-3">
501
+ <button
502
+ onClick={handleSaveAnnotations}
503
+ className="px-6 py-2 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors"
504
+ >
505
+ Save Annotations
506
+ </button>
507
+ <button 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">
508
+ Next
509
+ <ArrowRight className="w-4 h-4" />
510
+ </button>
511
+ </div>
512
+ </div>
513
+
514
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
515
+ <div className="lg:col-span-2">
516
+ <ImageAnnotator
517
+ imageUrl={selectedItem?.url || cervixImageUrl}
518
+ onAnnotationsChange={setAnnotations}
519
+ />
520
+ </div>
521
+ </div>
522
+ </div>
523
+ )}
524
+
525
+ {/* Exit Warning Dialog */}
526
+ {showExitWarning && (
527
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
528
+ <div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4">
529
+ <h3 className="text-xl font-bold text-[#0A2540] mb-3">Leave Examination?</h3>
530
+ <p className="text-gray-600 mb-6">
531
+ If you go back now, all captures, timer data, and annotations will be lost. Are you sure you want to continue?
532
+ </p>
533
+ <div className="flex gap-3 justify-end">
534
+ <button
535
+ onClick={() => setShowExitWarning(false)}
536
+ className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
537
+ >
538
+ Cancel
539
+ </button>
540
+ <button
541
+ onClick={() => {
542
+ setShowExitWarning(false);
543
+ goBack();
544
+ }}
545
+ className="px-6 py-2 bg-red-500 text-white rounded-lg font-semibold hover:bg-red-600 transition-colors"
546
+ >
547
+ Leave Anyway
548
+ </button>
549
+ </div>
550
+ </div>
551
+ </div>
552
+ )}
553
+ </div>
554
+ </div>
555
+ </div>
556
+ );
557
+ }
src/pages/PatientHistoryPage.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PatientHistoryForm } from '../components/PatientHistoryForm';
2
+ type Props = {
3
+ goToImaging: () => void;
4
+ goBackToRegistry: () => void;
5
+ patientID?: string | undefined;
6
+ goToGuidedCapture?: () => void;
7
+ };
8
+
9
+ export function PatientHistoryPage({
10
+ goToImaging,
11
+ goBackToRegistry,
12
+ patientID,
13
+ goToGuidedCapture
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
+ }
src/pages/PatientRegistry.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
9
+ };
10
+
11
+ export function PatientRegistry({ onNewPatient, onSelectExisting, onBackToHome, onNext }: Props) {
12
+ const [patientId, setPatientId] = useState('');
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [results, setResults] = useState<Array<{ id: string; name: string; examDate: string }>>([]);
15
+ const [searched, setSearched] = useState(false);
16
+
17
+ // Demo in-memory patients; replace with real API later
18
+ const mockPatients = [
19
+ { id: 'PT-2025-8492', name: 'Aisha Khan', examDate: '2025-01-10' },
20
+ { id: 'P-10234', name: 'Sarah Johnson', examDate: '2024-01-15' },
21
+ { id: 'P-10235', name: 'Emily Chen', examDate: '2024-01-15' }
22
+ ];
23
+
24
+ const handleSearch = () => {
25
+ const q = patientId.trim();
26
+ setSearched(true);
27
+ if (!q) {
28
+ setError('Please enter a Patient ID');
29
+ setResults([]);
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">
40
+ <button onClick={onBackToHome} className="flex items-center gap-2 px-4 py-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600">
41
+ <ArrowLeft className="w-5 h-5 md:w-6 md:h-6" />
42
+ <span className="text-sm md:text-base">Go to Homepage</span>
43
+ </button>
44
+ </div>
45
+
46
+ <div className="w-full max-w-6xl mx-auto mb-4 md:mb-6 px-4">
47
+ <h1 className="text-2xl md:text-3xl lg:text-4xl font-extrabold text-[#0A2540]">Patient Records</h1>
48
+ </div>
49
+
50
+ <div className="w-full mx-auto p-4 md:p-8 lg:p-12 bg-white h-full min-h-[60vh]">
51
+ <div className="mb-6 md:mb-8">
52
+ <p className="text-sm md:text-base lg:text-lg text-gray-600 mb-4">Search by Patient ID or add a new patient.</p>
53
+
54
+ <div className="space-y-4">
55
+ <label className="block text-sm md:text-base font-medium text-gray-600">Search by Patient ID</label>
56
+ <div className="flex flex-col lg:flex-row gap-3 md:gap-4 items-stretch lg:items-center">
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([]);
64
+ setSearched(true);
65
+ }
66
+ }} className="px-4 py-3 md:py-4 border border-gray-200 rounded-lg bg-white text-sm md:text-base">
67
+ <option value="">Demo data</option>
68
+ <option value="all">Show all demo patients</option>
69
+ <option value="clear">Clear results</option>
70
+ </select>
71
+
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>}
79
+ </div>
80
+ </div>
81
+
82
+ <div>
83
+ {!searched && <p className="text-sm md:text-base text-gray-600">Enter a Patient ID and click Search.</p>}
84
+
85
+ {searched && results.length === 0 && <div className="py-8 text-center text-gray-600 text-sm md:text-base">No patient record found.</div>}
86
+
87
+ {results.length > 0 && (
88
+ <div className="mt-4 overflow-x-auto">
89
+ <table className="min-w-full divide-y divide-gray-200 text-xs md:text-sm">
90
+ <thead className="bg-gray-50">
91
+ <tr>
92
+ <th className="px-3 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-gray-500 uppercase">Patient ID</th>
93
+ <th className="px-3 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-gray-500 uppercase">Patient Name</th>
94
+ <th className="px-3 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-gray-500 uppercase">Examination Date</th>
95
+ <th className="px-3 md:px-4 py-2 md:py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
96
+ </tr>
97
+ </thead>
98
+ <tbody className="bg-white divide-y divide-gray-100">
99
+ {results.map(r => (
100
+ <tr key={r.id}>
101
+ <td className="px-3 md:px-4 py-2 md:py-3 text-[#0A2540] font-mono text-xs md:text-sm">{r.id}</td>
102
+ <td className="px-3 md:px-4 py-2 md:py-3 text-gray-700 font-medium text-xs md:text-sm">{r.name}</td>
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>
113
+ </td>
114
+ </tr>
115
+ ))}
116
+ </tbody>
117
+ </table>
118
+ </div>
119
+ )}
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ export default PatientRegistry;
src/pages/ReportPage.tsx ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
7
+ src: string;
8
+ stepId: string;
9
+ type: 'image' | 'video';
10
+ };
11
+
12
+ type Props = {
13
+ onBack: () => void;
14
+ onNext: () => void;
15
+ patientData?: any;
16
+ capturedImages?: CapturedImage[];
17
+ biopsyData?: any;
18
+ findings?: any;
19
+ };
20
+
21
+ export function ReportPage({
22
+ onBack,
23
+ onNext,
24
+ patientData = {},
25
+ capturedImages = [],
26
+ biopsyData = {},
27
+ findings = {}
28
+ }: Props) {
29
+ type ImageBucket = 'native' | 'acetic' | 'lugol' | 'biopsy';
30
+ type ImageItem = {
31
+ id: string;
32
+ name: string;
33
+ url: string;
34
+ file: File;
35
+ };
36
+
37
+ // Form state with dummy data
38
+ const [formData, setFormData] = useState({
39
+ regNo: patientData.regNo || 'CP-2026-001',
40
+ name: patientData.name || 'Jane Doe',
41
+ opdNo: patientData.opdNo || 'OPD-45892',
42
+ age: patientData.age || '35',
43
+ parity: patientData.parity || 'P2L2',
44
+ wo: patientData.wo || 'Married',
45
+ indication: patientData.indication || 'VIA+ / PAP+ / HPV-DNA+',
46
+ complaint: patientData.reason || 'Abnormal bleeding, post-menopausal',
47
+ examQuality: 'Adequate',
48
+ transformationZone: 'II',
49
+ acetowL: 'Present',
50
+ diagnosis: findings.diagnosis || 'Cervical intraepithelial neoplasia (CIN) Grade II with acetowhite lesion',
51
+ treatmentPlan: findings.plan || 'LEEP procedure recommended, HPV testing, follow-up in 3 months',
52
+ followUp: findings.followUp || 'Return after 3 months for post-LEEP assessment',
53
+ biopsySites: biopsyData?.sites || '12 o\'clock position - CIN II',
54
+ biopsyNotes: biopsyData?.notes || 'Multiple biopsies taken from affected areas',
55
+ doctorSignature: '',
56
+ signatureDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
57
+ });
58
+
59
+ const [imageBuckets, setImageBuckets] = useState<Record<ImageBucket, ImageItem[]>>({
60
+ native: [],
61
+ acetic: [],
62
+ lugol: [],
63
+ biopsy: []
64
+ });
65
+ const imageBucketsRef = useRef(imageBuckets);
66
+ const reportContentRef = useRef<HTMLDivElement>(null);
67
+
68
+ const handleFormChange = (field: keyof typeof formData, value: string) => {
69
+ setFormData(prev => ({
70
+ ...prev,
71
+ [field]: value
72
+ }));
73
+ };
74
+
75
+ const handleExportPDF = () => {
76
+ if (!reportContentRef.current) return;
77
+
78
+ const element = reportContentRef.current;
79
+ const patientName = patientData?.name || 'Patient';
80
+ const reportDate = new Date().toISOString().split('T')[0];
81
+
82
+ const options = {
83
+ margin: [10, 10, 10, 10],
84
+ filename: `Colposcopy_Report_${patientName}_${reportDate}.pdf`,
85
+ image: { type: 'jpeg', quality: 0.98 },
86
+ html2canvas: { scale: 2 },
87
+ jsPDF: { orientation: 'portrait', unit: 'mm', format: 'a4' }
88
+ };
89
+
90
+ html2pdf().set(options).from(element).save();
91
+ };
92
+
93
+ const addFilesToBucket = (bucket: ImageBucket, files: File[]) => {
94
+ const imageFiles = files.filter(file => file.type.startsWith('image/'));
95
+ if (!imageFiles.length) return;
96
+
97
+ const items: ImageItem[] = imageFiles.map((file, index) => ({
98
+ id: `${bucket}-${Date.now()}-${index}`,
99
+ name: file.name,
100
+ url: URL.createObjectURL(file),
101
+ file
102
+ }));
103
+
104
+ setImageBuckets(prev => ({
105
+ ...prev,
106
+ [bucket]: [...prev[bucket], ...items]
107
+ }));
108
+ };
109
+
110
+ const handleCapturedImageDragStart = (e: React.DragEvent, capturedImage: CapturedImage) => {
111
+ e.dataTransfer.setData('application/captured-image', JSON.stringify(capturedImage));
112
+ e.dataTransfer.effectAllowed = 'copy';
113
+ };
114
+
115
+ const handleSectionDrop = async (e: React.DragEvent, targetBucket: ImageBucket) => {
116
+ e.preventDefault();
117
+
118
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
119
+ const files = Array.from(e.dataTransfer.files || []);
120
+ addFilesToBucket(targetBucket, files);
121
+ return;
122
+ }
123
+
124
+ // Check for captured image from sidebar
125
+ const capturedImageData = e.dataTransfer.getData('application/captured-image');
126
+ if (capturedImageData) {
127
+ try {
128
+ const capturedImage = JSON.parse(capturedImageData) as CapturedImage;
129
+ const imageFile = await fetch(capturedImage.src)
130
+ .then(r => r.blob())
131
+ .then(blob => new File([blob], `captured-${capturedImage.id}.jpg`, { type: 'image/jpeg' }));
132
+ addFilesToBucket(targetBucket, [imageFile]);
133
+ return;
134
+ } catch {
135
+ // ignore invalid captured image data
136
+ }
137
+ }
138
+
139
+ const data = e.dataTransfer.getData('text/plain');
140
+ if (!data) return;
141
+
142
+ try {
143
+ const parsed = JSON.parse(data) as { bucket: ImageBucket; id: string };
144
+ if (!parsed?.bucket || !parsed?.id) return;
145
+ if (parsed.bucket === targetBucket) return;
146
+
147
+ setImageBuckets(prev => {
148
+ const sourceItems = prev[parsed.bucket];
149
+ const movedItem = sourceItems.find(item => item.id === parsed.id);
150
+ if (!movedItem) return prev;
151
+
152
+ return {
153
+ ...prev,
154
+ [parsed.bucket]: sourceItems.filter(item => item.id !== parsed.id),
155
+ [targetBucket]: [...prev[targetBucket], movedItem]
156
+ };
157
+ });
158
+ } catch {
159
+ // ignore invalid drag data
160
+ }
161
+ };
162
+
163
+ const handleDragOver = (e: React.DragEvent) => {
164
+ e.preventDefault();
165
+ };
166
+
167
+ const removeImage = (bucket: ImageBucket, id: string) => {
168
+ setImageBuckets(prev => ({
169
+ ...prev,
170
+ [bucket]: prev[bucket].filter(item => item.id !== id)
171
+ }));
172
+ const item = imageBuckets[bucket].find(entry => entry.id === id);
173
+ if (item) {
174
+ URL.revokeObjectURL(item.url);
175
+ }
176
+ };
177
+
178
+ useEffect(() => {
179
+ imageBucketsRef.current = imageBuckets;
180
+ }, [imageBuckets]);
181
+
182
+ useEffect(() => {
183
+ return () => {
184
+ Object.values(imageBucketsRef.current).flat().forEach((item: ImageItem) => URL.revokeObjectURL(item.url));
185
+ };
186
+ }, []);
187
+
188
+ return (
189
+ <div className="w-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-teal-50/20 print:bg-white">
190
+ <style>{`
191
+ @media print {
192
+ @page {
193
+ size: A4;
194
+ margin: 0;
195
+ }
196
+ body {
197
+ margin: 0;
198
+ padding: 0;
199
+ }
200
+ html {
201
+ margin: 0;
202
+ padding: 0;
203
+ }
204
+ }
205
+ `}</style>
206
+ <div className="relative z-10 py-6 md:py-8 print:py-0">
207
+ <div className="w-full max-w-7xl mx-auto px-4 md:px-6 print:max-w-full print:mx-0 print:px-6">
208
+
209
+ {/* Page Header */}
210
+ <div className="mb-8 bg-white rounded-2xl shadow-lg border border-gray-200 p-6 no-print print:hidden">
211
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
212
+ <div className="flex items-center gap-4">
213
+ <button
214
+ onClick={onBack}
215
+ 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"
216
+ >
217
+ <ArrowLeft className="w-5 h-5" />
218
+ </button>
219
+ <div>
220
+ <h2 className="text-2xl md:text-3xl font-bold bg-gradient-to-r from-[#0A2540] to-[#05998c] bg-clip-text text-transparent flex items-center gap-3">
221
+ <div className="bg-gradient-to-br from-[#05998c] to-[#047569] p-3 rounded-xl shadow-md">
222
+ <FileText className="w-7 h-7 text-white" />
223
+ </div>
224
+ Colposcopy Examination Report
225
+ </h2>
226
+ <p className="text-sm text-gray-500 mt-1 ml-1">Professional Medical Documentation</p>
227
+ </div>
228
+ </div>
229
+ <div className="flex gap-3">
230
+ <button
231
+ onClick={handleExportPDF}
232
+ 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"
233
+ >
234
+ <Download className="w-4 h-4" />
235
+ Export PDF
236
+ </button>
237
+ <button
238
+ onClick={onNext}
239
+ 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"
240
+ >
241
+ Complete
242
+ <CheckCircle className="w-4 h-4" />
243
+ </button>
244
+ </div>
245
+ </div>
246
+ </div>
247
+
248
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 print:grid-cols-1">
249
+
250
+ {/* Report Content */}
251
+ <div className="lg:col-span-2 w-full" ref={reportContentRef}>
252
+ <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]">
253
+
254
+ {/* Report Header with Gradient */}
255
+ <div className="bg-white border-b-4 border-gray-800 p-6 text-center print:p-3 print:border-b-2">
256
+ <h1 className="text-2xl font-bold text-gray-900 mb-1 print:text-lg">COLPOSCOPY EXAMINATION REPORT</h1>
257
+ <p className="text-sm text-gray-600 print:text-xs">Medical Documentation</p>
258
+ <div className="flex items-center justify-center gap-2 text-gray-600 text-sm mt-3 print:mt-1.5 print:text-xs">
259
+ <span>Date: {new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
260
+ </div>
261
+ </div>
262
+
263
+ <div className="p-8 space-y-6 print:p-3 print:space-y-2 overflow-y-auto print:overflow-visible">
264
+
265
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
266
+ <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>
267
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3 print:gap-1.5 text-sm">
268
+ <div>
269
+ <span className="text-xs font-semibold text-gray-600">Reg. No.</span>
270
+ <input
271
+ type="text"
272
+ value={formData.regNo}
273
+ onChange={(e) => handleFormChange('regNo', e.target.value)}
274
+ 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"
275
+ />
276
+ </div>
277
+ <div>
278
+ <span className="text-xs font-semibold text-gray-600">Name</span>
279
+ <input
280
+ type="text"
281
+ value={formData.name}
282
+ onChange={(e) => handleFormChange('name', e.target.value)}
283
+ 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"
284
+ />
285
+ </div>
286
+ <div>
287
+ <span className="text-xs font-semibold text-gray-600">OPD No.</span>
288
+ <input
289
+ type="text"
290
+ value={formData.opdNo}
291
+ onChange={(e) => handleFormChange('opdNo', e.target.value)}
292
+ 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"
293
+ />
294
+ </div>
295
+ <div>
296
+ <span className="text-xs font-semibold text-gray-600">Age</span>
297
+ <input
298
+ type="text"
299
+ value={formData.age}
300
+ onChange={(e) => handleFormChange('age', e.target.value)}
301
+ 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"
302
+ />
303
+ </div>
304
+ <div>
305
+ <span className="text-xs font-semibold text-gray-600">Parity</span>
306
+ <input
307
+ type="text"
308
+ value={formData.parity}
309
+ onChange={(e) => handleFormChange('parity', e.target.value)}
310
+ 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"
311
+ />
312
+ </div>
313
+ <div>
314
+ <span className="text-xs font-semibold text-gray-600">W/O</span>
315
+ <input
316
+ type="text"
317
+ value={formData.wo}
318
+ onChange={(e) => handleFormChange('wo', e.target.value)}
319
+ 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"
320
+ />
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ {/* Medical History */}
326
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
327
+ <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>
328
+ <div className="space-y-3 print:space-y-1 text-sm">
329
+ <div>
330
+ <span className="text-xs font-semibold text-gray-600">Indication for Colposcopy:</span>
331
+ <input
332
+ type="text"
333
+ value={formData.indication}
334
+ onChange={(e) => handleFormChange('indication', e.target.value)}
335
+ 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"
336
+ />
337
+ </div>
338
+ <div>
339
+ <span className="text-xs font-semibold text-gray-600">Chief Complaint:</span>
340
+ <textarea
341
+ value={formData.complaint}
342
+ onChange={(e) => handleFormChange('complaint', e.target.value)}
343
+ 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"
344
+ rows={2}
345
+ />
346
+ </div>
347
+ </div>
348
+ </div>
349
+
350
+ {/* Examination Details */}
351
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
352
+ <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>
353
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3 print:gap-1.5 text-sm">
354
+ <div>
355
+ <span className="text-xs font-semibold text-gray-600">Exam Quality</span>
356
+ <select
357
+ value={formData.examQuality}
358
+ onChange={(e) => handleFormChange('examQuality', e.target.value)}
359
+ 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"
360
+ >
361
+ <option>Adequate</option>
362
+ <option>Inadequate</option>
363
+ </select>
364
+ </div>
365
+ <div>
366
+ <span className="text-xs font-semibold text-gray-600">Transformation Zone</span>
367
+ <select
368
+ value={formData.transformationZone}
369
+ onChange={(e) => handleFormChange('transformationZone', e.target.value)}
370
+ 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"
371
+ >
372
+ <option>I</option>
373
+ <option>II</option>
374
+ <option>III</option>
375
+ </select>
376
+ </div>
377
+ <div>
378
+ <span className="text-xs font-semibold text-gray-600">Acetowhite Lesion</span>
379
+ <select
380
+ value={formData.acetowL}
381
+ onChange={(e) => handleFormChange('acetowL', e.target.value)}
382
+ 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"
383
+ >
384
+ <option>Present</option>
385
+ <option>Absent</option>
386
+ </select>
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ {/* Image Sections */}
392
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
393
+ <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>
394
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 print:gap-2">
395
+ {(
396
+ [
397
+ { key: 'native', label: 'Native Image', color: 'from-blue-500 to-blue-600' },
398
+ { key: 'acetic', label: 'Acetic Acid', color: 'from-purple-500 to-purple-600' },
399
+ { key: 'lugol', label: 'Lugol\'s Iodine', color: 'from-amber-500 to-amber-600' },
400
+ { key: 'biopsy', label: 'Biopsy Site', color: 'from-red-500 to-red-600' }
401
+ ] as { key: ImageBucket; label: string; color: string }[]
402
+ ).map(section => (
403
+ <div
404
+ key={section.key}
405
+ onDrop={(e) => handleSectionDrop(e, section.key)}
406
+ onDragOver={handleDragOver}
407
+ 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]"
408
+ >
409
+ <div className="text-xs font-semibold text-gray-700 mb-2 print:mb-1 print:text-xs">{section.label}</div>
410
+ {imageBuckets[section.key].length === 0 ? (
411
+ <div className="text-center text-gray-400 text-xs py-6 print:py-2 border border-dashed border-gray-300 rounded print:text-xs">
412
+ <p>Drag & drop</p>
413
+ </div>
414
+ ) : (
415
+ <div className="grid grid-cols-2 gap-2 print:gap-1">
416
+ {imageBuckets[section.key].map(item => (
417
+ <div key={item.id} className="relative group">
418
+ <img
419
+ src={item.url}
420
+ alt={item.name}
421
+ className="w-full h-16 print:h-12 object-cover border border-gray-300"
422
+ />
423
+ <button
424
+ onClick={() => removeImage(section.key, item.id)}
425
+ 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"
426
+ >
427
+ ×
428
+ </button>
429
+ </div>
430
+ ))}
431
+ </div>
432
+ )}
433
+ </div>
434
+ ))}
435
+ </div>
436
+ </div>
437
+
438
+ {/* Biopsy Marking Section */}
439
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
440
+ <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>
441
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 print:gap-2">
442
+ {/* Biopsy Image */}
443
+ <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">
444
+ <div className="text-xs font-semibold text-gray-700 mb-2 print:mb-1 print:text-xs">Biopsy Marking Image</div>
445
+ {biopsyData?.markedImage ? (
446
+ <div className="relative w-full h-full flex items-center justify-center bg-black rounded">
447
+ <img
448
+ src={biopsyData.markedImage}
449
+ alt="Biopsy Marking"
450
+ className="w-full h-40 print:h-24 object-contain"
451
+ />
452
+ </div>
453
+ ) : (
454
+ <div className="text-center text-gray-400 text-xs py-10 print:py-4">
455
+ <p>No biopsy marking</p>
456
+ </div>
457
+ )}
458
+ </div>
459
+
460
+ {/* Biopsy Information */}
461
+ <div className="space-y-3 print:space-y-1.5">
462
+ <div>
463
+ <span className="text-xs font-semibold text-gray-600">Biopsy Sites:</span>
464
+ <textarea
465
+ value={formData.biopsySites}
466
+ onChange={(e) => handleFormChange('biopsySites', e.target.value)}
467
+ 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"
468
+ rows={3}
469
+ />
470
+ </div>
471
+ <div>
472
+ <span className="text-xs font-semibold text-gray-600">Biopsy Notes:</span>
473
+ <textarea
474
+ value={formData.biopsyNotes}
475
+ onChange={(e) => handleFormChange('biopsyNotes', e.target.value)}
476
+ 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"
477
+ rows={3}
478
+ />
479
+ </div>
480
+ </div>
481
+ </div>
482
+ </div>
483
+
484
+ {/* Diagnosis and Plan */}
485
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200">
486
+ <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>
487
+ <div className="space-y-2 text-sm print:space-y-0.5 print:text-xs">
488
+ <div>
489
+ <span className="text-xs font-semibold text-gray-600">Colposcopic Diagnosis:</span>
490
+ <textarea
491
+ value={formData.diagnosis}
492
+ onChange={(e) => handleFormChange('diagnosis', e.target.value)}
493
+ 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"
494
+ rows={2}
495
+ />
496
+ </div>
497
+ <div>
498
+ <span className="text-xs font-semibold text-gray-600">Treatment Plan:</span>
499
+ <textarea
500
+ value={formData.treatmentPlan}
501
+ onChange={(e) => handleFormChange('treatmentPlan', e.target.value)}
502
+ 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"
503
+ rows={2}
504
+ />
505
+ </div>
506
+ <div>
507
+ <span className="text-xs font-semibold text-gray-600">Follow-up Plan:</span>
508
+ <textarea
509
+ value={formData.followUp}
510
+ onChange={(e) => handleFormChange('followUp', e.target.value)}
511
+ 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"
512
+ rows={2}
513
+ />
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ {/* Signature Section */}
519
+ <div className="border border-gray-300 p-4 print:p-2 print:border-gray-200 mt-6 print:mt-2">
520
+ <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>
521
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 print:gap-2 text-sm print:text-xs">
522
+ <div>
523
+ <p className="text-xs font-semibold text-gray-600 mb-8 print:mb-3">Patient Signature</p>
524
+ <div className="border-b-2 border-gray-700 w-full h-12 print:h-6"></div>
525
+ </div>
526
+ <div>
527
+ <p className="text-xs font-semibold text-gray-600 mb-8 print:mb-3">Doctor Signature</p>
528
+ <input
529
+ type="text"
530
+ value={formData.doctorSignature}
531
+ onChange={(e) => handleFormChange('doctorSignature', e.target.value)}
532
+ placeholder="Dr. Name"
533
+ 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"
534
+ />
535
+ </div>
536
+ <div>
537
+ <p className="text-xs font-semibold text-gray-600 mb-2 print:mb-1">Date</p>
538
+ <input
539
+ type="text"
540
+ value={formData.signatureDate}
541
+ onChange={(e) => handleFormChange('signatureDate', e.target.value)}
542
+ 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"
543
+ />
544
+ </div>
545
+ </div>
546
+ </div>
547
+
548
+ </div>
549
+ </div>
550
+ </div>
551
+
552
+ {/* Image Library Sidebar */}
553
+ <div className="lg:col-span-1 print:hidden">
554
+ <div className="bg-white border border-gray-300 rounded-2xl shadow-lg p-4 sticky top-[829px]">
555
+ <h3 className="text-sm font-bold text-gray-900 mb-4 pb-2 border-b border-gray-400">IMAGE LIBRARY</h3>
556
+ {capturedImages.length > 0 ? (
557
+ <div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
558
+ {Object.entries(
559
+ capturedImages.reduce((acc, img) => {
560
+ const key = img.stepId;
561
+ if (!acc[key]) acc[key] = [];
562
+ acc[key].push(img);
563
+ return acc;
564
+ }, {} as Record<string, CapturedImage[]>)
565
+ ).map(([stepId, images]) => (
566
+ <div key={stepId} className="bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200 rounded-lg p-3 shadow-sm">
567
+ <div className="flex items-center gap-2 mb-2">
568
+ <div className={`w-2 h-2 rounded-full ${
569
+ stepId === 'native' ? 'bg-blue-500' :
570
+ stepId === 'acetowhite' ? 'bg-purple-500' :
571
+ stepId === 'greenFilter' ? 'bg-green-500' :
572
+ stepId === 'lugol' ? 'bg-amber-500' : 'bg-gray-500'
573
+ }`}></div>
574
+ <span className="text-xs font-bold text-gray-700 uppercase">
575
+ {stepId === 'native' ? 'Native' :
576
+ stepId === 'acetowhite' ? 'Acetic Acid' :
577
+ stepId === 'greenFilter' ? 'Green Filter' :
578
+ stepId === 'lugol' ? 'Lugol' : stepId}
579
+ </span>
580
+ </div>
581
+ <div className="grid grid-cols-2 gap-2">
582
+ {images.map(img => (
583
+ <div key={img.id} className="relative group">
584
+ {img.type === 'video' ? (
585
+ <div className="w-full h-20 bg-gray-100 border border-gray-200 flex items-center justify-center">
586
+ <Camera className="w-5 h-5 text-gray-400" />
587
+ </div>
588
+ ) : (
589
+ <img
590
+ src={img.src}
591
+ alt={`Captured ${stepId}`}
592
+ draggable
593
+ onDragStart={(e) => handleCapturedImageDragStart(e, img)}
594
+ className="w-full h-20 object-cover border border-gray-200 cursor-grab hover:border-blue-500 hover:shadow-md transition-all"
595
+ />
596
+ )}
597
+ </div>
598
+ ))}
599
+ </div>
600
+ </div>
601
+ ))}
602
+ </div>
603
+ ) : (
604
+ <div className="text-center py-8 text-gray-400">
605
+ <p className="text-sm font-medium">No captured images yet</p>
606
+ <p className="text-xs mt-1">Images will appear here after capture</p>
607
+ </div>
608
+ )}
609
+ </div>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ </div>
614
+ </div>
615
+ );
616
+ }
tailwind.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ content: [
3
+ './index.html',
4
+ './src/**/*.{js,ts,jsx,tsx}'
5
+ ],
6
+ theme: {
7
+ extend: {
8
+ screens: {
9
+ 'xs': '320px',
10
+ 'sm': '640px',
11
+ 'md': '768px',
12
+ 'lg': '1024px',
13
+ 'xl': '1280px',
14
+ '2xl': '1536px',
15
+ 'tablet': '768px',
16
+ 'tablet-landscape': { 'raw': '(min-width: 768px) and (orientation: landscape)' },
17
+ },
18
+ spacing: {
19
+ 'safe': 'max(1rem, env(safe-area-inset-bottom))',
20
+ }
21
+ }
22
+ }
23
+ }
tsconfig.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true
22
+ },
23
+ "include": ["src"],
24
+ "references": [{ "path": "./tsconfig.node.json" }]
25
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true
9
+ },
10
+ "include": ["vite.config.ts"]
11
+ }
vite.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })