@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,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
+ }