@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/configs/lib/upgrade-actions.js +31 -1
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +11 -2
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +3 -2
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/dictionaries/context.js +4 -2
- package/dist/modules/customers/api/dictionaries/context.js.map +2 -2
- package/dist/modules/customers/api/interactions/conflicts/route.js +2 -1
- package/dist/modules/customers/api/interactions/conflicts/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/counts/route.js +7 -2
- package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +3 -2
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/cli.js +19 -0
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +8 -3
- package/dist/modules/customers/commands/interactions.js.map +2 -2
- package/dist/modules/customers/commands/shared.js +2 -1
- package/dist/modules/customers/commands/shared.js.map +2 -2
- package/dist/modules/customers/components/DictionarySettings.js +5 -0
- package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
- package/dist/modules/customers/components/DictionarySortSettings.js +5 -0
- package/dist/modules/customers/components/DictionarySortSettings.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesCard.js +3 -2
- package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityCard.js +2 -1
- package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimeline.js +3 -2
- package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskDialog.js +3 -2
- package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskForm.js +170 -69
- package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
- package/dist/modules/customers/components/detail/TasksSection.js +27 -4
- package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/usePersonTasks.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +2 -2
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/validators.js +6 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/dictionaries.js +2 -1
- package/dist/modules/customers/lib/dictionaries.js.map +2 -2
- package/dist/modules/customers/lib/interactionProjection.js +2 -1
- package/dist/modules/customers/lib/interactionProjection.js.map +2 -2
- package/dist/modules/customers/lib/interactionStatus.js +24 -0
- package/dist/modules/customers/lib/interactionStatus.js.map +7 -0
- package/dist/modules/customers/lib/link-channel-message-handler.js +4 -5
- package/dist/modules/customers/lib/link-channel-message-handler.js.map +2 -2
- package/dist/modules/dictionaries/components/AppearanceSelector.js +7 -3
- package/dist/modules/dictionaries/components/AppearanceSelector.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +1 -0
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/configs/lib/upgrade-actions.ts +31 -1
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +12 -3
- package/src/modules/customers/api/companies/[id]/route.ts +3 -2
- package/src/modules/customers/api/dictionaries/context.ts +2 -0
- package/src/modules/customers/api/interactions/conflicts/route.ts +2 -1
- package/src/modules/customers/api/interactions/counts/route.ts +7 -2
- package/src/modules/customers/api/people/[id]/route.ts +3 -2
- package/src/modules/customers/cli.ts +19 -0
- package/src/modules/customers/commands/interactions.ts +8 -3
- package/src/modules/customers/commands/shared.ts +1 -0
- package/src/modules/customers/components/DictionarySettings.tsx +5 -0
- package/src/modules/customers/components/DictionarySortSettings.tsx +5 -0
- package/src/modules/customers/components/detail/ActivitiesCard.tsx +3 -2
- package/src/modules/customers/components/detail/ActivityCard.tsx +2 -1
- package/src/modules/customers/components/detail/ActivityTimeline.tsx +3 -2
- package/src/modules/customers/components/detail/TaskDialog.tsx +4 -1
- package/src/modules/customers/components/detail/TaskForm.tsx +218 -71
- package/src/modules/customers/components/detail/TasksSection.tsx +28 -2
- package/src/modules/customers/components/detail/hooks/usePersonTasks.ts +1 -0
- package/src/modules/customers/data/enrichers.ts +2 -7
- package/src/modules/customers/data/validators.ts +16 -2
- package/src/modules/customers/i18n/de.json +10 -0
- package/src/modules/customers/i18n/en.json +10 -0
- package/src/modules/customers/i18n/es.json +10 -0
- package/src/modules/customers/i18n/pl.json +10 -0
- package/src/modules/customers/lib/dictionaries.ts +1 -0
- package/src/modules/customers/lib/interactionProjection.ts +6 -2
- package/src/modules/customers/lib/interactionStatus.ts +30 -0
- package/src/modules/customers/lib/link-channel-message-handler.ts +4 -5
- package/src/modules/dictionaries/components/AppearanceSelector.tsx +10 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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={
|
|
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 (
|
|
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
|
)
|
|
@@ -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', [...
|
|
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.
|
|
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.
|
|
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",
|