@open-mercato/core 0.8.1-develop.7274.1.21a0083828 → 0.8.1-develop.7294.1.0ef99fc518

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 (261) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  3. package/dist/modules/auth/components/AclEditor.js +21 -3
  4. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  5. package/dist/modules/customers/backend/customers/companies/page.js +6 -4
  6. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  7. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +6 -4
  8. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +2 -2
  9. package/dist/modules/customers/backend/customers/deals/page.js +6 -4
  10. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  11. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +5 -3
  12. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +6 -4
  14. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  15. package/dist/modules/customers/backend/customers/people/page.js +6 -4
  16. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  17. package/dist/modules/customers/components/calendar/editor/PeopleField.js +9 -2
  18. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  19. package/dist/modules/customers/components/calendar/editor/hooks.js +4 -2
  20. package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
  21. package/dist/modules/customers/components/calendar/editor/lookups.js +7 -3
  22. package/dist/modules/customers/components/calendar/editor/lookups.js.map +2 -2
  23. package/dist/modules/customers/components/detail/AssignRoleDialog.js +5 -2
  24. package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
  25. package/dist/modules/customers/components/detail/RoleAssignmentRow.js +4 -2
  26. package/dist/modules/customers/components/detail/RoleAssignmentRow.js.map +2 -2
  27. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +8 -5
  28. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  29. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +10 -7
  30. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  31. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +4 -2
  32. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  33. package/dist/modules/customers/components/detail/schedule/fieldConfig.js +9 -0
  34. package/dist/modules/customers/components/detail/schedule/fieldConfig.js.map +2 -2
  35. package/dist/modules/customers/components/detail/schedule/index.js +3 -1
  36. package/dist/modules/customers/components/detail/schedule/index.js.map +2 -2
  37. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +11 -4
  38. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  39. package/dist/modules/customers/components/formConfig.js +17 -9
  40. package/dist/modules/customers/components/formConfig.js.map +2 -2
  41. package/dist/modules/customers/data/validators.js +12 -7
  42. package/dist/modules/customers/data/validators.js.map +2 -2
  43. package/dist/modules/customers/lib/assignableStaff.js +103 -22
  44. package/dist/modules/customers/lib/assignableStaff.js.map +2 -2
  45. package/dist/modules/customers/setup.js +10 -2
  46. package/dist/modules/customers/setup.js.map +2 -2
  47. package/dist/modules/data_sync/api/schedules/[id]/route.js +5 -0
  48. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
  49. package/dist/modules/data_sync/api/schedules/route.js +5 -0
  50. package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
  51. package/dist/modules/data_sync/backend/data-sync/page.js +29 -5
  52. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  53. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +48 -11
  54. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  55. package/dist/modules/data_sync/data/validators.js +13 -2
  56. package/dist/modules/data_sync/data/validators.js.map +2 -2
  57. package/dist/modules/data_sync/lib/schedule-value.js +30 -0
  58. package/dist/modules/data_sync/lib/schedule-value.js.map +7 -0
  59. package/dist/modules/eudr/data/validators.js.map +2 -2
  60. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +3 -4
  61. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  62. package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js +3 -1
  63. package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js.map +2 -2
  64. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js +2 -1
  65. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js.map +2 -2
  66. package/dist/modules/portal/frontend/[orgSlug]/portal/page.js +3 -4
  67. package/dist/modules/portal/frontend/[orgSlug]/portal/page.js.map +2 -2
  68. package/dist/modules/portal/frontend/[orgSlug]/portal/profile/page.js +3 -4
  69. package/dist/modules/portal/frontend/[orgSlug]/portal/profile/page.js.map +2 -2
  70. package/dist/modules/portal/lib/navigation.js +3 -4
  71. package/dist/modules/portal/lib/navigation.js.map +2 -2
  72. package/dist/modules/query_index/lib/engine.js +79 -14
  73. package/dist/modules/query_index/lib/engine.js.map +2 -2
  74. package/dist/modules/staff/backend/staff/time-tracking/page.js +122 -86
  75. package/dist/modules/staff/backend/staff/time-tracking/page.js.map +2 -2
  76. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +38 -17
  77. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  78. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js +45 -18
  79. package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js.map +2 -2
  80. package/dist/modules/staff/extension-points.js +16 -0
  81. package/dist/modules/staff/extension-points.js.map +2 -2
  82. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js +3 -4
  83. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js.map +2 -2
  84. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js +3 -4
  85. package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js.map +2 -2
  86. package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js +14 -13
  87. package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js.map +2 -2
  88. package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js +60 -41
  89. package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js.map +2 -2
  90. package/dist/modules/warranty_claims/api/ai/assess/route.js +2 -2
  91. package/dist/modules/warranty_claims/api/ai/assess/route.js.map +2 -2
  92. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js +3 -4
  93. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js.map +3 -3
  94. package/dist/modules/warranty_claims/api/ai/suggest/route.js +3 -3
  95. package/dist/modules/warranty_claims/api/ai/suggest/route.js.map +2 -2
  96. package/dist/modules/warranty_claims/api/assign/route.js +3 -3
  97. package/dist/modules/warranty_claims/api/assign/route.js.map +2 -2
  98. package/dist/modules/warranty_claims/api/credit-memo/route.js +2 -2
  99. package/dist/modules/warranty_claims/api/credit-memo/route.js.map +2 -2
  100. package/dist/modules/warranty_claims/api/entitlement/route.js +2 -2
  101. package/dist/modules/warranty_claims/api/entitlement/route.js.map +2 -2
  102. package/dist/modules/warranty_claims/api/events/route.js +4 -4
  103. package/dist/modules/warranty_claims/api/events/route.js.map +2 -2
  104. package/dist/modules/warranty_claims/api/external/claims/route.js +3 -3
  105. package/dist/modules/warranty_claims/api/external/claims/route.js.map +2 -2
  106. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +2 -2
  107. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +2 -2
  108. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +2 -2
  109. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +2 -2
  110. package/dist/modules/warranty_claims/api/portal/claims/route.js +2 -2
  111. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
  112. package/dist/modules/warranty_claims/api/portal/events/route.js +2 -2
  113. package/dist/modules/warranty_claims/api/portal/events/route.js.map +2 -2
  114. package/dist/modules/warranty_claims/api/receiving/route.js +2 -2
  115. package/dist/modules/warranty_claims/api/receiving/route.js.map +2 -2
  116. package/dist/modules/warranty_claims/api/replacement-order/route.js +2 -2
  117. package/dist/modules/warranty_claims/api/replacement-order/route.js.map +2 -2
  118. package/dist/modules/warranty_claims/api/return-label/route.js +2 -2
  119. package/dist/modules/warranty_claims/api/return-label/route.js.map +2 -2
  120. package/dist/modules/warranty_claims/api/risk/route.js +2 -2
  121. package/dist/modules/warranty_claims/api/risk/route.js.map +2 -2
  122. package/dist/modules/warranty_claims/api/sales-return/route.js +2 -2
  123. package/dist/modules/warranty_claims/api/sales-return/route.js.map +2 -2
  124. package/dist/modules/warranty_claims/api/settings-general/route.js +4 -4
  125. package/dist/modules/warranty_claims/api/settings-general/route.js.map +2 -2
  126. package/dist/modules/warranty_claims/api/stats/route.js +2 -2
  127. package/dist/modules/warranty_claims/api/stats/route.js.map +2 -2
  128. package/dist/modules/warranty_claims/api/submit/route.js +3 -3
  129. package/dist/modules/warranty_claims/api/submit/route.js.map +2 -2
  130. package/dist/modules/warranty_claims/api/transition/route.js +3 -3
  131. package/dist/modules/warranty_claims/api/transition/route.js.map +2 -2
  132. package/dist/modules/warranty_claims/api/vendor-recovery/route.js +3 -3
  133. package/dist/modules/warranty_claims/api/vendor-recovery/route.js.map +2 -2
  134. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +2 -2
  135. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +2 -2
  136. package/dist/modules/warranty_claims/backend/page.js +4 -2
  137. package/dist/modules/warranty_claims/backend/page.js.map +2 -2
  138. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +9 -2
  139. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +2 -2
  140. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js +4 -2
  141. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js.map +2 -2
  142. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +3 -4
  143. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +2 -2
  144. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js +3 -2
  145. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js.map +2 -2
  146. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +3 -2
  147. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +2 -2
  148. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -1
  149. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  150. package/dist/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.js +2 -2
  151. package/dist/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.js.map +2 -2
  152. package/dist/modules/workflows/lib/event-trigger-service.js +56 -1
  153. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  154. package/package.json +7 -7
  155. package/src/modules/auth/api/users/acl/route.ts +4 -3
  156. package/src/modules/auth/components/AclEditor.tsx +38 -2
  157. package/src/modules/auth/i18n/de.json +5 -1
  158. package/src/modules/auth/i18n/en.json +5 -1
  159. package/src/modules/auth/i18n/es.json +5 -1
  160. package/src/modules/auth/i18n/ko.json +5 -1
  161. package/src/modules/auth/i18n/pl.json +5 -1
  162. package/src/modules/customers/backend/customers/companies/page.tsx +6 -4
  163. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +6 -4
  164. package/src/modules/customers/backend/customers/deals/page.tsx +6 -4
  165. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +5 -3
  166. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +6 -4
  167. package/src/modules/customers/backend/customers/people/page.tsx +6 -4
  168. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +9 -2
  169. package/src/modules/customers/components/calendar/editor/hooks.ts +4 -2
  170. package/src/modules/customers/components/calendar/editor/lookups.ts +12 -4
  171. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +4 -1
  172. package/src/modules/customers/components/detail/RoleAssignmentRow.tsx +4 -2
  173. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +15 -7
  174. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +16 -7
  175. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +4 -2
  176. package/src/modules/customers/components/detail/schedule/fieldConfig.ts +15 -0
  177. package/src/modules/customers/components/detail/schedule/index.ts +1 -1
  178. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +16 -9
  179. package/src/modules/customers/components/formConfig.tsx +35 -12
  180. package/src/modules/customers/data/validators.ts +14 -8
  181. package/src/modules/customers/i18n/de.json +1 -0
  182. package/src/modules/customers/i18n/en.json +1 -0
  183. package/src/modules/customers/i18n/es.json +1 -0
  184. package/src/modules/customers/i18n/ko.json +1 -0
  185. package/src/modules/customers/i18n/pl.json +1 -0
  186. package/src/modules/customers/lib/assignableStaff.ts +172 -34
  187. package/src/modules/customers/setup.ts +8 -0
  188. package/src/modules/data_sync/api/schedules/[id]/route.ts +5 -0
  189. package/src/modules/data_sync/api/schedules/route.ts +5 -0
  190. package/src/modules/data_sync/backend/data-sync/page.tsx +48 -4
  191. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +61 -2
  192. package/src/modules/data_sync/data/validators.ts +26 -2
  193. package/src/modules/data_sync/i18n/de.json +2 -0
  194. package/src/modules/data_sync/i18n/en.json +2 -0
  195. package/src/modules/data_sync/i18n/es.json +2 -0
  196. package/src/modules/data_sync/i18n/ko.json +2 -0
  197. package/src/modules/data_sync/i18n/pl.json +2 -0
  198. package/src/modules/data_sync/lib/schedule-value.ts +45 -0
  199. package/src/modules/eudr/data/validators.ts +3 -4
  200. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +5 -4
  201. package/src/modules/portal/frontend/[orgSlug]/portal/invite/page.tsx +3 -1
  202. package/src/modules/portal/frontend/[orgSlug]/portal/login/page.tsx +2 -1
  203. package/src/modules/portal/frontend/[orgSlug]/portal/page.tsx +5 -5
  204. package/src/modules/portal/frontend/[orgSlug]/portal/profile/page.tsx +5 -4
  205. package/src/modules/portal/i18n/de.json +1 -0
  206. package/src/modules/portal/i18n/en.json +1 -0
  207. package/src/modules/portal/i18n/es.json +1 -0
  208. package/src/modules/portal/i18n/ko.json +1 -0
  209. package/src/modules/portal/i18n/pl.json +1 -0
  210. package/src/modules/portal/lib/navigation.ts +8 -3
  211. package/src/modules/query_index/lib/engine.ts +111 -16
  212. package/src/modules/staff/backend/staff/time-tracking/page.tsx +161 -130
  213. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +55 -43
  214. package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.tsx +61 -38
  215. package/src/modules/staff/extension-points.ts +16 -0
  216. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.tsx +3 -4
  217. package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.tsx +3 -4
  218. package/src/modules/staff/i18n/de.json +3 -0
  219. package/src/modules/staff/i18n/en.json +3 -0
  220. package/src/modules/staff/i18n/es.json +3 -0
  221. package/src/modules/staff/i18n/ko.json +3 -0
  222. package/src/modules/staff/i18n/pl.json +3 -0
  223. package/src/modules/staff/lib/time-tracking-ui/ReportSheet.tsx +32 -29
  224. package/src/modules/staff/lib/time-tracking-ui/TaskListView.tsx +89 -79
  225. package/src/modules/warranty_claims/api/ai/assess/route.ts +2 -2
  226. package/src/modules/warranty_claims/api/ai/draft-reply/route.ts +2 -3
  227. package/src/modules/warranty_claims/api/ai/suggest/route.ts +3 -3
  228. package/src/modules/warranty_claims/api/assign/route.ts +3 -3
  229. package/src/modules/warranty_claims/api/credit-memo/route.ts +2 -2
  230. package/src/modules/warranty_claims/api/entitlement/route.ts +2 -2
  231. package/src/modules/warranty_claims/api/events/route.ts +4 -4
  232. package/src/modules/warranty_claims/api/external/claims/route.ts +3 -3
  233. package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +2 -2
  234. package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +2 -2
  235. package/src/modules/warranty_claims/api/portal/claims/route.ts +2 -2
  236. package/src/modules/warranty_claims/api/portal/events/route.ts +2 -2
  237. package/src/modules/warranty_claims/api/receiving/route.ts +2 -2
  238. package/src/modules/warranty_claims/api/replacement-order/route.ts +2 -2
  239. package/src/modules/warranty_claims/api/return-label/route.ts +2 -2
  240. package/src/modules/warranty_claims/api/risk/route.ts +2 -2
  241. package/src/modules/warranty_claims/api/sales-return/route.ts +2 -2
  242. package/src/modules/warranty_claims/api/settings-general/route.ts +4 -4
  243. package/src/modules/warranty_claims/api/stats/route.ts +2 -2
  244. package/src/modules/warranty_claims/api/submit/route.ts +3 -3
  245. package/src/modules/warranty_claims/api/transition/route.ts +3 -3
  246. package/src/modules/warranty_claims/api/vendor-recovery/route.ts +3 -3
  247. package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +2 -2
  248. package/src/modules/warranty_claims/backend/page.tsx +4 -2
  249. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +9 -2
  250. package/src/modules/warranty_claims/backend/warranty_claims/settings/page.tsx +4 -2
  251. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +5 -4
  252. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.tsx +5 -2
  253. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +5 -2
  254. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -1
  255. package/src/modules/workflows/frontend/[orgSlug]/portal/tasks/[id]/page.tsx +2 -2
  256. package/src/modules/workflows/i18n/de.json +1 -1
  257. package/src/modules/workflows/i18n/en.json +1 -1
  258. package/src/modules/workflows/i18n/es.json +1 -1
  259. package/src/modules/workflows/i18n/ko.json +1 -1
  260. package/src/modules/workflows/i18n/pl.json +1 -1
  261. package/src/modules/workflows/lib/event-trigger-service.ts +139 -0
@@ -1,8 +1,13 @@
1
- import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
1
+ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
2
2
  import type { FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'
3
3
 
4
4
  export type AssignableStaffMember = {
5
- teamMemberId: string
5
+ /**
6
+ * Real team-member id when the staff module is present; `null` when resolved
7
+ * via the auth-users fallback (auth user ids are not staff ids — passing this
8
+ * value to a staff API would produce a wrong lookup).
9
+ */
10
+ teamMemberId: string | null
6
11
  userId: string
7
12
  displayName: string
8
13
  email: string | null
@@ -16,6 +21,20 @@ type AssignableStaffResponse = {
16
21
  pageSize?: number
17
22
  }
18
23
 
24
+ /** Shape returned by GET /api/auth/users — distinct from AssignableStaffResponse. */
25
+ type AuthUsersListResponse = {
26
+ items?: AuthUserListItem[]
27
+ total?: number
28
+ /** /api/auth/users does not return page/pageSize; callers fall back to passed-in values. */
29
+ isSuperAdmin?: boolean
30
+ }
31
+
32
+ type AuthUserListItem = {
33
+ id?: unknown
34
+ name?: unknown
35
+ email?: unknown
36
+ }
37
+
19
38
  export type AssignableStaffMembersPage = {
20
39
  items: AssignableStaffMember[]
21
40
  /**
@@ -34,8 +53,9 @@ export type AssignableStaffMembersPage = {
34
53
  // When that module is disabled, its `/api/staff/team-members/assignable` endpoint is
35
54
  // absent and the request resolves to 404. Customers UI (deals / people / companies
36
55
  // owner filters, role-assignment dialogs) is core and always enabled, so it must not
37
- // break in that case — a missing staff module simply means there is no roster to
38
- // offer. Treat the 404 as an empty page and let any other failure propagate.
56
+ // break in that case — fall back to tenant auth users, because `ownerUserId` is an
57
+ // auth user id, not a staff team-member id (issue #6183). Treat other failures as
58
+ // before: propagate them to the caller.
39
59
  function isAssignableEndpointMissing(error: unknown): boolean {
40
60
  return (
41
61
  typeof error === 'object' &&
@@ -44,34 +64,12 @@ function isAssignableEndpointMissing(error: unknown): boolean {
44
64
  )
45
65
  }
46
66
 
47
- export async function fetchAssignableStaffMembersPage(
48
- query: string,
49
- options?: { page?: number; pageSize?: number; signal?: AbortSignal },
50
- ): Promise<AssignableStaffMembersPage> {
51
- const page = options?.page ?? 1
52
- const pageSize = options?.pageSize ?? 24
53
- const params = new URLSearchParams()
54
- params.set('page', String(page))
55
- params.set('pageSize', String(pageSize))
56
- const normalizedQuery = query.trim()
57
- if (normalizedQuery.length > 0) {
58
- params.set('search', normalizedQuery)
59
- }
60
-
61
- let data: AssignableStaffResponse
62
- try {
63
- data = await readApiResultOrThrow<AssignableStaffResponse>(
64
- `/api/staff/team-members/assignable?${params.toString()}`,
65
- options?.signal ? { signal: options.signal } : undefined,
66
- )
67
- } catch (error) {
68
- if (isAssignableEndpointMissing(error)) {
69
- return { items: [], servedCount: 0, total: 0, page, pageSize }
70
- }
71
- throw error
72
- }
67
+ function isAuthUsersFallbackDegradableHttpError(error: unknown): boolean {
68
+ const status = (error as { status?: unknown })?.status
69
+ return typeof status === 'number' && status >= 400 && status < 500
70
+ }
73
71
 
74
- const rawItems = Array.isArray(data?.items) ? data.items : []
72
+ function mapStaffItems(rawItems: Array<Record<string, unknown>>): AssignableStaffMember[] {
75
73
  const deduped = new Map<string, AssignableStaffMember>()
76
74
 
77
75
  for (const item of rawItems) {
@@ -130,13 +128,153 @@ export async function fetchAssignableStaffMembersPage(
130
128
  })
131
129
  }
132
130
 
131
+ return Array.from(deduped.values())
132
+ }
133
+
134
+ function mapAuthUsersToAssignable(rawItems: AuthUserListItem[]): AssignableStaffMember[] {
135
+ const deduped = new Map<string, AssignableStaffMember>()
136
+
137
+ for (const item of rawItems) {
138
+ if (!item || typeof item.id !== 'string' || !item.id.trim()) continue
139
+ const userId = item.id.trim()
140
+ if (deduped.has(userId)) continue
141
+
142
+ const name = typeof item.name === 'string' && item.name.trim().length > 0 ? item.name.trim() : null
143
+ const email = typeof item.email === 'string' && item.email.trim().length > 0 ? item.email.trim() : null
144
+ const displayName = name ?? email ?? userId
145
+
146
+ deduped.set(userId, {
147
+ // Auth user ids are not staff team-member ids. Callers must not pass this
148
+ // value to a staff API endpoint.
149
+ teamMemberId: null,
150
+ userId,
151
+ displayName,
152
+ email,
153
+ teamName: null,
154
+ })
155
+ }
156
+
157
+ return Array.from(deduped.values())
158
+ }
159
+
160
+ /**
161
+ * When the optional `staff` module is absent, resolve assignable owners from
162
+ * `GET /api/auth/users` so CRM apps without staff still show owner names and
163
+ * pickers (ownerUserId is always an auth user id).
164
+ *
165
+ * A 401/403/404 from the fallback endpoint degrades to an empty roster.
166
+ * A 5xx or network error propagates so the operator gets a signal.
167
+ * An AbortError is always rethrown so callers can distinguish cancellation.
168
+ */
169
+ async function fetchAssignableUsersFallback(
170
+ query: string,
171
+ options: { page: number; pageSize: number; activeOrgId?: string | null; signal?: AbortSignal },
172
+ ): Promise<AssignableStaffMembersPage> {
173
+ const { page, pageSize, activeOrgId, signal } = options
174
+ const params = new URLSearchParams()
175
+ params.set('page', String(page))
176
+ params.set('pageSize', String(pageSize))
177
+ // Only scope to the active organization when one is known. A null/missing
178
+ // orgId on the session resolves to `organizationId IS NULL` server-side,
179
+ // which returns only org-less users and drops all deal owners that belong
180
+ // to any organization.
181
+ if (activeOrgId) {
182
+ params.set('scopeToActiveOrganization', '1')
183
+ }
184
+ const normalizedQuery = query.trim()
185
+ if (normalizedQuery.length > 0) {
186
+ params.set('search', normalizedQuery)
187
+ }
188
+
189
+ let call: Awaited<ReturnType<typeof apiCall<AuthUsersListResponse>>> | null
190
+ try {
191
+ call = await apiCall<AuthUsersListResponse>(
192
+ `/api/auth/users?${params.toString()}`,
193
+ { headers: { 'x-om-forbidden-redirect': '0' }, signal },
194
+ { fallback: null },
195
+ )
196
+ } catch (err) {
197
+ // apiCall rethrows AbortError — preserve it so callers can distinguish
198
+ // cancellation from an empty roster.
199
+ if (err && typeof err === 'object' && (err as { name?: unknown }).name === 'AbortError') throw err
200
+ // HTTP 4xx from apiFetch (rare) degrades to an empty roster; network/5xx propagate.
201
+ if (isAuthUsersFallbackDegradableHttpError(err)) {
202
+ call = null
203
+ } else {
204
+ throw err
205
+ }
206
+ }
207
+
208
+ if (!call) {
209
+ return { items: [], servedCount: 0, total: 0, page, pageSize }
210
+ }
211
+ // 5xx: backend is down — propagate so operators get a signal rather than silent empty pickers.
212
+ if (!call.ok && call.status >= 500) {
213
+ throw Object.assign(new Error(`[internal] Auth users lookup failed (${call.status})`), { status: call.status })
214
+ }
215
+ // 401/403/404: permission not granted or endpoint gone — degrade to empty roster.
216
+ if (!call.ok) {
217
+ return { items: [], servedCount: 0, total: 0, page, pageSize }
218
+ }
219
+
220
+ const rawItems = Array.isArray(call.result?.items) ? (call.result.items as AuthUserListItem[]) : []
221
+ const items = mapAuthUsersToAssignable(rawItems)
222
+
223
+ return {
224
+ items,
225
+ servedCount: rawItems.length,
226
+ total:
227
+ typeof call.result?.total === 'number' && Number.isFinite(call.result.total)
228
+ ? call.result.total
229
+ : items.length,
230
+ // /api/auth/users does not return page/pageSize; fall back to the values passed in.
231
+ page,
232
+ pageSize,
233
+ }
234
+ }
235
+
236
+ export async function fetchAssignableStaffMembersPage(
237
+ query: string,
238
+ options?: { page?: number; pageSize?: number; activeOrgId?: string | null; signal?: AbortSignal },
239
+ ): Promise<AssignableStaffMembersPage> {
240
+ const page = options?.page ?? 1
241
+ const pageSize = options?.pageSize ?? 24
242
+ const params = new URLSearchParams()
243
+ params.set('page', String(page))
244
+ params.set('pageSize', String(pageSize))
245
+ const normalizedQuery = query.trim()
246
+ if (normalizedQuery.length > 0) {
247
+ params.set('search', normalizedQuery)
248
+ }
249
+
250
+ let data: AssignableStaffResponse
251
+ try {
252
+ data = await readApiResultOrThrow<AssignableStaffResponse>(
253
+ `/api/staff/team-members/assignable?${params.toString()}`,
254
+ options?.signal ? { signal: options.signal } : undefined,
255
+ )
256
+ } catch (error) {
257
+ if (isAssignableEndpointMissing(error)) {
258
+ return fetchAssignableUsersFallback(query, {
259
+ page,
260
+ pageSize,
261
+ activeOrgId: options?.activeOrgId,
262
+ signal: options?.signal,
263
+ })
264
+ }
265
+ throw error
266
+ }
267
+
268
+ const rawItems = Array.isArray(data?.items) ? data.items : []
269
+ const items = mapStaffItems(rawItems)
270
+
133
271
  return {
134
- items: Array.from(deduped.values()),
272
+ items,
135
273
  servedCount: rawItems.length,
136
274
  total:
137
275
  typeof data?.total === 'number' && Number.isFinite(data.total)
138
276
  ? data.total
139
- : deduped.size,
277
+ : items.length,
140
278
  page:
141
279
  typeof data?.page === 'number' && Number.isFinite(data.page)
142
280
  ? data.page
@@ -150,7 +288,7 @@ export async function fetchAssignableStaffMembersPage(
150
288
 
151
289
  export async function fetchAssignableStaffMembers(
152
290
  query: string,
153
- options?: { pageSize?: number; signal?: AbortSignal },
291
+ options?: { pageSize?: number; activeOrgId?: string | null; signal?: AbortSignal },
154
292
  ): Promise<AssignableStaffMember[]> {
155
293
  const result = await fetchAssignableStaffMembersPage(query, options)
156
294
  return result.items
@@ -84,6 +84,11 @@ export const setup: ModuleSetupConfig = {
84
84
  defaultRoleFeatures: {
85
85
  admin: [
86
86
  'customers.*',
87
+ // `auth.users.list` is granted alongside `customers.*` so the owner picker resolves
88
+ // user names in CRM-preset apps where the optional `staff` module is absent.
89
+ // Without it the fallback to /api/auth/users returns 403 and owners show as "Unknown".
90
+ // Existing tenants pick this up via `yarn mercato auth sync-role-acls`.
91
+ 'auth.users.list',
87
92
  ],
88
93
  employee: [
89
94
  'customers.people.view',
@@ -103,6 +108,9 @@ export const setup: ModuleSetupConfig = {
103
108
  'customers.roles.view',
104
109
  'customers.roles.manage',
105
110
  'customers.email.compose',
111
+ // Required so the staff-absent fallback to /api/auth/users succeeds for employees too.
112
+ // See `admin` entry above for full rationale.
113
+ 'auth.users.list',
106
114
  ],
107
115
  },
108
116
  }
@@ -6,6 +6,8 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
7
7
  import { readOptimisticLockExpected } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
8
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
+ import { isInvalidScheduleValueError } from '@open-mercato/shared/lib/schedule/invalidScheduleValue'
10
+ import { buildScheduleValueErrorBody } from '../../../lib/schedule-value'
9
11
  import { updateSyncScheduleSchema } from '../../../data/validators'
10
12
  import type { SyncScheduleService } from '../../../lib/sync-schedule-service'
11
13
  import { serializeSchedule } from '../serialize'
@@ -143,6 +145,9 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
143
145
  if (isCrudHttpError(error)) {
144
146
  return NextResponse.json(error.body, { status: error.status })
145
147
  }
148
+ if (isInvalidScheduleValueError(error)) {
149
+ return NextResponse.json(buildScheduleValueErrorBody(error.scheduleType), { status: 422 })
150
+ }
146
151
  const message = error instanceof Error ? error.message : 'Failed to update sync schedule'
147
152
  return NextResponse.json({ error: message }, { status: 422 })
148
153
  }
@@ -8,6 +8,8 @@ import type { SyncScheduleService } from '../../lib/sync-schedule-service'
8
8
  import { serializeSchedule } from './serialize'
9
9
  import { readOptimisticLockExpected } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
10
10
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
11
+ import { isInvalidScheduleValueError } from '@open-mercato/shared/lib/schedule/invalidScheduleValue'
12
+ import { buildScheduleValueErrorBody } from '../../lib/schedule-value'
11
13
  import {
12
14
  runCrudMutationGuardAfterSuccess,
13
15
  validateCrudMutationGuard,
@@ -120,6 +122,9 @@ export async function POST(req: Request) {
120
122
  if (isCrudHttpError(error)) {
121
123
  return NextResponse.json(error.body, { status: error.status })
122
124
  }
125
+ if (isInvalidScheduleValueError(error)) {
126
+ return NextResponse.json(buildScheduleValueErrorBody(error.scheduleType), { status: 422 })
127
+ }
123
128
  const message = error instanceof Error ? error.message : 'Failed to save sync schedule'
124
129
  return NextResponse.json({ error: message }, { status: 422 })
125
130
  }
@@ -30,6 +30,8 @@ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimi
30
30
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
31
31
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
32
32
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
33
+ import { isValidScheduleInterval } from '@open-mercato/shared/lib/schedule/interval'
34
+ import { hasScheduleValueFieldError } from '@open-mercato/core/modules/data_sync/lib/schedule-value'
33
35
  import { useT } from '@open-mercato/shared/lib/i18n/context'
34
36
  import { cn } from '@open-mercato/shared/lib/utils'
35
37
  import {
@@ -134,6 +136,19 @@ type SyncScheduleEditorState = {
134
136
 
135
137
  const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
136
138
 
139
+ const SCHEDULE_VALUE_ERROR_ID = 'data-sync-dashboard-schedule-value-error'
140
+
141
+ type ScheduleValueError = 'empty' | 'format' | null
142
+
143
+ function detectScheduleValueError(
144
+ scheduleType: 'cron' | 'interval',
145
+ scheduleValue: string,
146
+ ): ScheduleValueError {
147
+ if (scheduleValue.length === 0) return 'empty'
148
+ if (scheduleType === 'interval' && !isValidScheduleInterval(scheduleValue)) return 'format'
149
+ return null
150
+ }
151
+
137
152
  /** Matches `runSyncSchema`'s own default, so omitting the field submits this value. */
138
153
  const DEFAULT_BATCH_SIZE = '100'
139
154
 
@@ -190,6 +205,7 @@ export default function SyncRunsDashboardPage() {
190
205
  const [isLoadingSchedule, setIsLoadingSchedule] = React.useState(false)
191
206
  const [isSavingSchedule, setIsSavingSchedule] = React.useState(false)
192
207
  const [isDeletingSchedule, setIsDeletingSchedule] = React.useState(false)
208
+ const [scheduleValueError, setScheduleValueError] = React.useState<ScheduleValueError>(null)
193
209
  const [reloadToken, setReloadToken] = React.useState(0)
194
210
  const scopeVersion = useOrganizationScopeVersion()
195
211
  const t = useT()
@@ -350,6 +366,7 @@ export default function SyncRunsDashboardPage() {
350
366
  }
351
367
 
352
368
  const record = Array.isArray(call.result?.items) ? call.result?.items[0] : undefined
369
+ setScheduleValueError(null)
353
370
  if (!record) {
354
371
  setScheduleEditor(buildDefaultScheduleState(selectedEntityType))
355
372
  setIsLoadingSchedule(false)
@@ -375,8 +392,17 @@ export default function SyncRunsDashboardPage() {
375
392
 
376
393
  const updateScheduleEditor = React.useCallback((changes: Partial<SyncScheduleEditorState>) => {
377
394
  setScheduleEditor((current) => ({ ...current, ...changes }))
395
+ if (changes.scheduleValue !== undefined || changes.scheduleType !== undefined) {
396
+ setScheduleValueError(null)
397
+ }
378
398
  }, [])
379
399
 
400
+ const scheduleValueErrorMessage = scheduleValueError === 'empty'
401
+ ? t('data_sync.dashboard.schedule.invalidValue', 'Provide a schedule value before saving.')
402
+ : scheduleEditor.scheduleType === 'cron'
403
+ ? t('data_sync.dashboard.schedule.invalidCron', 'Enter a cron expression the scheduler can parse, for example `0 * * * *`.')
404
+ : t('data_sync.dashboard.schedule.invalidInterval', 'Enter a whole number followed by s, m, h or d (for example `15m`, `1h` or `24h`), at least one minute long.')
405
+
380
406
  const handleCancel = React.useCallback(async (row: SyncRunRow) => {
381
407
  // optimistic-lock-exempt: run lifecycle action endpoint (cancel), not a concurrent record edit
382
408
  const call = await apiCall(`/api/data_sync/runs/${encodeURIComponent(row.id)}/cancel`, {
@@ -477,11 +503,18 @@ export default function SyncRunsDashboardPage() {
477
503
 
478
504
  const handleSaveSchedule = React.useCallback(async () => {
479
505
  if (!selectedIntegration || !selectedEntityType) return
480
- if (scheduleEditor.scheduleValue.trim().length === 0) {
481
- flash(t('data_sync.dashboard.schedule.invalidValue', 'Provide a schedule value before saving.'), 'error')
506
+ const scheduleValue = scheduleEditor.scheduleValue.trim()
507
+
508
+ // The documented interval format is checked here so a value the scheduler
509
+ // can never run never reaches the API; cron stays server-validated and
510
+ // comes back through the same inline field error.
511
+ const valueError = detectScheduleValueError(scheduleEditor.scheduleType, scheduleValue)
512
+ if (valueError) {
513
+ setScheduleValueError(valueError)
482
514
  return
483
515
  }
484
516
 
517
+ setScheduleValueError(null)
485
518
  setIsSavingSchedule(true)
486
519
  try {
487
520
  const call = await runMutation({
@@ -499,7 +532,7 @@ export default function SyncRunsDashboardPage() {
499
532
  entityType: selectedEntityType,
500
533
  direction: selectedDirection,
501
534
  scheduleType: scheduleEditor.scheduleType,
502
- scheduleValue: scheduleEditor.scheduleValue.trim(),
535
+ scheduleValue,
503
536
  timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
504
537
  fullSync: scheduleEditor.fullSync,
505
538
  isEnabled: scheduleEditor.isEnabled,
@@ -511,7 +544,7 @@ export default function SyncRunsDashboardPage() {
511
544
  entityType: selectedEntityType,
512
545
  direction: selectedDirection,
513
546
  scheduleType: scheduleEditor.scheduleType,
514
- scheduleValue: scheduleEditor.scheduleValue.trim(),
547
+ scheduleValue,
515
548
  timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
516
549
  fullSync: scheduleEditor.fullSync,
517
550
  isEnabled: scheduleEditor.isEnabled,
@@ -524,6 +557,10 @@ export default function SyncRunsDashboardPage() {
524
557
  })
525
558
 
526
559
  if (!call.ok || !call.result) {
560
+ if (hasScheduleValueFieldError(call.result)) {
561
+ setScheduleValueError('format')
562
+ return
563
+ }
527
564
  const conflictError = Object.assign(
528
565
  new Error((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule')),
529
566
  {
@@ -965,7 +1002,14 @@ export default function SyncRunsDashboardPage() {
965
1002
  onChange={(event) => updateScheduleEditor({ scheduleValue: event.target.value })}
966
1003
  disabled={isLoadingSchedule || isSavingSchedule || isDeletingSchedule || !selectedIntegration || !selectedEntityType}
967
1004
  placeholder={scheduleEditor.scheduleType === 'cron' ? '0 * * * *' : '1h'}
1005
+ aria-invalid={scheduleValueError ? true : undefined}
1006
+ aria-describedby={scheduleValueError ? SCHEDULE_VALUE_ERROR_ID : undefined}
968
1007
  />
1008
+ {scheduleValueError ? (
1009
+ <p id={SCHEDULE_VALUE_ERROR_ID} className="text-xs text-status-error-text">
1010
+ {scheduleValueErrorMessage}
1011
+ </p>
1012
+ ) : null}
969
1013
  <p className="text-xs text-muted-foreground">
970
1014
  {scheduleEditor.scheduleType === 'cron'
971
1015
  ? t('data_sync.dashboard.schedule.cronHelp', 'Example: `0 * * * *` runs at the start of every hour.')
@@ -21,7 +21,9 @@ import {
21
21
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
22
22
  import { Switch } from '@open-mercato/ui/primitives/switch'
23
23
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
24
+ import { isValidScheduleInterval } from '@open-mercato/shared/lib/schedule/interval'
24
25
  import { useT } from '@open-mercato/shared/lib/i18n/context'
26
+ import { hasScheduleValueFieldError } from '../lib/schedule-value'
25
27
  import { getSyncSummaryVariant } from '../lib/syncRunStatus'
26
28
  import type { RunParameter } from '../lib/adapter'
27
29
  import { getApplicableRunParameters } from '../lib/run-parameters'
@@ -122,6 +124,21 @@ function buildScheduleKey(entityType: string, direction: 'import' | 'export'): s
122
124
  return `${entityType}:${direction}`
123
125
  }
124
126
 
127
+ type ScheduleValueError = 'empty' | 'format' | undefined
128
+
129
+ function buildScheduleValueErrorId(scheduleKey: string): string {
130
+ return `data-sync-schedule-value-error-${scheduleKey.replace(/[^a-zA-Z0-9]+/g, '-')}`
131
+ }
132
+
133
+ function detectScheduleValueError(
134
+ scheduleType: 'cron' | 'interval',
135
+ scheduleValue: string,
136
+ ): ScheduleValueError {
137
+ if (scheduleValue.length === 0) return 'empty'
138
+ if (scheduleType === 'interval' && !isValidScheduleInterval(scheduleValue)) return 'format'
139
+ return undefined
140
+ }
141
+
125
142
  function buildScheduleEditors(
126
143
  entityTypes: string[],
127
144
  directions: Array<'import' | 'export'>,
@@ -163,6 +180,17 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
163
180
  const [runningKey, setRunningKey] = React.useState<string | null>(null)
164
181
  const [savingKey, setSavingKey] = React.useState<string | null>(null)
165
182
  const [deletingKey, setDeletingKey] = React.useState<string | null>(null)
183
+ const [valueErrors, setValueErrors] = React.useState<Record<string, ScheduleValueError>>({})
184
+
185
+ const describeScheduleValueError = React.useCallback((
186
+ valueError: ScheduleValueError,
187
+ scheduleType: 'cron' | 'interval',
188
+ ) => {
189
+ if (valueError === 'empty') return t('data_sync.dashboard.schedule.invalidValue', 'Provide a schedule value before saving.')
190
+ return scheduleType === 'cron'
191
+ ? t('data_sync.dashboard.schedule.invalidCron', 'Enter a cron expression the scheduler can parse, for example `0 * * * *`.')
192
+ : t('data_sync.dashboard.schedule.invalidInterval', 'Enter a whole number followed by s, m, h or d (for example `15m`, `1h` or `24h`), at least one minute long.')
193
+ }, [t])
166
194
 
167
195
  const load = React.useCallback(async () => {
168
196
  setIsLoading(true)
@@ -178,6 +206,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
178
206
  const supportedEntities = resolvedOption?.supportedEntities ?? []
179
207
  const supportedDirections = getSupportedDirections(resolvedOption?.direction)
180
208
  setSchedules(buildScheduleEditors(supportedEntities, supportedDirections, schedulesCall.result?.items ?? []))
209
+ setValueErrors({})
181
210
  } catch (error) {
182
211
  const message = error instanceof Error ? error.message : t('data_sync.integrationTab.loadError', 'Failed to load sync schedules.')
183
212
  flash(message, 'error')
@@ -214,6 +243,9 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
214
243
  ...patch,
215
244
  },
216
245
  }))
246
+ if (patch.scheduleValue !== undefined || patch.scheduleType !== undefined) {
247
+ setValueErrors((current) => (current[key] ? { ...current, [key]: undefined } : current))
248
+ }
217
249
  }, [])
218
250
 
219
251
  const handleStartSync = React.useCallback(async (entityType: string, direction: 'import' | 'export', scheduleKey: string) => {
@@ -281,6 +313,18 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
281
313
 
282
314
  const handleSaveSchedule = React.useCallback(async (entityType: string, direction: 'import' | 'export', scheduleKey: string) => {
283
315
  const scheduleState = schedules[scheduleKey] ?? buildDefaultScheduleState(entityType)
316
+ const scheduleValue = scheduleState.scheduleValue.trim()
317
+
318
+ // The documented interval format is checked here so a value the scheduler
319
+ // can never run never reaches the API; cron stays server-validated and
320
+ // comes back through the same inline field error.
321
+ const valueError = detectScheduleValueError(scheduleState.scheduleType, scheduleValue)
322
+ if (valueError) {
323
+ setValueErrors((current) => ({ ...current, [scheduleKey]: valueError }))
324
+ return
325
+ }
326
+
327
+ setValueErrors((current) => (current[scheduleKey] ? { ...current, [scheduleKey]: undefined } : current))
284
328
  setSavingKey(scheduleKey)
285
329
  try {
286
330
  // Keyed upsert (POST). When the server resolves an existing row the save
@@ -297,7 +341,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
297
341
  entityType,
298
342
  direction,
299
343
  scheduleType: scheduleState.scheduleType,
300
- scheduleValue: scheduleState.scheduleValue,
344
+ scheduleValue,
301
345
  timezone: scheduleState.timezone,
302
346
  fullSync: scheduleState.fullSync,
303
347
  isEnabled: scheduleState.isEnabled,
@@ -309,7 +353,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
309
353
  entityType,
310
354
  direction,
311
355
  scheduleType: scheduleState.scheduleType,
312
- scheduleValue: scheduleState.scheduleValue,
356
+ scheduleValue,
313
357
  timezone: scheduleState.timezone,
314
358
  fullSync: scheduleState.fullSync,
315
359
  isEnabled: scheduleState.isEnabled,
@@ -322,6 +366,10 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
322
366
  })
323
367
 
324
368
  if (!call.ok || !call.result) {
369
+ if (hasScheduleValueFieldError(call.result)) {
370
+ setValueErrors((current) => ({ ...current, [scheduleKey]: 'format' }))
371
+ return
372
+ }
325
373
  const conflictError = Object.assign(
326
374
  new Error((call.result as { error?: string } | null)?.error ?? 'Failed to save schedule'),
327
375
  {
@@ -498,6 +546,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
498
546
  const isSaving = savingKey === row.key
499
547
  const isDeleting = deletingKey === row.key
500
548
  const controlsDisabled = isRunning || isSaving || isDeleting
549
+ const valueError = valueErrors[row.key]
501
550
  return (
502
551
  <tr key={row.key} className="border-t align-top">
503
552
  <td className="px-3 py-3 font-medium">{formatEntityTypeLabel(row.entityType)}</td>
@@ -525,7 +574,17 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
525
574
  onChange={(event) => updateScheduleEditor(row.key, { scheduleValue: event.target.value }, row.entityType)}
526
575
  disabled={controlsDisabled}
527
576
  placeholder={scheduleState.scheduleType === 'cron' ? '0 * * * *' : '1h'}
577
+ aria-invalid={valueError ? true : undefined}
578
+ aria-describedby={valueError ? buildScheduleValueErrorId(row.key) : undefined}
579
+ aria-label={scheduleState.scheduleType === 'cron'
580
+ ? t('data_sync.dashboard.schedule.cronValue', 'Cron expression')
581
+ : t('data_sync.dashboard.schedule.intervalValue', 'Interval')}
528
582
  />
583
+ {valueError ? (
584
+ <p id={buildScheduleValueErrorId(row.key)} className="mt-1 text-xs text-status-error-text">
585
+ {describeScheduleValueError(valueError, scheduleState.scheduleType)}
586
+ </p>
587
+ ) : null}
529
588
  </td>
530
589
  <td className="px-3 py-3">
531
590
  <Input
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod'
2
+ import { isValidScheduleInterval } from '@open-mercato/shared/lib/schedule/interval'
3
+ import { SCHEDULE_VALUE_FIELD, SCHEDULE_VALUE_FORMAT_MESSAGES } from '../lib/schedule-value'
2
4
 
3
5
  export const runSyncSchema = z.object({
4
6
  integrationId: z.string().min(1),
@@ -46,6 +48,28 @@ export const listSyncSchedulesQuerySchema = z.object({
46
48
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
47
49
  })
48
50
 
51
+ /**
52
+ * The documented interval format is part of the API contract, so a value the
53
+ * scheduler can never run (`"3600"`) is rejected here instead of surfacing an
54
+ * internal scheduler message from deep inside the write path. The value is
55
+ * checked exactly as the scheduler will receive it — surrounding whitespace is
56
+ * not tolerated here, because the scheduler's anchored parser would reject it
57
+ * later. Cron is left to that parser, which reports back through the same
58
+ * `scheduleValue` field error.
59
+ */
60
+ function refineScheduleValueFormat(
61
+ value: { scheduleType?: 'cron' | 'interval'; scheduleValue?: string },
62
+ ctx: z.RefinementCtx,
63
+ ): void {
64
+ if (value.scheduleType !== 'interval' || typeof value.scheduleValue !== 'string') return
65
+ if (isValidScheduleInterval(value.scheduleValue)) return
66
+ ctx.addIssue({
67
+ code: 'custom',
68
+ path: [SCHEDULE_VALUE_FIELD],
69
+ message: SCHEDULE_VALUE_FORMAT_MESSAGES.interval,
70
+ })
71
+ }
72
+
49
73
  export const createSyncScheduleSchema = z.object({
50
74
  integrationId: z.string().min(1),
51
75
  entityType: z.string().min(1),
@@ -55,7 +79,7 @@ export const createSyncScheduleSchema = z.object({
55
79
  timezone: z.string().min(1).default('UTC'),
56
80
  fullSync: z.boolean().default(false),
57
81
  isEnabled: z.boolean().default(true),
58
- })
82
+ }).superRefine(refineScheduleValueFormat)
59
83
 
60
84
  export const updateSyncScheduleSchema = z.object({
61
85
  integrationId: z.string().min(1).optional(),
@@ -68,7 +92,7 @@ export const updateSyncScheduleSchema = z.object({
68
92
  isEnabled: z.boolean().optional(),
69
93
  }).refine((value) => Object.keys(value).length > 0, {
70
94
  message: 'At least one field must be updated',
71
- })
95
+ }).superRefine(refineScheduleValueFormat)
72
96
 
73
97
  export type CreateSyncScheduleInput = z.infer<typeof createSyncScheduleSchema>
74
98
  export type UpdateSyncScheduleInput = z.infer<typeof updateSyncScheduleSchema>
@@ -31,6 +31,8 @@
31
31
  "data_sync.dashboard.schedule.interval": "Interval",
32
32
  "data_sync.dashboard.schedule.intervalHelp": "Example: `1h`, `6h`, or `24h` for repeating intervals.",
33
33
  "data_sync.dashboard.schedule.intervalValue": "Interval",
34
+ "data_sync.dashboard.schedule.invalidCron": "Geben Sie einen Cron-Ausdruck an, den der Scheduler verarbeiten kann, zum Beispiel `0 * * * *`.",
35
+ "data_sync.dashboard.schedule.invalidInterval": "Geben Sie eine ganze Zahl mit der Einheit s, m, h oder d an (zum Beispiel `15m`, `1h` oder `24h`), mindestens eine Minute lang.",
34
36
  "data_sync.dashboard.schedule.invalidValue": "Provide a schedule value before saving.",
35
37
  "data_sync.dashboard.schedule.lastRun": "Last scheduled run: {value}",
36
38
  "data_sync.dashboard.schedule.neverRun": "Saved, but no scheduled execution has completed yet.",
@@ -31,6 +31,8 @@
31
31
  "data_sync.dashboard.schedule.interval": "Interval",
32
32
  "data_sync.dashboard.schedule.intervalHelp": "Example: `1h`, `6h`, or `24h` for repeating intervals.",
33
33
  "data_sync.dashboard.schedule.intervalValue": "Interval",
34
+ "data_sync.dashboard.schedule.invalidCron": "Enter a cron expression the scheduler can parse, for example `0 * * * *`.",
35
+ "data_sync.dashboard.schedule.invalidInterval": "Enter a whole number followed by s, m, h or d (for example `15m`, `1h` or `24h`), at least one minute long.",
34
36
  "data_sync.dashboard.schedule.invalidValue": "Provide a schedule value before saving.",
35
37
  "data_sync.dashboard.schedule.lastRun": "Last scheduled run: {value}",
36
38
  "data_sync.dashboard.schedule.neverRun": "Saved, but no scheduled execution has completed yet.",
@@ -31,6 +31,8 @@
31
31
  "data_sync.dashboard.schedule.interval": "Interval",
32
32
  "data_sync.dashboard.schedule.intervalHelp": "Example: `1h`, `6h`, or `24h` for repeating intervals.",
33
33
  "data_sync.dashboard.schedule.intervalValue": "Interval",
34
+ "data_sync.dashboard.schedule.invalidCron": "Introduce una expresión cron que el planificador pueda interpretar, por ejemplo `0 * * * *`.",
35
+ "data_sync.dashboard.schedule.invalidInterval": "Introduce un número entero seguido de s, m, h o d (por ejemplo `15m`, `1h` o `24h`), de al menos un minuto.",
34
36
  "data_sync.dashboard.schedule.invalidValue": "Provide a schedule value before saving.",
35
37
  "data_sync.dashboard.schedule.lastRun": "Last scheduled run: {value}",
36
38
  "data_sync.dashboard.schedule.neverRun": "Saved, but no scheduled execution has completed yet.",