@open-mercato/core 0.9.0 → 0.9.1-develop.7459.1.99e3accd30

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 (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
  3. package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
  4. package/dist/generated/entities/customer_interaction/index.js +2 -0
  5. package/dist/generated/entities/customer_interaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/commands/categories.js +1 -1
  9. package/dist/modules/catalog/commands/categories.js.map +2 -2
  10. package/dist/modules/catalog/commands/productDeleteChildren.js +389 -0
  11. package/dist/modules/catalog/commands/productDeleteChildren.js.map +7 -0
  12. package/dist/modules/catalog/commands/products.js +52 -17
  13. package/dist/modules/catalog/commands/products.js.map +2 -2
  14. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +4 -6
  15. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  16. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +4 -6
  17. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +2 -2
  18. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
  19. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
  20. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
  21. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  22. package/dist/modules/communication_channels/lib/inbound-message-origin.js +1 -1
  23. package/dist/modules/communication_channels/lib/inbound-message-origin.js.map +2 -2
  24. package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
  25. package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
  27. package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
  28. package/dist/modules/customers/api/activity-types/route.js +151 -0
  29. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  30. package/dist/modules/customers/api/deals/[id]/people/route.js +58 -5
  31. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  32. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  33. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  34. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  35. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  36. package/dist/modules/customers/api/interactions/route.js +3 -0
  37. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  38. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  39. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  40. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  41. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +0 -23
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/page.js +15 -17
  45. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  46. package/dist/modules/customers/calendar-event-types.js +548 -0
  47. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  48. package/dist/modules/customers/commands/dictionaries.js +70 -10
  49. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  50. package/dist/modules/customers/commands/interactions.js +187 -22
  51. package/dist/modules/customers/commands/interactions.js.map +3 -3
  52. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  53. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  54. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  55. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  56. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  57. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  58. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  59. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  60. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  61. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  62. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  63. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  64. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  65. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  66. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  67. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  68. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  69. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  70. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  71. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  72. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  73. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  74. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  75. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  76. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  77. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  78. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  79. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  80. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  81. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  82. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  83. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  84. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  85. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  86. package/dist/modules/customers/components/calendar/types.js +1 -0
  87. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  88. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +78 -343
  89. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  90. package/dist/modules/customers/components/detail/CreatePersonDialog.js +12 -11
  91. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  92. package/dist/modules/customers/components/detail/DealForm.js +7 -3
  93. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  94. package/dist/modules/customers/components/detail/DealPeopleSection.js +206 -0
  95. package/dist/modules/customers/components/detail/DealPeopleSection.js.map +7 -0
  96. package/dist/modules/customers/components/detail/LinkedPeopleSection.js +342 -0
  97. package/dist/modules/customers/components/detail/LinkedPeopleSection.js.map +7 -0
  98. package/dist/modules/customers/components/detail/PersonCard.js +5 -2
  99. package/dist/modules/customers/components/detail/PersonCard.js.map +2 -2
  100. package/dist/modules/customers/components/linking/adapters/personAdapter.js +3 -0
  101. package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
  102. package/dist/modules/customers/data/entities.js +6 -0
  103. package/dist/modules/customers/data/entities.js.map +2 -2
  104. package/dist/modules/customers/data/validators.js +39 -10
  105. package/dist/modules/customers/data/validators.js.map +2 -2
  106. package/dist/modules/customers/di.js +20 -1
  107. package/dist/modules/customers/di.js.map +2 -2
  108. package/dist/modules/customers/extension-points.js +13 -0
  109. package/dist/modules/customers/extension-points.js.map +2 -2
  110. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  111. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  112. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  113. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  114. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  115. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  116. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  117. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  118. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  119. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  120. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  121. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  122. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  123. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  124. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  125. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  126. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  127. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  128. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  129. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  130. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  131. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  132. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  133. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  134. package/dist/modules/messages/subscribers/message-notification.js +13 -1
  135. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  136. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
  137. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
  138. package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
  139. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  140. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  141. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  142. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  143. package/dist/modules/sales/components/documents/LineItemDialog.js +159 -81
  144. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  145. package/dist/modules/sales/components/documents/lineItemUtils.js +7 -0
  146. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  147. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  148. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  149. package/dist/modules/sync_excel/lib/adapters/customers.js +28 -25
  150. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  151. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  152. package/generated/entities/customer_interaction/index.ts +1 -0
  153. package/generated/entity-fields-registry.ts +2 -0
  154. package/package.json +9 -8
  155. package/src/modules/auth/i18n/de.json +3 -0
  156. package/src/modules/auth/i18n/en.json +3 -0
  157. package/src/modules/auth/i18n/es.json +3 -0
  158. package/src/modules/auth/i18n/ko.json +3 -0
  159. package/src/modules/auth/i18n/pl.json +3 -0
  160. package/src/modules/catalog/commands/categories.ts +1 -1
  161. package/src/modules/catalog/commands/productDeleteChildren.ts +557 -0
  162. package/src/modules/catalog/commands/products.ts +58 -17
  163. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +4 -6
  164. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +4 -6
  165. package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
  166. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
  167. package/src/modules/communication_channels/i18n/de.json +4 -0
  168. package/src/modules/communication_channels/i18n/en.json +4 -0
  169. package/src/modules/communication_channels/i18n/es.json +4 -0
  170. package/src/modules/communication_channels/i18n/ko.json +4 -0
  171. package/src/modules/communication_channels/i18n/pl.json +4 -0
  172. package/src/modules/communication_channels/lib/inbound-message-origin.ts +2 -6
  173. package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
  174. package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
  175. package/src/modules/customers/api/activity-types/route.ts +154 -0
  176. package/src/modules/customers/api/deals/[id]/people/route.ts +82 -3
  177. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  178. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  179. package/src/modules/customers/api/interactions/route.ts +4 -0
  180. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  181. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  182. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +0 -29
  183. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -17
  184. package/src/modules/customers/calendar-event-types.ts +674 -0
  185. package/src/modules/customers/commands/dictionaries.ts +84 -8
  186. package/src/modules/customers/commands/interactions.ts +244 -21
  187. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  188. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  189. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  190. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  191. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  192. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  193. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  194. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  195. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  196. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  197. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  198. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  199. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  200. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  201. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  202. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  203. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  204. package/src/modules/customers/components/calendar/types.ts +1 -0
  205. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +94 -388
  206. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +39 -19
  207. package/src/modules/customers/components/detail/DealForm.tsx +7 -3
  208. package/src/modules/customers/components/detail/DealPeopleSection.tsx +256 -0
  209. package/src/modules/customers/components/detail/LinkedPeopleSection.tsx +474 -0
  210. package/src/modules/customers/components/detail/PersonCard.tsx +12 -4
  211. package/src/modules/customers/components/detail/types.ts +1 -0
  212. package/src/modules/customers/components/linking/adapters/personAdapter.tsx +4 -0
  213. package/src/modules/customers/data/entities.ts +8 -1
  214. package/src/modules/customers/data/validators.ts +42 -7
  215. package/src/modules/customers/di.ts +21 -0
  216. package/src/modules/customers/extension-points.ts +13 -0
  217. package/src/modules/customers/i18n/de.json +104 -8
  218. package/src/modules/customers/i18n/en.json +104 -8
  219. package/src/modules/customers/i18n/es.json +104 -8
  220. package/src/modules/customers/i18n/ko.json +104 -8
  221. package/src/modules/customers/i18n/pl.json +104 -8
  222. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  223. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  224. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  225. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  226. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  227. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  228. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  229. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  230. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  231. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  232. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  233. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  234. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  235. package/src/modules/messages/subscribers/message-notification.ts +13 -3
  236. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
  237. package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
  238. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  239. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  240. package/src/modules/sales/components/documents/LineItemDialog.tsx +194 -111
  241. package/src/modules/sales/components/documents/lineItemUtils.ts +18 -0
  242. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
  243. package/src/modules/sync_excel/lib/adapters/customers.ts +33 -27
@@ -8,30 +8,30 @@ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
8
  import { extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
9
9
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
10
10
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
11
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
11
12
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
13
  import { CrudForm, type CrudFormGroup, type CrudFormGroupComponentProps } from '@open-mercato/ui/backend/CrudForm'
14
+ import { useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
15
+ import { projectCalendarCustomValues, useCalendarCustomFields, validateCalendarCustomValues } from './editor/useCalendarCustomFields'
13
16
  import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
14
17
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
15
18
  import { Button } from '@open-mercato/ui/primitives/button'
16
19
  import { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'
17
20
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
18
21
  import { Input } from '@open-mercato/ui/primitives/input'
22
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
23
+ import { calendarDayEndInstant, calendarDayStartInstant, calendarWallTimeToInstant, calendarTimezoneOptions, defaultCalendarTimezone, isCalendarTimezone } from '../../lib/calendar/timezone'
19
24
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
20
25
  import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
21
26
  import { E } from '#generated/entities.ids.generated'
22
27
  import {
23
- buildEditorTypeOptions,
28
+ buildRecurrenceRule,
24
29
  buildInteractionPayload,
25
30
  computeDurationMinutes,
26
31
  createDefaultFormState,
27
- defaultRepeatDaysForDateInput,
28
- editorKindOfInteractionType,
29
- KIND_CONFIG,
30
32
  parseItemToFormState,
31
33
  resolveSavedOwnerUserId,
32
34
  type EditorFormState,
33
- type EditorKind,
34
- type EditorPriority,
35
35
  } from '../../lib/calendar/editorPayload'
36
36
  import type { ConflictScope } from '../../lib/calendar/preferences'
37
37
  import { renderDictionaryIcon } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
@@ -39,14 +39,11 @@ import { normalizeCustomFieldSubmitValue } from '../detail/customFieldUtils'
39
39
  import type { CalendarItem } from './types'
40
40
  import { EDITOR_SCROLL_EVENT, Field } from './editor/inputs'
41
41
  import { SegmentGroup } from './editor/SegmentGroup'
42
- import { PriorityField } from './editor/PriorityField'
43
42
  import { RelatedToField } from './editor/RelatedToField'
44
- import { RepeatField } from './editor/RepeatField'
45
- import { PeopleField } from './editor/PeopleField'
46
- import { ResourcesField } from './editor/ResourcesField'
47
- import { ScheduleSection } from './editor/ScheduleSection'
48
- import { LocationField } from './editor/LocationField'
49
43
  import { useConflictProbe, useEditorLabelResolution } from './editor/hooks'
44
+ import { EventTypePanel } from './editor/EventTypePanel'
45
+ import { eventTypeConfig, eventTypeOptions, isSelectableEventType, selectedEventType, useEventTypeCatalog } from './editor/useEventTypeCatalog'
46
+ import type { ScopedCalendarEventType } from '../../lib/calendar/eventTypeResolver'
50
47
 
51
48
  export interface CalendarEventEditorProps {
52
49
  open: boolean
@@ -54,7 +51,8 @@ export interface CalendarEventEditorProps {
54
51
  item?: CalendarItem | null
55
52
  defaultDate?: Date
56
53
  defaultRange?: { start: Date; end: Date } | null
57
- typeLabels: Record<string, string>
54
+ /** @deprecated Event-type labels are loaded from the scoped catalog. */
55
+ typeLabels?: Record<string, string>
58
56
  typeIcons?: Record<string, string | null>
59
57
  conflictScope?: ConflictScope
60
58
  currentUserId?: string | null
@@ -65,13 +63,7 @@ export interface CalendarEventEditorProps {
65
63
  }
66
64
 
67
65
  const FORM_ID = 'customers-calendar-event-editor'
68
- const INTERACTION_ENTITY_IDS = [E.customers.customer_interaction]
69
-
70
- const PEOPLE_FIELD_TEXT = {
71
- attendees: { labelKey: 'customers.calendar.editor.attendees', label: 'Attendees', placeholderKey: 'customers.calendar.editor.addPeoplePlaceholder', placeholder: 'Add staff or customer…' },
72
- participants: { labelKey: 'customers.calendar.editor.participants', label: 'Participants', placeholderKey: 'customers.calendar.editor.addPeoplePlaceholder', placeholder: 'Add staff or customer…' },
73
- to: { labelKey: 'customers.calendar.editor.to', label: 'To', placeholderKey: 'customers.calendar.editor.addRecipientPlaceholder', placeholder: 'Add recipient…' },
74
- } as const
66
+ const INTERACTION_INJECTION_SPOT = `crud-form:${E.customers.customer_interaction.replace(/:/g, '.')}`
75
67
 
76
68
  // CrudForm values carry the flattened EditorFormState keys (seeded via
77
69
  // initialValues) plus cf_* custom-field keys managed by CrudForm itself.
@@ -87,35 +79,108 @@ function customFieldInitialValues(item: CalendarItem): Record<string, unknown> {
87
79
  )
88
80
  }
89
81
 
90
- type EditorBodyProps = {
91
- ctx: CrudFormGroupComponentProps
82
+ function typeChangeDiscardFields(error: unknown): string[] | null {
83
+ if (!error || typeof error !== 'object') return null
84
+ const details = error as { status?: unknown; code?: unknown; fields?: unknown }
85
+ if (details.status !== 409 || details.code !== 'calendar_type_change_confirmation_required') return null
86
+ if (!Array.isArray(details.fields) || details.fields.length === 0 ||
87
+ !details.fields.every((field) => typeof field === 'string' && field.length > 0)) return null
88
+ return details.fields
89
+ }
90
+
91
+ type EditorBodyProps = Pick<CrudFormGroupComponentProps, 'values' | 'errors' | 'setValue'> & {
92
92
  open: boolean
93
93
  isEdit: boolean
94
94
  item?: CalendarItem | null
95
- typeLabels: Record<string, string>
96
- typeIcons: Record<string, string | null>
97
95
  conflictScope: ConflictScope
98
96
  currentUserId: string | null
99
97
  resourcesEnabled: boolean
100
98
  staffEnabled: boolean
99
+ saving: boolean
100
+ catalogItems: readonly ScopedCalendarEventType[]
101
+ catalogReady: boolean
102
+ catalogError: boolean
103
+ onRetryCatalog(): void
104
+ onSelectedTypeChange(key: string): void
105
+ }
106
+
107
+ function CatalogStatusAlert({ error, onRetry }: { error: boolean; onRetry(): void }) {
108
+ const t = useT()
109
+ return (
110
+ <Alert status={error ? 'error' : 'information'} className="rounded-lg lg:col-span-2">
111
+ <AlertDescription>
112
+ {error
113
+ ? t('customers.calendar.editor.catalogLoadFailed')
114
+ : t('customers.calendar.editor.catalogLoading')}
115
+ </AlertDescription>
116
+ {error ? (
117
+ <Button type="button" variant="outline" onClick={onRetry}>
118
+ {t('customers.calendar.errors.retry')}
119
+ </Button>
120
+ ) : null}
121
+ </Alert>
122
+ )
101
123
  }
102
124
 
103
- function EditorBody({
104
- ctx,
125
+ const TimezoneField = React.memo(function TimezoneField({
126
+ value,
127
+ error,
128
+ disabled,
129
+ onChange,
130
+ }: {
131
+ value: string | null | undefined
132
+ error?: string
133
+ disabled: boolean
134
+ onChange(timezone: string): void
135
+ }) {
136
+ const t = useT()
137
+ const [open, setOpen] = React.useState(false)
138
+ const timezone = value ?? defaultCalendarTimezone()
139
+ const options = React.useMemo(() => open ? calendarTimezoneOptions(timezone) : [timezone], [open, timezone])
140
+ const items = React.useMemo(
141
+ () => options.map((timezone) => <SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>),
142
+ [options],
143
+ )
144
+ return (
145
+ <Field label={t('customers.calendar.editor.timezone', 'Time zone')} error={error}>
146
+ <Select value={timezone} onValueChange={onChange} disabled={disabled} open={open} onOpenChange={setOpen}>
147
+ <SelectTrigger aria-label={t('customers.calendar.editor.timezone', 'Time zone')}><SelectValue /></SelectTrigger>
148
+ <SelectContent>{items}</SelectContent>
149
+ </Select>
150
+ </Field>
151
+ )
152
+ })
153
+
154
+ const TypeSwitcher = React.memo(SegmentGroup) as typeof SegmentGroup
155
+ const NO_RESOURCES: EditorFormState['resources'] = []
156
+ const MemoRelatedToField = React.memo(RelatedToField)
157
+
158
+ // CrudForm re-invokes the group renderer on every one of its own renders;
159
+ // memoizing on the form state identities skips the ones that change neither
160
+ // the values nor the errors.
161
+ const EditorBody = React.memo(function EditorBody({
162
+ values,
163
+ errors,
164
+ setValue,
105
165
  open,
106
166
  isEdit,
107
167
  item,
108
- typeLabels,
109
- typeIcons,
110
168
  conflictScope,
111
169
  currentUserId,
112
170
  resourcesEnabled,
113
171
  staffEnabled,
172
+ saving,
173
+ catalogItems,
174
+ catalogReady,
175
+ catalogError,
176
+ onRetryCatalog,
177
+ onSelectedTypeChange,
114
178
  }: EditorBodyProps) {
115
179
  const t = useT()
116
- const { setValue, errors } = ctx
117
- const form = formStateOfValues(ctx.values)
118
- const config = KIND_CONFIG[form.kind]
180
+ const form = formStateOfValues(values)
181
+ const selectedType = form.category ?? form.kind
182
+ const definition = selectedEventType(catalogItems, selectedType)
183
+ const config = eventTypeConfig(definition)
119
184
 
120
185
  const update = React.useCallback(
121
186
  (patch: Partial<EditorFormState>) => {
@@ -124,6 +189,23 @@ function EditorBody({
124
189
  [setValue],
125
190
  )
126
191
 
192
+ const updateTimezone = React.useCallback((timezone: string) => setValue('timezone', timezone), [setValue])
193
+ const selectType = React.useCallback((type: string) => {
194
+ const nextDefinition = selectedEventType(catalogItems, type)
195
+ update({ kind: nextDefinition.behavior.baseKind, category: type })
196
+ onSelectedTypeChange(type)
197
+ }, [catalogItems, update, onSelectedTypeChange])
198
+ const updateRelatedTo = React.useCallback((relatedTo: EditorFormState['relatedTo']) => update({ relatedTo }), [update])
199
+ const updateDeal = React.useCallback(
200
+ (deal: { id: string; label: string } | null) => update({ dealId: deal?.id ?? null, dealLabel: deal?.label ?? null }),
201
+ [update],
202
+ )
203
+ const deal = React.useMemo(
204
+ () => form.dealId && form.dealLabel ? { id: form.dealId, label: form.dealLabel } : null,
205
+ [form.dealId, form.dealLabel],
206
+ )
207
+ const capabilities = React.useMemo(() => ({ resourcesEnabled, staffEnabled }), [resourcesEnabled, staffEnabled])
208
+
127
209
  useEditorLabelResolution(open, form, update)
128
210
  // Probe against the owner the interaction will actually be SAVED with so the
129
211
  // editor warning matches the grid's post-save conflict rings: tasks own via
@@ -133,24 +215,13 @@ function EditorBody({
133
215
  // Self-exclude by the underlying interaction id (raw.id): findEditorConflictItems
134
216
  // drops candidates by raw.id, and every expanded occurrence of a recurring series
135
217
  // shares it — so the edited record never conflicts with itself.
136
- const conflict = useConflictProbe(open, form, config, isEdit && item ? item.raw.id : null, draftOwnerUserId, conflictScope, currentUserId)
218
+ // Resources count toward a conflict only when the selected type can book them.
219
+ const draftResources = resourcesEnabled && definition.behavior.fields.resources ? form.resources : NO_RESOURCES
220
+ const conflict = useConflictProbe(open, form, config, isEdit && item ? item.raw.id : null, draftOwnerUserId, conflictScope, currentUserId, draftResources)
137
221
 
138
- const kindLabels = React.useMemo(
139
- () => ({
140
- meeting: t('customers.calendar.editor.types.meeting', 'meeting'),
141
- call: t('customers.calendar.editor.types.call', 'call'),
142
- email: t('customers.calendar.editor.types.email', 'email'),
143
- note: t('customers.calendar.editor.types.note', 'note'),
144
- event: t('customers.calendar.editor.types.event', 'event'),
145
- task: t('customers.calendar.editor.types.task', 'task'),
146
- }),
147
- [t],
148
- ) satisfies Record<EditorKind, string>
149
-
150
- const selectedType = form.category ?? form.kind
151
222
  const typeOptions = React.useMemo(
152
- () => buildEditorTypeOptions({ typeLabels, typeIcons, selectedValue: selectedType, kindLabels }),
153
- [typeLabels, typeIcons, selectedType, kindLabels],
223
+ () => eventTypeOptions(catalogItems, selectedType, t),
224
+ [catalogItems, selectedType, t],
154
225
  )
155
226
  const typeSwitcherOptions = React.useMemo(
156
227
  () =>
@@ -162,18 +233,6 @@ function EditorBody({
162
233
  [typeOptions],
163
234
  )
164
235
 
165
- // Candidates for the Call "insert phone from contact" button: the linked
166
- // person (companies have no phone) + customer attendees. Their userId is the
167
- // person id, so the picker can resolve primary_phone from the people API.
168
- const phoneContactIds = React.useMemo(() => {
169
- const ids: string[] = []
170
- if (form.relatedTo && form.relatedTo.kind !== 'company') ids.push(form.relatedTo.id)
171
- for (const participant of form.participants) {
172
- if (participant.isCustomer && participant.userId) ids.push(participant.userId)
173
- }
174
- return Array.from(new Set(ids))
175
- }, [form.relatedTo, form.participants])
176
-
177
236
  const titleLabel = form.kind === 'email'
178
237
  ? t('customers.calendar.editor.titleLabel.email', 'Subject')
179
238
  : form.kind === 'note'
@@ -181,11 +240,6 @@ function EditorBody({
181
240
  : t('customers.calendar.editor.titleLabel.generic', 'Title')
182
241
 
183
242
  return (
184
- // Single column on phones (full-screen sheet). On lg+ the dialog widens and
185
- // the fields group into two thematic columns: WHEN (schedule + repeat) on
186
- // the left, CONTEXT (related record, category, location) on the right;
187
- // people/resources and the task fields pair up below; title, description
188
- // and the type switcher span both columns.
189
243
  <div className="grid w-full grid-cols-1 items-start gap-4 lg:grid-cols-2 lg:gap-x-6">
190
244
  {conflict ? (
191
245
  <Alert status="warning" className="rounded-lg lg:col-span-2">
@@ -193,11 +247,20 @@ function EditorBody({
193
247
  <AlertDescription>{conflict}</AlertDescription>
194
248
  </Alert>
195
249
  ) : null}
250
+ {!catalogReady ? (
251
+ <CatalogStatusAlert error={catalogError} onRetry={onRetryCatalog} />
252
+ ) : null}
253
+ {catalogReady && (definition.historical || catalogItems.some((item) => item.key === selectedType && !item.selectable)) ? (
254
+ <Alert status="warning" className="rounded-lg lg:col-span-2">
255
+ <AlertDescription>{t('customers.calendar.editor.historicalType')}</AlertDescription>
256
+ </Alert>
257
+ ) : null}
196
258
  <div className="w-full lg:col-span-2">
197
- <SegmentGroup<string>
259
+ <TypeSwitcher<string>
198
260
  ariaLabel={t('customers.calendar.editor.typeSwitcher', 'Event type')}
199
261
  value={selectedType}
200
- onChange={(type) => update({ kind: editorKindOfInteractionType(type), category: type })}
262
+ onChange={selectType}
263
+ disabled={!catalogReady}
201
264
  options={typeSwitcherOptions}
202
265
  />
203
266
  </div>
@@ -212,127 +275,33 @@ function EditorBody({
212
275
  size="lg"
213
276
  />
214
277
  </Field>
215
- <div className="flex w-full flex-col gap-4">
216
- <ScheduleSection
217
- dateLabel={config.dateLabel}
218
- hasAllDay={config.hasAllDay}
219
- hasEnd={config.hasEnd}
220
- allDay={form.allDay}
221
- date={form.date}
222
- startTime={form.startTime}
223
- endDate={form.endDate}
224
- endTime={form.endTime}
225
- endsError={errors.ends}
226
- onAllDayChange={(allDay) => update({ allDay })}
227
- onDateChange={(date) => {
228
- const untouchedDefault =
229
- JSON.stringify(form.repeatDays) === JSON.stringify(defaultRepeatDaysForDateInput(form.date))
230
- update(
231
- untouchedDefault
232
- ? { date, repeatDays: defaultRepeatDaysForDateInput(date) }
233
- : { date },
234
- )
235
- }}
236
- onStartTimeChange={(startTime) => update({ startTime })}
237
- onEndDateChange={(endDate) => update({ endDate })}
238
- onEndTimeChange={(endTime) => update({ endTime })}
239
- />
240
- {config.hasRepeat ? (
241
- <RepeatField
242
- freq={form.repeatFreq}
243
- days={form.repeatDays}
244
- endType={form.repeatEndType}
245
- count={form.repeatCount}
246
- untilDate={form.repeatUntilDate}
247
- onFreqChange={(repeatFreq) =>
248
- update(
249
- repeatFreq === 'weekly'
250
- ? { repeatFreq, repeatDays: defaultRepeatDaysForDateInput(form.date) }
251
- : { repeatFreq },
252
- )}
253
- onToggleDay={(index) =>
254
- update({ repeatDays: form.repeatDays.map((active, dayIndex) => (dayIndex === index ? !active : active)) })}
255
- onEndTypeChange={(repeatEndType) => update({ repeatEndType })}
256
- onCountChange={(repeatCount) => update({ repeatCount })}
257
- onUntilDateChange={(repeatUntilDate) => update({ repeatUntilDate })}
258
- />
259
- ) : null}
260
- {config.people && config.people !== 'assignee' ? (
261
- <Field label={t(PEOPLE_FIELD_TEXT[config.people].labelKey, PEOPLE_FIELD_TEXT[config.people].label)}>
262
- <PeopleField
263
- mode="multi"
264
- includeCustomers
265
- includeStaff={staffEnabled}
266
- placeholder={t(PEOPLE_FIELD_TEXT[config.people].placeholderKey, PEOPLE_FIELD_TEXT[config.people].placeholder)}
267
- ariaLabel={t(PEOPLE_FIELD_TEXT[config.people].labelKey, PEOPLE_FIELD_TEXT[config.people].label)}
268
- value={form.participants}
269
- onChange={(participants) => update({ participants })}
278
+ <TimezoneField value={form.timezone} error={errors.timezone} disabled={saving} onChange={updateTimezone} />
279
+ <div className="lg:col-span-2">
280
+ <Field label={t('customers.calendar.editor.relatedTo', 'Related to')} error={errors.relatedTo}>
281
+ <MemoRelatedToField
282
+ label={t('customers.calendar.editor.relatedTo', 'Related to')}
283
+ value={form.relatedTo}
284
+ deal={deal}
285
+ onChange={updateRelatedTo}
286
+ onDealChange={updateDeal}
287
+ error={errors.relatedTo}
270
288
  />
271
289
  </Field>
272
- ) : null}
273
- {config.people === 'assignee' && staffEnabled ? (
274
- <Field label={t('customers.calendar.editor.assignee', 'Assignee')} error={errors.assignee}>
275
- <PeopleField
276
- mode="single"
277
- includeCustomers={false}
278
- includeStaff
279
- placeholder={t('customers.calendar.editor.assigneePlaceholder', 'Assign to a team member…')}
280
- ariaLabel={t('customers.calendar.editor.assignee', 'Assignee')}
281
- value={form.assigneeUserId
282
- ? [{ userId: form.assigneeUserId, name: form.assigneeName ?? form.assigneeUserId, isCustomer: false }]
283
- : []}
284
- onChange={(entries) => {
285
- const next = entries[entries.length - 1] ?? null
286
- update({ assigneeUserId: next?.userId ?? null, assigneeName: next?.name ?? null })
287
- }}
288
- />
289
- </Field>
290
- ) : null}
291
290
  </div>
292
- <div className="flex w-full flex-col gap-4">
293
- <Field label={t('customers.calendar.editor.relatedTo', 'Related to')} error={errors.relatedTo}>
294
- <RelatedToField
295
- label={t('customers.calendar.editor.relatedTo', 'Related to')}
296
- value={form.relatedTo}
297
- deal={form.dealId && form.dealLabel ? { id: form.dealId, label: form.dealLabel } : null}
298
- onChange={(relatedTo) => update({ relatedTo })}
299
- onDealChange={(deal) => update({ dealId: deal?.id ?? null, dealLabel: deal?.label ?? null })}
300
- error={errors.relatedTo}
301
- />
302
- </Field>
303
- {config.location ? (
304
- <LocationField
305
- variant={config.location}
306
- value={form.location}
307
- onChange={(location) => update({ location })}
308
- phoneContactIds={phoneContactIds}
309
- />
310
- ) : null}
311
- {resourcesEnabled ? (
312
- <Field label={t('customers.calendar.editor.resources', 'Resources')}>
313
- <ResourcesField
314
- placeholder={t('customers.calendar.editor.resourcesPlaceholder', 'Add a resource…')}
315
- ariaLabel={t('customers.calendar.editor.resources', 'Resources')}
316
- value={form.resources}
317
- onChange={(resources) => update({ resources })}
318
- />
319
- </Field>
320
- ) : null}
321
- {config.hasPriority ? (
322
- <Field label={t('customers.calendar.editor.priority.label', 'Priority')}>
323
- <PriorityField
324
- value={form.priority}
325
- ariaLabel={t('customers.calendar.editor.priority.label', 'Priority')}
326
- labels={{
327
- low: t('customers.calendar.editor.priority.low', 'Low'),
328
- medium: t('customers.calendar.editor.priority.medium', 'Medium'),
329
- high: t('customers.calendar.editor.priority.high', 'High'),
330
- }}
331
- onChange={(priority) => update({ priority })}
291
+ {catalogReady ? (
292
+ <div className="lg:col-span-2">
293
+ <EventTypePanel
294
+ definition={definition}
295
+ panelKey={definition.panelKey}
296
+ mode={isEdit ? 'edit' : 'create'}
297
+ values={values}
298
+ errors={errors}
299
+ disabled={saving}
300
+ capabilities={capabilities}
301
+ setValue={setValue}
332
302
  />
333
- </Field>
303
+ </div>
334
304
  ) : null}
335
- </div>
336
305
  <Field label={t('customers.calendar.editor.description', 'Description')} className="lg:col-span-2">
337
306
  <Textarea
338
307
  value={form.description}
@@ -344,7 +313,7 @@ function EditorBody({
344
313
  </Field>
345
314
  </div>
346
315
  )
347
- }
316
+ })
348
317
 
349
318
  export function CalendarEventEditor({
350
319
  open,
@@ -352,8 +321,6 @@ export function CalendarEventEditor({
352
321
  item,
353
322
  defaultDate,
354
323
  defaultRange,
355
- typeLabels,
356
- typeIcons,
357
324
  conflictScope,
358
325
  currentUserId,
359
326
  resourcesEnabled,
@@ -363,7 +330,54 @@ export function CalendarEventEditor({
363
330
  }: CalendarEventEditorProps) {
364
331
  const t = useT()
365
332
  const [saving, setSaving] = React.useState(false)
333
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
334
+ const dirtyRef = React.useRef(false)
335
+ const closeConfirmPendingRef = React.useRef(false)
336
+ const [closePromptOpen, setClosePromptOpen] = React.useState(false)
337
+ // The shared confirm answers Cmd/Ctrl+Enter with its confirm action, which
338
+ // here discards the draft — on the very shortcut that saves the form. The
339
+ // shortcut is swallowed while the prompt is up; the buttons and Escape (keep
340
+ // editing) still answer it. Owned by an effect so the listener is removed
341
+ // even when the editor unmounts with the prompt still open.
342
+ React.useEffect(() => {
343
+ if (!closePromptOpen) return
344
+ const swallowSubmitShortcut = (event: KeyboardEvent) => {
345
+ if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return
346
+ event.preventDefault()
347
+ event.stopImmediatePropagation()
348
+ }
349
+ window.addEventListener('keydown', swallowSubmitShortcut, true)
350
+ return () => window.removeEventListener('keydown', swallowSubmitShortcut, true)
351
+ }, [closePromptOpen])
352
+ const requestClose = React.useCallback(async () => {
353
+ if (saving || closeConfirmPendingRef.current) return
354
+ if (dirtyRef.current) {
355
+ closeConfirmPendingRef.current = true
356
+ setClosePromptOpen(true)
357
+ try {
358
+ const confirmed = await confirm({
359
+ title: t('ui.forms.confirmUnsavedChanges', 'You have unsaved changes. Are you sure you want to leave?'),
360
+ confirmText: t('customers.deals.detail.unsavedConfirm', 'Discard changes'),
361
+ cancelText: t('customers.deals.detail.unsavedCancel', 'Keep editing'),
362
+ variant: 'destructive',
363
+ })
364
+ if (!confirmed) return
365
+ } finally {
366
+ closeConfirmPendingRef.current = false
367
+ setClosePromptOpen(false)
368
+ }
369
+ }
370
+ onOpenChange(false)
371
+ }, [confirm, onOpenChange, saving, t])
372
+ const handleDirtyChange = React.useCallback((dirty: boolean) => { dirtyRef.current = dirty }, [])
373
+ React.useEffect(() => { if (!open) dirtyRef.current = false }, [open])
366
374
  const isEdit = mode === 'edit' && Boolean(item?.id)
375
+ const catalog = useEventTypeCatalog(open)
376
+ const { items: catalogItems, status: catalogStatus, retry: retryCatalog } = catalog
377
+ const [selectedTypeKey, setSelectedTypeKey] = React.useState(() => isEdit && item ? item.interactionType : 'meeting')
378
+ const autoSelectedCreateType = !isEdit && catalog.status === 'ready' && !isSelectableEventType(catalog.items, 'meeting')
379
+ ? catalog.items.find((entry) => entry.selectable && !entry.historical) ?? null
380
+ : null
367
381
 
368
382
  // CrudForm reads initialValues once, so every dialog open gets a fresh form
369
383
  // instance keyed by the open sequence + edited record.
@@ -373,7 +387,7 @@ export function CalendarEventEditor({
373
387
  if (open && !wasOpenRef.current) setOpenSeq((seq) => seq + 1)
374
388
  wasOpenRef.current = open
375
389
  }, [open])
376
- const formKey = `${mode}:${item?.id ?? 'new'}:${openSeq}`
390
+ const formKey = `${mode}:${item?.id ?? 'new'}:${openSeq}:${autoSelectedCreateType?.key ?? 'default'}`
377
391
 
378
392
  const initialValues = React.useMemo<Record<string, unknown>>(() => {
379
393
  if (isEdit && item) {
@@ -384,22 +398,56 @@ export function CalendarEventEditor({
384
398
  updatedAt: item.updatedAt ?? undefined,
385
399
  }
386
400
  }
387
- return { ...createDefaultFormState(defaultDate ?? null, undefined, defaultRange ?? null) }
401
+ const defaults = createDefaultFormState(defaultDate ?? null, undefined, defaultRange ?? null)
402
+ if (!autoSelectedCreateType) return { ...defaults }
403
+ return {
404
+ ...defaults,
405
+ kind: autoSelectedCreateType.behavior.baseKind,
406
+ category: autoSelectedCreateType.key,
407
+ }
388
408
  // eslint-disable-next-line react-hooks/exhaustive-deps -- openSeq re-seeds defaults per dialog open
389
- }, [isEdit, item, defaultDate, defaultRange, openSeq])
409
+ }, [isEdit, item, defaultDate, defaultRange, openSeq, autoSelectedCreateType])
410
+ const initialTypeKey = String(initialValues.category ?? initialValues.kind ?? 'meeting')
411
+ React.useEffect(() => { setSelectedTypeKey(initialTypeKey) }, [initialTypeKey, formKey])
412
+ const selectedDefinition = selectedEventType(catalog.items, selectedTypeKey)
413
+ const customFields = useCalendarCustomFields(open, E.customers.customer_interaction, selectedDefinition.behavior.customFieldsetIds)
414
+ const injectionWidgets = useInjectionWidgets(INTERACTION_INJECTION_SPOT)
415
+ const canCreateType = isSelectableEventType(catalog.items, selectedTypeKey)
416
+ const formReady = isEdit || catalog.status === 'ready'
390
417
 
391
418
  const handleSubmit = React.useCallback(
392
419
  async (values: Record<string, unknown>) => {
393
420
  const form = formStateOfValues(values)
394
- const config = KIND_CONFIG[form.kind]
421
+ if (catalogStatus !== 'ready' || customFields.status !== 'ready' || injectionWidgets.loading || injectionWidgets.error) {
422
+ throw createCrudFormError(t('customers.calendar.editor.catalogLoadFailed'))
423
+ }
424
+ const definition = selectedEventType(catalogItems, form.category ?? form.kind)
425
+ const selectedKey = form.category ?? form.kind
426
+ if ((mode === 'create' || selectedKey !== initialTypeKey) &&
427
+ !isSelectableEventType(catalogItems, selectedKey)) {
428
+ throw createCrudFormError(t('customers.calendar.editor.typeUnavailable'))
429
+ }
430
+ const config = eventTypeConfig(definition)
395
431
  const fieldErrors: Record<string, string> = {}
432
+ const timezone = form.timezone ?? defaultCalendarTimezone()
433
+ const startTime = form.allDay && config.hasAllDay ? '00:00' : form.startTime
434
+ if (!isCalendarTimezone(timezone)) fieldErrors.timezone = t('customers.calendar.editor.validation.timezoneInvalid', 'Choose a valid time zone')
435
+ else if (!(form.allDay && config.hasAllDay ? calendarDayStartInstant(form.date, timezone) : calendarWallTimeToInstant(form.date, startTime, timezone))) {
436
+ fieldErrors[form.allDay && config.hasAllDay ? 'date' : 'startTime'] = t('customers.calendar.editor.validation.timezoneGap', 'This local time does not exist in the selected time zone')
437
+ }
438
+ if (config.hasEnd && !form.allDay && isCalendarTimezone(timezone) && !calendarWallTimeToInstant(form.endDate, form.endTime, timezone)) {
439
+ fieldErrors.ends = t('customers.calendar.editor.validation.timezoneGap', 'This local time does not exist in the selected time zone')
440
+ }
441
+ if (config.hasRepeat && form.repeatFreq !== 'none' && isCalendarTimezone(timezone) && form.repeatEndType === 'date' && form.repeatUntilDate && !calendarDayEndInstant(form.repeatUntilDate, timezone)) {
442
+ fieldErrors.repeatUntilDate = t('customers.calendar.editor.validation.timezoneGap', 'This local time does not exist in the selected time zone')
443
+ }
396
444
  if (!form.title.trim()) {
397
445
  fieldErrors.title = t('customers.calendar.editor.validation.titleRequired', 'Title is required')
398
446
  }
399
447
  if (!form.relatedTo) {
400
448
  fieldErrors.relatedTo = t('customers.calendar.editor.validation.relatedToRequired', 'Select a person or company to link this event')
401
449
  }
402
- if (config.hasEnd && !form.allDay && computeDurationMinutes(form) === null) {
450
+ if (!fieldErrors.ends && !fieldErrors.startTime && !fieldErrors.date && config.hasEnd && !form.allDay && computeDurationMinutes(form) === null) {
403
451
  fieldErrors.ends = t('customers.calendar.editor.validation.endsBeforeStarts', 'End must be after start')
404
452
  }
405
453
  // A task must be assigned to a team member — surface the requirement inline
@@ -410,25 +458,89 @@ export function CalendarEventEditor({
410
458
  if (Object.keys(fieldErrors).length > 0) {
411
459
  throw createCrudFormError(Object.values(fieldErrors)[0], fieldErrors)
412
460
  }
461
+ const customValidation = validateCalendarCustomValues(values, customFields.definitions)
462
+ if (!customValidation.ok) {
463
+ const translatedErrors = Object.fromEntries(Object.entries(customValidation.fieldErrors).map(([key, message]) => [key, t(message, message)]))
464
+ throw createCrudFormError(Object.values(translatedErrors)[0], translatedErrors)
465
+ }
413
466
  setSaving(true)
414
467
  try {
415
- const payload = buildInteractionPayload(form, {
468
+ const payload = buildInteractionPayload({ ...form, timezone }, {
469
+ config,
416
470
  mode,
417
471
  id: item?.id,
418
472
  resourcesEnabled: resourcesEnabled === true,
419
473
  staffEnabled: staffEnabled !== false,
420
474
  })
421
- const custom = collectCustomFieldValues(values, {
475
+ payload.enforceSelectableType = true
476
+ const applicable = definition.behavior.fields
477
+ const time = form.allDay && applicable.allDay ? '00:00' : form.startTime
478
+ payload.time = time
479
+ payload.scheduledAt = (form.allDay && applicable.allDay ? calendarDayStartInstant(form.date, timezone) : calendarWallTimeToInstant(form.date, time, timezone))!.toISOString()
480
+ payload.timezone = timezone
481
+ payload.allDay = applicable.allDay ? form.allDay : null
482
+ payload.durationMinutes = applicable.endTime && !(form.allDay && applicable.allDay) ? computeDurationMinutes(form) : null
483
+ payload.location = applicable.location === 'none' ? null : form.location.trim() || null
484
+ const recurrenceRule = applicable.recurrence ? buildRecurrenceRule(form) : null
485
+ payload.recurrenceRule = recurrenceRule
486
+ payload.recurrenceEnd = recurrenceRule && form.repeatEndType === 'date' && form.repeatUntilDate
487
+ ? calendarDayEndInstant(form.repeatUntilDate, timezone)!.toISOString()
488
+ : null
489
+ payload.participants = applicable.people !== 'none' && applicable.people !== 'assignee' && form.participants.length
490
+ ? form.participants.map((participant) => ({
491
+ userId: participant.userId,
492
+ name: participant.name,
493
+ email: participant.email,
494
+ status: participant.isCustomer ? 'customer' : 'pending',
495
+ }))
496
+ : null
497
+ if (applicable.people === 'assignee' && staffEnabled !== false) payload.ownerUserId = form.assigneeUserId ?? null
498
+ else delete payload.ownerUserId
499
+ if (applicable.priority) payload.priority = form.priority === 'low' ? 10 : form.priority === 'high' ? 90 : 50
500
+ else delete payload.priority
501
+ if (!applicable.resources) delete payload.linkedEntities
502
+ const custom = collectCustomFieldValues(projectCalendarCustomValues(values, customFields.definitions), {
422
503
  transform: (value) => normalizeCustomFieldSubmitValue(value),
423
504
  })
424
505
  for (const [key, value] of Object.entries(custom)) payload[`cf_${key}`] = value
425
506
  // CrudForm supplies the optimistic-lock header (auto-derived from
426
507
  // initialValues.updatedAt) via scoped request headers around onSubmit.
427
- await apiCallOrThrow('/api/customers/interactions', {
508
+ const submitInteraction = (confirmDiscardInapplicableValues: boolean) => apiCallOrThrow('/api/customers/interactions', {
428
509
  method: isEdit ? 'PUT' : 'POST',
429
510
  headers: { 'content-type': 'application/json' },
430
- body: JSON.stringify(payload),
511
+ body: JSON.stringify(confirmDiscardInapplicableValues
512
+ ? { ...payload, confirmDiscardInapplicableValues: true }
513
+ : payload),
431
514
  })
515
+ try {
516
+ await submitInteraction(false)
517
+ } catch (error) {
518
+ const fields = typeChangeDiscardFields(error)
519
+ if (!fields) throw error
520
+ const approved = await confirm({
521
+ title: t('customers.calendar.editor.discardTitle'),
522
+ text: `${t('customers.calendar.editor.discardDescription')} ${t('customers.calendar.editor.discardFields')} ${fields.map((field) => {
523
+ const definition = customFields.allDefinitions.find((entry) => `cf_${entry.key}` === field)
524
+ if (definition?.label) return definition.label
525
+ const labelKeys: Record<string, string> = {
526
+ location: 'customers.calendar.editor.location',
527
+ participants: 'customers.calendar.editor.attendees',
528
+ linkedEntities: 'customers.calendar.editor.resources',
529
+ allDay: 'customers.calendar.editor.allDay',
530
+ durationMinutes: 'customers.calendar.editor.dates.ends',
531
+ priority: 'customers.calendar.editor.priority.label',
532
+ recurrenceRule: 'customers.calendar.editor.repeat.label',
533
+ recurrenceEnd: 'customers.calendar.editor.repeat.ends',
534
+ }
535
+ return t(labelKeys[field] ?? 'customers.calendar.editor.discardUnknownField')
536
+ }).join(', ')}`,
537
+ confirmText: t('customers.calendar.editor.discardConfirm'),
538
+ cancelText: t('customers.calendar.editor.cancel'),
539
+ variant: 'destructive',
540
+ })
541
+ if (!approved) return
542
+ await submitInteraction(true)
543
+ }
432
544
  flash(t('customers.calendar.editor.saved', 'Event saved'), 'success')
433
545
  onOpenChange(false)
434
546
  requestAnimationFrame(() => { onSaved() })
@@ -445,7 +557,7 @@ export function CalendarEventEditor({
445
557
  setSaving(false)
446
558
  }
447
559
  },
448
- [isEdit, item?.id, mode, onOpenChange, onSaved, resourcesEnabled, staffEnabled, t],
560
+ [catalogItems, catalogStatus, customFields.status, customFields.definitions, customFields.allDefinitions, injectionWidgets.loading, injectionWidgets.error, confirm, initialTypeKey, isEdit, item?.id, mode, onOpenChange, onSaved, resourcesEnabled, staffEnabled, t],
449
561
  )
450
562
 
451
563
  const groups = React.useMemo<CrudFormGroup[]>(
@@ -455,36 +567,43 @@ export function CalendarEventEditor({
455
567
  bare: true,
456
568
  component: (ctx) => (
457
569
  <EditorBody
458
- ctx={ctx}
570
+ values={ctx.values}
571
+ errors={ctx.errors}
572
+ setValue={ctx.setValue}
459
573
  open={open}
460
574
  isEdit={isEdit}
461
575
  item={item}
462
- typeLabels={typeLabels}
463
- typeIcons={typeIcons ?? {}}
464
576
  conflictScope={conflictScope ?? 'all'}
465
577
  currentUserId={currentUserId ?? null}
466
578
  resourcesEnabled={resourcesEnabled === true}
467
579
  staffEnabled={staffEnabled !== false}
580
+ saving={saving}
581
+ catalogItems={catalogItems}
582
+ catalogReady={catalogStatus === 'ready'}
583
+ catalogError={catalogStatus === 'error'}
584
+ onRetryCatalog={retryCatalog}
585
+ onSelectedTypeChange={setSelectedTypeKey}
468
586
  />
469
587
  ),
470
588
  },
471
- { id: 'customFields', kind: 'customFields' },
589
+ ...(customFields.fields.length ? [{ id: 'customFields', title: t('entities.customFields.title'), fields: customFields.fields.map((field) => field.id) }] : []),
472
590
  ],
473
- [open, isEdit, item, typeLabels, typeIcons, conflictScope, currentUserId, resourcesEnabled, staffEnabled],
591
+ [open, isEdit, item, conflictScope, currentUserId, resourcesEnabled, staffEnabled, saving, catalogItems, catalogStatus, retryCatalog, customFields.fields, t],
474
592
  )
475
593
 
476
594
  const handleKeyDown = useDialogKeyHandler({
595
+ onCancel: () => { void requestClose() },
477
596
  onConfirm: () => {
478
597
  const formElement = document.getElementById(FORM_ID)
479
598
  if (formElement instanceof HTMLFormElement) formElement.requestSubmit()
480
599
  },
481
- disabled: saving,
600
+ disabled: saving || customFields.status !== 'ready' || injectionWidgets.loading || !!injectionWidgets.error || catalog.status !== 'ready' || (mode === 'create' && !canCreateType),
482
601
  })
483
602
 
484
603
  const dialogTitle = isEdit ? t('customers.calendar.editor.title.edit', 'Edit event') : t('customers.calendar.editor.title.create', 'New event')
485
604
 
486
605
  return (
487
- <Dialog open={open} onOpenChange={onOpenChange}>
606
+ <Dialog open={open} onOpenChange={(nextOpen) => { if (nextOpen) onOpenChange(true); else void requestClose() }}>
488
607
  <DialogContent
489
608
  onKeyDown={handleKeyDown}
490
609
  // Only dismiss on a genuine click OUTSIDE the whole editor. Radix
@@ -492,10 +611,13 @@ export function CalendarEventEditor({
492
611
  // DatePicker/Select popover) up to the dialog as an "interact
493
612
  // outside" event when it dismisses that popover — without this guard
494
613
  // it would tear down the entire editor. Keep it open when the target
495
- // is inside the dialog body or inside any portalled popover.
614
+ // is inside the dialog body or inside any portalled popover. The
615
+ // shared confirm renders as a native modal `<dialog>` on top of the
616
+ // editor; answering it is not an outside click either, otherwise it
617
+ // would re-request the close it is asking about.
496
618
  onInteractOutside={(event) => {
497
619
  const target = event.detail.originalEvent.target as HTMLElement | null
498
- if (target?.closest('[data-dialog-content]') || target?.closest('[data-radix-popper-content-wrapper]')) {
620
+ if (target?.closest('[data-dialog-content]') || target?.closest('[data-radix-popper-content-wrapper]') || target?.closest('dialog')) {
499
621
  event.preventDefault()
500
622
  }
501
623
  }}
@@ -512,7 +634,7 @@ export function CalendarEventEditor({
512
634
  <IconButton
513
635
  variant="ghost"
514
636
  size="sm"
515
- onClick={() => onOpenChange(false)}
637
+ onClick={() => { void requestClose() }}
516
638
  aria-label={t('customers.calendar.editor.close', 'Close')}
517
639
  className="shrink-0 text-muted-foreground"
518
640
  >
@@ -530,18 +652,24 @@ export function CalendarEventEditor({
530
652
  }}
531
653
  >
532
654
  <div className="px-4 py-4 sm:px-6 sm:py-5">
533
- <CrudForm<Record<string, unknown>>
534
- key={formKey}
535
- formId={FORM_ID}
536
- embedded
537
- hideFooterActions
538
- customFieldsManageMode="page"
539
- fields={[]}
540
- groups={groups}
541
- initialValues={initialValues}
542
- entityIds={INTERACTION_ENTITY_IDS}
543
- onSubmit={handleSubmit}
544
- />
655
+ {formReady ? (
656
+ <CrudForm<Record<string, unknown>>
657
+ key={formKey}
658
+ formId={FORM_ID}
659
+ embedded
660
+ trackDirtyWhenEmbedded
661
+ onDirtyChange={handleDirtyChange}
662
+ hideFooterActions
663
+ customFieldsManageMode="page"
664
+ fields={customFields.fields}
665
+ groups={groups}
666
+ initialValues={initialValues}
667
+ injectionSpotId={INTERACTION_INJECTION_SPOT}
668
+ onSubmit={handleSubmit}
669
+ />
670
+ ) : (
671
+ <CatalogStatusAlert error={catalog.status === 'error'} onRetry={catalog.retry} />
672
+ )}
545
673
  </div>
546
674
  </div>
547
675
  <div className="flex shrink-0 items-center justify-end gap-3 border-t border-border bg-background px-5 py-4">
@@ -555,12 +683,13 @@ export function CalendarEventEditor({
555
683
  <Button
556
684
  type="submit"
557
685
  form={FORM_ID}
558
- disabled={saving}
686
+ disabled={saving || customFields.status !== 'ready' || injectionWidgets.loading || !!injectionWidgets.error || catalog.status !== 'ready' || (mode === 'create' && !canCreateType)}
559
687
  >
560
688
  {saving ? t('customers.calendar.editor.saving', 'Saving…') : t('customers.calendar.editor.save', 'Save event')}
561
689
  </Button>
562
690
  </div>
563
691
  </DialogContent>
692
+ {ConfirmDialogElement}
564
693
  </Dialog>
565
694
  )
566
695
  }