@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,973 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import {
5
+ engineerRounds, roundDepartmentVisits, roundVisitNotes,
6
+ engineerProfiles, departments, users, organizations,
7
+ hospitals, devices, serviceTickets, electronicSignatures, recordVersions,
8
+ } from '@/lib/db/schema';
9
+ import { requireAuth, requireRole, hasRole } from '@/lib/auth/rbac';
10
+ import { createAuditLog } from '@/lib/audit';
11
+ import {
12
+ startRoundSchema, completeDepartmentVisitSchema, addVisitNoteSchema,
13
+ completeRoundSchema, roundHistoryFilterSchema, roundCalendarQuerySchema,
14
+ } from '@/lib/validators/engineer-rounds';
15
+ import { systemSettingsSchema } from '@/lib/validators/system-settings';
16
+ import { eq, and, desc, asc, sql, count, between, inArray, gte, lte } from 'drizzle-orm';
17
+ import { verifySignatureAuth } from './signature-auth';
18
+
19
+ // ── Helper: Check if Engineer's Round feature is enabled ────────────────────────
20
+
21
+ export async function isEngineerRoundEnabled(orgId?: string): Promise<boolean> {
22
+ try {
23
+ const session = await requireAuth();
24
+ const targetOrgId = orgId || session.organizationId;
25
+ if (!targetOrgId) return false;
26
+
27
+ const org = await db.query.organizations.findFirst({
28
+ where: eq(organizations.id, targetOrgId),
29
+ });
30
+ if (!org) return false;
31
+
32
+ const parsed = systemSettingsSchema.safeParse(org.settings || {});
33
+ if (!parsed.success) return false;
34
+
35
+ return parsed.data.features?.engineerRoundEnabled ?? false;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ // ── Helper: Get engineer profile with covered departments ───────────────────────
42
+
43
+ async function getEngineerProfile(userId: string) {
44
+ return db.query.engineerProfiles.findFirst({
45
+ where: eq(engineerProfiles.userId, userId),
46
+ with: {
47
+ hospital: true,
48
+ },
49
+ });
50
+ }
51
+
52
+ // ── Helper: Get today's date as YYYY-MM-DD ──────────────────────────────────────
53
+
54
+ function getTodayDate(): string {
55
+ return new Date().toISOString().split('T')[0];
56
+ }
57
+
58
+ // ── Get Today's Round (or create scheduled one) ─────────────────────────────────
59
+
60
+ export async function getTodayRound() {
61
+ const session = await requireAuth();
62
+ const enabled = await isEngineerRoundEnabled();
63
+ if (!enabled) {
64
+ return { success: false, error: 'Engineer\'s Round feature is not enabled' };
65
+ }
66
+
67
+ const today = getTodayDate();
68
+
69
+ try {
70
+ // Check if a round already exists for today
71
+ const existingRound = await db.query.engineerRounds.findFirst({
72
+ where: and(
73
+ eq(engineerRounds.engineerId, session.id),
74
+ eq(engineerRounds.roundDate, today),
75
+ ),
76
+ with: {
77
+ hospital: true,
78
+ departmentVisits: {
79
+ with: {
80
+ notes: {
81
+ with: {
82
+ author: { columns: { id: true, fullName: true } },
83
+ },
84
+ },
85
+ department: { columns: { id: true, name: true, code: true } },
86
+ },
87
+ orderBy: [asc(roundDepartmentVisits.sequence)],
88
+ },
89
+ engineer: { columns: { id: true, fullName: true } },
90
+ },
91
+ });
92
+
93
+ if (existingRound) {
94
+ // Count linked tickets for each visit
95
+ const visitIds = existingRound.departmentVisits.map(v => v.id);
96
+ let ticketCounts: Record<string, number> = {};
97
+
98
+ if (visitIds.length > 0) {
99
+ const tickets = await db
100
+ .select({
101
+ roundVisitId: serviceTickets.roundVisitId,
102
+ count: count(),
103
+ })
104
+ .from(serviceTickets)
105
+ .where(inArray(serviceTickets.roundVisitId, visitIds))
106
+ .groupBy(serviceTickets.roundVisitId);
107
+
108
+ ticketCounts = Object.fromEntries(
109
+ tickets.map(t => [t.roundVisitId, Number(t.count)])
110
+ );
111
+ }
112
+
113
+ return {
114
+ success: true,
115
+ data: {
116
+ ...existingRound,
117
+ departmentVisits: existingRound.departmentVisits.map(v => ({
118
+ ...v,
119
+ ticketCount: ticketCounts[v.id] || 0,
120
+ })),
121
+ },
122
+ };
123
+ }
124
+
125
+ // No round exists — get engineer profile to determine if they should have one
126
+ const profile = await getEngineerProfile(session.id);
127
+ if (!profile) {
128
+ return { success: false, error: 'Engineer profile not found. Please set up your profile first.' };
129
+ }
130
+
131
+ // Check if today is a working day
132
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
133
+ const todayDay = dayNames[new Date().getDay()];
134
+ const workingDays = (profile.workingDays as string[]) || [];
135
+
136
+ if (!workingDays.includes(todayDay)) {
137
+ return {
138
+ success: true,
139
+ data: null,
140
+ isOffDay: true,
141
+ message: 'Today is not a scheduled working day',
142
+ };
143
+ }
144
+
145
+ // Get covered departments
146
+ const coveredDeptIds = (profile.coveredDepartmentIds as string[]) || [];
147
+ if (coveredDeptIds.length === 0) {
148
+ return {
149
+ success: false,
150
+ error: 'No departments assigned to your profile. Please contact your manager.',
151
+ };
152
+ }
153
+
154
+ // Fetch department details
155
+ const deptList = await db.query.departments.findMany({
156
+ where: and(
157
+ inArray(departments.id, coveredDeptIds),
158
+ eq(departments.status, 'active'),
159
+ ),
160
+ columns: { id: true, name: true, code: true },
161
+ });
162
+
163
+ return {
164
+ success: true,
165
+ data: null,
166
+ canStart: true,
167
+ hospitalId: profile.hospitalId,
168
+ hospitalName: profile.hospital?.name,
169
+ departments: deptList,
170
+ shiftType: inferShiftType(),
171
+ };
172
+ } catch (error: any) {
173
+ return { success: false, error: error.message };
174
+ }
175
+ }
176
+
177
+ // ── Start Round ─────────────────────────────────────────────────────────────────
178
+
179
+ export async function startRound(input: unknown) {
180
+ const session = await requireAuth();
181
+ await requireRole('BIOMED_ENG', 'BIOMED_TECH', 'BIOMED_MGR');
182
+
183
+ const enabled = await isEngineerRoundEnabled();
184
+ if (!enabled) {
185
+ return { success: false, error: 'Engineer\'s Round feature is not enabled' };
186
+ }
187
+
188
+ const validated = startRoundSchema.safeParse(input);
189
+ if (!validated.success) {
190
+ return { success: false, error: validated.error.message };
191
+ }
192
+
193
+ const { hospitalId, shiftType, password } = validated.data;
194
+ const today = getTodayDate();
195
+
196
+ // Check for existing round today
197
+ const existing = await db.query.engineerRounds.findFirst({
198
+ where: and(
199
+ eq(engineerRounds.engineerId, session.id),
200
+ eq(engineerRounds.roundDate, today),
201
+ ),
202
+ });
203
+
204
+ if (existing && existing.status !== 'scheduled') {
205
+ return { success: false, error: 'A round already exists for today' };
206
+ }
207
+
208
+ // Verify signature (password re-auth)
209
+ const authResult = await verifySignatureAuth(session.id, password);
210
+ if (!authResult.success) {
211
+ return { success: false, error: 'Signature verification failed: ' + authResult.error };
212
+ }
213
+
214
+ // Get engineer profile for departments
215
+ const profile = await getEngineerProfile(session.id);
216
+ if (!profile) {
217
+ return { success: false, error: 'Engineer profile not found' };
218
+ }
219
+
220
+ const coveredDeptIds = (profile.coveredDepartmentIds as string[]) || [];
221
+ if (coveredDeptIds.length === 0) {
222
+ return { success: false, error: 'No departments assigned to your profile' };
223
+ }
224
+
225
+ // Fetch active departments
226
+ const deptList = await db.query.departments.findMany({
227
+ where: and(
228
+ inArray(departments.id, coveredDeptIds),
229
+ eq(departments.status, 'active'),
230
+ ),
231
+ columns: { id: true, name: true },
232
+ });
233
+
234
+ try {
235
+ const result = await db.transaction(async (tx) => {
236
+ const roundId = existing?.id || crypto.randomUUID();
237
+
238
+ // Create the e-signature record version
239
+ const contentSnapshot = {
240
+ action: 'start_engineer_round',
241
+ engineerId: session.id,
242
+ engineerName: session.fullName,
243
+ roundDate: today,
244
+ shiftType,
245
+ hospitalId,
246
+ departments: deptList.map(d => d.name),
247
+ signedAt: new Date().toISOString(),
248
+ };
249
+
250
+ const { createHash } = await import('crypto');
251
+ const contentHash = createHash('sha256')
252
+ .update(JSON.stringify(contentSnapshot))
253
+ .digest('hex');
254
+
255
+ const [recordVersion] = await tx.insert(recordVersions).values({
256
+ organizationId: session.organizationId!,
257
+ entityType: 'engineer_round',
258
+ entityId: roundId,
259
+ versionNumber: 1,
260
+ canonicalSnapshotJsonb: contentSnapshot,
261
+ contentHashSha256: contentHash,
262
+ createdByUserId: session.id,
263
+ }).returning();
264
+
265
+ const [signature] = await tx.insert(electronicSignatures).values({
266
+ organizationId: session.organizationId!,
267
+ recordVersionId: recordVersion.id,
268
+ entityType: 'engineer_round',
269
+ entityId: roundId,
270
+ signaturePurpose: 'perform',
271
+ signerUserId: session.id,
272
+ signerNameSnapshot: session.fullName || 'Unknown',
273
+ signerRoleSnapshot: 'BIOMED_ENG',
274
+ attestationTextVersion: 'I confirm the start of this daily engineering round.',
275
+ authMethod: 'password_reauth',
276
+ signedAt: new Date(),
277
+ signedContentHashSha256: contentHash,
278
+ }).returning();
279
+
280
+ // Create or update the round
281
+ if (existing) {
282
+ await tx.update(engineerRounds)
283
+ .set({
284
+ status: 'in_progress',
285
+ startedAt: new Date(),
286
+ startSignatureId: signature.id,
287
+ totalDepartments: deptList.length,
288
+ updatedAt: new Date(),
289
+ })
290
+ .where(eq(engineerRounds.id, existing.id));
291
+ } else {
292
+ await tx.insert(engineerRounds).values({
293
+ id: roundId,
294
+ organizationId: session.organizationId!,
295
+ hospitalId,
296
+ engineerId: session.id,
297
+ roundDate: today,
298
+ shiftType,
299
+ status: 'in_progress',
300
+ startedAt: new Date(),
301
+ startSignatureId: signature.id,
302
+ totalDepartments: deptList.length,
303
+ });
304
+ }
305
+
306
+ // Create department visit entries
307
+ const visitValues = deptList.map((dept, idx) => ({
308
+ roundId,
309
+ departmentId: dept.id,
310
+ departmentName: dept.name,
311
+ sequence: idx + 1,
312
+ status: 'pending' as const,
313
+ }));
314
+
315
+ await tx.insert(roundDepartmentVisits).values(visitValues);
316
+
317
+ // Audit log
318
+ await createAuditLog(tx, {
319
+ action: 'CREATE',
320
+ entityType: 'engineer_round',
321
+ entityId: roundId,
322
+ actorUserId: session.id,
323
+ organizationId: session.organizationId!,
324
+ hospitalId,
325
+ newState: {
326
+ roundDate: today,
327
+ shiftType,
328
+ departments: deptList.map(d => d.name),
329
+ signatureId: signature.id,
330
+ },
331
+ changeReason: 'Engineer started daily round with electronic signature',
332
+ });
333
+
334
+ return { roundId, signatureId: signature.id };
335
+ });
336
+
337
+ return { success: true, data: result };
338
+ } catch (error: any) {
339
+ return { success: false, error: error.message };
340
+ }
341
+ }
342
+
343
+ // ── Complete Department Visit ────────────────────────────────────────────────────
344
+
345
+ export async function completeDepartmentVisit(input: unknown) {
346
+ const session = await requireAuth();
347
+
348
+ const validated = completeDepartmentVisitSchema.safeParse(input);
349
+ if (!validated.success) {
350
+ return { success: false, error: validated.error.message };
351
+ }
352
+
353
+ const { visitId, noteText, deviceMentions } = validated.data;
354
+
355
+ try {
356
+ // Verify visit belongs to the current engineer's active round
357
+ const visit = await db.query.roundDepartmentVisits.findFirst({
358
+ where: eq(roundDepartmentVisits.id, visitId),
359
+ with: {
360
+ round: { columns: { id: true, engineerId: true, status: true } },
361
+ },
362
+ });
363
+
364
+ if (!visit) {
365
+ return { success: false, error: 'Department visit not found' };
366
+ }
367
+ if (visit.round.engineerId !== session.id) {
368
+ return { success: false, error: 'This visit does not belong to your round' };
369
+ }
370
+ if (visit.round.status !== 'in_progress') {
371
+ return { success: false, error: 'Round is not in progress' };
372
+ }
373
+ if (visit.status === 'completed') {
374
+ return { success: false, error: 'This department visit is already completed' };
375
+ }
376
+
377
+ const result = await db.transaction(async (tx) => {
378
+ const now = new Date();
379
+
380
+ // Mark visit as completed
381
+ await tx.update(roundDepartmentVisits)
382
+ .set({
383
+ status: 'completed',
384
+ startedAt: visit.startedAt || now,
385
+ completedAt: now,
386
+ })
387
+ .where(eq(roundDepartmentVisits.id, visitId));
388
+
389
+ // Add note if provided
390
+ if (noteText && noteText.trim()) {
391
+ await tx.insert(roundVisitNotes).values({
392
+ visitId,
393
+ authorId: session.id,
394
+ noteText: noteText.trim(),
395
+ deviceMentions: deviceMentions || [],
396
+ });
397
+
398
+ // Increment note count on round
399
+ await tx.update(engineerRounds)
400
+ .set({
401
+ totalNotes: sql`${engineerRounds.totalNotes} + 1`,
402
+ updatedAt: now,
403
+ })
404
+ .where(eq(engineerRounds.id, visit.round.id));
405
+ }
406
+
407
+ // Increment completed department count
408
+ await tx.update(engineerRounds)
409
+ .set({
410
+ completedDepartments: sql`${engineerRounds.completedDepartments} + 1`,
411
+ updatedAt: now,
412
+ })
413
+ .where(eq(engineerRounds.id, visit.round.id));
414
+
415
+ return { visitId, completedAt: now };
416
+ });
417
+
418
+ return { success: true, data: result };
419
+ } catch (error: any) {
420
+ return { success: false, error: error.message };
421
+ }
422
+ }
423
+
424
+ // ── Add Note to Visit ───────────────────────────────────────────────────────────
425
+
426
+ export async function addVisitNote(input: unknown) {
427
+ const session = await requireAuth();
428
+
429
+ const validated = addVisitNoteSchema.safeParse(input);
430
+ if (!validated.success) {
431
+ return { success: false, error: validated.error.message };
432
+ }
433
+
434
+ const { visitId, noteText, deviceMentions } = validated.data;
435
+
436
+ try {
437
+ const visit = await db.query.roundDepartmentVisits.findFirst({
438
+ where: eq(roundDepartmentVisits.id, visitId),
439
+ with: {
440
+ round: { columns: { id: true, engineerId: true, status: true } },
441
+ },
442
+ });
443
+
444
+ if (!visit) {
445
+ return { success: false, error: 'Visit not found' };
446
+ }
447
+ if (visit.round.engineerId !== session.id) {
448
+ return { success: false, error: 'This visit does not belong to your round' };
449
+ }
450
+
451
+ const [note] = await db.insert(roundVisitNotes).values({
452
+ visitId,
453
+ authorId: session.id,
454
+ noteText: noteText.trim(),
455
+ deviceMentions: deviceMentions || [],
456
+ }).returning();
457
+
458
+ // Increment note count
459
+ await db.update(engineerRounds)
460
+ .set({
461
+ totalNotes: sql`${engineerRounds.totalNotes} + 1`,
462
+ updatedAt: new Date(),
463
+ })
464
+ .where(eq(engineerRounds.id, visit.round.id));
465
+
466
+ return { success: true, data: note };
467
+ } catch (error: any) {
468
+ return { success: false, error: error.message };
469
+ }
470
+ }
471
+
472
+ // ── Complete Round ──────────────────────────────────────────────────────────────
473
+
474
+ export async function completeRound(input: unknown) {
475
+ const session = await requireAuth();
476
+
477
+ const validated = completeRoundSchema.safeParse(input);
478
+ if (!validated.success) {
479
+ return { success: false, error: validated.error.message };
480
+ }
481
+
482
+ const { roundId } = validated.data;
483
+
484
+ try {
485
+ const round = await db.query.engineerRounds.findFirst({
486
+ where: eq(engineerRounds.id, roundId),
487
+ with: {
488
+ departmentVisits: { columns: { id: true, status: true } },
489
+ },
490
+ });
491
+
492
+ if (!round) {
493
+ return { success: false, error: 'Round not found' };
494
+ }
495
+ if (round.engineerId !== session.id) {
496
+ return { success: false, error: 'This is not your round' };
497
+ }
498
+ if (round.status !== 'in_progress') {
499
+ return { success: false, error: 'Round is not in progress' };
500
+ }
501
+
502
+ const now = new Date();
503
+ const completedCount = round.departmentVisits.filter(v => v.status === 'completed').length;
504
+ const pendingVisits = round.departmentVisits.filter(v => v.status === 'pending');
505
+
506
+ // Count linked tickets
507
+ const ticketResult = await db
508
+ .select({ count: count() })
509
+ .from(serviceTickets)
510
+ .where(eq(serviceTickets.roundVisitId, roundId));
511
+ // Actually count tickets linked to any visit in this round
512
+ const visitIds = round.departmentVisits.map(v => v.id);
513
+ let totalTickets = 0;
514
+ if (visitIds.length > 0) {
515
+ const [tc] = await db
516
+ .select({ count: count() })
517
+ .from(serviceTickets)
518
+ .where(inArray(serviceTickets.roundVisitId, visitIds));
519
+ totalTickets = Number(tc?.count || 0);
520
+ }
521
+
522
+ const result = await db.transaction(async (tx) => {
523
+ // Mark remaining pending visits as skipped
524
+ if (pendingVisits.length > 0) {
525
+ await tx.update(roundDepartmentVisits)
526
+ .set({ status: 'skipped' })
527
+ .where(
528
+ and(
529
+ eq(roundDepartmentVisits.roundId, roundId),
530
+ eq(roundDepartmentVisits.status, 'pending'),
531
+ )
532
+ );
533
+ }
534
+
535
+ // Complete the round
536
+ await tx.update(engineerRounds)
537
+ .set({
538
+ status: 'completed',
539
+ completedAt: now,
540
+ completedDepartments: completedCount,
541
+ totalIssuesReported: totalTickets,
542
+ updatedAt: now,
543
+ })
544
+ .where(eq(engineerRounds.id, roundId));
545
+
546
+ // Audit log
547
+ await createAuditLog(tx, {
548
+ action: 'UPDATE',
549
+ entityType: 'engineer_round',
550
+ entityId: roundId,
551
+ actorUserId: session.id,
552
+ organizationId: round.organizationId,
553
+ hospitalId: round.hospitalId,
554
+ newState: {
555
+ status: 'completed',
556
+ completedDepartments: completedCount,
557
+ totalDepartments: round.totalDepartments,
558
+ totalIssuesReported: totalTickets,
559
+ skippedDepartments: pendingVisits.length,
560
+ },
561
+ changeReason: 'Engineer completed daily round',
562
+ });
563
+
564
+ return {
565
+ roundId,
566
+ completedAt: now,
567
+ completedDepartments: completedCount,
568
+ totalDepartments: round.totalDepartments,
569
+ skippedDepartments: pendingVisits.length,
570
+ totalIssuesReported: totalTickets,
571
+ };
572
+ });
573
+
574
+ return { success: true, data: result };
575
+ } catch (error: any) {
576
+ return { success: false, error: error.message };
577
+ }
578
+ }
579
+
580
+ // ── Get Round Detail ────────────────────────────────────────────────────────────
581
+
582
+ export async function getRoundDetail(roundId: string) {
583
+ const session = await requireAuth();
584
+
585
+ try {
586
+ const round = await db.query.engineerRounds.findFirst({
587
+ where: eq(engineerRounds.id, roundId),
588
+ with: {
589
+ hospital: { columns: { id: true, name: true } },
590
+ engineer: { columns: { id: true, fullName: true } },
591
+ startSignature: {
592
+ columns: { id: true, signedAt: true, signerNameSnapshot: true },
593
+ },
594
+ departmentVisits: {
595
+ with: {
596
+ department: { columns: { id: true, name: true, code: true } },
597
+ notes: {
598
+ with: {
599
+ author: { columns: { id: true, fullName: true } },
600
+ },
601
+ orderBy: [asc(roundVisitNotes.createdAt)],
602
+ },
603
+ },
604
+ orderBy: [asc(roundDepartmentVisits.sequence)],
605
+ },
606
+ },
607
+ });
608
+
609
+ if (!round) {
610
+ return { success: false, error: 'Round not found' };
611
+ }
612
+
613
+ // Check access: own round or manager/admin
614
+ const isOwn = round.engineerId === session.id;
615
+ const isManager = hasRole(session.roles, 'BIOMED_MGR', 'HOSP_ADMIN', 'ORG_ADMIN', 'SYS_ADMIN');
616
+ if (!isOwn && !isManager) {
617
+ return { success: false, error: 'Access denied' };
618
+ }
619
+
620
+ // Get ticket counts per visit
621
+ const visitIds = round.departmentVisits.map(v => v.id);
622
+ const ticketsByVisit: Record<string, any[]> = {};
623
+
624
+ if (visitIds.length > 0) {
625
+ const tickets = await db
626
+ .select({
627
+ roundVisitId: serviceTickets.roundVisitId,
628
+ ticketNumber: serviceTickets.ticketNumber,
629
+ title: serviceTickets.title,
630
+ priorityCode: serviceTickets.priorityCode,
631
+ statusCode: serviceTickets.statusCode,
632
+ })
633
+ .from(serviceTickets)
634
+ .where(inArray(serviceTickets.roundVisitId, visitIds));
635
+
636
+ for (const ticket of tickets) {
637
+ if (ticket.roundVisitId) {
638
+ if (!ticketsByVisit[ticket.roundVisitId]) ticketsByVisit[ticket.roundVisitId] = [];
639
+ ticketsByVisit[ticket.roundVisitId].push(ticket);
640
+ }
641
+ }
642
+ }
643
+
644
+ return {
645
+ success: true,
646
+ data: {
647
+ ...round,
648
+ departmentVisits: round.departmentVisits.map(v => ({
649
+ ...v,
650
+ tickets: ticketsByVisit[v.id] || [],
651
+ })),
652
+ },
653
+ };
654
+ } catch (error: any) {
655
+ return { success: false, error: error.message };
656
+ }
657
+ }
658
+
659
+ // ── Round History ───────────────────────────────────────────────────────────────
660
+
661
+ export async function getRoundHistory(input: unknown) {
662
+ const session = await requireAuth();
663
+
664
+ const validated = roundHistoryFilterSchema.safeParse(input || {});
665
+ if (!validated.success) {
666
+ return { success: false, error: validated.error.message };
667
+ }
668
+
669
+ const { hospitalId, engineerId, status, dateFrom, dateTo, page, pageSize } = validated.data;
670
+
671
+ try {
672
+ const isManager = hasRole(session.roles, 'BIOMED_MGR', 'HOSP_ADMIN', 'ORG_ADMIN', 'SYS_ADMIN');
673
+ const targetEngineerId = isManager ? (engineerId || undefined) : session.id;
674
+
675
+ const conditions = [];
676
+ if (session.organizationId) {
677
+ conditions.push(eq(engineerRounds.organizationId, session.organizationId));
678
+ }
679
+ if (targetEngineerId) {
680
+ conditions.push(eq(engineerRounds.engineerId, targetEngineerId));
681
+ }
682
+ if (hospitalId) {
683
+ conditions.push(eq(engineerRounds.hospitalId, hospitalId));
684
+ }
685
+ if (status) {
686
+ conditions.push(eq(engineerRounds.status, status));
687
+ }
688
+ if (dateFrom) {
689
+ conditions.push(gte(engineerRounds.roundDate, dateFrom));
690
+ }
691
+ if (dateTo) {
692
+ conditions.push(lte(engineerRounds.roundDate, dateTo));
693
+ }
694
+
695
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
696
+ const offset = (page - 1) * pageSize;
697
+
698
+ const [rounds, [totalResult]] = await Promise.all([
699
+ db.query.engineerRounds.findMany({
700
+ where: whereClause,
701
+ with: {
702
+ hospital: { columns: { id: true, name: true } },
703
+ engineer: { columns: { id: true, fullName: true } },
704
+ },
705
+ orderBy: [desc(engineerRounds.roundDate), desc(engineerRounds.createdAt)],
706
+ limit: pageSize,
707
+ offset,
708
+ }),
709
+ db.select({ count: count() })
710
+ .from(engineerRounds)
711
+ .where(whereClause),
712
+ ]);
713
+
714
+ return {
715
+ success: true,
716
+ data: rounds,
717
+ pagination: {
718
+ page,
719
+ pageSize,
720
+ total: Number(totalResult?.count || 0),
721
+ totalPages: Math.ceil(Number(totalResult?.count || 0) / pageSize),
722
+ },
723
+ };
724
+ } catch (error: any) {
725
+ return { success: false, error: error.message };
726
+ }
727
+ }
728
+
729
+ // ── Calendar Data (GitHub contributions style) ──────────────────────────────────
730
+
731
+ export async function getRoundCalendarData(input: unknown) {
732
+ const session = await requireAuth();
733
+
734
+ const validated = roundCalendarQuerySchema.safeParse(input);
735
+ if (!validated.success) {
736
+ return { success: false, error: validated.error.message };
737
+ }
738
+
739
+ const { engineerId, month, year } = validated.data;
740
+
741
+ // Access check
742
+ const isManager = hasRole(session.roles, 'BIOMED_MGR', 'HOSP_ADMIN', 'ORG_ADMIN', 'SYS_ADMIN');
743
+ if (engineerId !== session.id && !isManager) {
744
+ return { success: false, error: 'Access denied' };
745
+ }
746
+
747
+ try {
748
+ // Calculate date range for the month
749
+ const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
750
+ const lastDay = new Date(year, month, 0).getDate();
751
+ const endDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
752
+
753
+ // Get all rounds for this month
754
+ const rounds = await db.query.engineerRounds.findMany({
755
+ where: and(
756
+ eq(engineerRounds.engineerId, engineerId),
757
+ gte(engineerRounds.roundDate, startDate),
758
+ lte(engineerRounds.roundDate, endDate),
759
+ ),
760
+ columns: {
761
+ id: true,
762
+ roundDate: true,
763
+ status: true,
764
+ totalDepartments: true,
765
+ completedDepartments: true,
766
+ totalIssuesReported: true,
767
+ totalNotes: true,
768
+ startedAt: true,
769
+ completedAt: true,
770
+ },
771
+ orderBy: [asc(engineerRounds.roundDate)],
772
+ });
773
+
774
+ // Get engineer's working days
775
+ const profile = await getEngineerProfile(engineerId);
776
+ const workingDays = (profile?.workingDays as string[]) || [];
777
+
778
+ // Build calendar data
779
+ const calendarDays = [];
780
+ for (let day = 1; day <= lastDay; day++) {
781
+ const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
782
+ const dateObj = new Date(year, month - 1, day);
783
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
784
+ const dayName = dayNames[dateObj.getDay()];
785
+ const isWorkingDay = workingDays.includes(dayName);
786
+ const round = rounds.find(r => r.roundDate === dateStr);
787
+
788
+ let status: 'completed' | 'partial' | 'missed' | 'in_progress' | 'scheduled' | 'off_day';
789
+ if (!isWorkingDay) {
790
+ status = 'off_day';
791
+ } else if (round) {
792
+ if (round.status === 'completed') {
793
+ status = round.completedDepartments < round.totalDepartments ? 'partial' : 'completed';
794
+ } else if (round.status === 'in_progress') {
795
+ status = 'in_progress';
796
+ } else if (round.status === 'missed') {
797
+ status = 'missed';
798
+ } else if (round.status === 'scheduled') {
799
+ status = 'scheduled';
800
+ } else {
801
+ status = 'missed';
802
+ }
803
+ } else {
804
+ // Working day with no round
805
+ const today = new Date();
806
+ today.setHours(0, 0, 0, 0);
807
+ const isPast = dateObj < today;
808
+ status = isPast ? 'missed' : 'scheduled';
809
+ }
810
+
811
+ calendarDays.push({
812
+ date: dateStr,
813
+ day,
814
+ dayName,
815
+ isWorkingDay,
816
+ status,
817
+ round: round || null,
818
+ });
819
+ }
820
+
821
+ // Summary stats
822
+ const workingDayCount = calendarDays.filter(d => d.isWorkingDay).length;
823
+ const completedCount = calendarDays.filter(d => d.status === 'completed' || d.status === 'partial').length;
824
+ const missedCount = calendarDays.filter(d => d.status === 'missed').length;
825
+
826
+ return {
827
+ success: true,
828
+ data: {
829
+ month,
830
+ year,
831
+ engineerId,
832
+ workingDays,
833
+ calendarDays,
834
+ summary: {
835
+ totalWorkingDays: workingDayCount,
836
+ completed: completedCount,
837
+ missed: missedCount,
838
+ complianceRate: workingDayCount > 0
839
+ ? Math.round((completedCount / workingDayCount) * 100)
840
+ : 0,
841
+ },
842
+ },
843
+ };
844
+ } catch (error: any) {
845
+ return { success: false, error: error.message };
846
+ }
847
+ }
848
+
849
+ // ── Manager: Get Team Round Compliance ──────────────────────────────────────────
850
+
851
+ export async function getTeamRoundCompliance(dateStr?: string) {
852
+ const session = await requireAuth();
853
+ await requireRole('BIOMED_MGR', 'HOSP_ADMIN', 'ORG_ADMIN', 'SYS_ADMIN');
854
+
855
+ const targetDate = dateStr || getTodayDate();
856
+
857
+ try {
858
+ // Get all engineers in the organization
859
+ const profiles = await db.query.engineerProfiles.findMany({
860
+ where: session.organizationId
861
+ ? eq(engineerProfiles.organizationId, session.organizationId)
862
+ : undefined,
863
+ with: {
864
+ user: { columns: { id: true, fullName: true } },
865
+ hospital: { columns: { id: true, name: true } },
866
+ },
867
+ });
868
+
869
+ // Get all rounds for the target date
870
+ const rounds = await db.query.engineerRounds.findMany({
871
+ where: and(
872
+ eq(engineerRounds.roundDate, targetDate),
873
+ session.organizationId
874
+ ? eq(engineerRounds.organizationId, session.organizationId)
875
+ : undefined,
876
+ ),
877
+ });
878
+
879
+ const roundsByEngineer = Object.fromEntries(
880
+ rounds.map(r => [r.engineerId, r])
881
+ );
882
+
883
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
884
+ const targetDayName = dayNames[new Date(targetDate + 'T00:00:00').getDay()];
885
+
886
+ const team = profiles.map(p => {
887
+ const workingDays = (p.workingDays as string[]) || [];
888
+ const isWorkingDay = workingDays.includes(targetDayName);
889
+ const round = roundsByEngineer[p.userId];
890
+
891
+ let status: string;
892
+ if (!isWorkingDay) {
893
+ status = 'off_duty';
894
+ } else if (round) {
895
+ status = round.status;
896
+ } else {
897
+ const today = new Date();
898
+ today.setHours(0, 0, 0, 0);
899
+ const isPast = new Date(targetDate + 'T00:00:00') < today;
900
+ status = isPast ? 'missed' : 'pending';
901
+ }
902
+
903
+ return {
904
+ engineerId: p.userId,
905
+ engineerName: p.user?.fullName || 'Unknown',
906
+ hospitalName: p.hospital?.name || 'Unknown',
907
+ isWorkingDay,
908
+ status,
909
+ round: round || null,
910
+ };
911
+ });
912
+
913
+ const scheduledEngineers = team.filter(t => t.isWorkingDay);
914
+ const completedEngineers = team.filter(t => t.status === 'completed');
915
+
916
+ return {
917
+ success: true,
918
+ data: {
919
+ date: targetDate,
920
+ team,
921
+ summary: {
922
+ totalEngineers: profiles.length,
923
+ scheduledToday: scheduledEngineers.length,
924
+ completed: completedEngineers.length,
925
+ inProgress: team.filter(t => t.status === 'in_progress').length,
926
+ missed: team.filter(t => t.status === 'missed').length,
927
+ offDuty: team.filter(t => !t.isWorkingDay).length,
928
+ complianceRate: scheduledEngineers.length > 0
929
+ ? Math.round((completedEngineers.length / scheduledEngineers.length) * 100)
930
+ : 100,
931
+ },
932
+ },
933
+ };
934
+ } catch (error: any) {
935
+ return { success: false, error: error.message };
936
+ }
937
+ }
938
+
939
+ // ── Get Devices by Department (for @mention) ────────────────────────────────────
940
+
941
+ export async function getDepartmentDevices(departmentId: string) {
942
+ const session = await requireAuth();
943
+
944
+ try {
945
+ const deviceList = await db.query.devices.findMany({
946
+ where: and(
947
+ eq(devices.departmentId, departmentId),
948
+ eq(devices.lifecycleStatus, 'active'),
949
+ ),
950
+ columns: {
951
+ id: true,
952
+ name: true,
953
+ internalCode: true,
954
+ assetNumber: true,
955
+ currentStatusCode: true,
956
+ },
957
+ orderBy: [asc(devices.name)],
958
+ });
959
+
960
+ return { success: true, data: deviceList };
961
+ } catch (error: any) {
962
+ return { success: false, error: error.message };
963
+ }
964
+ }
965
+
966
+ // ── Helper: Infer shift type from current time ──────────────────────────────────
967
+
968
+ function inferShiftType(): string {
969
+ const hour = new Date().getHours();
970
+ if (hour >= 6 && hour < 14) return 'morning';
971
+ if (hour >= 14 && hour < 22) return 'evening';
972
+ return 'night';
973
+ }