@open-mercato/core 0.6.6-develop.6519.1.bdc91fb755 → 0.6.6-develop.6520.1.55df6df792

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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/backend/calendar/page.js +9 -1
  3. package/dist/modules/customers/backend/calendar/page.js.map +2 -2
  4. package/dist/modules/customers/components/calendar/AgendaList.js +0 -11
  5. package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
  6. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -235
  7. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
  8. package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
  9. package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
  10. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +2 -2
  11. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  12. package/dist/modules/customers/components/calendar/MonthGrid.js +1 -11
  13. package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
  14. package/dist/modules/customers/components/calendar/TimeGrid.js +162 -176
  15. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  16. package/dist/modules/customers/components/calendar/editor/LocationField.js +61 -14
  17. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  18. package/dist/modules/customers/components/calendar/editor/PeopleField.js +10 -5
  19. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  20. package/dist/modules/customers/components/calendar/editor/PriorityField.js +85 -0
  21. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +7 -0
  22. package/dist/modules/customers/components/calendar/editor/RelatedToField.js +8 -3
  23. package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
  24. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +225 -0
  25. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +7 -0
  26. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +12 -7
  27. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  28. package/dist/modules/customers/components/calendar/editor/inputs.js +68 -57
  29. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  30. package/dist/modules/customers/components/calendar/editor/lookups.js +82 -2
  31. package/dist/modules/customers/components/calendar/editor/lookups.js.map +2 -2
  32. package/dist/modules/customers/components/calendar/useCalendarItems.js +6 -1
  33. package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
  34. package/dist/modules/customers/data/validators.js +3 -1
  35. package/dist/modules/customers/data/validators.js.map +2 -2
  36. package/dist/modules/customers/lib/calendar/editorPayload.js +53 -22
  37. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  38. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +1 -1
  39. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  40. package/package.json +7 -7
  41. package/src/modules/customers/backend/calendar/page.tsx +8 -1
  42. package/src/modules/customers/components/calendar/AgendaList.tsx +0 -10
  43. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +444 -261
  44. package/src/modules/customers/components/calendar/CalendarScreen.tsx +12 -5
  45. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +6 -6
  46. package/src/modules/customers/components/calendar/MonthGrid.tsx +0 -11
  47. package/src/modules/customers/components/calendar/TimeGrid.tsx +1 -24
  48. package/src/modules/customers/components/calendar/editor/LocationField.tsx +64 -2
  49. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +11 -4
  50. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +90 -0
  51. package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +8 -3
  52. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +264 -0
  53. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +14 -6
  54. package/src/modules/customers/components/calendar/editor/inputs.tsx +91 -50
  55. package/src/modules/customers/components/calendar/editor/lookups.ts +115 -2
  56. package/src/modules/customers/components/calendar/useCalendarItems.ts +8 -1
  57. package/src/modules/customers/data/validators.ts +3 -1
  58. package/src/modules/customers/i18n/de.json +12 -2
  59. package/src/modules/customers/i18n/en.json +12 -2
  60. package/src/modules/customers/i18n/es.json +12 -2
  61. package/src/modules/customers/i18n/pl.json +12 -2
  62. package/src/modules/customers/lib/calendar/editorPayload.ts +94 -37
  63. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +1 -1
  64. package/src/modules/entities/i18n/de.json +120 -0
  65. package/src/modules/entities/i18n/en.json +120 -0
  66. package/src/modules/entities/i18n/es.json +120 -0
  67. package/src/modules/entities/i18n/pl.json +120 -0
  68. package/dist/modules/customers/components/calendar/editor/CategoryField.js +0 -75
  69. package/dist/modules/customers/components/calendar/editor/CategoryField.js.map +0 -7
  70. package/src/modules/customers/components/calendar/editor/CategoryField.tsx +0 -79
@@ -4,11 +4,13 @@ import * as React from 'react'
4
4
  import { Calendar, X } from 'lucide-react'
5
5
  import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
6
6
  import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
7
- import { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
8
- import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
9
- import { mapCrudServerErrorToFormErrors } from '@open-mercato/ui/backend/utils/serverErrors'
7
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
9
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
10
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
10
11
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
11
- import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
12
+ import { CrudForm, type CrudFormGroup, type CrudFormGroupComponentProps } from '@open-mercato/ui/backend/CrudForm'
13
+ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
12
14
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
13
15
  import { Button } from '@open-mercato/ui/primitives/button'
14
16
  import { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'
@@ -16,13 +18,14 @@ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
16
18
  import { Input } from '@open-mercato/ui/primitives/input'
17
19
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
18
20
  import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
21
+ import { E } from '#generated/entities.ids.generated'
19
22
  import {
20
- buildEditorCategoryOptions,
23
+ buildEditorTypeOptions,
21
24
  buildInteractionPayload,
22
25
  computeDurationMinutes,
23
26
  createDefaultFormState,
24
27
  defaultRepeatDaysForDateInput,
25
- EDITOR_KINDS,
28
+ editorKindOfInteractionType,
26
29
  KIND_CONFIG,
27
30
  parseItemToFormState,
28
31
  resolveSavedOwnerUserId,
@@ -31,13 +34,16 @@ import {
31
34
  type EditorPriority,
32
35
  } from '../../lib/calendar/editorPayload'
33
36
  import type { ConflictScope } from '../../lib/calendar/preferences'
37
+ import { renderDictionaryIcon } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
38
+ import { normalizeCustomFieldSubmitValue } from '../detail/customFieldUtils'
34
39
  import type { CalendarItem } from './types'
35
- import { Field } from './editor/inputs'
40
+ import { EDITOR_SCROLL_EVENT, Field } from './editor/inputs'
36
41
  import { SegmentGroup } from './editor/SegmentGroup'
42
+ import { PriorityField } from './editor/PriorityField'
37
43
  import { RelatedToField } from './editor/RelatedToField'
38
- import { CategoryField } from './editor/CategoryField'
39
44
  import { RepeatField } from './editor/RepeatField'
40
45
  import { PeopleField } from './editor/PeopleField'
46
+ import { ResourcesField } from './editor/ResourcesField'
41
47
  import { ScheduleSection } from './editor/ScheduleSection'
42
48
  import { LocationField } from './editor/LocationField'
43
49
  import { useConflictProbe, useEditorLabelResolution } from './editor/hooks'
@@ -49,16 +55,17 @@ export interface CalendarEventEditorProps {
49
55
  defaultDate?: Date
50
56
  defaultRange?: { start: Date; end: Date } | null
51
57
  typeLabels: Record<string, string>
52
- typeColors: Record<string, string | null>
53
- surfacedTypes?: string[]
54
- eventCategories?: string[]
58
+ typeIcons?: Record<string, string | null>
55
59
  conflictScope?: ConflictScope
56
60
  currentUserId?: string | null
61
+ resourcesEnabled?: boolean
62
+ staffEnabled?: boolean
57
63
  onOpenChange(open: boolean): void
58
64
  onSaved(): void
59
65
  }
60
66
 
61
- type FieldErrors = { title?: string; relatedTo?: string; ends?: string }
67
+ const FORM_ID = 'customers-calendar-event-editor'
68
+ const INTERACTION_ENTITY_IDS = [E.customers.customer_interaction]
62
69
 
63
70
  const PEOPLE_FIELD_TEXT = {
64
71
  attendees: { labelKey: 'customers.calendar.editor.attendees', label: 'Attendees', placeholderKey: 'customers.calendar.editor.addPeoplePlaceholder', placeholder: 'Add staff or customer…' },
@@ -66,35 +73,57 @@ const PEOPLE_FIELD_TEXT = {
66
73
  to: { labelKey: 'customers.calendar.editor.to', label: 'To', placeholderKey: 'customers.calendar.editor.addRecipientPlaceholder', placeholder: 'Add recipient…' },
67
74
  } as const
68
75
 
69
- export function CalendarEventEditor({ open, mode, item, defaultDate, defaultRange, typeLabels, typeColors, surfacedTypes, eventCategories, conflictScope, currentUserId, onOpenChange, onSaved }: CalendarEventEditorProps) {
76
+ // CrudForm values carry the flattened EditorFormState keys (seeded via
77
+ // initialValues) plus cf_* custom-field keys managed by CrudForm itself.
78
+ function formStateOfValues(values: Record<string, unknown>): EditorFormState {
79
+ return values as unknown as EditorFormState
80
+ }
81
+
82
+ function customFieldInitialValues(item: CalendarItem): Record<string, unknown> {
83
+ const customValues = (item.raw as Record<string, unknown>).customValues
84
+ if (!customValues || typeof customValues !== 'object' || Array.isArray(customValues)) return {}
85
+ return Object.fromEntries(
86
+ Object.entries(customValues as Record<string, unknown>).map(([key, value]) => [`cf_${key}`, value]),
87
+ )
88
+ }
89
+
90
+ type EditorBodyProps = {
91
+ ctx: CrudFormGroupComponentProps
92
+ open: boolean
93
+ isEdit: boolean
94
+ item?: CalendarItem | null
95
+ typeLabels: Record<string, string>
96
+ typeIcons: Record<string, string | null>
97
+ conflictScope: ConflictScope
98
+ currentUserId: string | null
99
+ resourcesEnabled: boolean
100
+ staffEnabled: boolean
101
+ }
102
+
103
+ function EditorBody({
104
+ ctx,
105
+ open,
106
+ isEdit,
107
+ item,
108
+ typeLabels,
109
+ typeIcons,
110
+ conflictScope,
111
+ currentUserId,
112
+ resourcesEnabled,
113
+ staffEnabled,
114
+ }: EditorBodyProps) {
70
115
  const t = useT()
71
116
  const locale = useLocale()
72
- const [form, setForm] = React.useState<EditorFormState>(() => createDefaultFormState(defaultDate ?? null, undefined, defaultRange ?? null))
73
- const [fieldErrors, setFieldErrors] = React.useState<FieldErrors>({})
74
- const [saving, setSaving] = React.useState(false)
75
- const openKeyRef = React.useRef<string | null>(null)
76
- const isEdit = mode === 'edit' && Boolean(item?.id)
117
+ const { setValue, errors } = ctx
118
+ const form = formStateOfValues(ctx.values)
77
119
  const config = KIND_CONFIG[form.kind]
78
120
 
79
- const update = React.useCallback((patch: Partial<EditorFormState>) => {
80
- setForm((previous) => ({ ...previous, ...patch }))
81
- }, [])
82
-
83
- React.useEffect(() => {
84
- if (!open) {
85
- openKeyRef.current = null
86
- return
87
- }
88
- const key = `${mode}:${item?.id ?? 'new'}`
89
- if (openKeyRef.current === key) return
90
- openKeyRef.current = key
91
- setForm(
92
- mode === 'edit' && item
93
- ? parseItemToFormState(item)
94
- : createDefaultFormState(defaultDate ?? null, undefined, defaultRange ?? null),
95
- )
96
- setFieldErrors({})
97
- }, [open, mode, item, defaultDate, defaultRange])
121
+ const update = React.useCallback(
122
+ (patch: Partial<EditorFormState>) => {
123
+ for (const [key, value] of Object.entries(patch)) setValue(key, value)
124
+ },
125
+ [setValue],
126
+ )
98
127
 
99
128
  useEditorLabelResolution(open, form, update)
100
129
  // Probe against the owner the interaction will actually be SAVED with so the
@@ -104,101 +133,378 @@ export function CalendarEventEditor({ open, mode, item, defaultDate, defaultRang
104
133
  const draftOwnerUserId = resolveSavedOwnerUserId(config, form, isEdit, item?.ownerUserId ?? null)
105
134
  // Self-exclude by the underlying interaction id (raw.id): findEditorConflictItems
106
135
  // drops candidates by raw.id, and every expanded occurrence of a recurring series
107
- // shares it — so the edited record never conflicts with itself. (asEditableItem
108
- // already normalizes a clicked occurrence's id to raw.id, so these match today;
109
- // raw.id keeps it correct even if a future edit path skips that normalization.)
110
- const conflict = useConflictProbe(open, form, config, isEdit && item ? item.raw.id : null, draftOwnerUserId, conflictScope ?? 'all', currentUserId ?? null)
111
-
112
- const { runMutation, retryLastMutation } = useGuardedMutation<{
113
- formId: string
114
- resourceKind: string
115
- resourceId: string
116
- retryLastMutation: () => Promise<boolean>
117
- }>({
118
- contextId: 'customers-calendar-event-editor',
119
- blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
120
- })
121
-
122
- const handleSave = React.useCallback(async () => {
123
- const errors: FieldErrors = {}
124
- if (!form.title.trim()) errors.title = t('customers.calendar.editor.validation.titleRequired', 'Title is required')
125
- if (!form.relatedTo) errors.relatedTo = t('customers.calendar.editor.validation.relatedToRequired', 'Select a person or company to link this event')
126
- if (config.hasEnd && !form.allDay && computeDurationMinutes(form) === null) {
127
- errors.ends = t('customers.calendar.editor.validation.endsBeforeStarts', 'End must be after start')
128
- }
129
- setFieldErrors(errors)
130
- if (Object.keys(errors).length > 0) return
131
- setSaving(true)
132
- try {
133
- const payload = buildInteractionPayload(form, { mode, id: item?.id })
134
- await runMutation({
135
- operation: () => {
136
- const call = () =>
137
- apiCallOrThrow('/api/customers/interactions', {
138
- method: isEdit ? 'PUT' : 'POST',
139
- headers: { 'content-type': 'application/json' },
140
- body: JSON.stringify(payload),
141
- })
142
- return isEdit ? withScopedApiRequestHeaders(buildOptimisticLockHeader(item?.updatedAt), call) : call()
143
- },
144
- mutationPayload: {
145
- operation: isEdit ? 'updateCalendarEvent' : 'createCalendarEvent',
146
- interactionId: item?.id ?? null,
147
- interactionType: form.category ?? form.kind,
148
- },
149
- context: {
150
- formId: 'customers-calendar-event-editor',
151
- resourceKind: 'customers.interaction',
152
- resourceId: item?.id ?? 'new',
153
- retryLastMutation,
154
- },
155
- })
156
- flash(t('customers.calendar.editor.saved', 'Event saved'), 'success')
157
- onOpenChange(false)
158
- requestAnimationFrame(() => { onSaved() })
159
- } catch (err) {
160
- // An optimistic-lock 409 is surfaced as the persistent conflict bar by
161
- // useGuardedMutation (surfaceRecordConflict) — close without re-flashing.
162
- if (extractOptimisticLockConflict(err)) {
163
- onOpenChange(false)
164
- return
165
- }
166
- const { message } = mapCrudServerErrorToFormErrors(err)
167
- const key = typeof message === 'string' ? message.trim() : ''
168
- flash(key ? t(key, key) : t('customers.calendar.editor.error', 'Failed to save event'), 'error')
169
- } finally {
170
- setSaving(false)
171
- }
172
- }, [config.hasEnd, form, isEdit, item?.id, mode, onOpenChange, onSaved, retryLastMutation, runMutation, t])
136
+ // shares it — so the edited record never conflicts with itself.
137
+ const conflict = useConflictProbe(open, form, config, isEdit && item ? item.raw.id : null, draftOwnerUserId, conflictScope, currentUserId)
173
138
 
174
- const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave, disabled: saving })
139
+ const kindLabels = React.useMemo(
140
+ () => ({
141
+ meeting: t('customers.calendar.editor.types.meeting', 'meeting'),
142
+ call: t('customers.calendar.editor.types.call', 'call'),
143
+ email: t('customers.calendar.editor.types.email', 'email'),
144
+ note: t('customers.calendar.editor.types.note', 'note'),
145
+ event: t('customers.calendar.editor.types.event', 'event'),
146
+ task: t('customers.calendar.editor.types.task', 'task'),
147
+ }),
148
+ [t],
149
+ ) satisfies Record<EditorKind, string>
175
150
 
176
- const categoryOptions = React.useMemo(
151
+ const selectedType = form.category ?? form.kind
152
+ const typeOptions = React.useMemo(
153
+ () => buildEditorTypeOptions({ typeLabels, typeIcons, selectedValue: selectedType, kindLabels }),
154
+ [typeLabels, typeIcons, selectedType, kindLabels],
155
+ )
156
+ const typeSwitcherOptions = React.useMemo(
177
157
  () =>
178
- buildEditorCategoryOptions({
179
- typeLabels,
180
- surfacedTypes: surfacedTypes ?? [],
181
- eventCategories: eventCategories ?? [],
182
- selectedValue: form.category ?? form.kind,
183
- selectedFallbackLabel: t(`customers.calendar.editor.types.${form.kind}`, form.kind),
184
- }),
185
- [typeLabels, surfacedTypes, eventCategories, form.category, form.kind, t],
158
+ typeOptions.map((option) => ({
159
+ value: option.value,
160
+ label: option.label,
161
+ icon: renderDictionaryIcon(option.icon, 'h-4 w-4'),
162
+ })),
163
+ [typeOptions],
186
164
  )
187
165
 
166
+ // Candidates for the Call "insert phone from contact" button: the linked
167
+ // person (companies have no phone) + customer attendees. Their userId is the
168
+ // person id, so the picker can resolve primary_phone from the people API.
169
+ const phoneContactIds = React.useMemo(() => {
170
+ const ids: string[] = []
171
+ if (form.relatedTo && form.relatedTo.kind !== 'company') ids.push(form.relatedTo.id)
172
+ for (const participant of form.participants) {
173
+ if (participant.isCustomer) ids.push(participant.userId)
174
+ }
175
+ return Array.from(new Set(ids))
176
+ }, [form.relatedTo, form.participants])
177
+
188
178
  const titleLabel = form.kind === 'email'
189
179
  ? t('customers.calendar.editor.titleLabel.email', 'Subject')
190
180
  : form.kind === 'note'
191
181
  ? t('customers.calendar.editor.titleLabel.note', 'Note')
192
182
  : t('customers.calendar.editor.titleLabel.generic', 'Title')
183
+
184
+ return (
185
+ // Single column on phones (full-screen sheet). On lg+ the dialog widens and
186
+ // the fields group into two thematic columns: WHEN (schedule + repeat) on
187
+ // the left, CONTEXT (related record, category, location) on the right;
188
+ // people/resources and the task fields pair up below; title, description
189
+ // and the type switcher span both columns.
190
+ <div className="grid w-full grid-cols-1 items-start gap-4 lg:grid-cols-2 lg:gap-x-6">
191
+ {conflict ? (
192
+ <Alert variant="warning" className="rounded-lg lg:col-span-2">
193
+ <AlertTitle>{t('customers.calendar.editor.conflictTitle', 'Calendar conflict')}</AlertTitle>
194
+ <AlertDescription>{conflict}</AlertDescription>
195
+ </Alert>
196
+ ) : null}
197
+ <div className="w-full lg:col-span-2">
198
+ <SegmentGroup<string>
199
+ ariaLabel={t('customers.calendar.editor.typeSwitcher', 'Event type')}
200
+ value={selectedType}
201
+ onChange={(type) => update({ kind: editorKindOfInteractionType(type), category: type })}
202
+ options={typeSwitcherOptions}
203
+ />
204
+ </div>
205
+ <Field label={titleLabel} error={errors.title} className="lg:col-span-2">
206
+ <Input
207
+ type="text"
208
+ value={form.title}
209
+ onChange={(event) => update({ title: event.target.value })}
210
+ placeholder={t('customers.calendar.editor.titlePlaceholder', 'Add a title…')}
211
+ aria-label={titleLabel}
212
+ autoFocus
213
+ size="lg"
214
+ />
215
+ </Field>
216
+ <div className="flex w-full flex-col gap-4">
217
+ <ScheduleSection
218
+ dateLabel={config.dateLabel}
219
+ hasAllDay={config.hasAllDay}
220
+ hasEnd={config.hasEnd}
221
+ allDay={form.allDay}
222
+ date={form.date}
223
+ startTime={form.startTime}
224
+ endDate={form.endDate}
225
+ endTime={form.endTime}
226
+ locale={locale}
227
+ endsError={errors.ends}
228
+ onAllDayChange={(allDay) => update({ allDay })}
229
+ onDateChange={(date) => {
230
+ const untouchedDefault =
231
+ JSON.stringify(form.repeatDays) === JSON.stringify(defaultRepeatDaysForDateInput(form.date))
232
+ update(
233
+ untouchedDefault
234
+ ? { date, repeatDays: defaultRepeatDaysForDateInput(date) }
235
+ : { date },
236
+ )
237
+ }}
238
+ onStartTimeChange={(startTime) => update({ startTime })}
239
+ onEndDateChange={(endDate) => update({ endDate })}
240
+ onEndTimeChange={(endTime) => update({ endTime })}
241
+ />
242
+ {config.hasRepeat ? (
243
+ <RepeatField
244
+ freq={form.repeatFreq}
245
+ days={form.repeatDays}
246
+ endType={form.repeatEndType}
247
+ count={form.repeatCount}
248
+ untilDate={form.repeatUntilDate}
249
+ locale={locale}
250
+ onFreqChange={(repeatFreq) =>
251
+ update(
252
+ repeatFreq === 'weekly'
253
+ ? { repeatFreq, repeatDays: defaultRepeatDaysForDateInput(form.date) }
254
+ : { repeatFreq },
255
+ )}
256
+ onToggleDay={(index) =>
257
+ update({ repeatDays: form.repeatDays.map((active, dayIndex) => (dayIndex === index ? !active : active)) })}
258
+ onEndTypeChange={(repeatEndType) => update({ repeatEndType })}
259
+ onCountChange={(repeatCount) => update({ repeatCount })}
260
+ onUntilDateChange={(repeatUntilDate) => update({ repeatUntilDate })}
261
+ />
262
+ ) : null}
263
+ {config.people && config.people !== 'assignee' ? (
264
+ <Field label={t(PEOPLE_FIELD_TEXT[config.people].labelKey, PEOPLE_FIELD_TEXT[config.people].label)}>
265
+ <PeopleField
266
+ mode="multi"
267
+ includeCustomers
268
+ includeStaff={staffEnabled}
269
+ placeholder={t(PEOPLE_FIELD_TEXT[config.people].placeholderKey, PEOPLE_FIELD_TEXT[config.people].placeholder)}
270
+ ariaLabel={t(PEOPLE_FIELD_TEXT[config.people].labelKey, PEOPLE_FIELD_TEXT[config.people].label)}
271
+ value={form.participants}
272
+ onChange={(participants) => update({ participants })}
273
+ />
274
+ </Field>
275
+ ) : null}
276
+ {config.people === 'assignee' && staffEnabled ? (
277
+ <Field label={t('customers.calendar.editor.assignee', 'Assignee')} error={errors.assignee}>
278
+ <PeopleField
279
+ mode="single"
280
+ includeCustomers={false}
281
+ includeStaff
282
+ placeholder={t('customers.calendar.editor.assigneePlaceholder', 'Assign to a team member…')}
283
+ ariaLabel={t('customers.calendar.editor.assignee', 'Assignee')}
284
+ value={form.assigneeUserId
285
+ ? [{ userId: form.assigneeUserId, name: form.assigneeName ?? form.assigneeUserId, isCustomer: false }]
286
+ : []}
287
+ onChange={(entries) => {
288
+ const next = entries[entries.length - 1] ?? null
289
+ update({ assigneeUserId: next?.userId ?? null, assigneeName: next?.name ?? null })
290
+ }}
291
+ />
292
+ </Field>
293
+ ) : null}
294
+ </div>
295
+ <div className="flex w-full flex-col gap-4">
296
+ <Field label={t('customers.calendar.editor.relatedTo', 'Related to')} error={errors.relatedTo}>
297
+ <RelatedToField
298
+ label={t('customers.calendar.editor.relatedTo', 'Related to')}
299
+ value={form.relatedTo}
300
+ deal={form.dealId && form.dealLabel ? { id: form.dealId, label: form.dealLabel } : null}
301
+ onChange={(relatedTo) => update({ relatedTo })}
302
+ onDealChange={(deal) => update({ dealId: deal?.id ?? null, dealLabel: deal?.label ?? null })}
303
+ error={errors.relatedTo}
304
+ />
305
+ </Field>
306
+ {config.location ? (
307
+ <LocationField
308
+ variant={config.location}
309
+ value={form.location}
310
+ onChange={(location) => update({ location })}
311
+ phoneContactIds={phoneContactIds}
312
+ />
313
+ ) : null}
314
+ {resourcesEnabled ? (
315
+ <Field label={t('customers.calendar.editor.resources', 'Resources')}>
316
+ <ResourcesField
317
+ placeholder={t('customers.calendar.editor.resourcesPlaceholder', 'Add a resource…')}
318
+ ariaLabel={t('customers.calendar.editor.resources', 'Resources')}
319
+ value={form.resources}
320
+ onChange={(resources) => update({ resources })}
321
+ />
322
+ </Field>
323
+ ) : null}
324
+ {config.hasPriority ? (
325
+ <Field label={t('customers.calendar.editor.priority.label', 'Priority')}>
326
+ <PriorityField
327
+ value={form.priority}
328
+ ariaLabel={t('customers.calendar.editor.priority.label', 'Priority')}
329
+ labels={{
330
+ low: t('customers.calendar.editor.priority.low', 'Low'),
331
+ medium: t('customers.calendar.editor.priority.medium', 'Medium'),
332
+ high: t('customers.calendar.editor.priority.high', 'High'),
333
+ }}
334
+ onChange={(priority) => update({ priority })}
335
+ />
336
+ </Field>
337
+ ) : null}
338
+ </div>
339
+ <Field label={t('customers.calendar.editor.description', 'Description')} className="lg:col-span-2">
340
+ <Textarea
341
+ value={form.description}
342
+ onChange={(event) => update({ description: event.target.value })}
343
+ placeholder={t('customers.calendar.editor.descriptionPlaceholder', 'Add details…')}
344
+ aria-label={t('customers.calendar.editor.description', 'Description')}
345
+ className="h-20 resize-none"
346
+ />
347
+ </Field>
348
+ </div>
349
+ )
350
+ }
351
+
352
+ export function CalendarEventEditor({
353
+ open,
354
+ mode,
355
+ item,
356
+ defaultDate,
357
+ defaultRange,
358
+ typeLabels,
359
+ typeIcons,
360
+ conflictScope,
361
+ currentUserId,
362
+ resourcesEnabled,
363
+ staffEnabled,
364
+ onOpenChange,
365
+ onSaved,
366
+ }: CalendarEventEditorProps) {
367
+ const t = useT()
368
+ const [saving, setSaving] = React.useState(false)
369
+ const isEdit = mode === 'edit' && Boolean(item?.id)
370
+
371
+ // CrudForm reads initialValues once, so every dialog open gets a fresh form
372
+ // instance keyed by the open sequence + edited record.
373
+ const [openSeq, setOpenSeq] = React.useState(0)
374
+ const wasOpenRef = React.useRef(false)
375
+ React.useEffect(() => {
376
+ if (open && !wasOpenRef.current) setOpenSeq((seq) => seq + 1)
377
+ wasOpenRef.current = open
378
+ }, [open])
379
+ const formKey = `${mode}:${item?.id ?? 'new'}:${openSeq}`
380
+
381
+ const initialValues = React.useMemo<Record<string, unknown>>(() => {
382
+ if (isEdit && item) {
383
+ return {
384
+ ...parseItemToFormState(item),
385
+ ...customFieldInitialValues(item),
386
+ id: item.id,
387
+ updatedAt: item.updatedAt ?? undefined,
388
+ }
389
+ }
390
+ return { ...createDefaultFormState(defaultDate ?? null, undefined, defaultRange ?? null) }
391
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- openSeq re-seeds defaults per dialog open
392
+ }, [isEdit, item, defaultDate, defaultRange, openSeq])
393
+
394
+ const handleSubmit = React.useCallback(
395
+ async (values: Record<string, unknown>) => {
396
+ const form = formStateOfValues(values)
397
+ const config = KIND_CONFIG[form.kind]
398
+ const fieldErrors: Record<string, string> = {}
399
+ if (!form.title.trim()) {
400
+ fieldErrors.title = t('customers.calendar.editor.validation.titleRequired', 'Title is required')
401
+ }
402
+ if (!form.relatedTo) {
403
+ fieldErrors.relatedTo = t('customers.calendar.editor.validation.relatedToRequired', 'Select a person or company to link this event')
404
+ }
405
+ if (config.hasEnd && !form.allDay && computeDurationMinutes(form) === null) {
406
+ fieldErrors.ends = t('customers.calendar.editor.validation.endsBeforeStarts', 'End must be after start')
407
+ }
408
+ // A task must be assigned to a team member — surface the requirement inline
409
+ // instead of letting the save fail with no visible reason (#3747 feedback).
410
+ if (config.people === 'assignee' && staffEnabled !== false && !form.assigneeUserId) {
411
+ fieldErrors.assignee = t('customers.calendar.editor.validation.assigneeRequired', 'Assign this task to a team member')
412
+ }
413
+ if (Object.keys(fieldErrors).length > 0) {
414
+ throw createCrudFormError(Object.values(fieldErrors)[0], fieldErrors)
415
+ }
416
+ setSaving(true)
417
+ try {
418
+ const payload = buildInteractionPayload(form, {
419
+ mode,
420
+ id: item?.id,
421
+ resourcesEnabled: resourcesEnabled === true,
422
+ staffEnabled: staffEnabled !== false,
423
+ })
424
+ const custom = collectCustomFieldValues(values, {
425
+ transform: (value) => normalizeCustomFieldSubmitValue(value),
426
+ })
427
+ for (const [key, value] of Object.entries(custom)) payload[`cf_${key}`] = value
428
+ // CrudForm supplies the optimistic-lock header (auto-derived from
429
+ // initialValues.updatedAt) via scoped request headers around onSubmit.
430
+ await apiCallOrThrow('/api/customers/interactions', {
431
+ method: isEdit ? 'PUT' : 'POST',
432
+ headers: { 'content-type': 'application/json' },
433
+ body: JSON.stringify(payload),
434
+ })
435
+ flash(t('customers.calendar.editor.saved', 'Event saved'), 'success')
436
+ onOpenChange(false)
437
+ requestAnimationFrame(() => { onSaved() })
438
+ } catch (err) {
439
+ // Surface an optimistic-lock 409 as the persistent conflict bar and
440
+ // close — the bar renders at page level, behind the dialog overlay.
441
+ if (extractOptimisticLockConflict(err)) {
442
+ surfaceRecordConflict(err, t)
443
+ onOpenChange(false)
444
+ return
445
+ }
446
+ throw err
447
+ } finally {
448
+ setSaving(false)
449
+ }
450
+ },
451
+ [isEdit, item?.id, mode, onOpenChange, onSaved, resourcesEnabled, staffEnabled, t],
452
+ )
453
+
454
+ const groups = React.useMemo<CrudFormGroup[]>(
455
+ () => [
456
+ {
457
+ id: 'details',
458
+ bare: true,
459
+ component: (ctx) => (
460
+ <EditorBody
461
+ ctx={ctx}
462
+ open={open}
463
+ isEdit={isEdit}
464
+ item={item}
465
+ typeLabels={typeLabels}
466
+ typeIcons={typeIcons ?? {}}
467
+ conflictScope={conflictScope ?? 'all'}
468
+ currentUserId={currentUserId ?? null}
469
+ resourcesEnabled={resourcesEnabled === true}
470
+ staffEnabled={staffEnabled !== false}
471
+ />
472
+ ),
473
+ },
474
+ { id: 'customFields', kind: 'customFields' },
475
+ ],
476
+ [open, isEdit, item, typeLabels, typeIcons, conflictScope, currentUserId, resourcesEnabled, staffEnabled],
477
+ )
478
+
479
+ const handleKeyDown = useDialogKeyHandler({
480
+ onConfirm: () => {
481
+ const formElement = document.getElementById(FORM_ID)
482
+ if (formElement instanceof HTMLFormElement) formElement.requestSubmit()
483
+ },
484
+ disabled: saving,
485
+ })
486
+
193
487
  const dialogTitle = isEdit ? t('customers.calendar.editor.title.edit', 'Edit event') : t('customers.calendar.editor.title.create', 'New event')
194
488
 
195
489
  return (
196
490
  <Dialog open={open} onOpenChange={onOpenChange}>
197
491
  <DialogContent
198
492
  onKeyDown={handleKeyDown}
493
+ // Only dismiss on a genuine click OUTSIDE the whole editor. Radix
494
+ // relays a click inside the dialog (but outside an open DS
495
+ // DatePicker/Select popover) up to the dialog as an "interact
496
+ // outside" event when it dismisses that popover — without this guard
497
+ // it would tear down the entire editor. Keep it open when the target
498
+ // is inside the dialog body or inside any portalled popover.
499
+ onInteractOutside={(event) => {
500
+ const target = event.detail.originalEvent.target as HTMLElement | null
501
+ if (target?.closest('[data-dialog-content]') || target?.closest('[data-radix-popper-content-wrapper]')) {
502
+ event.preventDefault()
503
+ }
504
+ }}
199
505
  aria-describedby={undefined}
200
506
  dismissible={false}
201
- className="flex h-dvh max-h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden rounded-none border-0 bg-background p-0 shadow-xl sm:h-auto sm:max-h-screen sm:w-full sm:max-w-md sm:rounded-2xl sm:border-0"
507
+ className="flex h-dvh max-h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden rounded-none border-0 bg-background p-0 shadow-xl sm:h-auto sm:max-h-[calc(100dvh-4rem)] sm:w-full sm:max-w-lg sm:rounded-2xl sm:border-0 lg:max-w-3xl"
202
508
  >
203
509
  <VisuallyHidden>
204
510
  <DialogTitle>{dialogTitle}</DialogTitle>
@@ -216,152 +522,29 @@ export function CalendarEventEditor({ open, mode, item, defaultDate, defaultRang
216
522
  <X aria-hidden className="size-5" />
217
523
  </IconButton>
218
524
  </div>
219
- <div className="flex-1 overflow-y-auto">
220
- <div className="flex flex-col items-start gap-5 px-4 py-5 sm:px-6 sm:py-6">
221
- {conflict ? (
222
- <Alert variant="warning" className="rounded-lg">
223
- <AlertTitle>{t('customers.calendar.editor.conflictTitle', 'Calendar conflict')}</AlertTitle>
224
- <AlertDescription>{conflict}</AlertDescription>
225
- </Alert>
226
- ) : null}
227
- <SegmentGroup<EditorKind>
228
- ariaLabel={t('customers.calendar.editor.typeSwitcher', 'Event type')}
229
- value={form.kind}
230
- onChange={(kind) => update({ kind, category: null })}
231
- options={EDITOR_KINDS.map((kind) => ({ value: kind, label: t(`customers.calendar.editor.types.${kind}`, kind) }))}
232
- />
233
- <Field label={titleLabel} error={fieldErrors.title}>
234
- <Input
235
- type="text"
236
- value={form.title}
237
- onChange={(event) => update({ title: event.target.value })}
238
- placeholder={t('customers.calendar.editor.titlePlaceholder', 'Add a title…')}
239
- aria-label={titleLabel}
240
- autoFocus
241
- size="lg"
242
- />
243
- </Field>
244
- <Field label={t('customers.calendar.editor.relatedTo', 'Related to')} error={fieldErrors.relatedTo}>
245
- <RelatedToField
246
- label={t('customers.calendar.editor.relatedTo', 'Related to')}
247
- value={form.relatedTo}
248
- deal={form.dealId && form.dealLabel ? { id: form.dealId, label: form.dealLabel } : null}
249
- onChange={(relatedTo) => update({ relatedTo })}
250
- onDealChange={(deal) => update({ dealId: deal?.id ?? null, dealLabel: deal?.label ?? null })}
251
- error={fieldErrors.relatedTo}
252
- />
253
- </Field>
254
- <ScheduleSection
255
- dateLabel={config.dateLabel}
256
- hasAllDay={config.hasAllDay}
257
- hasEnd={config.hasEnd}
258
- allDay={form.allDay}
259
- date={form.date}
260
- startTime={form.startTime}
261
- endDate={form.endDate}
262
- endTime={form.endTime}
263
- locale={locale}
264
- endsError={fieldErrors.ends}
265
- onAllDayChange={(allDay) => update({ allDay })}
266
- onDateChange={(date) => {
267
- const untouchedDefault =
268
- JSON.stringify(form.repeatDays) === JSON.stringify(defaultRepeatDaysForDateInput(form.date))
269
- update(
270
- untouchedDefault
271
- ? { date, repeatDays: defaultRepeatDaysForDateInput(date) }
272
- : { date },
273
- )
274
- }}
275
- onStartTimeChange={(startTime) => update({ startTime })}
276
- onEndDateChange={(endDate) => update({ endDate })}
277
- onEndTimeChange={(endTime) => update({ endTime })}
525
+ <div
526
+ className="flex-1 overflow-y-auto"
527
+ onScroll={() => {
528
+ // Tell the DS date/time fields to close their (controlled) popover
529
+ // so a portalled popover doesn't float over the form or drift away
530
+ // from its field while scrolling (#3747 feedback). Radix ignores
531
+ // synthetic dismiss events, so the fields drive their own `open`.
532
+ document.dispatchEvent(new CustomEvent(EDITOR_SCROLL_EVENT))
533
+ }}
534
+ >
535
+ <div className="px-4 py-4 sm:px-6 sm:py-5">
536
+ <CrudForm<Record<string, unknown>>
537
+ key={formKey}
538
+ formId={FORM_ID}
539
+ embedded
540
+ hideFooterActions
541
+ customFieldsManageMode="page"
542
+ fields={[]}
543
+ groups={groups}
544
+ initialValues={initialValues}
545
+ entityIds={INTERACTION_ENTITY_IDS}
546
+ onSubmit={handleSubmit}
278
547
  />
279
- {config.hasRepeat ? (
280
- <RepeatField
281
- freq={form.repeatFreq}
282
- days={form.repeatDays}
283
- endType={form.repeatEndType}
284
- count={form.repeatCount}
285
- untilDate={form.repeatUntilDate}
286
- locale={locale}
287
- onFreqChange={(repeatFreq) =>
288
- update(
289
- repeatFreq === 'weekly'
290
- ? { repeatFreq, repeatDays: defaultRepeatDaysForDateInput(form.date) }
291
- : { repeatFreq },
292
- )}
293
- onToggleDay={(index) =>
294
- update({ repeatDays: form.repeatDays.map((active, dayIndex) => (dayIndex === index ? !active : active)) })}
295
- onEndTypeChange={(repeatEndType) => update({ repeatEndType })}
296
- onCountChange={(repeatCount) => update({ repeatCount })}
297
- onUntilDateChange={(repeatUntilDate) => update({ repeatUntilDate })}
298
- />
299
- ) : null}
300
- <Field label={t('customers.calendar.editor.category', 'Category')}>
301
- <CategoryField
302
- label={t('customers.calendar.editor.category', 'Category')}
303
- value={form.category ?? form.kind}
304
- options={categoryOptions}
305
- colors={typeColors}
306
- onChange={(category) => update({ category })}
307
- />
308
- </Field>
309
- {config.location ? (
310
- <LocationField variant={config.location} value={form.location} onChange={(location) => update({ location })} />
311
- ) : null}
312
- {config.people && config.people !== 'assignee' ? (
313
- <Field label={t(PEOPLE_FIELD_TEXT[config.people].labelKey, PEOPLE_FIELD_TEXT[config.people].label)}>
314
- <PeopleField
315
- mode="multi"
316
- includeCustomers
317
- placeholder={t(PEOPLE_FIELD_TEXT[config.people].placeholderKey, PEOPLE_FIELD_TEXT[config.people].placeholder)}
318
- ariaLabel={t(PEOPLE_FIELD_TEXT[config.people].labelKey, PEOPLE_FIELD_TEXT[config.people].label)}
319
- value={form.participants}
320
- onChange={(participants) => update({ participants })}
321
- />
322
- </Field>
323
- ) : null}
324
- {config.hasPriority ? (
325
- <Field label={t('customers.calendar.editor.priority.label', 'Priority')}>
326
- <SegmentGroup<EditorPriority>
327
- size="md"
328
- ariaLabel={t('customers.calendar.editor.priority.label', 'Priority')}
329
- value={form.priority}
330
- onChange={(priority) => update({ priority })}
331
- options={[
332
- { value: 'low', label: t('customers.calendar.editor.priority.low', 'Low') },
333
- { value: 'medium', label: t('customers.calendar.editor.priority.medium', 'Medium') },
334
- { value: 'high', label: t('customers.calendar.editor.priority.high', 'High') },
335
- ]}
336
- />
337
- </Field>
338
- ) : null}
339
- {config.people === 'assignee' ? (
340
- <Field label={t('customers.calendar.editor.assignee', 'Assignee')}>
341
- <PeopleField
342
- mode="single"
343
- includeCustomers={false}
344
- placeholder={t('customers.calendar.editor.assigneePlaceholder', 'Assign to a team member…')}
345
- ariaLabel={t('customers.calendar.editor.assignee', 'Assignee')}
346
- value={form.assigneeUserId
347
- ? [{ userId: form.assigneeUserId, name: form.assigneeName ?? form.assigneeUserId, isCustomer: false }]
348
- : []}
349
- onChange={(entries) => {
350
- const next = entries[entries.length - 1] ?? null
351
- update({ assigneeUserId: next?.userId ?? null, assigneeName: next?.name ?? null })
352
- }}
353
- />
354
- </Field>
355
- ) : null}
356
- <Field label={t('customers.calendar.editor.description', 'Description')}>
357
- <Textarea
358
- value={form.description}
359
- onChange={(event) => update({ description: event.target.value })}
360
- placeholder={t('customers.calendar.editor.descriptionPlaceholder', 'Add details…')}
361
- aria-label={t('customers.calendar.editor.description', 'Description')}
362
- className="h-24 resize-none"
363
- />
364
- </Field>
365
548
  </div>
366
549
  </div>
367
550
  <div className="flex shrink-0 items-center justify-end gap-3 border-t border-border bg-background px-5 py-4">
@@ -373,8 +556,8 @@ export function CalendarEventEditor({ open, mode, item, defaultDate, defaultRang
373
556
  {t('customers.calendar.editor.cancel', 'Cancel')}
374
557
  </Button>
375
558
  <Button
376
- type="button"
377
- onClick={handleSave}
559
+ type="submit"
560
+ form={FORM_ID}
378
561
  disabled={saving}
379
562
  >
380
563
  {saving ? t('customers.calendar.editor.saving', 'Saving…') : t('customers.calendar.editor.save', 'Save event')}