Commit ·
bab7e89
0
Parent(s):
Initial deployment of Pathora Colposcopy Assistant
Browse files- .dockerignore +12 -0
- .eslintrc.cjs +18 -0
- .gitattributes +6 -0
- .gitignore +24 -0
- Dockerfile +30 -0
- README.md +132 -0
- index.html +15 -0
- package-lock.json +0 -0
- package.json +34 -0
- postcss.config.js +6 -0
- public/AI_Demo_img.png +3 -0
- public/C87Aceto_(1).jpg +3 -0
- public/banner.jpeg +3 -0
- public/greenC87Aceto_(1).jpg +3 -0
- public/white_logo.png +3 -0
- src/App.tsx +121 -0
- src/components/AceticAnnotator.tsx +767 -0
- src/components/ChatBot.tsx +135 -0
- src/components/Footer.tsx +40 -0
- src/components/Header.tsx +38 -0
- src/components/ImageAnnotator.tsx +620 -0
- src/components/ImagingObservations.tsx +328 -0
- src/components/PatientHistoryForm.tsx +391 -0
- src/components/Sidebar.tsx +57 -0
- src/index.css +131 -0
- src/index.tsx +10 -0
- src/pages/AcetowhiteExamPage.tsx +569 -0
- src/pages/BiopsyMarking.tsx +578 -0
- src/pages/Compare.tsx +419 -0
- src/pages/ExaminationRecordsPage.tsx +475 -0
- src/pages/GreenFilterPage.tsx +314 -0
- src/pages/GuidedCapturePage.tsx +952 -0
- src/pages/HomePage.tsx +45 -0
- src/pages/LugolExamPage.tsx +557 -0
- src/pages/PatientHistoryPage.tsx +20 -0
- src/pages/PatientRegistry.tsx +126 -0
- src/pages/ReportPage.tsx +616 -0
- tailwind.config.js +23 -0
- tsconfig.json +25 -0
- tsconfig.node.json +11 -0
- vite.config.ts +7 -0
.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
|
public/C87Aceto_(1).jpg
ADDED
|
Git LFS Details
|
public/banner.jpeg
ADDED
|
Git LFS Details
|
public/greenC87Aceto_(1).jpg
ADDED
|
Git LFS Details
|
public/white_logo.png
ADDED
|
Git LFS Details
|
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'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 |
+
})
|