@omersx/bemms 0.0.0-stage → 0.1.0

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 (335) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +203 -2
  3. package/drizzle.config.ts +11 -0
  4. package/next.config.ts +51 -0
  5. package/package.json +99 -3
  6. package/postcss.config.mjs +7 -0
  7. package/public/hospital_demo_devices.csv +32 -0
  8. package/public/icons/icon-192.png +0 -0
  9. package/public/icons/icon-512.png +0 -0
  10. package/public/icons/icon-maskable-512.png +0 -0
  11. package/public/manifest.webmanifest +31 -0
  12. package/public/sw.js +177 -0
  13. package/src/app/(auth)/layout.tsx +20 -0
  14. package/src/app/(auth)/login/page.tsx +135 -0
  15. package/src/app/(dashboard)/admin/audit-logs/page.tsx +178 -0
  16. package/src/app/(dashboard)/admin/data/page.tsx +37 -0
  17. package/src/app/(dashboard)/admin/departments/[deptId]/edit/page.tsx +206 -0
  18. package/src/app/(dashboard)/admin/departments/[deptId]/page.tsx +71 -0
  19. package/src/app/(dashboard)/admin/departments/new/page.tsx +6 -0
  20. package/src/app/(dashboard)/admin/departments/page.tsx +6 -0
  21. package/src/app/(dashboard)/admin/general/page.tsx +374 -0
  22. package/src/app/(dashboard)/admin/hospitals/[hospitalId]/edit/page.tsx +304 -0
  23. package/src/app/(dashboard)/admin/hospitals/[hospitalId]/page.tsx +94 -0
  24. package/src/app/(dashboard)/admin/hospitals/new/page.tsx +302 -0
  25. package/src/app/(dashboard)/admin/hospitals/page.tsx +6 -0
  26. package/src/app/(dashboard)/admin/layout.tsx +49 -0
  27. package/src/app/(dashboard)/admin/organizations/[orgId]/edit/page.tsx +172 -0
  28. package/src/app/(dashboard)/admin/organizations/[orgId]/page.tsx +82 -0
  29. package/src/app/(dashboard)/admin/organizations/new/page.tsx +150 -0
  30. package/src/app/(dashboard)/admin/organizations/page.tsx +6 -0
  31. package/src/app/(dashboard)/admin/page.tsx +258 -0
  32. package/src/app/(dashboard)/admin/roles/page.tsx +153 -0
  33. package/src/app/(dashboard)/admin/settings/page.tsx +69 -0
  34. package/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx +153 -0
  35. package/src/app/(dashboard)/admin/users/[userId]/page.tsx +317 -0
  36. package/src/app/(dashboard)/admin/users/invite/page.tsx +3 -0
  37. package/src/app/(dashboard)/admin/users/new/page.tsx +5 -0
  38. package/src/app/(dashboard)/admin/users/page.tsx +23 -0
  39. package/src/app/(dashboard)/dashboard/page.tsx +462 -0
  40. package/src/app/(dashboard)/departments/[deptId]/page.tsx +39 -0
  41. package/src/app/(dashboard)/departments/new/page.tsx +330 -0
  42. package/src/app/(dashboard)/departments/page.tsx +52 -0
  43. package/src/app/(dashboard)/devices/[id]/edit/page.tsx +131 -0
  44. package/src/app/(dashboard)/devices/[id]/page.tsx +408 -0
  45. package/src/app/(dashboard)/devices/[id]/qr/page.tsx +54 -0
  46. package/src/app/(dashboard)/devices/new/page.tsx +382 -0
  47. package/src/app/(dashboard)/devices/page.tsx +80 -0
  48. package/src/app/(dashboard)/engineering-team/page.tsx +41 -0
  49. package/src/app/(dashboard)/layout.tsx +43 -0
  50. package/src/app/(dashboard)/maintenance/checklists/page.tsx +57 -0
  51. package/src/app/(dashboard)/maintenance/page.tsx +231 -0
  52. package/src/app/(dashboard)/maintenance/plans/page.tsx +142 -0
  53. package/src/app/(dashboard)/maintenance/tasks/[id]/page.tsx +997 -0
  54. package/src/app/(dashboard)/maintenance/tasks/page.tsx +119 -0
  55. package/src/app/(dashboard)/more/page.tsx +310 -0
  56. package/src/app/(dashboard)/my-department/page.tsx +151 -0
  57. package/src/app/(dashboard)/notifications/notification-list.tsx +103 -0
  58. package/src/app/(dashboard)/notifications/page.tsx +59 -0
  59. package/src/app/(dashboard)/profile/page.tsx +27 -0
  60. package/src/app/(dashboard)/reports/costs/page.tsx +127 -0
  61. package/src/app/(dashboard)/reports/history/page.tsx +105 -0
  62. package/src/app/(dashboard)/reports/inventory/page.tsx +116 -0
  63. package/src/app/(dashboard)/reports/maintenance/page.tsx +136 -0
  64. package/src/app/(dashboard)/reports/page.tsx +120 -0
  65. package/src/app/(dashboard)/reports/tickets/page.tsx +139 -0
  66. package/src/app/(dashboard)/reports/workload/page.tsx +135 -0
  67. package/src/app/(dashboard)/rounds/[id]/page.tsx +197 -0
  68. package/src/app/(dashboard)/rounds/history/page.tsx +82 -0
  69. package/src/app/(dashboard)/rounds/page.tsx +107 -0
  70. package/src/app/(dashboard)/scan/[code]/page.tsx +154 -0
  71. package/src/app/(dashboard)/scan/page.tsx +73 -0
  72. package/src/app/(dashboard)/team/page.tsx +42 -0
  73. package/src/app/(dashboard)/tickets/[id]/page.tsx +465 -0
  74. package/src/app/(dashboard)/tickets/create/page.tsx +722 -0
  75. package/src/app/(dashboard)/tickets/page.tsx +55 -0
  76. package/src/app/(dashboard)/tickets/triage/page.tsx +161 -0
  77. package/src/app/api/admin/data/export/route.ts +316 -0
  78. package/src/app/api/auth/[...nextauth]/route.ts +3 -0
  79. package/src/app/api/health/route.ts +46 -0
  80. package/src/app/api/notifications/sse/route.ts +91 -0
  81. package/src/app/api/notifications/subscribe/route.ts +54 -0
  82. package/src/app/api/notifications/vapid-key/route.ts +7 -0
  83. package/src/app/api/reports/export/route.ts +102 -0
  84. package/src/app/api/tickets/[id]/attachments/route.ts +129 -0
  85. package/src/app/api/tickets/[id]/live-sync/route.ts +52 -0
  86. package/src/app/error.tsx +54 -0
  87. package/src/app/globals.css +89 -0
  88. package/src/app/layout.tsx +59 -0
  89. package/src/app/not-found.tsx +32 -0
  90. package/src/app/page.tsx +5 -0
  91. package/src/components/admin/add-department-user-dialog.tsx +759 -0
  92. package/src/components/admin/add-engineer-dialog.tsx +773 -0
  93. package/src/components/admin/add-user-type-dialog.tsx +89 -0
  94. package/src/components/admin/audit-log-export.tsx +113 -0
  95. package/src/components/admin/audit-log-filters.tsx +255 -0
  96. package/src/components/admin/audit-log-table.tsx +383 -0
  97. package/src/components/admin/data-management/danger-zone.tsx +301 -0
  98. package/src/components/admin/data-management/data-management-tabs.tsx +61 -0
  99. package/src/components/admin/data-management/export-center.tsx +301 -0
  100. package/src/components/admin/data-management/import-wizard.tsx +558 -0
  101. package/src/components/admin/settings-forms/features-form.tsx +115 -0
  102. package/src/components/admin/settings-forms/health-panel.tsx +182 -0
  103. package/src/components/admin/settings-forms/localization-form.tsx +203 -0
  104. package/src/components/admin/settings-forms/retention-form.tsx +164 -0
  105. package/src/components/admin/settings-forms/security-form.tsx +186 -0
  106. package/src/components/admin/system-settings-tabs.tsx +88 -0
  107. package/src/components/admin/user-role-dialog.tsx +293 -0
  108. package/src/components/admin/user-status-dialog.tsx +166 -0
  109. package/src/components/admin/users-page-client.tsx +514 -0
  110. package/src/components/dashboard/action-buttons.tsx +93 -0
  111. package/src/components/dashboard/admin-alerts.tsx +86 -0
  112. package/src/components/dashboard/availability-alerts.tsx +65 -0
  113. package/src/components/dashboard/maintenance-due-list.tsx +87 -0
  114. package/src/components/dashboard/round-widget.tsx +110 -0
  115. package/src/components/dashboard/stat-card.tsx +38 -0
  116. package/src/components/dashboard/ticket-list-compact.tsx +86 -0
  117. package/src/components/departments/department-cards-view.tsx +244 -0
  118. package/src/components/departments/department-detail-view.tsx +467 -0
  119. package/src/components/departments/department-visuals.tsx +190 -0
  120. package/src/components/departments/my-department-view.tsx +333 -0
  121. package/src/components/devices/change-status-dialog.tsx +146 -0
  122. package/src/components/devices/device-action-buttons.tsx +125 -0
  123. package/src/components/devices/device-availability-banner.tsx +59 -0
  124. package/src/components/devices/device-badges.tsx +55 -0
  125. package/src/components/devices/device-documents-tab.tsx +93 -0
  126. package/src/components/devices/device-maintenance-tab.tsx +113 -0
  127. package/src/components/devices/device-mini-qr.tsx +109 -0
  128. package/src/components/devices/device-qr-card.tsx +187 -0
  129. package/src/components/devices/device-signatures-tab.tsx +607 -0
  130. package/src/components/devices/device-tickets-tab.tsx +139 -0
  131. package/src/components/devices/device-timelines.tsx +124 -0
  132. package/src/components/devices/qr-camera-scanner.tsx +167 -0
  133. package/src/components/devices/qr-code-display.tsx +139 -0
  134. package/src/components/devices/quick-add-category-dialog.tsx +135 -0
  135. package/src/components/devices/quick-add-manufacturer-dialog.tsx +212 -0
  136. package/src/components/devices/transfer-device-dialog.tsx +190 -0
  137. package/src/components/layout/header.tsx +80 -0
  138. package/src/components/layout/mobile-nav.tsx +91 -0
  139. package/src/components/layout/more-item-badge.tsx +15 -0
  140. package/src/components/layout/nav-badge.tsx +72 -0
  141. package/src/components/layout/sidebar.tsx +297 -0
  142. package/src/components/maintenance/maintenance-badges.tsx +579 -0
  143. package/src/components/maintenance/maintenance-nav.tsx +107 -0
  144. package/src/components/maintenance/parts-costs-manager.tsx +909 -0
  145. package/src/components/maintenance/signature-card.tsx +413 -0
  146. package/src/components/maintenance/task-action-bar.tsx +955 -0
  147. package/src/components/notifications/notification-bell.tsx +367 -0
  148. package/src/components/notifications/notification-item.tsx +93 -0
  149. package/src/components/notifications/notification-preferences.tsx +182 -0
  150. package/src/components/profile/user-profile-view.tsx +626 -0
  151. package/src/components/providers/nav-counts-provider.tsx +235 -0
  152. package/src/components/providers/session-provider.tsx +7 -0
  153. package/src/components/providers/theme-provider.tsx +124 -0
  154. package/src/components/pwa/install-prompt.tsx +82 -0
  155. package/src/components/pwa/sw-register.tsx +43 -0
  156. package/src/components/reports/export-button.tsx +62 -0
  157. package/src/components/reports/filter-chips.tsx +47 -0
  158. package/src/components/reports/summary-cards.tsx +40 -0
  159. package/src/components/reports/transparency-header.tsx +54 -0
  160. package/src/components/rounds/department-visit-sheet.tsx +199 -0
  161. package/src/components/rounds/mention-textarea.tsx +187 -0
  162. package/src/components/rounds/round-calendar.tsx +233 -0
  163. package/src/components/rounds/round-department-list.tsx +281 -0
  164. package/src/components/rounds/round-history-client.tsx +218 -0
  165. package/src/components/rounds/start-round-card.tsx +184 -0
  166. package/src/components/shared/confirm-dialog.tsx +67 -0
  167. package/src/components/shared/data-table.tsx +104 -0
  168. package/src/components/shared/page-header.tsx +20 -0
  169. package/src/components/shared/sign-out-button.tsx +52 -0
  170. package/src/components/shared/status-badge.tsx +42 -0
  171. package/src/components/shared/table-search.tsx +55 -0
  172. package/src/components/signatures/sign-action-dialog.tsx +253 -0
  173. package/src/components/signatures/signature-history.tsx +121 -0
  174. package/src/components/signatures/signature-status-panel.tsx +164 -0
  175. package/src/components/team/department-coverage.tsx +141 -0
  176. package/src/components/team/edit-engineer-dialog.tsx +242 -0
  177. package/src/components/team/engineer-card.tsx +180 -0
  178. package/src/components/team/engineer-details-dialog.tsx +432 -0
  179. package/src/components/team/schedule-calendar.tsx +204 -0
  180. package/src/components/team/staff-engineer-team-view.tsx +368 -0
  181. package/src/components/team/team-header-stats.tsx +88 -0
  182. package/src/components/team/team-page-client.tsx +216 -0
  183. package/src/components/theme/theme-selector-card.tsx +63 -0
  184. package/src/components/theme/theme-toggle.tsx +65 -0
  185. package/src/components/tickets/staff-status-label.tsx +58 -0
  186. package/src/components/tickets/staff-ticket-card.tsx +109 -0
  187. package/src/components/tickets/staff-ticket-progress-tracker.tsx +405 -0
  188. package/src/components/tickets/staff-tickets-view.tsx +182 -0
  189. package/src/components/tickets/ticket-action-buttons.tsx +873 -0
  190. package/src/components/tickets/ticket-auto-refresh.tsx +197 -0
  191. package/src/components/tickets/ticket-badges.tsx +43 -0
  192. package/src/components/tickets/ticket-card.tsx +197 -0
  193. package/src/components/tickets/ticket-cards-view.tsx +395 -0
  194. package/src/components/tickets/ticket-comment-section.tsx +684 -0
  195. package/src/components/tickets/ticket-dispatch-status.tsx +184 -0
  196. package/src/components/tickets/ticket-list-auto-refresh.tsx +55 -0
  197. package/src/components/tickets/ticket-reporter-profile.tsx +235 -0
  198. package/src/components/tickets/ticket-signatures-panel.tsx +32 -0
  199. package/src/components/tickets/ticket-timeline.tsx +131 -0
  200. package/src/components/tickets/ticket-triage-panel.tsx +124 -0
  201. package/src/components/tickets/tickets-nav.tsx +107 -0
  202. package/src/components/ui/avatar.tsx +43 -0
  203. package/src/components/ui/badge.tsx +34 -0
  204. package/src/components/ui/button.tsx +58 -0
  205. package/src/components/ui/card.tsx +77 -0
  206. package/src/components/ui/checkbox.tsx +31 -0
  207. package/src/components/ui/dialog.tsx +123 -0
  208. package/src/components/ui/dropdown-menu.tsx +126 -0
  209. package/src/components/ui/form.tsx +179 -0
  210. package/src/components/ui/input.tsx +24 -0
  211. package/src/components/ui/label.tsx +27 -0
  212. package/src/components/ui/popover.tsx +34 -0
  213. package/src/components/ui/progress.tsx +36 -0
  214. package/src/components/ui/scroll-area.tsx +47 -0
  215. package/src/components/ui/select.tsx +160 -0
  216. package/src/components/ui/separator.tsx +25 -0
  217. package/src/components/ui/sheet.tsx +131 -0
  218. package/src/components/ui/switch.tsx +30 -0
  219. package/src/components/ui/table.tsx +121 -0
  220. package/src/components/ui/tabs.tsx +62 -0
  221. package/src/components/ui/textarea.tsx +23 -0
  222. package/src/components/ui/tooltip.tsx +27 -0
  223. package/src/lib/actions/__tests__/devices.test.ts +176 -0
  224. package/src/lib/actions/__tests__/signatures.test.ts +303 -0
  225. package/src/lib/actions/__tests__/tickets.test.ts +243 -0
  226. package/src/lib/actions/audit-logs.ts +298 -0
  227. package/src/lib/actions/checklists.ts +429 -0
  228. package/src/lib/actions/dashboard.ts +358 -0
  229. package/src/lib/actions/data-management.ts +723 -0
  230. package/src/lib/actions/departments.ts +369 -0
  231. package/src/lib/actions/device-categories.ts +117 -0
  232. package/src/lib/actions/devices.ts +748 -0
  233. package/src/lib/actions/engineer-rounds.ts +973 -0
  234. package/src/lib/actions/engineers.ts +285 -0
  235. package/src/lib/actions/hospitals.ts +148 -0
  236. package/src/lib/actions/locations.ts +115 -0
  237. package/src/lib/actions/maintenance-plans.ts +316 -0
  238. package/src/lib/actions/maintenance-tasks.ts +415 -0
  239. package/src/lib/actions/manufacturers.ts +132 -0
  240. package/src/lib/actions/nav-counts.ts +144 -0
  241. package/src/lib/actions/notifications.ts +136 -0
  242. package/src/lib/actions/organizations.ts +99 -0
  243. package/src/lib/actions/qr.ts +147 -0
  244. package/src/lib/actions/report-history.ts +77 -0
  245. package/src/lib/actions/reports.ts +192 -0
  246. package/src/lib/actions/roles.ts +32 -0
  247. package/src/lib/actions/signature-auth.ts +377 -0
  248. package/src/lib/actions/signatures.ts +532 -0
  249. package/src/lib/actions/system-settings.ts +274 -0
  250. package/src/lib/actions/tickets.ts +1445 -0
  251. package/src/lib/actions/users.ts +740 -0
  252. package/src/lib/audit/index.ts +103 -0
  253. package/src/lib/auth/__tests__/rbac.test.ts +33 -0
  254. package/src/lib/auth/index.ts +338 -0
  255. package/src/lib/auth/password.ts +10 -0
  256. package/src/lib/auth/rbac.ts +103 -0
  257. package/src/lib/auth/types.ts +57 -0
  258. package/src/lib/constants/departments.ts +16 -0
  259. package/src/lib/constants/manufacturers.ts +32 -0
  260. package/src/lib/db/index.ts +23 -0
  261. package/src/lib/db/schema/audit-logs.ts +42 -0
  262. package/src/lib/db/schema/departments.ts +39 -0
  263. package/src/lib/db/schema/device-categories.ts +38 -0
  264. package/src/lib/db/schema/devices.ts +195 -0
  265. package/src/lib/db/schema/engineer-profiles.ts +63 -0
  266. package/src/lib/db/schema/engineer-rounds.ts +115 -0
  267. package/src/lib/db/schema/enums.ts +148 -0
  268. package/src/lib/db/schema/hospitals.ts +34 -0
  269. package/src/lib/db/schema/index.ts +24 -0
  270. package/src/lib/db/schema/locations.ts +46 -0
  271. package/src/lib/db/schema/maintenance-plans.ts +99 -0
  272. package/src/lib/db/schema/maintenance-records.ts +161 -0
  273. package/src/lib/db/schema/maintenance-tasks.ts +196 -0
  274. package/src/lib/db/schema/manufacturers.ts +60 -0
  275. package/src/lib/db/schema/notifications.ts +48 -0
  276. package/src/lib/db/schema/organizations.ts +24 -0
  277. package/src/lib/db/schema/report-history.ts +28 -0
  278. package/src/lib/db/schema/roles.ts +59 -0
  279. package/src/lib/db/schema/sessions.ts +46 -0
  280. package/src/lib/db/schema/signature-events.ts +48 -0
  281. package/src/lib/db/schema/signature-policies.ts +60 -0
  282. package/src/lib/db/schema/signatures.ts +87 -0
  283. package/src/lib/db/schema/tickets.ts +169 -0
  284. package/src/lib/db/schema/user-assignments.ts +85 -0
  285. package/src/lib/db/schema/users.ts +39 -0
  286. package/src/lib/db/seed.ts +131 -0
  287. package/src/lib/logger/__tests__/logger.test.ts +154 -0
  288. package/src/lib/logger/formatters.ts +159 -0
  289. package/src/lib/logger/index.ts +184 -0
  290. package/src/lib/logger/types.ts +48 -0
  291. package/src/lib/notifications/create.ts +71 -0
  292. package/src/lib/notifications/dispatcher.ts +490 -0
  293. package/src/lib/notifications/index.ts +7 -0
  294. package/src/lib/notifications/push-sender.ts +128 -0
  295. package/src/lib/notifications/push.ts +64 -0
  296. package/src/lib/notifications/triggers.ts +166 -0
  297. package/src/lib/utils/__tests__/crypto.test.ts +30 -0
  298. package/src/lib/utils/__tests__/export.test.ts +55 -0
  299. package/src/lib/utils/__tests__/nav-badge.test.ts +147 -0
  300. package/src/lib/utils/__tests__/staff-status.test.ts +50 -0
  301. package/src/lib/utils/attestations.ts +19 -0
  302. package/src/lib/utils/crypto.ts +18 -0
  303. package/src/lib/utils/csv-parser.ts +181 -0
  304. package/src/lib/utils/export.ts +45 -0
  305. package/src/lib/utils/nav-badge.ts +225 -0
  306. package/src/lib/utils/sound.ts +152 -0
  307. package/src/lib/utils/staff-status.ts +115 -0
  308. package/src/lib/utils.ts +6 -0
  309. package/src/lib/validators/__tests__/auth.test.ts +127 -0
  310. package/src/lib/validators/__tests__/data-management.test.ts +182 -0
  311. package/src/lib/validators/__tests__/devices-profile.test.ts +129 -0
  312. package/src/lib/validators/__tests__/devices.test.ts +98 -0
  313. package/src/lib/validators/__tests__/engineer-rounds.test.ts +170 -0
  314. package/src/lib/validators/__tests__/maintenance.test.ts +178 -0
  315. package/src/lib/validators/__tests__/signatures.test.ts +154 -0
  316. package/src/lib/validators/__tests__/system-settings.test.ts +119 -0
  317. package/src/lib/validators/__tests__/tickets.test.ts +176 -0
  318. package/src/lib/validators/__tests__/users.test.ts +92 -0
  319. package/src/lib/validators/auth.ts +32 -0
  320. package/src/lib/validators/data-management.ts +67 -0
  321. package/src/lib/validators/departments.ts +14 -0
  322. package/src/lib/validators/devices.ts +94 -0
  323. package/src/lib/validators/engineer-rounds.ts +70 -0
  324. package/src/lib/validators/hospitals.ts +17 -0
  325. package/src/lib/validators/locations.ts +15 -0
  326. package/src/lib/validators/maintenance.ts +211 -0
  327. package/src/lib/validators/organizations.ts +11 -0
  328. package/src/lib/validators/reports.ts +31 -0
  329. package/src/lib/validators/signatures.ts +49 -0
  330. package/src/lib/validators/system-settings.ts +55 -0
  331. package/src/lib/validators/tickets.ts +125 -0
  332. package/src/lib/validators/users.ts +43 -0
  333. package/src/middleware.ts +45 -0
  334. package/tailwind.config.ts +76 -0
  335. package/tsconfig.json +28 -0
@@ -0,0 +1,684 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { toast } from "sonner";
6
+ import { addTicketComment } from "@/lib/actions/tickets";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Textarea } from "@/components/ui/textarea";
9
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
10
+ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
11
+ import {
12
+ Send,
13
+ Lock,
14
+ Globe,
15
+ Loader2,
16
+ Wrench,
17
+ MessageCircle,
18
+ Stethoscope,
19
+ Paperclip,
20
+ Image as ImageIcon,
21
+ FileText,
22
+ Download,
23
+ X,
24
+ File as FileIcon,
25
+ } from "lucide-react";
26
+ import { cn } from "@/lib/utils";
27
+
28
+ interface Comment {
29
+ id: string;
30
+ body: string;
31
+ visibility: string;
32
+ createdAt: Date | string;
33
+ authorUserId?: string;
34
+ author?: {
35
+ id?: string;
36
+ fullName?: string | null;
37
+ } | null;
38
+ }
39
+
40
+ interface PendingAttachment {
41
+ id: string;
42
+ file: File;
43
+ previewUrl?: string;
44
+ isImage: boolean;
45
+ }
46
+
47
+ interface ParsedAttachment {
48
+ name: string;
49
+ url: string;
50
+ size: number;
51
+ type?: string;
52
+ }
53
+
54
+ const QUICK_CHAT_TEMPLATES = [
55
+ "When is this expected to be ready for patient care?",
56
+ "The device is currently in Room / Bed for inspection.",
57
+ "Please call our nurse station extension when arriving.",
58
+ "Thank you, device is verified and working properly.",
59
+ ];
60
+
61
+ function formatFileSize(bytes?: number): string {
62
+ if (!bytes || bytes === 0) return "0 B";
63
+ const k = 1024;
64
+ const sizes = ["B", "KB", "MB", "GB"];
65
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
66
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
67
+ }
68
+
69
+ function parseCommentBody(rawBody: string): { text: string; attachments: ParsedAttachment[] } {
70
+ const attachments: ParsedAttachment[] = [];
71
+ const text = rawBody
72
+ .replace(/\[attachment:(\{.*?\})\]/g, (_, jsonStr) => {
73
+ try {
74
+ const parsed = JSON.parse(jsonStr);
75
+ if (parsed.url && parsed.name) {
76
+ attachments.push(parsed);
77
+ }
78
+ return "";
79
+ } catch {
80
+ return "";
81
+ }
82
+ })
83
+ .trim();
84
+
85
+ return { text, attachments };
86
+ }
87
+
88
+ export function TicketCommentSection({
89
+ ticketId,
90
+ comments,
91
+ canAddInternal = false,
92
+ currentUserId,
93
+ assignedEngineerUserId,
94
+ assignedEngineerName,
95
+ }: {
96
+ ticketId: string;
97
+ comments: Comment[];
98
+ canAddInternal?: boolean;
99
+ currentUserId?: string;
100
+ assignedEngineerUserId?: string | null;
101
+ assignedEngineerName?: string | null;
102
+ }) {
103
+ const router = useRouter();
104
+ const [body, setBody] = useState("");
105
+ const [visibility, setVisibility] = useState<"public" | "internal">("public");
106
+ const [loading, setLoading] = useState(false);
107
+ const [pendingFiles, setPendingFiles] = useState<PendingAttachment[]>([]);
108
+ const scrollRef = useRef<HTMLDivElement>(null);
109
+ const fileInputRef = useRef<HTMLInputElement>(null);
110
+ const imageInputRef = useRef<HTMLInputElement>(null);
111
+
112
+ const [optimisticComments, setOptimisticComments] = useState<Comment[]>([]);
113
+
114
+ // Clear optimistic comments when server re-renders with new props
115
+ useEffect(() => {
116
+ setOptimisticComments([]);
117
+ }, [comments]);
118
+
119
+ const allComments = [...comments, ...optimisticComments];
120
+
121
+ // If user cannot add internal notes (e.g. ward staff), filter out internal comments
122
+ const visibleComments = canAddInternal
123
+ ? allComments
124
+ : allComments.filter((c) => c.visibility === "public");
125
+
126
+ // Sort chronologically (oldest first) like a chat thread
127
+ const sortedComments = [...visibleComments].sort(
128
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
129
+ );
130
+
131
+ // Auto-scroll to bottom on load and whenever comments change
132
+ useEffect(() => {
133
+ if (scrollRef.current) {
134
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
135
+ }
136
+ }, [comments, optimisticComments]);
137
+
138
+ // Handle file selection from input
139
+ const addFiles = (files: FileList | File[]) => {
140
+ const newItems: PendingAttachment[] = [];
141
+ Array.from(files).forEach((file) => {
142
+ // 20MB limit
143
+ if (file.size > 20 * 1024 * 1024) {
144
+ toast.error(`File "${file.name}" exceeds 20MB limit`);
145
+ return;
146
+ }
147
+ const isImg = file.type.startsWith("image/");
148
+ newItems.push({
149
+ id: `${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
150
+ file,
151
+ isImage: isImg,
152
+ previewUrl: isImg ? URL.createObjectURL(file) : undefined,
153
+ });
154
+ });
155
+
156
+ if (newItems.length > 0) {
157
+ setPendingFiles((prev) => [...prev, ...newItems]);
158
+ }
159
+ };
160
+
161
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
162
+ if (e.target.files && e.target.files.length > 0) {
163
+ addFiles(e.target.files);
164
+ e.target.value = "";
165
+ }
166
+ };
167
+
168
+ const removePendingFile = (id: string) => {
169
+ setPendingFiles((prev) => {
170
+ const target = prev.find((item) => item.id === id);
171
+ if (target?.previewUrl) {
172
+ URL.revokeObjectURL(target.previewUrl);
173
+ }
174
+ return prev.filter((item) => item.id !== id);
175
+ });
176
+ };
177
+
178
+ // Support pasting images from clipboard
179
+ const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
180
+ if (e.clipboardData.files && e.clipboardData.files.length > 0) {
181
+ const files = Array.from(e.clipboardData.files);
182
+ const images = files.filter((f) => f.type.startsWith("image/"));
183
+ if (images.length > 0) {
184
+ e.preventDefault();
185
+ addFiles(images);
186
+ toast.info(`Added ${images.length} image(s) from clipboard`);
187
+ }
188
+ }
189
+ };
190
+
191
+ const handleSubmit = async () => {
192
+ const text = body.trim();
193
+ if ((!text && pendingFiles.length === 0) || loading) return;
194
+ setLoading(true);
195
+
196
+ // Save copy of pending files to process
197
+ const filesToUpload = [...pendingFiles];
198
+ const currentVis = canAddInternal ? visibility : "public";
199
+
200
+ // Create optimistic display
201
+ let optimisticBody = text;
202
+ filesToUpload.forEach((p) => {
203
+ optimisticBody += `\n[attachment:{"name":"${p.file.name}","url":"${
204
+ p.previewUrl || "#"
205
+ }","size":${p.file.size},"type":"${p.file.type}"}]`;
206
+ });
207
+
208
+ const tempComment: Comment = {
209
+ id: `temp-${Date.now()}`,
210
+ body: optimisticBody.trim(),
211
+ visibility: currentVis,
212
+ createdAt: new Date(),
213
+ authorUserId: currentUserId,
214
+ author: {
215
+ id: currentUserId,
216
+ fullName: "You",
217
+ },
218
+ };
219
+
220
+ setOptimisticComments((prev) => [...prev, tempComment]);
221
+ setBody("");
222
+ setPendingFiles([]);
223
+
224
+ try {
225
+ // 1. Upload files first if any
226
+ const uploadedTags: string[] = [];
227
+ for (const item of filesToUpload) {
228
+ const formData = new FormData();
229
+ formData.append("file", item.file);
230
+ formData.append("visibility", currentVis);
231
+
232
+ const uploadRes = await fetch(`/api/tickets/${ticketId}/attachments`, {
233
+ method: "POST",
234
+ body: formData,
235
+ });
236
+
237
+ if (!uploadRes.ok) {
238
+ const errData = await uploadRes.json();
239
+ throw new Error(errData.error || `Failed to upload ${item.file.name}`);
240
+ }
241
+
242
+ const data = await uploadRes.json();
243
+ if (data.success && data.attachment) {
244
+ const tag = `[attachment:${JSON.stringify({
245
+ name: data.attachment.fileName,
246
+ url: data.attachment.url,
247
+ size: data.attachment.fileSize,
248
+ type: data.attachment.mimeType,
249
+ })}]`;
250
+ uploadedTags.push(tag);
251
+ }
252
+ }
253
+
254
+ // 2. Assemble comment body with attachment tags
255
+ let finalBody = text;
256
+ if (uploadedTags.length > 0) {
257
+ finalBody = finalBody ? `${finalBody}\n\n${uploadedTags.join("\n")}` : uploadedTags.join("\n");
258
+ }
259
+
260
+ // 3. Post comment
261
+ const res = await addTicketComment(ticketId, {
262
+ body: finalBody.trim(),
263
+ visibility: currentVis,
264
+ });
265
+
266
+ if (res?.success) {
267
+ router.refresh();
268
+ toast.success(
269
+ currentVis === "internal"
270
+ ? "Internal note posted"
271
+ : filesToUpload.length > 0
272
+ ? "Message with attachments sent"
273
+ : "Message sent"
274
+ );
275
+ } else {
276
+ // Rollback on failure
277
+ setOptimisticComments((prev) => prev.filter((c) => c.id !== tempComment.id));
278
+ setBody(text);
279
+ setPendingFiles(filesToUpload);
280
+ toast.error((res as any)?.error || "Failed to post message");
281
+ }
282
+ } catch (err: any) {
283
+ setOptimisticComments((prev) => prev.filter((c) => c.id !== tempComment.id));
284
+ setBody(text);
285
+ setPendingFiles(filesToUpload);
286
+ toast.error(err.message || "An unexpected error occurred");
287
+ } finally {
288
+ setLoading(false);
289
+ }
290
+ };
291
+
292
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
293
+ // Send on Enter (without Shift)
294
+ if (e.key === "Enter" && !e.shiftKey) {
295
+ e.preventDefault();
296
+ handleSubmit();
297
+ }
298
+ };
299
+
300
+ return (
301
+ <div className="flex flex-col h-[580px] rounded-xl overflow-hidden bg-background border shadow-xs">
302
+ {/* Hidden File Pickers */}
303
+ <input
304
+ ref={fileInputRef}
305
+ type="file"
306
+ multiple
307
+ className="hidden"
308
+ onChange={handleFileChange}
309
+ />
310
+ <input
311
+ ref={imageInputRef}
312
+ type="file"
313
+ multiple
314
+ accept="image/*"
315
+ className="hidden"
316
+ onChange={handleFileChange}
317
+ />
318
+
319
+ {/* Chat Sub-header */}
320
+ <div className="px-4 py-2.5 bg-muted/40 border-b flex items-center justify-between text-xs text-muted-foreground">
321
+ <div className="flex items-center gap-2">
322
+ <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
323
+ <span>
324
+ {assignedEngineerName
325
+ ? `Direct chat with ${assignedEngineerName}`
326
+ : "Direct chat with Biomedical Engineering"}
327
+ </span>
328
+ </div>
329
+ <span>{sortedComments.length} messages</span>
330
+ </div>
331
+
332
+ {/* Chat History / Bubble Thread Feed */}
333
+ <div
334
+ ref={scrollRef}
335
+ className="flex-1 overflow-y-auto p-4 space-y-3.5 bg-slate-50/50 dark:bg-muted/10"
336
+ >
337
+ {sortedComments.length === 0 ? (
338
+ <div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-2 p-6 text-center">
339
+ <MessageCircle className="w-10 h-10 opacity-20" />
340
+ <p className="text-sm font-medium">No messages yet in this ticket.</p>
341
+ <p className="text-xs max-w-sm">
342
+ Use this chat to ask the assigned engineer questions, confirm room locations, attach error screenshots, or receive live repair updates.
343
+ </p>
344
+ </div>
345
+ ) : (
346
+ sortedComments.map((c) => (
347
+ <ChatBubble
348
+ key={c.id}
349
+ comment={c}
350
+ isCurrentUser={Boolean(
351
+ currentUserId &&
352
+ (c.authorUserId === currentUserId ||
353
+ (c.author as any)?.id === currentUserId)
354
+ )}
355
+ isAssignedEngineer={Boolean(
356
+ assignedEngineerUserId &&
357
+ (c.authorUserId === assignedEngineerUserId ||
358
+ (c.author as any)?.id === assignedEngineerUserId)
359
+ )}
360
+ />
361
+ ))
362
+ )}
363
+ </div>
364
+
365
+ {/* Quick clinical templates */}
366
+ <div className="px-4 py-1.5 bg-card border-t border-border/60 overflow-x-auto flex gap-1.5 scrollbar-none">
367
+ {QUICK_CHAT_TEMPLATES.map((tmpl) => (
368
+ <button
369
+ key={tmpl}
370
+ type="button"
371
+ onClick={() => setBody(tmpl)}
372
+ className="text-[11px] px-2.5 py-1 rounded-full border bg-muted/30 hover:bg-muted hover:text-foreground text-muted-foreground transition whitespace-nowrap shrink-0"
373
+ >
374
+ {tmpl}
375
+ </button>
376
+ ))}
377
+ </div>
378
+
379
+ {/* Chat Input Box */}
380
+ <div className="p-3 bg-card border-t shadow-xs z-10 space-y-2">
381
+ {canAddInternal && (
382
+ <Tabs
383
+ value={visibility}
384
+ onValueChange={(v) => setVisibility(v as "public" | "internal")}
385
+ className="mb-1"
386
+ >
387
+ <TabsList className="grid w-full grid-cols-2 max-w-[340px] h-7">
388
+ <TabsTrigger value="public" className="text-xs data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
389
+ <Globe className="w-3.5 h-3.5 mr-1.5" /> Public Message
390
+ </TabsTrigger>
391
+ <TabsTrigger value="internal" className="text-xs data-[state=active]:bg-amber-500 data-[state=active]:text-white">
392
+ <Lock className="w-3.5 h-3.5 mr-1.5" /> Internal Note
393
+ </TabsTrigger>
394
+ </TabsList>
395
+ </Tabs>
396
+ )}
397
+
398
+ {/* Pending Attachments Tray */}
399
+ {pendingFiles.length > 0 && (
400
+ <div className="flex flex-wrap gap-2 p-2 bg-muted/40 rounded-lg border border-dashed border-border animate-in fade-in">
401
+ {pendingFiles.map((p) => (
402
+ <div
403
+ key={p.id}
404
+ className="relative flex items-center gap-2 bg-background border rounded-lg p-1.5 pr-2.5 text-xs shadow-xs group"
405
+ >
406
+ {p.isImage && p.previewUrl ? (
407
+ <img
408
+ src={p.previewUrl}
409
+ alt={p.file.name}
410
+ className="w-8 h-8 rounded object-cover border"
411
+ />
412
+ ) : (
413
+ <div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center text-primary">
414
+ <FileText className="w-4 h-4" />
415
+ </div>
416
+ )}
417
+ <div className="max-w-[140px] truncate">
418
+ <p className="font-medium truncate text-[11px]">{p.file.name}</p>
419
+ <p className="text-[10px] text-muted-foreground">{formatFileSize(p.file.size)}</p>
420
+ </div>
421
+ <button
422
+ type="button"
423
+ onClick={() => removePendingFile(p.id)}
424
+ className="ml-1 text-muted-foreground hover:text-destructive p-0.5 rounded-full hover:bg-muted"
425
+ title="Remove file"
426
+ >
427
+ <X className="w-3.5 h-3.5" />
428
+ </button>
429
+ </div>
430
+ ))}
431
+ </div>
432
+ )}
433
+
434
+ <div
435
+ className={cn(
436
+ "rounded-xl border transition-all focus-within:ring-2",
437
+ visibility === "internal"
438
+ ? "bg-amber-50/40 dark:bg-amber-950/20 border-amber-300 ring-amber-400"
439
+ : "bg-background border-input ring-primary/20"
440
+ )}
441
+ >
442
+ <Textarea
443
+ value={body}
444
+ onChange={(e) => setBody(e.target.value)}
445
+ onKeyDown={handleKeyDown}
446
+ onPaste={handlePaste}
447
+ placeholder={
448
+ visibility === "internal"
449
+ ? "Type an internal note for biomedical team (Press Enter to send)..."
450
+ : "Type a message to biomedical engineering (or paste screenshot, press Enter to send)..."
451
+ }
452
+ className="border-0 bg-transparent focus-visible:ring-0 resize-none min-h-[60px] max-h-[120px] text-xs sm:text-sm p-3"
453
+ />
454
+
455
+ {/* Action Toolbar */}
456
+ <div className="flex items-center justify-between p-2 pt-0 border-t border-border/40">
457
+ {/* Attachment Icons / File Picker buttons */}
458
+ <div className="flex items-center gap-1">
459
+ <Button
460
+ type="button"
461
+ variant="ghost"
462
+ size="sm"
463
+ onClick={() => fileInputRef.current?.click()}
464
+ disabled={loading}
465
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground gap-1 rounded-md"
466
+ title="Attach document or file (PDF, DOCX, TXT, Excel)"
467
+ >
468
+ <Paperclip className="w-3.5 h-3.5" />
469
+ <span className="hidden sm:inline text-[11px]">Attach</span>
470
+ </Button>
471
+
472
+ <Button
473
+ type="button"
474
+ variant="ghost"
475
+ size="sm"
476
+ onClick={() => imageInputRef.current?.click()}
477
+ disabled={loading}
478
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground gap-1 rounded-md"
479
+ title="Attach photo or screenshot"
480
+ >
481
+ <ImageIcon className="w-3.5 h-3.5" />
482
+ <span className="hidden sm:inline text-[11px]">Photo</span>
483
+ </Button>
484
+ </div>
485
+
486
+ {/* Right side: Helper + Send button */}
487
+ <div className="flex items-center gap-2">
488
+ <span className="text-[10px] text-muted-foreground hidden md:inline">
489
+ <kbd className="font-mono bg-muted px-1 rounded text-[9px]">Enter</kbd> send, <kbd className="font-mono bg-muted px-1 rounded text-[9px]">Shift+Enter</kbd> newline
490
+ </span>
491
+
492
+ <Button
493
+ size="sm"
494
+ onClick={handleSubmit}
495
+ disabled={(!body.trim() && pendingFiles.length === 0) || loading}
496
+ className={cn(
497
+ "h-7 text-xs gap-1.5 px-3 font-semibold",
498
+ visibility === "internal" ? "bg-amber-600 hover:bg-amber-700 text-white" : ""
499
+ )}
500
+ >
501
+ {loading ? (
502
+ <>
503
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
504
+ Sending...
505
+ </>
506
+ ) : (
507
+ <>
508
+ <Send className="w-3.5 h-3.5" />
509
+ Send
510
+ </>
511
+ )}
512
+ </Button>
513
+ </div>
514
+ </div>
515
+ </div>
516
+ </div>
517
+ </div>
518
+ );
519
+ }
520
+
521
+ function ChatBubble({
522
+ comment,
523
+ isCurrentUser,
524
+ isAssignedEngineer,
525
+ }: {
526
+ comment: Comment;
527
+ isCurrentUser: boolean;
528
+ isAssignedEngineer: boolean;
529
+ }) {
530
+ const isInternal = comment.visibility === "internal";
531
+
532
+ const formattedTime = comment.createdAt
533
+ ? new Date(comment.createdAt).toLocaleTimeString([], {
534
+ hour: "2-digit",
535
+ minute: "2-digit",
536
+ })
537
+ : "";
538
+
539
+ const { text, attachments } = parseCommentBody(comment.body);
540
+
541
+ return (
542
+ <div
543
+ className={cn(
544
+ "flex gap-2.5 max-w-[85%] sm:max-w-[75%]",
545
+ isCurrentUser ? "ml-auto flex-row-reverse" : "mr-auto"
546
+ )}
547
+ >
548
+ {/* Avatar */}
549
+ <Avatar className="h-7 w-7 shrink-0 mt-1">
550
+ <AvatarFallback
551
+ className={cn(
552
+ "text-[10px] font-bold",
553
+ isInternal
554
+ ? "bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200"
555
+ : isAssignedEngineer
556
+ ? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
557
+ : isCurrentUser
558
+ ? "bg-primary text-primary-foreground"
559
+ : "bg-muted text-muted-foreground"
560
+ )}
561
+ >
562
+ {comment.author?.fullName?.substring(0, 2).toUpperCase() || "U"}
563
+ </AvatarFallback>
564
+ </Avatar>
565
+
566
+ {/* Bubble Content */}
567
+ <div className="space-y-1 min-w-0">
568
+ {/* Author Label */}
569
+ <div
570
+ className={cn(
571
+ "flex items-center gap-1.5 text-[11px]",
572
+ isCurrentUser ? "justify-end" : "justify-start"
573
+ )}
574
+ >
575
+ <span className="font-semibold text-foreground truncate">
576
+ {isCurrentUser ? "You" : comment.author?.fullName || "Staff"}
577
+ </span>
578
+
579
+ {isAssignedEngineer && !isCurrentUser ? (
580
+ <span className="bg-blue-100 dark:bg-blue-950 text-blue-700 dark:text-blue-300 px-1.5 py-0.2 rounded font-medium text-[10px] flex items-center gap-1">
581
+ <Wrench className="w-2.5 h-2.5" /> Engineer
582
+ </span>
583
+ ) : !isCurrentUser && (comment.author as any)?.jobTitle ? (
584
+ <span className="bg-muted text-muted-foreground px-1.5 py-0.2 rounded font-medium text-[10px] flex items-center gap-1">
585
+ <Stethoscope className="w-2.5 h-2.5 text-primary" />
586
+ {(comment.author as any).jobTitle}
587
+ </span>
588
+ ) : null}
589
+
590
+ {isInternal && (
591
+ <span className="bg-amber-200 dark:bg-amber-900 text-amber-900 dark:text-amber-200 px-1 py-0.2 rounded font-medium text-[9px] flex items-center gap-0.5">
592
+ <Lock className="w-2.5 h-2.5" /> Internal
593
+ </span>
594
+ )}
595
+
596
+ <span className="text-[10px] text-muted-foreground ml-1">
597
+ {formattedTime}
598
+ </span>
599
+ </div>
600
+
601
+ {/* Bubble Text & Attachments */}
602
+ <div
603
+ className={cn(
604
+ "p-3 rounded-2xl text-xs sm:text-sm leading-relaxed shadow-xs space-y-2 break-words",
605
+ isCurrentUser
606
+ ? "bg-primary text-primary-foreground rounded-tr-xs"
607
+ : isInternal
608
+ ? "bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 text-amber-950 dark:text-amber-100 rounded-tl-xs"
609
+ : isAssignedEngineer
610
+ ? "bg-card border-2 border-blue-200/80 dark:border-blue-900/60 text-foreground rounded-tl-xs"
611
+ : "bg-card border text-foreground rounded-tl-xs"
612
+ )}
613
+ >
614
+ {text && (
615
+ <div className="whitespace-pre-wrap leading-relaxed">
616
+ {text}
617
+ </div>
618
+ )}
619
+
620
+ {/* Render Attached Files */}
621
+ {attachments.length > 0 && (
622
+ <div className="space-y-2 pt-0.5">
623
+ {attachments.map((att, idx) => {
624
+ const isImage =
625
+ att.type?.startsWith("image/") ||
626
+ [".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) =>
627
+ att.name.toLowerCase().endsWith(ext)
628
+ );
629
+
630
+ if (isImage) {
631
+ return (
632
+ <a
633
+ key={idx}
634
+ href={att.url}
635
+ target="_blank"
636
+ rel="noopener noreferrer"
637
+ className="block overflow-hidden rounded-lg border border-border/40 hover:opacity-95 transition group"
638
+ >
639
+ <img
640
+ src={att.url}
641
+ alt={att.name}
642
+ className="max-h-56 max-w-full rounded-lg object-contain bg-black/5 dark:bg-white/5"
643
+ loading="lazy"
644
+ />
645
+ <div className="p-1 px-2 text-[10px] flex items-center justify-between opacity-80 group-hover:opacity-100 bg-black/30 text-white">
646
+ <span className="truncate max-w-[180px]">{att.name}</span>
647
+ <span>{formatFileSize(att.size)}</span>
648
+ </div>
649
+ </a>
650
+ );
651
+ }
652
+
653
+ return (
654
+ <a
655
+ key={idx}
656
+ href={att.url}
657
+ download={att.name}
658
+ target="_blank"
659
+ rel="noopener noreferrer"
660
+ className={cn(
661
+ "flex items-center gap-2 p-2 px-2.5 rounded-lg border text-xs transition",
662
+ isCurrentUser
663
+ ? "bg-white/10 hover:bg-white/20 border-white/20 text-primary-foreground"
664
+ : "bg-muted/60 hover:bg-muted border-border text-foreground"
665
+ )}
666
+ >
667
+ <div className="p-1 rounded bg-background/80 text-primary">
668
+ <FileText className="w-3.5 h-3.5" />
669
+ </div>
670
+ <div className="flex-1 min-w-0">
671
+ <p className="font-medium truncate text-[11px]">{att.name}</p>
672
+ <p className="text-[10px] opacity-75">{formatFileSize(att.size)}</p>
673
+ </div>
674
+ <Download className="w-3.5 h-3.5 opacity-70 shrink-0" />
675
+ </a>
676
+ );
677
+ })}
678
+ </div>
679
+ )}
680
+ </div>
681
+ </div>
682
+ </div>
683
+ );
684
+ }