@omniradiology/omnirad 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
@@ -0,0 +1,103 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createClient } from "@supabase/supabase-js";
3
+
4
+ /**
5
+ * POST /api/settings/test-supabase
6
+ *
7
+ * Tests a Supabase connection with the provided URL and anon key.
8
+ * Returns success status, report count, and whether the patients table exists.
9
+ */
10
+ export async function POST(request: NextRequest) {
11
+ try {
12
+ const { supabaseUrl, supabaseAnonKey } = await request.json();
13
+
14
+ if (!supabaseUrl?.trim() || !supabaseAnonKey?.trim()) {
15
+ return NextResponse.json(
16
+ { success: false, error: "Both Project URL and Anon Key are required." },
17
+ { status: 400 }
18
+ );
19
+ }
20
+
21
+ // Validate URL format
22
+ try {
23
+ const url = new URL(supabaseUrl.trim());
24
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
25
+ throw new Error("Invalid protocol");
26
+ }
27
+ } catch {
28
+ return NextResponse.json(
29
+ { success: false, error: "Invalid URL format. Expected https://your-project.supabase.co" },
30
+ { status: 400 }
31
+ );
32
+ }
33
+
34
+ const client = createClient(supabaseUrl.trim(), supabaseAnonKey.trim());
35
+
36
+ // 1. Test: query the reports table
37
+ let reportCount = 0;
38
+ let reportsTableExists = false;
39
+ const { data: reports, error: reportsError } = await client
40
+ .from("reports")
41
+ .select("id", { count: "exact", head: true });
42
+
43
+ if (reportsError) {
44
+ // Check if the error is "table doesn't exist" vs auth/network error
45
+ const msg = reportsError.message?.toLowerCase() || "";
46
+ const code = reportsError.code || "";
47
+
48
+ if (msg.includes("does not exist") || code === "42P01") {
49
+ // Table doesn't exist yet — connection works but schema not set up
50
+ return NextResponse.json({
51
+ success: true,
52
+ connected: true,
53
+ reportsTableExists: false,
54
+ patientsTableExists: false,
55
+ reportCount: 0,
56
+ message: "Connected to Supabase, but the reports table does not exist yet. Please run the SQL setup script.",
57
+ });
58
+ }
59
+
60
+ // Auth or network error
61
+ return NextResponse.json({
62
+ success: false,
63
+ error: `Connection failed: ${reportsError.message}`,
64
+ hint: reportsError.hint || undefined,
65
+ });
66
+ }
67
+
68
+ reportsTableExists = true;
69
+ // The count is returned via the response header when using head: true
70
+ // We need to use a different approach to get the count
71
+ const { count } = await client
72
+ .from("reports")
73
+ .select("*", { count: "exact", head: true });
74
+ reportCount = count ?? 0;
75
+
76
+ // 2. Test: check if patients table exists
77
+ let patientsTableExists = false;
78
+ const { error: patientsError } = await client
79
+ .from("patients")
80
+ .select("id", { count: "exact", head: true });
81
+
82
+ if (!patientsError) {
83
+ patientsTableExists = true;
84
+ }
85
+
86
+ return NextResponse.json({
87
+ success: true,
88
+ connected: true,
89
+ reportsTableExists,
90
+ patientsTableExists,
91
+ reportCount,
92
+ message: patientsTableExists
93
+ ? `Connected! ${reportCount} report(s) in cloud.`
94
+ : `Connected! ${reportCount} report(s) found. Patients table missing — run the full SQL setup for patient sync.`,
95
+ });
96
+ } catch (err) {
97
+ console.error("[API] test-supabase error:", err);
98
+ return NextResponse.json(
99
+ { success: false, error: `Unexpected error: ${err instanceof Error ? err.message : String(err)}` },
100
+ { status: 500 }
101
+ );
102
+ }
103
+ }
@@ -0,0 +1,48 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import path from "path";
3
+ import fs from "fs";
4
+
5
+ const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
6
+ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
7
+ const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/svg+xml", "image/webp"];
8
+
9
+ export async function POST(request: NextRequest) {
10
+ try {
11
+ const formData = await request.formData();
12
+ const file = formData.get("file") as File | null;
13
+
14
+ if (!file) {
15
+ return NextResponse.json({ error: "No file provided" }, { status: 400 });
16
+ }
17
+
18
+ if (!ALLOWED_TYPES.includes(file.type)) {
19
+ return NextResponse.json({ error: "Invalid file type. Only images are allowed." }, { status: 400 });
20
+ }
21
+
22
+ if (file.size > MAX_SIZE) {
23
+ return NextResponse.json({ error: "File too large. Maximum size is 5MB." }, { status: 400 });
24
+ }
25
+
26
+ // Ensure upload directory exists
27
+ if (!fs.existsSync(UPLOAD_DIR)) {
28
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true });
29
+ }
30
+
31
+ // Generate unique filename
32
+ const ext = path.extname(file.name) || ".png";
33
+ const baseName = path.basename(file.name, ext).replace(/[^a-zA-Z0-9_-]/g, "_");
34
+ const uniqueName = `${baseName}_${Date.now()}${ext}`;
35
+ const filePath = path.join(UPLOAD_DIR, uniqueName);
36
+
37
+ // Write file
38
+ const bytes = await file.arrayBuffer();
39
+ fs.writeFileSync(filePath, Buffer.from(bytes));
40
+
41
+ // Return the public URL path
42
+ const publicUrl = `/uploads/${uniqueName}`;
43
+ return NextResponse.json({ success: true, url: publicUrl, filename: uniqueName });
44
+ } catch (error) {
45
+ console.error("[API] Upload error:", error);
46
+ return NextResponse.json({ error: "Upload failed" }, { status: 500 });
47
+ }
48
+ }
@@ -0,0 +1,30 @@
1
+ "use client"
2
+
3
+ import WorkspaceLayout from "@/components/copilot/WorkspaceLayout";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { Suspense } from "react";
6
+
7
+ function CopilotContent() {
8
+ const searchParams = useSearchParams();
9
+ const patientId = searchParams.get("patientId") || undefined;
10
+ const reportId = searchParams.get("reportId") || undefined;
11
+
12
+ return (
13
+ <WorkspaceLayout
14
+ initialPatientId={patientId}
15
+ initialReportId={reportId}
16
+ />
17
+ );
18
+ }
19
+
20
+ export default function CopilotPage() {
21
+ return (
22
+ <Suspense fallback={
23
+ <div className="flex items-center justify-center h-full">
24
+ <div className="text-text-muted animate-pulse">Loading AI Copilot...</div>
25
+ </div>
26
+ }>
27
+ <CopilotContent />
28
+ </Suspense>
29
+ );
30
+ }
@@ -0,0 +1,141 @@
1
+ @import "tailwindcss";
2
+
3
+ @layer base {
4
+ :root {
5
+ /* Background Colors (Soft Light Mode - No Pure White) */
6
+ --bg-primary: #e2e8f0;
7
+ /* Slate 200 */
8
+ --bg-surface: #f1f5f9;
9
+ /* Slate 100 */
10
+ --bg-panel: #cbd5e1;
11
+ /* Slate 300 for inputs/inner panels */
12
+
13
+ /* Text Colors (Light Mode - using Dark Mode background colors for harmony) */
14
+ --text-heading: #0B1A2A;
15
+ /* Dark mode bg-panel */
16
+ --text-primary: #0F1E33;
17
+ /* Dark mode bg-primary */
18
+ --text-secondary: #23324A;
19
+ /* Dark mode border-card */
20
+ --text-muted: #4b5563;
21
+ /* Gray 600 */
22
+
23
+ /* Border Colors (Light Mode) */
24
+ --border-primary: #94a3b8;
25
+ /* Slate 400 */
26
+ --border-card: #cbd5e1;
27
+ /* Slate 300 */
28
+ }
29
+
30
+ .dark {
31
+ /* Background Colors (Dark Mode) */
32
+ --bg-primary: #0F1E33;
33
+ --bg-surface: #111827;
34
+ --bg-panel: #0B1A2A;
35
+
36
+ /* Text Colors (Dark Mode) */
37
+ --text-heading: #FFFFFF;
38
+ --text-primary: #E5E7EB;
39
+ --text-secondary: #9CA3AF;
40
+ --text-muted: #6B7280;
41
+
42
+ /* Border Colors (Dark Mode) */
43
+ --border-primary: #1F2937;
44
+ --border-card: #23324A;
45
+ }
46
+ }
47
+
48
+ @theme {
49
+ /* Brand Colors */
50
+ --color-primary: #3B82F6;
51
+ --color-primary-hover: #2563EB;
52
+ --color-primary-main: var(--color-primary);
53
+ --color-primary-dark: var(--color-primary-hover);
54
+
55
+ /* Status Colors */
56
+ --color-success: #10B981;
57
+ --color-warning: #F59E0B;
58
+ --color-danger: #EF4444;
59
+ --color-info: #38BDF8;
60
+
61
+ /* Background Colors */
62
+ --color-bg-primary: var(--bg-primary);
63
+ --color-bg-surface: var(--bg-surface);
64
+ --color-bg-panel: var(--bg-panel);
65
+
66
+ /* Text Colors */
67
+ --color-text-heading: var(--text-heading);
68
+ --color-text-primary: var(--text-primary);
69
+ --color-text-secondary: var(--text-secondary);
70
+ --color-text-muted: var(--text-muted);
71
+
72
+ /* Border Colors */
73
+ --color-border-primary: var(--border-primary);
74
+ --color-border-card: var(--border-card);
75
+
76
+ /* Overrides for PDF Generation Compatibility (html2canvas) */
77
+ /* Blue */
78
+ --color-blue-50: #eff6ff;
79
+ --color-blue-100: #dbeafe;
80
+ --color-blue-200: #bfdbfe;
81
+ --color-blue-500: #3b82f6;
82
+ --color-blue-600: #2563eb;
83
+ --color-blue-700: #1d4ed8;
84
+ --color-blue-800: #1e40af;
85
+ --color-blue-900: #1e3a8a;
86
+
87
+ /* Green */
88
+ --color-green-50: #f0fdf4;
89
+ --color-green-100: #dcfce7;
90
+ --color-green-500: #22c55e;
91
+ --color-green-600: #16a34a;
92
+ --color-green-700: #15803d;
93
+ --color-green-800: #166534;
94
+
95
+ /* Red */
96
+ --color-red-50: #fef2f2;
97
+ --color-red-100: #fee2e2;
98
+ --color-red-200: #fecaca;
99
+ --color-red-500: #ef4444;
100
+ --color-red-600: #dc2626;
101
+ --color-red-700: #b91c1c;
102
+ --color-red-800: #991b1b;
103
+
104
+ /* Yellow/Orange */
105
+ --color-yellow-100: #fef9c3;
106
+ --color-yellow-500: #eab308;
107
+ --color-yellow-800: #854d0e;
108
+ --color-orange-600: #ea580c;
109
+
110
+ /* Gray */
111
+ --color-gray-100: #f3f4f6;
112
+ --color-gray-200: #e5e7eb;
113
+ --color-gray-300: #d1d5db;
114
+ --color-gray-700: #374151;
115
+ }
116
+
117
+ /* Custom Base Styles */
118
+ body {
119
+ background-color: var(--color-bg-primary);
120
+ color: var(--color-text-primary);
121
+ font-family: Arial, Helvetica, sans-serif;
122
+ }
123
+
124
+ /* Scrollbar Styling for Dark Theme */
125
+ ::-webkit-scrollbar {
126
+ width: 8px;
127
+ height: 8px;
128
+ }
129
+
130
+ ::-webkit-scrollbar-track {
131
+ background: var(--color-bg-surface);
132
+ }
133
+
134
+ ::-webkit-scrollbar-thumb {
135
+ background: var(--color-border-card);
136
+ border-radius: 4px;
137
+ }
138
+
139
+ ::-webkit-scrollbar-thumb:hover {
140
+ background: var(--color-text-muted);
141
+ }
@@ -0,0 +1,242 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { getReports } from "@/lib/api";
5
+ import { ReportData } from "@/types";
6
+ import { Button } from "@/components/ui/basic";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { FileText, Calendar, User, Clock, ChevronRight, Search, Filter, Database } from "lucide-react";
9
+ import { ReportView } from "@/components/dashboard/ReportView";
10
+
11
+ export default function HistoryPage() {
12
+ const [reports, setReports] = React.useState<any[]>([]);
13
+ const [loading, setLoading] = React.useState(true);
14
+ const [selectedReportId, setSelectedReportId] = React.useState<string | null>(null);
15
+ const [searchQuery, setSearchQuery] = React.useState("");
16
+ const [sourceFilter, setSourceFilter] = React.useState<string>("Local SQLite");
17
+ React.useEffect(() => {
18
+ loadReports();
19
+ }, []);
20
+
21
+ const loadReports = async () => {
22
+ setLoading(true);
23
+ try {
24
+ const allReports = await getReports();
25
+
26
+ // Sort by Date DESC (Newest First)
27
+ allReports.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
28
+
29
+ setReports(allReports || []);
30
+ } catch (err) {
31
+ console.error("Error loading reports:", err);
32
+ setReports([]);
33
+ } finally {
34
+ setLoading(false);
35
+ }
36
+ };
37
+
38
+ // Filter reports based on search and source
39
+ const filteredReports = reports.filter(report => {
40
+ const reportData = report.report_data;
41
+ if (!reportData) return false;
42
+
43
+ // Search filter
44
+ const matchesSearch = searchQuery === "" ||
45
+ reportData.patient?.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
46
+ reportData.study?.examination?.toLowerCase().includes(searchQuery.toLowerCase()) ||
47
+ reportData.study?.modality?.toLowerCase().includes(searchQuery.toLowerCase());
48
+
49
+ // Source filter
50
+ let matchesSource = false;
51
+ if (sourceFilter === "Local SQLite") {
52
+ // Include 'Local' and 'Synced' (since Synced means it's available locally too)
53
+ matchesSource = report._source === 'Local' || report._source === 'Synced';
54
+ } else if (sourceFilter === "Supabase Cloud") {
55
+ // Include 'Supabase' and 'Synced' (since Synced means it's available in cloud too)
56
+ matchesSource = report._source === 'Supabase' || report._source === 'Synced';
57
+ }
58
+
59
+ return matchesSearch && matchesSource;
60
+ });
61
+
62
+ const selectedReportObj = reports.find(r => r.id === selectedReportId);
63
+
64
+ if (selectedReportObj) {
65
+ return (
66
+ <div className="h-full flex flex-col p-4 bg-bg-primary overflow-hidden">
67
+ <Button variant="ghost" onClick={() => setSelectedReportId(null)} className="mb-2 self-start gap-2 text-text-secondary hover:text-text-primary">
68
+ ← Back to History
69
+ </Button>
70
+ <div className="flex-1 overflow-hidden rounded-xl border border-border-primary bg-bg-surface shadow-sm">
71
+ <ReportView
72
+ report={selectedReportObj.report_data}
73
+ onNewPatient={() => setSelectedReportId(null)}
74
+ reportId={selectedReportObj.id}
75
+ onStatusChange={loadReports}
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <div className="p-6 h-full flex flex-col gap-6 overflow-hidden bg-bg-primary text-text-primary font-sans">
84
+ {/* Header Section */}
85
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
86
+ <div>
87
+ <h2 className="text-2xl font-bold text-text-heading tracking-tight">Report History</h2>
88
+ <p className="text-text-secondary text-sm">Manage and view your generated radiology reports.</p>
89
+ </div>
90
+ </div>
91
+
92
+ {/* Controls Bar */}
93
+ <div className="flex flex-col md:flex-row gap-4">
94
+ <div className="flex-1 relative group">
95
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary-main transition-colors" />
96
+ <input
97
+ type="text"
98
+ placeholder="Search patient, modality, or exam..."
99
+ value={searchQuery}
100
+ onChange={(e) => setSearchQuery(e.target.value)}
101
+ className="w-full pl-10 pr-4 py-2.5 bg-bg-surface border border-border-primary rounded-lg text-sm text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-main/20 focus:border-primary-main transition-all shadow-sm"
102
+ />
103
+ </div>
104
+ <div className="relative w-full md:w-56">
105
+ <Database className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
106
+ <select
107
+ value={sourceFilter}
108
+ onChange={(e) => setSourceFilter(e.target.value)}
109
+ className="w-full pl-10 pr-8 py-2.5 bg-bg-surface border border-border-primary rounded-lg text-sm text-text-primary appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-main/20 focus:border-primary-main shadow-sm"
110
+ >
111
+ <option value="Local SQLite">Local SQLite</option>
112
+ <option value="Supabase Cloud">Supabase Cloud</option>
113
+ </select>
114
+ <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
115
+ <svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ {/* Table Section */}
121
+ <div className="flex-1 overflow-hidden bg-bg-surface rounded-xl border border-border-primary shadow-sm flex flex-col">
122
+ {/* Table Header */}
123
+ <div className="grid grid-cols-12 gap-4 px-6 py-3 bg-bg-panel border-b border-border-primary text-xs font-semibold text-text-muted uppercase tracking-wider">
124
+ <div className="col-span-3">Patient</div>
125
+ <div className="col-span-3">Examination</div>
126
+ <div className="col-span-2">Date & Time</div>
127
+ <div className="col-span-2">Urgency</div>
128
+ <div className="col-span-2 text-right">Status & Source</div>
129
+ </div>
130
+
131
+ {/* Table Body */}
132
+ <div className="flex-1 overflow-y-auto">
133
+ {loading ? (
134
+ <div className="flex flex-col items-center justify-center h-48 gap-3 text-text-muted">
135
+ <div className="w-6 h-6 border-2 border-primary-main border-t-transparent rounded-full animate-spin" />
136
+ <p className="text-sm">Loading reports...</p>
137
+ </div>
138
+ ) : filteredReports.length === 0 ? (
139
+ <div className="flex flex-col items-center justify-center h-64 gap-4 text-center">
140
+ <div className="p-4 bg-bg-panel rounded-full">
141
+ <FileText className="w-8 h-8 text-text-muted" />
142
+ </div>
143
+ <div>
144
+ <h3 className="text-base font-medium text-text-heading">No reports found</h3>
145
+ <p className="text-sm text-text-secondary mt-1">
146
+ {searchQuery || sourceFilter !== "Local SQLite" ? "Try adjusting your filters" : "Generate a new report to get started"}
147
+ </p>
148
+ </div>
149
+ </div>
150
+ ) : (
151
+ <div className="divide-y divide-border-primary/50">
152
+ {filteredReports.map((report) => {
153
+ const reportData = report.report_data;
154
+ const status = reportData?.report_footer?.report_status || 'Pending';
155
+ const urgency = reportData.urgency || 'Routine';
156
+ const source = report._source || 'Local';
157
+
158
+ return (
159
+ <div
160
+ key={report.id}
161
+ onClick={() => setSelectedReportId(report.id)}
162
+ className="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-bg-panel/50 transition-colors cursor-pointer group"
163
+ >
164
+ {/* Patient Column (3) */}
165
+ <div className="col-span-3">
166
+ <div className="font-medium text-text-heading text-sm group-hover:text-primary-main transition-colors">
167
+ {reportData.patient?.name || report.patient_name || "Unknown Patient"}
168
+ </div>
169
+ <div className="flex items-center gap-1.5 mt-1 text-xs text-text-secondary">
170
+ <User size={12} />
171
+ <span>{reportData.patient.age}y</span>
172
+ <span className="w-0.5 h-0.5 bg-text-muted rounded-full" />
173
+ <span>{reportData.patient.gender}</span>
174
+ </div>
175
+ </div>
176
+
177
+ {/* Exam Column (3) */}
178
+ <div className="col-span-3">
179
+ <div className="font-medium text-text-primary text-sm flex items-center gap-2">
180
+ {reportData.study.modality}
181
+ </div>
182
+ <div className="text-xs text-text-secondary mt-1 truncate pr-4" title={reportData.study.examination}>
183
+ {reportData.study.examination}
184
+ </div>
185
+ </div>
186
+
187
+ {/* Date Column (2) */}
188
+ <div className="col-span-2">
189
+ <div className="flex items-center gap-1.5 text-sm text-text-primary">
190
+ <Calendar size={12} className="text-text-muted" />
191
+ {new Date(report.created_at).toLocaleDateString()}
192
+ </div>
193
+ <div className="flex items-center gap-1.5 mt-1 text-xs text-text-secondary">
194
+ <Clock size={12} />
195
+ {new Date(report.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
196
+ </div>
197
+ </div>
198
+
199
+ {/* Urgency Column (2) */}
200
+ <div className="col-span-2">
201
+ <Badge
202
+ variant="outline"
203
+ className={`
204
+ text-[10px] font-bold uppercase tracking-wider border px-2 py-0.5
205
+ ${urgency === 'Critical' ? 'bg-red-600 text-white border-transparent' :
206
+ urgency === 'Urgent' ? 'bg-orange-500 text-white border-transparent' :
207
+ 'bg-green-600 text-white border-transparent'}
208
+ `}
209
+ >
210
+ {urgency}
211
+ </Badge>
212
+ </div>
213
+
214
+ {/* Status & Source Column (2) */}
215
+ <div className="col-span-2 flex flex-col items-end justify-center gap-1.5">
216
+ <Badge
217
+ variant="outline"
218
+ className={`
219
+ text-[10px] font-bold uppercase tracking-wider border px-2 py-0.5
220
+ ${status === 'Approved' ? 'bg-green-50 text-green-700 border-green-200' :
221
+ status === 'Rejected' ? 'bg-red-50 text-red-700 border-red-200' :
222
+ 'bg-gray-50 text-gray-600 border-gray-200'}
223
+ `}
224
+ >
225
+ {status}
226
+ </Badge>
227
+
228
+ <div className="flex items-center gap-1 text-[9px] font-semibold tracking-wider text-text-muted">
229
+ <Database size={10} />
230
+ {source.toUpperCase()}
231
+ </div>
232
+ </div>
233
+ </div>
234
+ );
235
+ })}
236
+ </div>
237
+ )}
238
+ </div>
239
+ </div>
240
+ </div>
241
+ );
242
+ }