@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,117 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { User, Calendar, FileText, Activity } from "lucide-react";
|
|
3
|
+
import { Patient } from "@/types";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
|
|
6
|
+
export function PatientCard({ patient, reportCount = 0, lastVisitDate, latestStatus }: { patient: Patient, reportCount?: number, lastVisitDate?: string, latestStatus?: string }) {
|
|
7
|
+
|
|
8
|
+
// Status color mapping
|
|
9
|
+
const getStatusColor = (status?: string) => {
|
|
10
|
+
if (!status) return "bg-zinc-500";
|
|
11
|
+
const s = status.toUpperCase();
|
|
12
|
+
if (s === 'APPROVED' || s === 'FINAL') return "bg-emerald-500";
|
|
13
|
+
if (s === 'REJECTED') return "bg-red-500";
|
|
14
|
+
return "bg-yellow-500";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const formatGender = (g?: string) => {
|
|
18
|
+
if (!g) return null;
|
|
19
|
+
const u = g.toUpperCase();
|
|
20
|
+
if (u === 'M') return 'Male';
|
|
21
|
+
if (u === 'F') return 'Female';
|
|
22
|
+
return g;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const calculateAge = (dobString?: string) => {
|
|
26
|
+
if (!dobString) return null;
|
|
27
|
+
|
|
28
|
+
// Handle DICOM format YYYYMMDD
|
|
29
|
+
let birthDate: Date;
|
|
30
|
+
if (dobString.length === 8 && !dobString.includes('-')) {
|
|
31
|
+
const y = parseInt(dobString.substring(0,4));
|
|
32
|
+
const m = parseInt(dobString.substring(4,6)) - 1;
|
|
33
|
+
const d = parseInt(dobString.substring(6,8));
|
|
34
|
+
birthDate = new Date(y, m, d);
|
|
35
|
+
} else {
|
|
36
|
+
birthDate = new Date(dobString);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isNaN(birthDate.getTime())) return null;
|
|
40
|
+
|
|
41
|
+
const today = new Date();
|
|
42
|
+
let age = today.getFullYear() - birthDate.getFullYear();
|
|
43
|
+
const monthDiff = today.getMonth() - birthDate.getMonth();
|
|
44
|
+
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
|
45
|
+
age--;
|
|
46
|
+
}
|
|
47
|
+
return age;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const displayAge = calculateAge(patient.dob) ?? patient.age;
|
|
51
|
+
const displayGender = formatGender(patient.gender);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Link href={`/patients/${patient.id}`} className="block">
|
|
55
|
+
<div className="bg-zinc-900/40 border border-zinc-800/60 rounded-xl p-5 hover:bg-zinc-800/40 hover:border-zinc-700/60 transition-all group">
|
|
56
|
+
<div className="flex items-start justify-between mb-4">
|
|
57
|
+
<div className="flex items-center gap-3">
|
|
58
|
+
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/10 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
|
59
|
+
<span className="text-lg font-semibold text-indigo-300">
|
|
60
|
+
{patient.patientName.substring(0, 2).toUpperCase()}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div>
|
|
64
|
+
<h3 className="font-medium text-zinc-100 group-hover:text-indigo-300 transition-colors">{patient.patientName}</h3>
|
|
65
|
+
<p className="text-xs text-zinc-500 mt-0.5">{patient.patientIdNumber ? `ID: ${patient.patientIdNumber}` : 'No ID provided'}</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
{latestStatus && (
|
|
69
|
+
<div className="flex items-center gap-1.5" title={`Latest report: ${latestStatus}`}>
|
|
70
|
+
<div className={`w-2 h-2 rounded-full ${getStatusColor(latestStatus)} animate-pulse`} />
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="grid grid-cols-2 gap-3 mb-4">
|
|
76
|
+
<div className="bg-zinc-950/50 rounded-lg p-2.5 border border-zinc-800/40 flex items-center gap-2">
|
|
77
|
+
<FileText className="w-3.5 h-3.5 text-zinc-500" />
|
|
78
|
+
<div>
|
|
79
|
+
<p className="text-[10px] text-zinc-500 uppercase font-semibold">Reports</p>
|
|
80
|
+
<p className="text-sm font-medium text-zinc-300">{reportCount}</p>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="bg-zinc-950/50 rounded-lg p-2.5 border border-zinc-800/40 flex items-center gap-2">
|
|
84
|
+
<Calendar className="w-3.5 h-3.5 text-zinc-500" />
|
|
85
|
+
<div>
|
|
86
|
+
<p className="text-[10px] text-zinc-500 uppercase font-semibold">Last Scan</p>
|
|
87
|
+
<p className="text-sm font-medium text-zinc-300 truncate">{lastVisitDate ? new Date(lastVisitDate).toLocaleDateString() : 'N/A'}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t border-zinc-800/40">
|
|
93
|
+
{displayGender && (
|
|
94
|
+
<div className="flex items-center gap-1.5 bg-indigo-500/10 border border-indigo-500/20 px-2.5 py-1 rounded-md text-xs font-medium text-indigo-300">
|
|
95
|
+
<Activity className="w-3.5 h-3.5 opacity-70" />
|
|
96
|
+
{displayGender}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
{displayAge !== null && (
|
|
100
|
+
<div className="flex items-center gap-1.5 bg-purple-500/10 border border-purple-500/20 px-2.5 py-1 rounded-md text-xs font-medium text-purple-300">
|
|
101
|
+
<User className="w-3.5 h-3.5 opacity-70" />
|
|
102
|
+
{displayAge} years old
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
{patient.dob && (
|
|
106
|
+
<div className="flex items-center gap-1.5 bg-zinc-800/50 border border-zinc-700/50 px-2.5 py-1 rounded-md text-xs font-medium text-zinc-300">
|
|
107
|
+
<Calendar className="w-3.5 h-3.5 opacity-70" />
|
|
108
|
+
DOB: {patient.dob.length === 8 && !patient.dob.includes('-')
|
|
109
|
+
? `${patient.dob.substring(0,4)}/${patient.dob.substring(4,6)}/${patient.dob.substring(6,8)}`
|
|
110
|
+
: new Date(patient.dob).toLocaleDateString()}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</Link>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Patient } from "@/types";
|
|
5
|
+
import { User, Activity, FileText, Calendar, Trash2, Download } from "lucide-react";
|
|
6
|
+
import { useRouter } from "next/navigation";
|
|
7
|
+
|
|
8
|
+
export function PatientHeader({ patient, reportCount }: { patient: Patient, reportCount: number }) {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
11
|
+
|
|
12
|
+
const handleDelete = async () => {
|
|
13
|
+
if (!confirm("Are you sure you want to delete this patient and all associated reports? This action cannot be undone.")) return;
|
|
14
|
+
|
|
15
|
+
setIsDeleting(true);
|
|
16
|
+
try {
|
|
17
|
+
await fetch(`/api/patients/${patient.id}`, { method: 'DELETE' });
|
|
18
|
+
router.push('/patients');
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.error("Failed to delete patient", e);
|
|
21
|
+
setIsDeleting(false);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const formatGender = (g?: string) => {
|
|
26
|
+
if (!g) return null;
|
|
27
|
+
const u = g.toUpperCase();
|
|
28
|
+
if (u === 'M') return 'Male';
|
|
29
|
+
if (u === 'F') return 'Female';
|
|
30
|
+
if (u === 'O') return 'Other';
|
|
31
|
+
return g;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const calculateAge = (dobString?: string) => {
|
|
35
|
+
if (!dobString) return null;
|
|
36
|
+
|
|
37
|
+
let birthDate: Date;
|
|
38
|
+
if (dobString.length === 8 && !dobString.includes('-')) {
|
|
39
|
+
const y = parseInt(dobString.substring(0,4));
|
|
40
|
+
const m = parseInt(dobString.substring(4,6)) - 1;
|
|
41
|
+
const d = parseInt(dobString.substring(6,8));
|
|
42
|
+
birthDate = new Date(y, m, d);
|
|
43
|
+
} else {
|
|
44
|
+
birthDate = new Date(dobString);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (isNaN(birthDate.getTime())) return null;
|
|
48
|
+
|
|
49
|
+
const today = new Date();
|
|
50
|
+
let age = today.getFullYear() - birthDate.getFullYear();
|
|
51
|
+
const monthDiff = today.getMonth() - birthDate.getMonth();
|
|
52
|
+
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
|
53
|
+
age--;
|
|
54
|
+
}
|
|
55
|
+
return age;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const displayAge = calculateAge(patient.dob) ?? patient.age;
|
|
59
|
+
const displayGender = formatGender(patient.gender);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 shadow-sm overflow-hidden relative">
|
|
63
|
+
{/* Background design */}
|
|
64
|
+
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4 pointer-events-none" />
|
|
65
|
+
|
|
66
|
+
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6 relative z-10">
|
|
67
|
+
<div className="flex items-start gap-5">
|
|
68
|
+
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/20 flex items-center justify-center shrink-0 mt-1">
|
|
69
|
+
<span className="text-2xl font-bold text-indigo-300">
|
|
70
|
+
{patient.patientName.substring(0, 2).toUpperCase()}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div>
|
|
75
|
+
<h1 className="text-2xl font-semibold text-zinc-100">{patient.patientName}</h1>
|
|
76
|
+
<p className="text-zinc-400 mt-1 flex items-center gap-2">
|
|
77
|
+
<User className="w-4 h-4" />
|
|
78
|
+
{patient.patientIdNumber ? `ID: ${patient.patientIdNumber}` : 'No ID Number provided'}
|
|
79
|
+
</p>
|
|
80
|
+
|
|
81
|
+
<div className="flex flex-wrap items-center gap-2 mt-4">
|
|
82
|
+
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-800 text-zinc-300 border border-zinc-700/50 flex items-center gap-1.5">
|
|
83
|
+
<Activity className="w-3 h-3 text-zinc-400" />
|
|
84
|
+
{displayGender || 'Unknown Gender'}
|
|
85
|
+
</span>
|
|
86
|
+
{displayAge !== null && (
|
|
87
|
+
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-800 text-zinc-300 border border-zinc-700/50 flex items-center gap-1.5">
|
|
88
|
+
<User className="w-3 h-3 text-zinc-400" />
|
|
89
|
+
Age: {displayAge}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
{patient.dob && (
|
|
93
|
+
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-800 text-zinc-300 border border-zinc-700/50 flex items-center gap-1.5">
|
|
94
|
+
<Calendar className="w-3 h-3 text-zinc-400" />
|
|
95
|
+
DOB: {patient.dob.length === 8 && !patient.dob.includes('-')
|
|
96
|
+
? `${patient.dob.substring(0,4)}/${patient.dob.substring(4,6)}/${patient.dob.substring(6,8)}`
|
|
97
|
+
: new Date(patient.dob).toLocaleDateString()}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-800 text-zinc-300 border border-zinc-700/50 flex items-center gap-1.5">
|
|
101
|
+
<FileText className="w-3 h-3 text-zinc-400" />
|
|
102
|
+
{reportCount} Total Reports
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="flex items-center gap-3">
|
|
109
|
+
{/* Add export functionality if needed */}
|
|
110
|
+
<button
|
|
111
|
+
onClick={handleDelete}
|
|
112
|
+
disabled={isDeleting}
|
|
113
|
+
className="p-2.5 text-zinc-400 hover:text-red-400 bg-zinc-950/50 hover:bg-red-500/10 border border-zinc-800 border-transparent rounded-xl transition-colors shrink-0"
|
|
114
|
+
title="Delete Patient"
|
|
115
|
+
>
|
|
116
|
+
<Trash2 className="w-4 h-4" />
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
|
+
import { Search, User, UserPlus, Loader2 } from "lucide-react";
|
|
5
|
+
import { Patient } from "@/types";
|
|
6
|
+
|
|
7
|
+
interface PatientSearchProps {
|
|
8
|
+
onSelect: (patient: Patient) => void;
|
|
9
|
+
onNewPatient: () => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
value?: string;
|
|
12
|
+
onChange?: (val: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PatientSearch({ onSelect, onNewPatient, placeholder = "Search patient by name or ID...", value = "", onChange }: PatientSearchProps) {
|
|
16
|
+
const [query, setQuery] = useState(value);
|
|
17
|
+
const [results, setResults] = useState<Patient[]>([]);
|
|
18
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
19
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
20
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setQuery(value);
|
|
24
|
+
}, [value]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
28
|
+
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
|
29
|
+
setIsOpen(false);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
33
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!isOpen) return;
|
|
38
|
+
|
|
39
|
+
const timer = setTimeout(async () => {
|
|
40
|
+
if (query.length < 2) {
|
|
41
|
+
setResults([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`/api/patients/search?q=${encodeURIComponent(query)}`);
|
|
48
|
+
if (res.ok) {
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
setResults(data);
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error("Failed to search patients", e);
|
|
54
|
+
} finally {
|
|
55
|
+
setIsLoading(false);
|
|
56
|
+
}
|
|
57
|
+
}, 300);
|
|
58
|
+
|
|
59
|
+
return () => clearTimeout(timer);
|
|
60
|
+
}, [query, isOpen]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="relative w-full" ref={wrapperRef}>
|
|
64
|
+
<div className="relative flex items-center">
|
|
65
|
+
<Search className="absolute left-3 w-4 h-4 text-zinc-500" />
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={query}
|
|
69
|
+
onChange={(e) => {
|
|
70
|
+
setQuery(e.target.value);
|
|
71
|
+
if (onChange) onChange(e.target.value);
|
|
72
|
+
setIsOpen(true);
|
|
73
|
+
}}
|
|
74
|
+
onFocus={() => setIsOpen(true)}
|
|
75
|
+
placeholder={placeholder}
|
|
76
|
+
className="w-full bg-zinc-800/50 border border-zinc-700/50 rounded-lg pl-9 pr-10 py-2.5 text-sm
|
|
77
|
+
focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition-all text-zinc-200"
|
|
78
|
+
/>
|
|
79
|
+
{isLoading && (
|
|
80
|
+
<Loader2 className="absolute right-3 w-4 h-4 text-zinc-400 animate-spin" />
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{isOpen && (query.length > 0) && (
|
|
85
|
+
<div className="absolute z-50 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
|
|
86
|
+
{results.length > 0 ? (
|
|
87
|
+
<div className="max-h-64 overflow-y-auto p-1">
|
|
88
|
+
{results.map((patient) => (
|
|
89
|
+
<button
|
|
90
|
+
key={patient.id}
|
|
91
|
+
onClick={() => {
|
|
92
|
+
onSelect(patient);
|
|
93
|
+
setQuery(patient.patientName);
|
|
94
|
+
if (onChange) onChange(patient.patientName);
|
|
95
|
+
setIsOpen(false);
|
|
96
|
+
}}
|
|
97
|
+
className="w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800/80 transition-colors"
|
|
98
|
+
>
|
|
99
|
+
<div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center shrink-0">
|
|
100
|
+
<User className="w-4 h-4 text-zinc-400" />
|
|
101
|
+
</div>
|
|
102
|
+
<div className="flex-1 min-w-0">
|
|
103
|
+
<div className="text-sm font-medium text-zinc-200 truncate">{patient.patientName}</div>
|
|
104
|
+
<div className="text-xs text-zinc-500 truncate">
|
|
105
|
+
{patient.patientIdNumber ? `ID: ${patient.patientIdNumber}` : 'No ID'}
|
|
106
|
+
{patient.dob ? ` • DOB: ${patient.dob}` : ''}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
!isLoading && query.length >= 2 && (
|
|
114
|
+
<div className="p-4 text-center text-sm text-zinc-500">
|
|
115
|
+
No patients found matching "{query}"
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<div className="border-t border-zinc-800 p-2 bg-zinc-900/50">
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => {
|
|
124
|
+
setIsOpen(false);
|
|
125
|
+
onNewPatient();
|
|
126
|
+
}}
|
|
127
|
+
className="w-full flex items-center justify-center gap-2 py-2 px-3 text-sm font-medium text-indigo-400 hover:text-indigo-300 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
|
128
|
+
>
|
|
129
|
+
<UserPlus className="w-4 h-4" />
|
|
130
|
+
Create New Patient
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { ReportData } from "@/types";
|
|
5
|
+
import { Calendar, FileText, CheckCircle, Clock, XCircle, Download, ExternalLink, Stethoscope, Activity, ClipboardList } from "lucide-react";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
|
|
8
|
+
export function PatientTimeline({ reports }: { reports: any[] }) {
|
|
9
|
+
if (!reports || reports.length === 0) {
|
|
10
|
+
return (
|
|
11
|
+
<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">
|
|
12
|
+
<div className="w-16 h-16 rounded-full bg-zinc-800/50 flex items-center justify-center text-zinc-500 mb-4">
|
|
13
|
+
<FileText className="w-8 h-8 opacity-50" />
|
|
14
|
+
</div>
|
|
15
|
+
<h3 className="text-lg font-medium text-zinc-300">No Reports Yet</h3>
|
|
16
|
+
<p className="text-zinc-500 mt-2 max-w-sm">This patient does not have any generated reports. Return to the dashboard to generate one.</p>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Group by month
|
|
22
|
+
const grouped = reports.reduce((acc: any, report) => {
|
|
23
|
+
const date = new Date(report.createdAt || report.created_at || Date.now());
|
|
24
|
+
const month = date.toLocaleString('default', { month: 'long', year: 'numeric' });
|
|
25
|
+
if (!acc[month]) acc[month] = [];
|
|
26
|
+
acc[month].push(report);
|
|
27
|
+
return acc;
|
|
28
|
+
}, {});
|
|
29
|
+
|
|
30
|
+
const getStatusIcon = (status: string) => {
|
|
31
|
+
const s = (status || "").toUpperCase();
|
|
32
|
+
if (s === 'APPROVED' || s === 'FINAL') return <CheckCircle className="w-4 h-4 text-emerald-500" />;
|
|
33
|
+
if (s === 'REJECTED') return <XCircle className="w-4 h-4 text-red-500" />;
|
|
34
|
+
return <Clock className="w-4 h-4 text-yellow-500" />;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="relative pl-4 md:pl-0">
|
|
39
|
+
{/* Timeline Spine */}
|
|
40
|
+
<div className="absolute left-4 md:left-[120px] top-0 bottom-0 w-px bg-zinc-800 hidden md:block" />
|
|
41
|
+
|
|
42
|
+
{Object.keys(grouped).map((month, mIdx) => (
|
|
43
|
+
<div key={month} className="mb-12 relative animate-in fade-in slide-in-from-bottom-4" style={{ animationDelay: `${mIdx * 100}ms` }}>
|
|
44
|
+
<div className="sticky top-4 z-10 w-max bg-bg-primary/95 backdrop-blur-sm md:ml-[56px] px-3 py-1.5 rounded-full border border-zinc-800/80 mb-6 text-sm font-semibold text-zinc-400 flex items-center gap-2">
|
|
45
|
+
<Calendar className="w-4 h-4 text-zinc-500" />
|
|
46
|
+
{month}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="space-y-6">
|
|
50
|
+
{grouped[month].map((report: any, idx: number) => {
|
|
51
|
+
const data = typeof report.reportData === 'string' ? JSON.parse(report.reportData) : (report.reportData || report.report_data);
|
|
52
|
+
const date = new Date(report.createdAt || report.created_at);
|
|
53
|
+
const status = data.report_footer?.report_status || report.reportStatus || 'Pending';
|
|
54
|
+
const modality = report.modality || data.study?.modality || "Imaging";
|
|
55
|
+
const urgency = data.urgency || report.urgency || "Routine";
|
|
56
|
+
const indication = data.clinical_information?.indication || "";
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div key={report.id} className="relative flex flex-col md:flex-row gap-4 md:gap-8 group">
|
|
60
|
+
{/* Date Left Column */}
|
|
61
|
+
<div className="md:w-[100px] shrink-0 text-left md:text-right pt-2 hidden md:block">
|
|
62
|
+
<div className="text-sm font-medium text-zinc-300">{date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}</div>
|
|
63
|
+
<div className="text-xs text-zinc-500">{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Timeline Node */}
|
|
67
|
+
<div className="absolute left-0 md:left-[120px] top-4 w-2.5 h-2.5 rounded-full bg-zinc-700 border-2 border-bg-primary -translate-x-[5px] group-hover:bg-indigo-500 group-hover:scale-125 transition-all shadow-sm hidden md:block" />
|
|
68
|
+
|
|
69
|
+
{/* Event Card */}
|
|
70
|
+
<div className="flex-1 bg-zinc-900 border border-zinc-800/80 rounded-xl p-5 hover:bg-zinc-800/40 hover:border-zinc-700 transition-all ml-4 md:ml-0 shadow-sm relative overflow-hidden">
|
|
71
|
+
<div className="absolute top-0 left-0 w-1 h-full bg-indigo-500/50" />
|
|
72
|
+
<div className="md:hidden mb-2 pb-2 border-b border-zinc-800/50 flex justify-between">
|
|
73
|
+
<span className="text-xs text-zinc-400">{date.toLocaleString()}</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="flex justify-between items-start gap-4 mb-3">
|
|
77
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
78
|
+
<span className="px-2.5 py-1 rounded text-xs font-semibold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
|
79
|
+
{modality}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="px-2.5 py-1 rounded text-xs font-medium bg-zinc-950 border border-zinc-800 flex items-center gap-1.5 shadow-xs">
|
|
82
|
+
{getStatusIcon(status)}
|
|
83
|
+
{status}
|
|
84
|
+
</span>
|
|
85
|
+
{urgency && urgency !== 'Routine' && (
|
|
86
|
+
<span className={`px-2.5 py-1 rounded text-xs font-semibold border ${
|
|
87
|
+
urgency.toUpperCase() === 'STAT' || urgency.toUpperCase() === 'EMERGENCY'
|
|
88
|
+
? 'bg-red-500/10 text-red-400 border-red-500/20'
|
|
89
|
+
: urgency.toUpperCase() === 'URGENT'
|
|
90
|
+
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
|
91
|
+
: 'bg-zinc-800 text-zinc-400 border-zinc-700'
|
|
92
|
+
}`}>
|
|
93
|
+
{urgency}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
<Link href={`/reports?id=${report.id}`} className="text-indigo-400 hover:text-indigo-300 bg-indigo-500/10 hover:bg-indigo-500/20 p-1.5 rounded-lg transition-colors shrink-0" title="View Full Report">
|
|
98
|
+
<ExternalLink className="w-4 h-4" />
|
|
99
|
+
</Link>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Metadata Row: Modality, Urgency */}
|
|
103
|
+
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-1 mb-4">
|
|
104
|
+
<div className="flex items-center gap-1.5">
|
|
105
|
+
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-widest">Modality:</span>
|
|
106
|
+
<span className="text-sm text-zinc-200 font-medium">{data.study?.modality || modality}</span>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="flex items-center gap-1.5">
|
|
109
|
+
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-widest">Urgency:</span>
|
|
110
|
+
<span className={`text-sm font-medium ${
|
|
111
|
+
urgency.toUpperCase() === 'STAT' || urgency.toUpperCase() === 'EMERGENCY' || urgency.toUpperCase() === 'CRITICAL' ? 'text-red-400' :
|
|
112
|
+
urgency.toUpperCase() === 'URGENT' || urgency.toUpperCase() === 'ASAP' ? 'text-amber-400' :
|
|
113
|
+
urgency.toUpperCase() === 'ROUTINE' || urgency.toUpperCase() === 'NORMAL' ? 'text-emerald-400' :
|
|
114
|
+
'text-zinc-300'
|
|
115
|
+
}`}>{urgency}</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Indication */}
|
|
120
|
+
{indication && (
|
|
121
|
+
<div className="mb-4">
|
|
122
|
+
<div className="flex items-center gap-1.5 mb-1 text-zinc-400">
|
|
123
|
+
<Stethoscope className="w-3.5 h-3.5 opacity-80" />
|
|
124
|
+
<p className="text-[11px] font-bold uppercase tracking-widest">Indication</p>
|
|
125
|
+
</div>
|
|
126
|
+
<p className="text-sm text-zinc-300 leading-relaxed">{indication}</p>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Impression */}
|
|
131
|
+
<div className="space-y-2 mt-4">
|
|
132
|
+
{data.impression && data.impression.length > 0 && (
|
|
133
|
+
<div>
|
|
134
|
+
<div className="flex items-center gap-1.5 mb-1 text-indigo-400/90">
|
|
135
|
+
<ClipboardList className="w-3.5 h-3.5" />
|
|
136
|
+
<p className="text-[11px] font-bold uppercase tracking-widest">Impression</p>
|
|
137
|
+
</div>
|
|
138
|
+
<p className="text-sm text-zinc-200 leading-relaxed">
|
|
139
|
+
{Array.isArray(data.impression) ? data.impression.join(". ") : data.impression}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|