@omersx/bemms 0.0.0-stage → 1.0.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,367 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback, useRef } from 'react';
4
+ import Link from 'next/link';
5
+ import { useRouter } from 'next/navigation';
6
+ import { Bell, CheckCheck, TicketCheck, Wrench, Monitor, AlertTriangle } from 'lucide-react';
7
+ import { playNotificationSound, playUrgentNotificationSound, unlockAudio } from '@/lib/utils/sound';
8
+ import { Button } from '@/components/ui/button';
9
+ import { Badge } from '@/components/ui/badge';
10
+ import {
11
+ Popover, PopoverContent, PopoverTrigger,
12
+ } from '@/components/ui/popover';
13
+ import { NavBadge } from '@/components/layout/nav-badge';
14
+ import { NotificationItem } from './notification-item';
15
+ import { getNotifications, markAsRead, markAllAsRead } from '@/lib/actions/notifications';
16
+
17
+ // ── Toast Component ──────────────────────────────────────────────────────────────
18
+
19
+ function NotificationToast({
20
+ notification,
21
+ onDismiss,
22
+ }: {
23
+ notification: { title: string; body: string; actionUrl?: string; notificationType?: string };
24
+ onDismiss: () => void;
25
+ }) {
26
+ const router = useRouter();
27
+
28
+ useEffect(() => {
29
+ const timer = setTimeout(onDismiss, 8000);
30
+ return () => clearTimeout(timer);
31
+ }, [onDismiss]);
32
+
33
+ const getIcon = () => {
34
+ const type = notification.notificationType || '';
35
+ if (type.startsWith('ticket_')) return <TicketCheck className="h-5 w-5 text-blue-500" />;
36
+ if (type.startsWith('pm_') || type.startsWith('task_')) return <Wrench className="h-5 w-5 text-purple-500" />;
37
+ if (type.startsWith('device_')) return <Monitor className="h-5 w-5 text-green-500" />;
38
+ if (type === 'sla_warning') return <AlertTriangle className="h-5 w-5 text-amber-500" />;
39
+ return <Bell className="h-5 w-5 text-primary" />;
40
+ };
41
+
42
+ const isCritical = notification.title.includes('🚨') || notification.title.includes('URGENT');
43
+
44
+ return (
45
+ <div
46
+ className={`fixed top-4 right-4 z-[100] max-w-sm w-full animate-in slide-in-from-top-2 fade-in duration-300 cursor-pointer ${
47
+ isCritical
48
+ ? 'bg-red-50 border-red-300 dark:bg-red-950 dark:border-red-800'
49
+ : 'bg-card border'
50
+ } rounded-lg shadow-lg p-4`}
51
+ onClick={() => {
52
+ if (notification.actionUrl) {
53
+ router.push(notification.actionUrl);
54
+ }
55
+ onDismiss();
56
+ }}
57
+ role="alert"
58
+ >
59
+ <div className="flex items-start gap-3">
60
+ <div className="shrink-0 mt-0.5">{getIcon()}</div>
61
+ <div className="flex-1 min-w-0">
62
+ <p className={`text-sm font-semibold leading-tight ${isCritical ? 'text-red-800 dark:text-red-200' : ''}`}>
63
+ {notification.title}
64
+ </p>
65
+ <p className="text-xs text-muted-foreground mt-1 line-clamp-2">
66
+ {notification.body}
67
+ </p>
68
+ {notification.actionUrl && (
69
+ <p className="text-xs text-primary mt-1.5 font-medium">
70
+ Tap to view →
71
+ </p>
72
+ )}
73
+ </div>
74
+ <button
75
+ onClick={(e) => { e.stopPropagation(); onDismiss(); }}
76
+ className="shrink-0 text-muted-foreground hover:text-foreground text-xs p-1"
77
+ aria-label="Dismiss"
78
+ >
79
+ ✕
80
+ </button>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ // ── Main Notification Bell ───────────────────────────────────────────────────────
87
+
88
+ export function NotificationBell() {
89
+ const [unreadCount, setUnreadCount] = useState(0);
90
+ const [items, setItems] = useState<any[]>([]);
91
+ const [open, setOpen] = useState(false);
92
+ const [loading, setLoading] = useState(false);
93
+ const [toast, setToast] = useState<{
94
+ title: string; body: string; actionUrl?: string; notificationType?: string;
95
+ } | null>(null);
96
+ const toastQueueRef = useRef<Array<typeof toast>>([]);
97
+ const showingToastRef = useRef(false);
98
+
99
+ // Show next toast from queue
100
+ const showNextToast = useCallback(() => {
101
+ if (toastQueueRef.current.length === 0) {
102
+ showingToastRef.current = false;
103
+ return;
104
+ }
105
+ showingToastRef.current = true;
106
+ const next = toastQueueRef.current.shift()!;
107
+ setToast(next);
108
+ }, []);
109
+
110
+ const dismissToast = useCallback(() => {
111
+ setToast(null);
112
+ // Small delay before showing next queued toast
113
+ setTimeout(showNextToast, 300);
114
+ }, [showNextToast]);
115
+
116
+ const queueToast = useCallback((notif: NonNullable<typeof toast>) => {
117
+ toastQueueRef.current.push(notif);
118
+ if (!showingToastRef.current) {
119
+ showNextToast();
120
+ }
121
+ }, [showNextToast]);
122
+
123
+ // Unlock Web Audio API on first user interaction for mobile device sound support
124
+ useEffect(() => {
125
+ const handleFirstInteraction = () => {
126
+ unlockAudio();
127
+ document.removeEventListener('click', handleFirstInteraction);
128
+ document.removeEventListener('touchstart', handleFirstInteraction);
129
+ };
130
+ document.addEventListener('click', handleFirstInteraction, { once: true });
131
+ document.addEventListener('touchstart', handleFirstInteraction, { once: true });
132
+ return () => {
133
+ document.removeEventListener('click', handleFirstInteraction);
134
+ document.removeEventListener('touchstart', handleFirstInteraction);
135
+ };
136
+ }, []);
137
+
138
+ // SSE connection for real-time unread count + new notification events
139
+ useEffect(() => {
140
+ let eventSource: EventSource | null = null;
141
+
142
+ const connect = () => {
143
+ eventSource = new EventSource('/api/notifications/sse');
144
+
145
+ eventSource.onmessage = (event) => {
146
+ try {
147
+ const data = JSON.parse(event.data);
148
+
149
+ if (data.type === 'unread_count') {
150
+ setUnreadCount(data.count);
151
+ }
152
+
153
+ // Real-time toast for new notifications
154
+ if (data.type === 'new_notifications' && Array.isArray(data.notifications)) {
155
+ let hasCritical = false;
156
+ data.notifications.forEach((notif: any) => {
157
+ queueToast({
158
+ title: notif.title,
159
+ body: notif.body,
160
+ actionUrl: notif.actionUrl || undefined,
161
+ notificationType: notif.notificationType,
162
+ });
163
+ // Check if any notification is urgent/critical
164
+ if (notif.title?.includes('🚨') || notif.title?.includes('URGENT')) {
165
+ hasCritical = true;
166
+ }
167
+ });
168
+
169
+ // Play notification sound (urgent alarm for critical, chime for normal)
170
+ if (data.notifications.length > 0) {
171
+ if (hasCritical) {
172
+ playUrgentNotificationSound();
173
+ } else {
174
+ playNotificationSound();
175
+ }
176
+ }
177
+
178
+ // Dispatch global event for live page sync (ticket detail, tickets list, etc.)
179
+ if (typeof window !== 'undefined') {
180
+ window.dispatchEvent(
181
+ new CustomEvent('bemms:notification', { detail: data.notifications })
182
+ );
183
+ }
184
+ }
185
+ } catch (e) {
186
+ // Ignore parse errors
187
+ }
188
+ };
189
+
190
+ eventSource.onerror = () => {
191
+ eventSource?.close();
192
+ // Reconnect after 30 seconds
193
+ setTimeout(connect, 30000);
194
+ };
195
+ };
196
+
197
+ connect();
198
+
199
+ // Also listen for service worker push messages for instant toast
200
+ const handleSWMessage = (event: MessageEvent) => {
201
+ if (event.data?.type === 'PUSH_RECEIVED') {
202
+ const notif = event.data.notification;
203
+ if (notif) {
204
+ queueToast({
205
+ title: notif.title,
206
+ body: notif.body,
207
+ actionUrl: notif.url || undefined,
208
+ notificationType: notif.data?.type,
209
+ });
210
+ // Bump unread count immediately
211
+ setUnreadCount((prev) => prev + 1);
212
+
213
+ // Play notification sound for push-received notifications
214
+ const isCritical =
215
+ notif.data?.priority === 'p1_critical' ||
216
+ notif.data?.impact === 'patient_care_affected' ||
217
+ notif.title?.includes('🚨') ||
218
+ notif.title?.includes('URGENT');
219
+ if (isCritical) {
220
+ playUrgentNotificationSound();
221
+ } else {
222
+ playNotificationSound();
223
+ }
224
+
225
+ // Dispatch global event for live page sync
226
+ if (typeof window !== 'undefined') {
227
+ window.dispatchEvent(
228
+ new CustomEvent('bemms:notification', { detail: [notif] })
229
+ );
230
+ }
231
+ }
232
+ }
233
+ };
234
+
235
+ if ('serviceWorker' in navigator) {
236
+ navigator.serviceWorker.addEventListener('message', handleSWMessage);
237
+ }
238
+
239
+ return () => {
240
+ eventSource?.close();
241
+ if ('serviceWorker' in navigator) {
242
+ navigator.serviceWorker.removeEventListener('message', handleSWMessage);
243
+ }
244
+ };
245
+ }, [queueToast]);
246
+
247
+ // Load notifications when popover opens
248
+ const loadNotifications = useCallback(async () => {
249
+ if (loading) return;
250
+ setLoading(true);
251
+ try {
252
+ const data = await getNotifications(10, 0);
253
+ setItems(data);
254
+ } catch (e) {
255
+ console.error('Failed to load notifications:', e);
256
+ } finally {
257
+ setLoading(false);
258
+ }
259
+ }, [loading]);
260
+
261
+ useEffect(() => {
262
+ if (open) {
263
+ loadNotifications();
264
+ }
265
+ }, [open]);
266
+
267
+ const handleMarkRead = async (id: string) => {
268
+ await markAsRead(id);
269
+ setItems((prev) =>
270
+ prev.map((item) =>
271
+ item.id === id ? { ...item, readAt: new Date() } : item
272
+ )
273
+ );
274
+ setUnreadCount((prev) => Math.max(0, prev - 1));
275
+ };
276
+
277
+ const handleMarkAllRead = async () => {
278
+ await markAllAsRead();
279
+ setItems((prev) =>
280
+ prev.map((item) => ({ ...item, readAt: item.readAt || new Date() }))
281
+ );
282
+ setUnreadCount(0);
283
+ };
284
+
285
+ return (
286
+ <>
287
+ {/* Toast Notification */}
288
+ {toast && (
289
+ <NotificationToast notification={toast} onDismiss={dismissToast} />
290
+ )}
291
+
292
+ {/* Bell Button */}
293
+ <Popover open={open} onOpenChange={setOpen}>
294
+ <PopoverTrigger asChild>
295
+ <Button variant="ghost" size="icon" className="relative min-h-[44px] min-w-[44px]">
296
+ <Bell className={`h-5 w-5 ${unreadCount > 0 ? 'animate-pulse' : ''}`} />
297
+ <NavBadge
298
+ type="count"
299
+ value={unreadCount}
300
+ variant="default"
301
+ position="icon-corner"
302
+ maxCount={99}
303
+ hideWhenZero={true}
304
+ ariaLabel={`${unreadCount} unread notifications`}
305
+ />
306
+ <span className="sr-only">Notifications</span>
307
+ </Button>
308
+ </PopoverTrigger>
309
+ <PopoverContent className="w-80 md:w-96 p-0" align="end" sideOffset={8}>
310
+ <div className="flex items-center justify-between px-4 py-3 border-b">
311
+ <h3 className="font-semibold text-sm">Notifications</h3>
312
+ {unreadCount > 0 && (
313
+ <Button
314
+ variant="ghost"
315
+ size="sm"
316
+ className="text-xs h-7 gap-1"
317
+ onClick={handleMarkAllRead}
318
+ >
319
+ <CheckCheck className="h-3.5 w-3.5" />
320
+ Mark all read
321
+ </Button>
322
+ )}
323
+ </div>
324
+
325
+ <div className="max-h-[400px] overflow-y-auto">
326
+ {loading && items.length === 0 ? (
327
+ <div className="py-8 text-center text-sm text-muted-foreground">
328
+ Loading...
329
+ </div>
330
+ ) : items.length === 0 ? (
331
+ <div className="py-8 text-center">
332
+ <Bell className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
333
+ <p className="text-sm text-muted-foreground">No notifications yet</p>
334
+ </div>
335
+ ) : (
336
+ <div className="divide-y">
337
+ {items.map((item) => (
338
+ <NotificationItem
339
+ key={item.id}
340
+ id={item.id}
341
+ notificationType={item.notificationType}
342
+ title={item.title}
343
+ body={item.body}
344
+ actionUrl={item.actionUrl}
345
+ readAt={item.readAt}
346
+ createdAt={item.createdAt}
347
+ onMarkRead={handleMarkRead}
348
+ />
349
+ ))}
350
+ </div>
351
+ )}
352
+ </div>
353
+
354
+ <div className="border-t px-4 py-2">
355
+ <Link
356
+ href="/notifications"
357
+ className="text-xs text-primary hover:underline"
358
+ onClick={() => setOpen(false)}
359
+ >
360
+ View all notifications →
361
+ </Link>
362
+ </div>
363
+ </PopoverContent>
364
+ </Popover>
365
+ </>
366
+ );
367
+ }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { formatDistanceToNow } from 'date-fns';
5
+ import { cn } from '@/lib/utils';
6
+ import {
7
+ TicketCheck, Wrench, Monitor, AlertTriangle,
8
+ MessageSquare, Clock, ShieldCheck, Bell,
9
+ } from 'lucide-react';
10
+
11
+ interface NotificationItemProps {
12
+ id: string;
13
+ notificationType: string;
14
+ title: string;
15
+ body: string;
16
+ actionUrl?: string | null;
17
+ readAt?: Date | string | null;
18
+ createdAt: Date | string;
19
+ onMarkRead?: (id: string) => void;
20
+ }
21
+
22
+ const iconMap: Record<string, typeof Bell> = {
23
+ ticket_assigned: TicketCheck,
24
+ ticket_updated: TicketCheck,
25
+ ticket_comment: MessageSquare,
26
+ pm_due_soon: Clock,
27
+ pm_overdue: AlertTriangle,
28
+ calibration_due: Clock,
29
+ task_review_required: ShieldCheck,
30
+ task_approved: ShieldCheck,
31
+ task_rejected: AlertTriangle,
32
+ device_status_changed: Monitor,
33
+ sla_warning: AlertTriangle,
34
+ system_alert: Bell,
35
+ };
36
+
37
+ const colorMap: Record<string, string> = {
38
+ ticket_assigned: 'text-blue-500',
39
+ ticket_updated: 'text-blue-500',
40
+ ticket_comment: 'text-purple-500',
41
+ pm_due_soon: 'text-amber-500',
42
+ pm_overdue: 'text-red-500',
43
+ calibration_due: 'text-amber-500',
44
+ task_review_required: 'text-orange-500',
45
+ task_approved: 'text-green-500',
46
+ task_rejected: 'text-red-500',
47
+ device_status_changed: 'text-blue-500',
48
+ sla_warning: 'text-red-500',
49
+ system_alert: 'text-gray-500',
50
+ };
51
+
52
+ export function NotificationItem({
53
+ id, notificationType, title, body,
54
+ actionUrl, readAt, createdAt, onMarkRead,
55
+ }: NotificationItemProps) {
56
+ const Icon = iconMap[notificationType] || Bell;
57
+ const color = colorMap[notificationType] || 'text-muted-foreground';
58
+ const isUnread = !readAt;
59
+ const timeAgo = formatDistanceToNow(new Date(createdAt), { addSuffix: true });
60
+
61
+ const content = (
62
+ <div
63
+ className={cn(
64
+ 'flex items-start gap-3 p-3 rounded-lg transition-colors min-h-[44px]',
65
+ isUnread ? 'bg-primary/5 hover:bg-primary/10' : 'hover:bg-muted/50',
66
+ )}
67
+ onClick={() => isUnread && onMarkRead?.(id)}
68
+ >
69
+ <div className={cn('mt-0.5 shrink-0', color)}>
70
+ <Icon className="h-5 w-5" />
71
+ </div>
72
+ <div className="flex-1 min-w-0">
73
+ <div className="flex items-start justify-between gap-2">
74
+ <p className={cn('text-sm line-clamp-1', isUnread && 'font-semibold')}>
75
+ {title}
76
+ </p>
77
+ {isUnread && (
78
+ <span className="h-2 w-2 rounded-full bg-primary shrink-0 mt-1.5" />
79
+ )}
80
+ </div>
81
+ <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{body}</p>
82
+ <p className="text-[10px] text-muted-foreground mt-1">{timeAgo}</p>
83
+ </div>
84
+ </div>
85
+ );
86
+
87
+ if (actionUrl) {
88
+ return <Link href={actionUrl} className="block">{content}</Link>;
89
+ }
90
+
91
+ return content;
92
+ }
93
+
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Switch } from '@/components/ui/switch';
6
+ import { Label } from '@/components/ui/label';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Bell, TicketCheck, Wrench, Monitor, Settings } from 'lucide-react';
9
+ import { toast } from 'sonner';
10
+ import {
11
+ getNotificationPreferences,
12
+ updateNotificationPreferences,
13
+ } from '@/lib/actions/notifications';
14
+
15
+ export function NotificationPreferences() {
16
+ const [prefs, setPrefs] = useState({
17
+ pushEnabled: true,
18
+ categories: {
19
+ tickets: true,
20
+ maintenance: true,
21
+ devices: true,
22
+ system: true,
23
+ },
24
+ });
25
+ const [saving, setSaving] = useState(false);
26
+ const [pushSupported, setPushSupported] = useState(false);
27
+ const [pushPermission, setPushPermission] = useState<string>('default');
28
+
29
+ useEffect(() => {
30
+ setPushSupported('Notification' in window && 'serviceWorker' in navigator);
31
+ if ('Notification' in window) {
32
+ setPushPermission(Notification.permission);
33
+ }
34
+
35
+ getNotificationPreferences().then((p) => {
36
+ if (p) setPrefs(p);
37
+ });
38
+ }, []);
39
+
40
+ const handleTogglePush = async (enabled: boolean) => {
41
+ if (enabled && pushPermission !== 'granted') {
42
+ const permission = await Notification.requestPermission();
43
+ setPushPermission(permission);
44
+ if (permission !== 'granted') {
45
+ toast.error('Notification permission denied');
46
+ return;
47
+ }
48
+
49
+ // Subscribe to push
50
+ try {
51
+ const reg = await navigator.serviceWorker.ready;
52
+ const res = await fetch('/api/notifications/vapid-key');
53
+ const { publicKey } = await res.json();
54
+
55
+ if (publicKey) {
56
+ const subscription = await reg.pushManager.subscribe({
57
+ userVisibleOnly: true,
58
+ applicationServerKey: publicKey,
59
+ });
60
+
61
+ await fetch('/api/notifications/subscribe', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify(subscription.toJSON()),
65
+ });
66
+ }
67
+ } catch (err) {
68
+ console.error('Push subscription failed:', err);
69
+ }
70
+ }
71
+
72
+ const updated = { ...prefs, pushEnabled: enabled };
73
+ setPrefs(updated);
74
+ await savePrefs(updated);
75
+ };
76
+
77
+ const handleToggleCategory = async (category: keyof typeof prefs.categories, enabled: boolean) => {
78
+ const updated = {
79
+ ...prefs,
80
+ categories: { ...prefs.categories, [category]: enabled },
81
+ };
82
+ setPrefs(updated);
83
+ await savePrefs(updated);
84
+ };
85
+
86
+ const savePrefs = async (p: typeof prefs) => {
87
+ setSaving(true);
88
+ try {
89
+ await updateNotificationPreferences(p);
90
+ toast.success('Preferences saved');
91
+ } catch (err) {
92
+ toast.error('Failed to save preferences');
93
+ } finally {
94
+ setSaving(false);
95
+ }
96
+ };
97
+
98
+ const sendTestNotification = async () => {
99
+ if (pushPermission !== 'granted') {
100
+ toast.error('Enable push notifications first');
101
+ return;
102
+ }
103
+
104
+ const reg = await navigator.serviceWorker.ready;
105
+ await reg.showNotification('BEMMS Test', {
106
+ body: 'Push notifications are working!',
107
+ icon: '/icons/icon-192.png',
108
+ badge: '/icons/icon-192.png',
109
+ });
110
+ toast.success('Test notification sent!');
111
+ };
112
+
113
+ const categories = [
114
+ { key: 'tickets' as const, label: 'Tickets', desc: 'Assignments, status changes, comments', icon: TicketCheck },
115
+ { key: 'maintenance' as const, label: 'Maintenance', desc: 'PM reminders, task reviews, approvals', icon: Wrench },
116
+ { key: 'devices' as const, label: 'Devices', desc: 'Status changes, availability alerts', icon: Monitor },
117
+ { key: 'system' as const, label: 'System', desc: 'SLA warnings, system alerts', icon: Settings },
118
+ ];
119
+
120
+ return (
121
+ <Card>
122
+ <CardHeader>
123
+ <CardTitle className="flex items-center gap-2">
124
+ <Bell className="h-5 w-5" />
125
+ Notification Preferences
126
+ </CardTitle>
127
+ </CardHeader>
128
+ <CardContent className="space-y-6">
129
+ {/* Push Master Toggle */}
130
+ <div className="flex items-center justify-between p-4 border rounded-lg">
131
+ <div>
132
+ <Label className="text-sm font-medium">Push Notifications</Label>
133
+ <p className="text-xs text-muted-foreground mt-1">
134
+ {!pushSupported
135
+ ? 'Not supported in this browser'
136
+ : pushPermission === 'denied'
137
+ ? 'Blocked — enable in browser settings'
138
+ : 'Receive native notifications on this device'}
139
+ </p>
140
+ </div>
141
+ <Switch
142
+ checked={prefs.pushEnabled}
143
+ onCheckedChange={handleTogglePush}
144
+ disabled={!pushSupported || pushPermission === 'denied'}
145
+ />
146
+ </div>
147
+
148
+ {/* Category Toggles */}
149
+ <div className="space-y-3">
150
+ <h4 className="text-sm font-medium text-muted-foreground">Categories</h4>
151
+ {categories.map((cat) => (
152
+ <div key={cat.key} className="flex items-center justify-between py-2">
153
+ <div className="flex items-center gap-3">
154
+ <cat.icon className="h-4 w-4 text-muted-foreground" />
155
+ <div>
156
+ <Label className="text-sm">{cat.label}</Label>
157
+ <p className="text-xs text-muted-foreground">{cat.desc}</p>
158
+ </div>
159
+ </div>
160
+ <Switch
161
+ checked={prefs.categories[cat.key]}
162
+ onCheckedChange={(v: boolean) => handleToggleCategory(cat.key, v)}
163
+ />
164
+ </div>
165
+ ))}
166
+ </div>
167
+
168
+ {/* Test Button */}
169
+ <Button
170
+ variant="outline"
171
+ size="sm"
172
+ onClick={sendTestNotification}
173
+ className="w-full min-h-[44px]"
174
+ disabled={!prefs.pushEnabled || pushPermission !== 'granted'}
175
+ >
176
+ Send Test Notification
177
+ </Button>
178
+ </CardContent>
179
+ </Card>
180
+ );
181
+ }
182
+