@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,293 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
interface AuditEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
actorName: string;
|
|
8
|
+
actorRole: string;
|
|
9
|
+
actorType: string;
|
|
10
|
+
action: string;
|
|
11
|
+
resourceType: string;
|
|
12
|
+
resourceId: string | null;
|
|
13
|
+
patientId: string | null;
|
|
14
|
+
ipAddress: string;
|
|
15
|
+
success: boolean;
|
|
16
|
+
reason: string | null;
|
|
17
|
+
metadata: Record<string, any> | null;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Pagination {
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
total: number;
|
|
25
|
+
totalPages: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ACTION_COLORS: Record<string, string> = {
|
|
29
|
+
"auth.login.success": "#22c55e",
|
|
30
|
+
"auth.login.failed": "#ef4444",
|
|
31
|
+
"patient.create": "#3b82f6",
|
|
32
|
+
"patient.view": "#8b5cf6",
|
|
33
|
+
"patient.update": "#f59e0b",
|
|
34
|
+
"patient.delete": "#ef4444",
|
|
35
|
+
"patient.export": "#06b6d4",
|
|
36
|
+
"patient.anonymize": "#dc2626",
|
|
37
|
+
"patient.restrict": "#f97316",
|
|
38
|
+
"report.create": "#3b82f6",
|
|
39
|
+
"report.view": "#8b5cf6",
|
|
40
|
+
"report.update": "#f59e0b",
|
|
41
|
+
"report.finalize": "#22c55e",
|
|
42
|
+
"report.delete": "#ef4444",
|
|
43
|
+
"settings.update": "#f59e0b",
|
|
44
|
+
"data.clear": "#dc2626",
|
|
45
|
+
"data.wipe": "#dc2626",
|
|
46
|
+
"user.create": "#3b82f6",
|
|
47
|
+
"user.update": "#f59e0b",
|
|
48
|
+
"user.delete": "#ef4444",
|
|
49
|
+
"compliance.settings.update": "#06b6d4",
|
|
50
|
+
"copilot.message": "#8b5cf6",
|
|
51
|
+
"segmentation.request": "#8b5cf6",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default function AuditLogTable() {
|
|
55
|
+
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
|
56
|
+
const [pagination, setPagination] = useState<Pagination>({ page: 1, limit: 25, total: 0, totalPages: 0 });
|
|
57
|
+
const [loading, setLoading] = useState(true);
|
|
58
|
+
const [filters, setFilters] = useState({
|
|
59
|
+
action: "",
|
|
60
|
+
resourceType: "",
|
|
61
|
+
startDate: "",
|
|
62
|
+
endDate: "",
|
|
63
|
+
success: "" as "" | "true" | "false",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const fetchLogs = useCallback(async (page = 1) => {
|
|
67
|
+
setLoading(true);
|
|
68
|
+
try {
|
|
69
|
+
const params = new URLSearchParams();
|
|
70
|
+
params.set("page", String(page));
|
|
71
|
+
params.set("limit", "25");
|
|
72
|
+
if (filters.action) params.set("action", filters.action);
|
|
73
|
+
if (filters.resourceType) params.set("resourceType", filters.resourceType);
|
|
74
|
+
if (filters.startDate) params.set("startDate", filters.startDate);
|
|
75
|
+
if (filters.endDate) params.set("endDate", filters.endDate);
|
|
76
|
+
if (filters.success) params.set("success", filters.success);
|
|
77
|
+
|
|
78
|
+
const res = await fetch(`/api/compliance/audit?${params.toString()}`);
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
const data = await res.json();
|
|
81
|
+
setEntries(data.data);
|
|
82
|
+
setPagination(data.pagination);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error("Failed to fetch audit logs:", err);
|
|
86
|
+
} finally {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
}
|
|
89
|
+
}, [filters]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
fetchLogs(1);
|
|
93
|
+
}, [fetchLogs]);
|
|
94
|
+
|
|
95
|
+
const formatDate = (iso: string) => {
|
|
96
|
+
try {
|
|
97
|
+
return new Date(iso).toLocaleString();
|
|
98
|
+
} catch { return iso; }
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const getActionColor = (action: string) => ACTION_COLORS[action] || "#6b7280";
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div style={{ width: "100%" }}>
|
|
105
|
+
{/* Filters */}
|
|
106
|
+
<div style={{
|
|
107
|
+
display: "flex", gap: "10px", marginBottom: "16px", flexWrap: "wrap",
|
|
108
|
+
padding: "12px", borderRadius: "8px",
|
|
109
|
+
background: "var(--card-bg, rgba(255,255,255,0.05))",
|
|
110
|
+
border: "1px solid var(--border-color, rgba(255,255,255,0.1))"
|
|
111
|
+
}}>
|
|
112
|
+
<input
|
|
113
|
+
placeholder="Filter by action..."
|
|
114
|
+
value={filters.action}
|
|
115
|
+
onChange={(e) => setFilters(f => ({ ...f, action: e.target.value }))}
|
|
116
|
+
style={filterInputStyle}
|
|
117
|
+
/>
|
|
118
|
+
<select
|
|
119
|
+
value={filters.resourceType}
|
|
120
|
+
onChange={(e) => setFilters(f => ({ ...f, resourceType: e.target.value }))}
|
|
121
|
+
style={filterInputStyle}
|
|
122
|
+
>
|
|
123
|
+
<option value="">All Resources</option>
|
|
124
|
+
<option value="patient">Patient</option>
|
|
125
|
+
<option value="report">Report</option>
|
|
126
|
+
<option value="auth">Auth</option>
|
|
127
|
+
<option value="config">Config</option>
|
|
128
|
+
<option value="ai">AI</option>
|
|
129
|
+
<option value="fhir">FHIR</option>
|
|
130
|
+
<option value="user">User</option>
|
|
131
|
+
</select>
|
|
132
|
+
<select
|
|
133
|
+
value={filters.success}
|
|
134
|
+
onChange={(e) => setFilters(f => ({ ...f, success: e.target.value as any }))}
|
|
135
|
+
style={filterInputStyle}
|
|
136
|
+
>
|
|
137
|
+
<option value="">All Results</option>
|
|
138
|
+
<option value="true">Success</option>
|
|
139
|
+
<option value="false">Failed</option>
|
|
140
|
+
</select>
|
|
141
|
+
<input
|
|
142
|
+
type="date"
|
|
143
|
+
value={filters.startDate}
|
|
144
|
+
onChange={(e) => setFilters(f => ({ ...f, startDate: e.target.value }))}
|
|
145
|
+
style={filterInputStyle}
|
|
146
|
+
title="Start date"
|
|
147
|
+
/>
|
|
148
|
+
<input
|
|
149
|
+
type="date"
|
|
150
|
+
value={filters.endDate}
|
|
151
|
+
onChange={(e) => setFilters(f => ({ ...f, endDate: e.target.value }))}
|
|
152
|
+
style={filterInputStyle}
|
|
153
|
+
title="End date"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Table */}
|
|
158
|
+
<div style={{ overflowX: "auto", borderRadius: "8px", border: "1px solid var(--border-color, rgba(255,255,255,0.1))" }}>
|
|
159
|
+
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "13px" }}>
|
|
160
|
+
<thead>
|
|
161
|
+
<tr style={{ background: "var(--card-bg, rgba(255,255,255,0.05))" }}>
|
|
162
|
+
<th style={thStyle}>Timestamp</th>
|
|
163
|
+
<th style={thStyle}>User</th>
|
|
164
|
+
<th style={thStyle}>Action</th>
|
|
165
|
+
<th style={thStyle}>Resource</th>
|
|
166
|
+
<th style={thStyle}>Status</th>
|
|
167
|
+
<th style={thStyle}>IP</th>
|
|
168
|
+
</tr>
|
|
169
|
+
</thead>
|
|
170
|
+
<tbody>
|
|
171
|
+
{loading ? (
|
|
172
|
+
<tr><td colSpan={6} style={{ padding: "24px", textAlign: "center", opacity: 0.5 }}>Loading...</td></tr>
|
|
173
|
+
) : entries.length === 0 ? (
|
|
174
|
+
<tr><td colSpan={6} style={{ padding: "24px", textAlign: "center", opacity: 0.5 }}>No audit logs found</td></tr>
|
|
175
|
+
) : entries.map((entry) => (
|
|
176
|
+
<tr key={entry.id} style={{
|
|
177
|
+
borderBottom: "1px solid var(--border-color, rgba(255,255,255,0.06))",
|
|
178
|
+
transition: "background 0.15s",
|
|
179
|
+
}}
|
|
180
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,0.03)")}
|
|
181
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
182
|
+
>
|
|
183
|
+
<td style={tdStyle}>{formatDate(entry.createdAt)}</td>
|
|
184
|
+
<td style={tdStyle}>
|
|
185
|
+
<div style={{ fontWeight: 500 }}>{entry.actorName}</div>
|
|
186
|
+
<div style={{ fontSize: "11px", opacity: 0.6 }}>{entry.actorRole}</div>
|
|
187
|
+
</td>
|
|
188
|
+
<td style={tdStyle}>
|
|
189
|
+
<span style={{
|
|
190
|
+
background: `${getActionColor(entry.action)}20`,
|
|
191
|
+
color: getActionColor(entry.action),
|
|
192
|
+
padding: "2px 8px",
|
|
193
|
+
borderRadius: "12px",
|
|
194
|
+
fontSize: "12px",
|
|
195
|
+
fontWeight: 500,
|
|
196
|
+
}}>
|
|
197
|
+
{entry.action}
|
|
198
|
+
</span>
|
|
199
|
+
</td>
|
|
200
|
+
<td style={tdStyle}>
|
|
201
|
+
<span style={{ opacity: 0.7 }}>{entry.resourceType}</span>
|
|
202
|
+
{entry.resourceId && (
|
|
203
|
+
<span style={{ fontSize: "11px", opacity: 0.4, marginLeft: "4px" }}>
|
|
204
|
+
{entry.resourceId.substring(0, 8)}...
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</td>
|
|
208
|
+
<td style={tdStyle}>
|
|
209
|
+
<span style={{
|
|
210
|
+
color: entry.success ? "#22c55e" : "#ef4444",
|
|
211
|
+
fontWeight: 500,
|
|
212
|
+
}}>
|
|
213
|
+
{entry.success ? "✓" : "✗"}
|
|
214
|
+
</span>
|
|
215
|
+
{entry.reason && (
|
|
216
|
+
<span style={{ fontSize: "11px", opacity: 0.5, marginLeft: "4px" }}>
|
|
217
|
+
{entry.reason.substring(0, 30)}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
</td>
|
|
221
|
+
<td style={{ ...tdStyle, fontSize: "11px", opacity: 0.5, fontFamily: "monospace" }}>
|
|
222
|
+
{entry.ipAddress}
|
|
223
|
+
</td>
|
|
224
|
+
</tr>
|
|
225
|
+
))}
|
|
226
|
+
</tbody>
|
|
227
|
+
</table>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Pagination */}
|
|
231
|
+
{pagination.totalPages > 1 && (
|
|
232
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "12px", fontSize: "13px" }}>
|
|
233
|
+
<span style={{ opacity: 0.6 }}>
|
|
234
|
+
Showing {((pagination.page - 1) * pagination.limit) + 1}–{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}
|
|
235
|
+
</span>
|
|
236
|
+
<div style={{ display: "flex", gap: "6px" }}>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => fetchLogs(pagination.page - 1)}
|
|
239
|
+
disabled={pagination.page <= 1}
|
|
240
|
+
style={paginationBtnStyle}
|
|
241
|
+
>
|
|
242
|
+
← Prev
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => fetchLogs(pagination.page + 1)}
|
|
246
|
+
disabled={pagination.page >= pagination.totalPages}
|
|
247
|
+
style={paginationBtnStyle}
|
|
248
|
+
>
|
|
249
|
+
Next →
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const thStyle: React.CSSProperties = {
|
|
259
|
+
padding: "10px 12px",
|
|
260
|
+
textAlign: "left",
|
|
261
|
+
fontWeight: 600,
|
|
262
|
+
fontSize: "12px",
|
|
263
|
+
textTransform: "uppercase",
|
|
264
|
+
letterSpacing: "0.5px",
|
|
265
|
+
opacity: 0.7,
|
|
266
|
+
borderBottom: "1px solid var(--border-color, rgba(255,255,255,0.1))",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const tdStyle: React.CSSProperties = {
|
|
270
|
+
padding: "10px 12px",
|
|
271
|
+
verticalAlign: "middle",
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const filterInputStyle: React.CSSProperties = {
|
|
275
|
+
padding: "6px 10px",
|
|
276
|
+
borderRadius: "6px",
|
|
277
|
+
border: "1px solid var(--border-color, rgba(255,255,255,0.15))",
|
|
278
|
+
background: "var(--input-bg, rgba(255,255,255,0.05))",
|
|
279
|
+
color: "inherit",
|
|
280
|
+
fontSize: "13px",
|
|
281
|
+
flex: "1",
|
|
282
|
+
minWidth: "120px",
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const paginationBtnStyle: React.CSSProperties = {
|
|
286
|
+
padding: "6px 12px",
|
|
287
|
+
borderRadius: "6px",
|
|
288
|
+
border: "1px solid var(--border-color, rgba(255,255,255,0.15))",
|
|
289
|
+
background: "var(--card-bg, rgba(255,255,255,0.05))",
|
|
290
|
+
color: "inherit",
|
|
291
|
+
cursor: "pointer",
|
|
292
|
+
fontSize: "13px",
|
|
293
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useMemo } from "react";
|
|
4
|
+
import { ActivityState } from "@/types/copilot";
|
|
5
|
+
import {
|
|
6
|
+
Brain,
|
|
7
|
+
Search,
|
|
8
|
+
FileText,
|
|
9
|
+
GitCompare,
|
|
10
|
+
BarChart3,
|
|
11
|
+
Monitor,
|
|
12
|
+
Crosshair,
|
|
13
|
+
PenTool,
|
|
14
|
+
Check,
|
|
15
|
+
Loader2,
|
|
16
|
+
Bot,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
|
|
19
|
+
// ─── Status → Icon mapping ──────────────────────────────────────────────────
|
|
20
|
+
const STATUS_ICONS: Record<string, React.ReactNode> = {
|
|
21
|
+
thinking: <Brain size={14} />,
|
|
22
|
+
searching: <Search size={14} />,
|
|
23
|
+
fetching: <FileText size={14} />,
|
|
24
|
+
comparing: <GitCompare size={14} />,
|
|
25
|
+
analyzing: <BarChart3 size={14} />,
|
|
26
|
+
viewer: <Monitor size={14} />,
|
|
27
|
+
segmenting: <Crosshair size={14} />,
|
|
28
|
+
generating: <PenTool size={14} />,
|
|
29
|
+
working: <Loader2 size={14} />,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ─── Status → gradient color mapping ────────────────────────────────────────
|
|
33
|
+
const STATUS_COLORS: Record<string, { from: string; to: string; glow: string }> = {
|
|
34
|
+
thinking: { from: "#a78bfa", to: "#818cf8", glow: "rgba(167,139,250,0.3)" },
|
|
35
|
+
searching: { from: "#38bdf8", to: "#22d3ee", glow: "rgba(56,189,248,0.3)" },
|
|
36
|
+
fetching: { from: "#34d399", to: "#10b981", glow: "rgba(52,211,153,0.3)" },
|
|
37
|
+
comparing: { from: "#fb923c", to: "#f97316", glow: "rgba(251,146,60,0.3)" },
|
|
38
|
+
analyzing: { from: "#60a5fa", to: "#3b82f6", glow: "rgba(96,165,250,0.3)" },
|
|
39
|
+
viewer: { from: "#a78bfa", to: "#8b5cf6", glow: "rgba(167,139,250,0.3)" },
|
|
40
|
+
segmenting: { from: "#f472b6", to: "#ec4899", glow: "rgba(244,114,182,0.3)" },
|
|
41
|
+
generating: { from: "#34d399", to: "#10b981", glow: "rgba(52,211,153,0.3)" },
|
|
42
|
+
working: { from: "#94a3b8", to: "#64748b", glow: "rgba(148,163,184,0.3)" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Inject keyframe animations once
|
|
46
|
+
const STYLE_ID = "activity-indicator-styles";
|
|
47
|
+
function ensureStyles() {
|
|
48
|
+
if (typeof document === "undefined") return;
|
|
49
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
50
|
+
const style = document.createElement("style");
|
|
51
|
+
style.id = STYLE_ID;
|
|
52
|
+
style.textContent = `
|
|
53
|
+
@keyframes actStepFadeIn {
|
|
54
|
+
from { opacity: 0; transform: translateX(-4px); }
|
|
55
|
+
to { opacity: 1; transform: translateX(0); }
|
|
56
|
+
}
|
|
57
|
+
@keyframes actIconPulse {
|
|
58
|
+
0%, 100% { transform: scale(1); opacity: 1; }
|
|
59
|
+
50% { transform: scale(1.1); opacity: 0.85; }
|
|
60
|
+
}
|
|
61
|
+
@keyframes actDotBounce {
|
|
62
|
+
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
63
|
+
40% { opacity: 1; transform: scale(1.2); }
|
|
64
|
+
}
|
|
65
|
+
@keyframes actFadeIn {
|
|
66
|
+
from { opacity: 0; }
|
|
67
|
+
to { opacity: 0.6; }
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
document.head.appendChild(style);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ActivityIndicatorProps {
|
|
74
|
+
activityState: ActivityState;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function ActivityIndicator({ activityState }: ActivityIndicatorProps) {
|
|
78
|
+
const { isActive, currentStatus, currentLabel, completedSteps, startedAt } = activityState;
|
|
79
|
+
const [elapsed, setElapsed] = useState(0);
|
|
80
|
+
const [isCollapsing, setIsCollapsing] = useState(false);
|
|
81
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
82
|
+
|
|
83
|
+
// Inject keyframe styles on mount
|
|
84
|
+
useEffect(() => { ensureStyles(); }, []);
|
|
85
|
+
|
|
86
|
+
// Animate in when becoming active
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (isActive) {
|
|
89
|
+
setIsCollapsing(false);
|
|
90
|
+
requestAnimationFrame(() => setIsVisible(true));
|
|
91
|
+
} else if (isVisible) {
|
|
92
|
+
setIsCollapsing(true);
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
setIsVisible(false);
|
|
95
|
+
setIsCollapsing(false);
|
|
96
|
+
}, 400);
|
|
97
|
+
return () => clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
}, [isActive]);
|
|
100
|
+
|
|
101
|
+
// Elapsed timer
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!isActive || !startedAt) {
|
|
104
|
+
setElapsed(0);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const interval = setInterval(() => {
|
|
108
|
+
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
|
109
|
+
}, 1000);
|
|
110
|
+
return () => clearInterval(interval);
|
|
111
|
+
}, [isActive, startedAt]);
|
|
112
|
+
|
|
113
|
+
const colors = useMemo(
|
|
114
|
+
() => STATUS_COLORS[currentStatus] || STATUS_COLORS.working,
|
|
115
|
+
[currentStatus]
|
|
116
|
+
);
|
|
117
|
+
const icon = STATUS_ICONS[currentStatus] || STATUS_ICONS.working;
|
|
118
|
+
|
|
119
|
+
if (!isVisible && !isActive) return null;
|
|
120
|
+
|
|
121
|
+
const formatElapsed = (s: number) => {
|
|
122
|
+
if (s < 60) return `${s}s`;
|
|
123
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex gap-3">
|
|
128
|
+
{/* Bot avatar */}
|
|
129
|
+
<div className="shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 flex items-center justify-center">
|
|
130
|
+
<Bot size={16} className="text-emerald-400" />
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Activity card */}
|
|
134
|
+
<div
|
|
135
|
+
style={{
|
|
136
|
+
opacity: isCollapsing ? 0 : 1,
|
|
137
|
+
transform: isCollapsing ? "translateY(-8px) scale(0.95)" : "translateY(0) scale(1)",
|
|
138
|
+
maxHeight: isCollapsing ? "0px" : "300px",
|
|
139
|
+
transition: "all 0.4s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
140
|
+
overflow: "hidden",
|
|
141
|
+
}}
|
|
142
|
+
className="bg-bg-panel border border-border-card rounded-2xl rounded-bl-md min-w-[200px] max-w-[320px]"
|
|
143
|
+
>
|
|
144
|
+
<div className="flex flex-col gap-1.5 px-4 py-3">
|
|
145
|
+
{/* Completed steps */}
|
|
146
|
+
{completedSteps.length > 0 && (
|
|
147
|
+
<div className="flex flex-col gap-1">
|
|
148
|
+
{completedSteps.map((step, i) => (
|
|
149
|
+
<div
|
|
150
|
+
key={i}
|
|
151
|
+
className="flex items-center gap-2"
|
|
152
|
+
style={{
|
|
153
|
+
animation: `actStepFadeIn 0.3s ease-out ${i * 50}ms forwards`,
|
|
154
|
+
opacity: 0,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<span className="flex items-center justify-center w-[18px] h-[18px] rounded-full shrink-0"
|
|
158
|
+
style={{ background: "rgba(52, 211, 153, 0.15)" }}>
|
|
159
|
+
<Check size={10} strokeWidth={3} className="text-emerald-400" />
|
|
160
|
+
</span>
|
|
161
|
+
<span className="text-xs text-text-muted line-through"
|
|
162
|
+
style={{ textDecorationColor: "rgba(100, 116, 139, 0.3)" }}>
|
|
163
|
+
{step.label}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{/* Current active step */}
|
|
171
|
+
{currentLabel && (
|
|
172
|
+
<div className="flex items-center gap-2 py-1">
|
|
173
|
+
<span
|
|
174
|
+
className="flex items-center justify-center w-[22px] h-[22px] rounded-md text-white shrink-0"
|
|
175
|
+
style={{
|
|
176
|
+
background: `linear-gradient(135deg, ${colors.from}, ${colors.to})`,
|
|
177
|
+
boxShadow: `0 0 12px ${colors.glow}`,
|
|
178
|
+
animation: "actIconPulse 2s ease-in-out infinite",
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{icon}
|
|
182
|
+
</span>
|
|
183
|
+
<span className="text-[13px] font-medium text-text-primary whitespace-nowrap">
|
|
184
|
+
{currentLabel}
|
|
185
|
+
</span>
|
|
186
|
+
<span className="flex gap-[3px] items-center ml-0.5">
|
|
187
|
+
{[0, 200, 400].map(delay => (
|
|
188
|
+
<span
|
|
189
|
+
key={delay}
|
|
190
|
+
className="w-1 h-1 rounded-full bg-text-muted"
|
|
191
|
+
style={{ animation: `actDotBounce 1.4s ease-in-out ${delay}ms infinite` }}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{/* Elapsed timer — shows after 2 seconds */}
|
|
199
|
+
{elapsed > 2 && (
|
|
200
|
+
<div
|
|
201
|
+
className="text-[10px] text-text-muted text-right mt-0.5"
|
|
202
|
+
style={{
|
|
203
|
+
fontVariantNumeric: "tabular-nums",
|
|
204
|
+
animation: "actFadeIn 0.5s ease-out",
|
|
205
|
+
opacity: 0.6,
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{formatElapsed(elapsed)}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { Clock, MessageSquare, Trash2, ChevronRight, User } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface SessionHistory {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
patientId: string | null;
|
|
9
|
+
patientName: string | null;
|
|
10
|
+
lastMessage: string;
|
|
11
|
+
messageCount: number;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ChatHistoryPanelProps {
|
|
17
|
+
onSelectSession: (sessionId: string) => void;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function ChatHistoryPanel({ onSelectSession, onClose }: ChatHistoryPanelProps) {
|
|
22
|
+
const [sessions, setSessions] = useState<SessionHistory[]>([]);
|
|
23
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
const fetchHistory = () => {
|
|
26
|
+
setIsLoading(true);
|
|
27
|
+
fetch("/api/copilot/history")
|
|
28
|
+
.then(res => res.json())
|
|
29
|
+
.then(data => {
|
|
30
|
+
if (Array.isArray(data)) {
|
|
31
|
+
setSessions(data);
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.catch(err => console.error("Error fetching copilot history:", err))
|
|
35
|
+
.finally(() => setIsLoading(false));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetchHistory();
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const handleClearAll = async () => {
|
|
43
|
+
if (!confirm("Are you sure you want to clear all Copilot chat history? This cannot be undone.")) return;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await fetch("/api/copilot/history", { method: "DELETE" });
|
|
47
|
+
setSessions([]);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error("Error clearing history:", err);
|
|
50
|
+
alert("Failed to clear history.");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Format relative time (e.g., "2 hours ago")
|
|
55
|
+
const getRelativeTime = (dateStr: string) => {
|
|
56
|
+
const date = new Date(dateStr);
|
|
57
|
+
const now = new Date();
|
|
58
|
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
59
|
+
|
|
60
|
+
if (diffInSeconds < 60) return "Just now";
|
|
61
|
+
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
|
62
|
+
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
|
63
|
+
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
|
64
|
+
return date.toLocaleDateString();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="absolute inset-0 z-50 bg-bg-surface flex flex-col animate-in slide-in-from-right-4 duration-200">
|
|
69
|
+
{/* Header */}
|
|
70
|
+
<div className="shrink-0 px-5 py-4 border-b border-border-primary flex items-center justify-between bg-bg-panel/50">
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
<button
|
|
73
|
+
onClick={onClose}
|
|
74
|
+
className="p-1 -ml-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-black/5 transition-all"
|
|
75
|
+
>
|
|
76
|
+
<ChevronRight size={20} className="rotate-180" />
|
|
77
|
+
</button>
|
|
78
|
+
<h3 className="text-sm font-bold text-text-heading flex items-center gap-2">
|
|
79
|
+
<Clock size={16} className="text-primary" />
|
|
80
|
+
Chat History
|
|
81
|
+
</h3>
|
|
82
|
+
</div>
|
|
83
|
+
{sessions.length > 0 && (
|
|
84
|
+
<button
|
|
85
|
+
onClick={handleClearAll}
|
|
86
|
+
className="text-xs flex items-center gap-1.5 text-text-muted hover:text-red-500 transition-colors px-2 py-1 rounded"
|
|
87
|
+
>
|
|
88
|
+
<Trash2 size={14} />
|
|
89
|
+
Clear All
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* List */}
|
|
95
|
+
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
|
96
|
+
{isLoading ? (
|
|
97
|
+
<div className="flex justify-center p-8 text-text-muted">
|
|
98
|
+
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
|
99
|
+
</div>
|
|
100
|
+
) : sessions.length === 0 ? (
|
|
101
|
+
<div className="flex flex-col items-center justify-center p-12 text-center text-text-muted">
|
|
102
|
+
<MessageSquare size={32} className="opacity-20 mb-3" />
|
|
103
|
+
<p className="text-sm">No chat history found.</p>
|
|
104
|
+
</div>
|
|
105
|
+
) : (
|
|
106
|
+
sessions.map(session => (
|
|
107
|
+
<button
|
|
108
|
+
key={session.sessionId}
|
|
109
|
+
onClick={() => onSelectSession(session.sessionId)}
|
|
110
|
+
className="w-full text-left p-3 rounded-xl border border-border-card bg-bg-panel hover:border-primary/30 hover:bg-primary/5 transition-all group"
|
|
111
|
+
>
|
|
112
|
+
<div className="flex justify-between items-start mb-1.5">
|
|
113
|
+
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-heading">
|
|
114
|
+
<MessageSquare size={12} className="text-primary" />
|
|
115
|
+
{session.messageCount} messages
|
|
116
|
+
</div>
|
|
117
|
+
<span className="text-[10px] text-text-muted font-medium">
|
|
118
|
+
{getRelativeTime(session.updatedAt)}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<p className="text-sm text-text-primary line-clamp-2 leading-relaxed mb-2">
|
|
123
|
+
{session.lastMessage || "No text"}
|
|
124
|
+
</p>
|
|
125
|
+
|
|
126
|
+
{session.patientId && (
|
|
127
|
+
<div className="flex items-center gap-1.5 text-xs text-text-secondary bg-black/5 dark:bg-white/5 w-fit px-2 py-1 rounded-md">
|
|
128
|
+
<User size={12} />
|
|
129
|
+
<span className="truncate max-w-[150px]">
|
|
130
|
+
{session.patientName || session.patientId}
|
|
131
|
+
</span>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</button>
|
|
135
|
+
))
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|