@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,136 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { notifications, pushSubscriptions, users } from '@/lib/db/schema';
5
+ import { requireAuth } from '@/lib/auth/rbac';
6
+ import { eq, and, isNull, desc, sql } from 'drizzle-orm';
7
+
8
+ export async function getUnreadCount() {
9
+ const user = await requireAuth();
10
+
11
+ const result = await db.select({
12
+ count: sql<number>`count(*)::int`,
13
+ })
14
+ .from(notifications)
15
+ .where(and(
16
+ eq(notifications.recipientUserId, user.id),
17
+ isNull(notifications.readAt),
18
+ ));
19
+
20
+ return result[0]?.count || 0;
21
+ }
22
+
23
+ export async function getNotifications(limit = 20, offset = 0) {
24
+ const user = await requireAuth();
25
+
26
+ return db.query.notifications.findMany({
27
+ where: eq(notifications.recipientUserId, user.id),
28
+ orderBy: [desc(notifications.createdAt)],
29
+ limit,
30
+ offset,
31
+ });
32
+ }
33
+
34
+ export async function markAsRead(notificationId: string) {
35
+ const user = await requireAuth();
36
+
37
+ await db.update(notifications)
38
+ .set({ readAt: new Date() })
39
+ .where(and(
40
+ eq(notifications.id, notificationId),
41
+ eq(notifications.recipientUserId, user.id),
42
+ ));
43
+ }
44
+
45
+ export async function markAllAsRead() {
46
+ const user = await requireAuth();
47
+
48
+ await db.update(notifications)
49
+ .set({ readAt: new Date() })
50
+ .where(and(
51
+ eq(notifications.recipientUserId, user.id),
52
+ isNull(notifications.readAt),
53
+ ));
54
+ }
55
+
56
+ export async function savePushSubscription(subscription: {
57
+ endpoint: string;
58
+ keys: { p256dh: string; auth: string };
59
+ userAgent?: string;
60
+ }) {
61
+ const user = await requireAuth();
62
+
63
+ // Check if subscription already exists
64
+ const existing = await db.query.pushSubscriptions.findFirst({
65
+ where: eq(pushSubscriptions.endpoint, subscription.endpoint),
66
+ });
67
+
68
+ if (existing) {
69
+ // Update existing
70
+ await db.update(pushSubscriptions)
71
+ .set({
72
+ p256dh: subscription.keys.p256dh,
73
+ auth: subscription.keys.auth,
74
+ userId: user.id,
75
+ })
76
+ .where(eq(pushSubscriptions.id, existing.id));
77
+ return existing;
78
+ }
79
+
80
+ // Create new
81
+ const [sub] = await db.insert(pushSubscriptions).values({
82
+ userId: user.id,
83
+ endpoint: subscription.endpoint,
84
+ p256dh: subscription.keys.p256dh,
85
+ auth: subscription.keys.auth,
86
+ userAgent: subscription.userAgent,
87
+ }).returning();
88
+
89
+ return sub;
90
+ }
91
+
92
+ export async function removePushSubscription(endpoint: string) {
93
+ const user = await requireAuth();
94
+
95
+ await db.delete(pushSubscriptions)
96
+ .where(and(
97
+ eq(pushSubscriptions.endpoint, endpoint),
98
+ eq(pushSubscriptions.userId, user.id),
99
+ ));
100
+ }
101
+
102
+ export async function updateNotificationPreferences(prefs: {
103
+ pushEnabled: boolean;
104
+ categories: {
105
+ tickets: boolean;
106
+ maintenance: boolean;
107
+ devices: boolean;
108
+ system: boolean;
109
+ };
110
+ }) {
111
+ const user = await requireAuth();
112
+
113
+ await db.update(users)
114
+ .set({ notificationPreferences: prefs })
115
+ .where(eq(users.id, user.id));
116
+ }
117
+
118
+ export async function getNotificationPreferences() {
119
+ const user = await requireAuth();
120
+
121
+ const userData = await db.query.users.findFirst({
122
+ where: eq(users.id, user.id),
123
+ columns: { notificationPreferences: true },
124
+ });
125
+
126
+ return userData?.notificationPreferences || {
127
+ pushEnabled: true,
128
+ categories: {
129
+ tickets: true,
130
+ maintenance: true,
131
+ devices: true,
132
+ system: true,
133
+ },
134
+ };
135
+ }
136
+
@@ -0,0 +1,99 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { organizations } from '@/lib/db/schema';
5
+ import { requireAuth, requireRole } from '@/lib/auth/rbac';
6
+ import { createAuditLog } from '@/lib/audit';
7
+ import { createOrganizationSchema, updateOrganizationSchema } from '@/lib/validators/organizations';
8
+ import { eq } from 'drizzle-orm';
9
+
10
+ export async function getOrganizations() {
11
+ await requireAuth();
12
+ await requireRole('SYS_ADMIN', 'ORG_ADMIN');
13
+ try {
14
+ const data = await db.query.organizations.findMany();
15
+ return { success: true, data };
16
+ } catch (error: any) {
17
+ return { success: false, error: error.message };
18
+ }
19
+ }
20
+
21
+ export async function getOrganizationById(id: string) {
22
+ await requireAuth();
23
+ await requireRole('SYS_ADMIN', 'ORG_ADMIN');
24
+ try {
25
+ const data = await db.query.organizations.findFirst({
26
+ where: eq(organizations.id, id),
27
+ });
28
+ return { success: true, data };
29
+ } catch (error: any) {
30
+ return { success: false, error: error.message };
31
+ }
32
+ }
33
+
34
+ export async function createOrganization(input: unknown) {
35
+ const session = await requireAuth();
36
+ await requireRole('SYS_ADMIN');
37
+ const validated = createOrganizationSchema.safeParse(input);
38
+ if (!validated.success) return { success: false, error: validated.error.message };
39
+
40
+ try {
41
+ return await db.transaction(async (tx) => {
42
+ const [newOrg] = await tx.insert(organizations).values(validated.data).returning();
43
+ await createAuditLog(tx, {
44
+ action: 'CREATE',
45
+ entityType: 'organization',
46
+ entityId: newOrg.id,
47
+ actorId: session.id,
48
+ details: validated.data,
49
+ });
50
+ return { success: true, data: newOrg };
51
+ });
52
+ } catch (error: any) {
53
+ return { success: false, error: error.message };
54
+ }
55
+ }
56
+
57
+ export async function updateOrganization(id: string, input: unknown) {
58
+ const session = await requireAuth();
59
+ await requireRole('SYS_ADMIN', 'ORG_ADMIN');
60
+ const validated = updateOrganizationSchema.safeParse(input);
61
+ if (!validated.success) return { success: false, error: validated.error.message };
62
+
63
+ try {
64
+ return await db.transaction(async (tx) => {
65
+ const [updated] = await tx.update(organizations).set(validated.data).where(eq(organizations.id, id)).returning();
66
+ await createAuditLog(tx, {
67
+ action: 'UPDATE',
68
+ entityType: 'organization',
69
+ entityId: id,
70
+ actorId: session.id,
71
+ details: validated.data,
72
+ });
73
+ return { success: true, data: updated };
74
+ });
75
+ } catch (error: any) {
76
+ return { success: false, error: error.message };
77
+ }
78
+ }
79
+
80
+ export async function archiveOrganization(id: string) {
81
+ const session = await requireAuth();
82
+ await requireRole('SYS_ADMIN');
83
+ try {
84
+ return await db.transaction(async (tx) => {
85
+ const [archived] = await tx.update(organizations).set({ status: 'archived' }).where(eq(organizations.id, id)).returning();
86
+ await createAuditLog(tx, {
87
+ action: 'ARCHIVE',
88
+ entityType: 'organization',
89
+ entityId: id,
90
+ actorId: session.id,
91
+ details: { status: 'archived' },
92
+ });
93
+ return { success: true, data: archived };
94
+ });
95
+ } catch (error: any) {
96
+ return { success: false, error: error.message };
97
+ }
98
+ }
99
+
@@ -0,0 +1,147 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { deviceQrLabels, devices } from '@/lib/db/schema';
5
+ import { requireAuth, requireRole } from '@/lib/auth/rbac';
6
+ import { createAuditLog } from '@/lib/audit';
7
+ import { eq, and, desc } from 'drizzle-orm';
8
+ import { nanoid } from 'nanoid';
9
+
10
+ function generateOpaqueReference(): string {
11
+ return `dv_${nanoid(10)}`;
12
+ }
13
+
14
+ export async function getActiveQrLabel(deviceId: string) {
15
+ await requireAuth();
16
+ try {
17
+ const data = await db.query.deviceQrLabels.findFirst({
18
+ where: and(
19
+ eq(deviceQrLabels.deviceId, deviceId),
20
+ eq(deviceQrLabels.labelStatus, 'active'),
21
+ ),
22
+ });
23
+ return { success: true, data };
24
+ } catch (error: any) {
25
+ return { success: false, error: error.message };
26
+ }
27
+ }
28
+
29
+ export async function generateNewQrLabel(deviceId: string, reason?: string) {
30
+ const session = await requireAuth();
31
+ await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
32
+
33
+ try {
34
+ const device = await db.query.devices.findFirst({ where: eq(devices.id, deviceId) });
35
+ if (!device) return { success: false, error: 'Device not found' };
36
+
37
+ return await db.transaction(async (tx) => {
38
+ // Revoke existing active label
39
+ const existing = await tx.query.deviceQrLabels.findFirst({
40
+ where: and(
41
+ eq(deviceQrLabels.deviceId, deviceId),
42
+ eq(deviceQrLabels.labelStatus, 'active'),
43
+ ),
44
+ });
45
+
46
+ const newRef = generateOpaqueReference();
47
+
48
+ if (existing) {
49
+ await tx.update(deviceQrLabels)
50
+ .set({
51
+ labelStatus: 'replaced',
52
+ revokedAt: new Date(),
53
+ revokedByUserId: session.id,
54
+ revocationReason: reason || 'Replaced with new label',
55
+ })
56
+ .where(eq(deviceQrLabels.id, existing.id));
57
+ }
58
+
59
+ const [newLabel] = await tx.insert(deviceQrLabels).values({
60
+ organizationId: device.organizationId,
61
+ deviceId,
62
+ opaqueReference: newRef,
63
+ labelStatus: 'active',
64
+ createdByUserId: session.id,
65
+ }).returning();
66
+
67
+ if (existing) {
68
+ await tx.update(deviceQrLabels)
69
+ .set({ replacedByQrId: newLabel.id })
70
+ .where(eq(deviceQrLabels.id, existing.id));
71
+ }
72
+
73
+ await createAuditLog(tx, {
74
+ action: 'CREATE', entityType: 'device_qr_label', entityId: newLabel.id,
75
+ actorId: session.id,
76
+ details: { deviceId, opaqueReference: newRef, replacedLabelId: existing?.id, reason },
77
+ });
78
+
79
+ return { success: true, data: newLabel };
80
+ });
81
+ } catch (error: any) {
82
+ return { success: false, error: error.message };
83
+ }
84
+ }
85
+
86
+ export async function recordQrPrint(qrLabelId: string) {
87
+ const session = await requireAuth();
88
+ try {
89
+ const [updated] = await db.update(deviceQrLabels)
90
+ .set({
91
+ printCount: (await db.query.deviceQrLabels.findFirst({ where: eq(deviceQrLabels.id, qrLabelId) }))!.printCount! + 1,
92
+ printedAt: new Date(),
93
+ printedByUserId: session.id,
94
+ })
95
+ .where(eq(deviceQrLabels.id, qrLabelId))
96
+ .returning();
97
+ return { success: true, data: updated };
98
+ } catch (error: any) {
99
+ return { success: false, error: error.message };
100
+ }
101
+ }
102
+
103
+ export async function resolveDeviceScan(opaqueReference: string) {
104
+ try {
105
+ const qrLabel = await db.query.deviceQrLabels.findFirst({
106
+ where: eq(deviceQrLabels.opaqueReference, opaqueReference),
107
+ with: {
108
+ device: {
109
+ with: {
110
+ hospital: true,
111
+ department: true,
112
+ location: true,
113
+ },
114
+ },
115
+ },
116
+ });
117
+
118
+ if (!qrLabel) {
119
+ return { success: false, error: 'QR code not found', code: 'NOT_FOUND' };
120
+ }
121
+ if (qrLabel.labelStatus !== 'active') {
122
+ return { success: false, error: 'This QR label has been revoked or replaced', code: 'INACTIVE' };
123
+ }
124
+ if (!qrLabel.device) {
125
+ return { success: false, error: 'Device not found', code: 'DEVICE_NOT_FOUND' };
126
+ }
127
+
128
+ return {
129
+ success: true,
130
+ data: {
131
+ deviceId: qrLabel.device.id,
132
+ deviceName: qrLabel.device.name,
133
+ internalCode: qrLabel.device.internalCode,
134
+ assetNumber: qrLabel.device.assetNumber,
135
+ currentStatus: qrLabel.device.currentStatusCode,
136
+ statusLimitationsNote: qrLabel.device.statusLimitationsNote,
137
+ organizationId: qrLabel.device.organizationId,
138
+ hospitalName: qrLabel.device.hospital?.name,
139
+ departmentName: qrLabel.device.department?.name,
140
+ locationName: qrLabel.device.location?.name || qrLabel.device.exactLocationDescription,
141
+ },
142
+ };
143
+ } catch (error: any) {
144
+ return { success: false, error: error.message };
145
+ }
146
+ }
147
+
@@ -0,0 +1,77 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { generatedReports } from '@/lib/db/schema/report-history';
5
+ import { requireAuth, requirePermission } from '@/lib/auth/rbac';
6
+ import { eq, and, desc } from 'drizzle-orm';
7
+
8
+ export async function saveReportToHistory(params: {
9
+ reportType: string;
10
+ title: string;
11
+ filters: any;
12
+ dateRangeStart?: Date;
13
+ dateRangeEnd?: Date;
14
+ timezone?: string;
15
+ rowCount?: number;
16
+ fileFormat?: string;
17
+ fileName: string;
18
+ }) {
19
+ const user = await requireAuth();
20
+ if (!user.organizationId) {
21
+ throw new Error('Organization ID required');
22
+ }
23
+
24
+ const [report] = await db.insert(generatedReports).values({
25
+ organizationId: user.organizationId,
26
+ reportType: params.reportType,
27
+ title: params.title,
28
+ filtersJsonb: params.filters,
29
+ dateRangeStart: params.dateRangeStart,
30
+ dateRangeEnd: params.dateRangeEnd,
31
+ timezone: params.timezone || 'UTC',
32
+ rowCount: params.rowCount,
33
+ fileFormat: params.fileFormat || 'csv',
34
+ fileName: params.fileName,
35
+ generatedByUserId: user.id,
36
+ }).returning();
37
+
38
+ return report;
39
+ }
40
+
41
+ export async function getReportHistory() {
42
+ const user = await requireAuth();
43
+ await requirePermission('REPORTS', 'VIEW');
44
+ if (!user.organizationId) return [];
45
+
46
+ return db.query.generatedReports.findMany({
47
+ where: eq(generatedReports.organizationId, user.organizationId),
48
+ orderBy: [desc(generatedReports.generatedAt)],
49
+ with: {
50
+ generatedByUser: {
51
+ columns: { id: true, fullName: true, email: true }
52
+ }
53
+ },
54
+ limit: 100
55
+ });
56
+ }
57
+
58
+ export async function getReportById(id: string) {
59
+ const user = await requireAuth();
60
+ await requirePermission('REPORTS', 'VIEW');
61
+ if (!user.organizationId) return null;
62
+
63
+ const report = await db.query.generatedReports.findFirst({
64
+ where: and(
65
+ eq(generatedReports.id, id),
66
+ eq(generatedReports.organizationId, user.organizationId)
67
+ ),
68
+ with: {
69
+ generatedByUser: {
70
+ columns: { id: true, fullName: true, email: true }
71
+ }
72
+ }
73
+ });
74
+
75
+ return report;
76
+ }
77
+
@@ -0,0 +1,192 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { requireAuth, requirePermission } from '@/lib/auth/rbac';
5
+ import {
6
+ devices, serviceTickets, maintenanceTasks,
7
+ maintenanceScheduleOccurrences, maintenanceCosts, maintenanceRecords,
8
+ users, hospitals, departments, deviceCategories
9
+ } from '@/lib/db/schema';
10
+ import { eq, and, sql, gte, lte, desc, inArray, or } from 'drizzle-orm';
11
+ import { z } from 'zod';
12
+ import {
13
+ inventoryReportFiltersSchema,
14
+ ticketPerformanceFiltersSchema,
15
+ maintenanceComplianceFiltersSchema,
16
+ costReportFiltersSchema,
17
+ workloadReportFiltersSchema,
18
+ deviceHistoryFiltersSchema
19
+ } from '@/lib/validators/reports';
20
+
21
+ export async function getInventoryReport(filters: z.infer<typeof inventoryReportFiltersSchema>) {
22
+ const user = await requireAuth();
23
+ await requirePermission('REPORTS', 'VIEW');
24
+ if (!user.organizationId) return [];
25
+
26
+ const conditions: any[] = [eq(devices.organizationId, user.organizationId)];
27
+ if (filters.hospitalId) conditions.push(eq(devices.hospitalId, filters.hospitalId));
28
+ if (filters.departmentId) conditions.push(eq(devices.departmentId, filters.departmentId));
29
+ if (filters.deviceCategoryId) conditions.push(eq(devices.deviceCategoryId, filters.deviceCategoryId));
30
+ if (filters.status) conditions.push(eq(devices.currentStatusCode, filters.status as any));
31
+
32
+ const groupByColumn = (() => {
33
+ switch(filters.groupBy) {
34
+ case 'hospital': return devices.hospitalId;
35
+ case 'department': return devices.departmentId;
36
+ case 'category': return devices.deviceCategoryId;
37
+ case 'status': return devices.currentStatusCode;
38
+ case 'manufacturer': return devices.manufacturerId;
39
+ default: return devices.departmentId;
40
+ }
41
+ })();
42
+
43
+ const results = await db.select({
44
+ groupId: groupByColumn,
45
+ totalCount: sql<number>`count(*)::int`,
46
+ operationalCount: sql<number>`sum(case when ${devices.currentStatusCode} = 'operational' then 1 else 0 end)::int`,
47
+ maintenanceCount: sql<number>`sum(case when ${devices.currentStatusCode} in ('under_maintenance', 'under_repair') then 1 else 0 end)::int`,
48
+ outOfServiceCount: sql<number>`sum(case when ${devices.currentStatusCode} = 'out_of_service' then 1 else 0 end)::int`,
49
+ decommissionedCount: sql<number>`sum(case when ${devices.currentStatusCode} = 'decommissioned' then 1 else 0 end)::int`,
50
+ })
51
+ .from(devices)
52
+ .where(and(...conditions))
53
+ .groupBy(groupByColumn);
54
+
55
+ return results;
56
+ }
57
+
58
+ export async function getTicketPerformanceReport(filters: z.infer<typeof ticketPerformanceFiltersSchema>) {
59
+ const user = await requireAuth();
60
+ await requirePermission('REPORTS', 'VIEW');
61
+ if (!user.organizationId) return null;
62
+
63
+ const conditions: any[] = [eq(serviceTickets.organizationId, user.organizationId)];
64
+ if (filters.hospitalId) conditions.push(eq(serviceTickets.hospitalId, filters.hospitalId));
65
+ if (filters.departmentId) conditions.push(eq(serviceTickets.departmentId, filters.departmentId));
66
+ if (filters.startDate) conditions.push(gte(serviceTickets.reportedAt, new Date(filters.startDate)));
67
+ if (filters.endDate) conditions.push(lte(serviceTickets.reportedAt, new Date(filters.endDate)));
68
+
69
+ const results = await db.select({
70
+ total: sql<number>`count(*)::int`,
71
+ openCount: sql<number>`sum(case when ${serviceTickets.statusCode} not in ('resolved', 'closed', 'cancelled') then 1 else 0 end)::int`,
72
+ closedCount: sql<number>`sum(case when ${serviceTickets.statusCode} in ('resolved', 'closed') then 1 else 0 end)::int`,
73
+ avgResponseTime: sql<number>`coalesce(avg(extract(epoch from (${serviceTickets.acceptedAt} - ${serviceTickets.reportedAt})) / 3600), 0)::float`,
74
+ avgResolutionTime: sql<number>`coalesce(avg(extract(epoch from (${serviceTickets.resolvedAt} - ${serviceTickets.reportedAt})) / 3600), 0)::float`,
75
+ priorityP1: sql<number>`sum(case when ${serviceTickets.priorityCode} = 'p1_critical' then 1 else 0 end)::int`,
76
+ priorityP2: sql<number>`sum(case when ${serviceTickets.priorityCode} = 'p2_high' then 1 else 0 end)::int`,
77
+ priorityP3: sql<number>`sum(case when ${serviceTickets.priorityCode} = 'p3_normal' then 1 else 0 end)::int`,
78
+ priorityP4: sql<number>`sum(case when ${serviceTickets.priorityCode} = 'p4_low' then 1 else 0 end)::int`,
79
+ })
80
+ .from(serviceTickets)
81
+ .where(and(...conditions));
82
+
83
+ return results[0] || null;
84
+ }
85
+
86
+ export async function getMaintenanceComplianceReport(filters: z.infer<typeof maintenanceComplianceFiltersSchema>) {
87
+ const user = await requireAuth();
88
+ await requirePermission('REPORTS', 'VIEW');
89
+ if (!user.organizationId) return { totalScheduled: 0, completed: 0, overdue: 0, complianceRate: 0 };
90
+
91
+ const conditions: any[] = [
92
+ eq(maintenanceTasks.organizationId, user.organizationId),
93
+ eq(maintenanceTasks.maintenanceType, 'preventive_maintenance' as any),
94
+ ];
95
+ if (filters.hospitalId) conditions.push(eq(maintenanceTasks.hospitalId, filters.hospitalId));
96
+ if (filters.departmentId) conditions.push(eq(maintenanceTasks.departmentId, filters.departmentId as any));
97
+ if (filters.startDate) conditions.push(gte(maintenanceTasks.dueDate, new Date(filters.startDate)));
98
+ if (filters.endDate) conditions.push(lte(maintenanceTasks.dueDate, new Date(filters.endDate)));
99
+
100
+ const results = await db.select({
101
+ totalScheduled: sql<number>`count(*)::int`,
102
+ completed: sql<number>`sum(case when ${maintenanceTasks.statusCode} in ('closed', 'work_complete', 'awaiting_review', 'awaiting_release') then 1 else 0 end)::int`,
103
+ overdue: sql<number>`sum(case when ${maintenanceTasks.statusCode} not in ('closed', 'cancelled') and ${maintenanceTasks.dueDate} < now() then 1 else 0 end)::int`,
104
+ })
105
+ .from(maintenanceTasks)
106
+ .where(and(...conditions));
107
+
108
+ const data = results[0] || { totalScheduled: 0, completed: 0, overdue: 0 };
109
+ const complianceRate = data.totalScheduled > 0
110
+ ? Math.round((data.completed / data.totalScheduled) * 100)
111
+ : 0;
112
+
113
+ return { ...data, complianceRate };
114
+ }
115
+
116
+ export async function getCostReport(filters: z.infer<typeof costReportFiltersSchema>) {
117
+ const user = await requireAuth();
118
+ await requirePermission('REPORTS', 'VIEW');
119
+ if (!user.organizationId) return { totalCost: 0, byCostType: [] };
120
+
121
+ const conditions: any[] = [eq(maintenanceTasks.organizationId, user.organizationId)];
122
+ if (filters.hospitalId) conditions.push(eq(maintenanceTasks.hospitalId, filters.hospitalId));
123
+ if (filters.startDate) conditions.push(gte(maintenanceCosts.recordedAt, new Date(filters.startDate)));
124
+ if (filters.endDate) conditions.push(lte(maintenanceCosts.recordedAt, new Date(filters.endDate)));
125
+
126
+ const results = await db.select({
127
+ costType: maintenanceCosts.costType,
128
+ totalAmount: sql<number>`coalesce(sum(${maintenanceCosts.amount}), 0)::int`,
129
+ count: sql<number>`count(*)::int`,
130
+ })
131
+ .from(maintenanceCosts)
132
+ .innerJoin(maintenanceTasks, eq(maintenanceCosts.maintenanceTaskId, maintenanceTasks.id))
133
+ .where(and(...conditions))
134
+ .groupBy(maintenanceCosts.costType);
135
+
136
+ const totalCost = results.reduce((acc, r) => acc + (r.totalAmount || 0), 0);
137
+
138
+ return { totalCost, byCostType: results };
139
+ }
140
+
141
+ export async function getWorkloadReport(filters: z.infer<typeof workloadReportFiltersSchema>) {
142
+ const user = await requireAuth();
143
+ await requirePermission('REPORTS', 'VIEW');
144
+ if (!user.organizationId) return [];
145
+
146
+ const conditions: any[] = [eq(maintenanceTasks.organizationId, user.organizationId)];
147
+ if (filters.hospitalId) conditions.push(eq(maintenanceTasks.hospitalId, filters.hospitalId));
148
+
149
+ const results = await db.select({
150
+ engineerId: maintenanceTasks.assignedEngineerUserId,
151
+ assignedCount: sql<number>`count(*)::int`,
152
+ completedCount: sql<number>`sum(case when ${maintenanceTasks.statusCode} in ('closed', 'work_complete', 'awaiting_review', 'awaiting_release') then 1 else 0 end)::int`,
153
+ inProgressCount: sql<number>`sum(case when ${maintenanceTasks.statusCode} in ('assigned', 'in_progress') then 1 else 0 end)::int`,
154
+ priorityP1Count: sql<number>`sum(case when ${maintenanceTasks.priorityCode} = 'p1_critical' then 1 else 0 end)::int`,
155
+ pmCount: sql<number>`sum(case when ${maintenanceTasks.maintenanceType} = 'preventive_maintenance' then 1 else 0 end)::int`,
156
+ })
157
+ .from(maintenanceTasks)
158
+ .where(and(...conditions, sql`${maintenanceTasks.assignedEngineerUserId} IS NOT NULL`))
159
+ .groupBy(maintenanceTasks.assignedEngineerUserId);
160
+
161
+ return results;
162
+ }
163
+
164
+ export async function getDeviceHistoryReport(filters: z.infer<typeof deviceHistoryFiltersSchema>) {
165
+ const user = await requireAuth();
166
+ await requirePermission('REPORTS', 'VIEW');
167
+ if (!user.organizationId) return { tickets: [], tasks: [] };
168
+
169
+ if (!filters.deviceId) {
170
+ throw new Error('Device ID is required for device history report');
171
+ }
172
+
173
+ const [tickets, tasks] = await Promise.all([
174
+ db.query.serviceTickets.findMany({
175
+ where: and(
176
+ eq(serviceTickets.deviceId, filters.deviceId),
177
+ eq(serviceTickets.organizationId, user.organizationId),
178
+ ),
179
+ orderBy: [desc(serviceTickets.reportedAt)],
180
+ }),
181
+ db.query.maintenanceTasks.findMany({
182
+ where: and(
183
+ eq(maintenanceTasks.deviceId, filters.deviceId),
184
+ eq(maintenanceTasks.organizationId, user.organizationId),
185
+ ),
186
+ orderBy: [desc(maintenanceTasks.createdAt)],
187
+ }),
188
+ ]);
189
+
190
+ return { tickets, tasks };
191
+ }
192
+
@@ -0,0 +1,32 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { requireAuth, requireRole } from '@/lib/auth/rbac';
5
+
6
+ export async function getRoles() {
7
+ await requireAuth();
8
+ await requireRole('SYS_ADMIN', 'ORG_ADMIN');
9
+ try {
10
+ const data = await db.query.roles.findMany();
11
+ return { success: true, data };
12
+ } catch (error: any) {
13
+ return { success: false, error: error.message };
14
+ }
15
+ }
16
+
17
+ export async function getPermissionsMatrix() {
18
+ await requireAuth();
19
+ await requireRole('SYS_ADMIN', 'ORG_ADMIN');
20
+ try {
21
+ const data = await db.query.rolePermissions.findMany({
22
+ with: {
23
+ role: true,
24
+ permission: true,
25
+ }
26
+ });
27
+ return { success: true, data };
28
+ } catch (error: any) {
29
+ return { success: false, error: error.message };
30
+ }
31
+ }
32
+