Spaces:
Running
Running
| import React, { useState, useEffect, useRef } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import { motion } from 'motion/react'; | |
| import { | |
| User, | |
| UserPen, | |
| Camera, | |
| ClipboardCheck, | |
| Globe, | |
| CheckCircle2, | |
| WifiOff, | |
| Lightbulb, | |
| Upload, | |
| Activity, | |
| ShieldCheck, | |
| AlertTriangle, | |
| FileText, | |
| Save, | |
| Share2, | |
| Download, | |
| Search, | |
| Filter, | |
| ChevronRight, | |
| ChevronLeft, | |
| ZoomIn, | |
| X, | |
| PlusCircle, | |
| History, | |
| Loader2, | |
| ArrowRight, | |
| HeartPulse, | |
| Printer, | |
| BookOpen, | |
| Home, | |
| Trash2, | |
| Clock, | |
| Heart | |
| } from 'lucide-react'; | |
| import { | |
| SAMPLE_CASE_TEMPLATES, | |
| CaseRecord, | |
| PatientDetails, | |
| DiagnosticResult | |
| } from './types'; | |
| import { TRANSLATIONS, LanguageOption } from './locales'; | |
| import { TreatmentRecommendations} from './components/TreatmentRecommendations'; | |
| import { jsPDF } from 'jspdf'; | |
| import { clearProfile, ClinicianProfile, ClinicianSetup, loadProfile } from './components/ClinicianSetup'; | |
| export default function App() { | |
| // ── Clinician profile ────────────────────────────────────────────────────── | |
| const [clinician, setClinician] = useState<ClinicianProfile | null>(() => loadProfile()); | |
| const handleProfileComplete = (profile: ClinicianProfile) => { | |
| setClinician({ ...profile }); | |
| }; | |
| // ── Navigation ───────────────────────────────────────────────────────────── | |
| const [screen, setScreen] = useState<'home' | 'assessment-info' | 'assessment-capture' | 'assessment-review' | 'referral-note' | 'case-history'>('home'); | |
| const [lang, setLang] = useState<LanguageOption>('English'); | |
| const [langMenuOpen, setLangMenuOpen] = useState(false); | |
| // ── Cases ────────────────────────────────────────────────────────────────── | |
| const [cases, setCases] = useState<CaseRecord[]>([]); | |
| // ── Assessment state ─────────────────────────────────────────────────────── | |
| const [patient, setPatient] = useState<PatientDetails>({ name: '', age: '', sex: '', contactNumber: '', symptoms: '' }); | |
| const [capturedImage, setCapturedImage] = useState<string | null>(null); | |
| const [customFileSelected, setCustomFileSelected] = useState<boolean>(false); | |
| const [analysisLoading, setAnalysisLoading] = useState(false); | |
| const [loadingText, setLoadingText] = useState(''); | |
| const [activeAnalysisResult, setActiveAnalysisResult] = useState<DiagnosticResult | null>(null); | |
| const [activeCaseId, setActiveCaseId] = useState<string>(''); | |
| const [showSuccessAnimation, setShowSuccessAnimation] = useState(false); | |
| const [referralNoteLoading, setReferralNoteLoading] = useState(false); | |
| // ── Prescribed Medication (Editable for Referral) ──────────────────────── | |
| const [prescribedMedication, setPrescribedMedication] = useState<string>(''); | |
| const [prescribedRegimen, setPrescribedRegimen] = useState<string>(''); | |
| const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null); | |
| const [viewMode, setViewMode] = useState<'card' | 'pdf'>('card'); | |
| useEffect(() => { | |
| if (activeAnalysisResult) { | |
| if (activeAnalysisResult.therapyRegimen) { | |
| setPrescribedMedication(activeAnalysisResult.therapyRegimen.medication || ''); | |
| setPrescribedRegimen(activeAnalysisResult.therapyRegimen.regimen || ''); | |
| } else { | |
| setPrescribedMedication(''); | |
| setPrescribedRegimen(''); | |
| } | |
| } | |
| }, [activeAnalysisResult]); | |
| // ── Case history UI ──────────────────────────────────────────────────────── | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [filterUrgency, setFilterUrgency] = useState<'All' | 'High' | 'Moderate' | 'Low'>('All'); | |
| const [selectedDetailsCase, setSelectedDetailsCase] = useState<CaseRecord | null>(null); | |
| const [zoomImage, setZoomImage] = useState(false); | |
| const [caseToDelete, setCaseToDelete] = useState<CaseRecord | null>(null); | |
| // ── FAQ state ────────────────────────────────────────────────────────────── | |
| const [faqOpenState, setFaqOpenState] = useState<Record<number, boolean>>({ 0: true }); | |
| // ── Signature canvas ─────────────────────────────────────────────────────── | |
| const signatureCanvasRef = useRef<HTMLCanvasElement | null>(null); | |
| const [isDrawingSig, setIsDrawingSig] = useState(false); | |
| const startDrawingSig = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => { | |
| setIsDrawingSig(true); | |
| const canvas = signatureCanvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| ctx.strokeStyle = '#0A1628'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = ('clientX' in e) ? e.clientX - rect.left : e.touches[0].clientX - rect.left; | |
| const y = ('clientY' in e) ? e.clientY - rect.top : e.touches[0].clientY - rect.top; | |
| ctx.beginPath(); ctx.moveTo(x, y); | |
| }; | |
| const drawSig = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => { | |
| if (!isDrawingSig) return; | |
| const canvas = signatureCanvasRef.current; if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); if (!ctx) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = ('clientX' in e) ? e.clientX - rect.left : e.touches[0].clientX - rect.left; | |
| const y = ('clientY' in e) ? e.clientY - rect.top : e.touches[0].clientY - rect.top; | |
| ctx.lineTo(x, y); ctx.stroke(); e.preventDefault(); | |
| }; | |
| const stopDrawingSig = () => setIsDrawingSig(false); | |
| const clearSig = () => { | |
| const canvas = signatureCanvasRef.current; if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); if (!ctx) return; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| }; | |
| // ── Sync / health ────────────────────────────────────────────────────────── | |
| const [dbStatus, setDbStatus] = useState<'online' | 'offline'>('online'); | |
| const [syncStatus, setSyncStatus] = useState<'synced' | 'pending' | 'syncing'>('synced'); | |
| const [lastSynced, setLastSynced] = useState<string | null>(null); | |
| const [autoSyncEnabled] = useState<boolean>(true); | |
| const triggerCloudSync = async (list: CaseRecord[]) => { | |
| if (list.length === 0) return; | |
| setSyncStatus('syncing'); | |
| try { | |
| await Promise.all(list.map(c => fetch('/api/cases', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(c) }))); | |
| setSyncStatus('synced'); setDbStatus('online'); | |
| const ts = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); | |
| setLastSynced(ts); localStorage.setItem('dermadetect_last_synced', ts); | |
| } catch { setSyncStatus('pending'); setDbStatus('offline'); } | |
| }; | |
| const probeDatabaseHealth = async () => { | |
| try { | |
| const t0 = performance.now(); | |
| const r = await fetch('/api/health'); | |
| if (r.ok) { setDbStatus('online'); return true; } | |
| setDbStatus('offline'); return false; | |
| } catch { setDbStatus('offline'); return false; } | |
| }; | |
| useEffect(() => { | |
| const saved = localStorage.getItem('dermadetect_last_synced'); | |
| if (saved) setLastSynced(saved); | |
| }, []); | |
| useEffect(() => { | |
| const init = async () => { | |
| const online = await probeDatabaseHealth(); | |
| if (online && autoSyncEnabled && cases.length > 0) await triggerCloudSync(cases); | |
| }; | |
| if (cases.length > 0) init(); | |
| const id = setInterval(async () => { | |
| const online = await probeDatabaseHealth(); | |
| if (online && autoSyncEnabled && cases.length > 0) await triggerCloudSync(cases); | |
| }, 15000); | |
| return () => clearInterval(id); | |
| }, [cases, autoSyncEnabled]); | |
| // ── Camera ───────────────────────────────────────────────────────────────── | |
| const [isCameraActive, setIsCameraActive] = useState(false); | |
| const [cameraError, setCameraError] = useState<string | null>(null); | |
| const videoRef = useRef<HTMLVideoElement>(null); | |
| const streamRef = useRef<MediaStream | null>(null); | |
| // ── Load cases ───────────────────────────────────────────────────────────── | |
| useEffect(() => { | |
| const load = async () => { | |
| try { | |
| const r = await fetch('/api/cases'); | |
| if (r.ok) { | |
| const d = await r.json(); | |
| if (d.cases?.length > 0) { setCases(d.cases); localStorage.setItem('dermadetect_cases', JSON.stringify(d.cases)); return; } | |
| } | |
| } catch { console.warn('Server unreachable, falling back to localStorage'); } | |
| const stored = localStorage.getItem('dermadetect_cases'); | |
| try { setCases(stored ? JSON.parse(stored) : []); } catch { setCases([]); } | |
| }; | |
| load(); | |
| }, []); | |
| const syncCasesToStorage = (updated: CaseRecord[]) => { | |
| setCases(updated); | |
| localStorage.setItem('dermadetect_cases', JSON.stringify(updated)); | |
| }; | |
| const t = TRANSLATIONS[lang]; | |
| const resetAndStartAssessment = () => { | |
| setPatient({ name: '', age: '', sex: '', contactNumber: '', symptoms: '' }); | |
| setCapturedImage(null); setCustomFileSelected(false); | |
| setActiveAnalysisResult(null); setActiveCaseId(''); | |
| setScreen('assessment-info'); stopWebcam(); | |
| }; | |
| const startWebcam = async () => { | |
| setIsCameraActive(true); setCameraError(null); | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: 640, height: 480 } }); | |
| streamRef.current = stream; | |
| if (videoRef.current) videoRef.current.srcObject = stream; | |
| } catch { setCameraError("Webcam not accessible. Please upload from Gallery."); setIsCameraActive(false); } | |
| }; | |
| const stopWebcam = () => { | |
| streamRef.current?.getTracks().forEach(t => t.stop()); | |
| streamRef.current = null; setIsCameraActive(false); | |
| }; | |
| const captureFrame = () => { | |
| if (!videoRef.current) return; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = videoRef.current.videoWidth || 640; | |
| canvas.height = videoRef.current.videoHeight || 480; | |
| const ctx = canvas.getContext('2d'); | |
| if (ctx) { ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height); setCapturedImage(canvas.toDataURL('image/jpeg')); setCustomFileSelected(true); stopWebcam(); } | |
| }; | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { setCapturedImage(reader.result as string); setCustomFileSelected(true); stopWebcam(); }; | |
| reader.readAsDataURL(file); | |
| }; | |
| // ── AI Analysis ──────────────────────────────────────────────────────────── | |
| const runAiAnalysis = async () => { | |
| if (!capturedImage) return; | |
| setAnalysisLoading(true); | |
| const messages = [ | |
| "Accessing secured local database sandbox...", | |
| "Encrypting transmission packet according to medical standard guidelines...", | |
| "Extracting skin lesion pigmentation & margins...", | |
| "Invoking DermaDefect diagnostic model...", | |
| "Conducting diagnostic taxonomy matrix parsing...", | |
| "Finalizing triage urgency confidence ratings...", | |
| ]; | |
| let i = 0; setLoadingText(messages[0]); | |
| const timer = setInterval(() => { i++; if (i < messages.length) setLoadingText(messages[i]); }, 700); | |
| try { | |
| const response = await fetch('/api/analyze-skin', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image: capturedImage, symptoms: patient.symptoms, patientInfo: { name: patient.name, age: patient.age, sex: patient.sex, contactNumber: patient.contactNumber } }), | |
| }); | |
| if (!response.ok) { const e = await response.text(); throw new Error(`Server error ${response.status}: ${e}`); } | |
| const raw = await response.json(); | |
| const result: DiagnosticResult = { | |
| primaryFinding: raw.primaryFinding ?? 'Unknown', | |
| confidence: raw.confidence ?? 0, | |
| urgency: (['High','Moderate','Low'].includes(raw.urgency) ? raw.urgency : 'Moderate') as 'High'|'Moderate'|'Low', | |
| urgencyText: raw.urgencyText ?? '', | |
| treatmentNotes: Array.isArray(raw.treatmentNotes) ? raw.treatmentNotes : [], | |
| recommendedAction: raw.recommendedAction ?? '', | |
| referralNote: raw.referralNote ?? '', | |
| conditionCode: raw.conditionCode ?? '', | |
| heatmap_b64: raw.heatmap_b64 ?? undefined, | |
| therapyRegimen: raw.therapyRegimen ?? undefined, | |
| patientHandout: raw.patientHandout ?? undefined, | |
| }; | |
| await new Promise(r => setTimeout(r, 800)); | |
| clearInterval(timer); | |
| setActiveAnalysisResult(result); | |
| setActiveCaseId(`DD-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`); | |
| setScreen('assessment-review'); | |
| } catch (err) { | |
| clearInterval(timer); | |
| await new Promise(r => setTimeout(r, 800)); | |
| window.alert(`Analysis failed: ${err instanceof Error ? err.message : 'Unknown error'}`); | |
| } finally { setAnalysisLoading(false); } | |
| }; | |
| // ── Lazy referral note ───────────────────────────────────────────────────── | |
| const handleFormatReferral = async () => { | |
| if (!activeAnalysisResult) return; | |
| if (activeAnalysisResult.referralNote) { setScreen('referral-note'); return; } | |
| setReferralNoteLoading(true); | |
| try { | |
| const response = await fetch('/api/referral-note', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| predictions: activeAnalysisResult.allPredictions ?? [], | |
| patient_name: patient.name, | |
| patient_age: patient.age, | |
| patient_sex: patient.sex, | |
| symptoms: patient.symptoms, | |
| primary_finding: activeAnalysisResult.primaryFinding, | |
| urgency: activeAnalysisResult.urgency, | |
| urgency_text: activeAnalysisResult.urgencyText, | |
| clinician_name: clinician?.name ?? '', | |
| facility_name: clinician?.facilityName ?? '', | |
| district: clinician?.district ?? '', | |
| region: clinician?.region ?? '', | |
| }), | |
| }); | |
| const data = await response.json(); | |
| setActiveAnalysisResult(prev => prev ? { ...prev, referralNote: data.referralNote } : prev); | |
| setScreen('referral-note'); | |
| } catch { setScreen('referral-note'); } | |
| finally { setReferralNoteLoading(false); } | |
| }; | |
| // ── Save case ────────────────────────────────────────────────────────────── | |
| const saveCaseRecord = async () => { | |
| if (!activeAnalysisResult || !capturedImage) return; | |
| const updatedFinding = { | |
| ...activeAnalysisResult, | |
| therapyRegimen: { | |
| ...activeAnalysisResult.therapyRegimen, | |
| medication: prescribedMedication, | |
| regimen: prescribedRegimen, | |
| dosage: activeAnalysisResult.therapyRegimen?.dosage || '', | |
| contraindications: activeAnalysisResult.therapyRegimen?.contraindications || '', | |
| warningNote: activeAnalysisResult.therapyRegimen?.warningNote || '', | |
| } | |
| }; | |
| const newRecord: CaseRecord = { | |
| id: activeCaseId, | |
| patient: { ...patient }, | |
| date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), | |
| finding: updatedFinding, | |
| image: capturedImage, | |
| healthWorker: clinician?.name ?? 'Unknown', | |
| saved: true, | |
| }; | |
| setActiveAnalysisResult(updatedFinding); | |
| syncCasesToStorage([newRecord, ...cases]); | |
| try { await fetch('/api/cases', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newRecord) }); } | |
| catch { console.warn('Failed to persist case to server'); } | |
| setShowSuccessAnimation(true); | |
| setTimeout(() => { setShowSuccessAnimation(false); setScreen('case-history'); window.scrollTo({ top: 0, behavior: 'smooth' }); }, 1800); | |
| }; | |
| // ── Delete case ──────────────────────────────────────────────────────────── | |
| const deleteCaseRecord = async (id: string) => { | |
| syncCasesToStorage(cases.filter(c => c.id !== id)); | |
| try { await fetch(`/api/cases/${id}`, { method: 'DELETE' }); } catch {} | |
| if (selectedDetailsCase?.id === id) setSelectedDetailsCase(null); | |
| setCaseToDelete(null); | |
| }; | |
| // ── PDF helpers ──────────────────────────────────────────────────────────── | |
| const clinicianName = clinician?.name ?? 'Health Worker'; | |
| const clinicianRole = clinician?.role ?? 'Community Health Worker'; | |
| const clinicianFacility = clinician?.facilityName ?? 'Community Clinic'; | |
| const clinicianDistrict = clinician?.district ?? ''; | |
| const clinicianRegion = clinician?.region ?? ''; | |
| const clinicianContact = clinician?.contact ?? ''; | |
| const downloadReferralNotePdf = () => { | |
| if (!activeAnalysisResult) return; | |
| try { | |
| const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); | |
| const pageWidth = 210; const pageHeight = 297; const margin = 12; const contentWidth = 186; | |
| // Colors | |
| const navyDark = [10, 51, 105]; // #0a3369 — Header background | |
| const borderSlate = [226, 232, 240]; // #e2e8f0 | |
| const urgencyStr = activeAnalysisResult.urgency || 'Low'; | |
| const isHigh = urgencyStr === 'High'; | |
| const isMod = urgencyStr === 'Moderate'; | |
| const urgencyColor = isHigh ? [216, 90, 48] : isMod ? [239, 159, 39] : [8, 47, 73]; // #D85A30, #EF9F27, #082F49 | |
| // Clean non-Unicode alerts to prevent '%T' glitch | |
| const urgencyTextStr = isHigh | |
| ? 'URGENT — Immediate referral required. Do not delay.' | |
| : isMod | |
| ? 'MODERATE — Refer to clinic within 3 days for assessment and treatment.' | |
| : 'MILD — Can be managed locally. Refer if no improvement in 7 days.'; | |
| const cleanRefId = activeCaseId || `DD-${new Date().getFullYear()}-00847`; | |
| let y = margin; | |
| // 1. Header Band | |
| doc.setFillColor(navyDark[0], navyDark[1], navyDark[2]); | |
| doc.rect(margin, y, contentWidth, 18, 'F'); | |
| // Logo & Brand text on left | |
| doc.setFillColor(255, 255, 255, 0.2); | |
| doc.rect(margin + 5, y + 4, 10, 10, 'F'); | |
| doc.setTextColor(255, 255, 255); | |
| doc.setFont('helvetica', 'bold'); doc.setFontSize(10); | |
| doc.text('DD', margin + 7.5, y + 10.5); | |
| doc.setFontSize(11); | |
| doc.text('DermaDetect', margin + 18, y + 8); | |
| doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); | |
| doc.setTextColor(230, 240, 255); | |
| doc.text('AI-Powered Skin Assessment', margin + 18, y + 12); | |
| // Title on right | |
| doc.setTextColor(255, 255, 255); | |
| doc.setFont('helvetica', 'bold'); doc.setFontSize(10); | |
| const titleStr = 'CLINICAL REFERRAL NOTE'; | |
| doc.text(titleStr, margin + contentWidth - 5 - doc.getTextWidth(titleStr), y + 8); | |
| doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); | |
| doc.setTextColor(230, 240, 255); | |
| const refStr = `REF: ${cleanRefId}`; | |
| doc.text(refStr, margin + contentWidth - 5 - doc.getTextWidth(refStr), y + 12); | |
| y += 22; | |
| // 2. Alert Stripe (Zero glyph glitches, smaller text size to avoid clipping) | |
| doc.setFillColor(urgencyColor[0], urgencyColor[1], urgencyColor[2]); | |
| doc.rect(margin, y, contentWidth, 9, 'F'); | |
| doc.setTextColor(255, 255, 255); | |
| doc.setFont('helvetica', 'bold'); doc.setFontSize(7.5); // reduced to 7.5 to fit perfectly | |
| // Draw a tiny native white circle instead of Unicode bullet to prevent %T glitch | |
| doc.setFillColor(255, 255, 255); | |
| const alertTextX = margin + (contentWidth - doc.getTextWidth(urgencyTextStr)) / 2; | |
| doc.circle(alertTextX - 2.5, y + 4.2, 0.7, 'F'); | |
| doc.text(urgencyTextStr, alertTextX + 1.5, y + 6.2); | |
| y += 15; | |
| const col1Width = 100; | |
| const col2Width = contentWidth - col1Width - 8; // 78 | |
| const col2X = margin + col1Width + 8; | |
| let leftY = y; | |
| let rightY = y; | |
| // ── LEFT COLUMN ───────────────────────────────────────────────────────── | |
| // Patient Info | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text('PATIENT INFORMATION', margin, leftY); | |
| leftY += 2; | |
| doc.setDrawColor(204, 251, 241); doc.setLineWidth(0.4); | |
| doc.line(margin, leftY, margin + col1Width, leftY); | |
| leftY += 4; | |
| const patientRows = [ | |
| ['Full Name', patient.name || '—'], | |
| ['Contact Number', patient.contactNumber || '—'], | |
| ['Age', patient.age ? `${patient.age} years` : '—'], | |
| ['Sex', patient.sex || '—'], | |
| ['Date of Visit', new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })], | |
| ['Patient ID', cleanRefId], | |
| ]; | |
| doc.setFontSize(7.5); | |
| patientRows.forEach(([lbl, val]) => { | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'normal'); | |
| doc.text(lbl, margin, leftY); | |
| doc.setTextColor(10, 22, 40); doc.setFont('helvetica', 'bold'); | |
| doc.text(val, margin + 35, leftY); | |
| leftY += 4.5; | |
| }); | |
| leftY += 3; | |
| // Referring Health Worker | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text('REFERRING HEALTH WORKER', margin, leftY); | |
| leftY += 2; | |
| doc.line(margin, leftY, margin + col1Width, leftY); | |
| leftY += 4; | |
| const workerRows = [ | |
| ['Name', clinicianName], | |
| ['Role', clinicianRole], | |
| ['Facility Name', clinicianFacility], | |
| ['District', clinicianDistrict || '—'], | |
| ['Region', clinicianRegion ? `${clinicianRegion} Region` : '—'], | |
| ['Contact', clinicianContact || '—'], | |
| ]; | |
| doc.setFontSize(7.5); | |
| workerRows.forEach(([lbl, val]) => { | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'normal'); | |
| doc.text(lbl, margin, leftY); | |
| doc.setTextColor(10, 22, 40); doc.setFont('helvetica', 'bold'); | |
| doc.text(val, margin + 35, leftY); | |
| leftY += 4.5; | |
| }); | |
| leftY += 3; | |
| // Refer To | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text('REFER TO', margin, leftY); | |
| leftY += 2; | |
| doc.line(margin, leftY, margin + col1Width, leftY); | |
| leftY += 4; | |
| const referRows = [ | |
| ['Facility Type', 'District Hospital / Dermatology Clinic'], | |
| ['Department', 'Dermatology / General OPD'], | |
| ['Urgency', isHigh ? 'Immediate — Do Not Delay' : isMod ? 'Within 3 days' : 'Within 7 days'], | |
| ]; | |
| doc.setFontSize(7.5); | |
| referRows.forEach(([lbl, val]) => { | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'normal'); | |
| doc.text(lbl, margin, leftY); | |
| if (lbl === 'Urgency') { | |
| doc.setTextColor(urgencyColor[0], urgencyColor[1], urgencyColor[2]); doc.setFont('helvetica', 'bold'); | |
| } else { | |
| doc.setTextColor(10, 22, 40); doc.setFont('helvetica', 'bold'); | |
| } | |
| doc.text(val, margin + 35, leftY); | |
| leftY += 4.5; | |
| }); | |
| leftY += 3; | |
| // Health Worker's Notes | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text("HEALTH WORKER'S NOTES", margin, leftY); | |
| leftY += 2; | |
| doc.line(margin, leftY, margin + col1Width, leftY); | |
| leftY += 3.5; | |
| doc.setFillColor(248, 250, 252); | |
| doc.setDrawColor(241, 245, 249); doc.setLineWidth(0.2); | |
| doc.rect(margin, leftY, col1Width, 14, 'DF'); | |
| doc.setTextColor(51, 65, 85); doc.setFont('helvetica', 'italic'); doc.setFontSize(7); | |
| const wrappedNotes = doc.splitTextToSize(patient.symptoms?.trim() ? `"${patient.symptoms.trim()}"` : 'No additional notes recorded.', col1Width - 6); | |
| doc.text(wrappedNotes, margin + 3, leftY + 5); | |
| leftY += 18; | |
| // Recommended Medications (Editable Card) | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text("RECOMMENDED MEDICATIONS", margin, leftY); | |
| leftY += 2; | |
| doc.setDrawColor(187, 247, 208); | |
| doc.line(margin, leftY, margin + col1Width, leftY); | |
| leftY += 3.5; | |
| doc.setFillColor(240, 253, 244); | |
| doc.rect(margin, leftY, col1Width, 24, 'DF'); | |
| doc.setTextColor(21, 128, 61); doc.setFont('helvetica', 'bold'); doc.setFontSize(6.5); | |
| doc.text('PRESCRIBED MEDICATION', margin + 3, leftY + 4.5); | |
| doc.setTextColor(20, 83, 45); doc.setFont('helvetica', 'bold'); doc.setFontSize(7.5); | |
| doc.text(prescribedMedication || 'None Prescribed', margin + 3, leftY + 8.5); | |
| doc.setTextColor(21, 128, 61); doc.setFont('helvetica', 'bold'); doc.setFontSize(6.5); | |
| doc.text('DOSAGE REGIMEN / DIRECTIONS', margin + 3, leftY + 14); | |
| doc.setTextColor(20, 83, 45); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); | |
| const wrappedRegimen = doc.splitTextToSize(prescribedRegimen || 'No directions specified.', col1Width - 6); | |
| doc.text(wrappedRegimen, margin + 3, leftY + 18); | |
| leftY += 28; | |
| // ── RIGHT COLUMN (Dynamic Height Tracking & Sizing Engine) ────────────────── | |
| const cardStartY = rightY; | |
| // 1. Prepare Text & Data to pre-calculate the container height dynamically | |
| const isHighText = activeAnalysisResult.urgency === 'High' ? 'Urgent Referral' : isMod ? 'Moderate Urgency' : 'Mild Urgency'; | |
| const descText = activeAnalysisResult.primaryFinding.toLowerCase().includes('ringworm') | |
| ? "Tinea corporis is a superficial fungal infection characterised by a ring-shaped, scaly, itchy rash. Highly treatable with topical antifungal agents." | |
| : activeAnalysisResult.primaryFinding.toLowerCase().includes('eczema') | |
| ? "Atopic dermatitis is a chronic pruritic inflammatory skin condition managed with hydration, trigger avoidance, and topical anti-inflammatories." | |
| : activeAnalysisResult.primaryFinding.toLowerCase().includes('impetigo') | |
| ? "Impetigo is a highly contagious superficial bacterial skin infection characterized by honey-colored crusts. Managed with antibiotic therapy." | |
| : activeAnalysisResult.primaryFinding.toLowerCase().includes('scabies') | |
| ? "Scabies is an intensely itchy skin infestation caused by the mite Sarcoptes scabiei. Highly contagious. Managed with permethrin or ivermectin." | |
| : "A potential clinical skin indication detected by the assistive triage scanner. Standard clinical diagnostic procedures are recommended before commencing definitive therapy."; | |
| const docDetails = activeAnalysisResult.primaryFinding.toLowerCase().includes('ringworm') | |
| ? ["Clotrimazole 1% cream — apply twice daily for 2–4 weeks", "Keep area clean and dry", "Avoid sharing towels or clothing"] | |
| : activeAnalysisResult.primaryFinding.toLowerCase().includes('eczema') | |
| ? ["Hydrocortisone 1% cream — apply twice daily for 7 days", "Apply thick emollient moisturizer frequently", "Avoid harsh scented soaps and hot baths"] | |
| : activeAnalysisResult.primaryFinding.toLowerCase().includes('impetigo') | |
| ? ["Mupirocin 2% topical ointment — apply 3 times daily", "Gently clean honey-colored crusts with warm soapy water", "Keep lesions covered to prevent auto-inoculation"] | |
| : activeAnalysisResult.primaryFinding.toLowerCase().includes('scabies') | |
| ? ["Permethrin 5% cream — apply from neck down, wash after 8-14 hours", "Treat all household contacts simultaneously", "Wash bedding and clothes in hot water"] | |
| : activeAnalysisResult.treatmentNotes?.length ? activeAnalysisResult.treatmentNotes : ["Monitor area daily for pigment or dimension shifts", "Keep the affected region clean, dry, and cool", "Refer to dermatology clinic if symptoms do not improve"]; | |
| // Pre-calculate heights | |
| doc.setFont('helvetica', 'normal'); doc.setFontSize(7); | |
| const wrappedDesc = doc.splitTextToSize(descText, col2Width - 8); | |
| const descHeight = wrappedDesc.length * 3.3; | |
| let tempY = cardStartY + 33 + descHeight + 5; // offset for suggested treatment header | |
| let treatBulletY = tempY + 4.5; | |
| doc.setFont('helvetica', 'normal'); doc.setFontSize(6.8); | |
| docDetails.slice(0, 3).forEach(item => { | |
| const wrappedItem = doc.splitTextToSize(item, col2Width - 10); | |
| treatBulletY += (wrappedItem.length * 3.4) + 1.2; | |
| }); | |
| const disclaimerText = 'This is an AI-generated suggestion. Final treatment decisions rest with the clinician.'; | |
| const cardHeight = treatBulletY - cardStartY + 6; | |
| // 2. Draw card background with exact pre-calculated height first | |
| doc.setFillColor(240, 253, 248); | |
| doc.setDrawColor(204, 251, 241); doc.setLineWidth(0.3); | |
| doc.rect(col2X, cardStartY, col2Width, cardHeight, 'DF'); | |
| doc.setDrawColor(8, 47, 73); doc.setLineWidth(0.8); | |
| doc.line(col2X, cardStartY, col2X, cardStartY + cardHeight); | |
| // 3. Write text on top of the background | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text('AI ASSESSMENT', col2X + 4, cardStartY + 5.5); | |
| doc.setFillColor(240, 253, 244); doc.setDrawColor(187, 247, 208); doc.setLineWidth(0.2); | |
| doc.rect(col2X + col2Width - 22, cardStartY + 3, 18, 4, 'DF'); | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(6); | |
| doc.text('ANALYSIS OK', col2X + col2Width - 19, cardStartY + 6); | |
| doc.setTextColor(10, 22, 40); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); | |
| doc.text(activeAnalysisResult.primaryFinding, col2X + 4, cardStartY + 13.5); | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); | |
| doc.text('Detection confidence', col2X + 4, cardStartY + 19); | |
| const barColor = isHigh ? [225, 29, 72] : isMod ? [245, 158, 11] : [16, 185, 129]; // Rose-600, Amber-500, Emerald-600 | |
| doc.setTextColor(barColor[0], barColor[1], barColor[2]); doc.setFont('helvetica', 'bold'); | |
| const confStr = `${activeAnalysisResult.confidence}%`; | |
| doc.text(confStr, col2X + col2Width - 4 - doc.getTextWidth(confStr), cardStartY + 19); | |
| // confidence visual bar | |
| doc.setFillColor(241, 245, 249); | |
| doc.rect(col2X + 4, cardStartY + 21.5, col2Width - 8, 1.2, 'F'); | |
| doc.setFillColor(barColor[0], barColor[1], barColor[2]); | |
| doc.rect(col2X + 4, cardStartY + 21.5, (col2Width - 8) * (activeAnalysisResult.confidence / 100), 1.2, 'F'); | |
| // Urgency badge with zero glyph glitches | |
| doc.setFillColor(urgencyColor[0], urgencyColor[1], urgencyColor[2]); | |
| doc.rect(col2X + 4, cardStartY + 25, doc.getTextWidth(isHighText) + 6, 4.5, 'F'); | |
| doc.setTextColor(255, 255, 255); doc.setFont('helvetica', 'bold'); doc.setFontSize(6.2); | |
| doc.circle(col2X + 7, cardStartY + 27.2, 0.6, 'F'); // Draw a native circle | |
| doc.text(isHighText, col2X + 9, cardStartY + 28.2); | |
| // Assessment description text | |
| doc.setTextColor(71, 85, 105); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); | |
| doc.text(wrappedDesc, col2X + 4, cardStartY + 33.5); | |
| // Suggested treatment header | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'bold'); doc.setFontSize(6.5); | |
| doc.text('SUGGESTED TREATMENT', col2X + 4, tempY); | |
| // Bullet points | |
| doc.setTextColor(10, 22, 40); doc.setFont('helvetica', 'normal'); doc.setFontSize(6.8); | |
| let renderBulletY = tempY + 4.5; | |
| docDetails.slice(0, 3).forEach(item => { | |
| const wrappedItem = doc.splitTextToSize(item, col2Width - 10); | |
| // Draw standard high-fidelity native bullet point circle | |
| doc.setFillColor(8, 47, 73); | |
| doc.circle(col2X + 5, renderBulletY - 1, 0.6, 'F'); | |
| doc.text(wrappedItem, col2X + 8, renderBulletY); | |
| renderBulletY += (wrappedItem.length * 3.4) + 1.2; | |
| }); | |
| // Disclaimer | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'italic'); doc.setFontSize(5.8); | |
| doc.text(disclaimerText, col2X + 4, cardHeight + cardStartY - 3.5); | |
| // Update rightY dynamically using the calculated height + spacing! | |
| rightY = cardStartY + cardHeight + 8; | |
| // Photos Block | |
| doc.setTextColor(8, 47, 73); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text('PHOTO TAKEN DURING ASSESSMENT', col2X, rightY); | |
| rightY += 2; | |
| doc.setDrawColor(186, 230, 253); | |
| doc.line(col2X, rightY, col2X + col2Width, rightY); | |
| rightY += 3.5; | |
| // Render the photos side by side in PDF | |
| const imgWidth = (col2Width - 4) / 2; | |
| const imgHeight = 32; | |
| let drawX = col2X; | |
| if (capturedImage) { | |
| try { doc.addImage(capturedImage, 'JPEG', drawX, rightY, imgWidth, imgHeight); } catch {} | |
| } else { | |
| doc.setFillColor(241, 245, 249); doc.rect(drawX, rightY, imgWidth, imgHeight, 'F'); | |
| doc.setTextColor(148, 163, 184); doc.setFontSize(6); | |
| doc.text('NO PHOTO CAPTURED', drawX + 5, rightY + 16); | |
| } | |
| // Draw high-fidelity overlay badge for Clinical Specimen | |
| doc.setFillColor(8, 47, 73); | |
| doc.rect(drawX + 1.5, rightY + 1.5, 20, 3.8, 'F'); | |
| doc.setTextColor(255, 255, 255); doc.setFont('helvetica', 'bold'); doc.setFontSize(4.5); | |
| doc.text('CLINICAL SPECIMEN', drawX + 2.5, rightY + 4.1); | |
| drawX += imgWidth + 4; | |
| if (activeAnalysisResult.heatmap_b64) { | |
| try { doc.addImage(`data:image/jpeg;base64,${activeAnalysisResult.heatmap_b64}`, 'JPEG', drawX, rightY, imgWidth, imgHeight); } catch {} | |
| } else { | |
| doc.setFillColor(241, 245, 249); doc.rect(drawX, rightY, imgWidth, imgHeight, 'F'); | |
| doc.setTextColor(148, 163, 184); doc.setFontSize(6); | |
| doc.text('NO HEATMAP AVAILABLE', drawX + 4, rightY + 16); | |
| } | |
| // Draw high-fidelity overlay badge for AI Saliency Map | |
| doc.setFillColor(8, 47, 73); | |
| doc.rect(drawX + 1.5, rightY + 1.5, 20, 3.8, 'F'); | |
| doc.setTextColor(255, 255, 255); doc.setFont('helvetica', 'bold'); doc.setFontSize(4.5); | |
| doc.text('AI SALIENCY MAP', drawX + 2.5, rightY + 4.1); | |
| rightY += imgHeight + 4; | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); | |
| doc.text(`Photo captured: ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`, col2X, rightY); | |
| rightY += 10; | |
| // Synchronize column heights for signature placement | |
| y = Math.max(leftY, rightY) + 4; | |
| // 3. Signature Block | |
| doc.setDrawColor(226, 232, 240); doc.setLineWidth(0.4); | |
| doc.line(margin, y, margin + contentWidth, y); | |
| y += 4; | |
| const sigWidth = contentWidth / 2 - 4; | |
| const sigHeight = 12; | |
| // Attending Clinician Signature | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'bold'); doc.setFontSize(7); | |
| doc.text('HEALTH WORKER SIGNATURE', margin, y); | |
| const sigCanvas = signatureCanvasRef.current; | |
| if (sigCanvas) { | |
| try { | |
| const sigImg = sigCanvas.toDataURL('image/png'); | |
| doc.addImage(sigImg, 'PNG', margin, y + 2, sigWidth, sigHeight); | |
| } catch {} | |
| } else { | |
| doc.setFillColor(248, 250, 252); doc.rect(margin, y + 2, sigWidth, sigHeight, 'F'); | |
| } | |
| doc.setTextColor(10, 22, 40); doc.setFont('helvetica', 'bold'); doc.setFontSize(8); | |
| doc.text(clinicianName, margin, y + sigHeight + 5); | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); | |
| doc.text(`${clinicianRole} · ${clinicianFacility}`, margin, y + sigHeight + 8); | |
| doc.text(`Date: ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`, margin, y + sigHeight + 11); | |
| // Receiving Clinician Signature Stamp Box | |
| const stampX = margin + contentWidth / 2 + 4; | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'bold'); doc.setFontSize(7); | |
| doc.text('RECEIVING CLINICIAN STAMP / SIGNATURE', stampX, y); | |
| doc.setFillColor(248, 250, 252); | |
| doc.setDrawColor(226, 232, 240); doc.setLineWidth(0.2); | |
| doc.rect(stampX, y + 2, sigWidth, sigHeight, 'DF'); | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'bold'); doc.setFontSize(6); | |
| doc.text('PLACE CLINICAL STAMP HERE', stampX + (sigWidth - doc.getTextWidth('PLACE CLINICAL STAMP HERE')) / 2, y + 2 + sigHeight / 2 + 1); | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'italic'); doc.setFontSize(6.5); | |
| const textToReceiving = '* To be completed at receiving facility'; | |
| doc.text(textToReceiving, margin + contentWidth - doc.getTextWidth(textToReceiving), y + sigHeight + 11); | |
| // 4. Footer | |
| const footerY = pageHeight - margin - 22; | |
| doc.setDrawColor(226, 232, 240); doc.setLineWidth(0.4); | |
| doc.line(margin, footerY, margin + contentWidth, footerY); | |
| doc.setFillColor(248, 250, 252); | |
| doc.rect(margin, footerY + 1, contentWidth, 21, 'F'); | |
| // Left brand in footer (zero glyph glitches) | |
| doc.setTextColor(71, 85, 105); doc.setFont('helvetica', 'bold'); doc.setFontSize(7.5); | |
| // Draw native bullet point circle instead of Unicode bullet to prevent %T glitch | |
| doc.circle(margin + 5, footerY + 4.8, 0.7, 'F'); | |
| doc.text('DermaDetect AI', margin + 7.5, footerY + 6); | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); | |
| doc.text('Generated by DermaDetect — AI Skin Assessment Tool', margin + 4, footerY + 9.5); | |
| // Right timestamp refs | |
| doc.setTextColor(100, 116, 139); doc.setFont('helvetica', 'bold'); doc.setFontSize(6.8); | |
| const refsHeader = 'TIMESTAMP & REFERRAL REFS'; | |
| doc.text(refsHeader, margin + contentWidth - 4 - doc.getTextWidth(refsHeader), footerY + 6); | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'normal'); doc.setFontSize(6.5); | |
| const refsLine1 = `Ref: ${cleanRefId}`; | |
| const refsLine2 = `Generated: ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} at ${new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })}`; | |
| doc.text(refsLine1, margin + contentWidth - 4 - doc.getTextWidth(refsLine1), footerY + 9.5); | |
| doc.text(refsLine2, margin + contentWidth - 4 - doc.getTextWidth(refsLine2), footerY + 13); | |
| // Centered disclaimer | |
| doc.setDrawColor(241, 245, 249); doc.setLineWidth(0.2); | |
| doc.line(margin + 4, footerY + 15, margin + contentWidth - 4, footerY + 15); | |
| doc.setTextColor(148, 163, 184); doc.setFont('helvetica', 'italic'); doc.setFontSize(6.2); | |
| const disclaimer = 'This referral note was generated with AI assistance. It is intended to support, not replace, clinical judgment.'; | |
| doc.text(disclaimer, margin + (contentWidth - doc.getTextWidth(disclaimer)) / 2, footerY + 19); | |
| // Save PDF | |
| doc.save(`Clinical_Referral_Note_${cleanRefId}_${patient.name.trim().replace(/\s+/g, '_')}.pdf`); | |
| } catch (err: any) { | |
| alert('PDF generation failed: ' + err.message); | |
| } | |
| }; | |
| const downloadPdfRecord = (record: CaseRecord) => { | |
| try { | |
| const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); | |
| const pageWidth = 210; const pageHeight = 297; const margin = 15; const contentWidth = 180; | |
| // Primary brand colors | |
| const primaryColor = [14, 116, 144]; // cyan-700 — main interactive blue | |
| const primaryDark = [8, 47, 73]; // sky-950 — deep navy for backgrounds/headers | |
| // Backgrounds & surfaces | |
| const paperBg = [240, 249, 255]; // sky-50 — near-white with a blue tint | |
| const borderSlate = [186, 230, 253]; // sky-200 — soft blue-tinted border | |
| // Text | |
| const textGray = [30, 58, 138]; // indigo-900 — deep blue-toned body text (replaces neutral gray) | |
| // Semantic accents (shifted cooler to match theme) | |
| const accentRed = [157, 23, 77]; // rose-800 — errors/danger (kept punchy, slightly cooler) | |
| const accentOrange = [161, 98, 7]; // amber-700 — warnings (muted gold instead of orange) | |
| const accentGreen = [6, 95, 70]; // emerald-800 — success (teal-leaning green) | |
| const urgencyStr = record.finding.urgency || 'Low'; | |
| let severityColor = accentGreen; | |
| if (urgencyStr === 'High') severityColor = accentRed; | |
| else if (urgencyStr === 'Moderate') severityColor = accentOrange; | |
| doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); | |
| doc.rect(margin, 12, 180, 1.5, 'F'); | |
| let y = 24; | |
| doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); | |
| doc.circle(margin + 4, y, 3, 'F'); | |
| doc.setDrawColor(255,255,255); doc.setLineWidth(0.6); | |
| doc.line(margin+4, y-1.5, margin+4, y+1.5); doc.line(margin+2.5, y, margin+5.5, y); | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); | |
| doc.setFont('helvetica','bold'); doc.setFontSize(10); | |
| doc.text('DERMADETECT™ CLINICAL CASE DOSSIER', margin+10, y-1); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); | |
| doc.setFont('helvetica','normal'); doc.setFontSize(7.5); | |
| doc.text('FIELD OBSERVATION & DIAGNOSTIC RECORD', margin+10, y+2.5); | |
| doc.setTextColor(primaryDark[0], primaryDark[1], primaryDark[2]); | |
| doc.setFont('helvetica','bold'); doc.setFontSize(11); | |
| const docTitle = 'CLINICAL REFERRAL DOSSIER'; | |
| doc.text(docTitle, pageWidth-margin-doc.getTextWidth(docTitle), y+1); | |
| y += 8; | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.5); | |
| doc.line(margin, y, pageWidth-margin, y); | |
| y += 6; | |
| doc.setFillColor(paperBg[0], paperBg[1], paperBg[2]); | |
| doc.rect(margin, y, contentWidth, 36, 'F'); | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.3); | |
| doc.rect(margin, y, contentWidth, 36, 'D'); | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); | |
| doc.setFont('helvetica','bold'); doc.setFontSize(8.5); | |
| doc.text('PATIENT ANTHROPOMETRIC RECORD', margin+6, y+6); | |
| doc.text('CLINICAL IDENTIFIER METADATA', margin+96, y+6); | |
| doc.line(margin+90, y+3, margin+90, y+33); | |
| doc.setTextColor(primaryDark[0], primaryDark[1], primaryDark[2]); doc.setFontSize(8); | |
| let rowY = y + 13; | |
| doc.setFont('helvetica','bold'); doc.text('Full Name:', margin+6, rowY); | |
| doc.setFont('helvetica','normal'); doc.text(record.patient.name, margin+28, rowY); | |
| doc.setFont('helvetica','bold'); doc.text('Case Reference:', margin+96, rowY); | |
| doc.setFont('helvetica','normal'); doc.text(record.id, margin+125, rowY); | |
| rowY += 5; | |
| doc.setFont('helvetica','bold'); doc.text('Age / Gender:', margin+6, rowY); | |
| doc.setFont('helvetica','normal'); doc.text(`${record.patient.age} Yrs / ${record.patient.sex}`, margin+28, rowY); | |
| doc.setFont('helvetica','bold'); doc.text('Assessment Date:', margin+96, rowY); | |
| doc.setFont('helvetica','normal'); doc.text(record.date || new Date().toLocaleDateString(), margin+125, rowY); | |
| rowY += 5; | |
| doc.setFont('helvetica','bold'); doc.text('Symptom Notes:', margin+6, rowY); | |
| doc.setFont('helvetica','italic'); | |
| const wrappedSx = doc.splitTextToSize(`"${record.patient.symptoms || 'None reported'}"`, 56); | |
| doc.text(wrappedSx, margin+28, rowY); | |
| doc.setFont('helvetica','bold'); doc.text('Triage Officer:', margin+96, rowY); | |
| doc.setFont('helvetica','normal'); doc.text(record.healthWorker || clinicianName, margin+125, rowY); | |
| y += 44; | |
| doc.setFillColor(paperBg[0], paperBg[1], paperBg[2]); | |
| doc.rect(margin, y, contentWidth, 24, 'F'); | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.3); | |
| doc.rect(margin, y, contentWidth, 24, 'D'); | |
| doc.setFillColor(severityColor[0], severityColor[1], severityColor[2]); | |
| doc.rect(margin, y, 4, 24, 'F'); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','bold'); doc.setFontSize(8); | |
| doc.text('PRIMARY TRIAGE CLASSIFICATION GUIDELINE TARGET', margin+8, y+6); | |
| doc.setTextColor(primaryDark[0], primaryDark[1], primaryDark[2]); doc.setFont('helvetica','bold'); doc.setFontSize(11); | |
| doc.text(record.finding.primaryFinding, margin+8, y+13); | |
| doc.setFontSize(8.5); doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); | |
| doc.text('Confidence Score: ', margin+8, y+19); | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFont('helvetica','bold'); | |
| doc.text(`${record.finding.confidence}% match`, margin+35, y+19); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); | |
| doc.text(' | Triage Priority Level: ', margin+55, y+19); | |
| doc.setTextColor(severityColor[0], severityColor[1], severityColor[2]); doc.setFont('helvetica','bold'); | |
| doc.text(`${urgencyStr} Severity`, margin+90, y+19); | |
| y += 31; | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFont('helvetica','bold'); doc.setFontSize(9); | |
| doc.text('I. CLINICAL IMAGE SPECIMEN SNAPSHOT', margin, y); | |
| doc.text('II. SUPPORTIVE PRACTICAL OUTPATIENT DIRECTIVE', margin+82, y); | |
| y += 2.5; | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.4); | |
| doc.line(margin, y, margin+74, y); doc.line(margin+82, y, pageWidth-margin, y); | |
| y += 5; | |
| const imageBoxY = y; | |
| let imgOk = false; | |
| if (record.image) { try { doc.addImage(record.image, 'JPEG', margin, y, 74, 52); imgOk = true; } catch {} } | |
| if (!imgOk) { | |
| doc.setFillColor(242,245,248); doc.rect(margin, y, 74, 52, 'F'); | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.rect(margin, y, 74, 52, 'D'); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','bold'); doc.setFontSize(7.5); | |
| doc.text('VISUAL DERMAL SPECIMEN RECORD', margin+14, y+24); | |
| } | |
| doc.setTextColor(primaryDark[0], primaryDark[1], primaryDark[2]); doc.setFont('helvetica','bold'); doc.setFontSize(8.5); | |
| const actionLines = doc.splitTextToSize(`Onward Action Plan: ${record.finding.recommendedAction}`, 94); | |
| doc.text(actionLines, margin+82, y); | |
| let notesY = y + actionLines.length * 4 + 2; | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); doc.setFontSize(8); | |
| (record.finding.treatmentNotes || []).forEach(note => { | |
| const bl = doc.splitTextToSize(`• ${note}`, 94); | |
| if (notesY + bl.length * 4 < imageBoxY + 54) { doc.text(bl, margin+82, notesY); notesY += bl.length * 4 + 1; } | |
| }); | |
| y = Math.max(imageBoxY + 52, notesY) + 8; | |
| doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); | |
| doc.setFillColor(paperBg[0], paperBg[1], paperBg[2]); | |
| doc.rect(margin, y, contentWidth, 20, 'F'); doc.rect(margin, y, contentWidth, 20, 'D'); | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFont('helvetica','bold'); doc.setFontSize(7.5); | |
| doc.text('OFFICIAL MOH REFERRAL TRANSCRIPT VALIDATION SEAL', margin+5, y+5); | |
| doc.setTextColor(primaryDark[0], primaryDark[1], primaryDark[2]); doc.setFont('helvetica','normal'); doc.setFontSize(7); | |
| doc.text('1. Generative diagnostic handbooks conform to clinical protocol V8.46 guidelines.', margin+5, y+10); | |
| doc.text('2. Local sandboxed key integration verified. Regional medical handovers active.', margin+5, y+14); | |
| doc.setLineWidth(0.4); doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); | |
| doc.rect(pageWidth-margin-32, y+3, 27, 14, 'D'); | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFont('helvetica','bold'); doc.setFontSize(7); | |
| doc.text('VERIFIED DOSSIER', pageWidth-margin-30, y+8); | |
| doc.setFontSize(5); doc.text('MOH TRANSCRIPT SYSTEMS', pageWidth-margin-29, y+13); | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.3); | |
| doc.line(margin, pageHeight-14, pageWidth-margin, pageHeight-14); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); doc.setFontSize(6.5); | |
| doc.text('Dermatological clinical referral handout generated within browser environment.', margin, pageHeight-10); | |
| doc.text('Page 1 of 2', pageWidth-margin-doc.getTextWidth('Page 1 of 2'), pageHeight-10); | |
| // Page 2 | |
| doc.addPage(); y = 20; | |
| doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.rect(margin, 12, 180, 1.5, 'F'); | |
| doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); doc.setFont('helvetica','bold'); doc.setFontSize(10); | |
| doc.text('SUPPORTIVE TREATMENT RECIPE & PHARMACOLOGICAL DOSES', margin, y+3); | |
| const rightH = 'SECURE PROTOCOL DISPENSING SCHEME'; | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); doc.setFontSize(7.5); | |
| doc.text(rightH, pageWidth-margin-doc.getTextWidth(rightH), y+3); | |
| y += 10; | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.5); | |
| doc.line(margin, y, pageWidth-margin, y); y += 8; | |
| y = pageHeight - 34; | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.3); | |
| doc.line(margin, y, pageWidth-margin, y); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); doc.setFontSize(6.8); | |
| doc.text(`Digital Verification Signature: ${record.healthWorker || clinicianName}`, margin, y+5); | |
| doc.text(`System Unique Sign-key Hash: SHA256-${record.id.slice(0,12).toUpperCase()}...`, margin, y+9); | |
| doc.setFontSize(7); doc.line(pageWidth-margin-45, y+12, pageWidth-margin, y+12); | |
| doc.text('Attending Clinician Authenticated Stamp', pageWidth-margin-45, y+16); | |
| doc.setDrawColor(borderSlate[0], borderSlate[1], borderSlate[2]); doc.setLineWidth(0.3); | |
| doc.line(margin, pageHeight-14, pageWidth-margin, pageHeight-14); | |
| doc.setTextColor(textGray[0], textGray[1], textGray[2]); doc.setFont('helvetica','normal'); doc.setFontSize(6.5); | |
| doc.text('Approved by National Digital Health Authority.', margin, pageHeight-10); | |
| doc.text('Page 2 of 2', pageWidth-margin-doc.getTextWidth('Page 2 of 2'), pageHeight-10); | |
| doc.save(`Clinical_Referral_Note_${record.id}_${record.patient.name.trim().replace(/\s+/g,'_')}.pdf`); | |
| } catch (err: any) { alert('PDF generation failed: ' + err.message); } | |
| }; | |
| // ── Processed cases ──────────────────────────────────────────────────────── | |
| const processedCases = cases.filter(item => { | |
| const q = searchQuery.toLowerCase(); | |
| const matchSearch = item.patient.name.toLowerCase().includes(q) || item.finding.primaryFinding.toLowerCase().includes(q) || item.id.toLowerCase().includes(q); | |
| const matchUrgency = filterUrgency === 'All' || item.finding.urgency === filterUrgency; | |
| return matchSearch && matchUrgency; | |
| }); | |
| // ── RENDER ───────────────────────────────────────────────────────────────── | |
| return ( | |
| <> | |
| {!clinician ? ( | |
| <ClinicianSetup onComplete={handleProfileComplete} /> | |
| ) : ( | |
| <div className="min-h-screen bg-[#f7fafc] text-[#181c1e] font-sans antialiased flex flex-col"> | |
| {/* ── HEADER ── */} | |
| <header className="bg-[#001B2E]/65 backdrop-blur-md border-b border-white/10 fixed top-0 left-0 right-0 z-50 h-20 shadow-[0_4px_30px_rgba(0,0,0,0.2)]"> | |
| <div className="flex justify-between items-center w-full px-6 md:px-12 max-w-7xl mx-auto h-full"> | |
| <div onClick={() => { setScreen('home'); stopWebcam(); }} className="flex items-center gap-4 cursor-pointer group"> | |
| <div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#00A6FB] via-[#003554] to-[#001B2E] flex items-center justify-center text-white font-display font-bold text-sm shadow-[inset_0_1px_3px_rgba(255,255,255,0.4)] transition-transform group-hover:scale-105">DD</div> | |
| <div className="flex flex-col"> | |
| <span className="font-display font-bold text-[#F0F4F8] text-xl tracking-tight leading-none">Derma<span className="text-[#00A6FB]">Defect</span></span> | |
| <span className="text-[10px] uppercase tracking-[0.15em] text-[#00A6FB]/80 font-semibold mt-1">Clinical Diagnostics</span> | |
| </div> | |
| </div> | |
| <nav className="hidden md:flex items-center gap-10 h-full"> | |
| {[ | |
| { label: t.newAssessment, action: resetAndStartAssessment, active: ['assessment-info','assessment-capture','assessment-review','referral-note'].includes(screen) }, | |
| { label: t.caseHistory, action: () => { setScreen('case-history'); stopWebcam(); }, active: screen === 'case-history' }, | |
| ].map(({ label, action, active }) => ( | |
| <button key={label} onClick={action} className={`font-sans text-sm font-medium transition-all duration-300 h-full relative flex items-center ${active ? 'text-white font-semibold' : 'text-[#F0F4F8]/60 hover:text-white'}`}> | |
| {label} | |
| {active && <span className="absolute bottom-0 left-0 w-full h-[3px] bg-[#00A6FB] rounded-t-full shadow-[0_-2px_12px_rgba(0,166,251,0.8)]" />} | |
| </button> | |
| ))} | |
| </nav> | |
| <div className="flex items-center gap-3"> | |
| {/* Clinician badge */} | |
| <div className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-white/5 border border-white/10 rounded-full"> | |
| <div className="w-6 h-6 rounded-full bg-[#00A6FB] flex items-center justify-center text-white font-bold text-[10px]"> | |
| {clinician.name.split(' ').map(n => n[0]).join('').slice(0,2).toUpperCase()} | |
| </div> | |
| <div className="text-left"> | |
| <p className="text-[11px] font-bold text-white leading-none">{clinician.name}</p> | |
| <p className="text-[9px] text-white/60 leading-none mt-0.5">{clinician.role}</p> | |
| </div> | |
| <button onClick={() => { clearProfile(); setClinician(null); }} title="Switch profile" className="ml-1 text-white/40 hover:text-white/80 transition-colors"> | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| {/* ── MAIN ── */} | |
| <main className="flex-1 pt-14 pb-20 md:pb-0 flex flex-col"> | |
| {/* HOME */} | |
| {screen === 'home' && ( | |
| <div className="flex-1 flex flex-col relative overflow-hidden bg-[#F8F9FA]"> | |
| {/* Hero */} | |
| <section className="relative min-h-screen flex flex-col overflow-hidden bg-[#001B2E] text-[#F0F4F8]"> | |
| <div className="absolute inset-0 z-0 pointer-events-none select-none overflow-hidden"> | |
| <img src="https://images.unsplash.com/photo-1579684385127-1ef15d508118?auto=format&fit=crop&w=2400&q=80" alt="" className="w-full h-full object-cover opacity-45 mix-blend-luminosity filter contrast-125 brightness-110" /> | |
| <div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#001B2E]/10 to-[#001B2E]" /> | |
| <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_20%,#001B2E_90%)]" /> | |
| </div> | |
| <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[70vw] h-[50vh] bg-[#00A6FB]/10 blur-[150px] rounded-full pointer-events-none z-0" /> | |
| <div className="max-w-4xl mx-auto w-full px-6 md:px-12 pt-19 pb-24 flex flex-col items-center justify-center text-center relative z-10 my-auto"> | |
| <div className="w-fit mb-8"> | |
| <span className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-full bg-white/[0.03] backdrop-blur-md border border-white/10 text-[11px] uppercase tracking-[0.25em] text-[#F0F4F8] font-semibold"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-[#00A6FB] shadow-[0_0_10px_#00A6FB]" /> | |
| Dermatology Support for General Practice | |
| </span> | |
| </div> | |
| <h1 className="font-display font-light text-white text-4xl sm:text-6xl md:text-7xl leading-[1.1] tracking-tight mb-8 drop-shadow-[0_4px_20px_rgba(0,0,0,0.5)]"> | |
| Diagnostic support, <br/> | |
| <span className="font-bold bg-gradient-to-r from-white via-[#00A6FB] to-[#00A6FB] bg-clip-text text-transparent">built for clinicians.</span> | |
| </h1> | |
| <p className="text-[#F0F4F8]/85 text-base md:text-xl leading-relaxed max-w-2xl font-sans font-light mb-12 drop-shadow-sm"> | |
| DermaDefect provides physicians with instant, evidence-backed reference mapping and diagnostic cross-examinations. | |
| </p> | |
| <div className="flex flex-col sm:flex-row items-center justify-center gap-5 w-full sm:w-auto"> | |
| <button onClick={resetAndStartAssessment} className="w-full sm:w-auto h-14 px-12 bg-[#00A6FB] text-[#001B2E] font-sans font-semibold text-base rounded-full shadow-[0_10px_25px_rgba(0,166,251,0.25)] hover:bg-white hover:-translate-y-0.5 transition-all flex items-center justify-center gap-3 cursor-pointer active:scale-95"> | |
| <PlusCircle className="w-5 h-5" /><span>Begin Assessment</span> | |
| </button> | |
| <a href="#how-it-works" className="w-full sm:w-auto h-14 px-12 border border-white/10 text-[#F0F4F8] bg-white/[0.04] backdrop-blur-md font-sans font-semibold text-base rounded-full hover:bg-white/[0.1] hover:border-white/20 transition-all flex items-center justify-center gap-3 active:scale-95"> | |
| <span>Read Clinical Protocol</span><ArrowRight className="w-4 h-4 opacity-70 text-[#00A6FB]" /> | |
| </a> | |
| </div> | |
| </div> | |
| </section> | |
| {/* Stats section */} | |
| <section className="py-24 md:py-32 px-6 md:px-12 bg-white relative z-10 select-none"> | |
| <div className="max-w-4xl mx-auto text-center mb-20"> | |
| <span className="inline-flex items-center px-4 py-1.5 rounded-full bg-[#00A6FB]/5 border border-[#00A6FB]/10 text-[11px] font-semibold uppercase tracking-[0.2em] text-[#00A6FB]">The Clinical Disparity</span> | |
| <h2 className="font-display font-bold text-[#001B2E] text-3xl sm:text-5xl tracking-tight mt-4 leading-[1.15]">Africa has fewer than 1 dermatologist<br className="hidden md:block"/> per million people.</h2> | |
| <div className="w-16 h-1 bg-[#00A6FB] rounded-full mx-auto mt-6 opacity-60" /> | |
| <p className="text-base md:text-lg text-[#003554]/75 font-sans font-light leading-relaxed mt-6 max-w-2xl mx-auto">Skin diseases are among the most common reasons for primary clinic visits — yet most go misdiagnosed or untreated at the community level.</p> | |
| </div> | |
| <div className="max-w-6xl mx-auto bg-gradient-to-b from-white to-[#F0F4F8]/30 rounded-3xl border border-[#003554]/10 overflow-hidden shadow-[0_20px_50px_rgba(0,27,46,0.04)]"> | |
| <div className="grid grid-cols-1 lg:grid-cols-12"> | |
| <div className="lg:col-span-7 relative min-h-[350px] sm:min-h-[450px] overflow-hidden"> | |
| <img src="https://images.unsplash.com/photo-1584515979956-d9f6e5d09982?auto=format&fit=crop&w=1600&q=80" alt="" className="absolute inset-0 w-full h-full object-cover" /> | |
| <div className="absolute inset-0 bg-gradient-to-t lg:bg-gradient-to-r from-[#001B2E]/95 via-[#001B2E]/70 to-transparent z-10" /> | |
| <div className="absolute bottom-12 left-12 z-20 max-w-sm text-left"> | |
| <span className="text-[10px] text-[#00A6FB] font-bold uppercase tracking-widest block mb-2">Field Support Optimization</span> | |
| <h3 className="text-white font-display font-bold text-2xl leading-tight">Empowering frontlines in local community clinics.</h3> | |
| </div> | |
| </div> | |
| <div className="lg:col-span-5 p-8 md:p-10 flex flex-col justify-center gap-6"> | |
| {[ | |
| { icon: <Heart className="w-5 h-5" />, stat: '1 in 3', label: 'Affected Annually', desc: 'Ghanaians affected by common, preventable dermatological conditions every year.' }, | |
| { icon: <Clock className="w-5 h-5" />, stat: '72 Hours', label: 'Average Rural Wait', desc: 'Mean commute and wait times to consult specialists in metropolitan centers.' }, | |
| { icon: <AlertTriangle className="w-5 h-5" />, stat: '60%', label: 'Initial Misdiagnosis', desc: 'Cases misdiagnosed at community care outposts without assistive workflows.', red: true }, | |
| ].map(({ icon, stat, label, desc, red }) => ( | |
| <div key={stat} className="bg-white p-6 rounded-2xl border border-[#003554]/5 shadow-sm hover:border-[#00A6FB]/20 transition-all duration-300"> | |
| <div className="flex items-center gap-4"> | |
| <div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 ${red ? 'bg-red-500/10 text-red-500' : 'bg-[#00A6FB]/10 text-[#00A6FB]'}`}>{icon}</div> | |
| <div> | |
| <h3 className="font-display font-bold text-3xl text-[#001B2E] tracking-tight">{stat}</h3> | |
| <p className={`text-[10px] uppercase tracking-widest font-bold mt-0.5 ${red ? 'text-red-500/70' : 'text-[#003554]/50'}`}>{label}</p> | |
| </div> | |
| </div> | |
| <p className="text-xs text-[#003554]/70 leading-relaxed mt-3 font-light">{desc}</p> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| {/* How it works */} | |
| <span id="how-it-works" className="-mt-20 pt-5 block" /> | |
| <section className="relative py-24 md:py-32 px-6 md:px-12 bg-[#F8FAFC] border-t border-slate-200 select-none overflow-hidden"> | |
| <div className="max-w-4xl mx-auto text-center mb-16 relative z-10"> | |
| <span className="inline-flex items-center px-4 py-1.5 rounded-full bg-[#00A6FB]/5 border border-[#00A6FB]/10 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#00A6FB]">The Protocol</span> | |
| <h2 className="font-display font-bold text-slate-900 text-3xl sm:text-5xl tracking-tight mt-4 leading-tight">Three steps. Two seconds. <span className="text-[#00A6FB]">One life changed.</span></h2> | |
| </div> | |
| <div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 items-stretch relative z-10"> | |
| {[ | |
| { step: '01', title: 'Capture Image', desc: 'The clinician captures a clear frame of the skin anomaly directly within the device browser, or uploads from local device storage.' }, | |
| { step: '02', title: 'Reference Map', desc: 'The internal software engine evaluates key visual structural parameters locally, formatting confirmed criteria markers in under two seconds.' }, | |
| { step: '03', title: 'Action Results', desc: 'Review structural indices, confirm urgency tiers, and copy instantly generated case summaries formatted for local referral networks.' }, | |
| ].map(({ step, title, desc }) => ( | |
| <div key={step} className="bg-white rounded-2xl border border-slate-200 p-5 flex flex-col gap-4 hover:shadow-lg hover:border-[#00A6FB]/30 transition-all duration-300"> | |
| <div className="aspect-[1.6] w-full rounded-xl overflow-hidden bg-slate-50 border border-slate-100"> | |
| <img src="https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?auto=format&fit=crop&w=800&q=80" alt="" className="w-full h-full object-cover filter brightness-95" /> | |
| </div> | |
| <div className="flex items-start justify-between pt-2"> | |
| <div> | |
| <span className="text-[10px] text-[#00A6FB] font-bold uppercase tracking-widest">Step {step}</span> | |
| <h3 className="font-display font-bold text-lg text-slate-900 mt-0.5">{title}</h3> | |
| </div> | |
| <span className="text-xl font-display font-light text-slate-300">{step}</span> | |
| </div> | |
| <p className="text-xs text-slate-500 leading-relaxed font-light">{desc}</p> | |
| </div> | |
| ))} | |
| </div> | |
| </section> | |
| {/* CTA */} | |
| <section className="py-20 md:py-28 px-4 md:px-8 bg-gradient-to-br from-[#0A1628] to-[#0F2D1F] text-white relative z-10 select-none overflow-hidden text-center border-t border-white/5"> | |
| <div className="max-w-4xl mx-auto space-y-7 relative z-10"> | |
| <h2 className="font-display font-black text-white text-3xl sm:text-4xl leading-tight tracking-tight">Ready to bring AI-powered skin care to your clinic?</h2> | |
| <p className="text-slate-300 font-sans text-sm sm:text-base leading-relaxed max-w-xl mx-auto">DermaDetect is free to use, works offline, and takes less than 2 minutes to learn.</p> | |
| <div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-3.5 max-w-md mx-auto"> | |
| <button onClick={resetAndStartAssessment} className="w-full sm:w-auto h-13 px-8 bg-[#00A6FB] text-[#001B2E] font-display font-bold text-sm rounded-xl flex items-center justify-center gap-2.5 cursor-pointer active:scale-95 hover:-translate-y-0.5 transition-all"> | |
| <PlusCircle className="w-5 h-5" /><span>Start Using DermaDetect</span> | |
| </button> | |
| </div> | |
| <div className="flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-10 pt-10 border-t border-white/5 font-mono text-[10.5px] text-slate-400"> | |
| <span className="flex items-center gap-2"><ShieldCheck className="w-4 h-4 text-[#082F49]" />🔒 Patient data stays on your device</span> | |
| <span className="flex items-center gap-2"><WifiOff className="w-4 h-4 text-[#EF9F27]" />📶 Works without internet</span> | |
| <span className="flex items-center gap-2"><Globe className="w-4 h-4 text-[#32c494]" />🌍 Built for African healthcare</span> | |
| </div> | |
| </div> | |
| </section> | |
| {/* FAQ */} | |
| <section className="bg-white py-16 md:py-24 border-b border-slate-200 select-none"> | |
| <div className="max-w-4xl mx-auto px-4"> | |
| <div className="text-center mb-12"> | |
| <span className="text-xs font-bold font-mono tracking-widest text-[#082F49] uppercase">FAQ</span> | |
| <h2 className="font-display font-black text-slate-900 text-2xl md:text-3xl tracking-tight mt-2.5">Privacy, Scope & Safety FAQ</h2> | |
| </div> | |
| <div className="space-y-4"> | |
| {[ | |
| { q: "What skin conditions can DermaDetect help with?", a: "The system assists in recognizing conditions including Tinea Corporis, Eczema, Impetigo, Scabies, Melanoma, Basal Cell Carcinoma, and more. It aids clinical triaging but does not replace a doctor's examination." }, | |
| { q: "How is patient data kept private and secure?", a: "All image evaluation occurs locally on your browser. No patient records or photographs are sent to external servers without your explicit action." }, | |
| { q: "Can I generate and download clinical reports offline?", a: "Yes. Once loaded, report compilation operates fully offline. You can generate, preview, and download patient summaries or referral notes directly to your local device." }, | |
| ].map((faq, idx) => { | |
| const isOpen = !!faqOpenState[idx]; | |
| return ( | |
| <div key={idx} className="border border-[#bccac1]/75 rounded-xl overflow-hidden transition-all duration-300 bg-[#F8F9FA]/50"> | |
| <button onClick={() => setFaqOpenState({ ...faqOpenState, [idx]: !isOpen })} className="w-full text-left p-5 flex justify-between items-center font-display font-bold text-sm text-[#181c1e] cursor-pointer"> | |
| <span>{faq.q}</span> | |
| <span className="text-[#082F49] text-lg font-bold ml-4">{isOpen ? '−' : '+'}</span> | |
| </button> | |
| {isOpen && <div className="px-5 pb-5 pt-1 text-xs text-slate-600 leading-relaxed border-t border-dashed border-slate-200">{faq.a}</div>} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </section> | |
| {/* Footer */} | |
| <footer className="bg-[#0A121D] text-slate-300 pt-16 pb-10 border-t-2 border-[#082F49]/30"> | |
| <div className="max-w-6xl mx-auto px-4 md:px-8 grid grid-cols-1 md:grid-cols-12 gap-10 pb-12 border-b border-white/5 text-xs"> | |
| <div className="md:col-span-5 space-y-4"> | |
| <div className="flex items-center gap-2.5 text-white"> | |
| <div className="w-8 h-8 rounded-lg bg-[#082F49] flex items-center justify-center font-black text-sm select-none">DD</div> | |
| <span className="font-display font-extrabold text-base text-white">DermaDetect</span> | |
| </div> | |
| <p className="text-slate-400 leading-relaxed text-xs">DermaDetect is an open-source assistive screening tool designed to expand primary care diagnostic support in community, rural, and outreach settings.</p> | |
| <p className="text-slate-500 text-[10.5px] leading-relaxed">*This is a clinical decision support tool and does not replace professional medical advice or formal diagnosis.</p> | |
| </div> | |
| <div className="md:col-span-2 space-y-3"> | |
| <h4 className="font-display font-bold text-white text-xs uppercase tracking-wider">Clinical Tools</h4> | |
| <ul className="space-y-2 text-slate-400"> | |
| <li><button onClick={resetAndStartAssessment} className="hover:text-white transition-colors">Start Assessment</button></li> | |
| <li><button onClick={() => setScreen('case-history')} className="hover:text-white transition-colors">View Patient Roster</button></li> | |
| </ul> | |
| </div> | |
| <div className="md:col-span-3 space-y-3"> | |
| <h4 className="font-display font-bold text-white text-xs uppercase tracking-wider">Device Privacy</h4> | |
| <p className="text-slate-400 leading-relaxed text-[11px]">All processing occurs locally within your browser. No sensitive patient data is transmitted to remote servers.</p> | |
| </div> | |
| </div> | |
| <div className="max-w-6xl mx-auto px-4 md:px-8 pt-8 flex justify-between items-center text-[11px] text-slate-400"> | |
| <span>© {new Date().getFullYear()} DermaDetect Telehealth Systems.</span> | |
| </div> | |
| </footer> | |
| </div> | |
| )} | |
| {/* ASSESSMENT FLOW */} | |
| {(screen === 'assessment-info' || screen === 'assessment-capture' || screen === 'assessment-review' || screen === 'referral-note') && ( | |
| <div className="flex-1 flex flex-col md:flex-row"> | |
| {/* Sidebar */} | |
| <aside className="w-full md:w-64 bg-slate-50 md:border-r border-slate-200 py-4 md:py-6 px-4 flex flex-col md:min-h-[calc(100vh-3.5rem)] select-none"> | |
| <div className="mb-6 hidden md:block mt-7"> | |
| <h2 className="font-display font-bold text-base text-slate-900">Assessment Progress</h2> | |
| <p className="text-xs text-slate-500 mt-0.5">Clinical Workflow Protocol</p> | |
| </div> | |
| <div className="flex md:flex-col gap-2 overflow-x-auto pb-2 md:pb-0 scrollbar-none"> | |
| {[ | |
| { label: 'Step 1: Patient Info', icon: <UserPen className="w-4 h-4 shrink-0" />, id: 'assessment-info', enabled: true }, | |
| { label: 'Step 2: Skin Capture', icon: <Camera className="w-4 h-4 shrink-0" />, id: 'assessment-capture', enabled: !!patient.name }, | |
| { label: 'Step 3: Action Results', icon: <ClipboardCheck className="w-4 h-4 shrink-0" />, id: 'assessment-review', enabled: !!activeAnalysisResult }, | |
| ].map(({ label, icon, id, enabled }) => ( | |
| <div key={id} onClick={() => { if (enabled) setScreen(id as any); }} | |
| className={`flex items-center gap-3 p-3 rounded-xl transition-all grow md:grow-0 shrink-0 ${!enabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} ${screen === id ? 'bg-[#00A6FB] text-white font-semibold shadow-sm' : 'text-slate-600 hover:bg-slate-200/60'}`}> | |
| {icon}<span className="font-display text-xs font-medium whitespace-nowrap">{label}</span> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="mt-auto hidden md:block pt-4 border-t border-slate-200"> | |
| <div className="flex items-center gap-2 px-1 text-slate-400"> | |
| <ShieldCheck className="w-4 h-4 text-[#00A6FB]" /> | |
| <span className="font-sans font-semibold text-[9px] tracking-wider uppercase">Secure Diagnostic Link</span> | |
| </div> | |
| </div> | |
| </aside> | |
| <div className="flex-1 flex flex-col pt-4 md:pt-0"> | |
| {/* Step 1: Patient Info */} | |
| {screen === 'assessment-info' && ( | |
| <div className="p-4 md:p-8 flex-1 flex items-center justify-center canvas-bg mt-7"> | |
| <div className="bg-white border border-[#bccac1] rounded-2xl p-6 md:p-8 max-w-lg w-full shadow-sm"> | |
| <header className="mb-6"> | |
| <h2 className="font-display font-extrabold text-xl md:text-2xl text-[#181c1e]">Patient Details</h2> | |
| <p className="text-xs text-[#3d4943] mt-1.5">Record basic patient identifiers, demographics, and symptom definitions.</p> | |
| </header> | |
| <form className="space-y-5" onSubmit={e => { e.preventDefault(); if (patient.name) setScreen('assessment-capture'); }}> | |
| <div className="space-y-1.5"> | |
| <label className="font-display text-xs font-bold text-[#181c1e] block">Patient Full Name</label> | |
| <input type="text" required value={patient.name} onChange={e => setPatient({...patient, name: e.target.value})} placeholder="Full legal name" className="w-full h-11 px-3.5 border border-[#bccac1] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#0077b6] bg-white text-sm transition-all" /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div className="space-y-1.5"> | |
| <label className="font-display text-xs font-bold text-[#181c1e] block">Age (Years)</label> | |
| <input type="number" required min="0" max="125" value={patient.age} onChange={e => setPatient({...patient, age: e.target.value})} placeholder="e.g. 34" className="w-full h-11 px-3.5 border border-[#bccac1] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#0077b6] bg-white text-sm transition-all" /> | |
| </div> | |
| <div className="space-y-1.5"> | |
| <label className="font-display text-xs font-bold text-[#181c1e] block">Sex Selection</label> | |
| <div className="flex h-11 border border-[#bccac1] rounded-lg overflow-hidden bg-white"> | |
| {['Male','Female'].map((s, i) => ( | |
| <React.Fragment key={s}> | |
| {i > 0 && <div className="w-[1px] bg-[#bccac1]" />} | |
| <button type="button" onClick={() => setPatient({...patient, sex: s as any})} className={`flex-1 text-xs font-bold transition-all ${patient.sex === s ? 'bg-[#d6e0f6] text-[#121c2c]' : 'text-[#3d4943] hover:bg-[#f1f4f6]'}`}>{s}</button> | |
| </React.Fragment> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-1.5"> | |
| <label className="font-display text-xs font-bold text-[#181c1e] block">Patient Contact Number</label> | |
| <input type="tel" required value={patient.contactNumber} onChange={e => setPatient({...patient, contactNumber: e.target.value})} placeholder="0200000000" max={10} className="w-full h-11 px-3.5 border border-[#bccac1] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#0077b6] bg-white text-sm transition-all" /> | |
| </div> | |
| <div className="space-y-1.5"> | |
| <label className="font-display text-xs font-bold text-[#181c1e] block">Brief Symptom Description</label> | |
| <textarea rows={3} value={patient.symptoms} onChange={e => setPatient({...patient, symptoms: e.target.value})} placeholder="Describe lesion duration, localized itch, flareups, and changes observed on the skin..." className="w-full p-3 border border-[#bccac1] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#0077b6] bg-white text-sm transition-all resize-none" /> | |
| </div> | |
| <div className="pt-2"> | |
| <button type="submit" disabled={!patient.name || !patient.age || !patient.sex || !patient.contactNumber} className="w-full h-11 bg-[#0077b6] hover:bg-[#0096c7] disabled:bg-[#bccac1] disabled:cursor-not-allowed text-white font-display font-bold text-sm rounded-lg transition-colors flex items-center justify-center gap-1.5 shadow-sm"> | |
| <span>Next Step: Capture Skin Image</span><ArrowRight className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| )} | |
| {/* Step 2: Capture */} | |
| {screen === 'assessment-capture' && ( | |
| <div className="p-4 md:p-8 flex-1 canvas-bg mt-7"> | |
| <div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8 items-start"> | |
| <div className="space-y-6"> | |
| <div> | |
| <span className="inline-block bg-[#ebeef0] text-[#555f71] px-2.5 py-0.5 rounded-full font-display font-semibold text-[10px] mb-2 uppercase tracking-wide">Assessment Phase 02</span> | |
| <h2 className="font-display font-extrabold text-[#181c1e] text-2xl">Capture Skin Image</h2> | |
| <p className="text-sm text-[#3d4943] mt-2 leading-relaxed">For accurate AI triage evaluation, capture a sharp close-up photo of the patient's skin lesion.</p> | |
| </div> | |
| <div className="bg-white border border-[#bccac1] p-4 rounded-xl flex gap-3"> | |
| <div className="p-2 bg-[#d6e0f6] rounded-full text-[#0077b6] h-fit"><Lightbulb className="w-5 h-5" /></div> | |
| <div> | |
| <h4 className="font-display font-bold text-xs text-[#181c1e]">Photography Tip</h4> | |
| <p className="text-[11px] text-[#3d4943] mt-1 leading-relaxed">Use natural lighting if possible. Avoid using direct camera flash over wet or oily ulcers.</p> | |
| </div> | |
| </div> | |
| <div className="flex flex-col sm:flex-row gap-3"> | |
| {isCameraActive ? ( | |
| <button onClick={captureFrame} className="flex-1 h-11 bg-[#0077b6] text-white rounded-lg font-display font-bold text-sm hover:bg-[#0096c7] transition-colors flex items-center justify-center gap-2 cursor-pointer shadow-sm"> | |
| <Camera className="w-5 h-5 animate-pulse" />Snaps Assessment Frame | |
| </button> | |
| ) : ( | |
| <button onClick={startWebcam} className="flex-1 h-11 bg-[#0077b6] text-white rounded-lg font-display font-bold text-sm hover:bg-[#0096c7] transition-colors flex items-center justify-center gap-2 cursor-pointer shadow-sm"> | |
| <Camera className="w-5 h-5" />Take Photo with Camera | |
| </button> | |
| )} | |
| <label className="flex-1 h-11 border-2 border-dashed border-[#0077b6] text-[#0077b6] bg-white rounded-lg font-display font-bold text-sm hover:bg-[#f1f4f6] transition-all flex items-center justify-center gap-2 cursor-pointer"> | |
| <Upload className="w-5 h-5" /><span>Upload from Gallery</span> | |
| <input type="file" accept="image/*" onChange={handleFileChange} className="hidden" /> | |
| </label> | |
| </div> | |
| {isCameraActive && <button onClick={stopWebcam} className="w-full h-9 bg-[#ffdad6] text-[#93000a] text-xs font-bold rounded-lg hover:bg-red-200 transition-colors">Cancel Camera Stream</button>} | |
| </div> | |
| <div className="space-y-4"> | |
| <div className="bg-white border rounded-2xl p-4 border-[#bccac1]"> | |
| <h3 className="font-display text-xs font-bold text-[#181c1e] mb-3 uppercase tracking-wider">Assessment Preview</h3> | |
| <div className="aspect-[4/5] bg-[#ebeef0] border-2 border-dashed border-[#bccac1] rounded-xl overflow-hidden flex flex-col items-center justify-center p-4 relative"> | |
| {isCameraActive && !capturedImage && <video ref={videoRef} autoPlay className="w-full h-full object-cover rounded-lg absolute inset-0" />} | |
| {capturedImage ? ( | |
| <img src={capturedImage} alt="Clinic Skin Frame" className="w-full h-full object-cover rounded-lg absolute inset-0" /> | |
| ) : (!isCameraActive && ( | |
| <div className="text-center p-6 space-y-3 pointer-events-none"> | |
| <span className="w-12 h-12 rounded-full bg-[#f1f4f6] border border-[#bccac1] flex items-center justify-center text-[#6d7a73] mx-auto"><Camera className="w-6 h-6" /></span> | |
| <p className="font-display font-extrabold text-sm text-[#3d4943]">No active image captured</p> | |
| </div> | |
| ))} | |
| {cameraError && !capturedImage && ( | |
| <div className="absolute inset-0 bg-[#ffdad6]/95 flex items-center justify-center p-6 text-center z-10 rounded-lg"> | |
| <p className="text-xs font-semibold text-[#93000a] leading-relaxed">{cameraError}</p> | |
| </div> | |
| )} | |
| </div> | |
| {capturedImage && ( | |
| <div className="mt-4 p-3 bg-[#f0f9ff] rounded-xl border border-[#bccac1] flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <CheckCircle2 className="w-5 h-5 text-[#0077b6]" /> | |
| <span className="text-xs font-bold text-[#181c1e]">{customFileSelected ? 'Local File Selected' : 'Specimen Pre-loaded'}</span> | |
| </div> | |
| <button onClick={() => { setCapturedImage(null); setCustomFileSelected(false); }} className="text-xs font-bold text-[#ba1a1a] hover:underline">Reset Choice</button> | |
| </div> | |
| )} | |
| </div> | |
| {capturedImage && ( | |
| <button onClick={runAiAnalysis} disabled={analysisLoading} className="w-full h-12 bg-[#0077b6] hover:bg-[#0096c7] text-white rounded-xl font-display font-extrabold text-sm flex items-center justify-center gap-2 shadow-md cursor-pointer active:scale-95 transition-all"> | |
| <Activity className="w-5 h-5" /><span>Analyse Skin Condition</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Step 3: Results */} | |
| {screen === 'assessment-review' && activeAnalysisResult && ( | |
| <div className="p-4 md:p-8 flex-1 bg-[#F8FAFC] mt-7 select-none"> | |
| <div className="max-w-4xl mx-auto space-y-6"> | |
| <header className="border-b border-slate-200 pb-4"> | |
| <h2 className="font-display font-bold text-2xl text-slate-900">Assessment Results</h2> | |
| <p className="text-xs text-slate-500 mt-1.5">Review structural profile classifications and matching reference criteria metrics.</p> | |
| </header> | |
| <div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-start"> | |
| <div className="md:col-span-7 space-y-6"> | |
| <div className="bg-white border border-slate-200 p-5 rounded-2xl shadow-sm"> | |
| <span className="text-[#00A6FB] font-sans font-bold text-[10px] tracking-wider uppercase block">Primary Medical Profile Finding</span> | |
| <div className="flex justify-between items-start mt-1.5 mb-4"> | |
| <h3 className="font-display font-bold text-xl md:text-2xl text-slate-900">{activeAnalysisResult.primaryFinding}</h3> | |
| <div className="bg-slate-50 px-3 py-1 rounded-xl flex flex-col items-end shrink-0 border border-slate-200"> | |
| <span className={`font-display font-bold text-xl leading-none ${activeAnalysisResult.urgency === 'High' ? 'text-rose-600' : activeAnalysisResult.urgency === 'Moderate' ? 'text-amber-500' : 'text-emerald-600'}`}>{activeAnalysisResult.confidence}%</span> | |
| <span className="text-[9px] font-semibold text-slate-400 mt-0.5 uppercase tracking-wider">Match</span> | |
| </div> | |
| </div> | |
| <div className="h-2 w-full bg-slate-100 rounded-full overflow-hidden"> | |
| <div className={`h-full rounded-full ${activeAnalysisResult.urgency === 'High' ? 'bg-rose-600' : activeAnalysisResult.urgency === 'Moderate' ? 'bg-amber-500' : 'bg-emerald-600'}`} style={{ width: `${activeAnalysisResult.confidence}%` }} /> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 className="font-bold text-sm text-[#0077b6] uppercase tracking-wider border-b border-[#f1f4f6] pb-2 mb-4">AI Clinical Evaluation</h3> | |
| <div className="space-y-6"> | |
| <div className="bg-white border border-[#bccac1] p-6 rounded-xl shadow-sm"> | |
| {activeAnalysisResult.referralNote ? ( | |
| <ReactMarkdown components={{ | |
| strong: ({ node, ...props }) => <strong className="block font-bold text-[#0077b6] text-xs uppercase tracking-wider mt-5 mb-2 pb-1 border-b border-slate-100" {...props} />, | |
| p: ({ node, ...props }) => <p className="text-sm text-slate-700 leading-relaxed mb-3 font-normal" {...props} />, | |
| ul: ({ node, ...props }) => <ul className="space-y-1.5 mb-3 ml-4" {...props} />, | |
| li: ({ node, ...props }) => <li className="text-sm text-slate-700 leading-relaxed list-disc" {...props} />, | |
| }}>{activeAnalysisResult.referralNote}</ReactMarkdown> | |
| ) : ( | |
| <p className="text-slate-400 italic text-sm">No clinical narrative generated yet. Click "Format Referral Summary" to generate.</p> | |
| )} | |
| </div> | |
| <div className="print:hidden"> | |
| <TreatmentRecommendations finding={activeAnalysisResult} patient={patient} caseId={activeCaseId || 'NEW-CASE'} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="md:col-span-5 space-y-6"> | |
| <div className="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm"> | |
| <div className="p-3 border-b border-slate-200 bg-slate-50 flex justify-between items-center text-xs font-medium text-slate-600"> | |
| <span>Patient Lesion Frame</span> | |
| <span className="text-[10px] text-slate-400 font-mono uppercase">IMG_REFERENCE.jpg</span> | |
| </div> | |
| <div className="aspect-square bg-slate-50 relative"> | |
| {capturedImage && <img src={capturedImage} alt="Captured skin lesion" className="w-full h-full object-cover filter brightness-95" />} | |
| </div> | |
| {activeAnalysisResult.heatmap_b64 && ( | |
| <div className="aspect-square bg-slate-50 relative pt-3"> | |
| <div className="bg-[#e0e3e5] overflow-hidden border border-[#bccac1] relative aspect-square"> | |
| <img src={`data:image/jpeg;base64,${activeAnalysisResult.heatmap_b64}`} alt="AI Saliency Map" className="w-full h-full object-cover" /> | |
| <div className="absolute top-2 left-2 bg-[#0077b6]/80 text-white text-[10px] px-2 py-1 rounded font-bold uppercase tracking-wide">AI Saliency Map</div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className={`p-4 rounded-2xl border flex items-start gap-3 shadow-sm ${activeAnalysisResult.urgency==='High' ? 'bg-rose-50 border-rose-200' : activeAnalysisResult.urgency==='Moderate' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}> | |
| <div className={`p-1.5 rounded-lg shrink-0 text-white ${activeAnalysisResult.urgency==='High' ? 'bg-rose-600' : activeAnalysisResult.urgency==='Moderate' ? 'bg-amber-600' : 'bg-slate-600'}`}> | |
| <AlertTriangle className="w-4 h-4" /> | |
| </div> | |
| <div className="space-y-1"> | |
| <span className="font-sans font-bold text-[10px] tracking-wider uppercase block">Triage Metric: {activeAnalysisResult.urgency} Urgency</span> | |
| <p className="text-xs text-slate-600 leading-relaxed font-light">{activeAnalysisResult.urgencyText}</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="pt-6 border-t border-slate-200 flex flex-col sm:flex-row justify-end gap-3"> | |
| <button onClick={handleFormatReferral} disabled={referralNoteLoading} className="h-11 px-6 border border-slate-200 text-slate-700 bg-white font-display font-semibold text-xs uppercase tracking-wider rounded-xl hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 cursor-pointer shadow-sm"> | |
| {referralNoteLoading ? <><Loader2 className="w-4 h-4 animate-spin text-[#00A6FB]" /><span>Generating Note...</span></> : <><FileText className="w-4 h-4 text-[#00A6FB]" /><span>Format Referral Summary</span></>} | |
| </button> | |
| <button onClick={saveCaseRecord} className="h-11 px-8 bg-[#00A6FB] text-white font-display font-semibold text-xs uppercase tracking-wider rounded-xl hover:bg-[#008cc4] transition-colors flex items-center justify-center gap-2 cursor-pointer shadow-md"> | |
| <Save className="w-4 h-4" /><span>Commit Case Record</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Step 4: Referral Note */} | |
| {screen === 'referral-note' && activeAnalysisResult && (() => { | |
| const getConditionDetails = (name: string) => { | |
| const n = name.toLowerCase(); | |
| if (n.includes('ringworm') || n.includes('tinea')) return { description: "Tinea corporis is a superficial fungal infection characterised by a ring-shaped, scaly, itchy rash. Highly treatable with topical antifungal agents.", suggestions: ["Clotrimazole 1% cream — apply twice daily for 2–4 weeks", "Keep area clean and dry", "Avoid sharing towels or clothing"] }; | |
| if (n.includes('eczema') || n.includes('atopic') || n.includes('dermatitis')) return { description: "Atopic dermatitis is a chronic pruritic inflammatory skin condition managed with hydration, trigger avoidance, and topical anti-inflammatories.", suggestions: ["Hydrocortisone 1% cream — apply twice daily for 7 days", "Apply thick emollient moisturizer frequently", "Avoid harsh scented soaps and hot baths"] }; | |
| if (n.includes('impetigo')) return { description: "Impetigo is a highly contagious superficial bacterial skin infection characterized by honey-colored crusts. Managed with antibiotic therapy.", suggestions: ["Mupirocin 2% topical ointment — apply 3 times daily", "Gently clean honey-colored crusts with warm soapy water", "Keep lesions covered to prevent auto-inoculation"] }; | |
| if (n.includes('scabies')) return { description: "Scabies is an intensely itchy skin infestation caused by the mite Sarcoptes scabiei. Highly contagious. Managed with permethrin or ivermectin.", suggestions: ["Permethrin 5% cream — apply from neck down, wash after 8-14 hours", "Treat all household contacts simultaneously", "Wash bedding and clothes in hot water"] }; | |
| return { description: "A potential clinical skin indication detected by the assistive triage scanner. Standard clinical diagnostic procedures are recommended before commencing definitive therapy.", suggestions: activeAnalysisResult.treatmentNotes?.length ? activeAnalysisResult.treatmentNotes : ["Monitor area daily for pigment or dimension shifts", "Keep the affected region clean, dry, and cool", "Refer to dermatology clinic if symptoms do not improve"] }; | |
| }; | |
| const docDetails = getConditionDetails(activeAnalysisResult.primaryFinding); | |
| const isHigh = activeAnalysisResult.urgency === 'High'; | |
| const isMod = activeAnalysisResult.urgency === 'Moderate'; | |
| const urgencyHex = isHigh ? '#D85A30' : isMod ? '#EF9F27' : '#082F49'; | |
| const urgencyStrip = isHigh ? '● URGENT — Immediate referral required. Do not delay.' : isMod ? '● MODERATE — Refer to clinic within 3 days for assessment and treatment.' : '● MILD — Can be managed locally. Refer if no improvement in 7 days.'; | |
| const urgencyBadge = isHigh ? '● Urgent Referral' : isMod ? '● Moderate Urgency' : '● Mild Urgency'; | |
| const cleanRefId = activeCaseId || `DD-${new Date().getFullYear()}-00847`; | |
| return ( | |
| <div className="p-4 md:p-8 flex-1 bg-slate-50 min-h-screen grid grid-cols-1 lg:grid-cols-12 gap-8 items-start max-w-6xl mx-auto w-full font-sans select-none"> | |
| {/* Left panel */} | |
| <div className="lg:col-span-4 space-y-4 no-print shrink-0 w-full"> | |
| <div className="bg-white border border-slate-200 p-6 rounded-xl space-y-4 shadow-sm"> | |
| <div className="flex items-center gap-2"> | |
| <Printer className="w-5 h-5" style={{ color: '#082F49' }} /> | |
| <h4 className="font-display font-bold text-sm text-[#0A1628]">Referral Note Actions</h4> | |
| </div> | |
| <p className="text-xs text-slate-500 leading-relaxed">This structured note details the verified findings and can be printed or shared.</p> | |
| <div className="space-y-2 pt-2"> | |
| <button onClick={downloadReferralNotePdf} className="w-full h-11 bg-[#082F49] hover:bg-[#032D49]/30 text-white font-display font-medium text-xs rounded-lg transition-all flex items-center justify-center gap-2 cursor-pointer shadow-sm"> | |
| <Printer className="w-4 h-4" /><span>Download as PDF</span> | |
| </button> | |
| <a href={`https://wa.me/?text=DermaDetect%20Referral%20Note%20for%20${encodeURIComponent(patient.name||'Patient')}%20—%20${encodeURIComponent(activeAnalysisResult.primaryFinding)}%20—%20${isHigh?'URGENT':isMod?'MODERATE':'MILD'}%20urgency.`} target="_blank" rel="noreferrer" className="w-full h-11 bg-[#082F49]/40 hover:bg-[#082F49]/60 text-white font-display font-medium text-xs rounded-lg transition-all flex items-center justify-center gap-2 cursor-pointer shadow-sm"> | |
| <Share2 className="w-4 h-4" /><span>Share via WhatsApp</span> | |
| </a> | |
| </div> | |
| </div> | |
| <button onClick={() => setScreen('assessment-review')} className="w-full h-10 bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 rounded-lg text-xs font-semibold flex items-center justify-center gap-1.5 transition-all cursor-pointer"> | |
| <ChevronLeft className="w-4 h-4" /><span>Return to Review Stage</span> | |
| </button> | |
| </div> | |
| {/* Document */} | |
| <div className="lg:col-span-8 flex justify-center w-full print-wrapper"> | |
| <div className="bg-white border-l-[4px] border-y border-r border-slate-200 w-full max-w-2xl p-6 md:p-8 rounded-xl shadow-md flex flex-col text-[#0A1628] font-sans leading-relaxed"> | |
| {/* Header band */} | |
| <div className="flex justify-between items-center bg-[#0a3369] text-white px-5 py-4 rounded-lg mb-4 h-16"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-9 h-9 rounded-md bg-white/20 flex items-center justify-center font-black text-white shrink-0">DD</div> | |
| <div> | |
| <h3 className="font-display font-extrabold text-base tracking-tight leading-none text-white">DermaDetect</h3> | |
| <p className="text-[10px] text-white/85 font-mono mt-1 font-semibold leading-none">AI-Powered Skin Assessment</p> | |
| </div> | |
| </div> | |
| <div className="h-10 w-[1px] bg-white/20 mx-4 hidden sm:block" /> | |
| <div className="text-right flex-1 sm:flex-initial"> | |
| <span className="font-display font-black text-xs sm:text-sm tracking-widest block uppercase text-white">CLINICAL REFERRAL NOTE</span> | |
| <span className="text-[9px] font-mono text-white/95 mt-1 block uppercase tracking-wider">REF: {cleanRefId}</span> | |
| </div> | |
| </div> | |
| {/* Alert stripe */} | |
| <div className="w-full h-9 flex items-center justify-center px-4 mb-6 rounded text-white text-[11px] font-bold" style={{ backgroundColor: urgencyHex }}> | |
| {urgencyStrip} | |
| </div> | |
| {/* Two-column body */} | |
| <div className="grid grid-cols-1 md:grid-cols-12 gap-6"> | |
| {/* Left col */} | |
| <div className="md:col-span-7 space-y-6"> | |
| {/* Patient info */} | |
| <div className="space-y-2 text-left"> | |
| <h4 className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase border-b border-teal-100 pb-1">PATIENT INFORMATION</h4> | |
| <div className="divide-y divide-slate-100 text-xs"> | |
| {[ | |
| ['Full Name', patient.name || '—'], | |
| ['Contact Number', patient.contactNumber || '—'], | |
| ['Age', patient.age ? `${patient.age} years` : '—'], | |
| ['Sex', patient.sex || '—'], | |
| ['Date of Visit', new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })], | |
| ['Patient ID', cleanRefId], | |
| ].map(([label, val]) => ( | |
| <div key={label} className="grid grid-cols-3 py-2"> | |
| <span className="text-slate-500 font-medium col-span-1">{label}</span> | |
| <span className="font-semibold text-[#0A1628] col-span-2 text-right md:text-left">{val}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Referring health worker — uses real clinician data */} | |
| <div className="space-y-2 text-left"> | |
| <h4 className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase border-b border-teal-100 pb-1">REFERRING HEALTH WORKER</h4> | |
| <div className="divide-y divide-slate-100 text-xs"> | |
| {[ | |
| ['Name', clinicianName], | |
| ['Role', clinicianRole], | |
| ['Facility Name', clinicianFacility], | |
| ['District', clinicianDistrict || '—'], | |
| ['Region', clinicianRegion ? `${clinicianRegion} Region` : '—'], | |
| ['Contact', clinicianContact || '—'], | |
| ].map(([label, val]) => ( | |
| <div key={label} className="grid grid-cols-3 py-2"> | |
| <span className="text-slate-500 font-medium col-span-1">{label}</span> | |
| <span className="font-semibold text-[#0A1628] col-span-2 text-right md:text-left">{val}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Refer to */} | |
| <div className="space-y-2 text-left"> | |
| <h4 className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase border-b border-teal-100 pb-1">REFER TO</h4> | |
| <div className="divide-y divide-slate-100 text-xs"> | |
| <div className="grid grid-cols-3 py-2"><span className="text-slate-500 font-medium col-span-1">Facility Type</span><span className="font-semibold text-[#0A1628] col-span-2 text-right md:text-left">District Hospital / Dermatology Clinic</span></div> | |
| <div className="grid grid-cols-3 py-2"><span className="text-slate-500 font-medium col-span-1">Department</span><span className="font-semibold text-slate-600 col-span-2 text-right md:text-left">Dermatology / General OPD</span></div> | |
| <div className="grid grid-cols-3 py-2"><span className="text-slate-500 font-medium col-span-1">Urgency</span><span className={`font-bold col-span-2 text-right md:text-left ${isHigh ? 'text-rose-600' : isMod ? 'text-amber-600' : 'text-[#082F49]'}`}>{isHigh ? 'Immediate — Do Not Delay' : isMod ? 'Within 3 days' : 'Within 7 days'}</span></div> | |
| </div> | |
| </div> | |
| {/* Health worker notes */} | |
| <div className="space-y-2 text-left"> | |
| <h4 className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase border-b border-teal-100 pb-1">HEALTH WORKER'S NOTES</h4> | |
| <div className="p-3.5 bg-slate-50 border border-slate-100 rounded-lg text-xs leading-relaxed text-slate-700 min-h-[60px]"> | |
| {patient.symptoms?.trim() ? <p className="italic">"{patient.symptoms.trim()}"</p> : <p className="text-slate-400 italic">No additional notes recorded.</p>} | |
| </div> | |
| </div> | |
| {/* Recommended Medications (Editable Card) */} | |
| <div className="space-y-2 text-left avoid-break"> | |
| <h4 className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase border-b border-teal-100 pb-1">RECOMMENDED MEDICATIONS <span className="no-print text-emerald-600 font-sans tracking-normal normal-case font-semibold text-[10px]"> (editable)</span></h4> | |
| <div className="p-3.5 bg-[#F0FDF4] border border-[#DCFCE7] rounded-lg text-xs space-y-3 shadow-xs"> | |
| <div className="flex flex-col gap-1"> | |
| <span className="text-[9px] text-[#15803D] uppercase font-bold tracking-wider">Prescribed Medication</span> | |
| <input | |
| type="text" | |
| value={prescribedMedication} | |
| onChange={(e) => setPrescribedMedication(e.target.value)} | |
| className="w-full bg-white border border-[#BBF7D0] rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-[#15803D] text-xs font-semibold text-[#14532D]" | |
| placeholder="e.g. Timolol 0.5% Ophthalmic Gel" | |
| /> | |
| </div> | |
| <div className="flex flex-col gap-1"> | |
| <span className="text-[9px] text-[#15803D] uppercase font-bold tracking-wider">Dosage Regimen / Directions</span> | |
| <textarea | |
| rows={2} | |
| value={prescribedRegimen} | |
| onChange={(e) => setPrescribedRegimen(e.target.value)} | |
| className="w-full bg-white border border-[#BBF7D0] rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-[#15803D] text-xs leading-relaxed text-[#14532D] resize-none" | |
| placeholder="e.g. Apply twice daily to the affected areas for 2 weeks." | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right col */} | |
| <div className="md:col-span-5 space-y-6"> | |
| <div className="p-4 bg-[#F0FDF8] border-l-2 border-l-[#082F49] rounded-r-xl border border-teal-100/35 text-left space-y-4 shadow-sm"> | |
| <div className="flex justify-between items-center"> | |
| <span className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase">AI ASSESSMENT</span> | |
| <span className="px-2 py-0.5 bg-teal-50 border border-teal-100 text-[9px] uppercase font-bold text-[#082F49] rounded">ANALYSIS OK</span> | |
| </div> | |
| <div className="space-y-1.5"> | |
| <h3 className="font-display font-black text-[#0A1628] text-base leading-snug">{activeAnalysisResult.primaryFinding}</h3> | |
| <div className="flex justify-between items-center text-[11px] font-medium text-slate-500"> | |
| <span>Detection confidence</span> | |
| <span className={`font-mono font-bold ${activeAnalysisResult.urgency === 'High' ? 'text-rose-600' : activeAnalysisResult.urgency === 'Moderate' ? 'text-amber-500' : 'text-emerald-600'}`}>{activeAnalysisResult.confidence}%</span> | |
| </div> | |
| <div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden"> | |
| <div className={`h-full rounded-full ${activeAnalysisResult.urgency === 'High' ? 'bg-rose-600' : activeAnalysisResult.urgency === 'Moderate' ? 'bg-amber-500' : 'bg-emerald-600'}`} style={{ width: `${activeAnalysisResult.confidence}%` }} /> | |
| </div> | |
| </div> | |
| <div className="pt-1"> | |
| <span className="inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-bold rounded-full text-white leading-none" style={{ backgroundColor: urgencyHex }}>{urgencyBadge}</span> | |
| </div> | |
| <p className="text-slate-600 text-xs leading-relaxed">{docDetails.description}</p> | |
| <div className="space-y-2 pt-2.5 border-t border-teal-100/40"> | |
| <span className="text-[10px] font-bold font-mono tracking-wider text-slate-400 uppercase block">SUGGESTED TREATMENT</span> | |
| <div className="flex flex-col gap-1.5"> | |
| {docDetails.suggestions.map((item, id) => ( | |
| <div key={id} className="flex gap-1.5 items-start text-xs text-[#0A1628]"> | |
| <span className="inline-block mt-1 w-1.5 h-1.5 bg-[#082F49] rounded-full shrink-0" /><span>{item}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| <p className="text-[9.5px] italic text-slate-400 leading-normal pt-1.5 border-t border-teal-100/20">This is an AI-generated suggestion. Final treatment decisions rest with the receiving clinician.</p> | |
| </div> | |
| {/* Photo */} | |
| <div className="space-y-2 text-left"> | |
| <h4 className="text-[11px] font-bold font-mono tracking-widest text-[#082F49] uppercase py-1 border-b border-sky-300"> | |
| PHOTO TAKEN DURING ASSESSMENT | |
| </h4> | |
| <div className="relative aspect-square w-full rounded-xl border border-slate-150 overflow-hidden bg-slate-50 shrink-0"> | |
| {capturedImage ? ( | |
| <img src={capturedImage} alt="Skin lesion" className="w-full h-full object-cover" referrerPolicy="no-referrer" /> | |
| ) : ( | |
| <div className="w-full h-full flex flex-col items-center justify-center text-slate-300"> | |
| <Activity className="w-8 h-8 animate-pulse" /> | |
| <span className="text-[10px] font-semibold tracking-wider uppercase mt-2">NO PHOTO CAPTURED</span> | |
| </div> | |
| )} | |
| <div className="absolute top-2.5 left-2.5 bg-[#082F49]/85 backdrop-blur-xs px-2 py-1 rounded text-[9px] font-bold font-mono text-white select-none tracking-widest uppercase">Clinical Specimen</div> | |
| <div className="absolute bottom-2.5 right-2.5 bg-slate-900/40 backdrop-blur-xs px-2 py-1 rounded text-[9px] font-bold font-mono text-white/95 select-none tracking-widest uppercase">DermaDetect AI</div> | |
| </div> | |
| <div className="relative aspect-square w-full rounded-xl border border-slate-150 overflow-hidden bg-slate-50 shrink-0"> | |
| {activeAnalysisResult.heatmap_b64 ? ( | |
| <img src={`data:image/png;base64,${activeAnalysisResult.heatmap_b64}`} alt="Skin lesion" className="w-full h-full object-cover" referrerPolicy="no-referrer" /> | |
| ) : ( | |
| <div className="w-full h-full flex flex-col items-center justify-center text-slate-300"> | |
| <Activity className="w-8 h-8 animate-pulse" /> | |
| <span className="text-[10px] font-semibold tracking-wider uppercase mt-2">NO HEATMAP AVAILABLE</span> | |
| </div> | |
| )} | |
| <div className="absolute top-2.5 left-2.5 bg-[#082F49]/85 backdrop-blur-xs px-2 py-1 rounded text-[9px] font-bold font-mono text-white select-none tracking-widest uppercase">AI Saliency Map</div> | |
| <div className="absolute bottom-2.5 right-2.5 bg-slate-900/40 backdrop-blur-xs px-2 py-1 rounded text-[9px] font-bold font-mono text-white/95 select-none tracking-widest uppercase">DermaDetect AI</div> | |
| </div> | |
| <span className="text-[10px] text-slate-400 mt-1 block font-mono">Photo captured: {new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Signature block */} | |
| <div className="grid grid-cols-2 gap-6 mt-8 pt-6 border-t border-slate-200 text-left"> | |
| <div className="space-y-2.5"> | |
| <span className="text-[10px] font-bold font-mono tracking-wider text-slate-400 uppercase block">HEALTH WORKER SIGNATURE</span> | |
| <div className="relative"> | |
| <canvas ref={signatureCanvasRef} onMouseDown={startDrawingSig} onMouseMove={drawSig} onMouseUp={stopDrawingSig} onMouseLeave={stopDrawingSig} onTouchStart={startDrawingSig} onTouchMove={drawSig} onTouchEnd={stopDrawingSig} className="w-full h-16 border border-dashed border-slate-300 rounded-lg bg-slate-50 cursor-crosshair" width={240} height={64} /> | |
| <button onClick={clearSig} className="absolute top-1.5 right-1.5 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-white border border-slate-200 text-slate-500 rounded hover:bg-slate-50 no-print">Clear</button> | |
| </div> | |
| <div className="text-xs"> | |
| <p className="font-bold text-[#0A1628]">{clinicianName}</p> | |
| <p className="text-[10px] text-slate-400 mt-0.5">{clinicianRole} · {clinicianFacility}</p> | |
| <p className="text-[10px] text-slate-400 mt-0.5">Date: {new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</p> | |
| </div> | |
| </div> | |
| <div className="space-y-2.5 flex flex-col justify-between"> | |
| <span className="text-[10px] font-bold font-mono tracking-wider text-slate-400 uppercase block">RECEIVING CLINICIAN STAMP / SIGNATURE</span> | |
| <div className="h-16 w-full border border-dashed border-slate-300 rounded-lg flex items-center justify-center bg-slate-50 text-center text-slate-300"> | |
| <span className="text-[9px] font-mono tracking-wider font-semibold uppercase">PLACE CLINICAL STAMP HERE</span> | |
| </div> | |
| <div className="text-[10px] text-[#3d4943] text-right italic">* To be completed at receiving facility</div> | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div className="mt-8 pt-4 border-t border-slate-200 bg-slate-50/80 p-4 rounded-b-xl flex flex-col gap-4 text-[10.5px] text-slate-400"> | |
| <div className="flex flex-col md:flex-row justify-between items-start gap-4"> | |
| {/* Left side */} | |
| <div className="text-left space-y-1"> | |
| <div className="flex items-center gap-1.5 font-bold text-slate-500"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-[#082F49]" /><span>DermaDetect AI</span> | |
| </div> | |
| <p className="text-[9.5px]">Generated by DermaDetect — AI Skin Assessment Tool</p> | |
| </div> | |
| {/* Right side */} | |
| <div className="text-right space-y-1"> | |
| <p className="font-mono font-bold uppercase text-slate-500">TIMESTAMP & REFERRAL REFS</p> | |
| <p className="text-[9.5px]">Ref: <span className="font-semibold">{cleanRefId}</span></p> | |
| <p className="text-[9.5px]">Generated: {new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} at {new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })}</p> | |
| </div> | |
| </div> | |
| {/* Bottom center */} | |
| <div className="text-center border-t border-slate-100/50 pt-2 text-[9px] leading-normal text-slate-400 max-w-xl mx-auto"> | |
| This referral note was generated with AI assistance. It is intended to support, not replace, clinical judgment. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| </div> | |
| )} | |
| {/* CASE HISTORY */} | |
| {screen === 'case-history' && ( | |
| <section className="flex-1 max-w-5xl mx-auto w-full px-4 md:px-8 py-8 flex flex-col justify-between"> | |
| <div className="grow space-y-6"> | |
| <div className="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-[#bccac1] pb-4 mt-7"> | |
| <div> | |
| <h1 className="font-display font-extrabold text-2xl md:text-3xl text-[#181c1e]">Case History Logs</h1> | |
| <p className="text-xs text-[#3d4943] mt-1.5">Browse fully registered patient skin diagnostics and triage urgencies.</p> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <div className="relative min-w-[200px] shrink-0"> | |
| <Search className="w-4 h-4 text-[#3d4943] absolute left-3.5 top-1/2 -translate-y-1/2" /> | |
| <input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search patient or diagnosis..." className="w-full text-xs h-9 pl-9 pr-3.5 bg-white border border-[#bccac1] rounded-lg focus:outline-none focus:ring-2 focus:ring-[#0077b6]" /> | |
| </div> | |
| <div className="flex items-center gap-1.5"> | |
| <Filter className="w-4 h-4 text-[#3d4943]" /> | |
| <select value={filterUrgency} onChange={e => setFilterUrgency(e.target.value as any)} className="text-xs h-9 px-2 bg-white border border-[#bccac1] rounded-lg focus:outline-none text-[#181c1e] font-semibold"> | |
| <option value="All">All Urgencies</option> | |
| <option value="High">High Urgency</option> | |
| <option value="Moderate">Moderate Urgency</option> | |
| <option value="Low">Low Urgency</option> | |
| </select> | |
| </div> | |
| <button onClick={() => { const b = new Blob([JSON.stringify(cases,null,2)],{type:'application/json'}); const u=URL.createObjectURL(b); const l=document.createElement('a'); l.href=u; l.download=`DermaDetect_Assessments_${new Date().toISOString().slice(0,10)}.json`; l.click(); }} className="h-9 px-4 bg-[#0077b6] hover:bg-[#0096c7] text-white flex items-center gap-1.5 rounded-lg text-xs font-semibold cursor-pointer shadow-sm"> | |
| <Download className="w-4 h-4" /><span>Export</span> | |
| </button> | |
| <button onClick={resetAndStartAssessment} className="h-9 px-4 bg-[#0077b6] hover:bg-[#0096c7] text-white flex items-center gap-1.5 rounded-lg text-xs font-semibold cursor-pointer shadow-sm"> | |
| <PlusCircle className="w-4 h-4" /><span>New</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="bg-white border border-[#bccac1] rounded-2xl overflow-hidden shadow-sm"> | |
| <div className="hidden md:block overflow-x-auto"> | |
| <table className="w-full text-left border-collapse"> | |
| <thead> | |
| <tr className="bg-[#f1f4f6] text-[#3d4943] text-xs font-bold border-b border-[#bccac1]"> | |
| {['Patient Name','Assessment Date','Clinical Finding','Urgency Triage',''].map(h => <th key={h} className="px-5 py-3 uppercase tracking-wider">{h}</th>)} | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-[#ebeef0]"> | |
| {processedCases.length > 0 ? processedCases.map(item => ( | |
| <tr key={item.id} onClick={() => setSelectedDetailsCase(item)} className="hover:bg-[#f1f4f6] transition-colors cursor-pointer group text-xs text-[#181c1e] font-semibold"> | |
| <td className="px-5 py-4"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-9 h-9 rounded-full bg-[#d6e0f6] text-[#121c2c] flex items-center justify-center font-bold font-display select-none">{item.patient.name.split(' ').map(n=>n[0]).join('').slice(0,2).toUpperCase()}</div> | |
| <div><p className="font-bold text-sm text-[#181c1e] group-hover:text-[#0077b6]">{item.patient.name}</p><p className="text-[10px] text-[#3d4943] font-mono">CASE REF: {item.id}</p></div> | |
| </div> | |
| </td> | |
| <td className="px-5 py-4 text-[#3d4943]">{item.date}</td> | |
| <td className="px-5 py-4"><span className="px-2.5 py-1 rounded bg-[#ebeef0] text-[#181c1e] font-bold text-[10px] border border-[#bccac1]">{item.finding?.primaryFinding || 'Unclassified'}</span></td> | |
| <td className="px-5 py-4"> | |
| <div className="flex items-center gap-1.5"> | |
| <span className={`w-2 h-2 rounded-full ${item.finding?.urgency==='High'?'bg-[#ba1a1a]':item.finding?.urgency==='Moderate'?'bg-[#e65100]':'bg-[#2e7d32]'}`} /> | |
| <span className={`font-bold ${item.finding?.urgency==='High'?'text-[#ba1a1a]':item.finding?.urgency==='Moderate'?'text-[#e65100]':'text-[#2e7d32]'}`}>{item.finding?.urgency||'Low'}</span> | |
| </div> | |
| </td> | |
| <td className="px-5 py-4 text-right" onClick={e => e.stopPropagation()}> | |
| <div className="flex items-center justify-end gap-1.5"> | |
| <button onClick={e => { e.stopPropagation(); downloadPdfRecord(item); }} title="Download PDF" className="text-emerald-600 hover:bg-emerald-50 p-1.5 rounded-full transition-colors cursor-pointer"><Download className="w-4 h-4" /></button> | |
| <button onClick={e => { e.stopPropagation(); setCaseToDelete(item); }} title="Delete" className="text-rose-600 hover:bg-rose-50 p-1.5 rounded-full transition-colors cursor-pointer"><Trash2 className="w-4 h-4" /></button> | |
| <button onClick={() => setSelectedDetailsCase(item)} title="View" className="text-[#0077b6] hover:bg-[#f0f9ff] p-1.5 rounded-full transition-colors cursor-pointer"><ChevronRight className="w-4 h-4" /></button> | |
| </div> | |
| </td> | |
| </tr> | |
| )) : ( | |
| <tr><td colSpan={5} className="text-center py-12 text-[#3d4943] font-medium">No patient case history found. Start a New Assessment to record a case.</td></tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Mobile list */} | |
| <div className="block md:hidden divide-y divide-[#ebeef0]"> | |
| {processedCases.length > 0 ? processedCases.map(item => ( | |
| <div key={item.id} onClick={() => setSelectedDetailsCase(item)} className="p-4 hover:bg-[#f1f4f6] transition-colors cursor-pointer space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-9 h-9 rounded-full bg-[#d6e0f6] text-[#121c2c] flex items-center justify-center font-bold font-display select-none shrink-0">{item.patient.name.split(' ').map(n=>n[0]).join('').slice(0,2).toUpperCase()}</div> | |
| <div className="min-w-0"><p className="font-bold text-sm text-[#181c1e] truncate">{item.patient.name}</p><p className="text-[10px] text-[#3d4943] font-mono">REF: {item.id}</p></div> | |
| </div> | |
| <div className="flex items-center gap-1.5 shrink-0"> | |
| <span className={`w-2 h-2 rounded-full ${item.finding?.urgency==='High'?'bg-[#ba1a1a]':item.finding?.urgency==='Moderate'?'bg-[#e65100]':'bg-[#2e7d32]'}`} /> | |
| <span className={`font-bold text-xs ${item.finding?.urgency==='High'?'text-[#ba1a1a]':item.finding?.urgency==='Moderate'?'text-[#e65100]':'text-[#2e7d32]'}`}>{item.finding?.urgency||'Low'}</span> | |
| </div> | |
| </div> | |
| <div className="flex justify-between items-end text-xs pt-1"> | |
| <div className="space-y-1.5 min-w-0"> | |
| <p className="text-[10px] text-[#3d4943]">Date: <strong>{item.date}</strong></p> | |
| <span className="inline-block px-2 py-0.5 rounded bg-[#ebeef0] text-[#181c1e] text-[10px] border border-[#bccac1] font-bold truncate max-w-[160px]">{item.finding?.primaryFinding||'Unclassified'}</span> | |
| </div> | |
| <div className="flex items-center gap-1.5" onClick={e => e.stopPropagation()}> | |
| <button onClick={e => { e.stopPropagation(); downloadPdfRecord(item); }} className="w-8 h-8 flex items-center justify-center text-emerald-600 hover:bg-emerald-50 rounded-lg border border-emerald-100 cursor-pointer"><Download className="w-4 h-4" /></button> | |
| <button onClick={e => { e.stopPropagation(); setCaseToDelete(item); }} className="w-8 h-8 flex items-center justify-center text-rose-600 hover:bg-rose-50 rounded-lg border border-rose-100 cursor-pointer"><Trash2 className="w-4 h-4" /></button> | |
| <button onClick={() => setSelectedDetailsCase(item)} className="w-8 h-8 flex items-center justify-center text-[#0077b6] hover:bg-[#f0f9ff] rounded-lg border border-sky-100 cursor-pointer"><ChevronRight className="w-4 h-4" /></button> | |
| </div> | |
| </div> | |
| </div> | |
| )) : <div className="text-center py-12 text-[#3d4943] font-medium text-xs">No cases found.</div>} | |
| </div> | |
| <footer className="px-5 py-3.5 bg-[#f1f4f6] flex justify-between items-center border-t border-[#bccac1] text-xs font-semibold text-[#181c1e]"> | |
| <span>Showing {processedCases.length} of {cases.length} entries</span> | |
| </footer> | |
| </div> | |
| </div> | |
| </section> | |
| )} | |
| </main> | |
| {/* Footer bar */} | |
| <footer className="bg-[#ebeef0] border-t border-[#bccac1] py-4 select-none no-print"> | |
| <div className="max-w-5xl mx-auto px-4 md:px-8 flex flex-col sm:flex-row justify-between items-center gap-3"> | |
| <div className="text-xs font-semibold text-[#555f71]">© {new Date().getFullYear()} DermaDetect • Community Health Digital System</div> | |
| <div className="flex items-center gap-4 text-xs font-semibold text-[#3d4943]"> | |
| <div className="flex items-center gap-1.5 text-emerald-600"><span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /><span>Dermal Analysis Toolkit Active</span></div> | |
| <span className="text-[#bccac1]">|</span> | |
| <button onClick={() => alert('DermaDetect HIPAA Compliant. No personal metrics logged remotely.')} className="hover:underline">Privacy Policy</button> | |
| </div> | |
| </div> | |
| </footer> | |
| {/* Loading modal */} | |
| {analysisLoading && ( | |
| <div className="fixed inset-0 bg-[#181c1e]/60 backdrop-blur-md flex items-center justify-center z-[100] px-4"> | |
| <div className="bg-white rounded-3xl p-8 max-w-sm w-full text-center border border-[#bccac1] shadow-2xl space-y-4"> | |
| <Loader2 className="w-14 h-14 text-[#0077b6] animate-spin mx-auto" /> | |
| <h3 className="font-display font-extrabold text-[#181c1e] text-lg">AI Dermatological Diagnostic Analysis</h3> | |
| <p className="text-xs text-[#3d4943] max-w-xs mx-auto animate-pulse leading-relaxed">{loadingText}</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Case detail modal */} | |
| {selectedDetailsCase && ( | |
| <div className="fixed inset-0 bg-[#181c1e]/50 backdrop-blur-sm z-[80] flex items-center justify-center p-4" onClick={e => { if(e.target===e.currentTarget) setSelectedDetailsCase(null); }}> | |
| <div className="bg-white w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl border border-[#bccac1]"> | |
| <div className="p-5 border-b border-[#bccac1] flex justify-between items-center bg-[#f1f4f6]"> | |
| <div> | |
| <span className="text-[10px] text-[#0077b6] font-black tracking-widest uppercase block leading-none">Case History Vault</span> | |
| <h2 className="font-display font-extrabold text-base md:text-lg text-[#181c1e] mt-2 block">Patient Assessment: {selectedDetailsCase.patient.name}</h2> | |
| </div> | |
| <button onClick={() => setSelectedDetailsCase(null)} className="p-1.5 hover:bg-[#e0e3e5] rounded-full transition-colors"><X className="w-5 h-5 text-[#3d4943]" /></button> | |
| </div> | |
| <div className="p-6 md:p-8 space-y-6 max-h-[72vh] overflow-y-auto"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6 pb-4 border-b border-[#f1f4f6]"> | |
| <div className="space-y-4 text-xs font-semibold"> | |
| <h3 className="font-display font-bold text-xs text-[#3d4943] uppercase tracking-wider border-b border-[#bccac1] pb-1">Patient Profile</h3> | |
| <div className="space-y-1"><p className="text-[10px] text-[#6d7a73] uppercase tracking-wider">Full Legal Name</p><p className="text-sm font-bold text-[#181c1e]">{selectedDetailsCase.patient.name}</p></div> | |
| <div className="space-y-1"><p className="text-[10px] text-[#6d7a73] uppercase tracking-wider">Case ID / Gender / Age</p><p className="text-[#181c1e]">{selectedDetailsCase.id} • {selectedDetailsCase.patient.sex} • {selectedDetailsCase.patient.age} Yrs</p></div> | |
| </div> | |
| <div className="space-y-4 text-xs font-semibold"> | |
| <h3 className="font-display font-bold text-xs text-[#3d4943] uppercase tracking-wider border-b border-[#bccac1] pb-1">Medical Indicators</h3> | |
| <div className="space-y-1"><p className="text-[10px] text-[#6d7a73] uppercase tracking-wider">Primary Finding</p><p className="text-sm font-bold text-[#181c1e]">{selectedDetailsCase.finding.primaryFinding}</p></div> | |
| <div className="space-y-1"><p className="text-[10px] text-[#6d7a73] uppercase tracking-wider">Urgency</p> | |
| <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold border ${selectedDetailsCase.finding.urgency==='High'?'bg-[#ffdad6] border-[#ba1a1a] text-[#93000a]':selectedDetailsCase.finding.urgency==='Moderate'?'bg-[#ffe0b2] border-[#ffe0b2] text-[#e65100]':'bg-[#e8f5e9] border-[#c8e6c9] text-[#2e7d32]'}`}> | |
| {selectedDetailsCase.finding.urgency} Urgency ({selectedDetailsCase.finding.confidence}% Match) | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-3"> | |
| <h3 className="font-display text-xs font-bold text-[#3d4943] uppercase tracking-wider border-b border-[#bccac1] pb-1">Lesion Specimen Media</h3> | |
| <div className={`aspect-video rounded-xl overflow-hidden relative border border-[#bccac1] bg-[#f1f4f6] cursor-pointer group ${zoomImage?'h-auto max-h-[450px]':''}`}> | |
| <img src={selectedDetailsCase.image} alt="Clinical specimen" className={`w-full h-full object-cover transition-all ${zoomImage?'object-contain bg-black':'group-hover:scale-105'}`} /> | |
| <div onClick={() => setZoomImage(!zoomImage)} className="absolute bottom-3 right-3 bg-white/90 px-3 py-1 border border-[#bccac1] rounded-lg text-xs font-bold text-[#181c1e] flex items-center gap-1.5 shadow-md"> | |
| <ZoomIn className="w-4 h-4 text-[#0077b6]" /><span>{zoomImage?'Normal View':'Full Zoom View'}</span> | |
| </div> | |
| </div> | |
| </div> | |
| {selectedDetailsCase.patient.symptoms && ( | |
| <div className="space-y-2"> | |
| <h3 className="font-display text-xs font-bold text-[#3d4943] uppercase tracking-wider border-b border-[#bccac1] pb-1">Patient Observation Symptoms</h3> | |
| <p className="text-xs text-[#3d4943] italic leading-relaxed p-3 border-l-2 border-[#0077b6] bg-[#f1f4f6] rounded-r-lg">"{selectedDetailsCase.patient.symptoms}"</p> | |
| </div> | |
| )} | |
| <div className="space-y-3"> | |
| <h3 className="font-display text-xs font-bold text-[#3d4943] uppercase tracking-wider border-b border-[#bccac1] pb-1">Clinical Recommended Actions</h3> | |
| <p className="text-sm font-bold text-[#181c1e] leading-relaxed">{selectedDetailsCase.finding.recommendedAction}</p> | |
| <TreatmentRecommendations finding={selectedDetailsCase.finding} patient={selectedDetailsCase.patient} caseId={selectedDetailsCase.id} /> | |
| </div> | |
| </div> | |
| <div className="p-4 bg-[#f1f4f6] flex flex-col sm:flex-row justify-between items-center gap-3 border-t border-[#bccac1]"> | |
| <button onClick={() => setCaseToDelete(selectedDetailsCase)} className="w-full sm:w-auto h-10 px-5 bg-rose-600 hover:bg-rose-700 text-white rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer order-last sm:order-first"> | |
| <Trash2 className="w-4 h-4" /><span>Delete Case</span> | |
| </button> | |
| <div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto"> | |
| <button onClick={() => setSelectedDetailsCase(null)} className="h-10 px-5 text-xs font-bold text-[#3d4943] hover:bg-[#e0e3e5] rounded-xl border border-[#bccac1] bg-white transition-colors cursor-pointer">Close View</button> | |
| <button onClick={() => downloadPdfRecord(selectedDetailsCase)} className="h-10 px-5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer"> | |
| <Download className="w-4 h-4" /><span>Download Report</span> | |
| </button> | |
| <button onClick={() => { setPatient(selectedDetailsCase.patient); setCapturedImage(selectedDetailsCase.image); setActiveAnalysisResult(selectedDetailsCase.finding); setActiveCaseId(selectedDetailsCase.id); setSelectedDetailsCase(null); setScreen('referral-note'); }} className="h-10 px-6 bg-[#0077b6] hover:bg-[#0096c7] text-white rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer"> | |
| <FileText className="w-4 h-4" /><span>Format Referral Note</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Success animation */} | |
| {showSuccessAnimation && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[9999] flex items-center justify-center p-4"> | |
| <div className="bg-white rounded-2xl w-full max-w-sm p-8 text-center shadow-2xl border border-slate-100"> | |
| <div className="checkmark-wrapper mb-6"> | |
| <svg className="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52"> | |
| <circle className="checkmark-circle" cx="26" cy="26" r="25" fill="none" /> | |
| <path className="checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" /> | |
| </svg> | |
| </div> | |
| <h3 className="font-display text-xl font-bold text-slate-900 tracking-tight mb-2">Case Saved Successfully</h3> | |
| <p className="text-sm text-slate-500 leading-relaxed mb-4">Diagnostic findings have been safely synced to patient records.</p> | |
| <div className="inline-flex items-center gap-1.5 text-xs text-[#0077b6] font-mono font-bold bg-[#f0f9ff] px-3 py-1.5 rounded-lg"> | |
| <span className="w-2 h-2 rounded-full bg-[#0077b6] animate-pulse" />REF ID: {activeCaseId} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Delete confirmation */} | |
| {caseToDelete && ( | |
| <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[9999] flex items-center justify-center p-4"> | |
| <div className="bg-white rounded-2xl w-full max-w-md p-6 md:p-8 shadow-2xl border border-slate-100 space-y-4"> | |
| <div className="flex items-start gap-4"> | |
| <div className="w-12 h-12 rounded-full bg-rose-50 border border-rose-100 text-rose-600 flex items-center justify-center shrink-0"><AlertTriangle className="w-6 h-6 animate-pulse" /></div> | |
| <div className="space-y-1"> | |
| <h3 className="font-display text-lg font-bold text-slate-900 tracking-tight">Delete Patient Case Record</h3> | |
| <p className="text-xs text-[#6d7a73]">You are about to permanently remove this case history. This process is irreversible.</p> | |
| </div> | |
| </div> | |
| <div className="p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs space-y-2"> | |
| {[['Patient Full Name:', caseToDelete.patient.name],['Case Reference ID:', caseToDelete.id],['Clinical Finding:', caseToDelete.finding.primaryFinding]].map(([l,v]) => ( | |
| <div key={l} className="flex justify-between items-center text-slate-500"><span>{l}</span><span className="font-bold text-slate-800">{v}</span></div> | |
| ))} | |
| </div> | |
| <div className="flex gap-3 justify-end pt-2 text-xs font-bold"> | |
| <button onClick={() => setCaseToDelete(null)} className="h-10 px-4 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl border border-slate-300 cursor-pointer">Cancel, Keep Record</button> | |
| <button onClick={() => deleteCaseRecord(caseToDelete.id)} className="h-10 px-5 bg-rose-600 hover:bg-rose-700 text-white rounded-xl flex items-center gap-1.5 cursor-pointer"> | |
| <Trash2 className="w-4 h-4" /><span>Yes, Permanently Delete</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Mobile nav */} | |
| <div className="fixed bottom-0 left-0 right-0 h-16 bg-white border-t border-[#bccac1] z-40 flex items-center justify-around px-4 md:hidden no-print shadow-[0_-4px_12px_rgba(0,0,0,0.05)]"> | |
| {[ | |
| { icon: <Home className="w-5 h-5" />, label: 'Home', action: () => { setScreen('home'); stopWebcam(); }, active: screen==='home' }, | |
| { icon: <PlusCircle className="w-5 h-5" />, label: 'New Case', action: resetAndStartAssessment, active: ['assessment-info','assessment-capture','assessment-review'].includes(screen) }, | |
| { icon: <History className="w-5 h-5" />, label: 'Logs', action: () => { setScreen('case-history'); stopWebcam(); }, active: screen==='case-history' }, | |
| ].map(({ icon, label, action, active }) => ( | |
| <button key={label} onClick={action} className={`flex flex-col items-center gap-1 text-[10px] font-bold transition-colors ${active?'text-[#0077b6]':'text-[#3d4943] hover:text-[#0077b6]'}`}> | |
| {icon}<span>{label}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } |