@open-mercato/core 0.8.1-develop.7454.1.010095490f → 0.8.1-develop.7455.1.8f7e53badb

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 (155) 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/customers/api/activity-types/route.js +151 -0
  9. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  10. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  11. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  12. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  13. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  14. package/dist/modules/customers/api/interactions/route.js +3 -0
  15. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  16. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  17. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  18. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  19. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  20. package/dist/modules/customers/calendar-event-types.js +548 -0
  21. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  22. package/dist/modules/customers/commands/dictionaries.js +70 -10
  23. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  24. package/dist/modules/customers/commands/interactions.js +187 -22
  25. package/dist/modules/customers/commands/interactions.js.map +3 -3
  26. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  27. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  28. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  29. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  30. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  31. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  32. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  33. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  34. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  35. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  36. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  37. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  38. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  39. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  40. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  41. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  42. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  43. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  44. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  45. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  46. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  47. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  48. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  49. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  50. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  51. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  52. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  53. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  54. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  55. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  56. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  57. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  58. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  59. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  60. package/dist/modules/customers/components/calendar/types.js +1 -0
  61. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  62. package/dist/modules/customers/data/entities.js +6 -0
  63. package/dist/modules/customers/data/entities.js.map +2 -2
  64. package/dist/modules/customers/data/validators.js +39 -10
  65. package/dist/modules/customers/data/validators.js.map +2 -2
  66. package/dist/modules/customers/di.js +20 -1
  67. package/dist/modules/customers/di.js.map +2 -2
  68. package/dist/modules/customers/extension-points.js +13 -0
  69. package/dist/modules/customers/extension-points.js.map +2 -2
  70. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  71. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  72. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  73. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  74. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  75. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  76. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  77. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  78. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  79. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  80. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  81. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  82. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  83. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  84. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  85. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  86. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  87. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  88. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  89. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  90. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  91. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  92. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  93. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  94. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  95. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  96. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  97. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  98. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  99. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  100. package/generated/entities/customer_interaction/index.ts +1 -0
  101. package/generated/entity-fields-registry.ts +2 -0
  102. package/package.json +7 -7
  103. package/src/modules/customers/api/activity-types/route.ts +154 -0
  104. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  105. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  106. package/src/modules/customers/api/interactions/route.ts +4 -0
  107. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  108. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  109. package/src/modules/customers/calendar-event-types.ts +674 -0
  110. package/src/modules/customers/commands/dictionaries.ts +84 -8
  111. package/src/modules/customers/commands/interactions.ts +244 -21
  112. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  113. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  114. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  115. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  116. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  117. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  118. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  119. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  120. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  121. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  122. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  123. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  124. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  125. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  126. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  127. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  128. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  129. package/src/modules/customers/components/calendar/types.ts +1 -0
  130. package/src/modules/customers/components/detail/types.ts +1 -0
  131. package/src/modules/customers/data/entities.ts +8 -1
  132. package/src/modules/customers/data/validators.ts +42 -7
  133. package/src/modules/customers/di.ts +21 -0
  134. package/src/modules/customers/extension-points.ts +13 -0
  135. package/src/modules/customers/i18n/de.json +83 -0
  136. package/src/modules/customers/i18n/en.json +83 -0
  137. package/src/modules/customers/i18n/es.json +83 -0
  138. package/src/modules/customers/i18n/ko.json +83 -0
  139. package/src/modules/customers/i18n/pl.json +83 -0
  140. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  141. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  142. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  143. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  144. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  145. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  146. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  147. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  148. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  149. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  150. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  151. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  152. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  153. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  154. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  155. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
@@ -21,6 +21,8 @@ function DateTimeRow({
21
21
  showTime,
22
22
  onDateChange,
23
23
  onTimeChange,
24
+ error,
25
+ disabled,
24
26
  }: {
25
27
  label: string
26
28
  date: string
@@ -28,14 +30,17 @@ function DateTimeRow({
28
30
  showTime: boolean
29
31
  onDateChange(next: string): void
30
32
  onTimeChange(next: string): void
33
+ error?: string | null
34
+ disabled?: boolean
31
35
  }) {
32
36
  return (
33
- <div className="flex w-full items-end gap-2.5">
37
+ <div className="flex w-full flex-wrap items-end gap-x-2.5 gap-y-1">
34
38
  <div className="flex min-w-0 flex-1 flex-col gap-1.5">
35
39
  <span className={LABEL_CLASS}>{label}</span>
36
- <DateControl value={date} onChange={onDateChange} ariaLabel={label} />
40
+ <DateControl value={date} onChange={onDateChange} ariaLabel={label} disabled={disabled} />
37
41
  </div>
38
- {showTime ? <TimeControl value={time} onChange={onTimeChange} ariaLabel={label} /> : null}
42
+ {showTime ? <TimeControl value={time} onChange={onTimeChange} ariaLabel={label} disabled={disabled} /> : null}
43
+ {error ? <p className="w-full text-xs text-status-error-text">{error}</p> : null}
39
44
  </div>
40
45
  )
41
46
  }
@@ -49,7 +54,9 @@ export function ScheduleSection({
49
54
  startTime,
50
55
  endDate,
51
56
  endTime,
57
+ startError,
52
58
  endsError,
59
+ disabled,
53
60
  onAllDayChange,
54
61
  onDateChange,
55
62
  onStartTimeChange,
@@ -64,7 +71,9 @@ export function ScheduleSection({
64
71
  startTime: string
65
72
  endDate: string
66
73
  endTime: string
74
+ startError?: string | null
67
75
  endsError?: string | null
76
+ disabled?: boolean
68
77
  onAllDayChange(next: boolean): void
69
78
  onDateChange(next: string): void
70
79
  onStartTimeChange(next: string): void
@@ -79,7 +88,7 @@ export function ScheduleSection({
79
88
  {hasAllDay ? (
80
89
  <div className="flex w-full items-center justify-between">
81
90
  <span className={LABEL_CLASS}>{t('customers.calendar.editor.allDay', 'All day')}</span>
82
- <AllDayToggle checked={allDay} onCheckedChange={onAllDayChange} label={t('customers.calendar.editor.allDay', 'All day')} />
91
+ <AllDayToggle checked={allDay} onCheckedChange={onAllDayChange} label={t('customers.calendar.editor.allDay', 'All day')} disabled={disabled} />
83
92
  </div>
84
93
  ) : null}
85
94
  <DateTimeRow
@@ -89,6 +98,8 @@ export function ScheduleSection({
89
98
  showTime={showTime}
90
99
  onDateChange={onDateChange}
91
100
  onTimeChange={onStartTimeChange}
101
+ error={startError}
102
+ disabled={disabled}
92
103
  />
93
104
  {hasEnd ? (
94
105
  <DateTimeRow
@@ -98,6 +109,7 @@ export function ScheduleSection({
98
109
  showTime={showTime}
99
110
  onDateChange={onEndDateChange}
100
111
  onTimeChange={onEndTimeChange}
112
+ disabled={disabled}
101
113
  />
102
114
  ) : null}
103
115
  {endsError ? <p className="text-xs text-status-error-text">{endsError}</p> : null}
@@ -12,12 +12,14 @@ export function SegmentGroup<T extends string>({
12
12
  onChange,
13
13
  size = 'sm',
14
14
  ariaLabel,
15
+ disabled = false,
15
16
  }: {
16
17
  options: Array<SegmentOption<T>>
17
18
  value: T
18
19
  onChange(next: T): void
19
20
  size?: 'sm' | 'md'
20
21
  ariaLabel: string
22
+ disabled?: boolean
21
23
  }) {
22
24
  return (
23
25
  <div
@@ -34,6 +36,7 @@ export function SegmentGroup<T extends string>({
34
36
  variant="ghost"
35
37
  aria-pressed={isActive}
36
38
  onClick={() => onChange(option.value)}
39
+ disabled={disabled}
37
40
  title={option.label}
38
41
  data-state={isActive ? 'active' : 'inactive'}
39
42
  className={cn(
@@ -1,11 +1,12 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { calendarDayEndInstant, calendarDayStartInstant, calendarWallTimeToInstant } from '../../../lib/calendar/timezone'
4
5
  import { endOfDay } from 'date-fns/endOfDay'
5
6
  import { startOfDay } from 'date-fns/startOfDay'
6
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
8
  import { computeDurationMinutes, type EditorFormState, type EditorKindConfig } from '../../../lib/calendar/editorPayload'
8
- import { findEditorConflictItems } from '../../../lib/calendar/conflicts'
9
+ import { findEditorConflicts } from '../../../lib/calendar/conflicts'
9
10
  import type { ConflictScope } from '../../../lib/calendar/preferences'
10
11
  import { mapInteractionToCalendarItem } from '../../../lib/calendar/mapItem'
11
12
  import { participantActorKey } from '../../../lib/calendar/participantIdentity'
@@ -55,12 +56,14 @@ export function useEditorLabelResolution(
55
56
  }, [activeOrgId, open, form.relatedTo, form.dealId, form.dealLabel, form.assigneeUserId, form.assigneeName, update])
56
57
  }
57
58
 
59
+ const NO_DRAFT_RESOURCES: ReadonlyArray<{ id: string; label: string }> = []
60
+
58
61
  function formatClock(date: Date): string {
59
62
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
60
63
  }
61
64
 
62
65
  // Debounced save-time conflict probe. Detection uses the SAME `findConflicts`
63
- // logic the grid uses (overlap + shared owner/participant) against a freshly
66
+ // logic the grid uses (overlap + shared owner/participant/resource) against a freshly
64
67
  // fetched ±1-day window, so the editor warning is always consistent with the
65
68
  // conflict badges/rings the user sees on the calendar. The warning is
66
69
  // informational and never blocks saving.
@@ -72,9 +75,11 @@ export function useConflictProbe(
72
75
  draftOwnerUserId: string | null,
73
76
  scope: ConflictScope,
74
77
  currentUserId: string | null,
78
+ draftResources: ReadonlyArray<{ id: string; label: string }> = NO_DRAFT_RESOURCES,
75
79
  ): string | null {
76
80
  const t = useT()
77
81
  const [conflict, setConflict] = React.useState<string | null>(null)
82
+ const resourcesKey = draftResources.map((resource) => resource.id).join(',')
78
83
  // Keys on the canonical actor key, not the raw userId — an external guest has
79
84
  // no userId, so a userId-only key never changes when guests are added or
80
85
  // removed and the probe would keep reporting the previous attendee set.
@@ -93,23 +98,26 @@ export function useConflictProbe(
93
98
  let start: Date
94
99
  let end: Date
95
100
  if (isAllDay) {
96
- const dayDate = new Date(`${form.date}T00:00:00`)
97
- if (Number.isNaN(dayDate.getTime())) {
101
+ const dayDate = form.timezone ? calendarDayStartInstant(form.date, form.timezone) : new Date(`${form.date}T00:00:00`)
102
+ if (!dayDate || Number.isNaN(dayDate.getTime())) {
98
103
  setConflict(null)
99
104
  return
100
105
  }
101
- start = startOfDay(dayDate)
102
- end = endOfDay(dayDate)
106
+ start = form.timezone ? dayDate : startOfDay(dayDate)
107
+ const dayEnd = form.timezone ? calendarDayEndInstant(form.date, form.timezone) : endOfDay(dayDate)
108
+ if (!dayEnd) { setConflict(null); return }
109
+ end = dayEnd
103
110
  } else {
104
111
  if (!form.startTime) {
105
112
  setConflict(null)
106
113
  return
107
114
  }
108
- start = new Date(`${form.date}T${form.startTime}:00`)
109
- if (Number.isNaN(start.getTime())) {
115
+ const draftStart = form.timezone ? calendarWallTimeToInstant(form.date, form.startTime, form.timezone) : new Date(`${form.date}T${form.startTime}:00`)
116
+ if (!draftStart || Number.isNaN(draftStart.getTime())) {
110
117
  setConflict(null)
111
118
  return
112
119
  }
120
+ start = draftStart
113
121
  const durationMinutes = config.hasEnd ? computeDurationMinutes(form) ?? 30 : 30
114
122
  end = new Date(start.getTime() + durationMinutes * 60_000)
115
123
  }
@@ -134,25 +142,33 @@ export function useConflictProbe(
134
142
  if (!item) continue
135
143
  others.push(...expandOccurrences(item, fetchWindow))
136
144
  }
137
- const conflictItems = findEditorConflictItems(
145
+ const conflicts = findEditorConflicts(
138
146
  {
139
147
  start,
140
148
  end,
141
149
  ownerUserId: draftOwnerUserId,
142
150
  participants: form.participants.map((participant) => ({ userId: participant.userId, name: participant.name })),
143
151
  status: form.status,
152
+ resources: [...draftResources],
153
+ ownerLabel: draftOwnerUserId && draftOwnerUserId === form.assigneeUserId ? form.assigneeName : null,
144
154
  },
145
155
  others,
146
156
  excludeId,
147
157
  { scope, currentUserId },
148
158
  )
149
159
  if (!active) return
150
- if (conflictItems.length === 0) {
160
+ if (conflicts.length === 0) {
151
161
  setConflict(null)
152
162
  return
153
163
  }
154
- const summary = conflictItems
155
- .map((item) => `${formatClock(item.start)}–${formatClock(item.end)}: ${item.title || t('customers.calendar.grid.untitled', 'Untitled')}`)
164
+ // Name who and what is double-booked: an overlap alone does not tell the
165
+ // user which attendee or room to change.
166
+ const summary = conflicts
167
+ .map(({ item, shared }) => {
168
+ const names = shared.map((subject) => subject.label).filter(Boolean)
169
+ const entry = `${formatClock(item.start)}–${formatClock(item.end)}: ${item.title || t('customers.calendar.grid.untitled', 'Untitled')}`
170
+ return names.length ? `${entry} (${names.join(', ')})` : entry
171
+ })
156
172
  .join(', ')
157
173
  setConflict(summary ? t('customers.calendar.editor.conflictWarning', 'Overlaps with: {items}', { items: summary }) : null)
158
174
  } catch {
@@ -165,6 +181,6 @@ export function useConflictProbe(
165
181
  controller.abort()
166
182
  }
167
183
  // Re-probe when schedule-relevant inputs change (time window, all-day, attendees, owner, status, scope).
168
- }, [open, form.date, form.startTime, form.endDate, form.endTime, form.allDay, form.status, participantsKey, config.hasAllDay, config.hasEnd, excludeId, draftOwnerUserId, scope, currentUserId, t]) // eslint-disable-line react-hooks/exhaustive-deps
184
+ }, [open, form.date, form.timezone, form.startTime, form.endDate, form.endTime, form.allDay, form.status, participantsKey, resourcesKey, config.hasAllDay, config.hasEnd, excludeId, draftOwnerUserId, scope, currentUserId, t]) // eslint-disable-line react-hooks/exhaustive-deps
169
185
  return conflict
170
186
  }
@@ -69,12 +69,13 @@ export function UppercaseBadge({ style, className, children }: { style?: React.C
69
69
  )
70
70
  }
71
71
 
72
- export function AllDayToggle({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange(next: boolean): void; label: string }) {
72
+ export function AllDayToggle({ checked, onCheckedChange, label, disabled }: { checked: boolean; onCheckedChange(next: boolean): void; label: string; disabled?: boolean }) {
73
73
  return (
74
74
  <Switch
75
75
  checked={checked}
76
76
  aria-label={label}
77
77
  onCheckedChange={onCheckedChange}
78
+ disabled={disabled}
78
79
  className="h-6 w-10"
79
80
  />
80
81
  )
@@ -112,11 +113,13 @@ export function DateControl({
112
113
  onChange,
113
114
  ariaLabel,
114
115
  className,
116
+ disabled,
115
117
  }: {
116
118
  value: string
117
119
  onChange(next: string): void
118
120
  ariaLabel: string
119
121
  className?: string
122
+ disabled?: boolean
120
123
  }) {
121
124
  const [open, setOpen] = React.useState(false)
122
125
  useCloseOnEditorScroll(setOpen)
@@ -128,6 +131,7 @@ export function DateControl({
128
131
  open={open}
129
132
  onOpenChange={setOpen}
130
133
  aria-label={ariaLabel}
134
+ disabled={disabled}
131
135
  className={cn('w-full', className)}
132
136
  />
133
137
  )
@@ -140,13 +144,18 @@ const TIME_OPTIONS: string[] = Array.from({ length: 48 }, (_, index) => {
140
144
  return `${String(hour).padStart(2, '0')}:${minute}`
141
145
  })
142
146
 
143
- export function TimeControl({ value, onChange, ariaLabel }: { value: string; onChange(next: string): void; ariaLabel: string }) {
147
+ export function TimeControl({ value, onChange, ariaLabel, disabled }: { value: string; onChange(next: string): void; ariaLabel: string; disabled?: boolean }) {
144
148
  const [open, setOpen] = React.useState(false)
145
149
  useCloseOnEditorScroll(setOpen)
146
- // Keep an off-grid value (e.g. an imported 22:15) selectable.
147
- const options = value && !TIME_OPTIONS.includes(value) ? [value, ...TIME_OPTIONS] : TIME_OPTIONS
150
+ // Radix renders every item even while the list is closed, so a closed picker
151
+ // mounts only the selected time and the full grid is built on open. An
152
+ // off-grid value (e.g. an imported 22:15) stays selectable.
153
+ const options = React.useMemo(() => {
154
+ if (!open) return value ? [value] : []
155
+ return value && !TIME_OPTIONS.includes(value) ? [value, ...TIME_OPTIONS] : TIME_OPTIONS
156
+ }, [open, value])
148
157
  return (
149
- <Select value={value} onValueChange={onChange} open={open} onOpenChange={setOpen}>
158
+ <Select value={value} onValueChange={onChange} open={open} onOpenChange={setOpen} disabled={disabled}>
150
159
  <SelectTrigger aria-label={ariaLabel} className="h-9 w-32 shrink-0">
151
160
  <SelectValue />
152
161
  </SelectTrigger>
@@ -165,12 +174,14 @@ export function PersonChip({
165
174
  compact,
166
175
  onRemove,
167
176
  removeLabel,
177
+ disabled,
168
178
  }: {
169
179
  name: string
170
180
  badge?: React.ReactNode
171
181
  compact?: boolean
172
182
  onRemove?: () => void
173
183
  removeLabel?: string
184
+ disabled?: boolean
174
185
  }) {
175
186
  return (
176
187
  <span
@@ -188,6 +199,7 @@ export function PersonChip({
188
199
  size="xs"
189
200
  onClick={(event) => { event.stopPropagation(); onRemove() }}
190
201
  aria-label={removeLabel}
202
+ disabled={disabled}
191
203
  className="size-5 shrink-0"
192
204
  >
193
205
  <Plus aria-hidden className="size-3.5 rotate-45 opacity-50" />
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { buildFormFieldsFromCustomFields, fetchCustomFieldFormStructure } from '@open-mercato/ui/backend/utils/customFieldForms'
5
+ import { filterCustomFieldDefs, type CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
6
+ import { validateValuesAgainstDefs } from '@open-mercato/shared/modules/entities/validation'
7
+
8
+ export function selectCalendarCustomFields(definitions: CustomFieldDefDto[], fieldsetIds: readonly string[]) {
9
+ if (!fieldsetIds.length) return filterCustomFieldDefs(definitions, 'form')
10
+ const allowed = new Set(fieldsetIds)
11
+ return filterCustomFieldDefs(definitions, 'form').filter((definition) => {
12
+ const fieldsets = definition.fieldsets?.length ? definition.fieldsets : definition.fieldset ? [definition.fieldset] : []
13
+ return fieldsets.length ? fieldsets.some((fieldset) => allowed.has(fieldset)) : allowed.has('__general__')
14
+ })
15
+ }
16
+
17
+ export function projectCalendarCustomValues(values: Record<string, unknown>, definitions: CustomFieldDefDto[]) {
18
+ const activeIds = new Set(definitions.map((definition) => `cf_${definition.key}`))
19
+ return Object.fromEntries(Object.entries(values).filter(([key]) => !key.startsWith('cf_') || activeIds.has(key)))
20
+ }
21
+
22
+ export function validateCalendarCustomValues(values: Record<string, unknown>, definitions: CustomFieldDefDto[]) {
23
+ const customValues = Object.fromEntries(definitions.map((definition) => [definition.key, values[`cf_${definition.key}`]]))
24
+ return validateValuesAgainstDefs(customValues, definitions.map((definition) => ({
25
+ key: definition.key,
26
+ kind: definition.kind,
27
+ configJson: { validation: definition.validation ?? [] },
28
+ })))
29
+ }
30
+
31
+ export function useCalendarCustomFields(open: boolean, entityId: string, fieldsetIds: readonly string[]) {
32
+ const [state, setState] = React.useState<{ definitions: CustomFieldDefDto[]; status: 'loading' | 'ready' | 'error' }>({
33
+ definitions: [], status: 'loading',
34
+ })
35
+ React.useEffect(() => {
36
+ if (!open) return
37
+ let active = true
38
+ setState({ definitions: [], status: 'loading' })
39
+ void fetchCustomFieldFormStructure([entityId]).then(({ definitions }) => {
40
+ if (active) setState({ definitions, status: 'ready' })
41
+ }).catch(() => {
42
+ if (active) setState({ definitions: [], status: 'error' })
43
+ })
44
+ return () => { active = false }
45
+ }, [open, entityId])
46
+ const definitions = React.useMemo(() => selectCalendarCustomFields(state.definitions, fieldsetIds), [state.definitions, fieldsetIds])
47
+ const fields = React.useMemo(() => buildFormFieldsFromCustomFields(definitions), [definitions])
48
+ return { definitions, allDefinitions: state.definitions, fields, status: state.status }
49
+ }
@@ -0,0 +1,100 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
5
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { calendarEventTypes, type EffectiveCalendarEventType } from '../../../calendar-event-types'
7
+ import type { CalendarEventTypeCatalog, ScopedCalendarEventType } from '../../../lib/calendar/eventTypeResolver'
8
+ import { KIND_CONFIG, type EditorKindConfig, type EditorTypeOption } from '../../../lib/calendar/editorPayload'
9
+
10
+ type CatalogState =
11
+ | { status: 'loading' | 'error'; items: readonly ScopedCalendarEventType[] }
12
+ | { status: 'ready'; items: readonly ScopedCalendarEventType[] }
13
+
14
+ export function useEventTypeCatalog(open: boolean) {
15
+ const [state, setState] = React.useState<CatalogState>({ status: 'loading', items: [] })
16
+ const [attempt, setAttempt] = React.useState(0)
17
+
18
+ React.useEffect(() => {
19
+ if (!open) {
20
+ setState({ status: 'loading', items: [] })
21
+ return
22
+ }
23
+ let active = true
24
+ setState({ status: 'loading', items: [] })
25
+ void readApiResultOrThrow<CalendarEventTypeCatalog>('/api/customers/activity-types')
26
+ .then((catalog) => {
27
+ if (!Array.isArray(catalog.items)) throw new Error('[internal] Invalid activity type catalog')
28
+ if (active) setState({ status: 'ready', items: catalog.items })
29
+ })
30
+ .catch((error) => {
31
+ if (!active) return
32
+ getTelemetryRuntime()?.reportError(error, {
33
+ module: 'customers',
34
+ code: 'customers.activity_type_catalog_load_failed',
35
+ })
36
+ setState({ status: 'error', items: [] })
37
+ })
38
+ return () => { active = false }
39
+ }, [open, attempt])
40
+
41
+ const retry = React.useCallback(() => setAttempt((value) => value + 1), [])
42
+
43
+ return { ...state, retry }
44
+ }
45
+
46
+ export function selectedEventType(
47
+ items: readonly ScopedCalendarEventType[],
48
+ key: string,
49
+ ): EffectiveCalendarEventType {
50
+ const normalizedKey = key.trim().toLowerCase()
51
+ const selected = items.find((item) => item.key === normalizedKey)
52
+ if (selected) return selected
53
+ const baseline = calendarEventTypes.find((item) => item.key === normalizedKey) ?? calendarEventTypes[0]!
54
+ return {
55
+ ...baseline,
56
+ key,
57
+ label: key,
58
+ provenance: {},
59
+ historical: true,
60
+ fallbackReason: 'unknown',
61
+ }
62
+ }
63
+
64
+ export function isSelectableEventType(items: readonly ScopedCalendarEventType[], key: string): boolean {
65
+ const normalizedKey = key.trim().toLowerCase()
66
+ return items.some((item) => item.key === normalizedKey && item.selectable && !item.historical)
67
+ }
68
+
69
+ export function eventTypeOptions(
70
+ items: readonly ScopedCalendarEventType[],
71
+ selectedKey: string,
72
+ translate: (key: string, fallback: string) => string,
73
+ ): EditorTypeOption[] {
74
+ const displayLabel = (item: ScopedCalendarEventType) => item.labelKey
75
+ ? translate(item.labelKey, item.label)
76
+ : item.label
77
+ const normalizedSelectedKey = selectedKey.trim().toLowerCase()
78
+ const options = items
79
+ .filter((item) => item.selectable && !item.historical)
80
+ .sort((left, right) => left.behavior.order - right.behavior.order)
81
+ .map((item) => ({ value: item.key === normalizedSelectedKey ? selectedKey : item.key, label: displayLabel(item), icon: item.icon ?? null }))
82
+ if (!options.some((option) => option.value === selectedKey)) {
83
+ const current = items.find((item) => item.key === normalizedSelectedKey)
84
+ options.unshift({ value: selectedKey, label: current ? displayLabel(current) : selectedKey, icon: current?.icon ?? null })
85
+ }
86
+ return options
87
+ }
88
+
89
+ export function eventTypeConfig(definition: EffectiveCalendarEventType): EditorKindConfig {
90
+ const { baseKind, fields } = definition.behavior
91
+ return {
92
+ dateLabel: KIND_CONFIG[baseKind].dateLabel,
93
+ hasEnd: fields.endTime,
94
+ hasAllDay: fields.allDay,
95
+ hasRepeat: fields.recurrence,
96
+ location: fields.location === 'none' ? null : fields.location,
97
+ people: fields.people === 'none' ? null : fields.people === 'recipients' ? 'to' : fields.people,
98
+ hasPriority: fields.priority,
99
+ }
100
+ }
@@ -33,6 +33,7 @@ export const calendarInteractionPayloadSchema = z
33
33
  scheduledAt: z.string().nullable().optional(),
34
34
  occurredAt: z.string().nullable().optional(),
35
35
  durationMinutes: z.number().nullable().optional(),
36
+ timezone: z.string().nullable().optional(),
36
37
  allDay: z.boolean().nullable().optional(),
37
38
  location: z.string().nullable().optional(),
38
39
  participants: z.array(calendarParticipantSchema).nullable().optional(),
@@ -156,6 +156,7 @@ export type InteractionSummary = {
156
156
  } | null
157
157
  _integrations?: Record<string, unknown>
158
158
  pinned?: boolean
159
+ timezone?: string | null
159
160
  duration?: number | null
160
161
  createdAt: string
161
162
  updatedAt: string
@@ -1,6 +1,7 @@
1
1
  import { Collection, OptionalProps } from '@mikro-orm/core'
2
2
  import { Entity, Index, ManyToOne, OneToMany, OneToOne, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
3
3
  import type { DictionaryEntrySortMode } from '@open-mercato/core/modules/dictionaries/lib/entrySort'
4
+ import type { CalendarEventTypeBehavior } from '../calendar-event-types'
4
5
 
5
6
  export type CustomerEntityKind = 'person' | 'company'
6
7
  export type CustomerAddressFormat = 'line_first' | 'street_first'
@@ -571,7 +572,7 @@ export class CustomerActivity {
571
572
  `create index "customer_interactions_email_channel_idx" on "customer_interactions" ("channel_id", "entity_id") where "interaction_type" = 'email' and "channel_id" is not null and "deleted_at" is null`,
572
573
  })
573
574
  export class CustomerInteraction {
574
- [OptionalProps]?: 'status' | 'pinned' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'durationMinutes' | 'location' | 'allDay' | 'recurrenceRule' | 'recurrenceEnd' | 'participants' | 'reminderMinutes' | 'visibility' | 'linkedEntities' | 'guestPermissions' | 'externalMessageId' | 'channelProviderKey' | 'channelId'
575
+ [OptionalProps]?: 'status' | 'pinned' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'timezone' | 'durationMinutes' | 'location' | 'allDay' | 'recurrenceRule' | 'recurrenceEnd' | 'participants' | 'reminderMinutes' | 'visibility' | 'linkedEntities' | 'guestPermissions' | 'externalMessageId' | 'channelProviderKey' | 'channelId'
575
576
 
576
577
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
577
578
  id!: string
@@ -661,6 +662,9 @@ export class CustomerInteraction {
661
662
  @Property({ name: 'deal_id', type: 'uuid', nullable: true })
662
663
  dealId?: string | null
663
664
 
665
+ @Property({ name: 'timezone', type: 'text', nullable: true })
666
+ timezone?: string | null
667
+
664
668
  @Property({ name: 'duration_minutes', type: 'int', nullable: true })
665
669
  durationMinutes?: number | null
666
670
 
@@ -953,6 +957,9 @@ export class CustomerDictionaryEntry {
953
957
  @Property({ type: 'text', nullable: true })
954
958
  icon?: string | null
955
959
 
960
+ @Property({ name: 'activity_type_behavior', type: 'jsonb', nullable: true })
961
+ activityTypeBehavior?: CalendarEventTypeBehavior | null
962
+
956
963
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
957
964
  createdAt: Date = new Date()
958
965
 
@@ -2,6 +2,8 @@ import { z } from 'zod'
2
2
  import { isValidPhoneNumber } from '@open-mercato/shared/lib/phone'
3
3
  import { COORDINATE_RANGES } from '@open-mercato/shared/lib/location/coordinates'
4
4
  import { dictionaryEntrySortModeSchema } from '@open-mercato/core/modules/dictionaries/lib/entrySort'
5
+ import { calendarEventTypeBehaviorSchema } from '../calendar-event-types'
6
+ import { calendarDayStartInstant, calendarWallTimeToInstant, isCalendarTimezone } from '../lib/calendar/timezone'
5
7
 
6
8
  const uuid = () => z.string().uuid()
7
9
 
@@ -350,13 +352,27 @@ const dictionaryColorSchema = z
350
352
  )
351
353
  const dictionaryIconSchema = z.string().trim().max(48)
352
354
 
355
+ function validateDictionaryBehavior(
356
+ payload: { kind: string; behavior?: unknown },
357
+ ctx: z.RefinementCtx,
358
+ ): void {
359
+ if (payload.behavior !== undefined && payload.kind !== 'activity_type') {
360
+ ctx.addIssue({
361
+ code: z.ZodIssueCode.custom,
362
+ path: ['behavior'],
363
+ message: 'Behavior is only supported for activity types',
364
+ })
365
+ }
366
+ }
367
+
353
368
  export const customerDictionaryEntryCreateSchema = scopedSchema.extend({
354
369
  kind: dictionaryKindEnum,
355
370
  value: dictionaryValueSchema,
356
371
  label: dictionaryLabelSchema.optional(),
357
372
  color: dictionaryColorSchema.nullable().optional(),
358
373
  icon: dictionaryIconSchema.nullable().optional(),
359
- })
374
+ behavior: calendarEventTypeBehaviorSchema.nullable().optional(),
375
+ }).superRefine(validateDictionaryBehavior)
360
376
 
361
377
  export type CustomerDictionaryEntryCreateInput = z.infer<typeof customerDictionaryEntryCreateSchema>
362
378
 
@@ -368,18 +384,21 @@ export const customerDictionaryEntryUpdateSchema = scopedSchema
368
384
  label: dictionaryLabelSchema.optional(),
369
385
  color: dictionaryColorSchema.nullable().optional(),
370
386
  icon: dictionaryIconSchema.nullable().optional(),
387
+ behavior: calendarEventTypeBehaviorSchema.nullable().optional(),
371
388
  })
372
389
  .refine(
373
390
  (payload) =>
374
391
  payload.value !== undefined ||
375
392
  payload.label !== undefined ||
376
393
  payload.color !== undefined ||
377
- payload.icon !== undefined,
394
+ payload.icon !== undefined ||
395
+ payload.behavior !== undefined,
378
396
  {
379
397
  message: 'Provide at least one field to update.',
380
398
  path: ['value'],
381
399
  }
382
400
  )
401
+ .superRefine(validateDictionaryBehavior)
383
402
 
384
403
  export type CustomerDictionaryEntryUpdateInput = z.infer<typeof customerDictionaryEntryUpdateSchema>
385
404
 
@@ -477,7 +496,10 @@ const interactionGuestPermissionsSchema = z
477
496
  })
478
497
  .strict()
479
498
 
499
+ export const calendarTimezoneSchema = z.string().trim().min(1).max(120).refine(isCalendarTimezone, { message: 'customers.calendar.editor.validation.timezoneInvalid' })
500
+
480
501
  const interactionExtendedFields = {
502
+ timezone: calendarTimezoneSchema.optional().nullable(),
481
503
  durationMinutes: z.number().int().min(0).optional().nullable(),
482
504
  location: z.string().trim().max(500).optional().nullable(),
483
505
  allDay: z.boolean().optional().nullable(),
@@ -494,6 +516,7 @@ const interactionCreateBaseSchema = scopedSchema.extend({
494
516
  id: z.string().uuid().optional(),
495
517
  entityId: z.string().uuid(),
496
518
  interactionType: z.string().trim().min(1).max(100),
519
+ enforceSelectableType: z.boolean().optional(),
497
520
  title: z.string().trim().max(500).optional().nullable(),
498
521
  body: z.string().trim().max(10000).optional().nullable(),
499
522
  // Lenient like `deal_status` (status: z.string().max(50)). The `interaction-statuses`
@@ -518,11 +541,13 @@ const interactionCreateBaseSchema = scopedSchema.extend({
518
541
  ...interactionExtendedFields,
519
542
  })
520
543
 
521
- function deriveScheduledAtFromDateTime(date?: string | null, time?: string | null): Date | null {
544
+ function deriveScheduledAtFromDateTime(date?: string | null, time?: string | null, timezone?: string | null, allDay?: boolean | null): Date | null {
522
545
  if (!date || typeof date !== 'string') return null
523
546
  const trimmedDate = date.trim()
524
547
  if (!trimmedDate) return null
525
548
  const trimmedTime = typeof time === 'string' ? time.trim() : ''
549
+ if (timezone && allDay) return calendarDayStartInstant(trimmedDate, timezone)
550
+ if (timezone) return calendarWallTimeToInstant(trimmedDate, trimmedTime || '00:00', timezone)
526
551
  const iso = trimmedTime ? `${trimmedDate}T${trimmedTime}:00` : `${trimmedDate}T00:00:00`
527
552
  const parsed = new Date(iso)
528
553
  return Number.isNaN(parsed.getTime()) ? null : parsed
@@ -551,9 +576,13 @@ export const interactionCreateSchema = interactionCreateBaseSchema
551
576
  // external API consumers don't silently persist `scheduled_at: null` after
552
577
  // the validator already enforced non-empty date/time. The form already
553
578
  // computes `scheduledAt` itself, so this branch is a no-op for the form path.
554
- .transform((value) => {
579
+ .transform((value, ctx) => {
555
580
  if (value.scheduledAt) return value
556
- const derived = deriveScheduledAtFromDateTime(value.date, value.time)
581
+ const derived = deriveScheduledAtFromDateTime(value.date, value.time, value.timezone, value.allDay)
582
+ if (value.timezone && value.date && !derived) {
583
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['time'], message: 'customers.calendar.editor.validation.timezoneGap' })
584
+ return z.NEVER
585
+ }
557
586
  return derived ? { ...value, scheduledAt: derived } : value
558
587
  })
559
588
 
@@ -572,6 +601,8 @@ const interactionUpdateBaseSchema = z
572
601
  // state to express — that depends on #5935 making the column nullable first.
573
602
  entityId: z.string().uuid().optional(),
574
603
  interactionType: z.string().trim().min(1).max(100).optional(),
604
+ enforceSelectableType: z.boolean().optional(),
605
+ confirmDiscardInapplicableValues: z.boolean().optional(),
575
606
  title: z.string().trim().max(500).optional().nullable(),
576
607
  body: z.string().trim().max(10000).optional().nullable(),
577
608
  status: z.string().max(50).optional(),
@@ -614,13 +645,17 @@ export const interactionUpdateSchema = interactionUpdateBaseSchema
614
645
  // Mirror the create-schema derivation for partial updates: when an external
615
646
  // caller supplies `date+time` without `scheduledAt`, derive the timestamp so
616
647
  // the update doesn't silently leave `scheduled_at` stale.
617
- .transform((value) => {
648
+ .transform((value, ctx) => {
618
649
  if (value.scheduledAt !== undefined) return value
619
650
  // An explicit `date: null` is how a caller drops the due date; mirror it
620
651
  // onto `scheduledAt` rather than leaving the old timestamp behind (#5941).
621
652
  if (value.date === null) return { ...value, scheduledAt: null }
622
653
  if (!value.date && !value.time) return value
623
- const derived = deriveScheduledAtFromDateTime(value.date, value.time)
654
+ const derived = deriveScheduledAtFromDateTime(value.date, value.time, value.timezone, value.allDay)
655
+ if (value.timezone && value.date && !derived) {
656
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['time'], message: 'customers.calendar.editor.validation.timezoneGap' })
657
+ return z.NEVER
658
+ }
624
659
  return derived ? { ...value, scheduledAt: derived } : value
625
660
  })
626
661