@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.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- package/types/pacs.ts +41 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ReportData } from "@/types";
|
|
3
|
+
import { Card, CardContent } from "@/components/ui/basic";
|
|
4
|
+
import { Download, Printer, Edit, XCircle, CheckCircle, FileText, Share2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/basic";
|
|
6
|
+
import { CollaborationPanel } from "@/components/dashboard/CollaborationPanel";
|
|
7
|
+
import { FullReportOverlay } from "@/components/dashboard/FullReportOverlay";
|
|
8
|
+
import { RejectionModal } from "@/components/dashboard/RejectionModal";
|
|
9
|
+
import { ApprovalModal } from "@/components/dashboard/ApprovalModal";
|
|
10
|
+
import { ReportEditor } from "@/components/dashboard/ReportEditor";
|
|
11
|
+
import { Comment, AuditLog } from "@/types";
|
|
12
|
+
import { updateReportData, updateReportStatus } from "@/lib/api";
|
|
13
|
+
import { StandardTemplate, PdfStandardTemplate } from "@/components/dashboard/ReportTemplates";
|
|
14
|
+
|
|
15
|
+
interface ReportViewProps {
|
|
16
|
+
report: ReportData;
|
|
17
|
+
onNewPatient: () => void;
|
|
18
|
+
reportId?: string;
|
|
19
|
+
imagePreview?: string | null;
|
|
20
|
+
imagesPreviews?: string[];
|
|
21
|
+
onStatusChange?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ReportView({ report, onNewPatient, reportId, imagePreview, imagesPreviews = [], onStatusChange }: ReportViewProps) {
|
|
25
|
+
const [currentUser, setCurrentUser] = React.useState({ name: "Dr. User", role: "Doctor" });
|
|
26
|
+
const [logoUrl, setLogoUrl] = React.useState<string>("");
|
|
27
|
+
|
|
28
|
+
// Local state for the report footer and collaboration so UI re-renders immediately
|
|
29
|
+
const [footer, setFooter] = React.useState({ ...report.report_footer });
|
|
30
|
+
const [collaboration, setCollaboration] = React.useState({
|
|
31
|
+
comments: report.collaboration?.comments || [],
|
|
32
|
+
logs: report.collaboration?.logs || []
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Keep state in sync if a completely new report is passed in
|
|
36
|
+
React.useEffect(() => {
|
|
37
|
+
setFooter({ ...report.report_footer });
|
|
38
|
+
setCollaboration({
|
|
39
|
+
comments: report.collaboration?.comments || [],
|
|
40
|
+
logs: report.collaboration?.logs || []
|
|
41
|
+
});
|
|
42
|
+
}, [report]);
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
// Load current authenticated user instead of global profile
|
|
46
|
+
fetch('/api/auth/me')
|
|
47
|
+
.then(res => res.json())
|
|
48
|
+
.then(data => {
|
|
49
|
+
if (data && !data.error) {
|
|
50
|
+
setCurrentUser({
|
|
51
|
+
name: data.fullName || "Dr. User",
|
|
52
|
+
role: data.position || data.role || "Doctor"
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.catch(e => console.error("Error fetching current user:", e));
|
|
57
|
+
|
|
58
|
+
// Load appearance settings (logo)
|
|
59
|
+
fetch('/api/settings?type=appearance')
|
|
60
|
+
.then(res => res.json())
|
|
61
|
+
.then(data => {
|
|
62
|
+
if (data && data.logo) {
|
|
63
|
+
setLogoUrl(data.logo);
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.catch(e => console.error("Error fetching appearance settings:", e));
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const handleAddComment = async (text: string) => {
|
|
70
|
+
if (!reportId) return;
|
|
71
|
+
|
|
72
|
+
const newComment: Comment = {
|
|
73
|
+
id: Date.now().toString(),
|
|
74
|
+
author: currentUser.name,
|
|
75
|
+
role: currentUser.role,
|
|
76
|
+
text,
|
|
77
|
+
timestamp: new Date().toISOString()
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const updatedComments = [...collaboration.comments, newComment];
|
|
81
|
+
const updatedLogs = [...collaboration.logs, {
|
|
82
|
+
id: Date.now().toString() + "_log",
|
|
83
|
+
action: "Comment Added",
|
|
84
|
+
user: currentUser.name,
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
details: text.substring(0, 50) + (text.length > 50 ? "..." : "")
|
|
87
|
+
} as AuditLog];
|
|
88
|
+
|
|
89
|
+
await updateReportData(reportId, {
|
|
90
|
+
collaboration: {
|
|
91
|
+
comments: updatedComments,
|
|
92
|
+
logs: updatedLogs
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Optimistically update local state
|
|
97
|
+
setCollaboration({ comments: updatedComments, logs: updatedLogs });
|
|
98
|
+
|
|
99
|
+
// Notify parent
|
|
100
|
+
if (onStatusChange) onStatusChange();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleUnreject = async () => {
|
|
104
|
+
if (!reportId) return;
|
|
105
|
+
|
|
106
|
+
const success = await updateReportStatus(reportId, 'Pending');
|
|
107
|
+
if (success) {
|
|
108
|
+
setFooter(prev => ({ ...prev, report_status: 'Pending', rejection_reason: undefined }));
|
|
109
|
+
|
|
110
|
+
// Add audit log optimistically
|
|
111
|
+
setCollaboration(prev => ({
|
|
112
|
+
...prev,
|
|
113
|
+
logs: [...prev.logs, {
|
|
114
|
+
id: `log_${Date.now()}`,
|
|
115
|
+
action: "Status Changed to Pending",
|
|
116
|
+
user: currentUser.name,
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
details: "Status reset"
|
|
119
|
+
}]
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
if (onStatusChange) onStatusChange();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handlePrint = () => window.print();
|
|
127
|
+
|
|
128
|
+
const handleDownloadPDF = async () => {
|
|
129
|
+
// Use the Clean Slate PDF generator which creates a fresh HTML structure
|
|
130
|
+
// This bypasses any UI state or visibility issues with the React components.
|
|
131
|
+
|
|
132
|
+
const filename = `${report.patient.name.replace(/\s+/g, '_')}_Report.pdf`;
|
|
133
|
+
|
|
134
|
+
// Determine template preference
|
|
135
|
+
let template: 'standard' | 'modern' | 'minimal' = 'standard';
|
|
136
|
+
let logoToUse = logoUrl;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch('/api/settings?type=appearance');
|
|
140
|
+
const config = await res.json();
|
|
141
|
+
if (config.template) {
|
|
142
|
+
template = config.template;
|
|
143
|
+
}
|
|
144
|
+
if (config.logo) {
|
|
145
|
+
logoToUse = config.logo;
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error("Error reading template preference", e);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { generatePDF } = await import('@/lib/pdfHelper');
|
|
152
|
+
await generatePDF(report, filename, template, logoToUse);
|
|
153
|
+
};
|
|
154
|
+
const urgencyColor = report.urgency === 'Critical' ? 'text-red-600' :
|
|
155
|
+
report.urgency === 'Urgent' ? 'text-orange-600' : 'text-green-600';
|
|
156
|
+
|
|
157
|
+
// Use local footer state for all status-driven UI
|
|
158
|
+
const statusColor = footer.report_status === 'Approved' ? 'bg-green-100 text-green-800' :
|
|
159
|
+
footer.report_status === 'Rejected' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800';
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
// ... imports
|
|
164
|
+
|
|
165
|
+
const [isFullReport, setIsFullReport] = React.useState(false);
|
|
166
|
+
const [isRejectModalOpen, setIsRejectModalOpen] = React.useState(false);
|
|
167
|
+
const [isApproveModalOpen, setIsApproveModalOpen] = React.useState(false);
|
|
168
|
+
const [isEditing, setIsEditing] = React.useState(false);
|
|
169
|
+
|
|
170
|
+
const handleSaveReport = async (updatedReport: ReportData) => {
|
|
171
|
+
if (!reportId) return;
|
|
172
|
+
|
|
173
|
+
// Optimistic update locally
|
|
174
|
+
Object.assign(report, updatedReport);
|
|
175
|
+
setIsEditing(false);
|
|
176
|
+
|
|
177
|
+
// Update backend
|
|
178
|
+
await updateReportData(reportId, {
|
|
179
|
+
findings: updatedReport.findings,
|
|
180
|
+
impression: updatedReport.impression,
|
|
181
|
+
recommendations: updatedReport.recommendations,
|
|
182
|
+
urgency: updatedReport.urgency
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Reload or update callback
|
|
186
|
+
if (onStatusChange) onStatusChange();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleRejectConfirm = async (reason: string, comment: string) => {
|
|
190
|
+
if (!reportId) return;
|
|
191
|
+
const success = await updateReportStatus(reportId, 'Rejected', { rejectionReason: reason, notes: comment });
|
|
192
|
+
if (success) {
|
|
193
|
+
setFooter(prev => ({ ...prev, report_status: 'Rejected', rejection_reason: reason }));
|
|
194
|
+
|
|
195
|
+
// Optmistic logs and comments updates
|
|
196
|
+
const timestamp = new Date().toISOString();
|
|
197
|
+
const newLogs = [...collaboration.logs, {
|
|
198
|
+
id: `log_${Date.now()}`,
|
|
199
|
+
action: "Status Changed to Rejected",
|
|
200
|
+
user: currentUser.name,
|
|
201
|
+
timestamp,
|
|
202
|
+
details: `Reason: ${reason}`
|
|
203
|
+
}];
|
|
204
|
+
let newComments = [...collaboration.comments];
|
|
205
|
+
if (comment) {
|
|
206
|
+
newComments.push({
|
|
207
|
+
id: `comment_${Date.now()}`,
|
|
208
|
+
author: currentUser.name,
|
|
209
|
+
role: currentUser.role,
|
|
210
|
+
text: comment,
|
|
211
|
+
timestamp
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
setCollaboration({ logs: newLogs, comments: newComments });
|
|
215
|
+
|
|
216
|
+
setIsRejectModalOpen(false);
|
|
217
|
+
if (onStatusChange) onStatusChange();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const handleApproveConfirm = async (signature: string, comment: string) => {
|
|
222
|
+
if (!reportId) return;
|
|
223
|
+
const success = await updateReportStatus(reportId, 'Approved', { signature, notes: comment });
|
|
224
|
+
if (success) {
|
|
225
|
+
const timestamp = new Date().toISOString();
|
|
226
|
+
setFooter(prev => ({
|
|
227
|
+
...prev,
|
|
228
|
+
report_status: 'Approved',
|
|
229
|
+
signature,
|
|
230
|
+
approved_by: currentUser.name,
|
|
231
|
+
approved_at: timestamp,
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
// Optmistic logs and comments updates
|
|
235
|
+
const newLogs = [...collaboration.logs, {
|
|
236
|
+
id: `log_${Date.now()}`,
|
|
237
|
+
action: "Status Changed to Approved",
|
|
238
|
+
user: currentUser.name,
|
|
239
|
+
timestamp,
|
|
240
|
+
details: "Report Approved"
|
|
241
|
+
}];
|
|
242
|
+
let newComments = [...collaboration.comments];
|
|
243
|
+
if (comment) {
|
|
244
|
+
newComments.push({
|
|
245
|
+
id: `comment_${Date.now()}`,
|
|
246
|
+
author: currentUser.name,
|
|
247
|
+
role: currentUser.role,
|
|
248
|
+
text: comment,
|
|
249
|
+
timestamp
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
setCollaboration({ logs: newLogs, comments: newComments });
|
|
253
|
+
|
|
254
|
+
setIsApproveModalOpen(false);
|
|
255
|
+
if (onStatusChange) onStatusChange();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className="h-full relative">
|
|
261
|
+
<RejectionModal
|
|
262
|
+
isOpen={isRejectModalOpen}
|
|
263
|
+
onClose={() => setIsRejectModalOpen(false)}
|
|
264
|
+
onConfirm={handleRejectConfirm}
|
|
265
|
+
/>
|
|
266
|
+
<ApprovalModal
|
|
267
|
+
isOpen={isApproveModalOpen}
|
|
268
|
+
onClose={() => setIsApproveModalOpen(false)}
|
|
269
|
+
onConfirm={handleApproveConfirm}
|
|
270
|
+
currentUser={currentUser}
|
|
271
|
+
/>
|
|
272
|
+
|
|
273
|
+
{isFullReport && (
|
|
274
|
+
<FullReportOverlay
|
|
275
|
+
report={{ ...report, report_footer: footer, collaboration }}
|
|
276
|
+
reportId={reportId}
|
|
277
|
+
imageSrc={imagePreview || report.image_data}
|
|
278
|
+
images={
|
|
279
|
+
imagesPreviews.length > 0 ? imagesPreviews :
|
|
280
|
+
report.images_data && report.images_data.length > 0 ? report.images_data :
|
|
281
|
+
[]
|
|
282
|
+
}
|
|
283
|
+
onClose={() => setIsFullReport(false)}
|
|
284
|
+
onNewPatient={onNewPatient}
|
|
285
|
+
onPrint={handlePrint}
|
|
286
|
+
onDownloadPDF={handleDownloadPDF}
|
|
287
|
+
onEdit={() => {
|
|
288
|
+
setIsFullReport(false);
|
|
289
|
+
setIsEditing(true);
|
|
290
|
+
}}
|
|
291
|
+
onReject={() => setIsRejectModalOpen(true)}
|
|
292
|
+
onApprove={() => setIsApproveModalOpen(true)}
|
|
293
|
+
onUnreject={handleUnreject}
|
|
294
|
+
onAddComment={handleAddComment}
|
|
295
|
+
currentUser={currentUser}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{isEditing ? (
|
|
300
|
+
<div className="h-full bg-bg-surface rounded-xl shadow-sm border border-border-primary overflow-hidden">
|
|
301
|
+
<ReportEditor
|
|
302
|
+
report={report}
|
|
303
|
+
onSave={handleSaveReport}
|
|
304
|
+
onCancel={() => setIsEditing(false)}
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
) : (
|
|
308
|
+
<div className="h-full flex flex-col overflow-hidden bg-bg-surface rounded-xl shadow-sm border border-border-primary">
|
|
309
|
+
<div className="flex flex-col border-b border-border-primary bg-bg-panel shrink-0">
|
|
310
|
+
{/* Top Row: Patient Info */}
|
|
311
|
+
<div className="p-4 pb-2 flex justify-between items-start">
|
|
312
|
+
<div className="flex flex-col gap-1">
|
|
313
|
+
<h1 className="text-2xl font-bold text-text-heading">{report.patient.name}</h1>
|
|
314
|
+
<div className="flex items-center gap-2 text-sm text-text-muted">
|
|
315
|
+
{report.patient.patient_id && (
|
|
316
|
+
<>
|
|
317
|
+
<span className="font-mono text-xs bg-white/5 px-1.5 py-0.5 rounded border border-white/10">ID: {report.patient.patient_id}</span>
|
|
318
|
+
<span>•</span>
|
|
319
|
+
</>
|
|
320
|
+
)}
|
|
321
|
+
<span>{report.study.modality}</span>
|
|
322
|
+
<span>•</span>
|
|
323
|
+
<span>{report.study.examination}</span>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
<div className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${statusColor}`}>
|
|
327
|
+
{footer.report_status}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Bottom Row: Action Toolbar */}
|
|
332
|
+
<div className="px-4 pb-4 pt-2 flex items-center justify-between gap-4">
|
|
333
|
+
{/* Left Group: View & Export */}
|
|
334
|
+
<div className="flex items-center gap-2">
|
|
335
|
+
<Button
|
|
336
|
+
variant={isFullReport ? "default" : "outline"}
|
|
337
|
+
size="sm"
|
|
338
|
+
onClick={() => setIsFullReport(!isFullReport)}
|
|
339
|
+
className="transition-all hover:shadow-md active:scale-95 hover:bg-blue-50 text-blue-700 border-blue-200"
|
|
340
|
+
>
|
|
341
|
+
{isFullReport ? "Exit Full View" : "Full Report"}
|
|
342
|
+
</Button>
|
|
343
|
+
<div className="h-6 w-px bg-border-primary mx-1" />
|
|
344
|
+
<Button
|
|
345
|
+
variant="outline"
|
|
346
|
+
size="icon"
|
|
347
|
+
onClick={handleDownloadPDF}
|
|
348
|
+
title="Download PDF"
|
|
349
|
+
className="w-8 h-8 transition-all hover:bg-gray-100 hover:text-blue-600 active:scale-95"
|
|
350
|
+
>
|
|
351
|
+
<Download size={16} />
|
|
352
|
+
</Button>
|
|
353
|
+
<Button
|
|
354
|
+
variant="outline"
|
|
355
|
+
size="icon"
|
|
356
|
+
onClick={handlePrint}
|
|
357
|
+
title="Print"
|
|
358
|
+
className="w-8 h-8 transition-all hover:bg-gray-100 hover:text-blue-600 active:scale-95"
|
|
359
|
+
>
|
|
360
|
+
<Printer size={16} />
|
|
361
|
+
</Button>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* Right Group: Workflow Actions */}
|
|
365
|
+
<div className="flex items-center gap-2">
|
|
366
|
+
{footer.report_status === 'Pending' && (
|
|
367
|
+
<>
|
|
368
|
+
<Button
|
|
369
|
+
variant="outline"
|
|
370
|
+
size="sm"
|
|
371
|
+
className="text-blue-600 border-blue-200 hover:bg-blue-50"
|
|
372
|
+
onClick={() => setIsEditing(true)}
|
|
373
|
+
>
|
|
374
|
+
<Edit size={14} className="mr-1.5" /> Edit
|
|
375
|
+
</Button>
|
|
376
|
+
<Button
|
|
377
|
+
variant="danger"
|
|
378
|
+
size="sm"
|
|
379
|
+
onClick={() => setIsRejectModalOpen(true)}
|
|
380
|
+
className="bg-red-50 text-red-600 border-red-200 hover:bg-red-100"
|
|
381
|
+
>
|
|
382
|
+
<XCircle size={14} className="mr-1.5" /> Reject
|
|
383
|
+
</Button>
|
|
384
|
+
<Button
|
|
385
|
+
variant="success"
|
|
386
|
+
size="sm"
|
|
387
|
+
onClick={() => setIsApproveModalOpen(true)}
|
|
388
|
+
className="bg-green-600 text-white hover:bg-green-700 hover:shadow-md border-transparent"
|
|
389
|
+
>
|
|
390
|
+
<CheckCircle size={14} className="mr-1.5" /> Approve
|
|
391
|
+
</Button>
|
|
392
|
+
</>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{footer.report_status === 'Rejected' && (
|
|
396
|
+
<Button
|
|
397
|
+
variant="danger"
|
|
398
|
+
size="sm"
|
|
399
|
+
onClick={handleUnreject}
|
|
400
|
+
className="bg-red-100 text-red-700 hover:bg-red-200 border-red-200"
|
|
401
|
+
title="Click to Unreject"
|
|
402
|
+
>
|
|
403
|
+
<XCircle size={14} className="mr-1.5" /> Unreject
|
|
404
|
+
</Button>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{footer.report_status === 'Approved' && (
|
|
408
|
+
<Button
|
|
409
|
+
variant="success"
|
|
410
|
+
size="sm"
|
|
411
|
+
className="bg-green-100 text-green-800 border-green-200 cursor-default"
|
|
412
|
+
>
|
|
413
|
+
<CheckCircle size={14} className="mr-1.5" /> Approved
|
|
414
|
+
</Button>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
<div className="h-6 w-px bg-border-primary mx-1" />
|
|
418
|
+
|
|
419
|
+
<Button variant="default" size="sm" onClick={onNewPatient} className="bg-blue-600 hover:bg-blue-700 shadow-sm active:scale-95">
|
|
420
|
+
New Patient
|
|
421
|
+
</Button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
{/* Scrollable Report Content */}
|
|
427
|
+
<div className="flex-1 overflow-y-auto p-6 space-y-8" id="report-container">
|
|
428
|
+
|
|
429
|
+
{/* Findings */}
|
|
430
|
+
<section>
|
|
431
|
+
<h3 className="text-sm font-bold text-text-muted uppercase tracking-wider mb-3">Findings</h3>
|
|
432
|
+
<div className="space-y-4">
|
|
433
|
+
{report.findings.map((finding, idx) => {
|
|
434
|
+
const status = finding.status?.toLowerCase() || 'normal';
|
|
435
|
+
|
|
436
|
+
let badgeColor = 'bg-gray-100 text-gray-700 border-gray-200';
|
|
437
|
+
let statusLabel = 'NORMAL';
|
|
438
|
+
|
|
439
|
+
if (status === 'abnormal') {
|
|
440
|
+
badgeColor = 'bg-red-100 text-red-700 border-red-200';
|
|
441
|
+
statusLabel = 'ABNORMAL';
|
|
442
|
+
} else if (status === 'normal') {
|
|
443
|
+
badgeColor = 'bg-green-100 text-green-700 border-green-200';
|
|
444
|
+
statusLabel = 'NORMAL';
|
|
445
|
+
} else if (status === 'indeterminate') {
|
|
446
|
+
badgeColor = 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
447
|
+
statusLabel = 'INDETERMINATE';
|
|
448
|
+
} else if (status === 'post_procedural' || status === 'post-procedural') {
|
|
449
|
+
badgeColor = 'bg-blue-100 text-blue-700 border-blue-200';
|
|
450
|
+
statusLabel = 'POST-PROCEDURAL';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<div key={idx} className="pb-3 border-b border-border-primary/30 last:border-0">
|
|
455
|
+
<div className="flex items-center gap-2 mb-1">
|
|
456
|
+
<span className="font-bold text-text-heading underline decoration-gray-300 underline-offset-4">
|
|
457
|
+
{finding.anatomical_region}
|
|
458
|
+
</span>
|
|
459
|
+
<span className={`text-[10px] px-2 py-0.5 rounded border font-bold uppercase tracking-wider ${badgeColor}`}>
|
|
460
|
+
{statusLabel}
|
|
461
|
+
</span>
|
|
462
|
+
</div>
|
|
463
|
+
<p className="text-text-primary leading-relaxed">
|
|
464
|
+
{finding.observation}
|
|
465
|
+
</p>
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
})}
|
|
469
|
+
</div>
|
|
470
|
+
</section>
|
|
471
|
+
|
|
472
|
+
{/* Impression */}
|
|
473
|
+
<section className="bg-blue-50/50 p-4 rounded-lg border border-blue-100/50">
|
|
474
|
+
<h3 className="text-sm font-bold text-blue-900 uppercase tracking-wider mb-2">Impression</h3>
|
|
475
|
+
<div className="space-y-2">
|
|
476
|
+
{report.impression.map((imp, idx) => (
|
|
477
|
+
<p key={idx} className="text-text-heading font-medium leading-relaxed">{imp}</p>
|
|
478
|
+
))}
|
|
479
|
+
</div>
|
|
480
|
+
</section>
|
|
481
|
+
|
|
482
|
+
{/* Urgency Level - Moved here per request */}
|
|
483
|
+
<div className="p-4 rounded-lg border border-border-primary bg-bg-panel/50">
|
|
484
|
+
<span className="block text-xs font-bold text-text-muted uppercase tracking-wider mb-1">Urgency Level</span>
|
|
485
|
+
<span className={`text-lg font-bold ${urgencyColor}`}>{report.urgency}</span>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
{/* Recommendations */}
|
|
489
|
+
{report.recommendations && report.recommendations.length > 0 && (
|
|
490
|
+
<section>
|
|
491
|
+
<h3 className="text-sm font-bold text-text-muted uppercase tracking-wider mb-2">Recommendations</h3>
|
|
492
|
+
<ul className="list-disc list-inside space-y-1 text-text-primary">
|
|
493
|
+
{report.recommendations.map((rec, idx) => (
|
|
494
|
+
<li key={idx}>{rec}</li>
|
|
495
|
+
))}
|
|
496
|
+
</ul>
|
|
497
|
+
</section>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{/* Footer Info */}
|
|
501
|
+
<div className="pt-8 border-t border-border-primary text-sm text-text-muted grid grid-cols-2 gap-4">
|
|
502
|
+
<div className="text-left">
|
|
503
|
+
<p>Prepared by: {footer.prepared_by}</p>
|
|
504
|
+
<p>{new Date(report.report_header.report_date).toLocaleDateString()}</p>
|
|
505
|
+
{footer.report_status === 'Rejected' && footer.rejection_reason && (
|
|
506
|
+
<div className="mt-2 text-red-600 font-medium">
|
|
507
|
+
Rejection Reason: {footer.rejection_reason}
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
{/* Approval Sig in Footer */}
|
|
512
|
+
{footer.report_status === 'Approved' && footer.approved_by && (
|
|
513
|
+
<div className="text-right">
|
|
514
|
+
<p className="text-xs uppercase font-bold mb-2">Electronically Signed By</p>
|
|
515
|
+
<p className="font-bold text-lg">{footer.approved_by}</p>
|
|
516
|
+
{footer.signature && (
|
|
517
|
+
<img src={footer.signature} alt="Signature" className="h-12 ml-auto opacity-80 mt-1 dark:invert" />
|
|
518
|
+
)}
|
|
519
|
+
<p className="text-xs mt-1">{new Date(footer.approved_at || "").toLocaleString()}</p>
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
{/* Collaboration (Included at bottom for utility) */}
|
|
525
|
+
<div className="pt-8 border-t border-border-primary">
|
|
526
|
+
<h3 className="text-sm font-bold text-text-muted uppercase tracking-wider mb-4">Collaboration & Logs</h3>
|
|
527
|
+
<CollaborationPanel
|
|
528
|
+
comments={collaboration.comments}
|
|
529
|
+
logs={collaboration.logs}
|
|
530
|
+
onAddComment={handleAddComment}
|
|
531
|
+
currentUser={currentUser}
|
|
532
|
+
/>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
</div>
|
|
538
|
+
);
|
|
539
|
+
}
|