@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,432 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useSearchParams, useRouter } from "next/navigation"
5
+ import { updateReportStatus, getReports } from "@/lib/api";
6
+ import { ReportData, ReportStatus } from "@/types";
7
+ import { Card, CardContent, Button } from "@/components/ui/basic";
8
+ import { Badge } from "@/components/ui/badge";
9
+ import { Eye, Check, X, Clock, RefreshCw, Trash2, Calendar } from "lucide-react";
10
+ import { ReportView } from "@/components/dashboard/ReportView";
11
+ import { ImageViewer } from "@/components/dashboard/ImageViewer";
12
+
13
+ // Helper to filter reports by status
14
+ // Defaults to 'Pending' if status is missing or 'Preliminary'
15
+ const getStatus = (r: any): ReportStatus => {
16
+ const s = r.report_data?.report_footer?.report_status;
17
+ if (s === 'Approved') return 'Approved';
18
+ if (s === 'Rejected') return 'Rejected';
19
+ return 'Pending';
20
+ };
21
+
22
+ import { Suspense } from "react";
23
+ import { ApprovalModal } from "@/components/dashboard/ApprovalModal";
24
+ import { RejectionModal } from "@/components/dashboard/RejectionModal";
25
+
26
+ function ReportsContent() {
27
+ const router = useRouter();
28
+ const [reports, setReports] = React.useState<any[]>([]);
29
+ const [loading, setLoading] = React.useState(true);
30
+ const [selectedReport, setSelectedReport] = React.useState<{ data: ReportData, id: string } | null>(null);
31
+ const [processingId, setProcessingId] = React.useState<string | null>(null);
32
+ const [cameFromExternal, setCameFromExternal] = React.useState(false);
33
+
34
+ // Modal State
35
+ const [actionReport, setActionReport] = React.useState<{ id: string, name: string } | null>(null);
36
+ const [showApproval, setShowApproval] = React.useState(false);
37
+ const [showRejection, setShowRejection] = React.useState(false);
38
+
39
+ // Get current user from localStorage
40
+ const [currentUser, setCurrentUser] = React.useState({ name: "Dr. User", role: "Radiologist" });
41
+
42
+ React.useEffect(() => {
43
+ fetch('/api/settings?type=profile')
44
+ .then(res => res.json())
45
+ .then(data => {
46
+ setCurrentUser({
47
+ name: data.fullName || "Dr. User",
48
+ role: data.role || "Radiologist"
49
+ });
50
+ })
51
+ .catch(e => console.error("Error loading profile:", e));
52
+ }, []);
53
+
54
+ const loadReports = async () => {
55
+ setLoading(true);
56
+ try {
57
+ // Use getReports() which merges Supabase (real UUIDs) + localStorage
58
+ // This ensures we pass real UUIDs to updateReportStatus so Supabase gets updated
59
+ const data = await getReports();
60
+ console.log("Reports loaded:", data?.length || 0, "reports");
61
+ setReports(data || []);
62
+ } catch (e) {
63
+ console.error("Error loading reports", e);
64
+ setReports([]);
65
+ }
66
+ setLoading(false);
67
+ };
68
+
69
+ const searchParams = useSearchParams();
70
+ const autoOpenId = searchParams.get('id');
71
+
72
+ React.useEffect(() => {
73
+ if (autoOpenId) setCameFromExternal(true);
74
+ loadReports();
75
+ }, []);
76
+
77
+ // Auto-open a report if ?id= is in the URL
78
+ React.useEffect(() => {
79
+ if (autoOpenId && reports.length > 0 && !selectedReport) {
80
+ const target = reports.find(r => r.id === autoOpenId);
81
+ if (target) {
82
+ setSelectedReport({ data: target.report_data, id: target.id });
83
+ }
84
+ }
85
+ }, [autoOpenId, reports]);
86
+
87
+ React.useEffect(() => {
88
+ if (selectedReport && reports.length > 0) {
89
+ const fresh = reports.find(r => r.id === selectedReport.id);
90
+ if (fresh && fresh.report_data && JSON.stringify(fresh.report_data) !== JSON.stringify(selectedReport.data)) {
91
+ setSelectedReport({ data: fresh.report_data, id: fresh.id });
92
+ }
93
+ }
94
+ }, [reports]);
95
+
96
+ const openApproval = (id: string, name: string) => {
97
+ setActionReport({ id, name });
98
+ setShowApproval(true);
99
+ };
100
+
101
+ const openRejection = (id: string, name: string) => {
102
+ setActionReport({ id, name });
103
+ setShowRejection(true);
104
+ };
105
+
106
+ const handleApprovalConfirm = async (signature: string, comments?: string) => {
107
+ if (!actionReport) return;
108
+ setProcessingId(actionReport.id);
109
+ const success = await updateReportStatus(actionReport.id, 'Approved', { signature, notes: comments });
110
+ if (success) {
111
+ await loadReports();
112
+ setShowApproval(false);
113
+ setActionReport(null);
114
+ }
115
+ setProcessingId(null);
116
+ };
117
+
118
+ const handleRejectionConfirm = async (reason: string) => {
119
+ if (!actionReport) return;
120
+ setProcessingId(actionReport.id);
121
+ const success = await updateReportStatus(actionReport.id, 'Rejected', { rejectionReason: reason });
122
+ if (success) {
123
+ await loadReports();
124
+ setShowRejection(false);
125
+ setActionReport(null);
126
+ }
127
+ setProcessingId(null);
128
+ };
129
+
130
+ const handleClearAll = async () => {
131
+ if (confirm("Are you sure you want to clear the board? This will hide the reports from this view but keep them in your history.")) {
132
+ setReports([]);
133
+ }
134
+ };
135
+
136
+ const handleView = (reportData: ReportData, reportId: string) => {
137
+ setSelectedReport({ data: reportData, id: reportId });
138
+ };
139
+
140
+ // Sorting logic helpers
141
+ const sortNewest = (a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
142
+ const sortOldest = (a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
143
+
144
+ // Pending: Queue (Oldest First)
145
+ const pendingReports = reports.filter(r => getStatus(r) === 'Pending').sort(sortOldest);
146
+ // Approved/Rejected: History (Newest First)
147
+ const approvedReports = reports.filter(r => getStatus(r) === 'Approved').sort(sortNewest);
148
+ const rejectedReports = reports.filter(r => getStatus(r) === 'Rejected').sort(sortNewest);
149
+
150
+ if (selectedReport) {
151
+ // Ensure report has proper status
152
+ const reportData = selectedReport.data;
153
+ if (!reportData.report_footer) {
154
+ reportData.report_footer = {
155
+ prepared_by: "OmniRad AI",
156
+ department: "Radiology",
157
+ report_status: "Pending"
158
+ };
159
+ }
160
+ if (!reportData.report_footer.report_status) {
161
+ reportData.report_footer.report_status = "Pending";
162
+ }
163
+
164
+
165
+ return (
166
+ <div className="h-full flex flex-col md:flex-row overflow-hidden bg-bg-primary">
167
+ {/* Left Panel - Image Viewer */}
168
+ <div className="w-full md:w-[450px] md:min-w-[450px] h-auto md:h-full border-b md:border-b-0 md:border-r border-border-primary bg-bg-primary overflow-hidden">
169
+ <ImageViewer
170
+ imageSrc={reportData.image_data || null}
171
+ images={reportData.images_data && reportData.images_data.length > 0 ? reportData.images_data : (reportData.image_data ? [reportData.image_data] : [])}
172
+ className="w-full h-full"
173
+ isCollapsed={false}
174
+ onToggleCollapse={() => { }}
175
+ />
176
+ </div>
177
+
178
+ {/* Right Panel - Report View */}
179
+ <div className="flex-1 h-full bg-bg-primary p-4 overflow-hidden flex flex-col">
180
+ <Button
181
+ variant="ghost"
182
+ onClick={() => {
183
+ if (cameFromExternal) {
184
+ router.back();
185
+ } else {
186
+ setSelectedReport(null);
187
+ }
188
+ }}
189
+ className="mb-2 self-start gap-2"
190
+ >
191
+ ← {cameFromExternal ? 'Back' : 'Back to Board'}
192
+ </Button>
193
+ <div className="flex-1 overflow-hidden">
194
+ <ReportView
195
+ report={reportData}
196
+ onNewPatient={() => setSelectedReport(null)}
197
+ reportId={selectedReport.id}
198
+ onStatusChange={loadReports}
199
+ />
200
+ </div>
201
+ </div>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ // If we arrived via ?id= and the report hasn't been selected yet, show a loader
207
+ // instead of flashing the board view
208
+ if (autoOpenId && !selectedReport) {
209
+ return (
210
+ <div className="h-full flex items-center justify-center bg-bg-primary">
211
+ <div className="w-8 h-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" />
212
+ </div>
213
+ );
214
+ }
215
+
216
+ return (
217
+ <div className="p-6 h-full flex flex-col gap-6 overflow-hidden bg-bg-primary text-text-primary">
218
+ <div className="flex justify-between items-center shrink-0">
219
+ <div>
220
+ <h2 className="text-2xl font-semibold text-text-heading">Reports Board</h2>
221
+ <p className="text-text-secondary text-sm">Manage radiology reports workflow.</p>
222
+ </div>
223
+ <div className="flex gap-2">
224
+ <Button variant="outline" onClick={handleClearAll} disabled={loading || reports.length === 0} className="text-red-500 hover:text-red-600 hover:bg-red-50 border-red-200">
225
+ <Trash2 size={16} className="mr-2" /> Clear Board
226
+ </Button>
227
+ <Button variant="outline" onClick={loadReports} disabled={loading}>
228
+ <RefreshCw size={16} className={`mr-2 ${loading ? 'animate-spin' : ''}`} /> Refresh
229
+ </Button>
230
+ </div>
231
+ </div>
232
+
233
+ <div className="flex-1 overflow-x-auto overflow-y-hidden">
234
+ <div className="flex h-full gap-6 min-w-[1000px]">
235
+ {/* Pending Column */}
236
+ <KanbanColumn
237
+ title="Pending"
238
+ count={pendingReports.length}
239
+ color="border-yellow-500/50"
240
+ headerColor="text-yellow-500"
241
+ >
242
+ {pendingReports.map(report => (
243
+ <ReportCard
244
+ key={report.id}
245
+ report={report}
246
+ onView={() => handleView(report.report_data, report.id)}
247
+ onApprove={() => openApproval(report.id, report.report_data.patient.name)}
248
+ onReject={() => openRejection(report.id, report.report_data.patient.name)}
249
+ processing={processingId === report.id}
250
+ />
251
+ ))}
252
+ </KanbanColumn>
253
+
254
+ {/* Approved Column */}
255
+ <KanbanColumn
256
+ title="Approved"
257
+ count={approvedReports.length}
258
+ color="border-green-500/50"
259
+ headerColor="text-green-500"
260
+ >
261
+ {approvedReports.map(report => (
262
+ <ReportCard
263
+ key={report.id}
264
+ report={report}
265
+ onView={() => handleView(report.report_data, report.id)}
266
+ status="approved"
267
+ />
268
+ ))}
269
+ </KanbanColumn>
270
+
271
+ {/* Rejected Column */}
272
+ <KanbanColumn
273
+ title="Rejected"
274
+ count={rejectedReports.length}
275
+ color="border-red-500/50"
276
+ headerColor="text-red-500"
277
+ >
278
+ {rejectedReports.map(report => (
279
+ <ReportCard
280
+ key={report.id}
281
+ report={report}
282
+ onView={() => handleView(report.report_data, report.id)}
283
+ status="rejected"
284
+ />
285
+ ))}
286
+ </KanbanColumn>
287
+ </div>
288
+ </div>
289
+
290
+ {/* Modals */}
291
+ <ApprovalModal
292
+ isOpen={showApproval}
293
+ onClose={() => {
294
+ setShowApproval(false);
295
+ setActionReport(null);
296
+ }}
297
+ onConfirm={handleApprovalConfirm}
298
+ currentUser={currentUser}
299
+ />
300
+ <RejectionModal
301
+ isOpen={showRejection}
302
+ onClose={() => {
303
+ setShowRejection(false);
304
+ setActionReport(null);
305
+ }}
306
+ onConfirm={handleRejectionConfirm}
307
+ />
308
+ </div>
309
+ );
310
+ }
311
+
312
+ export default function ReportsPage() {
313
+ return (
314
+ <Suspense fallback={<div className="h-full flex items-center justify-center p-8">Loading Reports...</div>}>
315
+ <ReportsContent />
316
+ </Suspense>
317
+ );
318
+ }
319
+
320
+ function KanbanColumn({ title, count, children, color, headerColor }: { title: string, count: number, children: React.ReactNode, color: string, headerColor: string }) {
321
+ return (
322
+ <div className={`flex-1 flex flex-col bg-bg-surface rounded-lg border ${color} h-full overflow-hidden`}>
323
+ <div className={`p-4 font-semibold flex justify-between items-center border-b border-border-primary bg-bg-panel/50 ${headerColor}`}>
324
+ <span>{title}</span>
325
+ <Badge variant="secondary" className="bg-bg-primary text-text-primary">{count}</Badge>
326
+ </div>
327
+ <div className="p-4 flex-1 overflow-y-auto space-y-4">
328
+ {count === 0 ? (
329
+ <div className="text-center text-text-muted text-sm py-8 opacity-50">No reports</div>
330
+ ) : children}
331
+ </div>
332
+ </div>
333
+ );
334
+ }
335
+
336
+ function ReportCard({ report, onView, onApprove, onReject, processing, status }: {
337
+ report: any,
338
+ onView: () => void,
339
+ onApprove?: () => void,
340
+ onReject?: () => void,
341
+ processing?: boolean,
342
+ status?: 'approved' | 'rejected'
343
+ }) {
344
+ const data = report.report_data;
345
+ return (
346
+ <Card className="bg-bg-panel border-border-primary hover:border-text-muted/50 transition-all duration-200 hover:shadow-lg">
347
+ <CardContent className="p-4 space-y-3">
348
+ <div className="flex justify-between items-start">
349
+ <div>
350
+ <p className="font-medium text-text-heading">{data.patient?.name || report.patient_name || 'Unknown Patient'}</p>
351
+ <p className="text-xs text-text-secondary">{data.patient.age}y • {data.patient.gender}</p>
352
+ </div>
353
+ <Badge variant="outline" className={`text-[10px] ${data.urgency === 'Critical' ? 'border-red-500 text-red-500' :
354
+ data.urgency === 'Urgent' ? 'border-yellow-500 text-yellow-500' :
355
+ 'border-green-500 text-green-500'
356
+ }`}>
357
+ {data.urgency}
358
+ </Badge>
359
+ </div>
360
+
361
+ <div className="flex flex-col gap-1 mt-2">
362
+ <p className="text-xs font-medium text-text-primary">{data.study.modality}</p>
363
+ <div className="flex justify-between items-center text-[10px] text-text-muted">
364
+ <div className="flex items-center gap-1">
365
+ <Calendar size={12} />
366
+ <span>{new Date(report.created_at).toLocaleDateString()}</span>
367
+ </div>
368
+ <div className="flex items-center gap-1">
369
+ <Clock size={12} />
370
+ <span>{new Date(report.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ <div className="pt-2 flex gap-2 justify-end flex-wrap">
376
+ {/* View Button - Always visible */}
377
+ <Button
378
+ size="sm"
379
+ variant="outline"
380
+ className="h-8 px-3 gap-2 text-xs hover:bg-bg-hover transition-all duration-200 hover:scale-105 active:scale-95"
381
+ onClick={onView}
382
+ >
383
+ <Eye size={14} />
384
+ <span>View</span>
385
+ </Button>
386
+
387
+ {/* Approve Button - Only for pending reports */}
388
+ {onApprove && (
389
+ <Button
390
+ size="sm"
391
+ variant="outline"
392
+ className="h-8 px-3 gap-2 text-xs text-green-500 border-green-500/50 hover:bg-green-950/20 hover:border-green-500 transition-all duration-200 hover:scale-105 active:scale-95"
393
+ onClick={onApprove}
394
+ disabled={processing}
395
+ >
396
+ <Check size={14} />
397
+ <span>Approve</span>
398
+ </Button>
399
+ )}
400
+
401
+ {/* Reject Button - Only for pending reports */}
402
+ {onReject && (
403
+ <Button
404
+ size="sm"
405
+ variant="outline"
406
+ className="h-8 px-3 gap-2 text-xs text-red-500 border-red-500/50 hover:bg-red-950/20 hover:border-red-500 transition-all duration-200 hover:scale-105 active:scale-95"
407
+ onClick={onReject}
408
+ disabled={processing}
409
+ >
410
+ <X size={14} />
411
+ <span>Reject</span>
412
+ </Button>
413
+ )}
414
+
415
+ {/* Status indicators for approved/rejected */}
416
+ {status === 'approved' && (
417
+ <div className="flex items-center gap-1 text-green-500 text-xs px-2 py-1 bg-green-950/20 rounded-md border border-green-500/50">
418
+ <Check size={14} />
419
+ <span>Approved</span>
420
+ </div>
421
+ )}
422
+ {status === 'rejected' && (
423
+ <div className="flex items-center gap-1 text-red-500 text-xs px-2 py-1 bg-red-950/20 rounded-md border border-red-500/50">
424
+ <X size={14} />
425
+ <span>Rejected</span>
426
+ </div>
427
+ )}
428
+ </div>
429
+ </CardContent>
430
+ </Card>
431
+ )
432
+ }