@open-mercato/core 0.6.6-develop.6515.1.01911d00e2 → 0.6.6-develop.6519.1.bdc91fb755

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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/configs/lib/upgrade-actions.js +31 -1
  3. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  4. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +11 -2
  5. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
  6. package/dist/modules/customers/api/companies/[id]/route.js +3 -2
  7. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  8. package/dist/modules/customers/api/dictionaries/context.js +4 -2
  9. package/dist/modules/customers/api/dictionaries/context.js.map +2 -2
  10. package/dist/modules/customers/api/interactions/conflicts/route.js +2 -1
  11. package/dist/modules/customers/api/interactions/conflicts/route.js.map +2 -2
  12. package/dist/modules/customers/api/interactions/counts/route.js +7 -2
  13. package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
  14. package/dist/modules/customers/api/people/[id]/route.js +3 -2
  15. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  16. package/dist/modules/customers/cli.js +19 -0
  17. package/dist/modules/customers/cli.js.map +2 -2
  18. package/dist/modules/customers/commands/interactions.js +8 -3
  19. package/dist/modules/customers/commands/interactions.js.map +2 -2
  20. package/dist/modules/customers/commands/shared.js +2 -1
  21. package/dist/modules/customers/commands/shared.js.map +2 -2
  22. package/dist/modules/customers/components/DictionarySettings.js +5 -0
  23. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  24. package/dist/modules/customers/components/DictionarySortSettings.js +5 -0
  25. package/dist/modules/customers/components/DictionarySortSettings.js.map +2 -2
  26. package/dist/modules/customers/components/detail/ActivitiesCard.js +3 -2
  27. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
  28. package/dist/modules/customers/components/detail/ActivityCard.js +2 -1
  29. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  30. package/dist/modules/customers/components/detail/ActivityTimeline.js +3 -2
  31. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  32. package/dist/modules/customers/components/detail/TaskDialog.js +3 -2
  33. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskForm.js +170 -69
  35. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TasksSection.js +27 -4
  37. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  38. package/dist/modules/customers/components/detail/hooks/usePersonTasks.js.map +2 -2
  39. package/dist/modules/customers/data/enrichers.js +2 -2
  40. package/dist/modules/customers/data/enrichers.js.map +2 -2
  41. package/dist/modules/customers/data/validators.js +6 -2
  42. package/dist/modules/customers/data/validators.js.map +2 -2
  43. package/dist/modules/customers/lib/dictionaries.js +2 -1
  44. package/dist/modules/customers/lib/dictionaries.js.map +2 -2
  45. package/dist/modules/customers/lib/interactionProjection.js +2 -1
  46. package/dist/modules/customers/lib/interactionProjection.js.map +2 -2
  47. package/dist/modules/customers/lib/interactionStatus.js +24 -0
  48. package/dist/modules/customers/lib/interactionStatus.js.map +7 -0
  49. package/dist/modules/customers/lib/link-channel-message-handler.js +4 -5
  50. package/dist/modules/customers/lib/link-channel-message-handler.js.map +2 -2
  51. package/dist/modules/dictionaries/components/AppearanceSelector.js +7 -3
  52. package/dist/modules/dictionaries/components/AppearanceSelector.js.map +2 -2
  53. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +1 -0
  54. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
  55. package/package.json +7 -7
  56. package/src/modules/configs/lib/upgrade-actions.ts +31 -1
  57. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +12 -3
  58. package/src/modules/customers/api/companies/[id]/route.ts +3 -2
  59. package/src/modules/customers/api/dictionaries/context.ts +2 -0
  60. package/src/modules/customers/api/interactions/conflicts/route.ts +2 -1
  61. package/src/modules/customers/api/interactions/counts/route.ts +7 -2
  62. package/src/modules/customers/api/people/[id]/route.ts +3 -2
  63. package/src/modules/customers/cli.ts +19 -0
  64. package/src/modules/customers/commands/interactions.ts +8 -3
  65. package/src/modules/customers/commands/shared.ts +1 -0
  66. package/src/modules/customers/components/DictionarySettings.tsx +5 -0
  67. package/src/modules/customers/components/DictionarySortSettings.tsx +5 -0
  68. package/src/modules/customers/components/detail/ActivitiesCard.tsx +3 -2
  69. package/src/modules/customers/components/detail/ActivityCard.tsx +2 -1
  70. package/src/modules/customers/components/detail/ActivityTimeline.tsx +3 -2
  71. package/src/modules/customers/components/detail/TaskDialog.tsx +4 -1
  72. package/src/modules/customers/components/detail/TaskForm.tsx +218 -71
  73. package/src/modules/customers/components/detail/TasksSection.tsx +28 -2
  74. package/src/modules/customers/components/detail/hooks/usePersonTasks.ts +1 -0
  75. package/src/modules/customers/data/enrichers.ts +2 -7
  76. package/src/modules/customers/data/validators.ts +16 -2
  77. package/src/modules/customers/i18n/de.json +10 -0
  78. package/src/modules/customers/i18n/en.json +10 -0
  79. package/src/modules/customers/i18n/es.json +10 -0
  80. package/src/modules/customers/i18n/pl.json +10 -0
  81. package/src/modules/customers/lib/dictionaries.ts +1 -0
  82. package/src/modules/customers/lib/interactionProjection.ts +6 -2
  83. package/src/modules/customers/lib/interactionStatus.ts +30 -0
  84. package/src/modules/customers/lib/link-channel-message-handler.ts +4 -5
  85. package/src/modules/dictionaries/components/AppearanceSelector.tsx +10 -1
  86. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +1 -0
@@ -10,6 +10,7 @@ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
10
10
  import { ActivitiesDayStrip } from './ActivitiesDayStrip'
11
11
  import { ActivitiesAddNewMenu, type ActivityKind } from './ActivitiesAddNewMenu'
12
12
  import type { InteractionSummary } from './types'
13
+ import { isOpenInteractionStatus } from '../../lib/interactionStatus'
13
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
15
 
15
16
  const logger = createLogger('customers')
@@ -71,7 +72,7 @@ function isOverdue(activity: InteractionSummary, now: Date): boolean {
71
72
  if (!scheduled) return false
72
73
  const date = new Date(scheduled)
73
74
  if (Number.isNaN(date.getTime())) return false
74
- return date.getTime() < now.getTime() && activity.status !== 'done'
75
+ return date.getTime() < now.getTime() && isOpenInteractionStatus(activity.status)
75
76
  }
76
77
 
77
78
  // Visible window for the day-strip + activity list. Mirrors `VISIBLE_DAYS = 5`
@@ -256,7 +257,7 @@ function PlannedEventRow({ activity, onClick, entityCompanyName, t }: PlannedEve
256
257
  const validDate = !Number.isNaN(date.getTime())
257
258
  const Icon = TYPE_ICONS[activity.interactionType] ?? Users
258
259
  const duration = typeof activity.duration === 'number' && activity.duration > 0 ? activity.duration : null
259
- const overdue = validDate && date.getTime() < Date.now() && activity.status !== 'done'
260
+ const overdue = validDate && date.getTime() < Date.now() && isOpenInteractionStatus(activity.status)
260
261
  const typeLabel = labelForType(activity.interactionType, t)
261
262
  const subtitleSuffix = activity.dealTitle ?? entityCompanyName ?? null
262
263
  const subtitle = subtitleSuffix ? `${typeLabel} · ${subtitleSuffix}` : typeLabel
@@ -10,6 +10,7 @@ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
10
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
11
  import { cn } from '@open-mercato/shared/lib/utils'
12
12
  import type { InteractionSummary } from './types'
13
+ import { isOpenInteractionStatus } from '../../lib/interactionStatus'
13
14
  import { ActivityAiActions } from './ActivityAiActions'
14
15
  import { EmailCardActions, type EmailCardWidgetData } from './EmailCardActions'
15
16
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -200,7 +201,7 @@ export function ActivityCard({ activity, onOpen, onChanged, runMutation }: Activ
200
201
  </div>
201
202
 
202
203
  <div className="flex items-center gap-1.5">
203
- {activity.status === 'planned' ? (
204
+ {isOpenInteractionStatus(activity.status) ? (
204
205
  <Button
205
206
  type="button"
206
207
  variant="default"
@@ -6,6 +6,7 @@ import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { AiActionChips } from './AiActionChips'
8
8
  import type { InteractionSummary } from './types'
9
+ import { isOpenInteractionStatus } from '../../lib/interactionStatus'
9
10
 
10
11
  const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
11
12
  call: Phone,
@@ -83,7 +84,7 @@ function TimelineEntry({
83
84
  const TypeIcon = TYPE_ICONS[activity.interactionType]
84
85
  const title = activity.title ?? activity.body ?? activity.interactionType
85
86
  const duration = activity.duration ? ` (${activity.duration} min)` : ''
86
- const isPlanned = activity.status === 'planned'
87
+ const isOpen = isOpenInteractionStatus(activity.status)
87
88
  const [markingDone, setMarkingDone] = React.useState(false)
88
89
 
89
90
  const handleMarkDone = React.useCallback(async (event: React.MouseEvent | React.KeyboardEvent) => {
@@ -127,7 +128,7 @@ function TimelineEntry({
127
128
  <span className="block text-[12px] font-semibold leading-tight text-foreground">
128
129
  {title}{duration}
129
130
  </span>
130
- {isPlanned && onMarkDone ? (
131
+ {isOpen && onMarkDone ? (
131
132
  <Button
132
133
  type="button"
133
134
  variant="default"
@@ -14,9 +14,11 @@ export type TaskDialogProps = {
14
14
  onSubmit: (payload: TaskFormPayload) => Promise<void>
15
15
  isSubmitting?: boolean
16
16
  contextMessage?: string
17
+ /** Forwarded to TaskForm to gate the rich status picker to the canonical interactions path. */
18
+ useCanonicalInteractions?: boolean
17
19
  }
18
20
 
19
- export function TaskDialog({ open, mode, onOpenChange, initialValues, onSubmit, isSubmitting, contextMessage }: TaskDialogProps) {
21
+ export function TaskDialog({ open, mode, onOpenChange, initialValues, onSubmit, isSubmitting, contextMessage, useCanonicalInteractions = false }: TaskDialogProps) {
20
22
  const t = useT()
21
23
 
22
24
  const dialogTitle =
@@ -49,6 +51,7 @@ export function TaskDialog({ open, mode, onOpenChange, initialValues, onSubmit,
49
51
  onSubmit={handleSubmit}
50
52
  onCancel={handleCancel}
51
53
  isSubmitting={isSubmitting}
54
+ useCanonicalInteractions={useCanonicalInteractions}
52
55
  />
53
56
  </DialogContent>
54
57
  </Dialog>
@@ -1,14 +1,34 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
4
+ import {
5
+ CrudForm,
6
+ type CrudCustomFieldRenderProps,
7
+ type CrudField,
8
+ type CrudFormGroup,
9
+ } from '@open-mercato/ui/backend/CrudForm'
5
10
  import { Button } from '@open-mercato/ui/primitives/button'
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectItemLeading,
16
+ SelectTrigger,
17
+ SelectTriggerLeading,
18
+ SelectValue,
19
+ } from '@open-mercato/ui/primitives/select'
20
+ import {
21
+ renderDictionaryColor,
22
+ renderDictionaryIcon,
23
+ } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
6
24
  import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
7
25
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
8
26
  import { useT } from '@open-mercato/shared/lib/i18n/context'
27
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
9
28
  import { CUSTOMER_INTERACTION_ENTITY_ID } from '../../lib/interactionCompatibility'
10
29
  import type { TaskFormPayload } from './hooks/usePersonTasks'
11
30
  import { normalizeCustomFieldSubmitValue } from './customFieldUtils'
31
+ import { useCustomerDictionary } from './hooks/useCustomerDictionary'
12
32
 
13
33
  export type TaskFormProps = {
14
34
  mode: 'create' | 'edit'
@@ -19,6 +39,12 @@ export type TaskFormProps = {
19
39
  cancelLabel?: string
20
40
  isSubmitting?: boolean
21
41
  formEntityId?: string
42
+ /**
43
+ * Render the rich, dictionary-backed status picker only on the canonical interactions path.
44
+ * On the legacy path the deprecated `/api/customers/todos` bridge round-trips a task through a
45
+ * binary done/not-done shape, so non-binary statuses cannot persist — keep the done checkbox there.
46
+ */
47
+ useCanonicalInteractions?: boolean
22
48
  }
23
49
 
24
50
  export function TaskForm({
@@ -30,81 +56,43 @@ export function TaskForm({
30
56
  cancelLabel,
31
57
  isSubmitting = false,
32
58
  formEntityId = CUSTOMER_INTERACTION_ENTITY_ID,
59
+ useCanonicalInteractions = false,
33
60
  }: TaskFormProps) {
34
61
  const t = useT()
35
62
  const containerRef = React.useRef<HTMLDivElement | null>(null)
63
+ const scopeVersion = useOrganizationScopeVersion()
64
+ const statusDictionary = useCustomerDictionary('interaction-statuses', scopeVersion)
36
65
 
37
- const fields = React.useMemo<CrudField[]>(() => {
66
+ const statusOptions = React.useMemo<TaskStatusOption[]>(() => {
67
+ const entries = statusDictionary.data?.entries ?? []
68
+ if (entries.length > 0) {
69
+ return entries.map((entry) => ({
70
+ value: entry.value,
71
+ label: entry.label,
72
+ color: entry.color ?? null,
73
+ icon: entry.icon ?? null,
74
+ }))
75
+ }
76
+ // Fallback to the canonical seeded set until the dictionary loads (or for existing
77
+ // tenants whose interaction-statuses dictionary has not been backfilled yet).
38
78
  return [
39
- {
40
- id: 'title',
41
- label: t('customers.people.detail.tasks.fields.title', 'Title'),
42
- type: 'text',
43
- required: true,
44
- placeholder: t('customers.people.detail.tasks.fields.titlePlaceholder', 'Task title'),
45
- },
46
- {
47
- id: 'scheduledAt',
48
- label: t('customers.people.detail.tasks.fields.scheduledAt', 'Scheduled for'),
49
- type: 'datetime',
50
- placeholder: t('customers.people.detail.tasks.fields.scheduledAtPlaceholder', 'Choose a date and time'),
51
- },
52
- {
53
- id: 'priority',
54
- label: t('customers.people.detail.tasks.fields.priority', 'Priority'),
55
- type: 'number',
56
- placeholder: t('customers.people.detail.tasks.fields.priorityPlaceholder', '0-100'),
57
- },
58
- {
59
- id: 'description',
60
- label: t('customers.people.detail.tasks.fields.description', 'Description'),
61
- type: 'textarea',
62
- placeholder: t('customers.people.detail.tasks.fields.descriptionPlaceholder', 'Add context, outcome, or follow-up notes'),
63
- layout: 'full',
64
- },
65
- {
66
- id: 'is_done',
67
- label: t('customers.people.detail.tasks.fields.done', 'Mark as done'),
68
- type: 'checkbox',
69
- },
79
+ { value: 'planned', label: t('customers.interactions.status.planned', 'Planned') },
80
+ { value: 'in_progress', label: t('customers.interactions.status.in_progress', 'In progress') },
81
+ { value: 'waiting', label: t('customers.interactions.status.waiting', 'Waiting / blocked') },
82
+ { value: 'done', label: t('customers.interactions.status.done', 'Done') },
83
+ { value: 'canceled', label: t('customers.interactions.status.canceled', 'Canceled') },
70
84
  ]
71
- }, [t])
85
+ }, [statusDictionary.data, t])
72
86
 
73
- const groups = React.useMemo<CrudFormGroup[]>(() => {
74
- return [
75
- {
76
- id: 'details',
77
- title: t('customers.people.detail.tasks.form.details', 'Task details'),
78
- column: 1,
79
- fields: ['title', 'scheduledAt', 'description'],
80
- },
81
- {
82
- id: 'status',
83
- title: t('customers.people.detail.tasks.form.status', 'Status'),
84
- column: 2,
85
- fields: ['priority', 'is_done'],
86
- },
87
- {
88
- id: 'attributes',
89
- title: t('customers.people.detail.tasks.form.customFields', 'Task attributes'),
90
- column: 1,
91
- kind: 'customFields',
92
- },
93
- {
94
- id: 'tips',
95
- title: t('customers.people.detail.tasks.form.tips', 'Tips'),
96
- column: 2,
97
- component: () => (
98
- <div className="text-sm text-muted-foreground">
99
- {t(
100
- 'customers.people.detail.tasks.form.tipsBody',
101
- 'Tasks save independently from the main customer form. Use clear titles like "Follow up call" or "Send pricing deck".',
102
- )}
103
- </div>
104
- ),
105
- },
106
- ]
107
- }, [t])
87
+ const fields = React.useMemo<CrudField[]>(
88
+ () => buildTaskFormFields({ useCanonicalInteractions, statusOptions, t }),
89
+ [statusOptions, t, useCanonicalInteractions],
90
+ )
91
+
92
+ const groups = React.useMemo<CrudFormGroup[]>(
93
+ () => buildTaskFormGroups({ useCanonicalInteractions, t }),
94
+ [t, useCanonicalInteractions],
95
+ )
108
96
 
109
97
  const resolvedSubmitLabel =
110
98
  submitLabel ??
@@ -136,6 +124,14 @@ export function TaskForm({
136
124
  [onSubmit, t],
137
125
  )
138
126
 
127
+ // On create, default the canonical status select to `planned` so the dropdown is never empty.
128
+ // The legacy path has no status field (just the done checkbox), so it needs no default.
129
+ const resolvedInitialValues = React.useMemo<Record<string, unknown> | undefined>(() => {
130
+ if (mode !== 'create') return initialValues
131
+ if (useCanonicalInteractions) return { status: 'planned', ...(initialValues ?? {}) }
132
+ return initialValues
133
+ }, [initialValues, mode, useCanonicalInteractions])
134
+
139
135
  return (
140
136
  <div ref={containerRef} onKeyDown={handleKeyDown}>
141
137
  <CrudForm
@@ -143,7 +139,7 @@ export function TaskForm({
143
139
  entityId={formEntityId}
144
140
  fields={fields}
145
141
  groups={groups}
146
- initialValues={initialValues}
142
+ initialValues={resolvedInitialValues}
147
143
  submitLabel={resolvedSubmitLabel}
148
144
  onSubmit={handleSubmit}
149
145
  extraActions={
@@ -156,6 +152,150 @@ export function TaskForm({
156
152
  )
157
153
  }
158
154
 
155
+ type TaskFormTranslator = (key: string, fallback?: string) => string
156
+
157
+ type TaskStatusOption = { value: string; label: string; color?: string | null; icon?: string | null }
158
+
159
+ type TaskStatusSelectProps = CrudCustomFieldRenderProps & {
160
+ options: TaskStatusOption[]
161
+ placeholder: string
162
+ }
163
+
164
+ // Dictionary-backed status picker that surfaces each status's icon + color (the dictionary
165
+ // "appearance") next to its label, both in the dropdown rows and the trigger. CrudForm's generic
166
+ // `select` renders label-only text, so the canonical status field uses this custom renderer instead.
167
+ function TaskStatusSelect({ id, value, setValue, disabled, options, placeholder }: TaskStatusSelectProps) {
168
+ const stringValue = typeof value === 'string' ? value : ''
169
+ const activeOption = options.find((option) => option.value === stringValue) ?? null
170
+ return (
171
+ <Select
172
+ value={stringValue}
173
+ onValueChange={(next) => setValue(next || undefined)}
174
+ disabled={disabled}
175
+ >
176
+ <SelectTrigger id={id} data-crud-focus-target="">
177
+ {activeOption && (activeOption.icon || activeOption.color) ? (
178
+ <SelectTriggerLeading>
179
+ {renderDictionaryIcon(activeOption.icon, 'h-4 w-4')}
180
+ {renderDictionaryColor(activeOption.color, 'h-3.5 w-3.5 rounded-full')}
181
+ </SelectTriggerLeading>
182
+ ) : null}
183
+ <SelectValue placeholder={placeholder}>{activeOption?.label}</SelectValue>
184
+ </SelectTrigger>
185
+ <SelectContent>
186
+ {options.map((option) => (
187
+ <SelectItem key={option.value} value={option.value}>
188
+ {option.icon || option.color ? (
189
+ <SelectItemLeading>
190
+ {renderDictionaryIcon(option.icon, 'h-4 w-4')}
191
+ {renderDictionaryColor(option.color, 'h-3.5 w-3.5 rounded-full')}
192
+ </SelectItemLeading>
193
+ ) : null}
194
+ {option.label}
195
+ </SelectItem>
196
+ ))}
197
+ </SelectContent>
198
+ </Select>
199
+ )
200
+ }
201
+
202
+ export function buildTaskFormFields(options: {
203
+ useCanonicalInteractions: boolean
204
+ statusOptions: TaskStatusOption[]
205
+ t: TaskFormTranslator
206
+ }): CrudField[] {
207
+ const { useCanonicalInteractions, statusOptions, t } = options
208
+ // Canonical path: dictionary-backed rich status picker. Legacy path: the binary done checkbox,
209
+ // because the deprecated todos bridge cannot persist non-binary statuses on read-back.
210
+ const statusField: CrudField = useCanonicalInteractions
211
+ ? {
212
+ id: 'status',
213
+ label: t('customers.people.detail.tasks.fields.status', 'Status'),
214
+ type: 'custom',
215
+ component: (props) => (
216
+ <TaskStatusSelect
217
+ {...props}
218
+ options={statusOptions}
219
+ placeholder={t('customers.people.detail.tasks.fields.statusPlaceholder', 'Select a status')}
220
+ />
221
+ ),
222
+ }
223
+ : {
224
+ id: 'is_done',
225
+ label: t('customers.people.detail.tasks.fields.done', 'Mark as done'),
226
+ type: 'checkbox',
227
+ }
228
+ return [
229
+ {
230
+ id: 'title',
231
+ label: t('customers.people.detail.tasks.fields.title', 'Title'),
232
+ type: 'text',
233
+ required: true,
234
+ placeholder: t('customers.people.detail.tasks.fields.titlePlaceholder', 'Task title'),
235
+ },
236
+ {
237
+ id: 'scheduledAt',
238
+ label: t('customers.people.detail.tasks.fields.scheduledAt', 'Scheduled for'),
239
+ type: 'datetime',
240
+ placeholder: t('customers.people.detail.tasks.fields.scheduledAtPlaceholder', 'Choose a date and time'),
241
+ },
242
+ {
243
+ id: 'priority',
244
+ label: t('customers.people.detail.tasks.fields.priority', 'Priority'),
245
+ type: 'number',
246
+ placeholder: t('customers.people.detail.tasks.fields.priorityPlaceholder', '0-100'),
247
+ },
248
+ {
249
+ id: 'description',
250
+ label: t('customers.people.detail.tasks.fields.description', 'Description'),
251
+ type: 'textarea',
252
+ placeholder: t('customers.people.detail.tasks.fields.descriptionPlaceholder', 'Add context, outcome, or follow-up notes'),
253
+ layout: 'full',
254
+ },
255
+ statusField,
256
+ ]
257
+ }
258
+
259
+ export function buildTaskFormGroups(options: {
260
+ useCanonicalInteractions: boolean
261
+ t: TaskFormTranslator
262
+ }): CrudFormGroup[] {
263
+ const { useCanonicalInteractions, t } = options
264
+ return [
265
+ {
266
+ id: 'details',
267
+ title: t('customers.people.detail.tasks.form.details', 'Task details'),
268
+ column: 1,
269
+ fields: ['title', 'scheduledAt', 'description'],
270
+ },
271
+ {
272
+ id: 'status',
273
+ title: t('customers.people.detail.tasks.form.status', 'Status'),
274
+ column: 2,
275
+ fields: ['priority', useCanonicalInteractions ? 'status' : 'is_done'],
276
+ },
277
+ {
278
+ id: 'attributes',
279
+ title: t('customers.people.detail.tasks.form.customFields', 'Task attributes'),
280
+ column: 1,
281
+ kind: 'customFields',
282
+ },
283
+ {
284
+ id: 'tips',
285
+ title: t('customers.people.detail.tasks.form.tips', 'Tips'),
286
+ column: 2,
287
+ component: () => (
288
+ <div className="text-sm text-muted-foreground">
289
+ {t(
290
+ 'customers.people.detail.tasks.form.tipsBody',
291
+ 'Tasks save independently from the main customer form. Use clear titles like "Follow up call" or "Send pricing deck".',
292
+ )}
293
+ </div>
294
+ ),
295
+ },
296
+ ]
297
+ }
298
+
159
299
  export function buildTaskSubmitPayload(values: Record<string, unknown>, t: (key: string, fallback?: string) => string): TaskFormPayload {
160
300
  const rawTitle = typeof values.title === 'string' ? values.title.trim() : ''
161
301
  if (!rawTitle.length) {
@@ -176,8 +316,15 @@ export function buildTaskSubmitPayload(values: Record<string, unknown>, t: (key:
176
316
 
177
317
  const rawDescription = typeof values.description === 'string' ? values.description.trim() : ''
178
318
  const rawScheduledAt = typeof values.scheduledAt === 'string' ? values.scheduledAt.trim() : ''
319
+ const rawStatus = typeof values.status === 'string' ? values.status.trim() : ''
320
+ const status = rawStatus.length ? rawStatus : undefined
179
321
  const base: TaskFormPayload['base'] = { title: rawTitle }
180
- if (typeof values.is_done === 'boolean') {
322
+ if (status !== undefined) {
323
+ base.status = status
324
+ // Keep is_done in sync so the deprecated todos bridge (which only carries a boolean)
325
+ // stays correct; the canonical interactions path uses base.status directly.
326
+ base.is_done = status === 'done'
327
+ } else if (typeof values.is_done === 'boolean') {
181
328
  base.is_done = values.is_done
182
329
  }
183
330
  base.description = rawDescription || null
@@ -5,6 +5,9 @@ import Link from 'next/link'
5
5
  import { Loader2, Pencil, Trash2 } from 'lucide-react'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
8
+ import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
9
+ import { mapDictionaryColorToTone } from '@open-mercato/shared/lib/query/advanced-filter'
10
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
8
11
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
12
  import { LoadingMessage, TabEmptyState } from '@open-mercato/ui/backend/detail'
10
13
  import { cn } from '@open-mercato/shared/lib/utils'
@@ -16,6 +19,7 @@ import { formatDateTime } from '@open-mercato/shared/lib/time'
16
19
  import { TimelineItemHeader } from './TimelineItemHeader'
17
20
  import { TaskDialog } from './TaskDialog'
18
21
  import { usePersonTasks, type TaskFormPayload } from './hooks/usePersonTasks'
22
+ import { useCustomerDictionary } from './hooks/useCustomerDictionary'
19
23
  import { useInteractions, type InteractionCreatePayload } from './hooks/useInteractions'
20
24
  import { mapInteractionRecordToTodoSummary } from '../../lib/interactionCompatibility'
21
25
 
@@ -66,11 +70,18 @@ function sortTaskSummaries(tasks: TodoLinkSummary[]): TodoLinkSummary[] {
66
70
  })
67
71
  }
68
72
 
73
+ const STATUS_BADGE_VARIANTS = new Set<StatusBadgeVariant>(['success', 'warning', 'error', 'info', 'neutral'])
74
+
75
+ function coerceStatusBadgeVariant(tone: ReturnType<typeof mapDictionaryColorToTone>): StatusBadgeVariant {
76
+ return tone && STATUS_BADGE_VARIANTS.has(tone as StatusBadgeVariant) ? (tone as StatusBadgeVariant) : 'neutral'
77
+ }
78
+
69
79
  function buildInitialFormValues(task: TodoLinkSummary | null): Record<string, unknown> | undefined {
70
80
  if (!task) return undefined
71
81
  const values: Record<string, unknown> = {
72
82
  title: task.title ?? '',
73
83
  is_done: task.isDone ?? false,
84
+ status: task.status ?? 'planned',
74
85
  description: task.description ?? '',
75
86
  priority: task.priority ?? '',
76
87
  scheduledAt: task.dueAt ?? '',
@@ -114,6 +125,10 @@ export function TasksSection({
114
125
  [runGuardedMutation],
115
126
  )
116
127
 
128
+ const scopeVersion = useOrganizationScopeVersion()
129
+ const statusDictionary = useCustomerDictionary('interaction-statuses', scopeVersion)
130
+ const statusMap = statusDictionary.data?.map ?? null
131
+
117
132
  // Legacy path: usePersonTasks (default)
118
133
  const legacyResult = usePersonTasks({ entityId, initialTasks })
119
134
 
@@ -136,7 +151,7 @@ export function TasksSection({
136
151
  entityId,
137
152
  interactionType: 'task',
138
153
  title: payload.base.title,
139
- status: payload.base.is_done ? 'done' : 'planned',
154
+ status: payload.base.status ?? (payload.base.is_done ? 'done' : 'planned'),
140
155
  priority: payload.base.priority ?? null,
141
156
  body: payload.base.description ?? null,
142
157
  scheduledAt: payload.base.scheduledAt ?? null,
@@ -151,7 +166,7 @@ export function TasksSection({
151
166
  async (task: TodoLinkSummary, payload: TaskFormPayload) => {
152
167
  await canonicalResult.updateInteraction(task.todoId, {
153
168
  title: payload.base.title,
154
- status: payload.base.is_done ? 'done' : 'planned',
169
+ status: payload.base.status ?? (payload.base.is_done ? 'done' : 'planned'),
155
170
  priority: payload.base.priority ?? null,
156
171
  body: payload.base.description ?? null,
157
172
  scheduledAt: payload.base.scheduledAt ?? null,
@@ -482,6 +497,16 @@ export function TasksSection({
482
497
  >
483
498
  {title}
484
499
  </span>
500
+ {task.status ? (
501
+ <StatusBadge
502
+ variant={coerceStatusBadgeVariant(
503
+ mapDictionaryColorToTone(statusMap?.[task.status]?.color ?? null),
504
+ )}
505
+ dot
506
+ >
507
+ {statusMap?.[task.status]?.label ?? task.status}
508
+ </StatusBadge>
509
+ ) : null}
485
510
  </span>
486
511
  }
487
512
  timestamp={task.createdAt}
@@ -593,6 +618,7 @@ export function TasksSection({
593
618
  onSubmit={handleDialogSubmit}
594
619
  isSubmitting={isMutating}
595
620
  contextMessage={dialogContextMessage}
621
+ useCanonicalInteractions={useCanonicalInteractions}
596
622
  />
597
623
  </div>
598
624
  )
@@ -41,6 +41,7 @@ export type TaskFormPayload = {
41
41
  base: {
42
42
  title: string
43
43
  is_done?: boolean
44
+ status?: string
44
45
  description?: string | null
45
46
  priority?: number | null
46
47
  scheduledAt?: string | null
@@ -2,6 +2,7 @@ import type { ResponseEnricher, EnricherContext } from '@open-mercato/shared/lib
2
2
  import type { CustomerKysely } from '../lib/kysely'
3
3
  import { resolveKyselyClient } from '../lib/kysely'
4
4
  import { fetchStuckThresholdDays } from '../lib/stuckDeals'
5
+ import { TERMINAL_INTERACTION_STATUS_LIST } from '../lib/interactionStatus'
5
6
 
6
7
  type DealRecord = Record<string, unknown> & {
7
8
  id: string
@@ -19,12 +20,6 @@ type PipelineState = {
19
20
 
20
21
  const ENRICHER_TIMEOUT_MS = 2000
21
22
  const DAY_MS = 24 * 60 * 60 * 1000
22
- // Mirror the canonical statuses defined in validators.ts (`interactionStatusValues`). The
23
- // canonical spelling is `canceled` (US, single L), not `cancelled` (UK). Misspelling this
24
- // here let canceled interactions slip into the open-activities count badge on the card.
25
- // We accept `completed` defensively too, even though `done` is the canonical "completed"
26
- // value in the validator, in case any legacy rows exist with the longer spelling.
27
- const TERMINAL_INTERACTION_STATUSES = ['done', 'canceled', 'completed'] as const
28
23
 
29
24
  function parseDate(value: unknown): Date | null {
30
25
  if (!value) return null
@@ -63,7 +58,7 @@ async function fetchOpenInteractionCounts(
63
58
  .where('organization_id', '=', organizationId)
64
59
  .where('tenant_id', '=', tenantId)
65
60
  .where('deleted_at', 'is', null)
66
- .where('status', 'not in', [...TERMINAL_INTERACTION_STATUSES])
61
+ .where('status', 'not in', [...TERMINAL_INTERACTION_STATUS_LIST])
67
62
  .groupBy('deal_id')
68
63
  .execute()
69
64
  for (const row of rows) {
@@ -406,7 +406,17 @@ export const todoLinkWithTodoCreateSchema = scopedSchema.extend({
406
406
 
407
407
  // --- Interaction schemas ---
408
408
 
409
+ /**
410
+ * @deprecated Interaction statuses are now dictionary-backed and tenant-configurable
411
+ * (the `interaction-statuses` dictionary). This frozen 3-value list is kept only for
412
+ * backward compatibility; it is no longer the validation source (the API accepts any
413
+ * `z.string().max(50)`). For open/terminal semantics use `lib/interactionStatus.ts`
414
+ * (`isOpenInteractionStatus` / `isTerminalInteractionStatus` / `INTERACTION_STATUS_*`);
415
+ * for the seeded default set use `INTERACTION_STATUS_DEFAULTS` in `cli.ts`. Do not expand
416
+ * this list — adding members would break exhaustive consumers.
417
+ */
409
418
  export const interactionStatusValues = ['planned', 'done', 'canceled'] as const
419
+ /** @deprecated See {@link interactionStatusValues}. */
410
420
  export type InteractionStatus = typeof interactionStatusValues[number]
411
421
 
412
422
  const interactionParticipantSchema = z.object({
@@ -449,7 +459,11 @@ const interactionCreateBaseSchema = scopedSchema.extend({
449
459
  interactionType: z.string().trim().min(1).max(100),
450
460
  title: z.string().trim().max(500).optional().nullable(),
451
461
  body: z.string().trim().max(10000).optional().nullable(),
452
- status: z.enum(interactionStatusValues).optional().default('planned'),
462
+ // Lenient like `deal_status` (status: z.string().max(50)). The `interaction-statuses`
463
+ // dictionary drives the UI dropdown; the API accepts any string <=50 chars so existing
464
+ // rows, external writers, and the dispatch-crm MCP keep working. Open/terminal semantics
465
+ // live in lib/interactionStatus.ts, not in this validator.
466
+ status: z.string().max(50).optional().default('planned'),
453
467
  date: z.string().trim().min(1, ACTIVITY_DATE_REQUIRED_MESSAGE_KEY).optional(),
454
468
  time: z.string().trim().min(1, ACTIVITY_TIME_REQUIRED_MESSAGE_KEY).optional(),
455
469
  phoneNumber: interactionPhoneNumberSchema,
@@ -516,7 +530,7 @@ const interactionUpdateBaseSchema = z
516
530
  interactionType: z.string().trim().min(1).max(100).optional(),
517
531
  title: z.string().trim().max(500).optional().nullable(),
518
532
  body: z.string().trim().max(10000).optional().nullable(),
519
- status: z.enum(interactionStatusValues).optional(),
533
+ status: z.string().max(50).optional(),
520
534
  date: z.string().trim().min(1, ACTIVITY_DATE_REQUIRED_MESSAGE_KEY).optional(),
521
535
  time: z.string().trim().min(1, ACTIVITY_TIME_REQUIRED_MESSAGE_KEY).optional(),
522
536
  phoneNumber: interactionPhoneNumberSchema,
@@ -836,6 +836,8 @@
836
836
  "customers.config.dictionaries.sections.dealStatuses.title": "Deal-Status",
837
837
  "customers.config.dictionaries.sections.industries.description": "Verwalten Sie die von Unternehmen verwendeten Branchen.",
838
838
  "customers.config.dictionaries.sections.industries.title": "Branchen",
839
+ "customers.config.dictionaries.sections.interactionStatuses.description": "Manage the statuses available for tasks and logged interactions.",
840
+ "customers.config.dictionaries.sections.interactionStatuses.title": "Interaction statuses",
839
841
  "customers.config.dictionaries.sections.jobTitles.description": "Konfiguriere Positionsbezeichnungen und ihr Erscheinungsbild.",
840
842
  "customers.config.dictionaries.sections.jobTitles.title": "Positionen",
841
843
  "customers.config.dictionaries.sections.lifecycle.description": "Konfiguriere Lifecycle-Phasen, um den Fortschritt von Kunden nachzuverfolgen.",
@@ -861,6 +863,10 @@
861
863
  "customers.config.nav.customers": "Kunden",
862
864
  "customers.config.pipelineStages.deletePipelineDesc": "This pipeline and all its stages will be permanently removed. Deals assigned to it will lose their pipeline assignment.",
863
865
  "customers.config.pipelineStages.deleteStageDesc": "This stage will be permanently removed. Deals assigned to it will lose their stage assignment.",
866
+ "customers.config.upgradeActions.interactionStatuses.cta": "Seed interaction statuses",
867
+ "customers.config.upgradeActions.interactionStatuses.loading": "Seeding interaction statuses…",
868
+ "customers.config.upgradeActions.interactionStatuses.message": "Seed the interaction-statuses dictionary so CRM tasks and logged interactions can use the configurable status list.",
869
+ "customers.config.upgradeActions.interactionStatuses.success": "Interaction statuses are ready.",
864
870
  "customers.countries.ca": "Kanada",
865
871
  "customers.countries.de": "Deutschland",
866
872
  "customers.countries.es": "Spanien",
@@ -1609,7 +1615,9 @@
1609
1615
  "customers.interactions.load.error": "Failed to load interactions.",
1610
1616
  "customers.interactions.status.canceled": "Canceled",
1611
1617
  "customers.interactions.status.done": "Done",
1618
+ "customers.interactions.status.in_progress": "In progress",
1612
1619
  "customers.interactions.status.planned": "Planned",
1620
+ "customers.interactions.status.waiting": "Waiting / blocked",
1613
1621
  "customers.interactions.update.error": "Failed to update interaction.",
1614
1622
  "customers.interactions.update.success": "Interaction updated.",
1615
1623
  "customers.linking.actions.cancel": "Abbrechen",
@@ -2126,6 +2134,8 @@
2126
2134
  "customers.people.detail.tasks.fields.priorityPlaceholder": "0-100",
2127
2135
  "customers.people.detail.tasks.fields.scheduledAt": "Geplant für",
2128
2136
  "customers.people.detail.tasks.fields.scheduledAtPlaceholder": "Datum und Uhrzeit auswählen",
2137
+ "customers.people.detail.tasks.fields.status": "Status",
2138
+ "customers.people.detail.tasks.fields.statusPlaceholder": "Status auswählen",
2129
2139
  "customers.people.detail.tasks.fields.title": "Titel",
2130
2140
  "customers.people.detail.tasks.fields.titlePlaceholder": "Aufgabentitel",
2131
2141
  "customers.people.detail.tasks.form.customFields": "Aufgabenattribute",