@open-mercato/core 0.7.1-develop.7178.1.ab93fbaf4e → 0.7.1-develop.7180.1.9717fbbb43

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 (808) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/staff_time_entry/index.js +14 -0
  3. package/dist/generated/entities/staff_time_entry/index.js.map +2 -2
  4. package/dist/generated/entities/staff_time_entry_tag/index.js +15 -0
  5. package/dist/generated/entities/staff_time_entry_tag/index.js.map +7 -0
  6. package/dist/generated/entities/staff_time_project/index.js +16 -0
  7. package/dist/generated/entities/staff_time_project/index.js.map +2 -2
  8. package/dist/generated/entities/staff_time_report/index.js +57 -0
  9. package/dist/generated/entities/staff_time_report/index.js.map +7 -0
  10. package/dist/generated/entities/staff_time_report_entry/index.js +27 -0
  11. package/dist/generated/entities/staff_time_report_entry/index.js.map +7 -0
  12. package/dist/generated/entities/staff_time_report_event/index.js +21 -0
  13. package/dist/generated/entities/staff_time_report_event/index.js.map +7 -0
  14. package/dist/generated/entities/staff_time_report_project/index.js +15 -0
  15. package/dist/generated/entities/staff_time_report_project/index.js.map +7 -0
  16. package/dist/generated/entities/staff_time_tag/index.js +21 -0
  17. package/dist/generated/entities/staff_time_tag/index.js.map +7 -0
  18. package/dist/generated/entities/staff_time_task/index.js +37 -0
  19. package/dist/generated/entities/staff_time_task/index.js.map +7 -0
  20. package/dist/generated/entities/staff_time_task_comment/index.js +21 -0
  21. package/dist/generated/entities/staff_time_task_comment/index.js.map +7 -0
  22. package/dist/generated/entities/staff_time_task_status/index.js +29 -0
  23. package/dist/generated/entities/staff_time_task_status/index.js.map +7 -0
  24. package/dist/generated/entities/staff_time_task_tag/index.js +15 -0
  25. package/dist/generated/entities/staff_time_task_tag/index.js.map +7 -0
  26. package/dist/generated/entities.ids.generated.js +11 -1
  27. package/dist/generated/entities.ids.generated.js.map +2 -2
  28. package/dist/generated/entity-fields-registry.js +149 -0
  29. package/dist/generated/entity-fields-registry.js.map +2 -2
  30. package/dist/helpers/integration/timesheetFixtures.js +7 -0
  31. package/dist/helpers/integration/timesheetFixtures.js.map +2 -2
  32. package/dist/modules/auth/cli.js +16 -0
  33. package/dist/modules/auth/cli.js.map +2 -2
  34. package/dist/modules/auth/lib/backendChrome.js +1 -0
  35. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  36. package/dist/modules/customer_accounts/lib/customerRoleAcls.js +44 -0
  37. package/dist/modules/customer_accounts/lib/customerRoleAcls.js.map +7 -0
  38. package/dist/modules/customer_accounts/setup.js +1 -28
  39. package/dist/modules/customer_accounts/setup.js.map +2 -2
  40. package/dist/modules/data_sync/api/options.js +2 -0
  41. package/dist/modules/data_sync/api/options.js.map +2 -2
  42. package/dist/modules/data_sync/backend/data-sync/page.js +39 -17
  43. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  44. package/dist/modules/data_sync/lib/start-controls.js +42 -0
  45. package/dist/modules/data_sync/lib/start-controls.js.map +7 -0
  46. package/dist/modules/staff/acl.js +9 -1
  47. package/dist/modules/staff/acl.js.map +2 -2
  48. package/dist/modules/staff/ai-agents.js +142 -0
  49. package/dist/modules/staff/ai-agents.js.map +7 -0
  50. package/dist/modules/staff/ai-tools/time-tracking-pack.js +348 -0
  51. package/dist/modules/staff/ai-tools/time-tracking-pack.js.map +7 -0
  52. package/dist/modules/staff/ai-tools/types.js +54 -0
  53. package/dist/modules/staff/ai-tools/types.js.map +7 -0
  54. package/dist/modules/staff/ai-tools.js +8 -0
  55. package/dist/modules/staff/ai-tools.js.map +7 -0
  56. package/dist/modules/staff/analytics.js +84 -1
  57. package/dist/modules/staff/analytics.js.map +2 -2
  58. package/dist/modules/staff/api/guards.js +37 -3
  59. package/dist/modules/staff/api/guards.js.map +2 -2
  60. package/dist/modules/staff/api/leave-requests/accept/route.js +1 -3
  61. package/dist/modules/staff/api/leave-requests/accept/route.js.map +2 -2
  62. package/dist/modules/staff/api/leave-requests/reject/route.js +1 -3
  63. package/dist/modules/staff/api/leave-requests/reject/route.js.map +2 -2
  64. package/dist/modules/staff/api/portal/time-reports/[id]/route.js +131 -0
  65. package/dist/modules/staff/api/portal/time-reports/[id]/route.js.map +7 -0
  66. package/dist/modules/staff/api/portal/time-reports/route.js +179 -0
  67. package/dist/modules/staff/api/portal/time-reports/route.js.map +7 -0
  68. package/dist/modules/staff/api/team-members/self/route.js +1 -3
  69. package/dist/modules/staff/api/team-members/self/route.js.map +2 -2
  70. package/dist/modules/staff/api/team-members/tags/assign/route.js +1 -3
  71. package/dist/modules/staff/api/team-members/tags/assign/route.js.map +2 -2
  72. package/dist/modules/staff/api/team-members/tags/unassign/route.js +1 -3
  73. package/dist/modules/staff/api/team-members/tags/unassign/route.js.map +2 -2
  74. package/dist/modules/staff/api/timesheets/_shared/withTimesheetInterceptors.js +174 -0
  75. package/dist/modules/staff/api/timesheets/_shared/withTimesheetInterceptors.js.map +7 -0
  76. package/dist/modules/staff/api/timesheets/access-requests/route.js +226 -0
  77. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +7 -0
  78. package/dist/modules/staff/api/timesheets/my-projects/[projectId]/route.js +15 -8
  79. package/dist/modules/staff/api/timesheets/my-projects/[projectId]/route.js.map +2 -2
  80. package/dist/modules/staff/api/timesheets/my-projects/route.js +13 -1
  81. package/dist/modules/staff/api/timesheets/my-projects/route.js.map +2 -2
  82. package/dist/modules/staff/api/timesheets/my-work/myWorkAggregate.js +42 -0
  83. package/dist/modules/staff/api/timesheets/my-work/myWorkAggregate.js.map +7 -0
  84. package/dist/modules/staff/api/timesheets/my-work/route.js +383 -0
  85. package/dist/modules/staff/api/timesheets/my-work/route.js.map +7 -0
  86. package/dist/modules/staff/api/timesheets/projects/kpis/route.js +14 -2
  87. package/dist/modules/staff/api/timesheets/projects/kpis/route.js.map +2 -2
  88. package/dist/modules/staff/api/timesheets/reports/[id]/close/route.js +168 -0
  89. package/dist/modules/staff/api/timesheets/reports/[id]/close/route.js.map +7 -0
  90. package/dist/modules/staff/api/timesheets/reports/[id]/export/route.js +246 -0
  91. package/dist/modules/staff/api/timesheets/reports/[id]/export/route.js.map +7 -0
  92. package/dist/modules/staff/api/timesheets/reports/[id]/sheet/route.js +183 -0
  93. package/dist/modules/staff/api/timesheets/reports/[id]/sheet/route.js.map +7 -0
  94. package/dist/modules/staff/api/timesheets/reports/[id]/unlock/route.js +164 -0
  95. package/dist/modules/staff/api/timesheets/reports/[id]/unlock/route.js.map +7 -0
  96. package/dist/modules/staff/api/timesheets/reports/preview/route.js +360 -0
  97. package/dist/modules/staff/api/timesheets/reports/preview/route.js.map +7 -0
  98. package/dist/modules/staff/api/timesheets/reports/route.js +316 -0
  99. package/dist/modules/staff/api/timesheets/reports/route.js.map +7 -0
  100. package/dist/modules/staff/api/timesheets/reports/shared.js +63 -0
  101. package/dist/modules/staff/api/timesheets/reports/shared.js.map +7 -0
  102. package/dist/modules/staff/api/timesheets/settings/reapply-rounding/route.js +164 -0
  103. package/dist/modules/staff/api/timesheets/settings/reapply-rounding/route.js.map +7 -0
  104. package/dist/modules/staff/api/timesheets/settings/rounding-impact/route.js +168 -0
  105. package/dist/modules/staff/api/timesheets/settings/rounding-impact/route.js.map +7 -0
  106. package/dist/modules/staff/api/timesheets/settings/route.js +199 -0
  107. package/dist/modules/staff/api/timesheets/settings/route.js.map +7 -0
  108. package/dist/modules/staff/api/timesheets/tags/access.js +76 -0
  109. package/dist/modules/staff/api/timesheets/tags/access.js.map +7 -0
  110. package/dist/modules/staff/api/timesheets/tags/entry-assignments/route.js +235 -0
  111. package/dist/modules/staff/api/timesheets/tags/entry-assignments/route.js.map +7 -0
  112. package/dist/modules/staff/api/timesheets/tags/route.js +160 -0
  113. package/dist/modules/staff/api/timesheets/tags/route.js.map +7 -0
  114. package/dist/modules/staff/api/timesheets/tags/task-assignments/route.js +233 -0
  115. package/dist/modules/staff/api/timesheets/tags/task-assignments/route.js.map +7 -0
  116. package/dist/modules/staff/api/timesheets/task-statuses/route.js +188 -0
  117. package/dist/modules/staff/api/timesheets/task-statuses/route.js.map +7 -0
  118. package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js +399 -0
  119. package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js.map +7 -0
  120. package/dist/modules/staff/api/timesheets/tasks/[id]/status/route.js +210 -0
  121. package/dist/modules/staff/api/timesheets/tasks/[id]/status/route.js.map +7 -0
  122. package/dist/modules/staff/api/timesheets/tasks/route.js +333 -0
  123. package/dist/modules/staff/api/timesheets/tasks/route.js.map +7 -0
  124. package/dist/modules/staff/api/timesheets/time-entries/[id]/duplicate/route.js +228 -0
  125. package/dist/modules/staff/api/timesheets/time-entries/[id]/duplicate/route.js.map +7 -0
  126. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +29 -10
  127. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
  128. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +31 -18
  129. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
  130. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +21 -7
  131. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  132. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +27 -7
  133. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
  134. package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js +215 -32
  135. package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js.map +2 -2
  136. package/dist/modules/staff/api/timesheets/time-entries/copy-day/route.js +378 -0
  137. package/dist/modules/staff/api/timesheets/time-entries/copy-day/route.js.map +7 -0
  138. package/dist/modules/staff/api/timesheets/time-entries/overlaps/route.js +340 -0
  139. package/dist/modules/staff/api/timesheets/time-entries/overlaps/route.js.map +7 -0
  140. package/dist/modules/staff/api/timesheets/time-entries/route.js +238 -26
  141. package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
  142. package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js +23 -8
  143. package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js.map +2 -2
  144. package/dist/modules/staff/api/timesheets/time-projects/[id]/change-currency/route.js +209 -0
  145. package/dist/modules/staff/api/timesheets/time-projects/[id]/change-currency/route.js.map +7 -0
  146. package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js +59 -2
  147. package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js.map +2 -2
  148. package/dist/modules/staff/api/timesheets/time-projects/route.js +264 -22
  149. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  150. package/dist/modules/staff/backend/staff/time-tracking/board/page.js +110 -0
  151. package/dist/modules/staff/backend/staff/time-tracking/board/page.js.map +7 -0
  152. package/dist/modules/staff/backend/staff/time-tracking/board/page.meta.js +18 -0
  153. package/dist/modules/staff/backend/staff/time-tracking/board/page.meta.js.map +7 -0
  154. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +981 -0
  155. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +7 -0
  156. package/dist/modules/staff/backend/staff/time-tracking/entries/page.meta.js +18 -0
  157. package/dist/modules/staff/backend/staff/time-tracking/entries/page.meta.js.map +7 -0
  158. package/dist/modules/staff/backend/staff/time-tracking/page.js +583 -0
  159. package/dist/modules/staff/backend/staff/time-tracking/page.js.map +7 -0
  160. package/dist/modules/staff/backend/staff/time-tracking/page.meta.js +15 -0
  161. package/dist/modules/staff/backend/staff/time-tracking/page.meta.js.map +7 -0
  162. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.js +65 -0
  163. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.js.map +7 -0
  164. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.meta.js +16 -0
  165. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.meta.js.map +7 -0
  166. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +229 -0
  167. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +7 -0
  168. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.meta.js +16 -0
  169. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.meta.js.map +7 -0
  170. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +1014 -0
  171. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +7 -0
  172. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.meta.js +16 -0
  173. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.meta.js.map +7 -0
  174. package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.js +97 -0
  175. package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.js.map +7 -0
  176. package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.meta.js +16 -0
  177. package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.meta.js.map +7 -0
  178. package/dist/modules/staff/backend/staff/time-tracking/projects/page.js +1040 -0
  179. package/dist/modules/staff/backend/staff/time-tracking/projects/page.js.map +7 -0
  180. package/dist/modules/staff/backend/staff/time-tracking/projects/page.meta.js +19 -0
  181. package/dist/modules/staff/backend/staff/time-tracking/projects/page.meta.js.map +7 -0
  182. package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js +353 -0
  183. package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js.map +7 -0
  184. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js +457 -0
  185. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js.map +7 -0
  186. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.meta.js +16 -0
  187. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.meta.js.map +7 -0
  188. package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.js +697 -0
  189. package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.js.map +7 -0
  190. package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.meta.js +16 -0
  191. package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.meta.js.map +7 -0
  192. package/dist/modules/staff/backend/staff/time-tracking/reports/page.js +174 -0
  193. package/dist/modules/staff/backend/staff/time-tracking/reports/page.js.map +7 -0
  194. package/dist/modules/staff/backend/staff/time-tracking/reports/page.meta.js +18 -0
  195. package/dist/modules/staff/backend/staff/time-tracking/reports/page.meta.js.map +7 -0
  196. package/dist/modules/staff/backend/staff/time-tracking/settings/page.js +546 -0
  197. package/dist/modules/staff/backend/staff/time-tracking/settings/page.js.map +7 -0
  198. package/dist/modules/staff/backend/staff/time-tracking/settings/page.meta.js +18 -0
  199. package/dist/modules/staff/backend/staff/time-tracking/settings/page.meta.js.map +7 -0
  200. package/dist/modules/staff/backend/staff/time-tracking/timesheet/GridView.js +710 -0
  201. package/dist/modules/staff/backend/staff/time-tracking/timesheet/GridView.js.map +7 -0
  202. package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.js +646 -0
  203. package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.js.map +7 -0
  204. package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.meta.js +18 -0
  205. package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.meta.js.map +7 -0
  206. package/dist/modules/staff/backend/staff/timesheets/page.js +4 -793
  207. package/dist/modules/staff/backend/staff/timesheets/page.js.map +2 -2
  208. package/dist/modules/staff/backend/staff/timesheets/page.meta.js +2 -7
  209. package/dist/modules/staff/backend/staff/timesheets/page.meta.js.map +2 -2
  210. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.js +5 -136
  211. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.js.map +2 -2
  212. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.meta.js +1 -7
  213. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.meta.js.map +2 -2
  214. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.js +5 -445
  215. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.js.map +3 -3
  216. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.meta.js +1 -7
  217. package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.meta.js.map +2 -2
  218. package/dist/modules/staff/backend/staff/timesheets/projects/create/page.js +4 -76
  219. package/dist/modules/staff/backend/staff/timesheets/projects/create/page.js.map +2 -2
  220. package/dist/modules/staff/backend/staff/timesheets/projects/create/page.meta.js +1 -7
  221. package/dist/modules/staff/backend/staff/timesheets/projects/create/page.meta.js.map +2 -2
  222. package/dist/modules/staff/backend/staff/timesheets/projects/page.js +4 -613
  223. package/dist/modules/staff/backend/staff/timesheets/projects/page.js.map +2 -2
  224. package/dist/modules/staff/backend/staff/timesheets/projects/page.meta.js +2 -17
  225. package/dist/modules/staff/backend/staff/timesheets/projects/page.meta.js.map +2 -2
  226. package/dist/modules/staff/ce.js +40 -0
  227. package/dist/modules/staff/ce.js.map +2 -2
  228. package/dist/modules/staff/cli.js +166 -1
  229. package/dist/modules/staff/cli.js.map +2 -2
  230. package/dist/modules/staff/commands/index.js +5 -0
  231. package/dist/modules/staff/commands/index.js.map +2 -2
  232. package/dist/modules/staff/commands/timesheets-entries.js +569 -21
  233. package/dist/modules/staff/commands/timesheets-entries.js.map +3 -3
  234. package/dist/modules/staff/commands/timesheets-projects.js +450 -7
  235. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  236. package/dist/modules/staff/commands/timesheets-reports.js +1036 -0
  237. package/dist/modules/staff/commands/timesheets-reports.js.map +7 -0
  238. package/dist/modules/staff/commands/timesheets-tags.js +1015 -0
  239. package/dist/modules/staff/commands/timesheets-tags.js.map +7 -0
  240. package/dist/modules/staff/commands/timesheets-task-comments.js +438 -0
  241. package/dist/modules/staff/commands/timesheets-task-comments.js.map +7 -0
  242. package/dist/modules/staff/commands/timesheets-task-statuses.js +723 -0
  243. package/dist/modules/staff/commands/timesheets-task-statuses.js.map +7 -0
  244. package/dist/modules/staff/commands/timesheets-tasks.js +834 -0
  245. package/dist/modules/staff/commands/timesheets-tasks.js.map +7 -0
  246. package/dist/modules/staff/data/enrichers.js +426 -12
  247. package/dist/modules/staff/data/enrichers.js.map +2 -2
  248. package/dist/modules/staff/data/entities.js +552 -3
  249. package/dist/modules/staff/data/entities.js.map +2 -2
  250. package/dist/modules/staff/data/extensions.js +41 -0
  251. package/dist/modules/staff/data/extensions.js.map +7 -0
  252. package/dist/modules/staff/data/validators.js +254 -9
  253. package/dist/modules/staff/data/validators.js.map +2 -2
  254. package/dist/modules/staff/di.js +42 -1
  255. package/dist/modules/staff/di.js.map +2 -2
  256. package/dist/modules/staff/events.js +84 -6
  257. package/dist/modules/staff/events.js.map +2 -2
  258. package/dist/modules/staff/events.payloads.js +158 -0
  259. package/dist/modules/staff/events.payloads.js.map +7 -0
  260. package/dist/modules/staff/extension-points.js +261 -0
  261. package/dist/modules/staff/extension-points.js.map +7 -0
  262. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js +138 -0
  263. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js.map +7 -0
  264. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.meta.js +12 -0
  265. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.meta.js.map +7 -0
  266. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js +133 -0
  267. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js.map +7 -0
  268. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.meta.js +19 -0
  269. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.meta.js.map +7 -0
  270. package/dist/modules/staff/lib/crud.js +11 -1
  271. package/dist/modules/staff/lib/crud.js.map +2 -2
  272. package/dist/modules/staff/lib/time-tracking/access.js +92 -0
  273. package/dist/modules/staff/lib/time-tracking/access.js.map +7 -0
  274. package/dist/modules/staff/lib/time-tracking/billability.js +39 -0
  275. package/dist/modules/staff/lib/time-tracking/billability.js.map +7 -0
  276. package/dist/modules/staff/lib/time-tracking/capacity.js +68 -0
  277. package/dist/modules/staff/lib/time-tracking/capacity.js.map +7 -0
  278. package/dist/modules/staff/lib/time-tracking/componentContracts.js +15 -0
  279. package/dist/modules/staff/lib/time-tracking/componentContracts.js.map +7 -0
  280. package/dist/modules/staff/lib/time-tracking/cost.js +82 -0
  281. package/dist/modules/staff/lib/time-tracking/cost.js.map +7 -0
  282. package/dist/modules/staff/lib/time-tracking/duration.js +55 -0
  283. package/dist/modules/staff/lib/time-tracking/duration.js.map +7 -0
  284. package/dist/modules/staff/lib/time-tracking/featureAccess.js +41 -0
  285. package/dist/modules/staff/lib/time-tracking/featureAccess.js.map +7 -0
  286. package/dist/modules/staff/lib/time-tracking/interval.js +72 -0
  287. package/dist/modules/staff/lib/time-tracking/interval.js.map +7 -0
  288. package/dist/modules/staff/lib/time-tracking/migrateProjectCodes.js +88 -0
  289. package/dist/modules/staff/lib/time-tracking/migrateProjectCodes.js.map +7 -0
  290. package/dist/modules/staff/lib/time-tracking/moneyVisibility.js +10 -0
  291. package/dist/modules/staff/lib/time-tracking/moneyVisibility.js.map +7 -0
  292. package/dist/modules/staff/lib/time-tracking/overlap.js +87 -0
  293. package/dist/modules/staff/lib/time-tracking/overlap.js.map +7 -0
  294. package/dist/modules/staff/lib/time-tracking/portalRecipients.js +27 -0
  295. package/dist/modules/staff/lib/time-tracking/portalRecipients.js.map +7 -0
  296. package/dist/modules/staff/lib/time-tracking/portalReports.js +16 -0
  297. package/dist/modules/staff/lib/time-tracking/portalReports.js.map +7 -0
  298. package/dist/modules/staff/lib/time-tracking/projectCode.js +135 -0
  299. package/dist/modules/staff/lib/time-tracking/projectCode.js.map +7 -0
  300. package/dist/modules/staff/lib/time-tracking/reapplyRounding.js +187 -0
  301. package/dist/modules/staff/lib/time-tracking/reapplyRounding.js.map +7 -0
  302. package/dist/modules/staff/lib/time-tracking/recalculationRunner.js +80 -0
  303. package/dist/modules/staff/lib/time-tracking/recalculationRunner.js.map +7 -0
  304. package/dist/modules/staff/lib/time-tracking/recalculations.js +56 -0
  305. package/dist/modules/staff/lib/time-tracking/recalculations.js.map +7 -0
  306. package/dist/modules/staff/lib/time-tracking/registries/invoke.js +63 -0
  307. package/dist/modules/staff/lib/time-tracking/registries/invoke.js.map +7 -0
  308. package/dist/modules/staff/lib/time-tracking/registries/registry.js +62 -0
  309. package/dist/modules/staff/lib/time-tracking/registries/registry.js.map +7 -0
  310. package/dist/modules/staff/lib/time-tracking/registries/scope.js +21 -0
  311. package/dist/modules/staff/lib/time-tracking/registries/scope.js.map +7 -0
  312. package/dist/modules/staff/lib/time-tracking/rollup.js +34 -0
  313. package/dist/modules/staff/lib/time-tracking/rollup.js.map +7 -0
  314. package/dist/modules/staff/lib/time-tracking/rounding.js +66 -0
  315. package/dist/modules/staff/lib/time-tracking/rounding.js.map +7 -0
  316. package/dist/modules/staff/lib/time-tracking/roundingImpact.js +39 -0
  317. package/dist/modules/staff/lib/time-tracking/roundingImpact.js.map +7 -0
  318. package/dist/modules/staff/lib/time-tracking/settingKeys.js +214 -0
  319. package/dist/modules/staff/lib/time-tracking/settingKeys.js.map +7 -0
  320. package/dist/modules/staff/lib/time-tracking/settings.js +87 -0
  321. package/dist/modules/staff/lib/time-tracking/settings.js.map +7 -0
  322. package/dist/modules/staff/lib/time-tracking/sqlInClause.js +9 -0
  323. package/dist/modules/staff/lib/time-tracking/sqlInClause.js.map +7 -0
  324. package/dist/modules/staff/lib/time-tracking/timeEntrySources.js +48 -0
  325. package/dist/modules/staff/lib/time-tracking/timeEntrySources.js.map +7 -0
  326. package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js +283 -0
  327. package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js.map +7 -0
  328. package/dist/modules/staff/lib/time-tracking-ui/CustomerPicker.js +324 -0
  329. package/dist/modules/staff/lib/time-tracking-ui/CustomerPicker.js.map +7 -0
  330. package/dist/modules/staff/lib/time-tracking-ui/DurationInput.js +121 -0
  331. package/dist/modules/staff/lib/time-tracking-ui/DurationInput.js.map +7 -0
  332. package/dist/modules/staff/lib/time-tracking-ui/EntryDetailsFields.js +188 -0
  333. package/dist/modules/staff/lib/time-tracking-ui/EntryDetailsFields.js.map +7 -0
  334. package/dist/modules/staff/lib/time-tracking-ui/EntryFilterBar.js +89 -0
  335. package/dist/modules/staff/lib/time-tracking-ui/EntryFilterBar.js.map +7 -0
  336. package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js +855 -0
  337. package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js.map +7 -0
  338. package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js +360 -0
  339. package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js.map +7 -0
  340. package/dist/modules/staff/lib/time-tracking-ui/KanbanColumn.js +259 -0
  341. package/dist/modules/staff/lib/time-tracking-ui/KanbanColumn.js.map +7 -0
  342. package/dist/modules/staff/lib/time-tracking-ui/NewTaskDialog.js +238 -0
  343. package/dist/modules/staff/lib/time-tracking-ui/NewTaskDialog.js.map +7 -0
  344. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +130 -0
  345. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +7 -0
  346. package/dist/modules/staff/lib/time-tracking-ui/PeriodSelector.js +98 -0
  347. package/dist/modules/staff/lib/time-tracking-ui/PeriodSelector.js.map +7 -0
  348. package/dist/modules/staff/lib/time-tracking-ui/PhasePlaceholder.js +27 -0
  349. package/dist/modules/staff/lib/time-tracking-ui/PhasePlaceholder.js.map +7 -0
  350. package/dist/modules/staff/lib/time-tracking-ui/ProjectCodeField.js +166 -0
  351. package/dist/modules/staff/lib/time-tracking-ui/ProjectCodeField.js.map +7 -0
  352. package/dist/modules/staff/lib/time-tracking-ui/ProjectCurrencyField.js +295 -0
  353. package/dist/modules/staff/lib/time-tracking-ui/ProjectCurrencyField.js.map +7 -0
  354. package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js +212 -0
  355. package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js.map +7 -0
  356. package/dist/modules/staff/lib/time-tracking-ui/ProjectTeamDrawer.js +729 -0
  357. package/dist/modules/staff/lib/time-tracking-ui/ProjectTeamDrawer.js.map +7 -0
  358. package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js +217 -0
  359. package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js.map +7 -0
  360. package/dist/modules/staff/lib/time-tracking-ui/TagPicker.js +158 -0
  361. package/dist/modules/staff/lib/time-tracking-ui/TagPicker.js.map +7 -0
  362. package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js +224 -0
  363. package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js.map +7 -0
  364. package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js +93 -0
  365. package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js.map +7 -0
  366. package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js +1196 -0
  367. package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js.map +7 -0
  368. package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js +176 -0
  369. package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js.map +7 -0
  370. package/dist/modules/staff/lib/time-tracking-ui/TaskPicker.js +307 -0
  371. package/dist/modules/staff/lib/time-tracking-ui/TaskPicker.js.map +7 -0
  372. package/dist/modules/staff/lib/time-tracking-ui/TaskQuickLog.js +284 -0
  373. package/dist/modules/staff/lib/time-tracking-ui/TaskQuickLog.js.map +7 -0
  374. package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js +73 -0
  375. package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js.map +7 -0
  376. package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDialog.js +1499 -0
  377. package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDialog.js.map +7 -0
  378. package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDurationCell.js +95 -0
  379. package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDurationCell.js.map +7 -0
  380. package/dist/modules/staff/lib/time-tracking-ui/TimesheetCalendar.js +188 -0
  381. package/dist/modules/staff/lib/time-tracking-ui/TimesheetCalendar.js.map +7 -0
  382. package/dist/modules/staff/lib/time-tracking-ui/TimesheetPeriodFooter.js +61 -0
  383. package/dist/modules/staff/lib/time-tracking-ui/TimesheetPeriodFooter.js.map +7 -0
  384. package/dist/modules/staff/lib/time-tracking-ui/TimesheetViewSwitch.js +45 -0
  385. package/dist/modules/staff/lib/time-tracking-ui/TimesheetViewSwitch.js.map +7 -0
  386. package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js +176 -0
  387. package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js.map +7 -0
  388. package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js +82 -0
  389. package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js.map +7 -0
  390. package/dist/modules/staff/lib/time-tracking-ui/boardSummary.js +5 -0
  391. package/dist/modules/staff/lib/time-tracking-ui/boardSummary.js.map +7 -0
  392. package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js +152 -0
  393. package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js.map +7 -0
  394. package/dist/modules/staff/lib/time-tracking-ui/projectTeamAssignment.js +92 -0
  395. package/dist/modules/staff/lib/time-tracking-ui/projectTeamAssignment.js.map +7 -0
  396. package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js +111 -0
  397. package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js.map +7 -0
  398. package/dist/modules/staff/lib/time-tracking-ui/timeEntryDialogState.js +243 -0
  399. package/dist/modules/staff/lib/time-tracking-ui/timeEntryDialogState.js.map +7 -0
  400. package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js +151 -0
  401. package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js.map +7 -0
  402. package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js +105 -0
  403. package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js.map +7 -0
  404. package/dist/modules/staff/lib/time-tracking-ui/timesheetData.js +132 -0
  405. package/dist/modules/staff/lib/time-tracking-ui/timesheetData.js.map +7 -0
  406. package/dist/modules/staff/lib/time-tracking-ui/timesheetPeriod.js +205 -0
  407. package/dist/modules/staff/lib/time-tracking-ui/timesheetPeriod.js.map +7 -0
  408. package/dist/modules/staff/lib/time-tracking-ui/timesheetTargets.js +53 -0
  409. package/dist/modules/staff/lib/time-tracking-ui/timesheetTargets.js.map +7 -0
  410. package/dist/modules/staff/lib/time-tracking-ui/useBoardFilters.js +77 -0
  411. package/dist/modules/staff/lib/time-tracking-ui/useBoardFilters.js.map +7 -0
  412. package/dist/modules/staff/lib/time-tracking-ui/useTimesheetPreferences.js +151 -0
  413. package/dist/modules/staff/lib/time-tracking-ui/useTimesheetPreferences.js.map +7 -0
  414. package/dist/modules/staff/lib/timeTrackingSeeds.js +809 -0
  415. package/dist/modules/staff/lib/timeTrackingSeeds.js.map +7 -0
  416. package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js +2 -3
  417. package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js.map +2 -2
  418. package/dist/modules/staff/lib/timesheets/timeEntryDecoration.js +152 -0
  419. package/dist/modules/staff/lib/timesheets/timeEntryDecoration.js.map +7 -0
  420. package/dist/modules/staff/lib/timesheets-projects/budgetBurn.js +36 -0
  421. package/dist/modules/staff/lib/timesheets-projects/budgetBurn.js.map +7 -0
  422. package/dist/modules/staff/lib/timesheets-projects/budgetThreshold.js +66 -0
  423. package/dist/modules/staff/lib/timesheets-projects/budgetThreshold.js.map +7 -0
  424. package/dist/modules/staff/lib/timesheets-projects/budgetThresholdState.js +78 -0
  425. package/dist/modules/staff/lib/timesheets-projects/budgetThresholdState.js.map +7 -0
  426. package/dist/modules/staff/lib/timesheets-projects/computeProjectFinancials.js +81 -0
  427. package/dist/modules/staff/lib/timesheets-projects/computeProjectFinancials.js.map +7 -0
  428. package/dist/modules/staff/lib/timesheets-projects/reportSelection.js +44 -0
  429. package/dist/modules/staff/lib/timesheets-projects/reportSelection.js.map +7 -0
  430. package/dist/modules/staff/lib/timesheets-projects-ui/AssignMembersDialog.js +169 -0
  431. package/dist/modules/staff/lib/timesheets-projects-ui/AssignMembersDialog.js.map +7 -0
  432. package/dist/modules/staff/lib/timesheets-projects-ui/ProjectBudgetCell.js +22 -0
  433. package/dist/modules/staff/lib/timesheets-projects-ui/ProjectBudgetCell.js.map +7 -0
  434. package/dist/modules/staff/lib/timesheets-projects-ui/ProjectCard.js +51 -1
  435. package/dist/modules/staff/lib/timesheets-projects-ui/ProjectCard.js.map +2 -2
  436. package/dist/modules/staff/lib/timesheets-projects-ui/ProjectsBulkActionsBar.js +70 -0
  437. package/dist/modules/staff/lib/timesheets-projects-ui/ProjectsBulkActionsBar.js.map +7 -0
  438. package/dist/modules/staff/lib/timesheets-reports/buildReportSheet.js +63 -0
  439. package/dist/modules/staff/lib/timesheets-reports/buildReportSheet.js.map +7 -0
  440. package/dist/modules/staff/lib/timesheets-reports/loadReportData.js +170 -0
  441. package/dist/modules/staff/lib/timesheets-reports/loadReportData.js.map +7 -0
  442. package/dist/modules/staff/lib/timesheets-reports/pdf.js +205 -0
  443. package/dist/modules/staff/lib/timesheets-reports/pdf.js.map +7 -0
  444. package/dist/modules/staff/lib/timesheets-reports/reportApprovalPolicies.js +68 -0
  445. package/dist/modules/staff/lib/timesheets-reports/reportApprovalPolicies.js.map +7 -0
  446. package/dist/modules/staff/lib/timesheets-reports/reportConfigData.js +135 -0
  447. package/dist/modules/staff/lib/timesheets-reports/reportConfigData.js.map +7 -0
  448. package/dist/modules/staff/lib/timesheets-reports/reportConfigState.js +48 -0
  449. package/dist/modules/staff/lib/timesheets-reports/reportConfigState.js.map +7 -0
  450. package/dist/modules/staff/lib/timesheets-reports/reportExport.js +262 -0
  451. package/dist/modules/staff/lib/timesheets-reports/reportExport.js.map +7 -0
  452. package/dist/modules/staff/lib/timesheets-reports/reportExportFormats.js +34 -0
  453. package/dist/modules/staff/lib/timesheets-reports/reportExportFormats.js.map +7 -0
  454. package/dist/modules/staff/lib/timesheets-reports/reportGroupings.js +79 -0
  455. package/dist/modules/staff/lib/timesheets-reports/reportGroupings.js.map +7 -0
  456. package/dist/modules/staff/lib/timesheets-reports/reportLabels.js +11 -0
  457. package/dist/modules/staff/lib/timesheets-reports/reportLabels.js.map +7 -0
  458. package/dist/modules/staff/lib/timesheets-reports/reportReference.js +83 -0
  459. package/dist/modules/staff/lib/timesheets-reports/reportReference.js.map +7 -0
  460. package/dist/modules/staff/lib/timesheets-reports/reportRows.js +42 -0
  461. package/dist/modules/staff/lib/timesheets-reports/reportRows.js.map +7 -0
  462. package/dist/modules/staff/lib/timesheets-reports/reportSheetData.js +107 -0
  463. package/dist/modules/staff/lib/timesheets-reports/reportSheetData.js.map +7 -0
  464. package/dist/modules/staff/lib/timesheets-reports/reportTotals.js +289 -0
  465. package/dist/modules/staff/lib/timesheets-reports/reportTotals.js.map +7 -0
  466. package/dist/modules/staff/lib/timesheets-reports/xlsx.js +145 -0
  467. package/dist/modules/staff/lib/timesheets-reports/xlsx.js.map +7 -0
  468. package/dist/modules/staff/lib/timesheets-tasks/computeTaskRollups.js +116 -0
  469. package/dist/modules/staff/lib/timesheets-tasks/computeTaskRollups.js.map +7 -0
  470. package/dist/modules/staff/lib/timesheets-tasks/defaultStatusTemplate.js +50 -0
  471. package/dist/modules/staff/lib/timesheets-tasks/defaultStatusTemplate.js.map +7 -0
  472. package/dist/modules/staff/lib/timesheets-tasks/seedProjectStatuses.js +27 -0
  473. package/dist/modules/staff/lib/timesheets-tasks/seedProjectStatuses.js.map +7 -0
  474. package/dist/modules/staff/lib/timesheets-tasks/statusPositions.js +65 -0
  475. package/dist/modules/staff/lib/timesheets-tasks/statusPositions.js.map +7 -0
  476. package/dist/modules/staff/lib/timesheets-tasks/statusSlug.js +31 -0
  477. package/dist/modules/staff/lib/timesheets-tasks/statusSlug.js.map +7 -0
  478. package/dist/modules/staff/lib/timesheets-tasks/taskHierarchy.js +31 -0
  479. package/dist/modules/staff/lib/timesheets-tasks/taskHierarchy.js.map +7 -0
  480. package/dist/modules/staff/lib/timesheets-tasks/taskHoursTotals.js +17 -0
  481. package/dist/modules/staff/lib/timesheets-tasks/taskHoursTotals.js.map +7 -0
  482. package/dist/modules/staff/lib/timesheets-tasks/taskReference.js +51 -0
  483. package/dist/modules/staff/lib/timesheets-tasks/taskReference.js.map +7 -0
  484. package/dist/modules/staff/lib/timesheets-ui/CreateProjectDialog.js +11 -4
  485. package/dist/modules/staff/lib/timesheets-ui/CreateProjectDialog.js.map +2 -2
  486. package/dist/modules/staff/lib/timesheets-ui/ListView.js +369 -111
  487. package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
  488. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +47 -2
  489. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  490. package/dist/modules/staff/migrations/Migration20260813073131_staff.js +79 -0
  491. package/dist/modules/staff/migrations/Migration20260813073131_staff.js.map +7 -0
  492. package/dist/modules/staff/migrations/Migration20260824143357_staff.js +15 -0
  493. package/dist/modules/staff/migrations/Migration20260824143357_staff.js.map +7 -0
  494. package/dist/modules/staff/notifications.client.js +16 -0
  495. package/dist/modules/staff/notifications.client.js.map +7 -0
  496. package/dist/modules/staff/notifications.handlers.js +80 -0
  497. package/dist/modules/staff/notifications.handlers.js.map +7 -0
  498. package/dist/modules/staff/notifications.js +168 -0
  499. package/dist/modules/staff/notifications.js.map +2 -2
  500. package/dist/modules/staff/search.js +159 -1
  501. package/dist/modules/staff/search.js.map +2 -2
  502. package/dist/modules/staff/setup.js +20 -1
  503. package/dist/modules/staff/setup.js.map +2 -2
  504. package/dist/modules/staff/subscribers/time-project-access-requested-notification.js +68 -0
  505. package/dist/modules/staff/subscribers/time-project-access-requested-notification.js.map +7 -0
  506. package/dist/modules/staff/subscribers/time-project-budget-threshold-notification.js +149 -0
  507. package/dist/modules/staff/subscribers/time-project-budget-threshold-notification.js.map +7 -0
  508. package/dist/modules/staff/subscribers/time-report-approved-notification.js +78 -0
  509. package/dist/modules/staff/subscribers/time-report-approved-notification.js.map +7 -0
  510. package/dist/modules/staff/subscribers/time-report-portal-broadcast.js +78 -0
  511. package/dist/modules/staff/subscribers/time-report-portal-broadcast.js.map +7 -0
  512. package/dist/modules/staff/translations.js +4 -1
  513. package/dist/modules/staff/translations.js.map +2 -2
  514. package/dist/modules/staff/widgets/components.js +7 -0
  515. package/dist/modules/staff/widgets/components.js.map +7 -0
  516. package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js +5 -4
  517. package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js.map +2 -2
  518. package/dist/modules/staff/widgets/notifications/TimeProjectAccessRequestedRenderer.js +135 -0
  519. package/dist/modules/staff/widgets/notifications/TimeProjectAccessRequestedRenderer.js.map +7 -0
  520. package/dist/modules/staff/widgets/notifications/index.js +5 -0
  521. package/dist/modules/staff/widgets/notifications/index.js.map +7 -0
  522. package/dist/modules/staff/workers/timesheets-reapply-rounding.js +43 -0
  523. package/dist/modules/staff/workers/timesheets-reapply-rounding.js.map +7 -0
  524. package/generated/entities/staff_time_entry/index.ts +7 -0
  525. package/generated/entities/staff_time_entry_tag/index.ts +6 -0
  526. package/generated/entities/staff_time_project/index.ts +8 -0
  527. package/generated/entities/staff_time_report/index.ts +27 -0
  528. package/generated/entities/staff_time_report_entry/index.ts +12 -0
  529. package/generated/entities/staff_time_report_event/index.ts +9 -0
  530. package/generated/entities/staff_time_report_project/index.ts +6 -0
  531. package/generated/entities/staff_time_tag/index.ts +9 -0
  532. package/generated/entities/staff_time_task/index.ts +17 -0
  533. package/generated/entities/staff_time_task_comment/index.ts +9 -0
  534. package/generated/entities/staff_time_task_status/index.ts +13 -0
  535. package/generated/entities/staff_time_task_tag/index.ts +6 -0
  536. package/generated/entities.ids.generated.ts +11 -1
  537. package/generated/entity-fields-registry.ts +149 -0
  538. package/package.json +7 -7
  539. package/src/helpers/integration/timesheetFixtures.ts +31 -1
  540. package/src/modules/auth/README.md +1 -1
  541. package/src/modules/auth/cli.ts +35 -1
  542. package/src/modules/auth/i18n/de.json +7 -0
  543. package/src/modules/auth/i18n/en.json +7 -0
  544. package/src/modules/auth/i18n/es.json +7 -0
  545. package/src/modules/auth/i18n/ko.json +7 -0
  546. package/src/modules/auth/i18n/pl.json +7 -0
  547. package/src/modules/auth/lib/backendChrome.tsx +1 -0
  548. package/src/modules/customer_accounts/lib/customerRoleAcls.ts +79 -0
  549. package/src/modules/customer_accounts/setup.ts +1 -45
  550. package/src/modules/data_sync/AGENTS.md +34 -0
  551. package/src/modules/data_sync/api/options.ts +2 -0
  552. package/src/modules/data_sync/backend/data-sync/page.tsx +82 -33
  553. package/src/modules/data_sync/i18n/de.json +1 -0
  554. package/src/modules/data_sync/i18n/en.json +1 -0
  555. package/src/modules/data_sync/i18n/es.json +1 -0
  556. package/src/modules/data_sync/i18n/ko.json +1 -0
  557. package/src/modules/data_sync/i18n/pl.json +1 -0
  558. package/src/modules/data_sync/lib/adapter.ts +45 -0
  559. package/src/modules/data_sync/lib/start-controls.ts +89 -0
  560. package/src/modules/staff/AGENTS.md +769 -1
  561. package/src/modules/staff/acl.ts +9 -0
  562. package/src/modules/staff/ai-agents.ts +189 -0
  563. package/src/modules/staff/ai-tools/time-tracking-pack.ts +494 -0
  564. package/src/modules/staff/ai-tools/types.ts +82 -0
  565. package/src/modules/staff/ai-tools.ts +13 -0
  566. package/src/modules/staff/analytics.ts +113 -0
  567. package/src/modules/staff/api/guards.ts +85 -3
  568. package/src/modules/staff/api/leave-requests/accept/route.ts +0 -2
  569. package/src/modules/staff/api/leave-requests/reject/route.ts +0 -2
  570. package/src/modules/staff/api/portal/time-reports/[id]/route.ts +182 -0
  571. package/src/modules/staff/api/portal/time-reports/route.ts +270 -0
  572. package/src/modules/staff/api/team-members/self/route.ts +0 -2
  573. package/src/modules/staff/api/team-members/tags/assign/route.ts +0 -2
  574. package/src/modules/staff/api/team-members/tags/unassign/route.ts +0 -2
  575. package/src/modules/staff/api/timesheets/_shared/withTimesheetInterceptors.ts +292 -0
  576. package/src/modules/staff/api/timesheets/access-requests/route.ts +299 -0
  577. package/src/modules/staff/api/timesheets/my-projects/[projectId]/route.ts +15 -7
  578. package/src/modules/staff/api/timesheets/my-projects/route.ts +14 -1
  579. package/src/modules/staff/api/timesheets/my-work/myWorkAggregate.ts +90 -0
  580. package/src/modules/staff/api/timesheets/my-work/route.ts +475 -0
  581. package/src/modules/staff/api/timesheets/projects/kpis/route.ts +15 -2
  582. package/src/modules/staff/api/timesheets/reports/[id]/close/route.ts +203 -0
  583. package/src/modules/staff/api/timesheets/reports/[id]/export/route.ts +315 -0
  584. package/src/modules/staff/api/timesheets/reports/[id]/sheet/route.ts +220 -0
  585. package/src/modules/staff/api/timesheets/reports/[id]/unlock/route.ts +208 -0
  586. package/src/modules/staff/api/timesheets/reports/preview/route.ts +449 -0
  587. package/src/modules/staff/api/timesheets/reports/route.ts +386 -0
  588. package/src/modules/staff/api/timesheets/reports/shared.ts +121 -0
  589. package/src/modules/staff/api/timesheets/settings/reapply-rounding/route.ts +205 -0
  590. package/src/modules/staff/api/timesheets/settings/rounding-impact/route.ts +226 -0
  591. package/src/modules/staff/api/timesheets/settings/route.ts +242 -0
  592. package/src/modules/staff/api/timesheets/tags/access.ts +134 -0
  593. package/src/modules/staff/api/timesheets/tags/entry-assignments/route.ts +290 -0
  594. package/src/modules/staff/api/timesheets/tags/route.ts +177 -0
  595. package/src/modules/staff/api/timesheets/tags/task-assignments/route.ts +294 -0
  596. package/src/modules/staff/api/timesheets/task-statuses/route.ts +232 -0
  597. package/src/modules/staff/api/timesheets/tasks/[id]/comments/route.ts +532 -0
  598. package/src/modules/staff/api/timesheets/tasks/[id]/status/route.ts +271 -0
  599. package/src/modules/staff/api/timesheets/tasks/route.ts +443 -0
  600. package/src/modules/staff/api/timesheets/time-entries/[id]/duplicate/route.ts +306 -0
  601. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +32 -10
  602. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +32 -17
  603. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +27 -6
  604. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +36 -6
  605. package/src/modules/staff/api/timesheets/time-entries/bulk/route.ts +331 -34
  606. package/src/modules/staff/api/timesheets/time-entries/copy-day/route.ts +533 -0
  607. package/src/modules/staff/api/timesheets/time-entries/overlaps/route.ts +457 -0
  608. package/src/modules/staff/api/timesheets/time-entries/route.ts +378 -24
  609. package/src/modules/staff/api/timesheets/time-entries/start-timer/route.ts +31 -9
  610. package/src/modules/staff/api/timesheets/time-projects/[id]/change-currency/route.ts +246 -0
  611. package/src/modules/staff/api/timesheets/time-projects/[id]/employees/route.ts +62 -2
  612. package/src/modules/staff/api/timesheets/time-projects/route.ts +397 -22
  613. package/src/modules/staff/backend/staff/time-tracking/board/page.meta.ts +14 -0
  614. package/src/modules/staff/backend/staff/time-tracking/board/page.tsx +151 -0
  615. package/src/modules/staff/backend/staff/time-tracking/entries/page.meta.ts +14 -0
  616. package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +1229 -0
  617. package/src/modules/staff/backend/staff/time-tracking/page.meta.ts +11 -0
  618. package/src/modules/staff/backend/staff/time-tracking/page.tsx +823 -0
  619. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.meta.ts +12 -0
  620. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.tsx +81 -0
  621. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.meta.ts +12 -0
  622. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +275 -0
  623. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.meta.ts +12 -0
  624. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +1420 -0
  625. package/src/modules/staff/backend/staff/time-tracking/projects/create/page.meta.ts +12 -0
  626. package/src/modules/staff/backend/staff/time-tracking/projects/create/page.tsx +109 -0
  627. package/src/modules/staff/backend/staff/time-tracking/projects/page.meta.ts +15 -0
  628. package/src/modules/staff/backend/staff/time-tracking/projects/page.tsx +1254 -0
  629. package/src/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.ts +466 -0
  630. package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.meta.ts +12 -0
  631. package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.tsx +632 -0
  632. package/src/modules/staff/backend/staff/time-tracking/reports/create/page.meta.ts +12 -0
  633. package/src/modules/staff/backend/staff/time-tracking/reports/create/page.tsx +867 -0
  634. package/src/modules/staff/backend/staff/time-tracking/reports/page.meta.ts +14 -0
  635. package/src/modules/staff/backend/staff/time-tracking/reports/page.tsx +246 -0
  636. package/src/modules/staff/backend/staff/time-tracking/settings/page.meta.ts +14 -0
  637. package/src/modules/staff/backend/staff/time-tracking/settings/page.tsx +701 -0
  638. package/src/modules/staff/backend/staff/time-tracking/timesheet/GridView.tsx +953 -0
  639. package/src/modules/staff/backend/staff/time-tracking/timesheet/page.meta.ts +14 -0
  640. package/src/modules/staff/backend/staff/time-tracking/timesheet/page.tsx +800 -0
  641. package/src/modules/staff/backend/staff/timesheets/page.meta.ts +6 -6
  642. package/src/modules/staff/backend/staff/timesheets/page.tsx +12 -1010
  643. package/src/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.meta.ts +0 -6
  644. package/src/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.tsx +4 -158
  645. package/src/modules/staff/backend/staff/timesheets/projects/[id]/page.meta.ts +0 -6
  646. package/src/modules/staff/backend/staff/timesheets/projects/[id]/page.tsx +4 -609
  647. package/src/modules/staff/backend/staff/timesheets/projects/create/page.meta.ts +0 -6
  648. package/src/modules/staff/backend/staff/timesheets/projects/create/page.tsx +3 -89
  649. package/src/modules/staff/backend/staff/timesheets/projects/page.meta.ts +2 -19
  650. package/src/modules/staff/backend/staff/timesheets/projects/page.tsx +3 -778
  651. package/src/modules/staff/ce.ts +63 -0
  652. package/src/modules/staff/cli.ts +207 -1
  653. package/src/modules/staff/commands/index.ts +5 -0
  654. package/src/modules/staff/commands/timesheets-entries.ts +1006 -28
  655. package/src/modules/staff/commands/timesheets-projects.ts +593 -7
  656. package/src/modules/staff/commands/timesheets-reports.ts +1329 -0
  657. package/src/modules/staff/commands/timesheets-tags.ts +1315 -0
  658. package/src/modules/staff/commands/timesheets-task-comments.ts +583 -0
  659. package/src/modules/staff/commands/timesheets-task-statuses.ts +889 -0
  660. package/src/modules/staff/commands/timesheets-tasks.ts +1121 -0
  661. package/src/modules/staff/data/enrichers.ts +675 -10
  662. package/src/modules/staff/data/entities.ts +494 -2
  663. package/src/modules/staff/data/extensions.ts +68 -0
  664. package/src/modules/staff/data/validators.ts +348 -3
  665. package/src/modules/staff/di.ts +96 -0
  666. package/src/modules/staff/events.payloads.ts +268 -0
  667. package/src/modules/staff/events.ts +87 -5
  668. package/src/modules/staff/extension-points.ts +308 -0
  669. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.meta.ts +10 -0
  670. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.tsx +225 -0
  671. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.meta.ts +17 -0
  672. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.tsx +182 -0
  673. package/src/modules/staff/i18n/de.json +899 -0
  674. package/src/modules/staff/i18n/en.json +899 -0
  675. package/src/modules/staff/i18n/es.json +899 -0
  676. package/src/modules/staff/i18n/ko.json +899 -0
  677. package/src/modules/staff/i18n/pl.json +899 -0
  678. package/src/modules/staff/lib/crud.ts +14 -3
  679. package/src/modules/staff/lib/time-tracking/access.ts +155 -0
  680. package/src/modules/staff/lib/time-tracking/billability.ts +68 -0
  681. package/src/modules/staff/lib/time-tracking/capacity.ts +124 -0
  682. package/src/modules/staff/lib/time-tracking/componentContracts.ts +26 -0
  683. package/src/modules/staff/lib/time-tracking/cost.ts +144 -0
  684. package/src/modules/staff/lib/time-tracking/duration.ts +68 -0
  685. package/src/modules/staff/lib/time-tracking/featureAccess.ts +115 -0
  686. package/src/modules/staff/lib/time-tracking/interval.ts +90 -0
  687. package/src/modules/staff/lib/time-tracking/migrateProjectCodes.ts +161 -0
  688. package/src/modules/staff/lib/time-tracking/moneyVisibility.ts +37 -0
  689. package/src/modules/staff/lib/time-tracking/overlap.ts +159 -0
  690. package/src/modules/staff/lib/time-tracking/portalRecipients.ts +76 -0
  691. package/src/modules/staff/lib/time-tracking/portalReports.ts +31 -0
  692. package/src/modules/staff/lib/time-tracking/projectCode.ts +221 -0
  693. package/src/modules/staff/lib/time-tracking/reapplyRounding.ts +313 -0
  694. package/src/modules/staff/lib/time-tracking/recalculationRunner.ts +118 -0
  695. package/src/modules/staff/lib/time-tracking/recalculations.ts +135 -0
  696. package/src/modules/staff/lib/time-tracking/registries/invoke.ts +122 -0
  697. package/src/modules/staff/lib/time-tracking/registries/registry.ts +124 -0
  698. package/src/modules/staff/lib/time-tracking/registries/scope.ts +56 -0
  699. package/src/modules/staff/lib/time-tracking/rollup.ts +59 -0
  700. package/src/modules/staff/lib/time-tracking/rounding.ts +126 -0
  701. package/src/modules/staff/lib/time-tracking/roundingImpact.ts +75 -0
  702. package/src/modules/staff/lib/time-tracking/settingKeys.ts +274 -0
  703. package/src/modules/staff/lib/time-tracking/settings.ts +168 -0
  704. package/src/modules/staff/lib/time-tracking/sqlInClause.ts +22 -0
  705. package/src/modules/staff/lib/time-tracking/timeEntrySources.ts +85 -0
  706. package/src/modules/staff/lib/time-tracking-ui/BoardFilterChips.tsx +360 -0
  707. package/src/modules/staff/lib/time-tracking-ui/CustomerPicker.tsx +427 -0
  708. package/src/modules/staff/lib/time-tracking-ui/DurationInput.tsx +192 -0
  709. package/src/modules/staff/lib/time-tracking-ui/EntryDetailsFields.tsx +224 -0
  710. package/src/modules/staff/lib/time-tracking-ui/EntryFilterBar.tsx +144 -0
  711. package/src/modules/staff/lib/time-tracking-ui/KanbanBoard.tsx +1065 -0
  712. package/src/modules/staff/lib/time-tracking-ui/KanbanCard.tsx +435 -0
  713. package/src/modules/staff/lib/time-tracking-ui/KanbanColumn.tsx +316 -0
  714. package/src/modules/staff/lib/time-tracking-ui/NewTaskDialog.tsx +291 -0
  715. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +162 -0
  716. package/src/modules/staff/lib/time-tracking-ui/PeriodSelector.tsx +149 -0
  717. package/src/modules/staff/lib/time-tracking-ui/PhasePlaceholder.tsx +31 -0
  718. package/src/modules/staff/lib/time-tracking-ui/ProjectCodeField.tsx +230 -0
  719. package/src/modules/staff/lib/time-tracking-ui/ProjectCurrencyField.tsx +432 -0
  720. package/src/modules/staff/lib/time-tracking-ui/ProjectFormSections.tsx +323 -0
  721. package/src/modules/staff/lib/time-tracking-ui/ProjectTeamDrawer.tsx +930 -0
  722. package/src/modules/staff/lib/time-tracking-ui/ReportSheet.tsx +357 -0
  723. package/src/modules/staff/lib/time-tracking-ui/TagPicker.tsx +195 -0
  724. package/src/modules/staff/lib/time-tracking-ui/TaskBoardScreen.tsx +243 -0
  725. package/src/modules/staff/lib/time-tracking-ui/TaskCommentThread.tsx +137 -0
  726. package/src/modules/staff/lib/time-tracking-ui/TaskDrawer.tsx +1405 -0
  727. package/src/modules/staff/lib/time-tracking-ui/TaskListView.tsx +260 -0
  728. package/src/modules/staff/lib/time-tracking-ui/TaskPicker.tsx +435 -0
  729. package/src/modules/staff/lib/time-tracking-ui/TaskQuickLog.tsx +296 -0
  730. package/src/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.tsx +97 -0
  731. package/src/modules/staff/lib/time-tracking-ui/TimeEntryDialog.tsx +1929 -0
  732. package/src/modules/staff/lib/time-tracking-ui/TimeEntryDurationCell.tsx +126 -0
  733. package/src/modules/staff/lib/time-tracking-ui/TimesheetCalendar.tsx +265 -0
  734. package/src/modules/staff/lib/time-tracking-ui/TimesheetPeriodFooter.tsx +78 -0
  735. package/src/modules/staff/lib/time-tracking-ui/TimesheetViewSwitch.tsx +70 -0
  736. package/src/modules/staff/lib/time-tracking-ui/boardDirectory.ts +231 -0
  737. package/src/modules/staff/lib/time-tracking-ui/boardFilters.ts +128 -0
  738. package/src/modules/staff/lib/time-tracking-ui/boardSummary.ts +20 -0
  739. package/src/modules/staff/lib/time-tracking-ui/kanbanBoardData.ts +228 -0
  740. package/src/modules/staff/lib/time-tracking-ui/projectTeamAssignment.ts +143 -0
  741. package/src/modules/staff/lib/time-tracking-ui/taskDrawerData.ts +194 -0
  742. package/src/modules/staff/lib/time-tracking-ui/timeEntryDialogState.ts +400 -0
  743. package/src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts +271 -0
  744. package/src/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.ts +168 -0
  745. package/src/modules/staff/lib/time-tracking-ui/timesheetData.ts +225 -0
  746. package/src/modules/staff/lib/time-tracking-ui/timesheetPeriod.ts +278 -0
  747. package/src/modules/staff/lib/time-tracking-ui/timesheetTargets.ts +93 -0
  748. package/src/modules/staff/lib/time-tracking-ui/useBoardFilters.ts +107 -0
  749. package/src/modules/staff/lib/time-tracking-ui/useTimesheetPreferences.ts +245 -0
  750. package/src/modules/staff/lib/timeTrackingSeeds.ts +1037 -0
  751. package/src/modules/staff/lib/timesheets/timeEntryCacheInvalidation.ts +11 -10
  752. package/src/modules/staff/lib/timesheets/timeEntryDecoration.ts +256 -0
  753. package/src/modules/staff/lib/timesheets-projects/budgetBurn.ts +60 -0
  754. package/src/modules/staff/lib/timesheets-projects/budgetThreshold.ts +135 -0
  755. package/src/modules/staff/lib/timesheets-projects/budgetThresholdState.ts +137 -0
  756. package/src/modules/staff/lib/timesheets-projects/computeProjectFinancials.ts +142 -0
  757. package/src/modules/staff/lib/timesheets-projects/reportSelection.ts +82 -0
  758. package/src/modules/staff/lib/timesheets-projects-ui/AssignMembersDialog.tsx +237 -0
  759. package/src/modules/staff/lib/timesheets-projects-ui/ProjectBudgetCell.tsx +33 -0
  760. package/src/modules/staff/lib/timesheets-projects-ui/ProjectCard.tsx +56 -1
  761. package/src/modules/staff/lib/timesheets-projects-ui/ProjectsBulkActionsBar.tsx +90 -0
  762. package/src/modules/staff/lib/timesheets-reports/buildReportSheet.ts +125 -0
  763. package/src/modules/staff/lib/timesheets-reports/loadReportData.ts +251 -0
  764. package/src/modules/staff/lib/timesheets-reports/pdf.ts +263 -0
  765. package/src/modules/staff/lib/timesheets-reports/reportApprovalPolicies.ts +137 -0
  766. package/src/modules/staff/lib/timesheets-reports/reportConfigData.ts +247 -0
  767. package/src/modules/staff/lib/timesheets-reports/reportConfigState.ts +89 -0
  768. package/src/modules/staff/lib/timesheets-reports/reportExport.ts +373 -0
  769. package/src/modules/staff/lib/timesheets-reports/reportExportFormats.ts +71 -0
  770. package/src/modules/staff/lib/timesheets-reports/reportGroupings.ts +153 -0
  771. package/src/modules/staff/lib/timesheets-reports/reportLabels.ts +23 -0
  772. package/src/modules/staff/lib/timesheets-reports/reportReference.ts +146 -0
  773. package/src/modules/staff/lib/timesheets-reports/reportRows.ts +89 -0
  774. package/src/modules/staff/lib/timesheets-reports/reportSheetData.ts +196 -0
  775. package/src/modules/staff/lib/timesheets-reports/reportTotals.ts +544 -0
  776. package/src/modules/staff/lib/timesheets-reports/xlsx.ts +207 -0
  777. package/src/modules/staff/lib/timesheets-tasks/computeTaskRollups.ts +238 -0
  778. package/src/modules/staff/lib/timesheets-tasks/defaultStatusTemplate.ts +90 -0
  779. package/src/modules/staff/lib/timesheets-tasks/seedProjectStatuses.ts +43 -0
  780. package/src/modules/staff/lib/timesheets-tasks/statusPositions.ts +132 -0
  781. package/src/modules/staff/lib/timesheets-tasks/statusSlug.ts +43 -0
  782. package/src/modules/staff/lib/timesheets-tasks/taskHierarchy.ts +99 -0
  783. package/src/modules/staff/lib/timesheets-tasks/taskHoursTotals.ts +42 -0
  784. package/src/modules/staff/lib/timesheets-tasks/taskReference.ts +108 -0
  785. package/src/modules/staff/lib/timesheets-ui/CreateProjectDialog.tsx +18 -4
  786. package/src/modules/staff/lib/timesheets-ui/ListView.tsx +504 -149
  787. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +50 -2
  788. package/src/modules/staff/migrations/.snapshot-open-mercato.json +3244 -228
  789. package/src/modules/staff/migrations/Migration20260813073131_staff.ts +94 -0
  790. package/src/modules/staff/migrations/Migration20260824143357_staff.ts +17 -0
  791. package/src/modules/staff/notifications.client.ts +16 -0
  792. package/src/modules/staff/notifications.handlers.ts +88 -0
  793. package/src/modules/staff/notifications.ts +163 -0
  794. package/src/modules/staff/search.ts +191 -1
  795. package/src/modules/staff/setup.ts +22 -0
  796. package/src/modules/staff/subscribers/time-project-access-requested-notification.ts +95 -0
  797. package/src/modules/staff/subscribers/time-project-budget-threshold-notification.ts +226 -0
  798. package/src/modules/staff/subscribers/time-report-approved-notification.ts +116 -0
  799. package/src/modules/staff/subscribers/time-report-portal-broadcast.ts +120 -0
  800. package/src/modules/staff/translations.ts +17 -0
  801. package/src/modules/staff/widgets/components.ts +34 -0
  802. package/src/modules/staff/widgets/injection/timer-sidebar-indicator/widget.tsx +6 -5
  803. package/src/modules/staff/widgets/notifications/TimeProjectAccessRequestedRenderer.tsx +138 -0
  804. package/src/modules/staff/widgets/notifications/index.ts +1 -0
  805. package/src/modules/staff/workers/timesheets-reapply-rounding.ts +59 -0
  806. package/dist/modules/staff/backend/staff/timesheets/projects/projectFormConfig.js +0 -123
  807. package/dist/modules/staff/backend/staff/timesheets/projects/projectFormConfig.js.map +0 -7
  808. package/src/modules/staff/backend/staff/timesheets/projects/projectFormConfig.ts +0 -138
@@ -0,0 +1,1929 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { z } from 'zod'
5
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
6
+ import { Lock, X } from 'lucide-react'
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@open-mercato/ui/primitives/dialog'
14
+ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
15
+ import { Badge } from '@open-mercato/ui/primitives/badge'
16
+ import { Button } from '@open-mercato/ui/primitives/button'
17
+ import { Input } from '@open-mercato/ui/primitives/input'
18
+ import { Kbd } from '@open-mercato/ui/primitives/kbd'
19
+ import { Label } from '@open-mercato/ui/primitives/label'
20
+ import {
21
+ Select,
22
+ SelectContent,
23
+ SelectItem,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ } from '@open-mercato/ui/primitives/select'
27
+ import { Separator } from '@open-mercato/ui/primitives/separator'
28
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
29
+ import { Switch } from '@open-mercato/ui/primitives/switch'
30
+ import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
31
+ import { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
32
+ import { TagPicker, tagChipStyle } from './TagPicker'
33
+ import { TaskPicker, type TaskPickerItem, type TaskPickerStatus } from './TaskPicker'
34
+ import { slugifyProjectName } from '../time-tracking/projectCode'
35
+ import { autoColorFromName } from '../timesheets-ui/colors'
36
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
37
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
38
+ import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
39
+ import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider'
40
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
41
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
42
+ import { InjectionSpot, useInjectionSpotEvents } from '@open-mercato/ui/backend/injection/InjectionSpot'
43
+ import { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'
44
+ import { registerComponent } from '@open-mercato/shared/modules/widgets/component-registry'
45
+ import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
46
+ import {
47
+ callbackProp,
48
+ opaqueProp,
49
+ optionalCallbackProp,
50
+ } from '../time-tracking/componentContracts'
51
+ import { extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
52
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
53
+ import { formatCurrency } from '@open-mercato/ui/utils/format'
54
+ import { hasFeature } from '@open-mercato/shared/security/features'
55
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
56
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
57
+ import { createLogger } from '@open-mercato/shared/lib/logger'
58
+ import { DurationInput } from './DurationInput'
59
+ import { formatDuration } from '../time-tracking/duration'
60
+ import { parseClock } from '../time-tracking/interval'
61
+ import { applicableRate, entryAmount } from '../time-tracking/cost'
62
+ import { roundMinutes, DEFAULT_ROUNDING_SETTINGS, type RoundingSettings } from '../time-tracking/rounding'
63
+ import {
64
+ combineDateAndClock,
65
+ createIntervalState,
66
+ reduceIntervalState,
67
+ resetIntervalState,
68
+ readRowItems,
69
+ readRowString,
70
+ shiftIsoDate,
71
+ todayIsoDate,
72
+ toOverlapEntry,
73
+ toProjectOption,
74
+ toTaskOption,
75
+ toTimeEntryRecord,
76
+ type OverlapEntry,
77
+ type ProjectOption,
78
+ type TagOption,
79
+ type TaskOption,
80
+ type TimeEntryRecord,
81
+ type TimeIntervalState,
82
+ } from './timeEntryDialogState'
83
+
84
+ const logger = createLogger('staff').child({ component: 'TimeEntryDialog' })
85
+
86
+ export const TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID = 'staff.time_tracking.entryDialog'
87
+ const TIME_ENTRY_ENTITY_ID = 'staff:staff_time_entry'
88
+
89
+ type TimeEntryFormInjectionContext = {
90
+ formId: string
91
+ entityId: string
92
+ recordId: string | null
93
+ resourceKind: string
94
+ resourceId: string
95
+ operation: 'create' | 'update'
96
+ locked: boolean
97
+ retryLastMutation: () => Promise<boolean>
98
+ }
99
+
100
+ function fieldValueSnapshot(value: unknown): string {
101
+ return JSON.stringify(value ?? null)
102
+ }
103
+ export const RATES_FEATURE = 'staff.timesheets.rates.view'
104
+ export const ENTRIES_LIST_PATH = '/backend/staff/time-tracking/entries'
105
+ export const REPORTS_PATH = '/backend/staff/time-tracking/reports'
106
+
107
+ const DIALOG_QUERY_ROOT = ['staff', 'time-tracking', 'entry-dialog'] as const
108
+ const DIRECTORY_PAGE_SIZE = 100
109
+ const TASK_SEARCH_PAGE_SIZE = 50
110
+ const TASK_SEARCH_DEBOUNCE_MS = 250
111
+
112
+ export type TimeEntryDialogDefaults = {
113
+ date?: string
114
+ taskId?: string | null
115
+ timeProjectId?: string | null
116
+ durationMinutes?: number | null
117
+ startClock?: string | null
118
+ endClock?: string | null
119
+ description?: string | null
120
+ isBillable?: boolean | null
121
+ tagIds?: string[]
122
+ }
123
+
124
+ export type TimeEntryDialogProps = {
125
+ open: boolean
126
+ onOpenChange: (open: boolean) => void
127
+ /** Present → edit that entry; absent → a brand new one. */
128
+ entryId?: string | null
129
+ /** Seed values for a new entry: a stopped timer, a grid cell, a board card. */
130
+ defaults?: TimeEntryDialogDefaults
131
+ /** Sub-title line, e.g. "Created from a stopped timer · 01:14:02". */
132
+ headerHint?: string | null
133
+ /** Fired after every successful write, including "save and add another". */
134
+ onSaved?: (result: { id: string | null; keptOpen: boolean }) => void
135
+ /** "Show that entry" on the overlap warning; falls back to the entries list. */
136
+ onShowEntry?: (entryId: string) => void
137
+ }
138
+
139
+ type SettingsPayload = {
140
+ rounding: RoundingSettings
141
+ defaults: { billable: boolean; chainStartFromPreviousEnd: boolean }
142
+ warnings: { overlap: boolean }
143
+ }
144
+
145
+ const FALLBACK_SETTINGS: SettingsPayload = {
146
+ rounding: { ...DEFAULT_ROUNDING_SETTINGS },
147
+ defaults: { billable: true, chainStartFromPreviousEnd: true },
148
+ warnings: { overlap: true },
149
+ }
150
+
151
+ function readSettings(payload: unknown): SettingsPayload {
152
+ const source = (payload ?? {}) as Record<string, unknown>
153
+ const rounding = (source.rounding ?? {}) as Record<string, unknown>
154
+ const defaults = (source.defaults ?? {}) as Record<string, unknown>
155
+ const warnings = (source.warnings ?? {}) as Record<string, unknown>
156
+ return {
157
+ rounding: {
158
+ unitMinutes:
159
+ rounding.unitMinutes === 5 || rounding.unitMinutes === 10 || rounding.unitMinutes === 15
160
+ ? rounding.unitMinutes
161
+ : 0,
162
+ direction: rounding.direction === 'nearest' ? 'nearest' : 'up',
163
+ },
164
+ defaults: {
165
+ billable: typeof defaults.billable === 'boolean' ? defaults.billable : true,
166
+ chainStartFromPreviousEnd:
167
+ typeof defaults.chainStartFromPreviousEnd === 'boolean' ? defaults.chainStartFromPreviousEnd : true,
168
+ },
169
+ warnings: { overlap: typeof warnings.overlap === 'boolean' ? warnings.overlap : true },
170
+ }
171
+ }
172
+
173
+ function parseRateText(value: string): number | null {
174
+ const trimmed = value.trim().replace(',', '.')
175
+ if (!trimmed) return null
176
+ const parsed = Number(trimmed)
177
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
178
+ }
179
+
180
+ /**
181
+ * Everything a person can type into the form, in the one shape both the seed and
182
+ * the live state can be expressed in. Comparing two of these is how the dialog
183
+ * knows whether closing it would throw work away.
184
+ */
185
+ type FormValues = {
186
+ taskId: string | null
187
+ description: string
188
+ date: string
189
+ startText: string
190
+ endText: string
191
+ durationMinutes: number | null
192
+ isBillable: boolean
193
+ rateOverrideEnabled: boolean
194
+ rateOverrideAmount: number | null
195
+ tagIds: string[]
196
+ }
197
+
198
+ type SaveMode = 'close' | 'again'
199
+
200
+ /** The two controls a failed save can point at, empty when nothing is wrong. */
201
+ type FieldIssues = {
202
+ task: string | null
203
+ duration: string | null
204
+ }
205
+
206
+ const NO_FIELD_ISSUES: FieldIssues = { task: null, duration: null }
207
+
208
+ /**
209
+ * A server complaint that names a field belongs under that field, not in a
210
+ * toast. Only the two the form can point at are claimed; anything else stays
211
+ * global and is flashed.
212
+ */
213
+ function readFieldIssues(error: unknown): FieldIssues | null {
214
+ const { fieldErrors } = normalizeCrudServerError(error)
215
+ if (!fieldErrors) return null
216
+ const task = fieldErrors.taskId ?? fieldErrors.task ?? null
217
+ const duration = fieldErrors.durationMinutes ?? fieldErrors.duration ?? null
218
+ if (!task && !duration) return null
219
+ return { task, duration }
220
+ }
221
+
222
+ function formSnapshot(values: FormValues): string {
223
+ return JSON.stringify([
224
+ values.taskId,
225
+ values.description.trim(),
226
+ values.date,
227
+ values.startText,
228
+ values.endText,
229
+ values.durationMinutes,
230
+ values.isBillable,
231
+ values.rateOverrideEnabled,
232
+ values.rateOverrideAmount,
233
+ [...values.tagIds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
234
+ ])
235
+ }
236
+
237
+ /**
238
+ * Two requests, merged.
239
+ *
240
+ * A single term cannot be routed to one field by shape — "Booking" is a title
241
+ * word and "AWR" is a code, and both are one alphanumeric token — and the query
242
+ * engine has no OR across fields, so the picker asks both questions and unions
243
+ * the answers. This is what makes task 400 of 900 findable: the directory the
244
+ * picker opens on is only ever its first page.
245
+ */
246
+ async function fetchTaskOptions(term: string): Promise<TaskOption[]> {
247
+ const params = new URLSearchParams({
248
+ page: '1',
249
+ pageSize: String(TASK_SEARCH_PAGE_SIZE),
250
+ sortField: 'title',
251
+ sortDir: 'asc',
252
+ })
253
+ const responses = await Promise.all(
254
+ [
255
+ `/api/staff/timesheets/tasks?${params.toString()}&q=${encodeURIComponent(term)}`,
256
+ `/api/staff/timesheets/tasks?${params.toString()}&reference=${encodeURIComponent(term)}`,
257
+ ].map((url) => apiCall<Record<string, unknown>>(url).catch(() => null)),
258
+ )
259
+ const merged = new Map<string, TaskOption>()
260
+ for (const response of responses) {
261
+ if (!response?.ok) continue
262
+ for (const row of readRowItems(response.result)) {
263
+ const option = toTaskOption(row)
264
+ if (option && !merged.has(option.id)) merged.set(option.id, option)
265
+ }
266
+ }
267
+ return [...merged.values()]
268
+ }
269
+
270
+ /** Resolves the entry's own task when it sorts outside the directory's first page. */
271
+ async function fetchTaskById(id: string): Promise<TaskOption | null> {
272
+ const call = await apiCall<Record<string, unknown>>(
273
+ `/api/staff/timesheets/tasks?ids=${encodeURIComponent(id)}&pageSize=1`,
274
+ )
275
+ if (!call.ok) return null
276
+ const row = readRowItems(call.result)[0]
277
+ return row ? toTaskOption(row) : null
278
+ }
279
+
280
+ function readErrorCode(error: unknown): string | null {
281
+ const body = (error as { body?: { code?: unknown; error?: unknown } } | null)?.body
282
+ if (body && typeof body.code === 'string') return body.code
283
+ if (body && typeof body.error === 'string') return body.error
284
+ return null
285
+ }
286
+
287
+ /**
288
+ * The full time entry form of screen 8, plus the two edge states of screen 9.
289
+ * Every surface of the module funnels through it — the entries list, the
290
+ * timesheet, the board and the task drawer — so it takes an entry id (edit) or a
291
+ * set of defaults (create) and owns nothing else about its host.
292
+ *
293
+ * Four decisions carry the design intent:
294
+ *
295
+ * 1. **The 2-of-3 arithmetic lives in one reducer, not in the render path.**
296
+ * `reduceIntervalState` calls `deriveInterval` with exactly the two anchor
297
+ * fields and stores which one came back computed, so the `wyliczone` badge is
298
+ * read off the state rather than recomputed. `start` is the preferred anchor,
299
+ * which is what makes editing the end restate the duration (note 1).
300
+ * 2. **The duration field is never rewritten while it is being typed.**
301
+ * `DurationInput` keeps unparseable text on its own; the dialog only remounts
302
+ * it — via `durationEpoch` — when the *derived* duration changed, i.e. when
303
+ * the user was editing some other field.
304
+ * 3. **Overlap is advisory.** The probe is a plain `apiCall`; a non-200, a
305
+ * throw or an empty list all mean "no warning", and Save never observes it.
306
+ * Only an unparseable duration disables Save (screen 9 notes 2 and 4).
307
+ * 4. **A locked entry opens read-only.** Every write path answers
308
+ * `409 entry_locked`, so the form shows the lock and a way to the report that
309
+ * closed it instead of letting someone compose a request that cannot succeed.
310
+ */
311
+ function DefaultTimeEntryDialog({
312
+ open,
313
+ onOpenChange,
314
+ entryId,
315
+ defaults,
316
+ headerHint,
317
+ onSaved,
318
+ onShowEntry,
319
+ }: TimeEntryDialogProps): React.ReactElement {
320
+ const t = useT()
321
+ const scopeVersion = useOrganizationScopeVersion()
322
+ const { payload } = useBackendChrome()
323
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
324
+ const canSeeMoney = hasFeature(payload?.grantedFeatures, RATES_FEATURE)
325
+ const isEdit = typeof entryId === 'string' && entryId.length > 0
326
+
327
+ const [taskId, setTaskId] = React.useState<string | null>(null)
328
+ const [description, setDescription] = React.useState('')
329
+ const [date, setDate] = React.useState(() => todayIsoDate())
330
+ const [interval, setIntervalState] = React.useState<TimeIntervalState>(() => createIntervalState({}))
331
+ const [isBillable, setIsBillable] = React.useState(true)
332
+ const [rateOverrideEnabled, setRateOverrideEnabled] = React.useState(false)
333
+ const [rateText, setRateText] = React.useState('')
334
+ const [tagIds, setTagIds] = React.useState<string[]>([])
335
+ const [overlaps, setOverlaps] = React.useState<OverlapEntry[]>([])
336
+ /** Which button is in flight, so the spinner lands on the one that was pressed. */
337
+ const [savingMode, setSavingMode] = React.useState<SaveMode | null>(null)
338
+ /**
339
+ * What is wrong with a single control, said under that control. A disabled
340
+ * Save cannot explain which of the two required fields it is waiting for, and
341
+ * a server complaint about one field is not a global failure either.
342
+ */
343
+ const [fieldIssues, setFieldIssues] = React.useState<FieldIssues>(NO_FIELD_ISSUES)
344
+ const saving = savingMode !== null
345
+ const [taskSearch, setTaskSearch] = React.useState('')
346
+ const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('')
347
+ /**
348
+ * The task the form is on, kept out of the two lists that come and go.
349
+ * The directory holds the first page only and the search results are replaced
350
+ * on every term, so without this the chosen task would vanish from under the
351
+ * selection the moment the search box was cleared.
352
+ */
353
+ const [pinnedTask, setPinnedTask] = React.useState<TaskOption | null>(null)
354
+ /** What the form was seeded with; `null` until it has been seeded at all. */
355
+ const [baseline, setBaseline] = React.useState<FormValues | null>(null)
356
+ /**
357
+ * T7.4 — the entry loop must be completable without a mouse, so the first field
358
+ * of the loop is also the focus target twice over: when the dialog opens (Radix
359
+ * would otherwise land on the × close button, which renders before the body) and
360
+ * after "save and add another", where focus would otherwise be stranded on the
361
+ * button and the next entry would start with a handful of Shift+Tabs.
362
+ */
363
+ const taskTriggerRef = React.useRef<HTMLDivElement | null>(null)
364
+ /**
365
+ * The picker is a search box inside a container, not a single focusable
366
+ * control, so the keyboard loop focuses the input within it. Without this,
367
+ * "save and add another" left focus stranded and the loop stopped being a loop.
368
+ */
369
+ const focusTaskPicker = React.useCallback(() => {
370
+ const host = taskTriggerRef.current
371
+ if (!host) return false
372
+ const focusable = host.querySelector('input, select, button') as HTMLElement | null
373
+ if (!focusable) return false
374
+ focusable.focus()
375
+ return true
376
+ }, [])
377
+
378
+ const seedKeyRef = React.useRef<string | null>(null)
379
+ const billableDefaultRef = React.useRef<string | null>(null)
380
+ const versionRef = React.useRef<string | null>(null)
381
+
382
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
383
+ formId: string
384
+ resourceKind: string
385
+ resourceId: string
386
+ retryLastMutation: () => Promise<boolean>
387
+ }>({
388
+ contextId: TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID,
389
+ blockedMessage: t('staff.time_tracking.entryDialog.errors.save', 'Could not save the time entry.'),
390
+ })
391
+
392
+ const settingsQuery = useQuery<SettingsPayload>({
393
+ queryKey: [...DIALOG_QUERY_ROOT, 'settings', `scope:${scopeVersion}`],
394
+ enabled: open,
395
+ staleTime: 300_000,
396
+ queryFn: async () => {
397
+ const call = await apiCall<Record<string, unknown>>('/api/staff/timesheets/settings')
398
+ return call.ok ? readSettings(call.result) : FALLBACK_SETTINGS
399
+ },
400
+ })
401
+ const settings = settingsQuery.data ?? FALLBACK_SETTINGS
402
+
403
+ const selfQuery = useQuery<{ id: string; displayName: string } | null>({
404
+ queryKey: [...DIALOG_QUERY_ROOT, 'self', `scope:${scopeVersion}`],
405
+ enabled: open,
406
+ staleTime: 300_000,
407
+ queryFn: async () => {
408
+ const call = await apiCall<{ member?: { id?: string; displayName?: string } | null }>(
409
+ '/api/staff/team-members/self',
410
+ )
411
+ const member = call.ok ? call.result?.member ?? null : null
412
+ if (!member?.id) return null
413
+ return { id: member.id, displayName: member.displayName ?? '' }
414
+ },
415
+ })
416
+
417
+ /**
418
+ * The first page of tasks, which is what the picker opens on. It is a head
419
+ * start, not the corpus — anything past it is reached through the search below.
420
+ */
421
+ const tasksQuery = useQuery<TaskOption[]>({
422
+ queryKey: [...DIALOG_QUERY_ROOT, 'tasks', `scope:${scopeVersion}`],
423
+ enabled: open,
424
+ staleTime: 60_000,
425
+ queryFn: async () => {
426
+ const call = await apiCall<Record<string, unknown>>(
427
+ `/api/staff/timesheets/tasks?page=1&pageSize=${DIRECTORY_PAGE_SIZE}&sortField=title&sortDir=asc`,
428
+ )
429
+ if (!call.ok) return []
430
+ return readRowItems(call.result)
431
+ .map(toTaskOption)
432
+ .filter((task): task is TaskOption => task !== null)
433
+ },
434
+ })
435
+
436
+ // Debounced so a typed word is one pair of requests rather than one per letter.
437
+ React.useEffect(() => {
438
+ if (!open) {
439
+ setDebouncedTaskSearch('')
440
+ return
441
+ }
442
+ const handle = setTimeout(() => setDebouncedTaskSearch(taskSearch.trim()), TASK_SEARCH_DEBOUNCE_MS)
443
+ return () => clearTimeout(handle)
444
+ }, [open, taskSearch])
445
+
446
+ const taskSearchQuery = useQuery<TaskOption[]>({
447
+ queryKey: [...DIALOG_QUERY_ROOT, 'task-search', `scope:${scopeVersion}`, debouncedTaskSearch],
448
+ enabled: open && debouncedTaskSearch.length > 0,
449
+ staleTime: 60_000,
450
+ queryFn: () => fetchTaskOptions(debouncedTaskSearch),
451
+ })
452
+
453
+ const projectsQuery = useQuery<ProjectOption[]>({
454
+ queryKey: [...DIALOG_QUERY_ROOT, 'projects', `scope:${scopeVersion}`],
455
+ enabled: open,
456
+ staleTime: 60_000,
457
+ queryFn: async () => {
458
+ // Best effort: a consultant without `projects.view` still gets the task
459
+ // picker, only without the customer and project suffix on each option.
460
+ const call = await apiCall<Record<string, unknown>>(
461
+ `/api/staff/timesheets/time-projects?page=1&pageSize=${DIRECTORY_PAGE_SIZE}&sortField=name&sortDir=asc`,
462
+ )
463
+ if (!call.ok) return []
464
+ return readRowItems(call.result)
465
+ .map(toProjectOption)
466
+ .filter((project): project is ProjectOption => project !== null)
467
+ },
468
+ })
469
+
470
+ const staffMemberId = selfQuery.data?.id ?? null
471
+
472
+ const statusesQuery = useQuery<Record<string, TaskPickerStatus>>({
473
+ queryKey: [...DIALOG_QUERY_ROOT, 'task-statuses', `scope:${scopeVersion}`],
474
+ enabled: open,
475
+ staleTime: 300_000,
476
+ queryFn: async () => {
477
+ const call = await apiCall<Record<string, unknown>>(
478
+ `/api/staff/timesheets/task-statuses?page=1&pageSize=${DIRECTORY_PAGE_SIZE}`,
479
+ )
480
+ if (!call.ok) return {}
481
+ const map: Record<string, TaskPickerStatus> = {}
482
+ for (const row of readRowItems(call.result)) {
483
+ const id = readRowString(row, 'id')
484
+ if (!id) continue
485
+ map[id] = {
486
+ id,
487
+ name: readRowString(row, 'name') ?? '',
488
+ color: readRowString(row, 'color'),
489
+ isDone: row.is_done === true || row.isDone === true,
490
+ }
491
+ }
492
+ return map
493
+ },
494
+ })
495
+
496
+ /**
497
+ * The tasks this person logged to recently. Repetition is the norm in time
498
+ * logging, so opening the picker on these turns the common case into one
499
+ * keystroke. No new endpoint — it is the entries list asked a narrower question.
500
+ */
501
+ const recentQuery = useQuery<string[]>({
502
+ queryKey: [...DIALOG_QUERY_ROOT, 'recent-tasks', `scope:${scopeVersion}`, staffMemberId ?? 'none'],
503
+ enabled: open && !!staffMemberId,
504
+ staleTime: 60_000,
505
+ queryFn: async () => {
506
+ const since = shiftIsoDate(todayIsoDate(), -21)
507
+ const call = await apiCall<Record<string, unknown>>(
508
+ `/api/staff/timesheets/time-entries?page=1&pageSize=100&sortField=date&sortDir=desc`
509
+ + `&staffMemberId=${encodeURIComponent(staffMemberId as string)}&from=${since}`,
510
+ )
511
+ if (!call.ok) return []
512
+ const seen: string[] = []
513
+ for (const row of readRowItems(call.result)) {
514
+ const taskId = readRowString(row, 'task_id', 'taskId')
515
+ if (taskId && !seen.includes(taskId)) seen.push(taskId)
516
+ if (seen.length >= 6) break
517
+ }
518
+ return seen
519
+ },
520
+ })
521
+
522
+ const tagsQuery = useQuery<TagOption[]>({
523
+ queryKey: [...DIALOG_QUERY_ROOT, 'tags', `scope:${scopeVersion}`],
524
+ enabled: open,
525
+ staleTime: 300_000,
526
+ queryFn: async () => {
527
+ const call = await apiCall<Record<string, unknown>>(
528
+ `/api/staff/timesheets/tags?page=1&pageSize=${DIRECTORY_PAGE_SIZE}&sortField=label&sortDir=asc`,
529
+ )
530
+ if (!call.ok) return []
531
+ return readRowItems(call.result)
532
+ .map((row) => {
533
+ const id = readRowString(row, 'id')
534
+ // The colour has always been stored and never rendered — the chips were
535
+ // monochrome while `staff_time_tags.color` held a DS palette key.
536
+ return id
537
+ ? { id, label: readRowString(row, 'label') ?? id, color: readRowString(row, 'color') }
538
+ : null
539
+ })
540
+ .filter((tag): tag is TagOption => tag !== null)
541
+ },
542
+ })
543
+
544
+ const entryQuery = useQuery<TimeEntryRecord | null>({
545
+ queryKey: [...DIALOG_QUERY_ROOT, 'entry', `scope:${scopeVersion}`, entryId ?? 'new'],
546
+ enabled: open && isEdit,
547
+ staleTime: 0,
548
+ queryFn: async () => {
549
+ // `ids`, not `id`: the list schema declares the plural and is `.passthrough()`,
550
+ // so `?id=` was accepted, never read, and the filter silently disappeared —
551
+ // leaving `pageSize=1` to return the first entry of the whole table.
552
+ const call = await apiCall<Record<string, unknown>>(
553
+ `/api/staff/timesheets/time-entries?ids=${encodeURIComponent(entryId as string)}&pageSize=1`,
554
+ )
555
+ if (!call.ok) throw new Error('[internal] time entry request failed')
556
+ const row = readRowItems(call.result)[0]
557
+ if (!row) return null
558
+ const record = toTimeEntryRecord(row)
559
+ if (!record) return null
560
+ // A dialog that edits a record other than the one it was asked for writes
561
+ // one entry's values onto another, so a mismatch fails loudly rather than
562
+ // populating the form. Nothing but a dropped filter can produce it.
563
+ if (record.id !== entryId) {
564
+ throw new Error('[internal] time entry response did not match the requested id')
565
+ }
566
+ return record
567
+ },
568
+ })
569
+
570
+ const entry = entryQuery.data ?? null
571
+ const locked = !!entry?.isLocked
572
+ const lockedReportId = entry?.lockedReportId ?? null
573
+ const directoryTasks = React.useMemo(() => tasksQuery.data ?? [], [tasksQuery.data])
574
+ const searchedTasks = React.useMemo(() => taskSearchQuery.data ?? [], [taskSearchQuery.data])
575
+
576
+ React.useEffect(() => {
577
+ if (!taskId) {
578
+ setPinnedTask(null)
579
+ return
580
+ }
581
+ const found =
582
+ directoryTasks.find((task) => task.id === taskId) ??
583
+ searchedTasks.find((task) => task.id === taskId) ??
584
+ null
585
+ if (found) setPinnedTask(found)
586
+ }, [directoryTasks, searchedTasks, taskId])
587
+
588
+ /**
589
+ * An entry can point at a task that neither the first directory page nor the
590
+ * current search contains — editing one written months ago, for instance — and
591
+ * a form that cannot name the task it is on cannot resolve its project or rate
592
+ * either, so that one row is fetched by id.
593
+ */
594
+ const unresolvedTaskId =
595
+ taskId && !pinnedTask && !tasksQuery.isPending && !taskSearchQuery.isFetching ? taskId : null
596
+
597
+ const unresolvedTaskQuery = useQuery<TaskOption | null>({
598
+ queryKey: [...DIALOG_QUERY_ROOT, 'task', `scope:${scopeVersion}`, unresolvedTaskId ?? 'none'],
599
+ enabled: open && !!unresolvedTaskId,
600
+ staleTime: 60_000,
601
+ queryFn: () => fetchTaskById(unresolvedTaskId as string),
602
+ })
603
+
604
+ const tasks = React.useMemo(() => {
605
+ const merged = new Map<string, TaskOption>()
606
+ for (const task of directoryTasks) merged.set(task.id, task)
607
+ for (const task of searchedTasks) if (!merged.has(task.id)) merged.set(task.id, task)
608
+ for (const task of [pinnedTask, unresolvedTaskQuery.data ?? null]) {
609
+ if (task && !merged.has(task.id)) merged.set(task.id, task)
610
+ }
611
+ return [...merged.values()]
612
+ }, [directoryTasks, pinnedTask, searchedTasks, unresolvedTaskQuery.data])
613
+
614
+ const projects = React.useMemo(() => projectsQuery.data ?? [], [projectsQuery.data])
615
+ const tagOptions = React.useMemo(() => tagsQuery.data ?? [], [tagsQuery.data])
616
+
617
+ const projectById = React.useMemo(() => {
618
+ const map = new Map<string, ProjectOption>()
619
+ for (const project of projects) map.set(project.id, project)
620
+ return map
621
+ }, [projects])
622
+
623
+ const selectedTask = React.useMemo(
624
+ () => tasks.find((task) => task.id === taskId) ?? null,
625
+ [taskId, tasks],
626
+ )
627
+
628
+ const projectId = selectedTask?.timeProjectId ?? entry?.timeProjectId ?? defaults?.timeProjectId ?? null
629
+ const project = projectId ? projectById.get(projectId) ?? null : null
630
+
631
+ const resetForm = React.useCallback(
632
+ (seed: {
633
+ taskId: string | null
634
+ description: string
635
+ date: string
636
+ start: string | null
637
+ end: string | null
638
+ durationMinutes: number | null
639
+ isBillable: boolean
640
+ rateOverrideAmount: number | null
641
+ tagIds: string[]
642
+ }) => {
643
+ setTaskId(seed.taskId)
644
+ setDescription(seed.description)
645
+ setDate(seed.date || todayIsoDate())
646
+ setIntervalState((current) =>
647
+ resetIntervalState(current, {
648
+ start: seed.start,
649
+ end: seed.end,
650
+ durationMinutes: seed.durationMinutes,
651
+ }),
652
+ )
653
+ setIsBillable(seed.isBillable)
654
+ setRateOverrideEnabled(seed.rateOverrideAmount !== null)
655
+ setRateText(seed.rateOverrideAmount !== null ? String(seed.rateOverrideAmount) : '')
656
+ setTagIds(seed.tagIds)
657
+ setOverlaps([])
658
+ setFieldIssues(NO_FIELD_ISSUES)
659
+ // The seed is run through the same derivation the state uses, so the
660
+ // baseline is what the form will actually show rather than what was asked
661
+ // for — otherwise a seeded start plus duration would read as an edit.
662
+ const derived = createIntervalState({
663
+ start: seed.start,
664
+ end: seed.end,
665
+ durationMinutes: seed.durationMinutes,
666
+ })
667
+ setBaseline({
668
+ taskId: seed.taskId,
669
+ description: seed.description,
670
+ date: seed.date || todayIsoDate(),
671
+ startText: derived.startText,
672
+ endText: derived.endText,
673
+ durationMinutes: derived.durationMinutes,
674
+ isBillable: seed.isBillable,
675
+ rateOverrideEnabled: seed.rateOverrideAmount !== null,
676
+ rateOverrideAmount: seed.rateOverrideAmount,
677
+ tagIds: seed.tagIds,
678
+ })
679
+ },
680
+ [],
681
+ )
682
+
683
+ React.useEffect(() => {
684
+ if (!open) {
685
+ seedKeyRef.current = null
686
+ billableDefaultRef.current = null
687
+ setBaseline(null)
688
+ setTaskSearch('')
689
+ setPinnedTask(null)
690
+ return
691
+ }
692
+ if (isEdit && !entry) return
693
+ const key = isEdit ? `edit:${entry?.id ?? entryId}:${entry?.updatedAt ?? ''}` : 'create'
694
+ if (seedKeyRef.current === key) return
695
+ seedKeyRef.current = key
696
+ versionRef.current = entry?.updatedAt ?? null
697
+ if (entry) {
698
+ resetForm({
699
+ taskId: entry.taskId,
700
+ description: entry.description,
701
+ date: entry.date,
702
+ start: entry.startText,
703
+ end: entry.endText,
704
+ durationMinutes: entry.durationMinutes,
705
+ isBillable: entry.isBillable,
706
+ rateOverrideAmount: entry.rateOverrideAmount,
707
+ tagIds: entry.tagIds,
708
+ })
709
+ return
710
+ }
711
+ resetForm({
712
+ taskId: defaults?.taskId ?? null,
713
+ description: defaults?.description ?? '',
714
+ date: defaults?.date ?? todayIsoDate(),
715
+ start: defaults?.startClock ?? null,
716
+ end: defaults?.endClock ?? null,
717
+ durationMinutes: defaults?.durationMinutes ?? null,
718
+ isBillable: defaults?.isBillable ?? true,
719
+ rateOverrideAmount: null,
720
+ tagIds: defaults?.tagIds ?? [],
721
+ })
722
+ }, [defaults, entry, entryId, isEdit, open, resetForm])
723
+
724
+ /**
725
+ * The tenant's billable default lands separately, and only on the one field it
726
+ * owns. Seeding the whole form once the settings request answers would race the
727
+ * first keystrokes and wipe them, which is exactly what this split avoids.
728
+ */
729
+ React.useEffect(() => {
730
+ if (!open || isEdit || settingsQuery.isPending) return
731
+ if (defaults?.isBillable !== undefined && defaults?.isBillable !== null) return
732
+ if (billableDefaultRef.current === seedKeyRef.current) return
733
+ billableDefaultRef.current = seedKeyRef.current
734
+ setIsBillable(settings.defaults.billable)
735
+ // The tenant default is part of the seed, just a late-arriving part, so the
736
+ // baseline moves with it — otherwise every new entry would open "dirty".
737
+ setBaseline((current) =>
738
+ current ? { ...current, isBillable: settings.defaults.billable } : current,
739
+ )
740
+ }, [defaults?.isBillable, isEdit, open, settings.defaults.billable, settingsQuery.isPending])
741
+
742
+ const durationMinutes = interval.durationMinutes
743
+ const startClock = interval.startText
744
+ const endClock = interval.endText
745
+
746
+ /**
747
+ * Advisory overlap probe. Every failure mode — a non-200, a throw, a partial
748
+ * span — lands on "no warning", because the endpoint may not answer and the
749
+ * form must still save.
750
+ */
751
+ React.useEffect(() => {
752
+ if (!open || locked || !settings.warnings.overlap) {
753
+ setOverlaps([])
754
+ return
755
+ }
756
+ if (!date || parseClock(startClock) === null || !durationMinutes) {
757
+ setOverlaps([])
758
+ return
759
+ }
760
+ let cancelled = false
761
+ const params = new URLSearchParams({
762
+ date,
763
+ startedAt: startClock,
764
+ durationMinutes: String(durationMinutes),
765
+ })
766
+ if (parseClock(endClock) !== null) params.set('endedAt', endClock)
767
+ if (isEdit && entryId) params.set('excludeId', entryId)
768
+ void apiCall<Record<string, unknown>>(`/api/staff/timesheets/time-entries/overlaps?${params.toString()}`)
769
+ .then((call) => {
770
+ if (cancelled) return
771
+ if (!call.ok) {
772
+ setOverlaps([])
773
+ return
774
+ }
775
+ setOverlaps(
776
+ readRowItems(call.result)
777
+ .map(toOverlapEntry)
778
+ .filter((item): item is OverlapEntry => item !== null),
779
+ )
780
+ })
781
+ .catch(() => {
782
+ if (!cancelled) setOverlaps([])
783
+ })
784
+ return () => {
785
+ cancelled = true
786
+ }
787
+ }, [date, durationMinutes, endClock, entryId, isEdit, locked, open, settings.warnings.overlap, startClock])
788
+
789
+ const rateOverrideAmount = rateOverrideEnabled ? parseRateText(rateText) : null
790
+ const currencyCode = project?.currencyCode ?? entry?.currencyCode ?? null
791
+
792
+ /**
793
+ * The dialog is hand-rolled, so it plays the `CrudForm` host contract by hand:
794
+ * the four render spots below, plus the `onFieldChange` / `onBeforeSave` /
795
+ * `onAfterSave` lifecycle, dispatched with the same semantics `CrudForm` uses —
796
+ * a widget returning `ok: false` from `onBeforeSave` blocks the write and its
797
+ * message is surfaced, and a value returned from `onFieldChange` is written back
798
+ * into the form.
799
+ */
800
+ const entryFormValues = React.useMemo<FormValues>(
801
+ () => ({
802
+ taskId,
803
+ description,
804
+ date,
805
+ startText: startClock,
806
+ endText: endClock,
807
+ durationMinutes,
808
+ isBillable,
809
+ rateOverrideEnabled,
810
+ rateOverrideAmount,
811
+ tagIds,
812
+ }),
813
+ [
814
+ date,
815
+ description,
816
+ durationMinutes,
817
+ endClock,
818
+ isBillable,
819
+ rateOverrideAmount,
820
+ rateOverrideEnabled,
821
+ startClock,
822
+ tagIds,
823
+ taskId,
824
+ ],
825
+ )
826
+
827
+ const entryInjectionContext = React.useMemo<TimeEntryFormInjectionContext>(
828
+ () => ({
829
+ formId: TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID,
830
+ entityId: TIME_ENTRY_ENTITY_ID,
831
+ recordId: entryId ?? null,
832
+ resourceKind: 'staff.timesheets.time_entry',
833
+ resourceId: entryId ?? 'new',
834
+ operation: isEdit ? 'update' : 'create',
835
+ locked,
836
+ retryLastMutation,
837
+ }),
838
+ [entryId, isEdit, locked, retryLastMutation],
839
+ )
840
+
841
+ const { triggerEvent: triggerEntryFormEvent } = useInjectionSpotEvents<
842
+ TimeEntryFormInjectionContext,
843
+ FormValues
844
+ >(extensionPoints.hosts.timeEntryForm.spotId)
845
+
846
+ /**
847
+ * Fields the last write to came from a widget, not from the person. Without it
848
+ * an `onFieldChange` handler that rewrites its own field would see the rewrite
849
+ * as a fresh edit and be dispatched again on every apply.
850
+ */
851
+ const injectedFieldWritesRef = React.useRef(new Set<string>())
852
+
853
+ const applyInjectedFieldValue = React.useCallback((fieldId: string, value: unknown) => {
854
+ switch (fieldId) {
855
+ case 'taskId':
856
+ injectedFieldWritesRef.current.add(fieldId)
857
+ setTaskId(typeof value === 'string' && value.length > 0 ? value : null)
858
+ return
859
+ case 'description':
860
+ injectedFieldWritesRef.current.add(fieldId)
861
+ setDescription(typeof value === 'string' ? value : '')
862
+ return
863
+ case 'date':
864
+ if (typeof value !== 'string') return
865
+ injectedFieldWritesRef.current.add(fieldId)
866
+ setDate(value)
867
+ return
868
+ case 'isBillable':
869
+ injectedFieldWritesRef.current.add(fieldId)
870
+ setIsBillable(value === true)
871
+ return
872
+ case 'rateOverrideEnabled':
873
+ injectedFieldWritesRef.current.add(fieldId)
874
+ setRateOverrideEnabled(value === true)
875
+ return
876
+ case 'rateOverrideAmount':
877
+ injectedFieldWritesRef.current.add(fieldId)
878
+ setRateText(typeof value === 'number' || typeof value === 'string' ? String(value) : '')
879
+ return
880
+ case 'tagIds':
881
+ if (!Array.isArray(value)) return
882
+ injectedFieldWritesRef.current.add(fieldId)
883
+ setTagIds(value.filter((id): id is string => typeof id === 'string'))
884
+ return
885
+ case 'startText':
886
+ if (typeof value !== 'string') return
887
+ injectedFieldWritesRef.current.add(fieldId)
888
+ setIntervalState((current) => reduceIntervalState(current, { field: 'start', value }))
889
+ return
890
+ case 'endText':
891
+ if (typeof value !== 'string') return
892
+ injectedFieldWritesRef.current.add(fieldId)
893
+ setIntervalState((current) => reduceIntervalState(current, { field: 'end', value }))
894
+ return
895
+ case 'durationMinutes':
896
+ injectedFieldWritesRef.current.add(fieldId)
897
+ setIntervalState((current) =>
898
+ reduceIntervalState(current, {
899
+ field: 'duration',
900
+ minutes: typeof value === 'number' ? value : null,
901
+ invalid: false,
902
+ }),
903
+ )
904
+ return
905
+ default:
906
+ }
907
+ }, [])
908
+
909
+ const previousEntryFormValuesRef = React.useRef<FormValues | null>(null)
910
+
911
+ React.useEffect(() => {
912
+ if (!open || locked) {
913
+ previousEntryFormValuesRef.current = null
914
+ injectedFieldWritesRef.current.clear()
915
+ return
916
+ }
917
+ const previous = previousEntryFormValuesRef.current
918
+ previousEntryFormValuesRef.current = entryFormValues
919
+ if (!previous) return
920
+ const injectedWrites = injectedFieldWritesRef.current
921
+ const changedFieldIds = (Object.keys(entryFormValues) as Array<keyof FormValues>).filter(
922
+ (fieldId) => fieldValueSnapshot(previous[fieldId]) !== fieldValueSnapshot(entryFormValues[fieldId]),
923
+ )
924
+ for (const fieldId of changedFieldIds) {
925
+ if (injectedWrites.delete(fieldId)) continue
926
+ void triggerEntryFormEvent('onFieldChange', entryFormValues, entryInjectionContext, {
927
+ fieldId,
928
+ fieldValue: entryFormValues[fieldId],
929
+ })
930
+ .then((result) => {
931
+ if (!result.ok || !result.fieldChange) return
932
+ const change = result.fieldChange
933
+ if (change.value !== undefined) applyInjectedFieldValue(fieldId, change.value)
934
+ for (const [sideFieldId, sideValue] of Object.entries(change.sideEffects ?? {})) {
935
+ applyInjectedFieldValue(sideFieldId, sideValue)
936
+ }
937
+ for (const message of change.messages ?? []) flash(message.text, message.severity)
938
+ })
939
+ .catch((err) => {
940
+ logger.error('staff.time_tracking entry dialog onFieldChange failed', { err })
941
+ })
942
+ }
943
+ }, [
944
+ applyInjectedFieldValue,
945
+ entryFormValues,
946
+ entryInjectionContext,
947
+ locked,
948
+ open,
949
+ triggerEntryFormEvent,
950
+ ])
951
+ const roundedMinutes = roundMinutes(durationMinutes ?? 0, settings.rounding)
952
+ const rate = applicableRate({ rateOverrideAmount }, { hourlyRate: project?.hourlyRate ?? null })
953
+ const cost = entryAmount(
954
+ { isBillable, roundedMinutes, rateOverrideAmount },
955
+ { hourlyRate: project?.hourlyRate ?? null },
956
+ )
957
+
958
+ const computedBadge = (field: 'start' | 'end' | 'duration' | 'cost') => (
959
+ <Badge variant="neutral" data-testid={`entry-dialog-computed-${field}`}>
960
+ {t('staff.time_tracking.entryDialog.computed', 'computed')}
961
+ </Badge>
962
+ )
963
+
964
+ const midnightEndDate = interval.crossesMidnight ? shiftIsoDate(date, 1) : null
965
+ /**
966
+ * Unparseable duration text keeps Save disabled — the field already explains
967
+ * itself and there is nothing to send. A *missing* required value does not:
968
+ * pressing Save is how the form is asked what it is waiting for, and it
969
+ * answers under the control rather than by staying greyed out and silent.
970
+ */
971
+ const canSubmit = !locked && !interval.durationInvalid && !saving
972
+
973
+ // A field stops complaining the moment it is filled in.
974
+ React.useEffect(() => {
975
+ if (!taskId) return
976
+ setFieldIssues((current) => (current.task ? { ...current, task: null } : current))
977
+ }, [taskId])
978
+
979
+ React.useEffect(() => {
980
+ if (!durationMinutes) return
981
+ setFieldIssues((current) => (current.duration ? { ...current, duration: null } : current))
982
+ }, [durationMinutes])
983
+
984
+ const validateRequired = React.useCallback(() => {
985
+ const next: FieldIssues = {
986
+ task: taskId
987
+ ? null
988
+ : t('staff.time_tracking.entryDialog.errors.taskRequired', 'Pick the task this time belongs to.'),
989
+ duration: durationMinutes
990
+ ? null
991
+ : t('staff.time_tracking.entryDialog.errors.durationRequired', 'Enter how long the work took.'),
992
+ }
993
+ setFieldIssues(next)
994
+ return !next.task && !next.duration
995
+ }, [durationMinutes, t, taskId])
996
+
997
+ /**
998
+ * A locked entry has nothing editable in it, and a form that has not been
999
+ * seeded yet has nothing to compare against; everywhere else the seed is the
1000
+ * baseline and any difference is work that closing would throw away.
1001
+ */
1002
+ const isDirty = React.useMemo(() => {
1003
+ if (locked || !baseline) return false
1004
+ return (
1005
+ formSnapshot(baseline) !==
1006
+ formSnapshot({
1007
+ taskId,
1008
+ description,
1009
+ date,
1010
+ startText: startClock,
1011
+ endText: endClock,
1012
+ durationMinutes,
1013
+ isBillable,
1014
+ rateOverrideEnabled,
1015
+ rateOverrideAmount,
1016
+ tagIds,
1017
+ })
1018
+ )
1019
+ }, [
1020
+ baseline,
1021
+ date,
1022
+ description,
1023
+ durationMinutes,
1024
+ endClock,
1025
+ isBillable,
1026
+ locked,
1027
+ rateOverrideAmount,
1028
+ rateOverrideEnabled,
1029
+ startClock,
1030
+ tagIds,
1031
+ taskId,
1032
+ ])
1033
+
1034
+ /**
1035
+ * Every way out of the dialog — Escape, the ×, the overlay, Cancel — lands
1036
+ * here, because a half-entered entry is lost the same way whichever one is
1037
+ * used. A clean form closes silently; only real work is worth an interruption.
1038
+ */
1039
+ const requestClose = React.useCallback(async () => {
1040
+ if (saving) return
1041
+ if (!isDirty) {
1042
+ onOpenChange(false)
1043
+ return
1044
+ }
1045
+ const confirmed = await confirm({
1046
+ title: t('staff.time_tracking.entryDialog.discard.title', 'Discard this time entry?'),
1047
+ text: t(
1048
+ 'staff.time_tracking.entryDialog.discard.text',
1049
+ 'It has not been saved yet, so closing now loses what you entered.',
1050
+ ),
1051
+ confirmText: t('staff.time_tracking.entryDialog.discard.confirm', 'Discard'),
1052
+ cancelText: t('staff.time_tracking.entryDialog.discard.cancel', 'Keep editing'),
1053
+ variant: 'destructive',
1054
+ })
1055
+ if (confirmed) onOpenChange(false)
1056
+ }, [confirm, isDirty, onOpenChange, saving, t])
1057
+
1058
+ const buildPayload = React.useCallback(() => {
1059
+ const startedAt = parseClock(startClock) !== null ? combineDateAndClock(date, startClock) : null
1060
+ const endedAt =
1061
+ parseClock(endClock) !== null ? combineDateAndClock(date, endClock, interval.crossesMidnight ? 1 : 0) : null
1062
+ return {
1063
+ date,
1064
+ durationMinutes: durationMinutes ?? 0,
1065
+ startedAt,
1066
+ endedAt,
1067
+ taskId,
1068
+ timeProjectId: projectId,
1069
+ description: description.trim() ? description.trim() : null,
1070
+ isBillable,
1071
+ tagIds,
1072
+ rateOverrideAmount: canSeeMoney ? rateOverrideAmount : undefined,
1073
+ }
1074
+ }, [
1075
+ canSeeMoney,
1076
+ date,
1077
+ description,
1078
+ durationMinutes,
1079
+ endClock,
1080
+ interval.crossesMidnight,
1081
+ isBillable,
1082
+ projectId,
1083
+ rateOverrideAmount,
1084
+ startClock,
1085
+ tagIds,
1086
+ taskId,
1087
+ ])
1088
+
1089
+ const reportFailure = React.useCallback(
1090
+ (error: unknown) => {
1091
+ if (surfaceRecordConflict(error, t)) return
1092
+ if (readErrorCode(error) === 'entry_locked') {
1093
+ flash(
1094
+ t(
1095
+ 'staff.time_tracking.entryDialog.locked.notice',
1096
+ 'This entry is closed in a report, so it can no longer be edited.',
1097
+ ),
1098
+ 'error',
1099
+ )
1100
+ return
1101
+ }
1102
+ const issues = readFieldIssues(error)
1103
+ if (issues) {
1104
+ setFieldIssues(issues)
1105
+ return
1106
+ }
1107
+ logger.error('staff.time_tracking entry dialog save failed', { err: error })
1108
+ flash(
1109
+ error instanceof Error && error.message
1110
+ ? error.message
1111
+ : t('staff.time_tracking.entryDialog.errors.save', 'Could not save the time entry.'),
1112
+ 'error',
1113
+ )
1114
+ },
1115
+ [t],
1116
+ )
1117
+
1118
+ const submit = React.useCallback(
1119
+ async (mode: SaveMode) => {
1120
+ if (!canSubmit) return
1121
+ if (!validateRequired()) return
1122
+ if (!isEdit && !staffMemberId) {
1123
+ flash(
1124
+ t(
1125
+ 'staff.time_tracking.entryDialog.errors.noStaffMember',
1126
+ 'Your account is not linked to a staff member, so time cannot be logged.',
1127
+ ),
1128
+ 'error',
1129
+ )
1130
+ return
1131
+ }
1132
+ const body = buildPayload()
1133
+
1134
+ const beforeSave = await triggerEntryFormEvent('onBeforeSave', entryFormValues, entryInjectionContext)
1135
+ if (!beforeSave.ok) {
1136
+ if (beforeSave.fieldErrors) {
1137
+ const taskIssue = beforeSave.fieldErrors.taskId ?? beforeSave.fieldErrors.task ?? null
1138
+ const durationIssue =
1139
+ beforeSave.fieldErrors.durationMinutes ?? beforeSave.fieldErrors.duration ?? null
1140
+ if (taskIssue || durationIssue) setFieldIssues({ task: taskIssue, duration: durationIssue })
1141
+ }
1142
+ flash(
1143
+ beforeSave.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
1144
+ 'error',
1145
+ )
1146
+ return
1147
+ }
1148
+ const injectedRequestHeaders = beforeSave.requestHeaders ?? {}
1149
+
1150
+ setSavingMode(mode)
1151
+ try {
1152
+ const saved = await runMutation({
1153
+ operation: () =>
1154
+ withScopedApiRequestHeaders(
1155
+ {
1156
+ ...injectedRequestHeaders,
1157
+ ...(isEdit ? buildOptimisticLockHeader(versionRef.current) : {}),
1158
+ },
1159
+ () =>
1160
+ apiCallOrThrow<Record<string, unknown>>(
1161
+ '/api/staff/timesheets/time-entries',
1162
+ {
1163
+ method: isEdit ? 'PUT' : 'POST',
1164
+ headers: { 'content-type': 'application/json' },
1165
+ body: JSON.stringify(
1166
+ isEdit ? { id: entryId, ...body } : { staffMemberId, source: 'manual', ...body },
1167
+ ),
1168
+ },
1169
+ {
1170
+ errorMessage: t(
1171
+ 'staff.time_tracking.entryDialog.errors.save',
1172
+ 'Could not save the time entry.',
1173
+ ),
1174
+ },
1175
+ ),
1176
+ ),
1177
+ context: {
1178
+ formId: TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID,
1179
+ resourceKind: 'staff.timesheets.time_entry',
1180
+ resourceId: entryId ?? 'new',
1181
+ retryLastMutation,
1182
+ },
1183
+ })
1184
+ const savedId = typeof saved?.result?.id === 'string' ? saved.result.id : entryId ?? null
1185
+ try {
1186
+ await triggerEntryFormEvent('onAfterSave', entryFormValues, entryInjectionContext)
1187
+ } catch (err) {
1188
+ logger.error('staff.time_tracking entry dialog onAfterSave failed', { err })
1189
+ }
1190
+ flash(t('staff.time_tracking.entryDialog.saved', 'Time entry saved.'), 'success')
1191
+ if (mode === 'again') {
1192
+ // Note 3: the dialog stays open, the description and the times clear,
1193
+ // and the next entry starts where this one ended.
1194
+ const chain = settings.defaults.chainStartFromPreviousEnd
1195
+ const nextDate = interval.crossesMidnight && midnightEndDate ? midnightEndDate : date
1196
+ const nextStart = chain ? endClock || null : null
1197
+ seedKeyRef.current = 'create'
1198
+ setDescription('')
1199
+ setDate(nextDate)
1200
+ setIntervalState((current) => resetIntervalState(current, { start: nextStart }))
1201
+ setOverlaps([])
1202
+ // The next entry starts clean, so this is its baseline — without it the
1203
+ // dialog would still be holding the one that was just written.
1204
+ setBaseline({
1205
+ taskId,
1206
+ description: '',
1207
+ date: nextDate,
1208
+ startText: nextStart ?? '',
1209
+ endText: '',
1210
+ durationMinutes: null,
1211
+ isBillable,
1212
+ rateOverrideEnabled,
1213
+ rateOverrideAmount,
1214
+ tagIds,
1215
+ })
1216
+ onSaved?.({ id: savedId, keptOpen: true })
1217
+ // Back to the top of the loop: pick task → duration → save → next.
1218
+ focusTaskPicker()
1219
+ return
1220
+ }
1221
+ onSaved?.({ id: savedId, keptOpen: false })
1222
+ onOpenChange(false)
1223
+ } catch (error) {
1224
+ reportFailure(error)
1225
+ } finally {
1226
+ setSavingMode(null)
1227
+ }
1228
+ },
1229
+ [
1230
+ buildPayload,
1231
+ canSubmit,
1232
+ date,
1233
+ endClock,
1234
+ entryFormValues,
1235
+ entryId,
1236
+ entryInjectionContext,
1237
+ interval.crossesMidnight,
1238
+ isBillable,
1239
+ isEdit,
1240
+ midnightEndDate,
1241
+ onOpenChange,
1242
+ onSaved,
1243
+ rateOverrideAmount,
1244
+ rateOverrideEnabled,
1245
+ reportFailure,
1246
+ retryLastMutation,
1247
+ runMutation,
1248
+ settings.defaults.chainStartFromPreviousEnd,
1249
+ staffMemberId,
1250
+ t,
1251
+ tagIds,
1252
+ taskId,
1253
+ triggerEntryFormEvent,
1254
+ validateRequired,
1255
+ ],
1256
+ )
1257
+
1258
+ const handleShowOverlap = React.useCallback(
1259
+ (overlap: OverlapEntry) => {
1260
+ if (onShowEntry) {
1261
+ onShowEntry(overlap.id)
1262
+ return
1263
+ }
1264
+ window.open(`${ENTRIES_LIST_PATH}?ids=${encodeURIComponent(overlap.id)}`, '_blank', 'noopener')
1265
+ },
1266
+ [onShowEntry],
1267
+ )
1268
+
1269
+ /**
1270
+ * The picker needs the project and its customer per row so it can group by
1271
+ * them; both already arrive with the projects query the dialog runs for the
1272
+ * rate, so this is a join in memory rather than another request.
1273
+ */
1274
+ const pickerItems = React.useMemo<TaskPickerItem[]>(
1275
+ () =>
1276
+ tasks.map((task) => {
1277
+ const project = task.timeProjectId ? projectById.get(task.timeProjectId) : undefined
1278
+ return {
1279
+ id: task.id,
1280
+ reference: task.reference,
1281
+ title: task.title,
1282
+ projectId: task.timeProjectId,
1283
+ projectName: project?.name ?? null,
1284
+ customerName: project?.customerName ?? null,
1285
+ statusId: task.taskStatusId ?? null,
1286
+ assigneeInitials: null,
1287
+ assigneeName: null,
1288
+ loggedMinutes: task.loggedMinutes ?? null,
1289
+ }
1290
+ }),
1291
+ [projectById, tasks],
1292
+ )
1293
+
1294
+ const queryClient = useQueryClient()
1295
+ /**
1296
+ * Creates the tag, then hands it back so the caller can assign it.
1297
+ *
1298
+ * The colour is decided here rather than left to the server, which stores
1299
+ * whatever it is sent and defaults to null. The picker already tints its dot
1300
+ * with the name-derived hue, so sending that same hue is what makes the chip
1301
+ * the entry ends up wearing the colour the row promised — instead of the grey
1302
+ * one an unset column renders as.
1303
+ */
1304
+ const createTag = React.useCallback(
1305
+ async (label: string): Promise<TagOption | null> => {
1306
+ const color = autoColorFromName(label).key
1307
+ try {
1308
+ const created = await apiCallOrThrow<Record<string, unknown>>(
1309
+ '/api/staff/timesheets/tags',
1310
+ {
1311
+ method: 'POST',
1312
+ headers: { 'content-type': 'application/json' },
1313
+ body: JSON.stringify({ label, slug: slugifyProjectName(label).toLowerCase(), color }),
1314
+ },
1315
+ { errorMessage: t('staff.time_tracking.entryDialog.errors.createTag', 'Could not create the tag.') },
1316
+ )
1317
+ const id = typeof created?.result?.id === 'string' ? created.result.id : null
1318
+ if (!id) return null
1319
+ const option: TagOption = { id, label, color }
1320
+ // Seeded before the refetch so the chip is never briefly the grey one the
1321
+ // fix is about; the invalidation right after replaces it with the row the
1322
+ // server actually stored.
1323
+ queryClient.setQueryData<TagOption[]>(
1324
+ [...DIALOG_QUERY_ROOT, 'tags', `scope:${scopeVersion}`],
1325
+ (current) => [...(current ?? []), option],
1326
+ )
1327
+ await queryClient.invalidateQueries({ queryKey: [...DIALOG_QUERY_ROOT, 'tags'] })
1328
+ return option
1329
+ } catch (error) {
1330
+ logger.warn('staff.time_tracking.entryDialog tag create failed', { err: error })
1331
+ flash(t('staff.time_tracking.entryDialog.errors.createTag', 'Could not create the tag.'), 'error')
1332
+ return null
1333
+ }
1334
+ },
1335
+ [queryClient, scopeVersion, t],
1336
+ )
1337
+
1338
+ const overlap = overlaps[0] ?? null
1339
+ const availableTags = React.useMemo(
1340
+ () => tagOptions.filter((tag) => !tagIds.includes(tag.id)),
1341
+ [tagIds, tagOptions],
1342
+ )
1343
+ const assignedTags = React.useMemo(() => {
1344
+ const byId = new Map(tagOptions.map((tag) => [tag.id, tag]))
1345
+ return tagIds.map((id) => byId.get(id) ?? { id, label: id, color: null })
1346
+ }, [tagIds, tagOptions])
1347
+
1348
+ const rateLabel = rate !== null ? formatCurrency(rate, currencyCode) ?? String(rate) : null
1349
+ const projectRateLabel =
1350
+ project?.hourlyRate !== null && project?.hourlyRate !== undefined
1351
+ ? formatCurrency(project.hourlyRate, currencyCode) ?? String(project.hourlyRate)
1352
+ : null
1353
+
1354
+ const body = (() => {
1355
+ if (isEdit && entryQuery.isLoading) {
1356
+ return <LoadingMessage label={t('staff.time_tracking.entryDialog.loading', 'Loading the time entry…')} />
1357
+ }
1358
+ if (isEdit && entryQuery.isError) {
1359
+ return <ErrorMessage label={t('staff.time_tracking.entryDialog.errors.load', 'Could not load the time entry.')} />
1360
+ }
1361
+ if (isEdit && !entry) {
1362
+ return <ErrorMessage label={t('staff.time_tracking.entryDialog.errors.notFound', 'Time entry not found or not accessible.')} />
1363
+ }
1364
+
1365
+ return (
1366
+ <div className="flex flex-col gap-4">
1367
+ <InjectionSpot
1368
+ spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'before-fields')}
1369
+ context={entryInjectionContext}
1370
+ data={entryFormValues}
1371
+ />
1372
+
1373
+ {locked ? (
1374
+ <Alert status="warning" data-testid="entry-dialog-locked">
1375
+ <AlertTitle className="flex items-center gap-2">
1376
+ <Lock className="size-3.5" aria-hidden="true" />
1377
+ {t('staff.time_tracking.entryDialog.locked.badge', 'Locked')}
1378
+ </AlertTitle>
1379
+ <AlertDescription>
1380
+ {t(
1381
+ 'staff.time_tracking.entryDialog.locked.notice',
1382
+ 'This entry is closed in a report, so it can no longer be edited.',
1383
+ )}
1384
+ </AlertDescription>
1385
+ {lockedReportId ? (
1386
+ <Button
1387
+ type="button"
1388
+ variant="outline"
1389
+ size="sm"
1390
+ className="mt-2"
1391
+ data-testid="entry-dialog-locked-report"
1392
+ onClick={() =>
1393
+ window.open(`${REPORTS_PATH}?id=${encodeURIComponent(lockedReportId)}`, '_blank', 'noopener')
1394
+ }
1395
+ >
1396
+ {t('staff.time_tracking.entryDialog.locked.showReport', 'Show the report')}
1397
+ </Button>
1398
+ ) : null}
1399
+ </Alert>
1400
+ ) : null}
1401
+
1402
+ <div className="flex flex-col gap-1.5">
1403
+ <Label htmlFor="entry-dialog-task">
1404
+ {t('staff.time_tracking.entryDialog.task', 'Task')}
1405
+ <span aria-hidden="true"> *</span>
1406
+ </Label>
1407
+ <div
1408
+ data-testid="entry-dialog-task"
1409
+ ref={taskTriggerRef}
1410
+ aria-invalid={fieldIssues.task ? true : undefined}
1411
+ aria-describedby={fieldIssues.task ? 'entry-dialog-task-message' : undefined}
1412
+ >
1413
+ <TaskPicker
1414
+ value={taskId}
1415
+ onChange={(next) => setTaskId(next || null)}
1416
+ items={pickerItems}
1417
+ recentTaskIds={recentQuery.data ?? []}
1418
+ statuses={statusesQuery.data ?? {}}
1419
+ onQueryChange={setTaskSearch}
1420
+ searching={taskSearchQuery.isFetching}
1421
+ loading={tasksQuery.isLoading}
1422
+ disabled={locked}
1423
+ />
1424
+ </div>
1425
+ {fieldIssues.task ? (
1426
+ <p
1427
+ id="entry-dialog-task-message"
1428
+ className="text-xs text-status-error-text"
1429
+ role="alert"
1430
+ data-testid="entry-dialog-task-error"
1431
+ >
1432
+ {fieldIssues.task}
1433
+ </p>
1434
+ ) : (
1435
+ <p className="text-xs text-muted-foreground">
1436
+ {t(
1437
+ 'staff.time_tracking.entryDialog.taskHint',
1438
+ 'The project and the customer follow from the task — you do not pick them separately.',
1439
+ )}
1440
+ </p>
1441
+ )}
1442
+ </div>
1443
+
1444
+ <div className="flex flex-col gap-1.5">
1445
+ <Label htmlFor="entry-dialog-description">
1446
+ {t('staff.time_tracking.entryDialog.description', 'Description')}
1447
+ </Label>
1448
+ <Input
1449
+ id="entry-dialog-description"
1450
+ value={description}
1451
+ disabled={locked}
1452
+ onChange={(event) => setDescription(event.target.value)}
1453
+ data-testid="entry-dialog-description"
1454
+ />
1455
+ </div>
1456
+
1457
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1458
+ <div className="flex flex-col gap-1.5">
1459
+ <Label htmlFor="entry-dialog-date">{t('staff.time_tracking.entryDialog.date', 'Date')}</Label>
1460
+ <Input
1461
+ id="entry-dialog-date"
1462
+ type="date"
1463
+ value={date}
1464
+ disabled={locked}
1465
+ onChange={(event) => setDate(event.target.value)}
1466
+ data-testid="entry-dialog-date"
1467
+ />
1468
+ </div>
1469
+ <div className="flex flex-col gap-1.5">
1470
+ <Label htmlFor="entry-dialog-duration" className="flex items-center gap-2">
1471
+ {t('staff.time_tracking.entryDialog.duration', 'Duration')}
1472
+ {interval.computed === 'duration' ? computedBadge('duration') : null}
1473
+ </Label>
1474
+ <DurationInput
1475
+ key={`entry-dialog-duration-${interval.durationEpoch}`}
1476
+ id="entry-dialog-duration"
1477
+ value={durationMinutes}
1478
+ disabled={locked}
1479
+ error={fieldIssues.duration ?? undefined}
1480
+ ariaLabel={t('staff.time_tracking.entryDialog.duration', 'Duration')}
1481
+ onChange={(minutes, state) =>
1482
+ setIntervalState((current) =>
1483
+ reduceIntervalState(current, {
1484
+ field: 'duration',
1485
+ minutes,
1486
+ invalid: state.status === 'invalid',
1487
+ }),
1488
+ )
1489
+ }
1490
+ />
1491
+ </div>
1492
+ </div>
1493
+
1494
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1495
+ <div className="flex flex-col gap-1.5">
1496
+ <Label htmlFor="entry-dialog-start" className="flex items-center gap-2">
1497
+ {t('staff.time_tracking.entryDialog.start', 'Start')}
1498
+ {interval.computed === 'start' ? computedBadge('start') : null}
1499
+ </Label>
1500
+ <Input
1501
+ id="entry-dialog-start"
1502
+ value={startClock}
1503
+ disabled={locked}
1504
+ inputClassName="font-mono tabular-nums"
1505
+ placeholder="15:10"
1506
+ onChange={(event) =>
1507
+ setIntervalState((current) =>
1508
+ reduceIntervalState(current, { field: 'start', value: event.target.value }),
1509
+ )
1510
+ }
1511
+ data-testid="entry-dialog-start"
1512
+ />
1513
+ </div>
1514
+ <div className="flex flex-col gap-1.5">
1515
+ <Label htmlFor="entry-dialog-end" className="flex items-center gap-2">
1516
+ {t('staff.time_tracking.entryDialog.end', 'End')}
1517
+ {interval.computed === 'end' ? computedBadge('end') : null}
1518
+ </Label>
1519
+ <Input
1520
+ id="entry-dialog-end"
1521
+ value={endClock}
1522
+ disabled={locked}
1523
+ inputClassName="font-mono tabular-nums"
1524
+ placeholder="16:25"
1525
+ onChange={(event) =>
1526
+ setIntervalState((current) =>
1527
+ reduceIntervalState(current, { field: 'end', value: event.target.value }),
1528
+ )
1529
+ }
1530
+ data-testid="entry-dialog-end"
1531
+ />
1532
+ <p className="text-xs text-muted-foreground">
1533
+ {t(
1534
+ 'staff.time_tracking.entryDialog.endHint',
1535
+ 'Change this field and the duration recalculates itself.',
1536
+ )}
1537
+ </p>
1538
+ </div>
1539
+ </div>
1540
+
1541
+ {midnightEndDate ? (
1542
+ <p className="text-xs text-muted-foreground" data-testid="entry-dialog-midnight">
1543
+ {t('staff.time_tracking.entryDialog.midnight', 'This entry ends the next day, {date}.', {
1544
+ date: midnightEndDate,
1545
+ })}
1546
+ </p>
1547
+ ) : null}
1548
+
1549
+ {overlap ? (
1550
+ <Alert status="warning" data-testid="entry-dialog-overlap">
1551
+ <AlertTitle>
1552
+ {t('staff.time_tracking.entryDialog.overlap.title', 'This time overlaps another entry')}
1553
+ </AlertTitle>
1554
+ <AlertDescription>
1555
+ {t(
1556
+ 'staff.time_tracking.entryDialog.overlap.body',
1557
+ '{start} – {end} · „{task}” · {project} ({duration}). Overlapping is allowed — if it is intentional, just save.',
1558
+ {
1559
+ start: overlap.startText,
1560
+ end: overlap.endText,
1561
+ task:
1562
+ overlap.taskTitle ??
1563
+ overlap.description ??
1564
+ t('staff.time_tracking.entryDialog.overlap.noTask', 'no task'),
1565
+ project:
1566
+ overlap.projectName ??
1567
+ t('staff.time_tracking.entryDialog.overlap.noProject', 'no project'),
1568
+ duration: formatDuration(overlap.durationMinutes, 'clock'),
1569
+ },
1570
+ )}
1571
+ </AlertDescription>
1572
+ <div className="mt-2 flex flex-wrap gap-2">
1573
+ <Button
1574
+ type="button"
1575
+ variant="outline"
1576
+ size="sm"
1577
+ onClick={() => handleShowOverlap(overlap)}
1578
+ data-testid="entry-dialog-overlap-show"
1579
+ >
1580
+ {t('staff.time_tracking.entryDialog.overlap.show', 'Show that entry')}
1581
+ </Button>
1582
+ {overlap.endText ? (
1583
+ <Button
1584
+ type="button"
1585
+ variant="outline"
1586
+ size="sm"
1587
+ disabled={locked}
1588
+ onClick={() =>
1589
+ setIntervalState((current) =>
1590
+ reduceIntervalState(current, { field: 'snapStart', value: overlap.endText }),
1591
+ )
1592
+ }
1593
+ data-testid="entry-dialog-overlap-snap"
1594
+ >
1595
+ {t('staff.time_tracking.entryDialog.overlap.snap', 'Snap start to {time}', {
1596
+ time: overlap.endText,
1597
+ })}
1598
+ </Button>
1599
+ ) : null}
1600
+ </div>
1601
+ </Alert>
1602
+ ) : null}
1603
+
1604
+ <Separator />
1605
+
1606
+ <div className="flex items-center gap-3">
1607
+ <Switch
1608
+ checked={isBillable}
1609
+ disabled={locked}
1610
+ onCheckedChange={(next) => setIsBillable(next)}
1611
+ aria-label={t('staff.time_tracking.entryDialog.billable', 'Billable')}
1612
+ data-testid="entry-dialog-billable"
1613
+ />
1614
+ <div className="flex min-w-0 flex-1 flex-col">
1615
+ <span className="text-sm font-medium text-foreground">
1616
+ {t('staff.time_tracking.entryDialog.billable', 'Billable')}
1617
+ </span>
1618
+ <span className="text-xs text-muted-foreground">
1619
+ {t('staff.time_tracking.entryDialog.billableHint', 'On by default for this project.')}
1620
+ </span>
1621
+ </div>
1622
+ {canSeeMoney && rateLabel ? (
1623
+ <Badge variant="success" data-testid="entry-dialog-rate-badge">
1624
+ {t('staff.time_tracking.entryDialog.ratePerHour', '{amount}/h', { amount: rateLabel })}
1625
+ </Badge>
1626
+ ) : null}
1627
+ </div>
1628
+
1629
+ {canSeeMoney ? (
1630
+ <>
1631
+ <div className="flex items-center gap-3">
1632
+ <Switch
1633
+ checked={rateOverrideEnabled}
1634
+ disabled={locked}
1635
+ onCheckedChange={(next) => setRateOverrideEnabled(next)}
1636
+ aria-label={t('staff.time_tracking.entryDialog.rateOverride', 'Custom rate for this entry')}
1637
+ data-testid="entry-dialog-rate-override"
1638
+ />
1639
+ <div className="flex min-w-0 flex-1 flex-col">
1640
+ <span className="text-sm font-medium text-foreground">
1641
+ {t('staff.time_tracking.entryDialog.rateOverride', 'Custom rate for this entry')}
1642
+ </span>
1643
+ <span className="text-xs text-muted-foreground">
1644
+ {t(
1645
+ 'staff.time_tracking.entryDialog.rateOverrideHint',
1646
+ 'Overrides the project rate here only.',
1647
+ )}
1648
+ </span>
1649
+ </div>
1650
+ </div>
1651
+
1652
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1653
+ {rateOverrideEnabled ? (
1654
+ <div className="flex flex-col gap-1.5">
1655
+ <Label htmlFor="entry-dialog-rate">
1656
+ {t('staff.time_tracking.entryDialog.rate', 'Rate')}
1657
+ </Label>
1658
+ <Input
1659
+ id="entry-dialog-rate"
1660
+ value={rateText}
1661
+ disabled={locked}
1662
+ inputMode="decimal"
1663
+ onChange={(event) => setRateText(event.target.value)}
1664
+ data-testid="entry-dialog-rate"
1665
+ />
1666
+ {projectRateLabel ? (
1667
+ <p className="text-xs text-muted-foreground">
1668
+ {t(
1669
+ 'staff.time_tracking.entryDialog.rateHint',
1670
+ 'Defaults to {amount} (the project rate).',
1671
+ { amount: projectRateLabel },
1672
+ )}
1673
+ </p>
1674
+ ) : null}
1675
+ </div>
1676
+ ) : null}
1677
+ <div className="flex flex-col gap-1.5">
1678
+ <Label htmlFor="entry-dialog-cost" className="flex items-center gap-2">
1679
+ {t('staff.time_tracking.entryDialog.cost', 'Cost')}
1680
+ {computedBadge('cost')}
1681
+ </Label>
1682
+ <Input
1683
+ id="entry-dialog-cost"
1684
+ readOnly
1685
+ disabled
1686
+ value={cost === null ? '' : formatCurrency(cost, currencyCode) ?? String(cost)}
1687
+ inputClassName="font-mono tabular-nums"
1688
+ data-testid="entry-dialog-cost"
1689
+ />
1690
+ {rateLabel && durationMinutes ? (
1691
+ <p className="text-xs text-muted-foreground" data-testid="entry-dialog-cost-hint">
1692
+ {t(
1693
+ 'staff.time_tracking.entryDialog.costHint',
1694
+ '{raw} rounded to {rounded} × {rate}',
1695
+ {
1696
+ raw: formatDuration(durationMinutes, 'clock'),
1697
+ rounded: formatDuration(roundedMinutes, 'clock'),
1698
+ rate: rateLabel,
1699
+ },
1700
+ )}
1701
+ </p>
1702
+ ) : null}
1703
+ </div>
1704
+ </div>
1705
+ </>
1706
+ ) : (
1707
+ <p className="text-xs text-muted-foreground" data-testid="entry-dialog-money-hidden">
1708
+ {t(
1709
+ 'staff.time_tracking.entryDialog.moneyHidden',
1710
+ 'Rates and cost are hidden — your permissions do not cover money on time entries.',
1711
+ )}
1712
+ </p>
1713
+ )}
1714
+
1715
+ <div className="flex flex-col gap-1.5">
1716
+ <span className="text-xs font-medium text-muted-foreground">
1717
+ {t('staff.time_tracking.entryDialog.tags', 'Tags')}
1718
+ </span>
1719
+ <div className="flex flex-wrap items-center gap-2">
1720
+ {assignedTags.map((tag) => (
1721
+ <Badge
1722
+ key={tag.id}
1723
+ variant="neutral"
1724
+ className="gap-1"
1725
+ style={tagChipStyle(tag)}
1726
+ >
1727
+ {tag.label}
1728
+ {locked ? null : (
1729
+ <button
1730
+ type="button"
1731
+ aria-label={t('staff.time_tracking.entryDialog.removeTag', 'Remove tag {label}', {
1732
+ label: tag.label,
1733
+ })}
1734
+ onClick={() => setTagIds((current) => current.filter((id) => id !== tag.id))}
1735
+ >
1736
+ <X className="size-3" aria-hidden="true" />
1737
+ </button>
1738
+ )}
1739
+ </Badge>
1740
+ ))}
1741
+ {!locked ? (
1742
+ <TagPicker
1743
+ available={availableTags}
1744
+ onAssign={(id) => setTagIds((current) => (current.includes(id) ? current : [...current, id]))}
1745
+ onCreate={createTag}
1746
+ labels={{
1747
+ add: t('staff.time_tracking.entryDialog.addTag', 'Add tag'),
1748
+ create: (name) =>
1749
+ t('staff.time_tracking.entryDialog.createTag', 'Create "{name}"', { name }),
1750
+ empty: t('staff.time_tracking.entryDialog.noTags', 'No matching tag'),
1751
+ }}
1752
+ />
1753
+ ) : null}
1754
+ </div>
1755
+ </div>
1756
+
1757
+ <InjectionSpot
1758
+ spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'fields')}
1759
+ context={entryInjectionContext}
1760
+ data={entryFormValues}
1761
+ />
1762
+
1763
+ <Alert status="information" data-testid="entry-dialog-creator">
1764
+ <AlertDescription>
1765
+ {selfQuery.data?.displayName
1766
+ ? t(
1767
+ 'staff.time_tracking.entryDialog.creator',
1768
+ 'Entry author: {name} — assigned automatically and not editable.',
1769
+ { name: selfQuery.data.displayName },
1770
+ )
1771
+ : t(
1772
+ 'staff.time_tracking.entryDialog.creatorUnknown',
1773
+ 'The entry author is assigned automatically and is not editable.',
1774
+ )}
1775
+ </AlertDescription>
1776
+ </Alert>
1777
+
1778
+ <InjectionSpot
1779
+ spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'after-fields')}
1780
+ context={entryInjectionContext}
1781
+ data={entryFormValues}
1782
+ />
1783
+ </div>
1784
+ )
1785
+ })()
1786
+
1787
+ return (
1788
+ <Dialog
1789
+ open={open}
1790
+ // Escape, the ×, and a click on the overlay all arrive here, which is why
1791
+ // the unsaved-work guard sits on this one callback rather than on each of
1792
+ // them. Radix keeps the dialog mounted until `open` actually flips, so
1793
+ // declining the prompt simply leaves the form where it was.
1794
+ onOpenChange={(next) => {
1795
+ if (next) {
1796
+ onOpenChange(true)
1797
+ return
1798
+ }
1799
+ void requestClose()
1800
+ }}
1801
+ >
1802
+ <DialogContent
1803
+ size="lg"
1804
+ data-testid="entry-dialog"
1805
+ onOpenAutoFocus={(event) => {
1806
+ // A locked entry has nothing to type into, so the default focus (the
1807
+ // close button) is the right one there.
1808
+ if (locked || !taskTriggerRef.current) return
1809
+ event.preventDefault()
1810
+ focusTaskPicker()
1811
+ }}
1812
+ onKeyDown={(event) => {
1813
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
1814
+ event.preventDefault()
1815
+ // Shift keeps the dialog open for the next entry, so a run of entries
1816
+ // never needs the pointer.
1817
+ void submit(event.shiftKey ? 'again' : 'close')
1818
+ }
1819
+ // Escape is deliberately not handled here. Radix's `DismissableLayer`
1820
+ // already dismisses only the topmost layer, so a picker popover open
1821
+ // inside the form consumes the key on its own; a handler here closed
1822
+ // the whole dialog regardless of what was open on top of it.
1823
+ }}
1824
+ >
1825
+ <DialogHeader>
1826
+ <DialogTitle>
1827
+ {isEdit
1828
+ ? t('staff.time_tracking.entryDialog.titleEdit', 'Time entry')
1829
+ : t('staff.time_tracking.entryDialog.titleCreate', 'New time entry')}
1830
+ </DialogTitle>
1831
+ {headerHint ? <span className="text-xs text-muted-foreground">{headerHint}</span> : null}
1832
+ </DialogHeader>
1833
+
1834
+ {body}
1835
+
1836
+ <DialogFooter>
1837
+ {/*
1838
+ * The labels are flex items, so without `whitespace-nowrap` "save and
1839
+ * add another" shrinks into a four-line column rather than staying on
1840
+ * the line. Hidden below `sm` as well: on a narrow dialog the hint
1841
+ * competes with the buttons for room it does not deserve, and a
1842
+ * keyboard hint is useless on a touch device anyway.
1843
+ */}
1844
+ <span className="mr-auto hidden items-center gap-1 whitespace-nowrap text-xs text-muted-foreground sm:flex">
1845
+ <Kbd>⌘</Kbd>
1846
+ <Kbd>↵</Kbd>
1847
+ {t('staff.time_tracking.entryDialog.shortcutSave', 'save')}
1848
+ {locked ? null : (
1849
+ <>
1850
+ <span aria-hidden="true">·</span>
1851
+ <Kbd>⌘</Kbd>
1852
+ <Kbd>⇧</Kbd>
1853
+ <Kbd>↵</Kbd>
1854
+ {t('staff.time_tracking.entryDialog.shortcutSaveAgain', 'save and add another')}
1855
+ </>
1856
+ )}
1857
+ <span aria-hidden="true">·</span>
1858
+ <Kbd>esc</Kbd>
1859
+ {t('staff.time_tracking.entryDialog.shortcutCancel', 'cancel')}
1860
+ </span>
1861
+ <InjectionSpot
1862
+ spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'footer')}
1863
+ context={entryInjectionContext}
1864
+ data={entryFormValues}
1865
+ />
1866
+ <Button type="button" variant="ghost" onClick={() => { void requestClose() }}>
1867
+ {locked
1868
+ ? t('staff.time_tracking.entryDialog.close', 'Close')
1869
+ : t('staff.time_tracking.entryDialog.cancel', 'Cancel')}
1870
+ </Button>
1871
+ {locked ? null : (
1872
+ <>
1873
+ <Button
1874
+ type="button"
1875
+ variant="outline"
1876
+ disabled={!canSubmit}
1877
+ onClick={() => void submit('again')}
1878
+ data-testid="entry-dialog-save-again"
1879
+ >
1880
+ {savingMode === 'again' ? <Spinner className="size-4" /> : null}
1881
+ {t('staff.time_tracking.entryDialog.saveAndNew', 'Save and add another')}
1882
+ </Button>
1883
+ <Button
1884
+ type="button"
1885
+ disabled={!canSubmit}
1886
+ onClick={() => void submit('close')}
1887
+ data-testid="entry-dialog-save"
1888
+ >
1889
+ {savingMode === 'close' ? <Spinner className="size-4" /> : null}
1890
+ {t('staff.time_tracking.entryDialog.save', 'Save')}
1891
+ </Button>
1892
+ </>
1893
+ )}
1894
+ </DialogFooter>
1895
+ {ConfirmDialogElement}
1896
+ </DialogContent>
1897
+ </Dialog>
1898
+ )
1899
+ }
1900
+
1901
+ const timeEntryDialogPropsSchema: z.ZodType<TimeEntryDialogProps> = z.object({
1902
+ open: z.boolean(),
1903
+ onOpenChange: callbackProp<(open: boolean) => void>(),
1904
+ entryId: z.string().nullable().optional(),
1905
+ defaults: opaqueProp<TimeEntryDialogDefaults>().optional(),
1906
+ headerHint: z.string().nullable().optional(),
1907
+ onSaved: optionalCallbackProp<(result: { id: string | null; keptOpen: boolean }) => void>(),
1908
+ onShowEntry: optionalCallbackProp<(entryId: string) => void>(),
1909
+ })
1910
+
1911
+ registerComponent<TimeEntryDialogProps>({
1912
+ id: extensionPoints.hosts.timeEntryDialogComponent.componentId,
1913
+ component: DefaultTimeEntryDialog,
1914
+ metadata: {
1915
+ module: 'staff',
1916
+ description: 'Create/edit dialog for a single time entry.',
1917
+ propsSchema: timeEntryDialogPropsSchema,
1918
+ },
1919
+ })
1920
+
1921
+ export function TimeEntryDialog(props: TimeEntryDialogProps) {
1922
+ const Resolved = useRegisteredComponent<TimeEntryDialogProps>(
1923
+ extensionPoints.hosts.timeEntryDialogComponent.componentId,
1924
+ DefaultTimeEntryDialog,
1925
+ )
1926
+ return <Resolved {...props} />
1927
+ }
1928
+
1929
+ export default TimeEntryDialog