@open-mercato/core 0.5.1-develop.3045.b4b3320cc2 → 0.6.0
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/AGENTS.md +21 -1
- package/dist/modules/api_keys/api/keys/route.js +9 -0
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/audit_logs/services/accessLogService.js +13 -0
- package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
- package/dist/modules/audit_logs/services/actionLogService.js +6 -5
- package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +27 -37
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +41 -28
- package/dist/modules/auth/api/users/route.js.map +3 -3
- package/dist/modules/auth/lib/grantChecks.js +160 -0
- package/dist/modules/auth/lib/grantChecks.js.map +7 -0
- package/dist/modules/configs/cli.js +11 -0
- package/dist/modules/configs/cli.js.map +2 -2
- package/dist/modules/configs/lib/touchGeneratedBarrels.js +46 -0
- package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +7 -0
- package/dist/modules/customers/api/activities/route.js +1 -52
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/counts/route.js +2 -1
- package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +21 -1
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
- package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
- package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
- package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
- package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
- package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/data/validators.js +74 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
- package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
- package/dist/modules/integrations/data/validators.js +2 -2
- package/dist/modules/integrations/data/validators.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +12 -1
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/messages/commands/actions.js +29 -14
- package/dist/modules/messages/commands/actions.js.map +2 -2
- package/dist/modules/messages/lib/actions.js +24 -4
- package/dist/modules/messages/lib/actions.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +49 -36
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/package.json +9 -10
- package/src/modules/api_keys/api/keys/route.ts +9 -0
- package/src/modules/audit_logs/services/accessLogService.ts +20 -0
- package/src/modules/audit_logs/services/actionLogService.ts +13 -5
- package/src/modules/auth/api/roles/acl/route.ts +32 -46
- package/src/modules/auth/api/users/route.ts +48 -33
- package/src/modules/auth/lib/grantChecks.ts +234 -0
- package/src/modules/configs/cli.ts +11 -0
- package/src/modules/configs/lib/touchGeneratedBarrels.ts +61 -0
- package/src/modules/customers/api/activities/route.ts +1 -76
- package/src/modules/customers/api/interactions/counts/route.ts +2 -1
- package/src/modules/customers/api/interactions/route.ts +28 -1
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
- package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
- package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
- package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
- package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
- package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
- package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
- package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
- package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
- package/src/modules/customers/data/validators.ts +85 -2
- package/src/modules/customers/i18n/de.json +11 -0
- package/src/modules/customers/i18n/en.json +11 -0
- package/src/modules/customers/i18n/es.json +11 -0
- package/src/modules/customers/i18n/pl.json +11 -0
- package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
- package/src/modules/integrations/data/validators.ts +8 -6
- package/src/modules/integrations/lib/credentials-service.ts +15 -1
- package/src/modules/messages/commands/actions.ts +28 -13
- package/src/modules/messages/lib/actions.ts +34 -3
- package/src/modules/sales/api/documents/factory.ts +55 -38
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Calendar, ExternalLink, Mail, MoreHorizontal, Phone, StickyNote, Users } from 'lucide-react'
|
|
4
|
+
import { Calendar, Check, ExternalLink, ListTodo, Mail, MoreHorizontal, Phone, StickyNote, Users } from 'lucide-react'
|
|
5
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
5
6
|
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
7
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
8
|
+
import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
6
9
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
10
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
11
|
import type { InteractionSummary } from './types'
|
|
9
12
|
import { ActivityAiActions } from './ActivityAiActions'
|
|
10
13
|
import { getInitials } from './utils'
|
|
11
14
|
|
|
15
|
+
type GuardedMutationRunner = <T,>(
|
|
16
|
+
operation: () => Promise<T>,
|
|
17
|
+
mutationPayload?: Record<string, unknown>,
|
|
18
|
+
) => Promise<T>
|
|
19
|
+
|
|
12
20
|
type ActivityCardProps = {
|
|
13
21
|
activity: InteractionSummary
|
|
14
22
|
onOpen?: (activity: InteractionSummary) => void
|
|
23
|
+
/** Called after a successful mark-done so the parent can refresh the timeline. */
|
|
24
|
+
onChanged?: () => void
|
|
25
|
+
/**
|
|
26
|
+
* Optional guarded-mutation runner. When provided, mutations route through the parent's
|
|
27
|
+
* `useGuardedMutation` so retry-last-mutation and the global injection contract apply.
|
|
28
|
+
* When omitted, mutations run directly via `apiCallOrThrow` (e.g. read-only contexts
|
|
29
|
+
* or jest unit tests that don't supply a guarded runner).
|
|
30
|
+
*/
|
|
31
|
+
runMutation?: GuardedMutationRunner
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
@@ -19,6 +36,7 @@ const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
|
|
19
36
|
email: Mail,
|
|
20
37
|
meeting: Users,
|
|
21
38
|
note: StickyNote,
|
|
39
|
+
task: ListTodo,
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
function formatDayLabel(value: string, t: ReturnType<typeof useT>): string {
|
|
@@ -54,7 +72,7 @@ function resolveTarget(activity: InteractionSummary): string | null {
|
|
|
54
72
|
return null
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
export function ActivityCard({ activity, onOpen }: ActivityCardProps) {
|
|
75
|
+
export function ActivityCard({ activity, onOpen, onChanged, runMutation }: ActivityCardProps) {
|
|
58
76
|
const t = useT()
|
|
59
77
|
const timestamp = activity.occurredAt ?? activity.scheduledAt ?? activity.createdAt
|
|
60
78
|
const TypeIcon = TYPE_ICONS[activity.interactionType] ?? StickyNote
|
|
@@ -69,6 +87,36 @@ export function ActivityCard({ activity, onOpen }: ActivityCardProps) {
|
|
|
69
87
|
? t('customers.activityLog.direction.with', 'with')
|
|
70
88
|
: ''
|
|
71
89
|
const showExternalLink = Boolean(activity._integrations && Object.keys(activity._integrations).length > 0)
|
|
90
|
+
const [markingDone, setMarkingDone] = React.useState(false)
|
|
91
|
+
|
|
92
|
+
const handleMarkDone = React.useCallback(async () => {
|
|
93
|
+
if (markingDone) return
|
|
94
|
+
setMarkingDone(true)
|
|
95
|
+
try {
|
|
96
|
+
const operation = () =>
|
|
97
|
+
apiCallOrThrow('/api/customers/interactions/complete', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ id: activity.id, occurredAt: new Date().toISOString() }),
|
|
101
|
+
})
|
|
102
|
+
if (runMutation) {
|
|
103
|
+
await runMutation(operation, {
|
|
104
|
+
id: activity.id,
|
|
105
|
+
status: 'done',
|
|
106
|
+
operation: 'completeActivity',
|
|
107
|
+
})
|
|
108
|
+
} else {
|
|
109
|
+
await operation()
|
|
110
|
+
}
|
|
111
|
+
flash(t('customers.activities.actions.markDoneSuccess', 'Activity marked done'), 'success')
|
|
112
|
+
onChanged?.()
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn('[customers.activityCard] mark done failed', activity.id, err)
|
|
115
|
+
flash(t('customers.activities.actions.markDoneError', 'Could not mark activity as done'), 'error')
|
|
116
|
+
} finally {
|
|
117
|
+
setMarkingDone(false)
|
|
118
|
+
}
|
|
119
|
+
}, [activity.id, markingDone, onChanged, runMutation, t])
|
|
72
120
|
|
|
73
121
|
return (
|
|
74
122
|
<div
|
|
@@ -111,18 +159,35 @@ export function ActivityCard({ activity, onOpen }: ActivityCardProps) {
|
|
|
111
159
|
) : null}
|
|
112
160
|
</div>
|
|
113
161
|
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
162
|
+
<div className="flex items-center gap-1.5">
|
|
163
|
+
{activity.status === 'planned' ? (
|
|
164
|
+
<Button
|
|
165
|
+
type="button"
|
|
166
|
+
variant="default"
|
|
167
|
+
size="sm"
|
|
168
|
+
disabled={markingDone}
|
|
169
|
+
onClick={(event) => {
|
|
170
|
+
event.stopPropagation()
|
|
171
|
+
void handleMarkDone()
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<Check className="size-3.5" />
|
|
175
|
+
{t('customers.activities.actions.markDone', 'Mark done')}
|
|
176
|
+
</Button>
|
|
177
|
+
) : null}
|
|
178
|
+
<IconButton
|
|
179
|
+
type="button"
|
|
180
|
+
variant="ghost"
|
|
181
|
+
size="sm"
|
|
182
|
+
aria-label={t('customers.timeline.more', 'More')}
|
|
183
|
+
onClick={(event) => {
|
|
184
|
+
event.stopPropagation()
|
|
185
|
+
onOpen?.(activity)
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<MoreHorizontal className="size-4" />
|
|
189
|
+
</IconButton>
|
|
190
|
+
</div>
|
|
126
191
|
</div>
|
|
127
192
|
|
|
128
193
|
<div className="mt-2">
|
|
@@ -5,16 +5,31 @@ import { Clock3, Search } from 'lucide-react'
|
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
7
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from '@open-mercato/ui/primitives/select'
|
|
8
15
|
import { ErrorMessage, LoadingMessage, TabEmptyState } from '@open-mercato/ui/backend/detail'
|
|
9
16
|
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
10
17
|
import type { ActivitySummary, InteractionSummary } from './types'
|
|
11
18
|
import { ActivityCard } from './ActivityCard'
|
|
12
19
|
|
|
20
|
+
type GuardedMutationRunner = <T,>(
|
|
21
|
+
operation: () => Promise<T>,
|
|
22
|
+
mutationPayload?: Record<string, unknown>,
|
|
23
|
+
) => Promise<T>
|
|
24
|
+
|
|
13
25
|
type ActivityHistorySectionProps = {
|
|
14
26
|
entityId: string
|
|
15
27
|
useCanonicalInteractions?: boolean
|
|
16
28
|
refreshKey?: number
|
|
17
29
|
onEditActivity?: (activity: InteractionSummary) => void
|
|
30
|
+
/** Optional guarded-mutation runner so per-row mutations route through the parent's
|
|
31
|
+
* `useGuardedMutation` and emit retry-last-mutation context. */
|
|
32
|
+
runMutation?: GuardedMutationRunner
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
type InteractionListResponse = {
|
|
@@ -29,12 +44,14 @@ type InteractionCountsResponse = {
|
|
|
29
44
|
email: number
|
|
30
45
|
meeting: number
|
|
31
46
|
note: number
|
|
47
|
+
task: number
|
|
32
48
|
total: number
|
|
33
49
|
}
|
|
34
50
|
call?: number
|
|
35
51
|
email?: number
|
|
36
52
|
meeting?: number
|
|
37
53
|
note?: number
|
|
54
|
+
task?: number
|
|
38
55
|
total?: number
|
|
39
56
|
}
|
|
40
57
|
|
|
@@ -43,6 +60,7 @@ const TYPE_FILTERS = [
|
|
|
43
60
|
{ value: 'email', labelKey: 'customers.timeline.filter.email', fallback: 'Email' },
|
|
44
61
|
{ value: 'meeting', labelKey: 'customers.timeline.filter.meeting', fallback: 'Meeting' },
|
|
45
62
|
{ value: 'note', labelKey: 'customers.timeline.filter.note', fallback: 'Note' },
|
|
63
|
+
{ value: 'task', labelKey: 'customers.timeline.filter.task', fallback: 'Task' },
|
|
46
64
|
] as const
|
|
47
65
|
|
|
48
66
|
function computeRangeStart(range: '7d' | '30d' | '90d'): Date {
|
|
@@ -137,11 +155,21 @@ function isWithinRange(activity: InteractionSummary, start: Date): boolean {
|
|
|
137
155
|
return timestamp >= start
|
|
138
156
|
}
|
|
139
157
|
|
|
158
|
+
function isAbortError(error: unknown): boolean {
|
|
159
|
+
return (
|
|
160
|
+
typeof error === 'object' &&
|
|
161
|
+
error !== null &&
|
|
162
|
+
'name' in error &&
|
|
163
|
+
(error as { name?: unknown }).name === 'AbortError'
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
export function ActivityHistorySection({
|
|
141
168
|
entityId,
|
|
142
169
|
useCanonicalInteractions = false,
|
|
143
170
|
refreshKey = 0,
|
|
144
171
|
onEditActivity,
|
|
172
|
+
runMutation,
|
|
145
173
|
}: ActivityHistorySectionProps) {
|
|
146
174
|
const t = useT()
|
|
147
175
|
const [searchInput, setSearchInput] = React.useState('')
|
|
@@ -152,9 +180,14 @@ export function ActivityHistorySection({
|
|
|
152
180
|
const [activities, setActivities] = React.useState<InteractionSummary[]>([])
|
|
153
181
|
const [loading, setLoading] = React.useState(true)
|
|
154
182
|
const [error, setError] = React.useState<string | null>(null)
|
|
155
|
-
const [counts, setCounts] = React.useState<Record<string, number>>({ call: 0, email: 0, meeting: 0, note: 0, total: 0 })
|
|
183
|
+
const [counts, setCounts] = React.useState<Record<string, number>>({ call: 0, email: 0, meeting: 0, note: 0, task: 0, total: 0 })
|
|
156
184
|
const [hasMore, setHasMore] = React.useState(false)
|
|
157
185
|
const [loadedPages, setLoadedPages] = React.useState(1)
|
|
186
|
+
const [localRefreshKey, setLocalRefreshKey] = React.useState(0)
|
|
187
|
+
const historyRequestSeqRef = React.useRef(0)
|
|
188
|
+
const handleActivityChanged = React.useCallback(() => {
|
|
189
|
+
setLocalRefreshKey((current) => current + 1)
|
|
190
|
+
}, [])
|
|
158
191
|
|
|
159
192
|
React.useEffect(() => {
|
|
160
193
|
const timeout = window.setTimeout(() => setSearch(searchInput.trim()), 300)
|
|
@@ -166,7 +199,7 @@ export function ActivityHistorySection({
|
|
|
166
199
|
void (async () => {
|
|
167
200
|
try {
|
|
168
201
|
const payload = await readApiResultOrThrow<InteractionCountsResponse>(
|
|
169
|
-
`/api/customers/interactions/counts?entityId=${encodeURIComponent(entityId)}
|
|
202
|
+
`/api/customers/interactions/counts?entityId=${encodeURIComponent(entityId)}`,
|
|
170
203
|
{ signal: controller.signal },
|
|
171
204
|
)
|
|
172
205
|
const result = payload.result ?? payload
|
|
@@ -175,16 +208,19 @@ export function ActivityHistorySection({
|
|
|
175
208
|
email: result.email ?? 0,
|
|
176
209
|
meeting: result.meeting ?? 0,
|
|
177
210
|
note: result.note ?? 0,
|
|
211
|
+
task: result.task ?? 0,
|
|
178
212
|
total: result.total ?? 0,
|
|
179
213
|
})
|
|
180
214
|
} catch {
|
|
181
|
-
setCounts({ call: 0, email: 0, meeting: 0, note: 0, total: 0 })
|
|
215
|
+
setCounts({ call: 0, email: 0, meeting: 0, note: 0, task: 0, total: 0 })
|
|
182
216
|
}
|
|
183
217
|
})()
|
|
184
218
|
return () => controller.abort()
|
|
185
|
-
}, [entityId, refreshKey])
|
|
219
|
+
}, [entityId, refreshKey, localRefreshKey])
|
|
186
220
|
|
|
187
|
-
const loadHistory = React.useCallback(async () => {
|
|
221
|
+
const loadHistory = React.useCallback(async (options: { signal: AbortSignal; requestSeq: number }) => {
|
|
222
|
+
const { signal, requestSeq } = options
|
|
223
|
+
const isStale = () => signal.aborted || requestSeq !== historyRequestSeqRef.current
|
|
188
224
|
setLoading(true)
|
|
189
225
|
setError(null)
|
|
190
226
|
try {
|
|
@@ -195,14 +231,14 @@ export function ActivityHistorySection({
|
|
|
195
231
|
let firstPageHasMore = false
|
|
196
232
|
let pagesLoaded = 0
|
|
197
233
|
|
|
234
|
+
const taskFilterActive = activeTypes.includes('task')
|
|
198
235
|
do {
|
|
199
236
|
const params = new URLSearchParams({
|
|
200
237
|
entityId,
|
|
201
|
-
status: 'done',
|
|
202
|
-
excludeInteractionType: 'task',
|
|
203
238
|
limit: String(pageSize),
|
|
204
239
|
from: rangeStart,
|
|
205
240
|
})
|
|
241
|
+
if (!taskFilterActive) params.set('excludeInteractionType', 'task')
|
|
206
242
|
if (activeTypes.length > 0) params.set('type', activeTypes.join(','))
|
|
207
243
|
if (search) params.set('search', search)
|
|
208
244
|
if (sortMode === 'recent') {
|
|
@@ -216,7 +252,9 @@ export function ActivityHistorySection({
|
|
|
216
252
|
|
|
217
253
|
const response = await readApiResultOrThrow<InteractionListResponse>(
|
|
218
254
|
`/api/customers/interactions?${params.toString()}`,
|
|
255
|
+
{ signal },
|
|
219
256
|
)
|
|
257
|
+
if (isStale()) return
|
|
220
258
|
const pageItems = Array.isArray(response.items) ? response.items : []
|
|
221
259
|
canonicalItems.push(...pageItems)
|
|
222
260
|
nextCursor = response.nextCursor
|
|
@@ -232,7 +270,9 @@ export function ActivityHistorySection({
|
|
|
232
270
|
for (let legacyPage = 1; legacyPage <= loadedPages; legacyPage += 1) {
|
|
233
271
|
const legacyPayload = await readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(
|
|
234
272
|
`/api/customers/activities?entityId=${encodeURIComponent(entityId)}&page=${legacyPage}&pageSize=20&sortField=occurredAt&sortDir=desc`,
|
|
273
|
+
{ signal },
|
|
235
274
|
).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 }))
|
|
275
|
+
if (isStale()) return
|
|
236
276
|
legacyItems.push(...(Array.isArray(legacyPayload.items) ? legacyPayload.items.map(normalizeLegacyActivity) : []))
|
|
237
277
|
legacyTotalPages = typeof legacyPayload.totalPages === 'number' ? legacyPayload.totalPages : legacyTotalPages
|
|
238
278
|
if (legacyPage >= legacyTotalPages) break
|
|
@@ -251,20 +291,28 @@ export function ActivityHistorySection({
|
|
|
251
291
|
firstPageHasMore = firstPageHasMore || legacyTotalPages > loadedPages
|
|
252
292
|
}
|
|
253
293
|
|
|
254
|
-
|
|
255
|
-
|
|
294
|
+
if (!isStale()) {
|
|
295
|
+
setActivities(sortActivities(combined, sortMode))
|
|
296
|
+
setHasMore(firstPageHasMore)
|
|
297
|
+
}
|
|
256
298
|
} catch (loadError) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
299
|
+
if (!isStale() && !isAbortError(loadError)) {
|
|
300
|
+
setActivities([])
|
|
301
|
+
setHasMore(false)
|
|
302
|
+
setError(t('customers.activityLog.error', 'Failed to load activity history'))
|
|
303
|
+
}
|
|
260
304
|
} finally {
|
|
261
|
-
setLoading(false)
|
|
305
|
+
if (!isStale()) setLoading(false)
|
|
262
306
|
}
|
|
263
307
|
}, [activeTypes, dateRange, entityId, loadedPages, search, sortMode, t, useCanonicalInteractions])
|
|
264
308
|
|
|
265
309
|
React.useEffect(() => {
|
|
266
|
-
|
|
267
|
-
|
|
310
|
+
const controller = new AbortController()
|
|
311
|
+
const requestSeq = historyRequestSeqRef.current + 1
|
|
312
|
+
historyRequestSeqRef.current = requestSeq
|
|
313
|
+
void loadHistory({ signal: controller.signal, requestSeq })
|
|
314
|
+
return () => controller.abort()
|
|
315
|
+
}, [loadHistory, refreshKey, localRefreshKey])
|
|
268
316
|
|
|
269
317
|
React.useEffect(() => {
|
|
270
318
|
setLoadedPages(1)
|
|
@@ -340,29 +388,45 @@ export function ActivityHistorySection({
|
|
|
340
388
|
)
|
|
341
389
|
})}
|
|
342
390
|
|
|
343
|
-
<
|
|
391
|
+
<Select
|
|
344
392
|
value={dateRange}
|
|
345
|
-
|
|
346
|
-
setDateRange(
|
|
393
|
+
onValueChange={(value) => {
|
|
394
|
+
setDateRange(value as '7d' | '30d' | '90d')
|
|
347
395
|
}}
|
|
348
|
-
className="h-8 rounded-lg border bg-background px-3 text-xs outline-none ring-offset-background focus:ring-2 focus:ring-ring"
|
|
349
396
|
>
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
397
|
+
<SelectTrigger
|
|
398
|
+
size="sm"
|
|
399
|
+
aria-label={t('customers.activityLog.filters.dateRangeLabel', 'Date range')}
|
|
400
|
+
className="w-auto"
|
|
401
|
+
>
|
|
402
|
+
<SelectValue />
|
|
403
|
+
</SelectTrigger>
|
|
404
|
+
<SelectContent>
|
|
405
|
+
<SelectItem value="7d">{t('customers.changelog.last7days', 'Last 7 days')}</SelectItem>
|
|
406
|
+
<SelectItem value="30d">{t('customers.changelog.last30days', 'Last 30 days')}</SelectItem>
|
|
407
|
+
<SelectItem value="90d">{t('customers.changelog.last90days', 'Last 90 days')}</SelectItem>
|
|
408
|
+
</SelectContent>
|
|
409
|
+
</Select>
|
|
410
|
+
|
|
411
|
+
<Select
|
|
356
412
|
value={sortMode}
|
|
357
|
-
|
|
358
|
-
setSortMode(
|
|
413
|
+
onValueChange={(value) => {
|
|
414
|
+
setSortMode(value as 'recent' | 'title-asc' | 'title-desc')
|
|
359
415
|
}}
|
|
360
|
-
className="h-8 rounded-lg border bg-background px-3 text-xs outline-none ring-offset-background focus:ring-2 focus:ring-ring"
|
|
361
416
|
>
|
|
362
|
-
<
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
417
|
+
<SelectTrigger
|
|
418
|
+
size="sm"
|
|
419
|
+
aria-label={t('customers.activityLog.filters.sortLabel', 'Sort order')}
|
|
420
|
+
className="w-auto"
|
|
421
|
+
>
|
|
422
|
+
<SelectValue />
|
|
423
|
+
</SelectTrigger>
|
|
424
|
+
<SelectContent>
|
|
425
|
+
<SelectItem value="recent">{t('customers.activityLog.sort.recent', 'Sort: newest')}</SelectItem>
|
|
426
|
+
<SelectItem value="title-asc">{t('customers.activityLog.sort.titleAsc', 'Sort: Name A-Z')}</SelectItem>
|
|
427
|
+
<SelectItem value="title-desc">{t('customers.activityLog.sort.titleDesc', 'Sort: Name Z-A')}</SelectItem>
|
|
428
|
+
</SelectContent>
|
|
429
|
+
</Select>
|
|
366
430
|
</div>
|
|
367
431
|
</div>
|
|
368
432
|
|
|
@@ -390,7 +454,12 @@ export function ActivityHistorySection({
|
|
|
390
454
|
<div className="h-px flex-1 bg-border" />
|
|
391
455
|
</div>
|
|
392
456
|
) : null}
|
|
393
|
-
<ActivityCard
|
|
457
|
+
<ActivityCard
|
|
458
|
+
activity={activity}
|
|
459
|
+
onOpen={onEditActivity}
|
|
460
|
+
onChanged={handleActivityChanged}
|
|
461
|
+
runMutation={runMutation}
|
|
462
|
+
/>
|
|
394
463
|
</React.Fragment>
|
|
395
464
|
)
|
|
396
465
|
})}
|
|
@@ -22,7 +22,11 @@ type ActivityLogTabProps = {
|
|
|
22
22
|
onEditActivity: (activity: InteractionSummary) => void
|
|
23
23
|
/** @deprecated No longer used after the ActivitiesCard refactor. Kept optional for callers; remove after one minor cycle. */
|
|
24
24
|
onCancelActivity?: (id: string) => void
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Guarded-mutation runner from the parent page. When provided, per-row mutations
|
|
27
|
+
* (e.g. ActivityCard "Mark done") route through `useGuardedMutation` so the global
|
|
28
|
+
* injection contract and retry-last-mutation context apply.
|
|
29
|
+
*/
|
|
26
30
|
runGuardedMutation?: GuardedMutationRunner
|
|
27
31
|
refreshKey?: number
|
|
28
32
|
useCanonicalInteractions?: boolean
|
|
@@ -36,6 +40,7 @@ export function ActivityLogTab({
|
|
|
36
40
|
onScheduleRequested,
|
|
37
41
|
onAddActivity,
|
|
38
42
|
onEditActivity,
|
|
43
|
+
runGuardedMutation,
|
|
39
44
|
refreshKey = 0,
|
|
40
45
|
useCanonicalInteractions = false,
|
|
41
46
|
entityCompanyName,
|
|
@@ -61,6 +66,7 @@ export function ActivityLogTab({
|
|
|
61
66
|
useCanonicalInteractions={useCanonicalInteractions}
|
|
62
67
|
refreshKey={refreshKey}
|
|
63
68
|
onEditActivity={onEditActivity}
|
|
69
|
+
runMutation={runGuardedMutation}
|
|
64
70
|
/>
|
|
65
71
|
</div>
|
|
66
72
|
)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import { Phone, Mail, Users, StickyNote, User } from 'lucide-react'
|
|
3
|
+
import { Check, ListTodo, Phone, Mail, Users, StickyNote, User } from 'lucide-react'
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
5
|
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
6
7
|
import { AiActionChips } from './AiActionChips'
|
|
7
8
|
import type { InteractionSummary } from './types'
|
|
8
9
|
|
|
@@ -11,14 +12,16 @@ const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
|
|
11
12
|
email: Mail,
|
|
12
13
|
meeting: Users,
|
|
13
14
|
note: StickyNote,
|
|
15
|
+
task: ListTodo,
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface ActivityTimelineProps {
|
|
17
19
|
activities: InteractionSummary[]
|
|
18
20
|
onEdit?: (activity: InteractionSummary) => void
|
|
21
|
+
onMarkDone?: (activityId: string) => void | Promise<void>
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
export function ActivityTimeline({ activities, onEdit }: ActivityTimelineProps) {
|
|
24
|
+
export function ActivityTimeline({ activities, onEdit, onMarkDone }: ActivityTimelineProps) {
|
|
22
25
|
const t = useT()
|
|
23
26
|
|
|
24
27
|
if (activities.length === 0) {
|
|
@@ -54,6 +57,7 @@ export function ActivityTimeline({ activities, onEdit }: ActivityTimelineProps)
|
|
|
54
57
|
t={t}
|
|
55
58
|
withBorder={index < activities.length - 1}
|
|
56
59
|
onEdit={onEdit}
|
|
60
|
+
onMarkDone={onMarkDone}
|
|
57
61
|
/>
|
|
58
62
|
</React.Fragment>
|
|
59
63
|
)
|
|
@@ -67,16 +71,31 @@ function TimelineEntry({
|
|
|
67
71
|
t,
|
|
68
72
|
withBorder,
|
|
69
73
|
onEdit,
|
|
74
|
+
onMarkDone,
|
|
70
75
|
}: {
|
|
71
76
|
activity: InteractionSummary
|
|
72
77
|
t: TranslateFn
|
|
73
78
|
withBorder: boolean
|
|
74
79
|
onEdit?: (activity: InteractionSummary) => void
|
|
80
|
+
onMarkDone?: (activityId: string) => void | Promise<void>
|
|
75
81
|
}) {
|
|
76
82
|
const dateStr = activity.scheduledAt ?? activity.occurredAt ?? activity.createdAt
|
|
77
83
|
const TypeIcon = TYPE_ICONS[activity.interactionType]
|
|
78
84
|
const title = activity.title ?? activity.body ?? activity.interactionType
|
|
79
85
|
const duration = activity.duration ? ` (${activity.duration} min)` : ''
|
|
86
|
+
const isPlanned = activity.status === 'planned'
|
|
87
|
+
const [markingDone, setMarkingDone] = React.useState(false)
|
|
88
|
+
|
|
89
|
+
const handleMarkDone = React.useCallback(async (event: React.MouseEvent | React.KeyboardEvent) => {
|
|
90
|
+
event.stopPropagation()
|
|
91
|
+
if (!onMarkDone || markingDone) return
|
|
92
|
+
setMarkingDone(true)
|
|
93
|
+
try {
|
|
94
|
+
await onMarkDone(activity.id)
|
|
95
|
+
} finally {
|
|
96
|
+
setMarkingDone(false)
|
|
97
|
+
}
|
|
98
|
+
}, [activity.id, markingDone, onMarkDone])
|
|
80
99
|
|
|
81
100
|
return (
|
|
82
101
|
<div
|
|
@@ -104,9 +123,24 @@ function TimelineEntry({
|
|
|
104
123
|
|
|
105
124
|
{/* Column 3: Content */}
|
|
106
125
|
<div className="min-w-0 space-y-1.5">
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
<div className="flex items-start justify-between gap-2">
|
|
127
|
+
<span className="block text-[12px] font-semibold leading-tight text-foreground">
|
|
128
|
+
{title}{duration}
|
|
129
|
+
</span>
|
|
130
|
+
{isPlanned && onMarkDone ? (
|
|
131
|
+
<Button
|
|
132
|
+
type="button"
|
|
133
|
+
variant="default"
|
|
134
|
+
size="sm"
|
|
135
|
+
disabled={markingDone}
|
|
136
|
+
onClick={handleMarkDone}
|
|
137
|
+
className="shrink-0"
|
|
138
|
+
>
|
|
139
|
+
<Check className="size-3.5" />
|
|
140
|
+
{t('customers.activities.actions.markDone', 'Mark done')}
|
|
141
|
+
</Button>
|
|
142
|
+
) : null}
|
|
143
|
+
</div>
|
|
110
144
|
|
|
111
145
|
{activity.body && activity.title && (
|
|
112
146
|
<p className="text-[11px] leading-snug text-muted-foreground">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import { Phone, Mail, Users, StickyNote, SlidersHorizontal } from 'lucide-react'
|
|
3
|
+
import { Phone, Mail, Users, StickyNote, ListTodo, SlidersHorizontal } from 'lucide-react'
|
|
4
4
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
@@ -13,6 +13,7 @@ const FILTER_TYPES = [
|
|
|
13
13
|
{ type: 'call', icon: Phone },
|
|
14
14
|
{ type: 'meeting', icon: Users },
|
|
15
15
|
{ type: 'email', icon: Mail },
|
|
16
|
+
{ type: 'task', icon: ListTodo },
|
|
16
17
|
] as const
|
|
17
18
|
|
|
18
19
|
type InteractionCounts = {
|
|
@@ -20,9 +21,15 @@ type InteractionCounts = {
|
|
|
20
21
|
email: number
|
|
21
22
|
meeting: number
|
|
22
23
|
note: number
|
|
24
|
+
task: number
|
|
23
25
|
total: number
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
type InteractionCountsResponse = {
|
|
29
|
+
ok?: boolean
|
|
30
|
+
result?: InteractionCounts
|
|
31
|
+
} & Partial<InteractionCounts>
|
|
32
|
+
|
|
26
33
|
interface ActivityTimelineFiltersProps {
|
|
27
34
|
entityId: string | null
|
|
28
35
|
activeTypes: string[]
|
|
@@ -58,11 +65,22 @@ export function ActivityTimelineFilters({
|
|
|
58
65
|
const controller = new AbortController()
|
|
59
66
|
void (async () => {
|
|
60
67
|
try {
|
|
61
|
-
const
|
|
68
|
+
const payload = await readApiResultOrThrow<InteractionCountsResponse>(
|
|
62
69
|
`/api/customers/interactions/counts?entityId=${encodeURIComponent(entityId)}`,
|
|
63
70
|
{ signal: controller.signal },
|
|
64
71
|
)
|
|
65
|
-
|
|
72
|
+
// Endpoint envelope is `{ ok, result: {...counts} }`. Some legacy fixtures
|
|
73
|
+
// return the counts at the top level — fall back to that shape so the chip
|
|
74
|
+
// badges keep working in either case.
|
|
75
|
+
const source = (payload.result ?? payload) as Partial<InteractionCounts>
|
|
76
|
+
setCounts({
|
|
77
|
+
call: source.call ?? 0,
|
|
78
|
+
email: source.email ?? 0,
|
|
79
|
+
meeting: source.meeting ?? 0,
|
|
80
|
+
note: source.note ?? 0,
|
|
81
|
+
task: source.task ?? 0,
|
|
82
|
+
total: source.total ?? 0,
|
|
83
|
+
})
|
|
66
84
|
} catch {
|
|
67
85
|
setCounts(null)
|
|
68
86
|
}
|
|
@@ -85,23 +103,27 @@ export function ActivityTimelineFilters({
|
|
|
85
103
|
return (
|
|
86
104
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
87
105
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
88
|
-
<
|
|
106
|
+
<Button
|
|
89
107
|
type="button"
|
|
108
|
+
variant="ghost"
|
|
109
|
+
size="sm"
|
|
90
110
|
onClick={handleSelectAll}
|
|
91
111
|
aria-pressed={allActive}
|
|
92
112
|
className={cn(CHIP_BASE, allActive ? CHIP_ACTIVE : CHIP_INACTIVE)}
|
|
93
113
|
>
|
|
94
114
|
<span>{t('customers.timeline.filter.all', 'All Activities')}</span>
|
|
95
|
-
</
|
|
115
|
+
</Button>
|
|
96
116
|
|
|
97
117
|
{FILTER_TYPES.map(({ type, icon: Icon }) => {
|
|
98
118
|
const isActive = activeTypes.includes(type)
|
|
99
119
|
const count = counts?.[type as keyof InteractionCounts]
|
|
100
120
|
const hasCount = typeof count === 'number' && count > 0
|
|
101
121
|
return (
|
|
102
|
-
<
|
|
122
|
+
<Button
|
|
103
123
|
key={type}
|
|
104
124
|
type="button"
|
|
125
|
+
variant="ghost"
|
|
126
|
+
size="sm"
|
|
105
127
|
onClick={() => handleTypeToggle(type)}
|
|
106
128
|
aria-pressed={isActive}
|
|
107
129
|
className={cn(CHIP_BASE, isActive ? CHIP_ACTIVE : CHIP_INACTIVE)}
|
|
@@ -111,7 +133,7 @@ export function ActivityTimelineFilters({
|
|
|
111
133
|
{t(`customers.timeline.filter.${type}`, type)}
|
|
112
134
|
{hasCount ? ` ${count}` : ''}
|
|
113
135
|
</span>
|
|
114
|
-
</
|
|
136
|
+
</Button>
|
|
115
137
|
)
|
|
116
138
|
})}
|
|
117
139
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import {
|
|
3
|
+
import { ListTodo, Mail, Phone, StickyNote, Users } from 'lucide-react'
|
|
4
4
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
@@ -10,6 +10,7 @@ const ACTIVITY_TYPES = [
|
|
|
10
10
|
{ type: 'email', icon: Mail, labelKey: 'customers.activityComposer.types.email', fallback: 'Email' },
|
|
11
11
|
{ type: 'meeting', icon: Users, labelKey: 'customers.activityComposer.types.meeting', fallback: 'Meeting' },
|
|
12
12
|
{ type: 'note', icon: StickyNote, labelKey: 'customers.activityComposer.types.note', fallback: 'Note' },
|
|
13
|
+
{ type: 'task', icon: ListTodo, labelKey: 'customers.activityComposer.types.task', fallback: 'Task' },
|
|
13
14
|
] as const
|
|
14
15
|
|
|
15
16
|
export type ActivityType = (typeof ACTIVITY_TYPES)[number]['type']
|
|
@@ -23,7 +24,7 @@ export function ActivityTypeSelector({ selectedType, onSelect }: ActivityTypeSel
|
|
|
23
24
|
const t = useT()
|
|
24
25
|
|
|
25
26
|
return (
|
|
26
|
-
<div className="grid grid-cols-
|
|
27
|
+
<div className="grid grid-cols-5 gap-2">
|
|
27
28
|
{ACTIVITY_TYPES.map(({ type, icon: Icon, labelKey, fallback }) => {
|
|
28
29
|
const isSelected = selectedType === type
|
|
29
30
|
return (
|