@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,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
|
+
}
|
package/app/globals.css
ADDED
|
@@ -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
|
+
}
|