@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.
Files changed (106) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +21 -1
  3. package/dist/modules/api_keys/api/keys/route.js +9 -0
  4. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  5. package/dist/modules/audit_logs/services/accessLogService.js +13 -0
  6. package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
  7. package/dist/modules/audit_logs/services/actionLogService.js +6 -5
  8. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  9. package/dist/modules/auth/api/roles/acl/route.js +27 -37
  10. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  11. package/dist/modules/auth/api/users/route.js +41 -28
  12. package/dist/modules/auth/api/users/route.js.map +3 -3
  13. package/dist/modules/auth/lib/grantChecks.js +160 -0
  14. package/dist/modules/auth/lib/grantChecks.js.map +7 -0
  15. package/dist/modules/configs/cli.js +11 -0
  16. package/dist/modules/configs/cli.js.map +2 -2
  17. package/dist/modules/configs/lib/touchGeneratedBarrels.js +46 -0
  18. package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +7 -0
  19. package/dist/modules/customers/api/activities/route.js +1 -52
  20. package/dist/modules/customers/api/activities/route.js.map +2 -2
  21. package/dist/modules/customers/api/interactions/counts/route.js +2 -1
  22. package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
  23. package/dist/modules/customers/api/interactions/route.js +21 -1
  24. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  25. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
  26. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  27. package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
  28. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  29. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
  30. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  31. package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
  32. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
  33. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
  34. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
  35. package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
  36. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  37. package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
  38. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  39. package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
  40. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  41. package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
  42. package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
  43. package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
  44. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  45. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
  46. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
  47. package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
  48. package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
  49. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
  50. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  51. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
  52. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  53. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
  54. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +74 -2
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
  58. package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
  59. package/dist/modules/integrations/data/validators.js +2 -2
  60. package/dist/modules/integrations/data/validators.js.map +2 -2
  61. package/dist/modules/integrations/lib/credentials-service.js +12 -1
  62. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  63. package/dist/modules/messages/commands/actions.js +29 -14
  64. package/dist/modules/messages/commands/actions.js.map +2 -2
  65. package/dist/modules/messages/lib/actions.js +24 -4
  66. package/dist/modules/messages/lib/actions.js.map +2 -2
  67. package/dist/modules/sales/api/documents/factory.js +49 -36
  68. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  69. package/package.json +9 -10
  70. package/src/modules/api_keys/api/keys/route.ts +9 -0
  71. package/src/modules/audit_logs/services/accessLogService.ts +20 -0
  72. package/src/modules/audit_logs/services/actionLogService.ts +13 -5
  73. package/src/modules/auth/api/roles/acl/route.ts +32 -46
  74. package/src/modules/auth/api/users/route.ts +48 -33
  75. package/src/modules/auth/lib/grantChecks.ts +234 -0
  76. package/src/modules/configs/cli.ts +11 -0
  77. package/src/modules/configs/lib/touchGeneratedBarrels.ts +61 -0
  78. package/src/modules/customers/api/activities/route.ts +1 -76
  79. package/src/modules/customers/api/interactions/counts/route.ts +2 -1
  80. package/src/modules/customers/api/interactions/route.ts +28 -1
  81. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
  82. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
  83. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
  84. package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
  85. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
  86. package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
  87. package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
  88. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
  89. package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
  90. package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
  91. package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
  92. package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
  93. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
  94. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
  95. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
  96. package/src/modules/customers/data/validators.ts +85 -2
  97. package/src/modules/customers/i18n/de.json +11 -0
  98. package/src/modules/customers/i18n/en.json +11 -0
  99. package/src/modules/customers/i18n/es.json +11 -0
  100. package/src/modules/customers/i18n/pl.json +11 -0
  101. package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
  102. package/src/modules/integrations/data/validators.ts +8 -6
  103. package/src/modules/integrations/lib/credentials-service.ts +15 -1
  104. package/src/modules/messages/commands/actions.ts +28 -13
  105. package/src/modules/messages/lib/actions.ts +34 -3
  106. 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
- <IconButton
115
- type="button"
116
- variant="ghost"
117
- size="sm"
118
- aria-label={t('customers.timeline.more', 'More')}
119
- onClick={(event) => {
120
- event.stopPropagation()
121
- onOpen?.(activity)
122
- }}
123
- >
124
- <MoreHorizontal className="size-4" />
125
- </IconButton>
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)}&status=done`,
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
- setActivities(sortActivities(combined, sortMode))
255
- setHasMore(firstPageHasMore)
294
+ if (!isStale()) {
295
+ setActivities(sortActivities(combined, sortMode))
296
+ setHasMore(firstPageHasMore)
297
+ }
256
298
  } catch (loadError) {
257
- setActivities([])
258
- setHasMore(false)
259
- setError(t('customers.activityLog.error', 'Failed to load activity history'))
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
- void loadHistory()
267
- }, [loadHistory, refreshKey])
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
- <select
391
+ <Select
344
392
  value={dateRange}
345
- onChange={(event) => {
346
- setDateRange(event.target.value as '7d' | '30d' | '90d')
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
- <option value="7d">{t('customers.changelog.last7days', 'Last 7 days')}</option>
351
- <option value="30d">{t('customers.changelog.last30days', 'Last 30 days')}</option>
352
- <option value="90d">{t('customers.changelog.last90days', 'Last 90 days')}</option>
353
- </select>
354
-
355
- <select
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
- onChange={(event) => {
358
- setSortMode(event.target.value as 'recent' | 'title-asc' | 'title-desc')
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
- <option value="recent">{t('customers.activityLog.sort.recent', 'Sort: newest')}</option>
363
- <option value="title-asc">{t('customers.activityLog.sort.titleAsc', 'Sort: Name A-Z')}</option>
364
- <option value="title-desc">{t('customers.activityLog.sort.titleDesc', 'Sort: Name Z-A')}</option>
365
- </select>
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 activity={activity} onOpen={onEditActivity} />
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
- /** @deprecated No longer used after the ActivitiesCard refactor. Kept optional for callers; remove after one minor cycle. */
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
- <span className="block text-[12px] font-semibold leading-tight text-foreground">
108
- {title}{duration}
109
- </span>
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 nextCounts = await readApiResultOrThrow<InteractionCounts>(
68
+ const payload = await readApiResultOrThrow<InteractionCountsResponse>(
62
69
  `/api/customers/interactions/counts?entityId=${encodeURIComponent(entityId)}`,
63
70
  { signal: controller.signal },
64
71
  )
65
- setCounts(nextCounts)
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
- <button
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
- </button>
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
- <button
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
- </button>
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 { Phone, Mail, Users, StickyNote } from 'lucide-react'
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-4 gap-2">
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 (