@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,315 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useParams } from "next/navigation";
5
+ import { PatientHeader } from "@/components/patients/patient-header";
6
+ import { PatientTimeline } from "@/components/patients/patient-timeline";
7
+ import { FileText, Clipboard, Settings, ArrowLeft, Edit2, Save, X, Check, ExternalLink } from "lucide-react";
8
+ import { Patient } from "@/types";
9
+ import Link from "next/link";
10
+
11
+ export default function PatientProfilePage() {
12
+ const params = useParams();
13
+ const id = params.id as string;
14
+
15
+ const [patient, setPatient] = useState<Patient | null>(null);
16
+ const [reports, setReports] = useState<any[]>([]);
17
+ const [isLoading, setIsLoading] = useState(true);
18
+ const [activeTab, setActiveTab] = useState("timeline");
19
+ const [isEditing, setIsEditing] = useState(false);
20
+ const [editData, setEditData] = useState<Partial<Patient>>({});
21
+ const [isSaving, setIsSaving] = useState(false);
22
+
23
+ useEffect(() => {
24
+ const loadData = async () => {
25
+ try {
26
+ const [pRes, rRes] = await Promise.all([
27
+ fetch(`/api/patients/${id}`),
28
+ fetch(`/api/patients/${id}/reports`)
29
+ ]);
30
+
31
+ if (pRes.ok) {
32
+ const data = await pRes.json();
33
+ setPatient(data);
34
+ setEditData(data); // Pre-fill edit state
35
+ }
36
+ if (rRes.ok) setReports(await rRes.json());
37
+ } catch(e) {
38
+ console.error(e);
39
+ } finally {
40
+ setIsLoading(false);
41
+ }
42
+ };
43
+
44
+ if (id) loadData();
45
+ }, [id]);
46
+
47
+ const handleSave = async () => {
48
+ setIsSaving(true);
49
+ try {
50
+ const res = await fetch(`/api/patients/${id}`, {
51
+ method: 'PUT',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(editData),
54
+ });
55
+ if (res.ok) {
56
+ setPatient({ ...patient!, ...editData });
57
+ setIsEditing(false);
58
+ } else {
59
+ alert("Failed to save patient information.");
60
+ }
61
+ } catch (e) {
62
+ console.error("Save error:", e);
63
+ alert("Error saving patient information.");
64
+ } finally {
65
+ setIsSaving(false);
66
+ }
67
+ };
68
+
69
+ if (isLoading) {
70
+ return (
71
+ <main className="flex-1 p-6 ml-20 h-screen flex justify-center items-center">
72
+ <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" />
73
+ </main>
74
+ );
75
+ }
76
+
77
+ if (!patient) {
78
+ return (
79
+ <main className="flex-1 p-6 ml-20 h-screen flex flex-col justify-center items-center text-zinc-400">
80
+ <h1 className="text-xl font-bold mb-2">Patient Not Found</h1>
81
+ <p>The patient record could not be loaded or has been deleted.</p>
82
+ </main>
83
+ );
84
+ }
85
+
86
+ return (
87
+ <main className="flex-1 p-6 lg:p-8 ml-20 h-screen overflow-y-auto">
88
+ <div className="max-w-5xl mx-auto">
89
+ <Link href="/patients" className="inline-flex items-center gap-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 bg-zinc-900/40 hover:bg-zinc-800/80 border border-zinc-800/50 hover:border-zinc-700/80 px-3 py-1.5 rounded-lg transition-all mb-6 shadow-sm group w-fit">
90
+ <ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
91
+ Back to Patients
92
+ </Link>
93
+ <PatientHeader patient={patient} reportCount={reports.length} />
94
+
95
+ {/* Tabs */}
96
+ <div className="flex items-center gap-1 mt-8 mb-6 border-b border-zinc-800">
97
+ <button
98
+ onClick={() => setActiveTab("timeline")}
99
+ className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === "timeline" ? "border-indigo-500 text-indigo-400 bg-indigo-500/5" : "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"}`}
100
+ >
101
+ <FileText className="w-4 h-4" />
102
+ Report Timeline
103
+ </button>
104
+ <button
105
+ onClick={() => setActiveTab("info")}
106
+ className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === "info" ? "border-indigo-500 text-indigo-400 bg-indigo-500/5" : "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"}`}
107
+ >
108
+ <Settings className="w-4 h-4" />
109
+ Patient Info
110
+ </button>
111
+ <button
112
+ onClick={() => setActiveTab("notes")}
113
+ className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === "notes" ? "border-indigo-500 text-indigo-400 bg-indigo-500/5" : "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"}`}
114
+ >
115
+ <Clipboard className="w-4 h-4" />
116
+ Notes & History
117
+ </button>
118
+ </div>
119
+
120
+ {/* Tab Content */}
121
+ <div className="pb-16">
122
+ {activeTab === "timeline" && (
123
+ <PatientTimeline reports={reports} />
124
+ )}
125
+
126
+ {activeTab === "info" && (
127
+ <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 relative">
128
+ <div className="flex justify-between items-center mb-6">
129
+ <h3 className="text-lg font-medium text-zinc-200">Patient Demographics</h3>
130
+ {!isEditing ? (
131
+ <button onClick={() => setIsEditing(true)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-medium transition-colors">
132
+ <Edit2 className="w-3.5 h-3.5" /> Edit
133
+ </button>
134
+ ) : (
135
+ <div className="flex items-center gap-2">
136
+ <button onClick={() => { setIsEditing(false); setEditData(patient!); }} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-medium transition-colors">
137
+ <X className="w-3.5 h-3.5" /> Cancel
138
+ </button>
139
+ <button onClick={handleSave} disabled={isSaving} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium transition-colors disabled:opacity-50">
140
+ {isSaving ? <div className="w-3.5 h-3.5 rounded-full border-2 border-white/30 border-t-white animate-spin" /> : <Save className="w-3.5 h-3.5" />}
141
+ Save
142
+ </button>
143
+ </div>
144
+ )}
145
+ </div>
146
+
147
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
148
+ {/* Fully editable layout */}
149
+ <div>
150
+ <label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">Full Name</label>
151
+ {isEditing ? (
152
+ <input type="text" value={editData.patientName || ""} onChange={(e) => setEditData({...editData, patientName: e.target.value})} className="w-full bg-zinc-950 border border-indigo-500/50 focus:border-indigo-500 rounded-lg px-4 py-2 text-zinc-200 outline-none transition-colors" />
153
+ ) : (
154
+ <div className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-zinc-300">{patient.patientName}</div>
155
+ )}
156
+ </div>
157
+ <div>
158
+ <label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">Patient ID Number</label>
159
+ {isEditing ? (
160
+ <input type="text" value={editData.patientIdNumber || ""} onChange={(e) => setEditData({...editData, patientIdNumber: e.target.value})} className="w-full bg-zinc-950 border border-indigo-500/50 focus:border-indigo-500 rounded-lg px-4 py-2 text-zinc-200 outline-none transition-colors" />
161
+ ) : (
162
+ <div className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-zinc-300">{patient.patientIdNumber || "—"}</div>
163
+ )}
164
+ </div>
165
+ <div>
166
+ <label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">Age</label>
167
+ {isEditing ? (
168
+ <input type="number" value={editData.age || ""} onChange={(e) => setEditData({...editData, age: parseInt(e.target.value) || undefined})} className="w-full bg-zinc-950 border border-indigo-500/50 focus:border-indigo-500 rounded-lg px-4 py-2 text-zinc-200 outline-none transition-colors" placeholder="e.g. 26" />
169
+ ) : (
170
+ <div className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-zinc-300">
171
+ {(() => {
172
+ if (!patient.dob) return null;
173
+ let bd: Date;
174
+ if (patient.dob.length === 8 && !patient.dob.includes("-")) {
175
+ bd = new Date(parseInt(patient.dob.substring(0,4)), parseInt(patient.dob.substring(4,6))-1, parseInt(patient.dob.substring(6,8)));
176
+ } else {
177
+ bd = new Date(patient.dob);
178
+ }
179
+ if (isNaN(bd.getTime())) return null;
180
+ const today = new Date();
181
+ let age = today.getFullYear() - bd.getFullYear();
182
+ const m = today.getMonth() - bd.getMonth();
183
+ if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
184
+ return age;
185
+ })() ?? patient.age ?? "—"}
186
+ </div>
187
+ )}
188
+ </div>
189
+ <div>
190
+ <label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">Gender</label>
191
+ {isEditing ? (
192
+ <select value={editData.gender || ""} onChange={(e) => setEditData({...editData, gender: e.target.value})} className="w-full bg-zinc-950 border border-indigo-500/50 focus:border-indigo-500 rounded-lg px-4 py-2.5 text-zinc-200 outline-none transition-colors appearance-none cursor-pointer">
193
+ <option value="">Select Gender</option>
194
+ <option value="Male">Male</option>
195
+ <option value="Female">Female</option>
196
+ <option value="Other">Other</option>
197
+ </select>
198
+ ) : (
199
+ <div className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-zinc-300">
200
+ {patient.gender ? (
201
+ patient.gender.toUpperCase() === "M" ? "Male" :
202
+ patient.gender.toUpperCase() === "F" ? "Female" :
203
+ patient.gender.toUpperCase() === "O" ? "Other" : patient.gender
204
+ ) : "—"}
205
+ </div>
206
+ )}
207
+ </div>
208
+ <div className="md:col-span-2">
209
+ <label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">Mobile Number</label>
210
+ {isEditing ? (
211
+ <input type="text" value={editData.mobile || ""} onChange={(e) => setEditData({...editData, mobile: e.target.value})} placeholder="+1 (555) 000-0000" className="w-full bg-zinc-950 border border-indigo-500/50 focus:border-indigo-500 rounded-lg px-4 py-2 text-zinc-200 outline-none transition-colors" />
212
+ ) : (
213
+ <div className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-zinc-300">{patient.mobile || "—"}</div>
214
+ )}
215
+ </div>
216
+ <div className="md:col-span-2">
217
+ <label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2">Address</label>
218
+ {isEditing ? (
219
+ <input type="text" value={editData.address || ""} onChange={(e) => setEditData({...editData, address: e.target.value})} placeholder="123 Medical Pl, City, ST" className="w-full bg-zinc-950 border border-indigo-500/50 focus:border-indigo-500 rounded-lg px-4 py-2 text-zinc-200 outline-none transition-colors" />
220
+ ) : (
221
+ <div className="bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2.5 text-zinc-300">{patient.address || "—"}</div>
222
+ )}
223
+ </div>
224
+ </div>
225
+ </div>
226
+ )}
227
+
228
+ {activeTab === "notes" && (
229
+ <div className="space-y-4">
230
+ {(() => {
231
+ // Extract all comments from all reports into a flattened array
232
+ const allComments: any[] = [];
233
+ reports.forEach(r => {
234
+ const raw = r.report_data || r.reportData;
235
+ let data: any = {};
236
+ try {
237
+ data = typeof raw === 'string' ? JSON.parse(raw) : raw;
238
+ } catch (e) { console.error("Error parsing report data", e); }
239
+
240
+ if (data?.collaboration?.comments && Array.isArray(data.collaboration.comments)) {
241
+ data.collaboration.comments.forEach((comment: any) => {
242
+ allComments.push({
243
+ ...comment,
244
+ reportId: r.id,
245
+ modality: data.study?.modality || "Report",
246
+ examination: data.study?.examination || "",
247
+ status: data.report_footer?.report_status || "Reviewed",
248
+ reportTimestamp: r.created_at || r.createdAt
249
+ });
250
+ });
251
+ }
252
+ });
253
+
254
+ // Sort newest comments first naturally
255
+ allComments.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
256
+
257
+ if (allComments.length === 0) {
258
+ return (
259
+ <div className="flex flex-col items-center justify-center p-12 text-center border-2 border-dashed border-zinc-800 rounded-xl bg-zinc-900/20">
260
+ <Clipboard className="w-12 h-12 text-zinc-600 mb-4 opacity-50" />
261
+ <h3 className="text-lg font-medium text-zinc-300 mb-2">No Comments Found</h3>
262
+ <p className="text-zinc-500 text-sm max-w-md">There are no reviewer comments or clinical notes associated with these reports yet.</p>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ return allComments.map(comment => {
268
+ const date = new Date(comment.timestamp);
269
+
270
+ return (
271
+ <div key={comment.id} className="bg-zinc-900/40 border border-zinc-800/60 rounded-xl p-5 relative overflow-hidden shadow-sm">
272
+ <div className={`absolute top-0 left-0 w-1 h-full ${comment.status.toLowerCase() === 'approved' ? 'bg-emerald-500/50' : comment.status.toLowerCase() === 'rejected' ? 'bg-red-500/50' : 'bg-indigo-500/50'}`} />
273
+ <div className="flex justify-between items-start mb-3">
274
+ <div className="flex items-center gap-3">
275
+ <div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center border border-zinc-700/50 shrink-0">
276
+ <Clipboard className="w-4 h-4 text-zinc-400" />
277
+ </div>
278
+ <div>
279
+ <div className="flex flex-wrap items-center gap-2">
280
+ <p className="text-sm font-medium text-zinc-200">
281
+ Notes on <span className="text-indigo-300 font-bold">{comment.examination || comment.modality}</span>
282
+ </p>
283
+ <Link href={`/reports?id=${comment.reportId}`} className="text-[10px] bg-indigo-500/10 text-indigo-400 hover:bg-indigo-500/20 px-2 py-0.5 rounded flex items-center gap-1 transition-colors border border-indigo-500/20">
284
+ Open Report <ExternalLink className="w-3 h-3" />
285
+ </Link>
286
+ </div>
287
+ <p className="text-xs text-zinc-500 mt-0.5">{date.toLocaleDateString()} at {date.toLocaleTimeString()}</p>
288
+ </div>
289
+ </div>
290
+ <span className={`text-xs px-2.5 py-1 rounded font-medium ${
291
+ comment.status.toLowerCase() === 'approved' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' :
292
+ comment.status.toLowerCase() === 'rejected' ? 'bg-red-500/10 text-red-400 border border-red-500/20' :
293
+ 'bg-zinc-800 text-zinc-400 border border-zinc-700'
294
+ }`}>{comment.status}</span>
295
+ </div>
296
+ <div className="bg-zinc-950/50 rounded-lg p-4 border border-zinc-800/40 mt-3 relative">
297
+ <div className="flex items-start gap-2">
298
+ <FileText className="w-4 h-4 text-indigo-400 mt-0.5 shrink-0 opacity-80" />
299
+ <div>
300
+ <p className="text-[11px] font-bold text-zinc-500 uppercase tracking-widest mb-1">{comment.author} {comment.role ? `(${comment.role})` : ""}</p>
301
+ <p className="text-sm text-zinc-300 italic leading-relaxed">"{comment.text}"</p>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ );
307
+ });
308
+ })()}
309
+ </div>
310
+ )}
311
+ </div>
312
+ </div>
313
+ </main>
314
+ );
315
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Users, Plus, LayoutGrid, List } from "lucide-react";
5
+ import { Patient } from "@/types";
6
+ import { PatientCard } from "@/components/patients/patient-card";
7
+ import Link from "next/link";
8
+ import { PatientSearch } from "@/components/patients/patient-search";
9
+ import { useRouter } from "next/navigation";
10
+
11
+ export default function PatientsPage() {
12
+ const [patients, setPatients] = useState<Patient[]>([]);
13
+ const [isLoading, setIsLoading] = useState(true);
14
+ const router = useRouter();
15
+
16
+ const fetchPatients = async (search = "") => {
17
+ setIsLoading(true);
18
+ try {
19
+ const url = search ? `/api/patients?search=${encodeURIComponent(search)}` : "/api/patients";
20
+ const res = await fetch(url);
21
+ if (res.ok) {
22
+ const data = await res.json();
23
+ setPatients(data);
24
+ }
25
+ } catch (e) {
26
+ console.error("Failed to fetch patients", e);
27
+ } finally {
28
+ setIsLoading(false);
29
+ }
30
+ };
31
+
32
+ useEffect(() => {
33
+ fetchPatients();
34
+ }, []);
35
+
36
+ const handleCreateNew = async () => {
37
+ const name = prompt("Enter new patient's name:");
38
+ if (!name) return;
39
+ const idNumber = prompt("Enter patient ID number (optional):");
40
+
41
+ try {
42
+ const res = await fetch("/api/patients", {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify({ patientName: name, patientIdNumber: idNumber })
46
+ });
47
+ if (res.ok) {
48
+ const newP = await res.json();
49
+ router.push(`/patients/${newP.id}`);
50
+ }
51
+ } catch (e) {
52
+ console.error(e);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <main className="flex-1 p-6 lg:p-8 ml-20 h-screen overflow-y-auto">
58
+ <div className="max-w-6xl mx-auto">
59
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
60
+ <div>
61
+ <h1 className="text-2xl font-bold text-zinc-100 flex items-center gap-2">
62
+ <Users className="text-indigo-400" />
63
+ Patient Records
64
+ </h1>
65
+ <p className="text-sm text-zinc-400 mt-1">Manage and view longitudinal patient histories</p>
66
+ </div>
67
+
68
+ <button
69
+ onClick={handleCreateNew}
70
+ className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg font-medium shadow-lg shadow-indigo-500/20 transition-all flex items-center gap-2"
71
+ >
72
+ <Plus className="w-5 h-5" />
73
+ New Patient
74
+ </button>
75
+ </div>
76
+
77
+ <div className="bg-bg-surface border border-border-primary rounded-2xl p-4 mb-8 shadow-sm">
78
+ <PatientSearch
79
+ onSelect={(p) => router.push(`/patients/${p.id}`)}
80
+ onNewPatient={handleCreateNew}
81
+ onChange={(val) => {
82
+ if (val.length === 0) fetchPatients();
83
+ }}
84
+ />
85
+ </div>
86
+
87
+ {isLoading ? (
88
+ <div className="flex justify-center p-12">
89
+ <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" />
90
+ </div>
91
+ ) : patients.length > 0 ? (
92
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
93
+ {patients.map((p: any) => (
94
+ <PatientCard key={p.id} patient={p} reportCount={p.reportCount || 0} lastVisitDate={p.createdAt} latestStatus={p.reportCount > 0 ? "FINAL" : undefined} />
95
+ ))}
96
+ </div>
97
+ ) : (
98
+ <div className="flex flex-col items-center justify-center p-16 border-2 border-dashed border-zinc-800 rounded-2xl bg-zinc-900/30">
99
+ <Users className="w-16 h-16 text-zinc-600 mb-4" />
100
+ <h3 className="text-lg font-medium text-zinc-300">No patients found</h3>
101
+ <p className="text-zinc-500 mt-2 text-center max-w-sm mb-6">Patient records are automatically created when you generate a report, or you can add one manually.</p>
102
+ <button onClick={handleCreateNew} className="text-indigo-400 hover:text-indigo-300 font-medium">
103
+ + Add Patient Manually
104
+ </button>
105
+ </div>
106
+ )}
107
+ </div>
108
+ </main>
109
+ );
110
+ }
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Card, CardContent, CardHeader, CardTitle, Input, Label, Button } from "@/components/ui/basic";
5
+ import { Save, User, KeyRound, LogOut, Eye, EyeOff } from "lucide-react";
6
+ import { useRouter } from "next/navigation";
7
+
8
+ export default function ProfilePage() {
9
+ const router = useRouter();
10
+
11
+ // Account details
12
+ const [account, setAccount] = React.useState({ fullName: "", username: "", email: "", role: "", position: "" });
13
+ const [accountSaved, setAccountSaved] = React.useState(false);
14
+ const [accountError, setAccountError] = React.useState("");
15
+ const [isAccountLoading, setIsAccountLoading] = React.useState(true);
16
+
17
+ // Password details
18
+ const [passwords, setPasswords] = React.useState({ current: "", new: "", confirm: "" });
19
+ const [showPasswords, setShowPasswords] = React.useState({ current: false, new: false, confirm: false });
20
+ const [passSaved, setPassSaved] = React.useState(false);
21
+ const [passError, setPassError] = React.useState("");
22
+
23
+ React.useEffect(() => {
24
+ fetch('/api/auth/me')
25
+ .then(res => res.json())
26
+ .then(meData => {
27
+ if (meData.id) {
28
+ setAccount({ fullName: meData.fullName, username: meData.username, email: meData.email, role: meData.role, position: meData.position || "" });
29
+ }
30
+ setIsAccountLoading(false);
31
+ })
32
+ .catch(() => setIsAccountLoading(false));
33
+ }, []);
34
+
35
+ const handleAccountSave = async () => {
36
+ setAccountError("");
37
+ const res = await fetch('/api/auth/me', {
38
+ method: 'PUT',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(account),
41
+ });
42
+ const data = await res.json();
43
+ if (!res.ok) {
44
+ setAccountError(data.error);
45
+ } else {
46
+ setAccountSaved(true);
47
+ setTimeout(() => setAccountSaved(false), 2000);
48
+ }
49
+ };
50
+
51
+ const handlePasswordSave = async () => {
52
+ setPassError("");
53
+ if (passwords.new !== passwords.confirm) {
54
+ setPassError("New passwords do not match.");
55
+ return;
56
+ }
57
+ const res = await fetch('/api/auth/password', {
58
+ method: 'PUT',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ currentPassword: passwords.current, newPassword: passwords.new }),
61
+ });
62
+ const data = await res.json();
63
+ if (!res.ok) {
64
+ setPassError(data.error);
65
+ } else {
66
+ setPassSaved(true);
67
+ setPasswords({ current: "", new: "", confirm: "" });
68
+ setTimeout(() => setPassSaved(false), 2000);
69
+ }
70
+ };
71
+
72
+
73
+ const handleLogout = async () => {
74
+ await fetch('/api/auth/logout', { method: 'POST' });
75
+ router.push('/login');
76
+ };
77
+
78
+ if (isAccountLoading) {
79
+ return <div className="p-6 max-w-5xl mx-auto"><p className="text-text-muted">Loading profile...</p></div>;
80
+ }
81
+
82
+ return (
83
+ <div className="p-6 max-w-5xl mx-auto space-y-6">
84
+ <div className="flex justify-between items-center">
85
+ <div className="space-y-1">
86
+ <h2 className="text-2xl font-semibold text-text-heading">Your Profile</h2>
87
+ <p className="text-text-secondary text-sm">Manage your personal details and security.</p>
88
+ </div>
89
+ <Button onClick={handleLogout} variant="danger" className="flex gap-2">
90
+ <LogOut size={16} /> Logout
91
+ </Button>
92
+ </div>
93
+
94
+ <Card className="bg-bg-surface border-border-primary">
95
+ <CardHeader>
96
+ <CardTitle>Account Details</CardTitle>
97
+ </CardHeader>
98
+ <CardContent className="space-y-6">
99
+ <div className="flex items-center gap-6 pb-6 border-b border-border-primary">
100
+ <div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center text-primary">
101
+ <User size={40} />
102
+ </div>
103
+ <div>
104
+ <h3 className="font-medium text-lg">{account.fullName || "Your Name"}</h3>
105
+ <div className="flex gap-2 items-center mt-1">
106
+ <span className="text-xs bg-bg-secondary text-text-secondary px-2 py-1 rounded inline-block uppercase tracking-widest">{account.role}</span>
107
+ {account.position && <span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded inline-block uppercase tracking-widest">{account.position}</span>}
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="grid gap-4">
113
+ <div className="grid grid-cols-2 gap-4">
114
+ <div className="space-y-2">
115
+ <Label>Full Name</Label>
116
+ <Input value={account.fullName} onChange={(e) => setAccount({ ...account, fullName: e.target.value })} />
117
+ </div>
118
+ <div className="space-y-2">
119
+ <Label>Username</Label>
120
+ <Input value={account.username} onChange={(e) => setAccount({ ...account, username: e.target.value })} />
121
+ </div>
122
+ </div>
123
+
124
+ <div className="grid grid-cols-2 gap-4">
125
+ <div className="space-y-2">
126
+ <Label>Email</Label>
127
+ <Input value={account.email} type="email" onChange={(e) => setAccount({ ...account, email: e.target.value })} />
128
+ </div>
129
+ <div className="space-y-2">
130
+ <Label>Professional Position</Label>
131
+ <select
132
+ className="flex h-10 w-full rounded-md border border-border-primary bg-bg-surface px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary shadow-sm"
133
+ value={account.position || ""}
134
+ onChange={(e) => setAccount({ ...account, position: e.target.value })}
135
+ >
136
+ <option value="" disabled>Select position...</option>
137
+ <option value="Radiologist">Radiologist</option>
138
+ <option value="Doctor">Doctor / Physician</option>
139
+ <option value="Technician">Radiology Technician</option>
140
+ <option value="Nurse">Nurse</option>
141
+ <option value="Administrator">Administrator</option>
142
+ <option value="Other">Other</option>
143
+ </select>
144
+ </div>
145
+ </div>
146
+ </div>
147
+
148
+ {accountError && <p className="text-red-500 text-sm mt-2">{accountError}</p>}
149
+
150
+ <div className="pt-4 flex items-center gap-4 border-t border-border-primary mt-4">
151
+ <Button onClick={handleAccountSave} className="gap-2">
152
+ <Save size={16} /> Update Details
153
+ </Button>
154
+ {accountSaved && <span className="text-sm text-green-600 font-medium">Updated successfully!</span>}
155
+ </div>
156
+ </CardContent>
157
+ </Card>
158
+
159
+ <Card className="bg-bg-surface border-border-primary">
160
+ <CardHeader>
161
+ <CardTitle className="flex items-center gap-2"><KeyRound size={20}/> Change Password</CardTitle>
162
+ </CardHeader>
163
+ <CardContent className="space-y-4">
164
+ <div className="space-y-2">
165
+ <Label>Current Password</Label>
166
+ <div className="relative">
167
+ <Input type={showPasswords.current ? "text" : "password"} value={passwords.current} onChange={(e) => setPasswords({...passwords, current: e.target.value})} className="pr-10" />
168
+ <button type="button" onClick={() => setShowPasswords({...showPasswords, current: !showPasswords.current})} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary focus:outline-none">
169
+ {showPasswords.current ? <EyeOff size={16} /> : <Eye size={16} />}
170
+ </button>
171
+ </div>
172
+ </div>
173
+ <div className="grid grid-cols-2 gap-4">
174
+ <div className="space-y-2">
175
+ <Label>New Password</Label>
176
+ <div className="relative">
177
+ <Input type={showPasswords.new ? "text" : "password"} value={passwords.new} onChange={(e) => setPasswords({...passwords, new: e.target.value})} className="pr-10" />
178
+ <button type="button" onClick={() => setShowPasswords({...showPasswords, new: !showPasswords.new})} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary focus:outline-none">
179
+ {showPasswords.new ? <EyeOff size={16} /> : <Eye size={16} />}
180
+ </button>
181
+ </div>
182
+ </div>
183
+ <div className="space-y-2">
184
+ <Label>Confirm New Password</Label>
185
+ <div className="relative">
186
+ <Input type={showPasswords.confirm ? "text" : "password"} value={passwords.confirm} onChange={(e) => setPasswords({...passwords, confirm: e.target.value})} className="pr-10" />
187
+ <button type="button" onClick={() => setShowPasswords({...showPasswords, confirm: !showPasswords.confirm})} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary focus:outline-none">
188
+ {showPasswords.confirm ? <EyeOff size={16} /> : <Eye size={16} />}
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ {passError && <p className="text-red-500 text-sm">{passError}</p>}
195
+
196
+ <div className="pt-4 flex items-center gap-4">
197
+ <Button onClick={handlePasswordSave} variant="outline" className="gap-2">
198
+ Update Password
199
+ </Button>
200
+ {passSaved && <span className="text-sm text-green-600 font-medium">Password saved!</span>}
201
+ </div>
202
+ </CardContent>
203
+ </Card>
204
+
205
+
206
+ </div>
207
+ );
208
+ }