@omniradiology/omnirad 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
@@ -0,0 +1,597 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import dynamic from 'next/dynamic'
5
+ import { Upload, X, Loader2, FileCheck2, Info } from "lucide-react"
6
+ import { Card, CardContent, Input, Label, Textarea, Button } from "@/components/ui/basic"
7
+ import { PatientContext, DicomMetadata } from "@/types"
8
+ import { parseDicomMetadata } from "@/lib/dicomMetadataParser"
9
+ import { DicomViewerHandle } from "./DicomViewer"
10
+ import { PatientSearch } from "@/components/patients/patient-search"
11
+
12
+ // Client-only dynamic import for the DICOM canvas viewer (no SSR)
13
+ const DicomViewer = dynamic(() => import('./DicomViewer').then(m => m.DicomViewer), {
14
+ ssr: false,
15
+ loading: () => <div className="flex bg-[#0a0b0e] border border-white/10 rounded-lg items-center justify-center h-full min-h-[300px] text-white/50"><Loader2 className="animate-spin w-8 h-8 opacity-60" /></div>
16
+ });
17
+
18
+ interface PatientFormProps {
19
+ onSubmit: (data: PatientContext, dicomRef?: React.RefObject<DicomViewerHandle | null>) => void;
20
+ isGenerating: boolean;
21
+ }
22
+
23
+ export function PatientForm({ onSubmit, isGenerating }: PatientFormProps) {
24
+ const [activeTab, setActiveTab] = React.useState<'manual' | 'dicom' | 'pacs'>('manual');
25
+ const [dragActive, setDragActive] = React.useState(false)
26
+ const [formData, setFormData] = React.useState<PatientContext>({
27
+ fullName: "",
28
+ patientId: "",
29
+ age: 0,
30
+ dob: "",
31
+ gender: "M",
32
+ indication: "",
33
+ symptoms: "",
34
+ history: "",
35
+ modality: "X-Ray",
36
+ image: null,
37
+ images: []
38
+ });
39
+
40
+ // DICOM State
41
+ const [isDicomProcessing, setIsDicomProcessing] = React.useState(false);
42
+ const [dicomFile, setDicomFile] = React.useState<File | null>(null);
43
+ const [dicomMeta, setDicomMeta] = React.useState<DicomMetadata | null>(null);
44
+ const [pacsMeta, setPacsMeta] = React.useState<any>(null);
45
+ const dicomViewerRef = React.useRef<DicomViewerHandle>(null);
46
+
47
+ // Refs
48
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
49
+ const dicomInputRef = React.useRef<HTMLInputElement>(null);
50
+
51
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
52
+ const { id, value } = e.target;
53
+ setFormData(prev => ({ ...prev, [id]: id === 'age' ? parseInt(value) || 0 : value }));
54
+ }
55
+
56
+ // -- LOCAL STORAGE PACS LISTENER --
57
+ React.useEffect(() => {
58
+ if (typeof window !== "undefined") {
59
+ const pacsStr = localStorage.getItem("omnirad_pending_pacs_import");
60
+ if (pacsStr) {
61
+ try {
62
+ const data = JSON.parse(pacsStr);
63
+ setPacsMeta(data);
64
+ setActiveTab('pacs');
65
+ setFormData(prev => ({
66
+ ...prev,
67
+ fullName: data.patientName || prev.fullName,
68
+ patientId: data.patientId || prev.patientId,
69
+ modality: data.modality || prev.modality,
70
+ indication: data.indication || prev.indication,
71
+ age: data.age || prev.age,
72
+ gender: data.gender || prev.gender,
73
+ }));
74
+ // Clean up so it doesn't linger forever
75
+ localStorage.removeItem("omnirad_pending_pacs_import");
76
+ } catch (e) { }
77
+ }
78
+ }
79
+ }, []);
80
+
81
+ // -- STANDARD MANUAL UPLOAD --
82
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
83
+ if (e.target.files && e.target.files.length > 0) {
84
+ const newFiles = Array.from(e.target.files);
85
+ setFormData(prev => {
86
+ const updatedImages = [...(prev.images || []), ...newFiles];
87
+ return { ...prev, image: updatedImages[0], images: updatedImages };
88
+ });
89
+ if (fileInputRef.current) fileInputRef.current.value = '';
90
+ }
91
+ }
92
+ const removeFile = (indexToRemove: number, e: React.MouseEvent) => {
93
+ e.stopPropagation();
94
+ setFormData(prev => {
95
+ const newImages = (prev.images || []).filter((_, i) => i !== indexToRemove);
96
+ return {
97
+ ...prev,
98
+ images: newImages,
99
+ image: newImages.length > 0 ? newImages[0] : null
100
+ };
101
+ });
102
+ };
103
+ const handleUploadClick = () => fileInputRef.current?.click();
104
+
105
+ // -- DICOM UPLOAD FLOW --
106
+ const calculateAge = (dobStr: string): number => {
107
+ if (!dobStr || dobStr.length < 8) return 0;
108
+ const year = parseInt(dobStr.substring(0, 4), 10);
109
+ return new Date().getFullYear() - year;
110
+ }
111
+
112
+ const processDicomFiles = async (files: FileList | File[]) => {
113
+ if (!files || files.length === 0) return;
114
+ const targetFile = files[0];
115
+
116
+ setIsDicomProcessing(true);
117
+ const result = await parseDicomMetadata(targetFile);
118
+ setIsDicomProcessing(false);
119
+
120
+ if (result.success && result.metadata) {
121
+ setDicomFile(targetFile);
122
+ setDicomMeta(result.metadata);
123
+
124
+ // Auto-fill empty fields based on DICOM meta
125
+ setFormData(prev => {
126
+ let defaultIndication = prev.indication;
127
+ if (!defaultIndication) {
128
+ const descParts = [];
129
+ if (result.metadata?.studyDescription) descParts.push(result.metadata.studyDescription);
130
+ if (result.metadata?.seriesDescription) descParts.push(result.metadata.seriesDescription);
131
+ if (descParts.length > 0) defaultIndication = descParts.join(' - ');
132
+ }
133
+
134
+ let matchedModality = prev.modality;
135
+ if (result.metadata?.modality) {
136
+ matchedModality = result.metadata.modality;
137
+ }
138
+
139
+ // Calculate age from DOB or directly from patientAge string like "032Y"
140
+ let derivedAge = 0;
141
+ if (result.metadata?.patientBirthDate) {
142
+ derivedAge = calculateAge(result.metadata.patientBirthDate);
143
+ } else if (result.metadata?.patientAge) {
144
+ const match = result.metadata.patientAge.match(/^0*(\d+)[YMWD]$/);
145
+ if (match) {
146
+ const val = parseInt(match[1]);
147
+ if (result.metadata.patientAge.endsWith('Y')) derivedAge = val;
148
+ else if (result.metadata.patientAge.endsWith('M')) derivedAge = Math.floor(val / 12);
149
+ }
150
+ }
151
+
152
+ return {
153
+ ...prev,
154
+ fullName: prev.fullName || result.metadata?.patientName || "",
155
+ patientId: prev.patientId || result.metadata?.patientId || "",
156
+ age: prev.age || derivedAge,
157
+ dob: prev.dob || result.metadata?.patientBirthDate || "",
158
+ gender: prev.gender || (result.metadata?.patientSex === 'F' ? 'F' : 'M'),
159
+ modality: matchedModality,
160
+ indication: defaultIndication,
161
+ };
162
+ });
163
+
164
+ if (dicomInputRef.current) dicomInputRef.current.value = '';
165
+ } else {
166
+ alert(result.error || "Could not parse DICOM file.");
167
+ }
168
+ };
169
+ const handleDicomFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
170
+ if (e.target.files) processDicomFiles(e.target.files);
171
+ }
172
+ const removeDicomFile = () => {
173
+ setDicomFile(null);
174
+ setDicomMeta(null);
175
+ }
176
+
177
+
178
+ // -- SHARED DRAG & DROP --
179
+ const handleDrag = (e: React.DragEvent) => {
180
+ e.preventDefault(); e.stopPropagation();
181
+ if (e.type === "dragenter" || e.type === "dragover") setDragActive(true);
182
+ else if (e.type === "dragleave") setDragActive(false);
183
+ }
184
+ const handleDrop = (e: React.DragEvent) => {
185
+ e.preventDefault(); e.stopPropagation(); setDragActive(false);
186
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
187
+ if (activeTab === 'manual') {
188
+ const newFiles = Array.from(e.dataTransfer.files);
189
+ setFormData(prev => {
190
+ const up = [...(prev.images || []), ...newFiles];
191
+ return { ...prev, image: up[0], images: up };
192
+ });
193
+ } else {
194
+ processDicomFiles(e.dataTransfer.files);
195
+ }
196
+ }
197
+ }
198
+
199
+ const handleSubmit = () => {
200
+ if (activeTab === 'dicom') {
201
+ onSubmit({ ...formData, image: dicomFile, images: dicomFile ? [dicomFile] : [], isDicom: true, dicomMetadata: dicomMeta }, dicomViewerRef as any);
202
+ } else if (activeTab === 'pacs') {
203
+ onSubmit({ ...formData, isPacs: true, pacsData: pacsMeta });
204
+ } else {
205
+ onSubmit(formData);
206
+ }
207
+ }
208
+
209
+ // Helper: field status badge for DICOM auto-fill
210
+ const FieldBadge = ({ filled, optional = false }: { filled: boolean, optional?: boolean }) => {
211
+ if (filled) return <span className="text-[9px] bg-emerald-500/15 text-emerald-400 px-1.5 py-0.5 rounded font-medium">✓ DICOM</span>;
212
+ if (optional) return <span className="text-[9px] bg-slate-500/15 text-slate-400 px-1.5 py-0.5 rounded font-medium">Missing (Optional)</span>;
213
+ return <span className="text-[9px] bg-amber-500/15 text-amber-400 px-1.5 py-0.5 rounded font-medium">Required</span>;
214
+ };
215
+
216
+ const isDicomLoaded = activeTab === 'dicom' && dicomFile && dicomMeta;
217
+
218
+ // ---- RENDER ----
219
+ return (
220
+ <div className="h-full flex flex-col gap-6 p-6">
221
+ <div className="space-y-3">
222
+ <div className="space-y-1">
223
+ <h2 className="text-2xl font-semibold text-text-heading">Case Details</h2>
224
+ <p className="text-text-secondary text-sm">Enter patient and study information</p>
225
+ </div>
226
+
227
+ {/* Mode Switcher */}
228
+ <div className="flex gap-2">
229
+ <button type="button" onClick={() => setActiveTab('manual')}
230
+ className={`px-4 py-1.5 rounded-md border text-sm font-medium transition-all ${
231
+ activeTab === 'manual' ? 'bg-blue-600/10 border-blue-500 text-blue-500 shadow-sm' : 'bg-transparent border-border-primary text-text-muted hover:text-text-primary hover:border-text-muted'
232
+ }`}>
233
+ Manual
234
+ </button>
235
+ <button type="button" onClick={() => setActiveTab('dicom')}
236
+ className={`px-4 py-1.5 rounded-md border text-sm font-medium transition-all ${
237
+ activeTab === 'dicom' ? 'bg-blue-600/10 border-blue-500 text-blue-500 shadow-sm' : 'bg-transparent border-border-primary text-text-muted hover:text-text-primary hover:border-text-muted'
238
+ }`}>
239
+ DICOM File
240
+ </button>
241
+ {activeTab === 'pacs' && (
242
+ <button type="button" onClick={() => setActiveTab('pacs')}
243
+ className="px-4 py-1.5 rounded-md border text-sm font-medium transition-all bg-emerald-600/10 border-emerald-500 text-emerald-500 shadow-sm">
244
+ PACS Import
245
+ </button>
246
+ )}
247
+ </div>
248
+ </div>
249
+
250
+ <Card className="flex-1 overflow-auto bg-bg-surface border-border-primary">
251
+ <CardContent className="space-y-6 pt-6">
252
+
253
+ {/* ══════════════ MANUAL TAB ══════════════ */}
254
+ {activeTab === 'manual' && (
255
+ <>
256
+ {/* Patient Information */}
257
+ <div className="space-y-4">
258
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Patient Information</h3>
259
+ <div className="grid grid-cols-12 gap-4">
260
+ <div className="col-span-12 md:col-span-6">
261
+ <Label htmlFor="fullName">Patient / Search *</Label>
262
+ <PatientSearch
263
+ value={formData.fullName}
264
+ onChange={(val) => setFormData(prev => ({ ...prev, fullName: val }))}
265
+ onSelect={(patient) => setFormData(prev => ({
266
+ ...prev,
267
+ fullName: patient.patientName,
268
+ patientId: patient.patientIdNumber || "",
269
+ age: patient.dob ? calculateAge(patient.dob) : prev.age,
270
+ dob: patient.dob || prev.dob,
271
+ gender: (patient.gender as any) || prev.gender
272
+ }))}
273
+ onNewPatient={() => {}}
274
+ />
275
+ </div>
276
+ <div className="col-span-6 md:col-span-3">
277
+ <Label htmlFor="age">Age *</Label>
278
+ <Input id="age" placeholder="00" type="number" value={formData.age || ''} onChange={handleChange} />
279
+ </div>
280
+ <div className="col-span-6 md:col-span-3">
281
+ <Label htmlFor="gender">Gender</Label>
282
+ <select id="gender" className="flex h-10 w-full rounded-md border border-border-primary bg-bg-panel px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" value={formData.gender} onChange={handleChange}>
283
+ <option value="M">Male</option>
284
+ <option value="F">Female</option>
285
+ </select>
286
+ </div>
287
+ </div>
288
+ </div>
289
+
290
+ {/* Clinical Context */}
291
+ <div className="space-y-4">
292
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Clinical Context</h3>
293
+ <div>
294
+ <Label htmlFor="indication">Indication *</Label>
295
+ <Input id="indication" placeholder="e.g. Rule out pneumonia" value={formData.indication} onChange={handleChange} />
296
+ </div>
297
+ <div>
298
+ <Label htmlFor="symptoms">Symptoms</Label>
299
+ <Input id="symptoms" placeholder="e.g. Cough, fever" value={formData.symptoms} onChange={handleChange} />
300
+ </div>
301
+ <div>
302
+ <Label htmlFor="history">Patient History</Label>
303
+ <Textarea id="history" placeholder="Relevant medical history..." className="resize-none h-20" value={formData.history} onChange={handleChange} />
304
+ </div>
305
+ </div>
306
+
307
+ {/* Study Details */}
308
+ <div className="space-y-4">
309
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Study Details</h3>
310
+ <div>
311
+ <Label htmlFor="modality">Modality *</Label>
312
+ <Input id="modality" list="modality-options" placeholder="e.g. X-Ray, CT, MRI" value={formData.modality} onChange={handleChange} />
313
+ <datalist id="modality-options">
314
+ <option value="X-Ray" />
315
+ <option value="CT" />
316
+ <option value="MRI" />
317
+ <option value="Ultrasound" />
318
+ </datalist>
319
+ </div>
320
+ </div>
321
+
322
+ {/* Medical Image Upload */}
323
+ <div className="space-y-4">
324
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Medical Image</h3>
325
+ <input ref={fileInputRef} type="file" accept="image/*,.pdf" multiple className="hidden" onChange={handleFileChange} />
326
+ <div
327
+ className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${dragActive ? 'border-primary bg-primary/5' : 'border-border-card hover:bg-bg-panel'}`}
328
+ onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop} onClick={handleUploadClick}
329
+ >
330
+ <div className="flex flex-col items-center gap-2">
331
+ <div className="p-3 rounded-full bg-bg-panel">
332
+ <Upload className="w-6 h-6 text-text-muted" />
333
+ </div>
334
+ {formData.images && formData.images.length > 0 ? (
335
+ <div className="flex flex-col gap-2 w-full mt-2">
336
+ {formData.images.map((file, idx) => (
337
+ <div key={idx} className="flex items-center justify-between p-2 bg-bg-primary border border-border-primary rounded-md">
338
+ <span className="text-sm text-text-primary truncate max-w-[200px]">{file.name}</span>
339
+ <Button type="button" variant="ghost" size="icon" className="h-6 w-6 text-red-500 hover:text-red-600 hover:bg-red-500/10" onClick={(e) => removeFile(idx, e)}><X className="w-4 h-4" /></Button>
340
+ </div>
341
+ ))}
342
+ <Button type="button" variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); handleUploadClick(); }} className="mt-2 w-fit mx-auto">Add More Files</Button>
343
+ </div>
344
+ ) : (
345
+ <p className="text-sm font-medium text-text-primary"><span>Drag & drop images here or <span className="text-primary hover:underline">browse</span></span></p>
346
+ )}
347
+ <p className="text-xs text-text-muted mt-2">Supports JPEG, PNG, PDF (max 8MB)</p>
348
+ </div>
349
+ </div>
350
+ </div>
351
+
352
+ <Button className="w-full" size="lg" onClick={handleSubmit} disabled={isGenerating || (!formData.images || formData.images.length === 0)}>
353
+ {isGenerating ? "Processing..." : "Generate Report"}
354
+ </Button>
355
+ </>
356
+ )}
357
+
358
+ {/* ══════════════ DICOM TAB ══════════════ */}
359
+ {activeTab === 'dicom' && (
360
+ <>
361
+ {/* DICOM Upload / Viewer */}
362
+ <div className="space-y-4">
363
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">DICOM View Engine</h3>
364
+ <input ref={dicomInputRef} type="file" accept=".dcm,.dicom" className="hidden" onChange={handleDicomFileInput} />
365
+
366
+ {!dicomFile ? (
367
+ /* ── PHASE 1: Empty upload dropzone ── */
368
+ <div
369
+ className={`min-h-[400px] border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center text-center transition-all cursor-pointer group ${dragActive ? 'border-blue-500 bg-blue-500/5' : 'border-border-card hover:bg-bg-panel'}`}
370
+ onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop} onClick={() => dicomInputRef.current?.click()}
371
+ >
372
+ {isDicomProcessing ? (
373
+ <Loader2 className="w-10 h-10 text-blue-500 animate-spin mb-4" />
374
+ ) : (
375
+ <div className="p-5 rounded-full bg-bg-panel mb-5 shadow-sm border border-border-primary group-hover:scale-110 transition-transform">
376
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-blue-500">
377
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2"/><path d="M8 17h2"/><path d="M14 13h2"/><path d="M14 17h2"/>
378
+ </svg>
379
+ </div>
380
+ )}
381
+ <h4 className="text-lg font-medium text-text-primary mb-1">
382
+ {isDicomProcessing ? "Parsing DICOM metadata..." : "Upload DICOM File"}
383
+ </h4>
384
+ <p className="text-sm text-text-muted max-w-xs mb-2">
385
+ Drag and drop a DICOM file (.dcm) here or click to browse.
386
+ </p>
387
+ <p className="text-xs text-text-muted/60">
388
+ Patient data and study details will be extracted automatically.
389
+ </p>
390
+ </div>
391
+ ) : (
392
+ /* ── PHASE 2: File loaded — viewer + extracted form ── */
393
+ <div className="space-y-3">
394
+ {/* File info bar */}
395
+ <div className="flex items-center justify-between p-2 bg-[#111318] rounded-md border border-white/10 shadow-sm">
396
+ <div className="flex items-center gap-3 overflow-hidden px-2">
397
+ <FileCheck2 className="w-5 h-5 text-blue-400 shrink-0" />
398
+ <div className="flex flex-col truncate">
399
+ <span className="text-sm font-medium text-white/90 truncate">{dicomFile.name}</span>
400
+ <span className="text-[10px] text-white/40">{dicomMeta?.modality} • {dicomMeta?.transferSyntaxUID?.includes('1.2.4.50') ? 'JPEG' : 'RAW'} • {dicomMeta?.columns}x{dicomMeta?.rows}</span>
401
+ </div>
402
+ </div>
403
+ <Button variant="ghost" size="icon" onClick={removeDicomFile} className="text-white/40 hover:text-white" title="Remove">
404
+ <X className="w-4 h-4" />
405
+ </Button>
406
+ </div>
407
+
408
+ {/* WebGL Viewport */}
409
+ <div className="w-full h-[350px] border border-white/10 rounded-lg overflow-hidden relative shadow-lg group shadow-black/50">
410
+ <DicomViewer
411
+ ref={dicomViewerRef}
412
+ file={dicomFile}
413
+ className="w-full h-full"
414
+ />
415
+ <div className="absolute top-2 left-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
416
+ <span className="bg-black/80 backdrop-blur text-[10px] text-white/70 px-2 py-1 rounded shadow-xl border border-white/10">
417
+ Left-drag: W/L · Right: Pan · Mid: Zoom
418
+ </span>
419
+ </div>
420
+ </div>
421
+ </div>
422
+ )}
423
+ </div>
424
+
425
+ {/* ── PHASE 2 continued: Extracted fields (only visible after upload) ── */}
426
+ {isDicomLoaded && (
427
+ <>
428
+ {/* Patient Info — auto-filled */}
429
+ <div className="space-y-4 border-t border-white/5 pt-5">
430
+ <div className="flex items-center justify-between">
431
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Patient Information</h3>
432
+ <span className="text-[10px] bg-blue-500/10 text-blue-400 px-2 py-0.5 rounded flex items-center gap-1"><Info className="w-3 h-3"/> Extracted from DICOM</span>
433
+ </div>
434
+ <div className="grid grid-cols-12 gap-4">
435
+ <div className="col-span-12 md:col-span-6">
436
+ <Label htmlFor="fullName" className="flex flex-wrap items-center gap-2">Patient / Search * <FieldBadge filled={!!formData.fullName} /></Label>
437
+ <PatientSearch
438
+ value={formData.fullName}
439
+ onChange={(val) => setFormData(prev => ({ ...prev, fullName: val }))}
440
+ onSelect={(patient) => setFormData(prev => ({
441
+ ...prev,
442
+ fullName: patient.patientName,
443
+ patientId: patient.patientIdNumber || prev.patientId,
444
+ age: patient.dob ? calculateAge(patient.dob) : prev.age,
445
+ dob: patient.dob || prev.dob,
446
+ gender: (patient.gender as any) || prev.gender
447
+ }))}
448
+ onNewPatient={() => {}}
449
+ />
450
+ </div>
451
+ <div className="col-span-12 md:col-span-6">
452
+ <Label htmlFor="patientId" className="flex flex-wrap items-center gap-2">Patient ID <FieldBadge filled={!!formData.patientId} optional={true} /></Label>
453
+ <Input id="patientId" placeholder="e.g. PAT-001" value={formData.patientId} onChange={handleChange} />
454
+ </div>
455
+ <div className="col-span-6 md:col-span-6">
456
+ <Label htmlFor="age" className="flex flex-wrap items-center gap-2">Age * <FieldBadge filled={!!formData.age} /></Label>
457
+ <Input id="age" placeholder="00" type="number" value={formData.age || ''} onChange={handleChange} />
458
+ </div>
459
+ <div className="col-span-6 md:col-span-6">
460
+ <Label htmlFor="gender" className="flex flex-wrap items-center gap-2">Gender <FieldBadge filled={true} /></Label>
461
+ <select id="gender" className="flex h-10 w-full rounded-md border border-border-primary bg-bg-panel px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" value={formData.gender} onChange={handleChange}>
462
+ <option value="M">Male</option>
463
+ <option value="F">Female</option>
464
+ </select>
465
+ </div>
466
+ </div>
467
+ </div>
468
+
469
+ {/* Clinical Context — mostly manual */}
470
+ <div className="space-y-4">
471
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Clinical Context</h3>
472
+ <div>
473
+ <Label htmlFor="indication" className="flex flex-wrap items-center gap-2">Indication * <FieldBadge filled={!!formData.indication} /></Label>
474
+ <Input id="indication" placeholder="e.g. Rule out pneumonia" value={formData.indication} onChange={handleChange} />
475
+ </div>
476
+ <div>
477
+ <Label htmlFor="symptoms" className="flex flex-wrap items-center gap-2">Symptoms <FieldBadge filled={!!formData.symptoms} /></Label>
478
+ <Input id="symptoms" placeholder="e.g. Cough, fever" value={formData.symptoms} onChange={handleChange} />
479
+ </div>
480
+ <div>
481
+ <Label htmlFor="history" className="flex flex-wrap items-center gap-2">Patient History <FieldBadge filled={!!formData.history} /></Label>
482
+ <Textarea id="history" placeholder="Relevant medical history..." className="resize-none h-20" value={formData.history} onChange={handleChange} />
483
+ </div>
484
+ </div>
485
+
486
+ {/* Study Details — auto-filled */}
487
+ <div className="space-y-4">
488
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Study Details</h3>
489
+ <div>
490
+ <Label htmlFor="modality" className="flex flex-wrap items-center gap-2">Modality * <FieldBadge filled={!!formData.modality} /></Label>
491
+ <Input id="modality" list="modality-options" placeholder="e.g. X-Ray, CT, MRI" value={formData.modality} onChange={handleChange} />
492
+ {/* (Datalist is already defined in the manual tab above, but having a duplicate or single shared one works fine. Browsers attach via ID) */}
493
+ </div>
494
+ </div>
495
+
496
+ <Button className="w-full" size="lg" onClick={handleSubmit} disabled={isGenerating}>
497
+ {isGenerating ? "Processing AI Analysis..." : "Generate Report"}
498
+ </Button>
499
+ </>
500
+ )}
501
+ </>
502
+ )}
503
+
504
+ {/* ══════════════ PACS TAB ══════════════ */}
505
+ {activeTab === 'pacs' && pacsMeta && (
506
+ <>
507
+ <div className="pt-2">
508
+ <div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl relative overflow-hidden group">
509
+ <div className="absolute inset-0 bg-gradient-to-r from-emerald-500/0 via-emerald-500/5 to-emerald-500/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out" />
510
+ <div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center shrink-0 border border-emerald-500/30">
511
+ <div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.8)] animate-pulse" />
512
+ </div>
513
+ <div className="flex flex-col">
514
+ <h4 className="text-emerald-400 font-semibold text-sm">Study Linked via PACS</h4>
515
+ <p className="text-xs text-emerald-500/70 mt-0.5">DICOM files will stream directly to AI upon generation.</p>
516
+ </div>
517
+ </div>
518
+ </div>
519
+
520
+ {/* Patient Info — auto-filled */}
521
+ <div className="space-y-4 border-t border-white/5 pt-5">
522
+ <div className="flex items-center justify-between">
523
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Patient Information</h3>
524
+ <span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-2 py-0.5 rounded flex items-center gap-1"><Info className="w-3 h-3"/> Imported from PACS</span>
525
+ </div>
526
+ <div className="grid grid-cols-12 gap-4">
527
+ <div className="col-span-12 md:col-span-6">
528
+ <Label htmlFor="fullName" className="flex flex-wrap items-center gap-2">Patient / Search * <FieldBadge filled={true} /></Label>
529
+ <PatientSearch
530
+ value={formData.fullName}
531
+ onChange={(val) => setFormData(prev => ({ ...prev, fullName: val }))}
532
+ onSelect={(patient) => setFormData(prev => ({
533
+ ...prev,
534
+ fullName: patient.patientName,
535
+ patientId: patient.patientIdNumber || prev.patientId,
536
+ age: patient.dob ? calculateAge(patient.dob) : prev.age,
537
+ dob: patient.dob || prev.dob,
538
+ gender: (patient.gender as any) || prev.gender
539
+ }))}
540
+ onNewPatient={() => {}}
541
+ />
542
+ </div>
543
+ <div className="col-span-12 md:col-span-6">
544
+ <Label htmlFor="patientId" className="flex flex-wrap items-center gap-2">Patient ID <FieldBadge filled={true} optional={true} /></Label>
545
+ <Input id="patientId" placeholder="e.g. PAT-001" value={formData.patientId} onChange={handleChange} />
546
+ </div>
547
+ <div className="col-span-6 md:col-span-6">
548
+ <Label htmlFor="age" className="flex flex-wrap items-center gap-2">Age * <FieldBadge filled={!!formData.age} /></Label>
549
+ <Input id="age" placeholder="00" type="number" value={formData.age || ''} onChange={handleChange} />
550
+ </div>
551
+ <div className="col-span-6 md:col-span-6">
552
+ <Label htmlFor="gender" className="flex flex-wrap items-center gap-2">Gender <FieldBadge filled={true} /></Label>
553
+ <select id="gender" className="flex h-10 w-full rounded-md border border-border-primary bg-bg-panel px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" value={formData.gender} onChange={handleChange}>
554
+ <option value="M">Male</option>
555
+ <option value="F">Female</option>
556
+ </select>
557
+ </div>
558
+ </div>
559
+ </div>
560
+
561
+ {/* Clinical Context — mostly manual */}
562
+ <div className="space-y-4">
563
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Clinical Context</h3>
564
+ <div>
565
+ <Label htmlFor="indication" className="flex flex-wrap items-center gap-2">Indication * <FieldBadge filled={!!formData.indication} /></Label>
566
+ <Input id="indication" placeholder="e.g. Rule out pneumonia" value={formData.indication} onChange={handleChange} />
567
+ </div>
568
+ <div>
569
+ <Label htmlFor="symptoms" className="flex flex-wrap items-center gap-2">Symptoms <FieldBadge filled={!!formData.symptoms} /></Label>
570
+ <Input id="symptoms" placeholder="e.g. Cough, fever" value={formData.symptoms} onChange={handleChange} />
571
+ </div>
572
+ <div>
573
+ <Label htmlFor="history" className="flex flex-wrap items-center gap-2">Patient History <FieldBadge filled={!!formData.history} /></Label>
574
+ <Textarea id="history" placeholder="Relevant medical history..." className="resize-none h-20" value={formData.history} onChange={handleChange} />
575
+ </div>
576
+ </div>
577
+
578
+ {/* Study Details — auto-filled */}
579
+ <div className="space-y-4">
580
+ <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Study Details</h3>
581
+ <div>
582
+ <Label htmlFor="modality" className="flex flex-wrap items-center gap-2">Modality * <FieldBadge filled={true} /></Label>
583
+ <Input id="modality" list="modality-options" placeholder="e.g. X-Ray, CT, MRI" value={formData.modality} onChange={handleChange} />
584
+ </div>
585
+ </div>
586
+
587
+ <Button className="w-full" size="lg" onClick={handleSubmit} disabled={isGenerating}>
588
+ {isGenerating ? "Processing AI Analysis..." : "Generate Report from PACS"}
589
+ </Button>
590
+ </>
591
+ )}
592
+
593
+ </CardContent>
594
+ </Card>
595
+ </div>
596
+ )
597
+ }