@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,432 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useSearchParams, useRouter } from "next/navigation"
|
|
5
|
+
import { updateReportStatus, getReports } from "@/lib/api";
|
|
6
|
+
import { ReportData, ReportStatus } from "@/types";
|
|
7
|
+
import { Card, CardContent, Button } from "@/components/ui/basic";
|
|
8
|
+
import { Badge } from "@/components/ui/badge";
|
|
9
|
+
import { Eye, Check, X, Clock, RefreshCw, Trash2, Calendar } from "lucide-react";
|
|
10
|
+
import { ReportView } from "@/components/dashboard/ReportView";
|
|
11
|
+
import { ImageViewer } from "@/components/dashboard/ImageViewer";
|
|
12
|
+
|
|
13
|
+
// Helper to filter reports by status
|
|
14
|
+
// Defaults to 'Pending' if status is missing or 'Preliminary'
|
|
15
|
+
const getStatus = (r: any): ReportStatus => {
|
|
16
|
+
const s = r.report_data?.report_footer?.report_status;
|
|
17
|
+
if (s === 'Approved') return 'Approved';
|
|
18
|
+
if (s === 'Rejected') return 'Rejected';
|
|
19
|
+
return 'Pending';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
import { Suspense } from "react";
|
|
23
|
+
import { ApprovalModal } from "@/components/dashboard/ApprovalModal";
|
|
24
|
+
import { RejectionModal } from "@/components/dashboard/RejectionModal";
|
|
25
|
+
|
|
26
|
+
function ReportsContent() {
|
|
27
|
+
const router = useRouter();
|
|
28
|
+
const [reports, setReports] = React.useState<any[]>([]);
|
|
29
|
+
const [loading, setLoading] = React.useState(true);
|
|
30
|
+
const [selectedReport, setSelectedReport] = React.useState<{ data: ReportData, id: string } | null>(null);
|
|
31
|
+
const [processingId, setProcessingId] = React.useState<string | null>(null);
|
|
32
|
+
const [cameFromExternal, setCameFromExternal] = React.useState(false);
|
|
33
|
+
|
|
34
|
+
// Modal State
|
|
35
|
+
const [actionReport, setActionReport] = React.useState<{ id: string, name: string } | null>(null);
|
|
36
|
+
const [showApproval, setShowApproval] = React.useState(false);
|
|
37
|
+
const [showRejection, setShowRejection] = React.useState(false);
|
|
38
|
+
|
|
39
|
+
// Get current user from localStorage
|
|
40
|
+
const [currentUser, setCurrentUser] = React.useState({ name: "Dr. User", role: "Radiologist" });
|
|
41
|
+
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
fetch('/api/settings?type=profile')
|
|
44
|
+
.then(res => res.json())
|
|
45
|
+
.then(data => {
|
|
46
|
+
setCurrentUser({
|
|
47
|
+
name: data.fullName || "Dr. User",
|
|
48
|
+
role: data.role || "Radiologist"
|
|
49
|
+
});
|
|
50
|
+
})
|
|
51
|
+
.catch(e => console.error("Error loading profile:", e));
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const loadReports = async () => {
|
|
55
|
+
setLoading(true);
|
|
56
|
+
try {
|
|
57
|
+
// Use getReports() which merges Supabase (real UUIDs) + localStorage
|
|
58
|
+
// This ensures we pass real UUIDs to updateReportStatus so Supabase gets updated
|
|
59
|
+
const data = await getReports();
|
|
60
|
+
console.log("Reports loaded:", data?.length || 0, "reports");
|
|
61
|
+
setReports(data || []);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error("Error loading reports", e);
|
|
64
|
+
setReports([]);
|
|
65
|
+
}
|
|
66
|
+
setLoading(false);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const searchParams = useSearchParams();
|
|
70
|
+
const autoOpenId = searchParams.get('id');
|
|
71
|
+
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
if (autoOpenId) setCameFromExternal(true);
|
|
74
|
+
loadReports();
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Auto-open a report if ?id= is in the URL
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
if (autoOpenId && reports.length > 0 && !selectedReport) {
|
|
80
|
+
const target = reports.find(r => r.id === autoOpenId);
|
|
81
|
+
if (target) {
|
|
82
|
+
setSelectedReport({ data: target.report_data, id: target.id });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}, [autoOpenId, reports]);
|
|
86
|
+
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (selectedReport && reports.length > 0) {
|
|
89
|
+
const fresh = reports.find(r => r.id === selectedReport.id);
|
|
90
|
+
if (fresh && fresh.report_data && JSON.stringify(fresh.report_data) !== JSON.stringify(selectedReport.data)) {
|
|
91
|
+
setSelectedReport({ data: fresh.report_data, id: fresh.id });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}, [reports]);
|
|
95
|
+
|
|
96
|
+
const openApproval = (id: string, name: string) => {
|
|
97
|
+
setActionReport({ id, name });
|
|
98
|
+
setShowApproval(true);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const openRejection = (id: string, name: string) => {
|
|
102
|
+
setActionReport({ id, name });
|
|
103
|
+
setShowRejection(true);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleApprovalConfirm = async (signature: string, comments?: string) => {
|
|
107
|
+
if (!actionReport) return;
|
|
108
|
+
setProcessingId(actionReport.id);
|
|
109
|
+
const success = await updateReportStatus(actionReport.id, 'Approved', { signature, notes: comments });
|
|
110
|
+
if (success) {
|
|
111
|
+
await loadReports();
|
|
112
|
+
setShowApproval(false);
|
|
113
|
+
setActionReport(null);
|
|
114
|
+
}
|
|
115
|
+
setProcessingId(null);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleRejectionConfirm = async (reason: string) => {
|
|
119
|
+
if (!actionReport) return;
|
|
120
|
+
setProcessingId(actionReport.id);
|
|
121
|
+
const success = await updateReportStatus(actionReport.id, 'Rejected', { rejectionReason: reason });
|
|
122
|
+
if (success) {
|
|
123
|
+
await loadReports();
|
|
124
|
+
setShowRejection(false);
|
|
125
|
+
setActionReport(null);
|
|
126
|
+
}
|
|
127
|
+
setProcessingId(null);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleClearAll = async () => {
|
|
131
|
+
if (confirm("Are you sure you want to clear the board? This will hide the reports from this view but keep them in your history.")) {
|
|
132
|
+
setReports([]);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleView = (reportData: ReportData, reportId: string) => {
|
|
137
|
+
setSelectedReport({ data: reportData, id: reportId });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Sorting logic helpers
|
|
141
|
+
const sortNewest = (a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
142
|
+
const sortOldest = (a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
143
|
+
|
|
144
|
+
// Pending: Queue (Oldest First)
|
|
145
|
+
const pendingReports = reports.filter(r => getStatus(r) === 'Pending').sort(sortOldest);
|
|
146
|
+
// Approved/Rejected: History (Newest First)
|
|
147
|
+
const approvedReports = reports.filter(r => getStatus(r) === 'Approved').sort(sortNewest);
|
|
148
|
+
const rejectedReports = reports.filter(r => getStatus(r) === 'Rejected').sort(sortNewest);
|
|
149
|
+
|
|
150
|
+
if (selectedReport) {
|
|
151
|
+
// Ensure report has proper status
|
|
152
|
+
const reportData = selectedReport.data;
|
|
153
|
+
if (!reportData.report_footer) {
|
|
154
|
+
reportData.report_footer = {
|
|
155
|
+
prepared_by: "OmniRad AI",
|
|
156
|
+
department: "Radiology",
|
|
157
|
+
report_status: "Pending"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!reportData.report_footer.report_status) {
|
|
161
|
+
reportData.report_footer.report_status = "Pending";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="h-full flex flex-col md:flex-row overflow-hidden bg-bg-primary">
|
|
167
|
+
{/* Left Panel - Image Viewer */}
|
|
168
|
+
<div className="w-full md:w-[450px] md:min-w-[450px] h-auto md:h-full border-b md:border-b-0 md:border-r border-border-primary bg-bg-primary overflow-hidden">
|
|
169
|
+
<ImageViewer
|
|
170
|
+
imageSrc={reportData.image_data || null}
|
|
171
|
+
images={reportData.images_data && reportData.images_data.length > 0 ? reportData.images_data : (reportData.image_data ? [reportData.image_data] : [])}
|
|
172
|
+
className="w-full h-full"
|
|
173
|
+
isCollapsed={false}
|
|
174
|
+
onToggleCollapse={() => { }}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Right Panel - Report View */}
|
|
179
|
+
<div className="flex-1 h-full bg-bg-primary p-4 overflow-hidden flex flex-col">
|
|
180
|
+
<Button
|
|
181
|
+
variant="ghost"
|
|
182
|
+
onClick={() => {
|
|
183
|
+
if (cameFromExternal) {
|
|
184
|
+
router.back();
|
|
185
|
+
} else {
|
|
186
|
+
setSelectedReport(null);
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
className="mb-2 self-start gap-2"
|
|
190
|
+
>
|
|
191
|
+
← {cameFromExternal ? 'Back' : 'Back to Board'}
|
|
192
|
+
</Button>
|
|
193
|
+
<div className="flex-1 overflow-hidden">
|
|
194
|
+
<ReportView
|
|
195
|
+
report={reportData}
|
|
196
|
+
onNewPatient={() => setSelectedReport(null)}
|
|
197
|
+
reportId={selectedReport.id}
|
|
198
|
+
onStatusChange={loadReports}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// If we arrived via ?id= and the report hasn't been selected yet, show a loader
|
|
207
|
+
// instead of flashing the board view
|
|
208
|
+
if (autoOpenId && !selectedReport) {
|
|
209
|
+
return (
|
|
210
|
+
<div className="h-full flex items-center justify-center bg-bg-primary">
|
|
211
|
+
<div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" />
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="p-6 h-full flex flex-col gap-6 overflow-hidden bg-bg-primary text-text-primary">
|
|
218
|
+
<div className="flex justify-between items-center shrink-0">
|
|
219
|
+
<div>
|
|
220
|
+
<h2 className="text-2xl font-semibold text-text-heading">Reports Board</h2>
|
|
221
|
+
<p className="text-text-secondary text-sm">Manage radiology reports workflow.</p>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="flex gap-2">
|
|
224
|
+
<Button variant="outline" onClick={handleClearAll} disabled={loading || reports.length === 0} className="text-red-500 hover:text-red-600 hover:bg-red-50 border-red-200">
|
|
225
|
+
<Trash2 size={16} className="mr-2" /> Clear Board
|
|
226
|
+
</Button>
|
|
227
|
+
<Button variant="outline" onClick={loadReports} disabled={loading}>
|
|
228
|
+
<RefreshCw size={16} className={`mr-2 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="flex-1 overflow-x-auto overflow-y-hidden">
|
|
234
|
+
<div className="flex h-full gap-6 min-w-[1000px]">
|
|
235
|
+
{/* Pending Column */}
|
|
236
|
+
<KanbanColumn
|
|
237
|
+
title="Pending"
|
|
238
|
+
count={pendingReports.length}
|
|
239
|
+
color="border-yellow-500/50"
|
|
240
|
+
headerColor="text-yellow-500"
|
|
241
|
+
>
|
|
242
|
+
{pendingReports.map(report => (
|
|
243
|
+
<ReportCard
|
|
244
|
+
key={report.id}
|
|
245
|
+
report={report}
|
|
246
|
+
onView={() => handleView(report.report_data, report.id)}
|
|
247
|
+
onApprove={() => openApproval(report.id, report.report_data.patient.name)}
|
|
248
|
+
onReject={() => openRejection(report.id, report.report_data.patient.name)}
|
|
249
|
+
processing={processingId === report.id}
|
|
250
|
+
/>
|
|
251
|
+
))}
|
|
252
|
+
</KanbanColumn>
|
|
253
|
+
|
|
254
|
+
{/* Approved Column */}
|
|
255
|
+
<KanbanColumn
|
|
256
|
+
title="Approved"
|
|
257
|
+
count={approvedReports.length}
|
|
258
|
+
color="border-green-500/50"
|
|
259
|
+
headerColor="text-green-500"
|
|
260
|
+
>
|
|
261
|
+
{approvedReports.map(report => (
|
|
262
|
+
<ReportCard
|
|
263
|
+
key={report.id}
|
|
264
|
+
report={report}
|
|
265
|
+
onView={() => handleView(report.report_data, report.id)}
|
|
266
|
+
status="approved"
|
|
267
|
+
/>
|
|
268
|
+
))}
|
|
269
|
+
</KanbanColumn>
|
|
270
|
+
|
|
271
|
+
{/* Rejected Column */}
|
|
272
|
+
<KanbanColumn
|
|
273
|
+
title="Rejected"
|
|
274
|
+
count={rejectedReports.length}
|
|
275
|
+
color="border-red-500/50"
|
|
276
|
+
headerColor="text-red-500"
|
|
277
|
+
>
|
|
278
|
+
{rejectedReports.map(report => (
|
|
279
|
+
<ReportCard
|
|
280
|
+
key={report.id}
|
|
281
|
+
report={report}
|
|
282
|
+
onView={() => handleView(report.report_data, report.id)}
|
|
283
|
+
status="rejected"
|
|
284
|
+
/>
|
|
285
|
+
))}
|
|
286
|
+
</KanbanColumn>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Modals */}
|
|
291
|
+
<ApprovalModal
|
|
292
|
+
isOpen={showApproval}
|
|
293
|
+
onClose={() => {
|
|
294
|
+
setShowApproval(false);
|
|
295
|
+
setActionReport(null);
|
|
296
|
+
}}
|
|
297
|
+
onConfirm={handleApprovalConfirm}
|
|
298
|
+
currentUser={currentUser}
|
|
299
|
+
/>
|
|
300
|
+
<RejectionModal
|
|
301
|
+
isOpen={showRejection}
|
|
302
|
+
onClose={() => {
|
|
303
|
+
setShowRejection(false);
|
|
304
|
+
setActionReport(null);
|
|
305
|
+
}}
|
|
306
|
+
onConfirm={handleRejectionConfirm}
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default function ReportsPage() {
|
|
313
|
+
return (
|
|
314
|
+
<Suspense fallback={<div className="h-full flex items-center justify-center p-8">Loading Reports...</div>}>
|
|
315
|
+
<ReportsContent />
|
|
316
|
+
</Suspense>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function KanbanColumn({ title, count, children, color, headerColor }: { title: string, count: number, children: React.ReactNode, color: string, headerColor: string }) {
|
|
321
|
+
return (
|
|
322
|
+
<div className={`flex-1 flex flex-col bg-bg-surface rounded-lg border ${color} h-full overflow-hidden`}>
|
|
323
|
+
<div className={`p-4 font-semibold flex justify-between items-center border-b border-border-primary bg-bg-panel/50 ${headerColor}`}>
|
|
324
|
+
<span>{title}</span>
|
|
325
|
+
<Badge variant="secondary" className="bg-bg-primary text-text-primary">{count}</Badge>
|
|
326
|
+
</div>
|
|
327
|
+
<div className="p-4 flex-1 overflow-y-auto space-y-4">
|
|
328
|
+
{count === 0 ? (
|
|
329
|
+
<div className="text-center text-text-muted text-sm py-8 opacity-50">No reports</div>
|
|
330
|
+
) : children}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function ReportCard({ report, onView, onApprove, onReject, processing, status }: {
|
|
337
|
+
report: any,
|
|
338
|
+
onView: () => void,
|
|
339
|
+
onApprove?: () => void,
|
|
340
|
+
onReject?: () => void,
|
|
341
|
+
processing?: boolean,
|
|
342
|
+
status?: 'approved' | 'rejected'
|
|
343
|
+
}) {
|
|
344
|
+
const data = report.report_data;
|
|
345
|
+
return (
|
|
346
|
+
<Card className="bg-bg-panel border-border-primary hover:border-text-muted/50 transition-all duration-200 hover:shadow-lg">
|
|
347
|
+
<CardContent className="p-4 space-y-3">
|
|
348
|
+
<div className="flex justify-between items-start">
|
|
349
|
+
<div>
|
|
350
|
+
<p className="font-medium text-text-heading">{data.patient?.name || report.patient_name || 'Unknown Patient'}</p>
|
|
351
|
+
<p className="text-xs text-text-secondary">{data.patient.age}y • {data.patient.gender}</p>
|
|
352
|
+
</div>
|
|
353
|
+
<Badge variant="outline" className={`text-[10px] ${data.urgency === 'Critical' ? 'border-red-500 text-red-500' :
|
|
354
|
+
data.urgency === 'Urgent' ? 'border-yellow-500 text-yellow-500' :
|
|
355
|
+
'border-green-500 text-green-500'
|
|
356
|
+
}`}>
|
|
357
|
+
{data.urgency}
|
|
358
|
+
</Badge>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div className="flex flex-col gap-1 mt-2">
|
|
362
|
+
<p className="text-xs font-medium text-text-primary">{data.study.modality}</p>
|
|
363
|
+
<div className="flex justify-between items-center text-[10px] text-text-muted">
|
|
364
|
+
<div className="flex items-center gap-1">
|
|
365
|
+
<Calendar size={12} />
|
|
366
|
+
<span>{new Date(report.created_at).toLocaleDateString()}</span>
|
|
367
|
+
</div>
|
|
368
|
+
<div className="flex items-center gap-1">
|
|
369
|
+
<Clock size={12} />
|
|
370
|
+
<span>{new Date(report.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div className="pt-2 flex gap-2 justify-end flex-wrap">
|
|
376
|
+
{/* View Button - Always visible */}
|
|
377
|
+
<Button
|
|
378
|
+
size="sm"
|
|
379
|
+
variant="outline"
|
|
380
|
+
className="h-8 px-3 gap-2 text-xs hover:bg-bg-hover transition-all duration-200 hover:scale-105 active:scale-95"
|
|
381
|
+
onClick={onView}
|
|
382
|
+
>
|
|
383
|
+
<Eye size={14} />
|
|
384
|
+
<span>View</span>
|
|
385
|
+
</Button>
|
|
386
|
+
|
|
387
|
+
{/* Approve Button - Only for pending reports */}
|
|
388
|
+
{onApprove && (
|
|
389
|
+
<Button
|
|
390
|
+
size="sm"
|
|
391
|
+
variant="outline"
|
|
392
|
+
className="h-8 px-3 gap-2 text-xs text-green-500 border-green-500/50 hover:bg-green-950/20 hover:border-green-500 transition-all duration-200 hover:scale-105 active:scale-95"
|
|
393
|
+
onClick={onApprove}
|
|
394
|
+
disabled={processing}
|
|
395
|
+
>
|
|
396
|
+
<Check size={14} />
|
|
397
|
+
<span>Approve</span>
|
|
398
|
+
</Button>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
{/* Reject Button - Only for pending reports */}
|
|
402
|
+
{onReject && (
|
|
403
|
+
<Button
|
|
404
|
+
size="sm"
|
|
405
|
+
variant="outline"
|
|
406
|
+
className="h-8 px-3 gap-2 text-xs text-red-500 border-red-500/50 hover:bg-red-950/20 hover:border-red-500 transition-all duration-200 hover:scale-105 active:scale-95"
|
|
407
|
+
onClick={onReject}
|
|
408
|
+
disabled={processing}
|
|
409
|
+
>
|
|
410
|
+
<X size={14} />
|
|
411
|
+
<span>Reject</span>
|
|
412
|
+
</Button>
|
|
413
|
+
)}
|
|
414
|
+
|
|
415
|
+
{/* Status indicators for approved/rejected */}
|
|
416
|
+
{status === 'approved' && (
|
|
417
|
+
<div className="flex items-center gap-1 text-green-500 text-xs px-2 py-1 bg-green-950/20 rounded-md border border-green-500/50">
|
|
418
|
+
<Check size={14} />
|
|
419
|
+
<span>Approved</span>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
{status === 'rejected' && (
|
|
423
|
+
<div className="flex items-center gap-1 text-red-500 text-xs px-2 py-1 bg-red-950/20 rounded-md border border-red-500/50">
|
|
424
|
+
<X size={14} />
|
|
425
|
+
<span>Rejected</span>
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
</div>
|
|
429
|
+
</CardContent>
|
|
430
|
+
</Card>
|
|
431
|
+
)
|
|
432
|
+
}
|