@open-mercato/core 0.10.1-develop.7717.1.2befe9c634 → 0.10.1-develop.7721.1.b5c4a00a68

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 (384) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/staff_support_routing_rule/index.js +21 -0
  3. package/dist/generated/entities/staff_support_routing_rule/index.js.map +7 -0
  4. package/dist/generated/entities/staff_support_ticket/index.js +37 -0
  5. package/dist/generated/entities/staff_support_ticket/index.js.map +7 -0
  6. package/dist/generated/entities/staff_time_project/index.js +2 -0
  7. package/dist/generated/entities/staff_time_project/index.js.map +2 -2
  8. package/dist/generated/entities/staff_time_project_member/index.js +2 -0
  9. package/dist/generated/entities/staff_time_project_member/index.js.map +2 -2
  10. package/dist/generated/entities/staff_time_task/index.js +4 -0
  11. package/dist/generated/entities/staff_time_task/index.js.map +2 -2
  12. package/dist/generated/entities/staff_time_task_comment/index.js +9 -1
  13. package/dist/generated/entities/staff_time_task_comment/index.js.map +2 -2
  14. package/dist/generated/entities.ids.generated.js +2 -0
  15. package/dist/generated/entities.ids.generated.js.map +2 -2
  16. package/dist/generated/entity-fields-registry.js +38 -0
  17. package/dist/generated/entity-fields-registry.js.map +2 -2
  18. package/dist/helpers/integration/auth.js +1 -0
  19. package/dist/helpers/integration/auth.js.map +2 -2
  20. package/dist/helpers/integration/communicationChannelsFixtures.js +20 -0
  21. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  22. package/dist/modules/catalog/api/bulk-delete/route.js +12 -2
  23. package/dist/modules/catalog/api/bulk-delete/route.js.map +2 -2
  24. package/dist/modules/communication_channels/api/post/test-seed/route.js +17 -3
  25. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/test-seed.js +38 -5
  27. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  28. package/dist/modules/messages/commands/messages.js +2 -3
  29. package/dist/modules/messages/commands/messages.js.map +2 -2
  30. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +2 -2
  31. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  32. package/dist/modules/staff/api/portal/support-tickets/[id]/attachments/route.js +299 -0
  33. package/dist/modules/staff/api/portal/support-tickets/[id]/attachments/route.js.map +7 -0
  34. package/dist/modules/staff/api/portal/support-tickets/[id]/messages/route.js +91 -0
  35. package/dist/modules/staff/api/portal/support-tickets/[id]/messages/route.js.map +7 -0
  36. package/dist/modules/staff/api/portal/support-tickets/[id]/route.js +146 -0
  37. package/dist/modules/staff/api/portal/support-tickets/[id]/route.js.map +7 -0
  38. package/dist/modules/staff/api/portal/support-tickets/options/route.js +60 -0
  39. package/dist/modules/staff/api/portal/support-tickets/options/route.js.map +7 -0
  40. package/dist/modules/staff/api/portal/support-tickets/route.js +220 -0
  41. package/dist/modules/staff/api/portal/support-tickets/route.js.map +7 -0
  42. package/dist/modules/staff/api/timesheets/settings/route.js +7 -1
  43. package/dist/modules/staff/api/timesheets/settings/route.js.map +2 -2
  44. package/dist/modules/staff/api/timesheets/support-routing-rules/fallback-project/route.js +122 -0
  45. package/dist/modules/staff/api/timesheets/support-routing-rules/fallback-project/route.js.map +7 -0
  46. package/dist/modules/staff/api/timesheets/support-routing-rules/preview/route.js +180 -0
  47. package/dist/modules/staff/api/timesheets/support-routing-rules/preview/route.js.map +7 -0
  48. package/dist/modules/staff/api/timesheets/support-routing-rules/route.js +147 -0
  49. package/dist/modules/staff/api/timesheets/support-routing-rules/route.js.map +7 -0
  50. package/dist/modules/staff/api/timesheets/support-tickets/options/route.js +133 -0
  51. package/dist/modules/staff/api/timesheets/support-tickets/options/route.js.map +7 -0
  52. package/dist/modules/staff/api/timesheets/tasks/[id]/claim/route.js +33 -0
  53. package/dist/modules/staff/api/timesheets/tasks/[id]/claim/route.js.map +7 -0
  54. package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js +25 -7
  55. package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js.map +2 -2
  56. package/dist/modules/staff/api/timesheets/tasks/[id]/release/route.js +32 -0
  57. package/dist/modules/staff/api/timesheets/tasks/[id]/release/route.js.map +7 -0
  58. package/dist/modules/staff/api/timesheets/tasks/route.js +94 -47
  59. package/dist/modules/staff/api/timesheets/tasks/route.js.map +2 -2
  60. package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js +3 -0
  61. package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js.map +2 -2
  62. package/dist/modules/staff/api/timesheets/time-projects/route.js +3 -0
  63. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  64. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +1 -0
  65. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  66. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +54 -4
  67. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  68. package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js +21 -5
  69. package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js.map +2 -2
  70. package/dist/modules/staff/backend/staff/time-tracking/settings/page.js +319 -254
  71. package/dist/modules/staff/backend/staff/time-tracking/settings/page.js.map +2 -2
  72. package/dist/modules/staff/backend/staff/time-tracking/support/page.js +363 -0
  73. package/dist/modules/staff/backend/staff/time-tracking/support/page.js.map +7 -0
  74. package/dist/modules/staff/backend/staff/time-tracking/support/page.meta.js +18 -0
  75. package/dist/modules/staff/backend/staff/time-tracking/support/page.meta.js.map +7 -0
  76. package/dist/modules/staff/cli.js +27 -0
  77. package/dist/modules/staff/cli.js.map +2 -2
  78. package/dist/modules/staff/commands/index.js +3 -0
  79. package/dist/modules/staff/commands/index.js.map +2 -2
  80. package/dist/modules/staff/commands/shared.js +10 -1
  81. package/dist/modules/staff/commands/shared.js.map +2 -2
  82. package/dist/modules/staff/commands/support-routing-rules.js +505 -0
  83. package/dist/modules/staff/commands/support-routing-rules.js.map +7 -0
  84. package/dist/modules/staff/commands/support-tickets.js +513 -0
  85. package/dist/modules/staff/commands/support-tickets.js.map +7 -0
  86. package/dist/modules/staff/commands/timesheets-projects.js +72 -14
  87. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  88. package/dist/modules/staff/commands/timesheets-task-assignment.js +270 -0
  89. package/dist/modules/staff/commands/timesheets-task-assignment.js.map +7 -0
  90. package/dist/modules/staff/commands/timesheets-task-comments.js +58 -12
  91. package/dist/modules/staff/commands/timesheets-task-comments.js.map +2 -2
  92. package/dist/modules/staff/commands/timesheets-tasks.js +91 -9
  93. package/dist/modules/staff/commands/timesheets-tasks.js.map +3 -3
  94. package/dist/modules/staff/data/enrichers.js +141 -0
  95. package/dist/modules/staff/data/enrichers.js.map +2 -2
  96. package/dist/modules/staff/data/entities.js +143 -2
  97. package/dist/modules/staff/data/entities.js.map +2 -2
  98. package/dist/modules/staff/data/extensions.js +8 -0
  99. package/dist/modules/staff/data/extensions.js.map +2 -2
  100. package/dist/modules/staff/data/validators.js +34 -5
  101. package/dist/modules/staff/data/validators.js.map +2 -2
  102. package/dist/modules/staff/encryption.js +6 -0
  103. package/dist/modules/staff/encryption.js.map +2 -2
  104. package/dist/modules/staff/events.js +25 -0
  105. package/dist/modules/staff/events.js.map +2 -2
  106. package/dist/modules/staff/events.payloads.js +54 -0
  107. package/dist/modules/staff/events.payloads.js.map +2 -2
  108. package/dist/modules/staff/extension-points.js +1 -0
  109. package/dist/modules/staff/extension-points.js.map +2 -2
  110. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.js +468 -0
  111. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.js.map +7 -0
  112. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.meta.js +12 -0
  113. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.meta.js.map +7 -0
  114. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.js +316 -0
  115. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.js.map +7 -0
  116. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.meta.js +12 -0
  117. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.meta.js.map +7 -0
  118. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.js +190 -0
  119. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.js.map +7 -0
  120. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.meta.js +19 -0
  121. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.meta.js.map +7 -0
  122. package/dist/modules/staff/index.js +1 -1
  123. package/dist/modules/staff/index.js.map +2 -2
  124. package/dist/modules/staff/lib/crud.js +2 -0
  125. package/dist/modules/staff/lib/crud.js.map +2 -2
  126. package/dist/modules/staff/lib/support/attachments.js +36 -0
  127. package/dist/modules/staff/lib/support/attachments.js.map +7 -0
  128. package/dist/modules/staff/lib/support/dictionaries.js +87 -0
  129. package/dist/modules/staff/lib/support/dictionaries.js.map +7 -0
  130. package/dist/modules/staff/lib/support/notifications.js +111 -0
  131. package/dist/modules/staff/lib/support/notifications.js.map +7 -0
  132. package/dist/modules/staff/lib/support/portalAttachments.js +100 -0
  133. package/dist/modules/staff/lib/support/portalAttachments.js.map +7 -0
  134. package/dist/modules/staff/lib/support/portalBroadcast.js +117 -0
  135. package/dist/modules/staff/lib/support/portalBroadcast.js.map +7 -0
  136. package/dist/modules/staff/lib/support/portalRoute.js +99 -0
  137. package/dist/modules/staff/lib/support/portalRoute.js.map +7 -0
  138. package/dist/modules/staff/lib/support/portalStatus.js +12 -0
  139. package/dist/modules/staff/lib/support/portalStatus.js.map +7 -0
  140. package/dist/modules/staff/lib/support/portalTickets.js +148 -0
  141. package/dist/modules/staff/lib/support/portalTickets.js.map +7 -0
  142. package/dist/modules/staff/lib/support/routing.js +81 -0
  143. package/dist/modules/staff/lib/support/routing.js.map +7 -0
  144. package/dist/modules/staff/lib/support/routingRuleDraft.js +25 -0
  145. package/dist/modules/staff/lib/support/routingRuleDraft.js.map +7 -0
  146. package/dist/modules/staff/lib/support/seedDefaults.js +114 -0
  147. package/dist/modules/staff/lib/support/seedDefaults.js.map +7 -0
  148. package/dist/modules/staff/lib/support/supportActivity.js +26 -0
  149. package/dist/modules/staff/lib/support/supportActivity.js.map +7 -0
  150. package/dist/modules/staff/lib/support-ui/PortalSupportNotLinked.js +23 -0
  151. package/dist/modules/staff/lib/support-ui/PortalSupportNotLinked.js.map +7 -0
  152. package/dist/modules/staff/lib/support-ui/portalAttachmentValidation.js +112 -0
  153. package/dist/modules/staff/lib/support-ui/portalAttachmentValidation.js.map +7 -0
  154. package/dist/modules/staff/lib/support-ui/portalPendingUploads.js +45 -0
  155. package/dist/modules/staff/lib/support-ui/portalPendingUploads.js.map +7 -0
  156. package/dist/modules/staff/lib/support-ui/portalSupport.js +51 -0
  157. package/dist/modules/staff/lib/support-ui/portalSupport.js.map +7 -0
  158. package/dist/modules/staff/lib/time-tracking/access.js +3 -39
  159. package/dist/modules/staff/lib/time-tracking/access.js.map +2 -2
  160. package/dist/modules/staff/lib/time-tracking/listProjectAccess.js +54 -0
  161. package/dist/modules/staff/lib/time-tracking/listProjectAccess.js.map +7 -0
  162. package/dist/modules/staff/lib/time-tracking/portalRecipients.js +92 -2
  163. package/dist/modules/staff/lib/time-tracking/portalRecipients.js.map +2 -2
  164. package/dist/modules/staff/lib/time-tracking/projectAssignment.js +112 -0
  165. package/dist/modules/staff/lib/time-tracking/projectAssignment.js.map +7 -0
  166. package/dist/modules/staff/lib/time-tracking/settingKeys.js +14 -0
  167. package/dist/modules/staff/lib/time-tracking/settingKeys.js.map +2 -2
  168. package/dist/modules/staff/lib/time-tracking/settings.js +28 -1
  169. package/dist/modules/staff/lib/time-tracking/settings.js.map +2 -2
  170. package/dist/modules/staff/lib/time-tracking/taskAssignmentRoute.js +143 -0
  171. package/dist/modules/staff/lib/time-tracking/taskAssignmentRoute.js.map +7 -0
  172. package/dist/modules/staff/lib/time-tracking/workRecipients.js +92 -0
  173. package/dist/modules/staff/lib/time-tracking/workRecipients.js.map +7 -0
  174. package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js +32 -3
  175. package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js.map +2 -2
  176. package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js +29 -0
  177. package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js.map +2 -2
  178. package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js +4 -0
  179. package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js.map +2 -2
  180. package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js +141 -1
  181. package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js.map +2 -2
  182. package/dist/modules/staff/lib/time-tracking-ui/SupportAttachmentsSection.js +153 -0
  183. package/dist/modules/staff/lib/time-tracking-ui/SupportAttachmentsSection.js.map +7 -0
  184. package/dist/modules/staff/lib/time-tracking-ui/SupportRequestPanel.js +67 -0
  185. package/dist/modules/staff/lib/time-tracking-ui/SupportRequestPanel.js.map +7 -0
  186. package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js +3 -1
  187. package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js.map +2 -2
  188. package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js +80 -17
  189. package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js.map +2 -2
  190. package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js +230 -41
  191. package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js.map +2 -2
  192. package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js +21 -1
  193. package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js.map +2 -2
  194. package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js +16 -4
  195. package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js.map +2 -2
  196. package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js +3 -1
  197. package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js.map +2 -2
  198. package/dist/modules/staff/lib/time-tracking-ui/projectDefaultAssignee.js +68 -0
  199. package/dist/modules/staff/lib/time-tracking-ui/projectDefaultAssignee.js.map +7 -0
  200. package/dist/modules/staff/lib/time-tracking-ui/settingsTabs.js +7 -0
  201. package/dist/modules/staff/lib/time-tracking-ui/settingsTabs.js.map +7 -0
  202. package/dist/modules/staff/lib/time-tracking-ui/supportTicketData.js +50 -0
  203. package/dist/modules/staff/lib/time-tracking-ui/supportTicketData.js.map +7 -0
  204. package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js +13 -1
  205. package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js.map +2 -2
  206. package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js +36 -2
  207. package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js.map +2 -2
  208. package/dist/modules/staff/migrations/Migration20261009180543_staff.js +42 -0
  209. package/dist/modules/staff/migrations/Migration20261009180543_staff.js.map +7 -0
  210. package/dist/modules/staff/notifications.js +92 -0
  211. package/dist/modules/staff/notifications.js.map +2 -2
  212. package/dist/modules/staff/setup.js +6 -2
  213. package/dist/modules/staff/setup.js.map +2 -2
  214. package/dist/modules/staff/subscribers/support-portal-comment-created.js +26 -0
  215. package/dist/modules/staff/subscribers/support-portal-comment-created.js.map +7 -0
  216. package/dist/modules/staff/subscribers/support-portal-comment-deleted.js +26 -0
  217. package/dist/modules/staff/subscribers/support-portal-comment-deleted.js.map +7 -0
  218. package/dist/modules/staff/subscribers/support-portal-comment-updated.js +26 -0
  219. package/dist/modules/staff/subscribers/support-portal-comment-updated.js.map +7 -0
  220. package/dist/modules/staff/subscribers/support-portal-status-column-updated.js +24 -0
  221. package/dist/modules/staff/subscribers/support-portal-status-column-updated.js.map +7 -0
  222. package/dist/modules/staff/subscribers/support-portal-task-status-changed.js +25 -0
  223. package/dist/modules/staff/subscribers/support-portal-task-status-changed.js.map +7 -0
  224. package/dist/modules/staff/subscribers/support-portal-task-updated.js +25 -0
  225. package/dist/modules/staff/subscribers/support-portal-task-updated.js.map +7 -0
  226. package/dist/modules/staff/subscribers/support-ticket-created-notification.js +25 -0
  227. package/dist/modules/staff/subscribers/support-ticket-created-notification.js.map +7 -0
  228. package/dist/modules/staff/subscribers/support-ticket-customer-replied-notification.js +25 -0
  229. package/dist/modules/staff/subscribers/support-ticket-customer-replied-notification.js.map +7 -0
  230. package/dist/modules/staff/subscribers/support-ticket-unnotified-clear.js +26 -0
  231. package/dist/modules/staff/subscribers/support-ticket-unnotified-clear.js.map +7 -0
  232. package/dist/modules/staff/subscribers/time-task-assignee-notification.js +83 -0
  233. package/dist/modules/staff/subscribers/time-task-assignee-notification.js.map +7 -0
  234. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.client.js +112 -0
  235. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.client.js.map +7 -0
  236. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.js +17 -0
  237. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.js.map +7 -0
  238. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.client.js +870 -0
  239. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.client.js.map +7 -0
  240. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.js +17 -0
  241. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.js.map +7 -0
  242. package/dist/modules/staff/widgets/injection-table.js +16 -1
  243. package/dist/modules/staff/widgets/injection-table.js.map +2 -2
  244. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js +4 -18
  245. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js.map +2 -2
  246. package/dist/modules/wms/components/backend/WmsLotDetailPage.js +4 -18
  247. package/dist/modules/wms/components/backend/WmsLotDetailPage.js.map +2 -2
  248. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js +4 -18
  249. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js.map +2 -2
  250. package/dist/modules/wms/lib/inventoryCsvExport.js +19 -0
  251. package/dist/modules/wms/lib/inventoryCsvExport.js.map +7 -0
  252. package/generated/entities/staff_support_routing_rule/index.ts +9 -0
  253. package/generated/entities/staff_support_ticket/index.ts +17 -0
  254. package/generated/entities/staff_time_project/index.ts +1 -0
  255. package/generated/entities/staff_time_project_member/index.ts +1 -0
  256. package/generated/entities/staff_time_task/index.ts +2 -0
  257. package/generated/entities/staff_time_task_comment/index.ts +4 -0
  258. package/generated/entities.ids.generated.ts +2 -0
  259. package/generated/entity-fields-registry.ts +38 -0
  260. package/package.json +8 -8
  261. package/src/helpers/integration/auth.ts +1 -1
  262. package/src/helpers/integration/communicationChannelsFixtures.ts +60 -2
  263. package/src/modules/catalog/api/bulk-delete/route.ts +12 -2
  264. package/src/modules/communication_channels/api/post/test-seed/route.ts +29 -12
  265. package/src/modules/communication_channels/lib/test-seed.ts +49 -10
  266. package/src/modules/messages/commands/messages.ts +8 -10
  267. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +2 -2
  268. package/src/modules/staff/AGENTS.md +22 -6
  269. package/src/modules/staff/api/portal/support-tickets/[id]/attachments/route.ts +327 -0
  270. package/src/modules/staff/api/portal/support-tickets/[id]/messages/route.ts +94 -0
  271. package/src/modules/staff/api/portal/support-tickets/[id]/route.ts +160 -0
  272. package/src/modules/staff/api/portal/support-tickets/options/route.ts +60 -0
  273. package/src/modules/staff/api/portal/support-tickets/route.ts +253 -0
  274. package/src/modules/staff/api/timesheets/settings/route.ts +7 -1
  275. package/src/modules/staff/api/timesheets/support-routing-rules/fallback-project/route.ts +131 -0
  276. package/src/modules/staff/api/timesheets/support-routing-rules/preview/route.ts +248 -0
  277. package/src/modules/staff/api/timesheets/support-routing-rules/route.ts +150 -0
  278. package/src/modules/staff/api/timesheets/support-tickets/options/route.ts +181 -0
  279. package/src/modules/staff/api/timesheets/tasks/[id]/claim/route.ts +31 -0
  280. package/src/modules/staff/api/timesheets/tasks/[id]/comments/route.ts +25 -7
  281. package/src/modules/staff/api/timesheets/tasks/[id]/release/route.ts +30 -0
  282. package/src/modules/staff/api/timesheets/tasks/route.ts +119 -67
  283. package/src/modules/staff/api/timesheets/time-projects/[id]/employees/route.ts +3 -0
  284. package/src/modules/staff/api/timesheets/time-projects/route.ts +3 -0
  285. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +1 -0
  286. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +67 -2
  287. package/src/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.ts +22 -3
  288. package/src/modules/staff/backend/staff/time-tracking/settings/page.tsx +405 -308
  289. package/src/modules/staff/backend/staff/time-tracking/support/page.meta.ts +14 -0
  290. package/src/modules/staff/backend/staff/time-tracking/support/page.tsx +428 -0
  291. package/src/modules/staff/cli.ts +28 -0
  292. package/src/modules/staff/commands/index.ts +3 -0
  293. package/src/modules/staff/commands/shared.ts +22 -1
  294. package/src/modules/staff/commands/support-routing-rules.ts +595 -0
  295. package/src/modules/staff/commands/support-tickets.ts +580 -0
  296. package/src/modules/staff/commands/timesheets-projects.ts +84 -13
  297. package/src/modules/staff/commands/timesheets-task-assignment.ts +319 -0
  298. package/src/modules/staff/commands/timesheets-task-comments.ts +70 -15
  299. package/src/modules/staff/commands/timesheets-tasks.ts +106 -15
  300. package/src/modules/staff/data/enrichers.ts +203 -0
  301. package/src/modules/staff/data/entities.ts +133 -2
  302. package/src/modules/staff/data/extensions.ts +9 -1
  303. package/src/modules/staff/data/validators.ts +36 -0
  304. package/src/modules/staff/encryption.ts +6 -0
  305. package/src/modules/staff/events.payloads.ts +87 -1
  306. package/src/modules/staff/events.ts +26 -0
  307. package/src/modules/staff/extension-points.ts +1 -0
  308. package/src/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.meta.ts +10 -0
  309. package/src/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.tsx +553 -0
  310. package/src/modules/staff/frontend/[orgSlug]/portal/support/new/page.meta.ts +10 -0
  311. package/src/modules/staff/frontend/[orgSlug]/portal/support/new/page.tsx +346 -0
  312. package/src/modules/staff/frontend/[orgSlug]/portal/support/page.meta.ts +17 -0
  313. package/src/modules/staff/frontend/[orgSlug]/portal/support/page.tsx +253 -0
  314. package/src/modules/staff/i18n/de.json +291 -0
  315. package/src/modules/staff/i18n/en.json +291 -0
  316. package/src/modules/staff/i18n/es.json +291 -0
  317. package/src/modules/staff/i18n/ko.json +291 -0
  318. package/src/modules/staff/i18n/pl.json +291 -0
  319. package/src/modules/staff/index.ts +1 -1
  320. package/src/modules/staff/lib/crud.ts +2 -1
  321. package/src/modules/staff/lib/support/attachments.ts +43 -0
  322. package/src/modules/staff/lib/support/dictionaries.ts +120 -0
  323. package/src/modules/staff/lib/support/notifications.ts +138 -0
  324. package/src/modules/staff/lib/support/portalAttachments.ts +140 -0
  325. package/src/modules/staff/lib/support/portalBroadcast.ts +161 -0
  326. package/src/modules/staff/lib/support/portalRoute.ts +108 -0
  327. package/src/modules/staff/lib/support/portalStatus.ts +15 -0
  328. package/src/modules/staff/lib/support/portalTickets.ts +233 -0
  329. package/src/modules/staff/lib/support/routing.ts +117 -0
  330. package/src/modules/staff/lib/support/routingRuleDraft.ts +45 -0
  331. package/src/modules/staff/lib/support/seedDefaults.ts +151 -0
  332. package/src/modules/staff/lib/support/supportActivity.ts +39 -0
  333. package/src/modules/staff/lib/support-ui/PortalSupportNotLinked.tsx +20 -0
  334. package/src/modules/staff/lib/support-ui/portalAttachmentValidation.ts +125 -0
  335. package/src/modules/staff/lib/support-ui/portalPendingUploads.ts +49 -0
  336. package/src/modules/staff/lib/support-ui/portalSupport.ts +116 -0
  337. package/src/modules/staff/lib/time-tracking/access.ts +2 -62
  338. package/src/modules/staff/lib/time-tracking/listProjectAccess.ts +78 -0
  339. package/src/modules/staff/lib/time-tracking/portalRecipients.ts +173 -5
  340. package/src/modules/staff/lib/time-tracking/projectAssignment.ts +172 -0
  341. package/src/modules/staff/lib/time-tracking/settingKeys.ts +11 -0
  342. package/src/modules/staff/lib/time-tracking/settings.ts +46 -1
  343. package/src/modules/staff/lib/time-tracking/taskAssignmentRoute.ts +165 -0
  344. package/src/modules/staff/lib/time-tracking/workRecipients.ts +183 -0
  345. package/src/modules/staff/lib/time-tracking-ui/BoardFilterChips.tsx +36 -1
  346. package/src/modules/staff/lib/time-tracking-ui/KanbanBoard.tsx +30 -0
  347. package/src/modules/staff/lib/time-tracking-ui/KanbanCard.tsx +9 -0
  348. package/src/modules/staff/lib/time-tracking-ui/ProjectFormSections.tsx +185 -6
  349. package/src/modules/staff/lib/time-tracking-ui/SupportAttachmentsSection.tsx +188 -0
  350. package/src/modules/staff/lib/time-tracking-ui/SupportRequestPanel.tsx +102 -0
  351. package/src/modules/staff/lib/time-tracking-ui/TaskBoardScreen.tsx +2 -0
  352. package/src/modules/staff/lib/time-tracking-ui/TaskCommentThread.tsx +105 -22
  353. package/src/modules/staff/lib/time-tracking-ui/TaskDrawer.tsx +278 -43
  354. package/src/modules/staff/lib/time-tracking-ui/boardDirectory.ts +21 -0
  355. package/src/modules/staff/lib/time-tracking-ui/boardFilters.ts +16 -2
  356. package/src/modules/staff/lib/time-tracking-ui/kanbanBoardData.ts +4 -0
  357. package/src/modules/staff/lib/time-tracking-ui/projectDefaultAssignee.ts +117 -0
  358. package/src/modules/staff/lib/time-tracking-ui/settingsTabs.ts +17 -0
  359. package/src/modules/staff/lib/time-tracking-ui/supportTicketData.ts +71 -0
  360. package/src/modules/staff/lib/time-tracking-ui/taskDrawerData.ts +15 -0
  361. package/src/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.ts +48 -1
  362. package/src/modules/staff/migrations/.snapshot-open-mercato.json +714 -0
  363. package/src/modules/staff/migrations/Migration20261009180543_staff.ts +50 -0
  364. package/src/modules/staff/notifications.ts +92 -0
  365. package/src/modules/staff/setup.ts +6 -2
  366. package/src/modules/staff/subscribers/support-portal-comment-created.ts +35 -0
  367. package/src/modules/staff/subscribers/support-portal-comment-deleted.ts +35 -0
  368. package/src/modules/staff/subscribers/support-portal-comment-updated.ts +35 -0
  369. package/src/modules/staff/subscribers/support-portal-status-column-updated.ts +36 -0
  370. package/src/modules/staff/subscribers/support-portal-task-status-changed.ts +34 -0
  371. package/src/modules/staff/subscribers/support-portal-task-updated.ts +34 -0
  372. package/src/modules/staff/subscribers/support-ticket-created-notification.ts +27 -0
  373. package/src/modules/staff/subscribers/support-ticket-customer-replied-notification.ts +27 -0
  374. package/src/modules/staff/subscribers/support-ticket-unnotified-clear.ts +29 -0
  375. package/src/modules/staff/subscribers/time-task-assignee-notification.ts +102 -0
  376. package/src/modules/staff/widgets/injection/portal-support-tickets/widget.client.tsx +151 -0
  377. package/src/modules/staff/widgets/injection/portal-support-tickets/widget.ts +16 -0
  378. package/src/modules/staff/widgets/injection/time-tracking-settings-support/widget.client.tsx +1058 -0
  379. package/src/modules/staff/widgets/injection/time-tracking-settings-support/widget.ts +17 -0
  380. package/src/modules/staff/widgets/injection-table.ts +15 -0
  381. package/src/modules/wms/components/backend/WmsLocationDetailPage.tsx +4 -20
  382. package/src/modules/wms/components/backend/WmsLotDetailPage.tsx +4 -20
  383. package/src/modules/wms/components/backend/WmsSkuDetailPage.tsx +4 -19
  384. package/src/modules/wms/lib/inventoryCsvExport.ts +18 -0
@@ -0,0 +1,1058 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import { Pencil, Plus, Save, Trash2 } from 'lucide-react'
6
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
7
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
9
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
10
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { createCrud, deleteCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
12
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
13
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
14
+ import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
15
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
16
+ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
17
+ import { Button } from '@open-mercato/ui/primitives/button'
18
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
19
+ import { Input } from '@open-mercato/ui/primitives/input'
20
+ import { Label } from '@open-mercato/ui/primitives/label'
21
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
22
+ import { ComboboxInput, type ComboboxOption } from '@open-mercato/ui/backend/inputs/ComboboxInput'
23
+ import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider'
24
+ import { hasFeature } from '@open-mercato/shared/security/features'
25
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
26
+ import { Switch } from '@open-mercato/ui/primitives/switch'
27
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@open-mercato/ui/primitives/table'
28
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
29
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
30
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
31
+ import { ICON_SUGGESTIONS } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
32
+ import {
33
+ DictionaryForm,
34
+ type DictionaryFormValues,
35
+ } from '@open-mercato/core/modules/dictionaries/components/DictionaryForm'
36
+ import {
37
+ DictionaryTable,
38
+ type DictionaryTableEntry,
39
+ } from '@open-mercato/core/modules/dictionaries/components/DictionaryTable'
40
+ import type { TimeTrackingSettings } from '../../../lib/time-tracking/settings'
41
+ import { DEFAULT_SUPPORT_REFERENCE_PREFIX } from '../../../lib/time-tracking/settingKeys'
42
+ import type { TimeTrackingSettingsTabContext } from '../../../lib/time-tracking-ui/settingsTabs'
43
+ import {
44
+ buildRoutingRuleEditDraft,
45
+ rebaseRoutingRuleDialog,
46
+ routingRuleVersion,
47
+ type RoutingRuleDialogState,
48
+ } from '../../../lib/support/routingRuleDraft'
49
+
50
+ const CATEGORY_DICTIONARY_KEY = 'staff.support_ticket_category'
51
+ const ROUTING_API = 'staff/timesheets/support-routing-rules'
52
+ const PREVIEW_URL = '/api/staff/timesheets/support-routing-rules/preview'
53
+ const FALLBACK_PROJECT_URL = '/api/staff/timesheets/support-routing-rules/fallback-project'
54
+ const PROJECTS_URL = '/api/staff/timesheets/time-projects'
55
+ const SETTINGS_URL = '/api/staff/timesheets/settings'
56
+ const UNNOTIFIED_INBOX_URL = '/backend/staff/time-tracking/support?supportUnnotified=true'
57
+ const SAVE_CONTEXT_ID = 'staff.support.settings'
58
+ const DICTIONARIES_VIEW_FEATURE = 'dictionaries.view'
59
+ const DICTIONARIES_MANAGE_FEATURE = 'dictionaries.manage'
60
+ const PROJECT_SEARCH_PAGE_SIZE = 20
61
+ const PREFIX_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/
62
+
63
+ type RuleProblem = 'paused' | 'project_unusable' | 'category_missing'
64
+
65
+ type PreviewProject = {
66
+ id: string
67
+ name: string | null
68
+ code: string | null
69
+ status: string
70
+ usable: boolean
71
+ nobodyNotified?: boolean
72
+ }
73
+
74
+ type PreviewRule = {
75
+ id: string
76
+ categoryValue: string | null
77
+ isActive: boolean
78
+ isFallback: boolean
79
+ project: PreviewProject
80
+ problem: RuleProblem | null
81
+ updatedAt?: string | null
82
+ }
83
+
84
+ type PreviewCategory = {
85
+ value: string
86
+ label: string
87
+ route: { via: 'category' | 'fallback'; project: PreviewProject } | null
88
+ }
89
+
90
+ type RoutingPreview = {
91
+ fallback: { ruleId: string; project: PreviewProject; updatedAt?: string | null } | null
92
+ rules: PreviewRule[]
93
+ categories: PreviewCategory[]
94
+ unnotifiedTicketCount?: number
95
+ }
96
+
97
+ type ProjectOption = {
98
+ id: string
99
+ name: string
100
+ code: string | null
101
+ }
102
+
103
+ type RuleDialogState = RoutingRuleDialogState
104
+
105
+ type CategoryDialogState =
106
+ | { mode: 'create' }
107
+ | { mode: 'edit'; entry: DictionaryTableEntry }
108
+
109
+ const EMPTY_PREVIEW: RoutingPreview = { fallback: null, rules: [], categories: [] }
110
+
111
+ const DEFAULT_CATEGORY_VALUES: DictionaryFormValues = {
112
+ value: '',
113
+ label: '',
114
+ color: null,
115
+ icon: null,
116
+ }
117
+
118
+ function isRecord(value: unknown): value is Record<string, unknown> {
119
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
120
+ }
121
+
122
+ function toStringOrNull(value: unknown): string | null {
123
+ return typeof value === 'string' && value.length > 0 ? value : null
124
+ }
125
+
126
+ function normalizeEntry(item: unknown): DictionaryTableEntry | null {
127
+ if (!isRecord(item)) return null
128
+ const id = toStringOrNull(item.id)
129
+ const value = toStringOrNull(item.value)
130
+ if (!id || !value) return null
131
+ return {
132
+ id,
133
+ value,
134
+ label: toStringOrNull(item.label) ?? value,
135
+ color: toStringOrNull(item.color),
136
+ icon: toStringOrNull(item.icon),
137
+ organizationId: toStringOrNull(item.organizationId),
138
+ tenantId: toStringOrNull(item.tenantId),
139
+ isInherited: item.isInherited === true,
140
+ createdAt: toStringOrNull(item.createdAt),
141
+ updatedAt: toStringOrNull(item.updatedAt),
142
+ }
143
+ }
144
+
145
+ function normalizeProject(item: unknown): ProjectOption | null {
146
+ if (!isRecord(item)) return null
147
+ const id = toStringOrNull(item.id)
148
+ if (!id) return null
149
+ return { id, name: toStringOrNull(item.name) ?? id, code: toStringOrNull(item.code) }
150
+ }
151
+
152
+ function projectLabel(project: { name: string | null; code: string | null; id: string }): string {
153
+ const name = project.name ?? project.id
154
+ return project.code ? `${name} (${project.code})` : name
155
+ }
156
+
157
+ async function searchProjects(query?: string): Promise<ComboboxOption[]> {
158
+ const params = new URLSearchParams({
159
+ page: '1',
160
+ pageSize: String(PROJECT_SEARCH_PAGE_SIZE),
161
+ sortField: 'name',
162
+ sortDir: 'asc',
163
+ })
164
+ const term = query?.trim() ?? ''
165
+ if (term) params.set('q', term)
166
+ try {
167
+ const call = await apiCall<{ items?: unknown[] }>(`${PROJECTS_URL}?${params.toString()}`)
168
+ if (!call.ok) return []
169
+ return (call.result?.items ?? [])
170
+ .map(normalizeProject)
171
+ .filter((project): project is ProjectOption => project !== null)
172
+ .map((project) => ({ value: project.id, label: projectLabel(project) }))
173
+ } catch {
174
+ return []
175
+ }
176
+ }
177
+
178
+ type SupportProjectPickerProps = {
179
+ labelId: string
180
+ value: string
181
+ knownProjects: ProjectOption[]
182
+ placeholder: string
183
+ disabled?: boolean
184
+ testId: string
185
+ onChange: (projectId: string) => void
186
+ }
187
+
188
+ function SupportProjectPicker({ labelId, value, knownProjects, placeholder, disabled, testId, onChange }: SupportProjectPickerProps) {
189
+ const seedOptions = React.useMemo<ComboboxOption[]>(
190
+ () => knownProjects.map((project) => ({ value: project.id, label: projectLabel(project) })),
191
+ [knownProjects],
192
+ )
193
+ return (
194
+ <div role="group" aria-labelledby={labelId} data-testid={testId}>
195
+ <ComboboxInput
196
+ value={value}
197
+ onChange={(next) => {
198
+ if (next && next !== value) onChange(next)
199
+ }}
200
+ seedOptions={seedOptions}
201
+ loadSuggestions={searchProjects}
202
+ allowCustomValues={false}
203
+ placeholder={placeholder}
204
+ disabled={disabled}
205
+ />
206
+ </div>
207
+ )
208
+ }
209
+
210
+ export default function SupportSettingsTab({ context }: InjectionWidgetComponentProps<TimeTrackingSettingsTabContext>) {
211
+ const t = useT()
212
+ const locale = useLocale()
213
+ const unnotifiedTicketsLabel = React.useCallback(
214
+ (count: number): string => {
215
+ let category: string
216
+ try {
217
+ category = new Intl.PluralRules(locale).select(count)
218
+ } catch {
219
+ category = count === 1 ? 'one' : 'other'
220
+ }
221
+ if (category === 'one') return t('staff.support.settings.routing.unnotifiedTickets.one', '{count} ticket was not notified to anyone.', { count })
222
+ if (category === 'few') return t('staff.support.settings.routing.unnotifiedTickets.few', '{count} tickets were not notified to anyone.', { count })
223
+ if (category === 'many') return t('staff.support.settings.routing.unnotifiedTickets.many', '{count} tickets were not notified to anyone.', { count })
224
+ return t('staff.support.settings.routing.unnotifiedTickets.other', '{count} tickets were not notified to anyone.', { count })
225
+ },
226
+ [locale, t],
227
+ )
228
+ const scopeVersion = useOrganizationScopeVersion()
229
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
230
+ const { payload: chromePayload, isReady: chromeReady } = useBackendChrome()
231
+ const grantedFeatures = chromePayload?.grantedFeatures
232
+ const canManageSettings = context.canManage
233
+ const canViewCategories = hasFeature(grantedFeatures, DICTIONARIES_VIEW_FEATURE)
234
+ const canManageCategories = canManageSettings && hasFeature(grantedFeatures, DICTIONARIES_MANAGE_FEATURE)
235
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
236
+ formId: string
237
+ resourceKind: string
238
+ retryLastMutation: () => Promise<boolean>
239
+ }>({
240
+ contextId: SAVE_CONTEXT_ID,
241
+ blockedMessage: t('staff.support.settings.errors.saveBlocked', 'Save blocked by validation'),
242
+ })
243
+ const routingContext = React.useMemo(() => ({
244
+ formId: SAVE_CONTEXT_ID,
245
+ resourceKind: 'staff.support.routing_rule',
246
+ retryLastMutation,
247
+ }), [retryLastMutation])
248
+ const categoryContext = React.useMemo(() => ({
249
+ formId: SAVE_CONTEXT_ID,
250
+ resourceKind: 'staff.support.categories',
251
+ retryLastMutation,
252
+ }), [retryLastMutation])
253
+ const settingsContext = React.useMemo(() => ({
254
+ formId: SAVE_CONTEXT_ID,
255
+ resourceKind: 'staff.timesheets.settings',
256
+ retryLastMutation,
257
+ }), [retryLastMutation])
258
+
259
+ const [categoryEntries, setCategoryEntries] = React.useState<DictionaryTableEntry[]>([])
260
+ const [dictionaryId, setDictionaryId] = React.useState<string | null>(null)
261
+ const [categoriesLoading, setCategoriesLoading] = React.useState(true)
262
+ const [categoriesError, setCategoriesError] = React.useState<string | null>(null)
263
+ const [categoryDialog, setCategoryDialog] = React.useState<CategoryDialogState | null>(null)
264
+ const [categorySubmitting, setCategorySubmitting] = React.useState(false)
265
+
266
+ const [preview, setPreview] = React.useState<RoutingPreview>(EMPTY_PREVIEW)
267
+ const [routingLoading, setRoutingLoading] = React.useState(true)
268
+ const [routingError, setRoutingError] = React.useState<string | null>(null)
269
+ const [ruleDialog, setRuleDialog] = React.useState<RuleDialogState | null>(null)
270
+ const [ruleSubmitting, setRuleSubmitting] = React.useState(false)
271
+ const [creatingFallback, setCreatingFallback] = React.useState(false)
272
+
273
+ const { settings, onSettingsSaved } = context
274
+ const savedPrefix = settings.support?.referencePrefix ?? DEFAULT_SUPPORT_REFERENCE_PREFIX
275
+ const [prefix, setPrefix] = React.useState(savedPrefix)
276
+ const [prefixError, setPrefixError] = React.useState<string | null>(null)
277
+ const [prefixSaving, setPrefixSaving] = React.useState(false)
278
+
279
+ const loadCategories = React.useCallback(async () => {
280
+ if (!chromeReady) return
281
+ if (!canViewCategories) {
282
+ setCategoryEntries([])
283
+ setDictionaryId(null)
284
+ setCategoriesError(null)
285
+ setCategoriesLoading(false)
286
+ return
287
+ }
288
+ setCategoriesLoading(true)
289
+ setCategoriesError(null)
290
+ try {
291
+ const dictionaries = await readApiResultOrThrow<{ items?: Array<{ id?: string; key?: string }> }>(
292
+ '/api/dictionaries',
293
+ undefined,
294
+ { fallback: { items: [] }, errorMessage: t('staff.support.settings.errors.load', 'Failed to load support settings.') },
295
+ )
296
+ const dictionary = (dictionaries.items ?? []).find((item) => item.key === CATEGORY_DICTIONARY_KEY)
297
+ const id = dictionary?.id ?? null
298
+ setDictionaryId(id)
299
+ if (!id) {
300
+ setCategoryEntries([])
301
+ return
302
+ }
303
+ const entries = await readApiResultOrThrow<{ items?: unknown[] }>(
304
+ `/api/dictionaries/${encodeURIComponent(id)}/entries`,
305
+ undefined,
306
+ { fallback: { items: [] }, errorMessage: t('staff.support.settings.errors.load', 'Failed to load support settings.') },
307
+ )
308
+ setCategoryEntries(
309
+ (entries.items ?? []).map(normalizeEntry).filter((entry): entry is DictionaryTableEntry => entry !== null),
310
+ )
311
+ } catch (err) {
312
+ setCategoriesError(err instanceof Error ? err.message : t('staff.support.settings.errors.load', 'Failed to load support settings.'))
313
+ } finally {
314
+ setCategoriesLoading(false)
315
+ }
316
+ }, [canViewCategories, chromeReady, t])
317
+
318
+ const loadRouting = React.useCallback(async () => {
319
+ setRoutingLoading(true)
320
+ setRoutingError(null)
321
+ try {
322
+ const errorMessage = t('staff.support.settings.errors.load', 'Failed to load support settings.')
323
+ const previewResult =
324
+ (await readApiResultOrThrow<RoutingPreview>(PREVIEW_URL, undefined, { fallback: EMPTY_PREVIEW, errorMessage })) ??
325
+ EMPTY_PREVIEW
326
+ setPreview(previewResult)
327
+ setRuleDialog((current) => rebaseRoutingRuleDialog(current, previewResult))
328
+ } catch (err) {
329
+ setRoutingError(err instanceof Error ? err.message : t('staff.support.settings.errors.load', 'Failed to load support settings.'))
330
+ } finally {
331
+ setRoutingLoading(false)
332
+ }
333
+ }, [t])
334
+
335
+ React.useEffect(() => {
336
+ setPrefix(savedPrefix)
337
+ }, [savedPrefix])
338
+
339
+ React.useEffect(() => {
340
+ void loadCategories()
341
+ }, [loadCategories, scopeVersion])
342
+
343
+ React.useEffect(() => {
344
+ void loadRouting()
345
+ }, [loadRouting, scopeVersion])
346
+
347
+ const reloadAll = React.useCallback(async () => {
348
+ await Promise.all([loadCategories(), loadRouting()])
349
+ }, [loadCategories, loadRouting])
350
+
351
+ const surfaceMutationError = React.useCallback((err: unknown, fallbackKey: string, fallback: string, onRefresh?: () => void) => {
352
+ if (surfaceRecordConflict(err, t, onRefresh ? { onRefresh } : undefined)) return
353
+ flash(err instanceof Error ? err.message : t(fallbackKey, fallback), 'error')
354
+ }, [t])
355
+
356
+ const submitCategory = React.useCallback(async (values: DictionaryFormValues) => {
357
+ if (!categoryDialog || !dictionaryId || categorySubmitting) return
358
+ setCategorySubmitting(true)
359
+ try {
360
+ if (categoryDialog.mode === 'create') {
361
+ await runMutation({
362
+ operation: async () => {
363
+ const call = await apiCall(`/api/dictionaries/${encodeURIComponent(dictionaryId)}/entries`, {
364
+ method: 'POST',
365
+ headers: { 'content-type': 'application/json' },
366
+ body: JSON.stringify(values),
367
+ })
368
+ if (!call.ok) await raiseCrudError(call.response, t('staff.support.settings.errors.save', 'Failed to save.'))
369
+ return call
370
+ },
371
+ context: categoryContext,
372
+ mutationPayload: { action: 'create', ...values },
373
+ })
374
+ } else {
375
+ const entry = categoryDialog.entry
376
+ const payload: Record<string, unknown> = {}
377
+ if (values.value !== entry.value) payload.value = values.value
378
+ if (values.label !== entry.label) payload.label = values.label
379
+ if ((values.color ?? null) !== (entry.color ?? null)) payload.color = values.color ?? null
380
+ if ((values.icon ?? null) !== (entry.icon ?? null)) payload.icon = values.icon ?? null
381
+ await runMutation({
382
+ operation: async () => {
383
+ const call = await withScopedApiRequestHeaders(
384
+ buildOptimisticLockHeader(entry.updatedAt),
385
+ () => apiCall(`/api/dictionaries/${encodeURIComponent(dictionaryId)}/entries/${encodeURIComponent(entry.id)}`, {
386
+ method: 'PATCH',
387
+ headers: { 'content-type': 'application/json' },
388
+ body: JSON.stringify(payload),
389
+ }),
390
+ )
391
+ if (!call.ok) await raiseCrudError(call.response, t('staff.support.settings.errors.save', 'Failed to save.'))
392
+ return call
393
+ },
394
+ context: categoryContext,
395
+ mutationPayload: { action: 'update', id: entry.id, ...payload },
396
+ })
397
+ }
398
+ flash(t('staff.support.settings.categories.saved', 'Category saved.'), 'success')
399
+ setCategoryDialog(null)
400
+ await reloadAll()
401
+ } catch (err) {
402
+ surfaceMutationError(err, 'staff.support.settings.errors.save', 'Failed to save.', () => { void loadCategories() })
403
+ } finally {
404
+ setCategorySubmitting(false)
405
+ }
406
+ }, [categoryContext, categoryDialog, categorySubmitting, dictionaryId, loadCategories, reloadAll, surfaceMutationError, runMutation, t])
407
+
408
+ const deleteCategory = React.useCallback(async (entry: DictionaryTableEntry) => {
409
+ if (!dictionaryId) return
410
+ const confirmed = await confirm({
411
+ title: t('staff.support.settings.categories.confirmDelete', 'Delete category "{value}"?', { value: entry.label || entry.value }),
412
+ variant: 'destructive',
413
+ })
414
+ if (!confirmed) return
415
+ try {
416
+ await runMutation({
417
+ operation: async () => {
418
+ const call = await withScopedApiRequestHeaders(
419
+ buildOptimisticLockHeader(entry.updatedAt),
420
+ () => apiCall(`/api/dictionaries/${encodeURIComponent(dictionaryId)}/entries/${encodeURIComponent(entry.id)}`, {
421
+ method: 'DELETE',
422
+ }),
423
+ )
424
+ if (!call.ok) await raiseCrudError(call.response, t('staff.support.settings.errors.delete', 'Failed to delete.'))
425
+ return call
426
+ },
427
+ context: categoryContext,
428
+ mutationPayload: { action: 'delete', id: entry.id },
429
+ })
430
+ flash(t('staff.support.settings.categories.deleted', 'Category deleted.'), 'success')
431
+ await reloadAll()
432
+ } catch (err) {
433
+ surfaceMutationError(err, 'staff.support.settings.errors.delete', 'Failed to delete.', () => { void loadCategories() })
434
+ }
435
+ }, [categoryContext, confirm, dictionaryId, loadCategories, reloadAll, surfaceMutationError, runMutation, t])
436
+
437
+ const createFallbackProject = React.useCallback(async () => {
438
+ setCreatingFallback(true)
439
+ try {
440
+ await runMutation({
441
+ operation: async () => {
442
+ const call = await apiCall(FALLBACK_PROJECT_URL, { method: 'POST' })
443
+ if (!call.ok) await raiseCrudError(call.response, t('staff.support.settings.errors.save', 'Failed to save.'))
444
+ return call
445
+ },
446
+ context: routingContext,
447
+ mutationPayload: { action: 'create_fallback_project' },
448
+ })
449
+ flash(t('staff.support.settings.routing.fallbackCreated', 'Fallback project created.'), 'success')
450
+ await loadRouting()
451
+ } catch (err) {
452
+ surfaceMutationError(err, 'staff.support.settings.errors.save', 'Failed to save.')
453
+ } finally {
454
+ setCreatingFallback(false)
455
+ }
456
+ }, [loadRouting, surfaceMutationError, routingContext, runMutation, t])
457
+
458
+ const updateRule = React.useCallback(async (
459
+ ruleId: string,
460
+ expectedUpdatedAt: string | null,
461
+ patch: { timeProjectId?: string; isActive?: boolean },
462
+ ) => {
463
+ try {
464
+ await runMutation({
465
+ operation: () => updateCrud(ROUTING_API, { id: ruleId, ...patch }, {
466
+ headers: buildOptimisticLockHeader(expectedUpdatedAt),
467
+ errorMessage: t('staff.support.settings.errors.save', 'Failed to save.'),
468
+ }),
469
+ context: routingContext,
470
+ mutationPayload: { action: 'update', id: ruleId, ...patch },
471
+ })
472
+ flash(t('staff.support.settings.routing.saved', 'Routing saved.'), 'success')
473
+ await loadRouting()
474
+ return true
475
+ } catch (err) {
476
+ surfaceMutationError(err, 'staff.support.settings.errors.save', 'Failed to save.', () => { void loadRouting() })
477
+ return false
478
+ }
479
+ }, [loadRouting, surfaceMutationError, routingContext, runMutation, t])
480
+
481
+ const deleteRule = React.useCallback(async (rule: PreviewRule) => {
482
+ const confirmed = await confirm({
483
+ title: t('staff.support.settings.routing.confirmDelete', 'Delete this rule? The category will use the fallback project.'),
484
+ variant: 'destructive',
485
+ })
486
+ if (!confirmed) return
487
+ try {
488
+ await runMutation({
489
+ operation: () => deleteCrud(ROUTING_API, rule.id, {
490
+ headers: buildOptimisticLockHeader(routingRuleVersion(rule)),
491
+ errorMessage: t('staff.support.settings.errors.delete', 'Failed to delete.'),
492
+ }),
493
+ context: routingContext,
494
+ mutationPayload: { action: 'delete', id: rule.id },
495
+ })
496
+ flash(t('staff.support.settings.routing.deleted', 'Rule deleted.'), 'success')
497
+ await loadRouting()
498
+ } catch (err) {
499
+ surfaceMutationError(err, 'staff.support.settings.errors.delete', 'Failed to delete.', () => { void loadRouting() })
500
+ }
501
+ }, [confirm, loadRouting, surfaceMutationError, routingContext, runMutation, t])
502
+
503
+ const submitRuleDialog = React.useCallback(async () => {
504
+ if (!ruleDialog || !ruleDialog.timeProjectId || ruleSubmitting) return
505
+ if (ruleDialog.mode === 'create' && !ruleDialog.categoryValue) return
506
+ setRuleSubmitting(true)
507
+ try {
508
+ if (ruleDialog.mode === 'create') {
509
+ await runMutation({
510
+ operation: () => createCrud(ROUTING_API, {
511
+ categoryValue: ruleDialog.categoryValue,
512
+ timeProjectId: ruleDialog.timeProjectId,
513
+ }, { errorMessage: t('staff.support.settings.errors.save', 'Failed to save.') }),
514
+ context: routingContext,
515
+ mutationPayload: { action: 'create', categoryValue: ruleDialog.categoryValue, timeProjectId: ruleDialog.timeProjectId },
516
+ })
517
+ flash(t('staff.support.settings.routing.saved', 'Routing saved.'), 'success')
518
+ setRuleDialog(null)
519
+ await loadRouting()
520
+ } else {
521
+ const ok = await updateRule(ruleDialog.ruleId, ruleDialog.expectedUpdatedAt, { timeProjectId: ruleDialog.timeProjectId })
522
+ if (ok) setRuleDialog(null)
523
+ }
524
+ } catch (err) {
525
+ surfaceMutationError(err, 'staff.support.settings.errors.save', 'Failed to save.', () => { void loadRouting() })
526
+ } finally {
527
+ setRuleSubmitting(false)
528
+ }
529
+ }, [loadRouting, surfaceMutationError, routingContext, ruleDialog, ruleSubmitting, runMutation, t, updateRule])
530
+
531
+ const savePrefix = React.useCallback(async () => {
532
+ const value = prefix.trim().toUpperCase()
533
+ if (!PREFIX_PATTERN.test(value)) {
534
+ setPrefixError(t('staff.support.settings.prefix.invalid', 'Use 2–10 uppercase letters or digits, starting with a letter.'))
535
+ return
536
+ }
537
+ setPrefixError(null)
538
+ setPrefixSaving(true)
539
+ try {
540
+ const next: TimeTrackingSettings = { ...settings, support: { ...(settings.support ?? {}), referencePrefix: value } }
541
+ const call = await runMutation({
542
+ operation: async () => {
543
+ const call = await apiCall<TimeTrackingSettings>(SETTINGS_URL, {
544
+ method: 'PUT',
545
+ headers: { 'content-type': 'application/json' },
546
+ body: JSON.stringify(next),
547
+ })
548
+ if (!call.ok) await raiseCrudError(call.response, t('staff.support.settings.errors.save', 'Failed to save.'))
549
+ return call
550
+ },
551
+ context: settingsContext,
552
+ mutationPayload: { support: { referencePrefix: value } },
553
+ })
554
+ if (call.result) onSettingsSaved(call.result)
555
+ flash(t('staff.support.settings.prefix.saved', 'Reference prefix saved.'), 'success')
556
+ } catch (err) {
557
+ surfaceMutationError(err, 'staff.support.settings.errors.save', 'Failed to save.')
558
+ } finally {
559
+ setPrefixSaving(false)
560
+ }
561
+ }, [onSettingsSaved, prefix, surfaceMutationError, runMutation, settings, settingsContext, t])
562
+
563
+ const categoryLabels = React.useMemo(() => {
564
+ const map = new Map<string, string>()
565
+ for (const category of preview.categories) map.set(category.value, category.label)
566
+ for (const entry of categoryEntries) if (!map.has(entry.value)) map.set(entry.value, entry.label)
567
+ return map
568
+ }, [categoryEntries, preview.categories])
569
+
570
+ const overrides = React.useMemo(() => preview.rules.filter((rule) => !rule.isFallback), [preview.rules])
571
+ const categoriesWithoutRule = React.useMemo(() => {
572
+ const ruled = new Set(overrides.map((rule) => rule.categoryValue))
573
+ return preview.categories.filter((category) => !ruled.has(category.value))
574
+ }, [overrides, preview.categories])
575
+
576
+ const fallbackName = preview.fallback ? projectLabel(preview.fallback.project) : ''
577
+
578
+ const knownProjects = React.useMemo<ProjectOption[]>(() => {
579
+ const byId = new Map<string, ProjectOption>()
580
+ const remember = (project: PreviewProject) => {
581
+ if (!byId.has(project.id)) byId.set(project.id, { id: project.id, name: project.name ?? project.id, code: project.code })
582
+ }
583
+ if (preview.fallback) remember(preview.fallback.project)
584
+ for (const rule of preview.rules) remember(rule.project)
585
+ for (const category of preview.categories) if (category.route) remember(category.route.project)
586
+ return [...byId.values()]
587
+ }, [preview])
588
+
589
+ const problemText = (problem: RuleProblem): string => {
590
+ if (problem === 'paused') return t('staff.support.settings.routing.problem.paused', 'The rule is paused.')
591
+ if (problem === 'category_missing') return t('staff.support.settings.routing.problem.categoryMissing', 'The category no longer exists.')
592
+ return t('staff.support.settings.routing.problem.projectUnusable', 'The project is completed, deleted, or has no default status.')
593
+ }
594
+
595
+ const nobodyNotifiedText = t(
596
+ 'staff.support.settings.routing.nobodyNotified',
597
+ "No one in this project will be notified about tickets routed here: it has no active assigner, no usable default assignee and no owner. New tickets go to the organization's project managers instead.",
598
+ )
599
+ const unnotifiedTicketCount = typeof preview.unnotifiedTicketCount === 'number' ? preview.unnotifiedTicketCount : 0
600
+
601
+ const tableTranslations = React.useMemo(() => ({
602
+ title: t('staff.support.settings.categories.title', 'Categories'),
603
+ valueColumn: t('staff.support.settings.categories.value', 'Value'),
604
+ labelColumn: t('staff.support.settings.categories.label', 'Label'),
605
+ appearanceColumn: t('staff.support.settings.categories.appearance', 'Appearance'),
606
+ addLabel: t('staff.support.settings.categories.add', 'Add category'),
607
+ editLabel: t('staff.support.settings.actions.edit', 'Edit'),
608
+ deleteLabel: t('staff.support.settings.actions.delete', 'Delete'),
609
+ refreshLabel: t('staff.support.settings.actions.refresh', 'Refresh'),
610
+ inheritedLabel: t('staff.support.settings.categories.inherited', 'Inherited'),
611
+ inheritedTooltip: t('staff.support.settings.categories.inheritedTooltip', 'Defined by a parent organization.'),
612
+ emptyLabel: t('staff.support.settings.categories.empty', 'No categories yet.'),
613
+ searchPlaceholder: t('staff.support.settings.categories.search', 'Search categories'),
614
+ }), [t])
615
+
616
+ const categoryFormValues = React.useMemo<DictionaryFormValues>(() => {
617
+ if (categoryDialog?.mode === 'edit') {
618
+ return {
619
+ value: categoryDialog.entry.value,
620
+ label: categoryDialog.entry.label,
621
+ color: categoryDialog.entry.color,
622
+ icon: categoryDialog.entry.icon,
623
+ }
624
+ }
625
+ return DEFAULT_CATEGORY_VALUES
626
+ }, [categoryDialog])
627
+
628
+ const categoryDialogTitle = categoryDialog?.mode === 'edit'
629
+ ? t('staff.support.settings.categories.editTitle', 'Edit category')
630
+ : t('staff.support.settings.categories.createTitle', 'Add category')
631
+
632
+ return (
633
+ <>
634
+ <div className="space-y-6">
635
+ <p className="text-sm text-muted-foreground">
636
+ {t('staff.support.settings.description', 'Configure support ticket categories, where each category is routed, and the ticket reference prefix.')}
637
+ </p>
638
+
639
+ <section className="rounded-xl border border-border bg-card text-card-foreground">
640
+ <div className="space-y-1 border-b border-border px-6 py-4">
641
+ <h2 className="text-lg font-medium">{t('staff.support.settings.categories.title', 'Categories')}</h2>
642
+ <p className="text-sm text-muted-foreground">
643
+ {t('staff.support.settings.categories.description', 'Categories customers pick when they file a ticket in the portal.')}
644
+ </p>
645
+ </div>
646
+ <div className="px-2 py-4 sm:px-4">
647
+ {chromeReady && !canViewCategories ? (
648
+ <Alert status="information">
649
+ <AlertDescription>
650
+ {t('staff.support.settings.categories.noAccess', 'You do not have permission to view the support categories.')}
651
+ </AlertDescription>
652
+ </Alert>
653
+ ) : categoriesError ? (
654
+ <ErrorMessage
655
+ label={categoriesError}
656
+ action={(
657
+ <Button type="button" variant="outline" onClick={() => { void loadCategories() }}>
658
+ {t('staff.support.settings.actions.retry', 'Try again')}
659
+ </Button>
660
+ )}
661
+ />
662
+ ) : !categoriesLoading && !dictionaryId ? (
663
+ <Alert status="warning">
664
+ <AlertDescription>
665
+ {t('staff.support.settings.categories.missingDictionary', 'The support categories dictionary has not been seeded for this organization yet.')}
666
+ </AlertDescription>
667
+ </Alert>
668
+ ) : (
669
+ <DictionaryTable
670
+ entries={categoryEntries}
671
+ loading={categoriesLoading}
672
+ canManage={canManageCategories}
673
+ onCreate={() => setCategoryDialog({ mode: 'create' })}
674
+ onEdit={(entry) => setCategoryDialog({ mode: 'edit', entry: categoryEntries.find((row) => row.id === entry.id) ?? entry })}
675
+ onDelete={(entry) => { void deleteCategory(categoryEntries.find((row) => row.id === entry.id) ?? entry) }}
676
+ onRefresh={() => { void loadCategories() }}
677
+ translations={tableTranslations}
678
+ />
679
+ )}
680
+ </div>
681
+ </section>
682
+
683
+ <section className="rounded-xl border border-border bg-card text-card-foreground">
684
+ <div className="space-y-1 border-b border-border px-6 py-4">
685
+ <h2 className="text-lg font-medium">{t('staff.support.settings.routing.title', 'Routing')}</h2>
686
+ <p className="text-sm text-muted-foreground">
687
+ {t('staff.support.settings.routing.description', 'Every category without a working rule goes to the fallback project. Add a rule to send a category to its own project.')}
688
+ </p>
689
+ </div>
690
+ <div className="space-y-6 px-6 py-4">
691
+ {routingLoading ? (
692
+ <LoadingMessage label={t('staff.support.settings.routing.loading', 'Loading routing…')} />
693
+ ) : routingError ? (
694
+ <ErrorMessage
695
+ label={routingError}
696
+ action={(
697
+ <Button type="button" variant="outline" onClick={() => { void loadRouting() }}>
698
+ {t('staff.support.settings.actions.retry', 'Try again')}
699
+ </Button>
700
+ )}
701
+ />
702
+ ) : (
703
+ <>
704
+ {unnotifiedTicketCount > 0 ? (
705
+ <Alert status="warning" data-testid="support-unnotified-tickets">
706
+ <AlertDescription>
707
+ {unnotifiedTicketsLabel(unnotifiedTicketCount)}{' '}
708
+ <Link className="font-medium underline" href={UNNOTIFIED_INBOX_URL}>
709
+ {t('staff.support.settings.routing.unnotifiedTicketsLink', 'Open them in the Support Inbox')}
710
+ </Link>
711
+ </AlertDescription>
712
+ </Alert>
713
+ ) : null}
714
+ {!preview.fallback ? (
715
+ <Alert
716
+ status="warning"
717
+ footer={(
718
+ <Button type="button" onClick={() => { void createFallbackProject() }} disabled={creatingFallback || !canManageSettings}>
719
+ <Plus className="mr-2 h-4 w-4" />
720
+ {t('staff.support.settings.routing.createFallback', 'Create fallback project')}
721
+ </Button>
722
+ )}
723
+ >
724
+ <AlertTitle>{t('staff.support.settings.routing.noFallbackTitle', 'No fallback project')}</AlertTitle>
725
+ <AlertDescription>
726
+ {t('staff.support.settings.routing.noFallbackDescription', 'Customers cannot file tickets for categories without their own rule until a fallback project exists.')}
727
+ </AlertDescription>
728
+ </Alert>
729
+ ) : (
730
+ <div className="space-y-3">
731
+ {!preview.fallback.project.usable ? (
732
+ <Alert status="error">
733
+ <AlertTitle>{t('staff.support.settings.routing.fallbackUnusableTitle', 'The fallback project cannot receive tickets')}</AlertTitle>
734
+ <AlertDescription>
735
+ {t('staff.support.settings.routing.fallbackUnusableDescription', 'It is completed, deleted, or has no default status. Point the fallback rule at another project.')}
736
+ </AlertDescription>
737
+ </Alert>
738
+ ) : null}
739
+ {preview.fallback.project.nobodyNotified === true ? (
740
+ <Alert status="warning" data-testid="support-fallback-nobody-notified">
741
+ <AlertDescription>{nobodyNotifiedText}</AlertDescription>
742
+ </Alert>
743
+ ) : null}
744
+ <div className="max-w-md space-y-2">
745
+ <Label id="support-fallback-project-label">
746
+ {t('staff.support.settings.routing.fallbackProject', 'Fallback project')}
747
+ </Label>
748
+ <SupportProjectPicker
749
+ labelId="support-fallback-project-label"
750
+ testId="support-fallback-project"
751
+ value={preview.fallback.project.id}
752
+ knownProjects={knownProjects}
753
+ placeholder={t('staff.support.settings.routing.searchProject', 'Search projects')}
754
+ disabled={!canManageSettings}
755
+ onChange={(projectId) => {
756
+ if (preview.fallback) {
757
+ void updateRule(preview.fallback.ruleId, routingRuleVersion(preview.fallback), { timeProjectId: projectId })
758
+ }
759
+ }}
760
+ />
761
+ </div>
762
+ </div>
763
+ )}
764
+
765
+ <div className="space-y-3">
766
+ <div className="flex items-center justify-between gap-2">
767
+ <h3 className="text-base font-medium">{t('staff.support.settings.routing.overrides', 'Category rules')}</h3>
768
+ <Button
769
+ type="button"
770
+ variant="outline"
771
+ disabled={categoriesWithoutRule.length === 0 || !canManageSettings}
772
+ onClick={() => setRuleDialog({
773
+ mode: 'create',
774
+ categoryValue: categoriesWithoutRule[0]?.value ?? '',
775
+ timeProjectId: '',
776
+ })}
777
+ >
778
+ <Plus className="mr-2 h-4 w-4" />
779
+ {t('staff.support.settings.routing.addRule', 'Add rule')}
780
+ </Button>
781
+ </div>
782
+ {overrides.length === 0 ? (
783
+ <p className="text-sm text-muted-foreground">
784
+ {t('staff.support.settings.routing.noOverrides', 'No category rules. Every category goes to the fallback project.')}
785
+ </p>
786
+ ) : (
787
+ <Table>
788
+ <TableHeader>
789
+ <TableRow>
790
+ <TableHead>{t('staff.support.settings.routing.category', 'Category')}</TableHead>
791
+ <TableHead>{t('staff.support.settings.routing.project', 'Project')}</TableHead>
792
+ <TableHead>{t('staff.support.settings.routing.active', 'Active')}</TableHead>
793
+ <TableHead>{t('staff.support.settings.routing.state', 'State')}</TableHead>
794
+ <TableHead className="w-24" />
795
+ </TableRow>
796
+ </TableHeader>
797
+ <TableBody>
798
+ {overrides.map((rule) => (
799
+ <TableRow key={rule.id}>
800
+ <TableCell>{categoryLabels.get(rule.categoryValue ?? '') ?? rule.categoryValue}</TableCell>
801
+ <TableCell>
802
+ <div className="space-y-1">
803
+ <span>{projectLabel(rule.project)}</span>
804
+ {rule.project.nobodyNotified === true ? (
805
+ <Alert status="warning" size="xs" data-testid="support-rule-nobody-notified">
806
+ <AlertDescription>{nobodyNotifiedText}</AlertDescription>
807
+ </Alert>
808
+ ) : null}
809
+ </div>
810
+ </TableCell>
811
+ <TableCell>
812
+ <Switch
813
+ checked={rule.isActive}
814
+ disabled={!canManageSettings}
815
+ aria-label={t('staff.support.settings.routing.active', 'Active')}
816
+ onCheckedChange={(checked) => { void updateRule(rule.id, routingRuleVersion(rule), { isActive: checked }) }}
817
+ />
818
+ </TableCell>
819
+ <TableCell>
820
+ {rule.problem ? (
821
+ <div className="space-y-1">
822
+ <StatusBadge variant="warning" dot>
823
+ {t('staff.support.settings.routing.fallingBack', 'Falling back to {project}', { project: fallbackName })}
824
+ </StatusBadge>
825
+ <p className="text-xs text-muted-foreground">{problemText(rule.problem)}</p>
826
+ </div>
827
+ ) : (
828
+ <StatusBadge variant="success" dot>
829
+ {t('staff.support.settings.routing.routing', 'Routing')}
830
+ </StatusBadge>
831
+ )}
832
+ </TableCell>
833
+ <TableCell>
834
+ <div className="flex justify-end gap-1">
835
+ <IconButton
836
+ type="button"
837
+ variant="ghost"
838
+ aria-label={t('staff.support.settings.actions.edit', 'Edit')}
839
+ disabled={!canManageSettings}
840
+ onClick={() => setRuleDialog(buildRoutingRuleEditDraft(rule))}
841
+ >
842
+ <Pencil className="h-4 w-4" />
843
+ </IconButton>
844
+ <IconButton
845
+ type="button"
846
+ variant="ghost"
847
+ aria-label={t('staff.support.settings.actions.delete', 'Delete')}
848
+ disabled={!canManageSettings}
849
+ onClick={() => { void deleteRule(rule) }}
850
+ >
851
+ <Trash2 className="h-4 w-4" />
852
+ </IconButton>
853
+ </div>
854
+ </TableCell>
855
+ </TableRow>
856
+ ))}
857
+ </TableBody>
858
+ </Table>
859
+ )}
860
+ </div>
861
+
862
+ <div className="space-y-3">
863
+ <h3 className="text-base font-medium">{t('staff.support.settings.preview.title', 'Where tickets go now')}</h3>
864
+ {preview.categories.length === 0 ? (
865
+ <p className="text-sm text-muted-foreground">
866
+ {t('staff.support.settings.preview.empty', 'No categories to route.')}
867
+ </p>
868
+ ) : (
869
+ <Table>
870
+ <TableHeader>
871
+ <TableRow>
872
+ <TableHead>{t('staff.support.settings.routing.category', 'Category')}</TableHead>
873
+ <TableHead>{t('staff.support.settings.routing.project', 'Project')}</TableHead>
874
+ <TableHead>{t('staff.support.settings.preview.via', 'Routed by')}</TableHead>
875
+ </TableRow>
876
+ </TableHeader>
877
+ <TableBody>
878
+ {preview.categories.map((category) => (
879
+ <TableRow key={category.value}>
880
+ <TableCell>{category.label}</TableCell>
881
+ <TableCell>
882
+ {category.route ? projectLabel(category.route.project) : (
883
+ <StatusBadge variant="error" dot>
884
+ {t('staff.support.settings.preview.unavailable', 'Not available on portal')}
885
+ </StatusBadge>
886
+ )}
887
+ </TableCell>
888
+ <TableCell>
889
+ {category.route
890
+ ? category.route.via === 'category'
891
+ ? t('staff.support.settings.preview.viaCategory', 'Category rule')
892
+ : t('staff.support.settings.preview.viaFallback', 'Fallback rule')
893
+ : '—'}
894
+ </TableCell>
895
+ </TableRow>
896
+ ))}
897
+ </TableBody>
898
+ </Table>
899
+ )}
900
+ </div>
901
+ </>
902
+ )}
903
+ </div>
904
+ </section>
905
+
906
+ <section className="rounded-xl border border-border bg-card text-card-foreground">
907
+ <div className="space-y-1 border-b border-border px-6 py-4">
908
+ <h2 className="text-lg font-medium">{t('staff.support.settings.prefix.title', 'Reference prefix')}</h2>
909
+ <p className="text-sm text-muted-foreground">
910
+ {t('staff.support.settings.prefix.description', 'Customers see ticket references such as SUP-1042. The prefix applies to the whole tenant.')}
911
+ </p>
912
+ </div>
913
+ <div className="px-6 py-4">
914
+ <div className="flex max-w-md items-end gap-2">
915
+ <div className="flex-1 space-y-2">
916
+ <Label htmlFor="support-reference-prefix">{t('staff.support.settings.referencePrefix', 'Reference prefix')}</Label>
917
+ <Input
918
+ id="support-reference-prefix"
919
+ value={prefix}
920
+ maxLength={10}
921
+ disabled={!canManageSettings}
922
+ onChange={(event) => setPrefix(event.target.value.toUpperCase())}
923
+ aria-invalid={prefixError ? true : undefined}
924
+ />
925
+ {prefixError ? <p className="text-sm text-status-error-text">{prefixError}</p> : null}
926
+ </div>
927
+ <Button type="button" onClick={() => { void savePrefix() }} disabled={prefixSaving || !canManageSettings}>
928
+ <Save className="mr-2 h-4 w-4" />
929
+ {t('staff.support.settings.actions.save', 'Save')}
930
+ </Button>
931
+ </div>
932
+ </div>
933
+ </section>
934
+ </div>
935
+
936
+ <Dialog open={categoryDialog !== null} onOpenChange={(open) => { if (!open) setCategoryDialog(null) }}>
937
+ <DialogContent
938
+ className="max-w-lg"
939
+ onKeyDown={(event) => {
940
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
941
+ event.preventDefault()
942
+ if (categorySubmitting) return
943
+ event.currentTarget.querySelector('form')?.requestSubmit()
944
+ }
945
+ }}
946
+ >
947
+ <DialogHeader>
948
+ <DialogTitle>{categoryDialogTitle}</DialogTitle>
949
+ </DialogHeader>
950
+ <DictionaryForm
951
+ mode={categoryDialog?.mode === 'edit' ? 'edit' : 'create'}
952
+ initialValues={categoryFormValues}
953
+ onSubmit={submitCategory}
954
+ onCancel={() => setCategoryDialog(null)}
955
+ submitting={categorySubmitting}
956
+ translations={{
957
+ title: categoryDialogTitle,
958
+ valueLabel: t('staff.support.settings.categories.value', 'Value'),
959
+ labelLabel: t('staff.support.settings.categories.label', 'Label'),
960
+ saveLabel: t('staff.support.settings.actions.save', 'Save'),
961
+ cancelLabel: t('staff.support.settings.actions.cancel', 'Cancel'),
962
+ appearance: {
963
+ colorLabel: t('staff.support.settings.categories.colorLabel', 'Color'),
964
+ colorHelp: t('staff.support.settings.categories.colorHelp', 'Shown as the category badge color.'),
965
+ colorClearLabel: t('staff.support.settings.categories.colorClear', 'Clear color'),
966
+ iconLabel: t('staff.support.settings.categories.iconLabel', 'Icon'),
967
+ iconPlaceholder: t('staff.support.settings.categories.iconPlaceholder', 'Icon name'),
968
+ iconPickerTriggerLabel: t('staff.support.settings.categories.iconBrowse', 'Browse icons'),
969
+ iconSearchPlaceholder: t('staff.support.settings.categories.iconSearchPlaceholder', 'Search icons'),
970
+ iconSearchEmptyLabel: t('staff.support.settings.categories.iconSearchEmpty', 'No icons found.'),
971
+ iconSuggestionsLabel: t('staff.support.settings.categories.iconSuggestions', 'Suggestions'),
972
+ iconClearLabel: t('staff.support.settings.categories.iconClear', 'Clear icon'),
973
+ previewEmptyLabel: t('staff.support.settings.categories.previewEmpty', 'No appearance'),
974
+ },
975
+ }}
976
+ iconSuggestions={ICON_SUGGESTIONS}
977
+ />
978
+ </DialogContent>
979
+ </Dialog>
980
+
981
+ <Dialog open={ruleDialog !== null} onOpenChange={(open) => { if (!open) setRuleDialog(null) }}>
982
+ <DialogContent
983
+ className="max-w-lg"
984
+ onKeyDown={(event) => {
985
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
986
+ event.preventDefault()
987
+ if (ruleSubmitting) return
988
+ void submitRuleDialog()
989
+ }
990
+ }}
991
+ >
992
+ <DialogHeader>
993
+ <DialogTitle>
994
+ {ruleDialog?.mode === 'edit'
995
+ ? t('staff.support.settings.routing.editRuleTitle', 'Edit rule')
996
+ : t('staff.support.settings.routing.addRuleTitle', 'Add rule')}
997
+ </DialogTitle>
998
+ </DialogHeader>
999
+ {ruleDialog ? (
1000
+ <form
1001
+ className="space-y-4"
1002
+ onSubmit={(event) => {
1003
+ event.preventDefault()
1004
+ void submitRuleDialog()
1005
+ }}
1006
+ >
1007
+ <div className="space-y-2">
1008
+ <Label htmlFor="support-rule-category">{t('staff.support.settings.routing.category', 'Category')}</Label>
1009
+ {ruleDialog.mode === 'create' ? (
1010
+ <Select
1011
+ value={ruleDialog.categoryValue}
1012
+ onValueChange={(value) => setRuleDialog({ ...ruleDialog, categoryValue: value })}
1013
+ >
1014
+ <SelectTrigger id="support-rule-category">
1015
+ <SelectValue placeholder={t('staff.support.settings.routing.selectCategory', 'Select a category')} />
1016
+ </SelectTrigger>
1017
+ <SelectContent>
1018
+ {categoriesWithoutRule.map((category) => (
1019
+ <SelectItem key={category.value} value={category.value}>{category.label}</SelectItem>
1020
+ ))}
1021
+ </SelectContent>
1022
+ </Select>
1023
+ ) : (
1024
+ <p id="support-rule-category" className="text-sm">
1025
+ {categoryLabels.get(ruleDialog.categoryValue) ?? ruleDialog.categoryValue}
1026
+ </p>
1027
+ )}
1028
+ </div>
1029
+ <div className="space-y-2">
1030
+ <Label id="support-rule-project-label">{t('staff.support.settings.routing.project', 'Project')}</Label>
1031
+ <SupportProjectPicker
1032
+ labelId="support-rule-project-label"
1033
+ testId="support-rule-project"
1034
+ value={ruleDialog.timeProjectId}
1035
+ knownProjects={knownProjects}
1036
+ placeholder={t('staff.support.settings.routing.selectProject', 'Select a project')}
1037
+ onChange={(projectId) => setRuleDialog({ ...ruleDialog, timeProjectId: projectId })}
1038
+ />
1039
+ </div>
1040
+ <div className="flex justify-end gap-2">
1041
+ <Button type="button" variant="outline" onClick={() => setRuleDialog(null)}>
1042
+ {t('staff.support.settings.actions.cancel', 'Cancel')}
1043
+ </Button>
1044
+ <Button
1045
+ type="submit"
1046
+ disabled={ruleSubmitting || !ruleDialog.timeProjectId || (ruleDialog.mode === 'create' && !ruleDialog.categoryValue)}
1047
+ >
1048
+ {t('staff.support.settings.actions.save', 'Save')}
1049
+ </Button>
1050
+ </div>
1051
+ </form>
1052
+ ) : null}
1053
+ </DialogContent>
1054
+ </Dialog>
1055
+ {ConfirmDialogElement}
1056
+ </>
1057
+ )
1058
+ }