@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,119 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import { FileText, LayoutDashboard, Settings, User, Users, Clock, LogOut, Server, MessageSquare } from 'lucide-react';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
7
|
+
|
|
8
|
+
export function Sidebar() {
|
|
9
|
+
const pathname = usePathname();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<aside className="fixed left-0 top-0 h-screen w-20 bg-bg-surface border-r border-border-primary flex flex-col items-center py-6 z-50 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.1)]">
|
|
14
|
+
<div className="mb-8 relative w-16 h-16">
|
|
15
|
+
<Image
|
|
16
|
+
src="/logo.svg"
|
|
17
|
+
alt="OmniRad Logo"
|
|
18
|
+
fill
|
|
19
|
+
className="object-contain"
|
|
20
|
+
priority
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<nav className="flex-1 flex flex-col gap-3 w-full px-3">
|
|
25
|
+
<NavItem
|
|
26
|
+
href="/"
|
|
27
|
+
icon={LayoutDashboard}
|
|
28
|
+
label="Dashboard"
|
|
29
|
+
isActive={pathname === '/'}
|
|
30
|
+
/>
|
|
31
|
+
<NavItem
|
|
32
|
+
href="/patients"
|
|
33
|
+
icon={Users}
|
|
34
|
+
label="Patients"
|
|
35
|
+
isActive={pathname.startsWith('/patients')}
|
|
36
|
+
/>
|
|
37
|
+
<NavItem
|
|
38
|
+
href="/reports"
|
|
39
|
+
icon={FileText}
|
|
40
|
+
label="Reports"
|
|
41
|
+
isActive={pathname.startsWith('/reports')}
|
|
42
|
+
/>
|
|
43
|
+
<NavItem
|
|
44
|
+
href="/history"
|
|
45
|
+
icon={Clock}
|
|
46
|
+
label="History"
|
|
47
|
+
isActive={pathname.startsWith('/history')}
|
|
48
|
+
/>
|
|
49
|
+
<NavItem
|
|
50
|
+
href="/pacs"
|
|
51
|
+
icon={Server}
|
|
52
|
+
label="PACS Browser"
|
|
53
|
+
isActive={pathname.startsWith('/pacs')}
|
|
54
|
+
/>
|
|
55
|
+
<NavItem
|
|
56
|
+
href="/copilot"
|
|
57
|
+
icon={MessageSquare}
|
|
58
|
+
label="AI Copilot"
|
|
59
|
+
isActive={pathname.startsWith('/copilot')}
|
|
60
|
+
/>
|
|
61
|
+
<div className="flex-1" /> {/* Spacer */}
|
|
62
|
+
<NavItem
|
|
63
|
+
href="/settings"
|
|
64
|
+
icon={Settings}
|
|
65
|
+
label="Settings"
|
|
66
|
+
isActive={pathname.startsWith('/settings')}
|
|
67
|
+
/>
|
|
68
|
+
<NavItem
|
|
69
|
+
href="/profile"
|
|
70
|
+
icon={User}
|
|
71
|
+
label="Profile"
|
|
72
|
+
isActive={pathname.startsWith('/profile')}
|
|
73
|
+
/>
|
|
74
|
+
</nav>
|
|
75
|
+
|
|
76
|
+
<div className="mt-4 pb-4 px-3 w-full">
|
|
77
|
+
<button
|
|
78
|
+
onClick={async () => {
|
|
79
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
80
|
+
router.push('/login');
|
|
81
|
+
}}
|
|
82
|
+
className="relative group flex w-full items-center justify-center p-3 rounded-xl transition-all duration-300 ease-out text-text-muted hover:bg-red-500/10 hover:text-red-500 hover:scale-105"
|
|
83
|
+
title="Logout"
|
|
84
|
+
>
|
|
85
|
+
<LogOut size={22} className="transition-transform duration-300 group-hover:stroke-[2.5px]" />
|
|
86
|
+
<span className="absolute left-16 bg-slate-900 text-white text-xs font-medium px-3 py-1.5 rounded-lg opacity-0 translate-x-[-10px] group-hover:opacity-100 group-hover:translate-x-0 pointer-events-none transition-all duration-200 shadow-xl z-50 whitespace-nowrap">
|
|
87
|
+
Logout
|
|
88
|
+
<span className="absolute left-[-4px] top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-900 rotate-45"></span>
|
|
89
|
+
</span>
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</aside>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function NavItem({ href, icon: Icon, label, isActive }: { href: string; icon: any; label: string; isActive?: boolean }) {
|
|
97
|
+
return (
|
|
98
|
+
<Link
|
|
99
|
+
href={href}
|
|
100
|
+
className={`
|
|
101
|
+
relative group flex items-center justify-center p-3 rounded-xl transition-all duration-300 ease-out
|
|
102
|
+
${isActive
|
|
103
|
+
? 'bg-primary text-white shadow-md shadow-primary/25 scale-105'
|
|
104
|
+
: 'text-text-muted hover:bg-primary/10 hover:text-primary hover:scale-105'
|
|
105
|
+
}
|
|
106
|
+
`}
|
|
107
|
+
title={label}
|
|
108
|
+
>
|
|
109
|
+
<Icon size={22} className={`transition-transform duration-300 ${isActive ? 'stroke-[2.5px]' : 'group-hover:stroke-[2.5px]'}`} />
|
|
110
|
+
|
|
111
|
+
{/* Tooltip Label */}
|
|
112
|
+
<span className="absolute left-16 bg-slate-900 text-white text-xs font-medium px-3 py-1.5 rounded-lg opacity-0 translate-x-[-10px] group-hover:opacity-100 group-hover:translate-x-0 pointer-events-none transition-all duration-200 shadow-xl z-50 whitespace-nowrap">
|
|
113
|
+
{label}
|
|
114
|
+
{/* Little arrow pointing left */}
|
|
115
|
+
<span className="absolute left-[-4px] top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-900 rotate-45"></span>
|
|
116
|
+
</span>
|
|
117
|
+
</Link>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { ImageViewer } from "@/components/dashboard/ImageViewer";
|
|
4
|
+
import { Loader2, X } from "lucide-react";
|
|
5
|
+
import { fetchRenderedJpegUrl } from "@/lib/pacs/dicomweb";
|
|
6
|
+
|
|
7
|
+
interface PacsImageViewerModalProps {
|
|
8
|
+
studyUid: string;
|
|
9
|
+
seriesUid: string;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PacsImageViewerModal({ studyUid, seriesUid, onClose }: PacsImageViewerModalProps) {
|
|
14
|
+
const [images, setImages] = useState<string[]>([]);
|
|
15
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let isMounted = true;
|
|
20
|
+
const loadImages = async () => {
|
|
21
|
+
setIsLoading(true);
|
|
22
|
+
try {
|
|
23
|
+
// Fetch instances for this series
|
|
24
|
+
const instancesRes = await fetch(`/api/pacs/qido/instances?studyUid=${studyUid}&seriesUid=${seriesUid}`);
|
|
25
|
+
if (!instancesRes.ok) throw new Error("Failed to fetch instances");
|
|
26
|
+
const instancesData = await instancesRes.json();
|
|
27
|
+
|
|
28
|
+
if (instancesData && instancesData.length > 0) {
|
|
29
|
+
// Build a list of all (instanceUid, frameNumber) pairs
|
|
30
|
+
// Multi-frame DICOM files have NumberOfFrames (0028,0008) > 1
|
|
31
|
+
const frameRequests: { uid: string; frame?: number }[] = [];
|
|
32
|
+
|
|
33
|
+
for (const inst of instancesData) {
|
|
34
|
+
const uid = inst["00080018"]?.Value?.[0];
|
|
35
|
+
if (!uid) continue;
|
|
36
|
+
|
|
37
|
+
const numFrames = parseInt(inst["00280008"]?.Value?.[0] || "1", 10);
|
|
38
|
+
|
|
39
|
+
if (numFrames > 1) {
|
|
40
|
+
// Multi-frame instance: fetch each frame individually (1-indexed)
|
|
41
|
+
for (let f = 1; f <= numFrames; f++) {
|
|
42
|
+
frameRequests.push({ uid, frame: f });
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
// Single-frame instance
|
|
46
|
+
frameRequests.push({ uid });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fetch rendered JPEGs for all frames
|
|
51
|
+
const urls = await Promise.all(frameRequests.map(async (req) => {
|
|
52
|
+
try {
|
|
53
|
+
return await fetchRenderedJpegUrl(studyUid, seriesUid, req.uid, req.frame);
|
|
54
|
+
} catch(e) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
if (isMounted) {
|
|
60
|
+
setImages(urls.filter(Boolean) as string[]);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
if (isMounted) setError("No images found in this series.");
|
|
64
|
+
}
|
|
65
|
+
} catch (err: any) {
|
|
66
|
+
if (isMounted) setError(err.message || "Failed to load sequence.");
|
|
67
|
+
} finally {
|
|
68
|
+
if (isMounted) setIsLoading(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
loadImages();
|
|
72
|
+
|
|
73
|
+
return () => { isMounted = false; };
|
|
74
|
+
}, [studyUid, seriesUid]);
|
|
75
|
+
|
|
76
|
+
// Force ESC key to close modal entirely
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const onKey = (e: KeyboardEvent) => {
|
|
79
|
+
if (e.key === 'Escape') onClose();
|
|
80
|
+
};
|
|
81
|
+
window.addEventListener('keydown', onKey);
|
|
82
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
83
|
+
}, [onClose]);
|
|
84
|
+
|
|
85
|
+
if (isLoading) {
|
|
86
|
+
return createPortal(
|
|
87
|
+
<div className="fixed inset-0 z-[9999] bg-black flex flex-col items-center justify-center">
|
|
88
|
+
<Loader2 className="w-10 h-10 animate-spin mb-4 text-primary" />
|
|
89
|
+
<p>Loading frames from PACS...</p>
|
|
90
|
+
</div>,
|
|
91
|
+
document.body
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (error) {
|
|
96
|
+
return createPortal(
|
|
97
|
+
<div className="fixed inset-0 z-[9999] bg-black flex flex-col items-center justify-center">
|
|
98
|
+
<div className="text-red-500 text-center">
|
|
99
|
+
<p className="mb-4">{error}</p>
|
|
100
|
+
<button onClick={onClose} className="px-4 py-2 border border-red-500/50 rounded hover:bg-red-500/10 text-white">Back</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>,
|
|
103
|
+
document.body
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (images.length > 0) {
|
|
108
|
+
return (
|
|
109
|
+
<ImageViewer
|
|
110
|
+
images={images}
|
|
111
|
+
isCollapsed={false}
|
|
112
|
+
onToggleCollapse={() => {}}
|
|
113
|
+
forceFullscreen={true}
|
|
114
|
+
onCloseFullscreen={onClose}
|
|
115
|
+
className="hidden" // hide the inline embedded element, only show the fullscreen portal
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Input, Label, Button } from "@/components/ui/basic";
|
|
3
|
+
import { Search, RotateCcw } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface PacsSearchFiltersProps {
|
|
6
|
+
onSearch: (filters: Record<string, string>) => void;
|
|
7
|
+
isLoading?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function PacsSearchFilters({ onSearch, isLoading = false }: PacsSearchFiltersProps) {
|
|
11
|
+
const [filters, setFilters] = useState<Record<string, string>>({
|
|
12
|
+
PatientName: "",
|
|
13
|
+
PatientID: "",
|
|
14
|
+
StudyDate: "",
|
|
15
|
+
Modality: ""
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
19
|
+
const { name, value } = e.target;
|
|
20
|
+
setFilters(prev => ({ ...prev, [name]: value }));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const handleApply = () => {
|
|
24
|
+
// Strip out empty filters before sending request
|
|
25
|
+
const appliedFilters: Record<string, string> = {};
|
|
26
|
+
Object.entries(filters).forEach(([key, val]) => {
|
|
27
|
+
if (val.trim() !== "") {
|
|
28
|
+
appliedFilters[key] = val;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
onSearch(appliedFilters);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleClear = () => {
|
|
35
|
+
setFilters({ PatientName: "", PatientID: "", StudyDate: "", Modality: "" });
|
|
36
|
+
onSearch({});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-6">
|
|
41
|
+
<div>
|
|
42
|
+
<Label htmlFor="PatientName">Patient Name</Label>
|
|
43
|
+
<Input
|
|
44
|
+
type="text"
|
|
45
|
+
id="PatientName"
|
|
46
|
+
name="PatientName"
|
|
47
|
+
placeholder="e.g. *Doe*"
|
|
48
|
+
value={filters.PatientName}
|
|
49
|
+
onChange={handleChange}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<Label htmlFor="PatientID">Patient ID</Label>
|
|
55
|
+
<Input
|
|
56
|
+
type="text"
|
|
57
|
+
id="PatientID"
|
|
58
|
+
name="PatientID"
|
|
59
|
+
placeholder="12345"
|
|
60
|
+
value={filters.PatientID}
|
|
61
|
+
onChange={handleChange}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div>
|
|
66
|
+
<Label htmlFor="StudyDate">Study Date</Label>
|
|
67
|
+
<Input
|
|
68
|
+
type="text"
|
|
69
|
+
id="StudyDate"
|
|
70
|
+
name="StudyDate"
|
|
71
|
+
placeholder="YYYYMMDD (or empty)"
|
|
72
|
+
value={filters.StudyDate}
|
|
73
|
+
onChange={handleChange}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div>
|
|
78
|
+
<Label htmlFor="Modality">Modality</Label>
|
|
79
|
+
<select
|
|
80
|
+
id="Modality"
|
|
81
|
+
name="Modality"
|
|
82
|
+
value={filters.Modality}
|
|
83
|
+
onChange={handleChange}
|
|
84
|
+
className="flex h-10 w-full rounded-md border border-border-primary bg-bg-panel px-3 py-2 text-sm text-text-primary mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
85
|
+
>
|
|
86
|
+
<option value="">Any</option>
|
|
87
|
+
<option value="CR">CR - Computed Radiography</option>
|
|
88
|
+
<option value="CT">CT - Computed Tomography</option>
|
|
89
|
+
<option value="MR">MR - Magnetic Resonance</option>
|
|
90
|
+
<option value="US">US - Ultrasound</option>
|
|
91
|
+
<option value="DX">DX - Digital Radiography</option>
|
|
92
|
+
<option value="PX">PX - Panoramic X-Ray</option>
|
|
93
|
+
</select>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="flex gap-3 pt-4 border-t border-border-primary">
|
|
97
|
+
<Button
|
|
98
|
+
variant="outline"
|
|
99
|
+
className="flex-1"
|
|
100
|
+
onClick={handleClear}
|
|
101
|
+
disabled={isLoading}
|
|
102
|
+
>
|
|
103
|
+
<RotateCcw className="w-4 h-4 mr-2" />
|
|
104
|
+
Reset
|
|
105
|
+
</Button>
|
|
106
|
+
<Button
|
|
107
|
+
className="flex-1"
|
|
108
|
+
onClick={handleApply}
|
|
109
|
+
disabled={isLoading}
|
|
110
|
+
>
|
|
111
|
+
<Search className="w-4 h-4 mr-2" />
|
|
112
|
+
{isLoading ? "Searching..." : "Search"}
|
|
113
|
+
</Button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { searchSeries, fetchRenderedJpegUrl, fetchStudyMetadata } from "@/lib/pacs/dicomweb";
|
|
3
|
+
import { DicomSeries, DicomStudy } from "@/types/pacs";
|
|
4
|
+
import { Loader2, Send, Eye } from "lucide-react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { Button } from "@/components/ui/basic";
|
|
7
|
+
import { PacsImageViewerModal } from "./PacsImageViewerModal";
|
|
8
|
+
|
|
9
|
+
interface PacsSeriesViewerProps {
|
|
10
|
+
study: DicomStudy;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PacsSeriesViewer({ study }: PacsSeriesViewerProps) {
|
|
14
|
+
const [series, setSeries] = useState<DicomSeries[]>([]);
|
|
15
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
16
|
+
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
|
17
|
+
const [seriesImageCounts, setSeriesImageCounts] = useState<Record<string, number>>({});
|
|
18
|
+
const [isSending, setIsSending] = useState(false);
|
|
19
|
+
const [viewingSeriesUid, setViewingSeriesUid] = useState<string | null>(null);
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let isMounted = true;
|
|
24
|
+
const loadSeries = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const data = await searchSeries(study.studyInstanceUid);
|
|
27
|
+
if (isMounted) setSeries(data);
|
|
28
|
+
|
|
29
|
+
// Fetch a thumbnail for each series
|
|
30
|
+
data.forEach(async (s) => {
|
|
31
|
+
if (s.numberOfSeriesRelatedInstances > 0) {
|
|
32
|
+
try {
|
|
33
|
+
// First get the real instance UIDs
|
|
34
|
+
const instancesRes = await fetch(`/api/pacs/qido/instances?studyUid=${study.studyInstanceUid}&seriesUid=${s.seriesInstanceUid}`);
|
|
35
|
+
const instancesData = await instancesRes.json();
|
|
36
|
+
|
|
37
|
+
let firstInstanceUid = "";
|
|
38
|
+
if (instancesData && instancesData.length > 0) {
|
|
39
|
+
firstInstanceUid = instancesData[0]["00080018"]?.Value?.[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (firstInstanceUid) {
|
|
43
|
+
const url = await fetchRenderedJpegUrl(study.studyInstanceUid, s.seriesInstanceUid, firstInstanceUid);
|
|
44
|
+
|
|
45
|
+
// Calculate total frames across all instances
|
|
46
|
+
// Multi-frame DICOM files have NumberOfFrames (0028,0008) > 1
|
|
47
|
+
let totalFrames = 0;
|
|
48
|
+
for (const inst of instancesData) {
|
|
49
|
+
const numFrames = parseInt(inst["00280008"]?.Value?.[0] || "1", 10);
|
|
50
|
+
totalFrames += numFrames;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (isMounted) {
|
|
54
|
+
setThumbnails(prev => ({ ...prev, [s.seriesInstanceUid]: url }));
|
|
55
|
+
setSeriesImageCounts(prev => ({ ...prev, [s.seriesInstanceUid]: totalFrames }));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error("Failed to load thumbnail for series", s.seriesInstanceUid);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error("Failed to load series for study", study.studyInstanceUid);
|
|
65
|
+
} finally {
|
|
66
|
+
if (isMounted) setIsLoading(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
loadSeries();
|
|
70
|
+
return () => { isMounted = false; };
|
|
71
|
+
}, [study.studyInstanceUid]);
|
|
72
|
+
|
|
73
|
+
const handleSendToOmniRad = async (selectedSeries: DicomSeries) => {
|
|
74
|
+
setIsSending(true);
|
|
75
|
+
try {
|
|
76
|
+
// Wait, we need the exact first instance UID of the series to fetch it for the report
|
|
77
|
+
// So we fetch /instances for this series
|
|
78
|
+
const instancesRes = await fetch(`/api/pacs/qido/instances?studyUid=${study.studyInstanceUid}&seriesUid=${selectedSeries.seriesInstanceUid}`);
|
|
79
|
+
const instancesData = await instancesRes.json();
|
|
80
|
+
|
|
81
|
+
let firstInstanceUid = "";
|
|
82
|
+
if (instancesData && instancesData.length > 0) {
|
|
83
|
+
firstInstanceUid = instancesData[0]["00080018"]?.Value?.[0]; // SOPInstanceUID
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!firstInstanceUid) throw new Error("Could not find any instances in this series.");
|
|
87
|
+
|
|
88
|
+
// Calculate age safely
|
|
89
|
+
let ageNum = 0;
|
|
90
|
+
if (study.patientAge) {
|
|
91
|
+
const parsed = parseInt(study.patientAge.replace(/\D/g, ''), 10);
|
|
92
|
+
if (!isNaN(parsed)) ageNum = parsed;
|
|
93
|
+
} else if (study.patientBirthDate && study.patientBirthDate.length >= 8) {
|
|
94
|
+
const year = parseInt(study.patientBirthDate.substring(0, 4), 10);
|
|
95
|
+
if (!isNaN(year)) ageNum = new Date().getFullYear() - year;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Combine descriptions for indication (matching exact behavior of local DICOM parser)
|
|
99
|
+
const descParts = [];
|
|
100
|
+
if (study.studyDescription && study.studyDescription !== "No Description") descParts.push(study.studyDescription);
|
|
101
|
+
if (selectedSeries.seriesDescription && selectedSeries.seriesDescription !== "No Description") descParts.push(selectedSeries.seriesDescription);
|
|
102
|
+
|
|
103
|
+
// Get metadata
|
|
104
|
+
const metadata = {
|
|
105
|
+
patientName: study.patientName,
|
|
106
|
+
patientId: study.patientId,
|
|
107
|
+
modality: selectedSeries.modality,
|
|
108
|
+
studyDate: study.studyDate,
|
|
109
|
+
indication: descParts.join(' - '),
|
|
110
|
+
age: ageNum,
|
|
111
|
+
gender: study.patientSex === 'F' ? 'F' : 'M',
|
|
112
|
+
pacsStudyUid: study.studyInstanceUid,
|
|
113
|
+
pacsSeriesUid: selectedSeries.seriesInstanceUid,
|
|
114
|
+
firstInstanceUid: firstInstanceUid,
|
|
115
|
+
pacsSource: "Orthanc"
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Store this temporarily in localStorage so the report generation form can pick it up
|
|
119
|
+
localStorage.setItem("omnirad_pending_pacs_import", JSON.stringify(metadata));
|
|
120
|
+
|
|
121
|
+
// Navigate to Dashboard Generate Report
|
|
122
|
+
router.push('/?source=pacs');
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
alert(`Error preparing DICOM study: ${e.message}`);
|
|
125
|
+
} finally {
|
|
126
|
+
setIsSending(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (isLoading) {
|
|
131
|
+
return <div className="p-8 flex items-center justify-center text-text-muted"><Loader2 className="animate-spin w-5 h-5 mr-2"/> Loading series...</div>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (series.length === 0) {
|
|
135
|
+
return <div className="p-8 text-center text-text-muted">No series found for this study.</div>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="p-4 bg-bg-surface border-t border-border-primary rounded-b-lg">
|
|
140
|
+
<h4 className="text-sm font-semibold mb-3 text-text-heading border-b border-border-primary pb-2">Series List</h4>
|
|
141
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
142
|
+
{series.map(s => (
|
|
143
|
+
<div key={s.seriesInstanceUid} className="border border-border-card bg-bg-panel rounded-lg overflow-hidden flex flex-col group relative">
|
|
144
|
+
<div className="aspect-square bg-slate-900 flex items-center justify-center overflow-hidden">
|
|
145
|
+
{thumbnails[s.seriesInstanceUid] ? (
|
|
146
|
+
<img src={thumbnails[s.seriesInstanceUid]} alt="Series Thumbnail" className="w-full h-full object-contain" />
|
|
147
|
+
) : (
|
|
148
|
+
<span className="text-xs text-slate-500">No Img preview</span>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
<div className="p-3">
|
|
152
|
+
<div className="font-semibold text-sm truncate" title={s.seriesDescription}>{s.seriesDescription}</div>
|
|
153
|
+
<div className="text-xs text-text-muted mt-1">Modality: {s.modality}</div>
|
|
154
|
+
<div className="text-xs text-text-muted">Images: {seriesImageCounts[s.seriesInstanceUid] || s.numberOfSeriesRelatedInstances}</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Hover Overlay */}
|
|
158
|
+
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center p-4 gap-3">
|
|
159
|
+
<Button
|
|
160
|
+
size="sm"
|
|
161
|
+
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white gap-2 border-none shadow-lg"
|
|
162
|
+
onClick={(e) => { e.stopPropagation(); setViewingSeriesUid(s.seriesInstanceUid); }}
|
|
163
|
+
disabled={isSending}
|
|
164
|
+
>
|
|
165
|
+
<Eye size={14} /> View Slices
|
|
166
|
+
</Button>
|
|
167
|
+
<Button
|
|
168
|
+
size="sm"
|
|
169
|
+
className="w-full bg-primary-main hover:bg-primary-hover text-white gap-2 border-none shadow-lg"
|
|
170
|
+
onClick={(e) => { e.stopPropagation(); handleSendToOmniRad(s); }}
|
|
171
|
+
disabled={isSending}
|
|
172
|
+
>
|
|
173
|
+
<Send size={14} /> Send to AI
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Fullscreen Viewer Modal */}
|
|
181
|
+
{viewingSeriesUid && (
|
|
182
|
+
<PacsImageViewerModal
|
|
183
|
+
studyUid={study.studyInstanceUid}
|
|
184
|
+
seriesUid={viewingSeriesUid}
|
|
185
|
+
onClose={() => setViewingSeriesUid(null)}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { DicomStudy } from "@/types/pacs";
|
|
3
|
+
import { ChevronDown, ChevronRight, Loader2, Hospital } from "lucide-react";
|
|
4
|
+
import { PacsSeriesViewer } from "./PacsSeriesViewer";
|
|
5
|
+
|
|
6
|
+
interface PacsStudyTableProps {
|
|
7
|
+
studies: DicomStudy[];
|
|
8
|
+
isLoading: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function PacsStudyTable({ studies, isLoading }: PacsStudyTableProps) {
|
|
12
|
+
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
|
|
13
|
+
|
|
14
|
+
const toggleRow = (uid: string) => {
|
|
15
|
+
setExpandedRows(prev => ({
|
|
16
|
+
...prev,
|
|
17
|
+
[uid]: !prev[uid]
|
|
18
|
+
}));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (isLoading) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex flex-col items-center justify-center py-20 bg-bg-surface/50 rounded-2xl border border-border-primary">
|
|
24
|
+
<Loader2 className="w-10 h-10 text-primary-main animate-spin" />
|
|
25
|
+
<p className="mt-4 text-text-secondary font-medium">Querying PACS Server...</p>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (studies.length === 0) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex flex-col items-center justify-center py-20 bg-bg-surface/50 rounded-2xl border border-border-primary text-center px-6">
|
|
33
|
+
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-white/5 flex items-center justify-center mb-4">
|
|
34
|
+
<Hospital className="w-8 h-8 text-slate-400 dark:text-slate-500" />
|
|
35
|
+
</div>
|
|
36
|
+
<h3 className="text-xl font-bold text-text-heading mb-2">No Studies Found</h3>
|
|
37
|
+
<p className="text-text-secondary max-w-sm">
|
|
38
|
+
Enter search criteria on the left to query the PACS server, or ensure your DICOMweb configuration is correct.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="rounded-xl border border-border-primary bg-bg-surface overflow-hidden shadow-sm">
|
|
46
|
+
<div className="overflow-x-auto">
|
|
47
|
+
<table className="w-full text-sm text-left">
|
|
48
|
+
<thead className="text-xs uppercase bg-bg-panel text-text-secondary border-b border-border-primary">
|
|
49
|
+
<tr>
|
|
50
|
+
<th className="px-4 py-3 w-10"></th>
|
|
51
|
+
<th className="px-4 py-3 font-semibold">Patient Name</th>
|
|
52
|
+
<th className="px-4 py-3 font-semibold">Patient ID</th>
|
|
53
|
+
<th className="px-4 py-3 font-semibold">Study Date</th>
|
|
54
|
+
<th className="px-4 py-3 font-semibold">Modality</th>
|
|
55
|
+
<th className="px-4 py-3 font-semibold">Description</th>
|
|
56
|
+
<th className="px-4 py-3 font-semibold whitespace-nowrap text-right">Series / Instances</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody className="divide-y divide-border-card">
|
|
60
|
+
{studies.map((study) => (
|
|
61
|
+
<React.Fragment key={study.studyInstanceUid}>
|
|
62
|
+
<tr
|
|
63
|
+
className={`hover:bg-bg-panel/50 transition-colors cursor-pointer ${expandedRows[study.studyInstanceUid] ? 'bg-primary-main/5' : ''}`}
|
|
64
|
+
onClick={() => toggleRow(study.studyInstanceUid)}
|
|
65
|
+
>
|
|
66
|
+
<td className="px-4 py-4 text-center">
|
|
67
|
+
<button className="text-text-muted hover:text-text-primary p-1 rounded-md transition-colors">
|
|
68
|
+
{expandedRows[study.studyInstanceUid] ? (
|
|
69
|
+
<ChevronDown size={18} />
|
|
70
|
+
) : (
|
|
71
|
+
<ChevronRight size={18} />
|
|
72
|
+
)}
|
|
73
|
+
</button>
|
|
74
|
+
</td>
|
|
75
|
+
<td className="px-4 py-4 font-medium text-text-primary">
|
|
76
|
+
{study.patientName}
|
|
77
|
+
</td>
|
|
78
|
+
<td className="px-4 py-4 text-text-secondary">
|
|
79
|
+
{study.patientId}
|
|
80
|
+
</td>
|
|
81
|
+
<td className="px-4 py-4 text-text-secondary">
|
|
82
|
+
{study.studyDate}
|
|
83
|
+
</td>
|
|
84
|
+
<td className="px-4 py-4">
|
|
85
|
+
<div className="inline-flex items-center px-2 py-0.5 rounded-full bg-slate-100 dark:bg-white/10 text-text-primary text-xs font-medium border border-border-card">
|
|
86
|
+
{study.modalitiesInStudy.join(", ") || "-"}
|
|
87
|
+
</div>
|
|
88
|
+
</td>
|
|
89
|
+
<td className="px-4 py-4 text-text-secondary truncate max-w-[200px]" title={study.studyDescription}>
|
|
90
|
+
{study.studyDescription}
|
|
91
|
+
</td>
|
|
92
|
+
<td className="px-4 py-4 text-right text-text-secondary">
|
|
93
|
+
<span className="font-medium text-text-primary">{study.numberOfStudyRelatedSeries}</span> / {study.numberOfStudyRelatedInstances}
|
|
94
|
+
</td>
|
|
95
|
+
</tr>
|
|
96
|
+
|
|
97
|
+
{expandedRows[study.studyInstanceUid] && (
|
|
98
|
+
<tr>
|
|
99
|
+
<td colSpan={7} className="p-0 border-b border-border-primary bg-slate-50 dark:bg-black/20">
|
|
100
|
+
<div className="animate-in fade-in slide-in-from-top-2 duration-200">
|
|
101
|
+
<PacsSeriesViewer study={study} />
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
)}
|
|
106
|
+
</React.Fragment>
|
|
107
|
+
))}
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|