@open-mercato/core 0.6.1-develop.3081.21270ec58a → 0.6.1-develop.3090.06ab462170

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 (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/audit_logs/services/accessLogService.js +13 -0
  3. package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
  4. package/dist/modules/audit_logs/services/actionLogService.js +6 -5
  5. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  6. package/dist/modules/customers/api/activities/route.js +1 -52
  7. package/dist/modules/customers/api/activities/route.js.map +2 -2
  8. package/dist/modules/customers/api/interactions/counts/route.js +2 -1
  9. package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
  10. package/dist/modules/customers/api/interactions/route.js +21 -1
  11. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
  13. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
  15. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
  17. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  18. package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
  19. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
  20. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
  21. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
  23. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  24. package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
  25. package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
  26. package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
  27. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  28. package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
  29. package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
  30. package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
  31. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  32. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
  33. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
  34. package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
  35. package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
  36. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
  37. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  38. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
  39. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  40. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
  41. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  42. package/dist/modules/customers/data/validators.js +74 -2
  43. package/dist/modules/customers/data/validators.js.map +2 -2
  44. package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
  45. package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
  46. package/dist/modules/integrations/lib/credentials-service.js +12 -1
  47. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  48. package/dist/modules/messages/commands/actions.js +29 -14
  49. package/dist/modules/messages/commands/actions.js.map +2 -2
  50. package/dist/modules/messages/lib/actions.js +24 -4
  51. package/dist/modules/messages/lib/actions.js.map +2 -2
  52. package/dist/modules/sales/api/documents/factory.js +49 -36
  53. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  54. package/package.json +5 -5
  55. package/src/modules/audit_logs/services/accessLogService.ts +20 -0
  56. package/src/modules/audit_logs/services/actionLogService.ts +13 -5
  57. package/src/modules/customers/api/activities/route.ts +1 -76
  58. package/src/modules/customers/api/interactions/counts/route.ts +2 -1
  59. package/src/modules/customers/api/interactions/route.ts +28 -1
  60. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
  61. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
  62. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
  63. package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
  64. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
  65. package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
  66. package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
  67. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
  68. package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
  69. package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
  70. package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
  71. package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
  72. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
  73. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
  74. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
  75. package/src/modules/customers/data/validators.ts +85 -2
  76. package/src/modules/customers/i18n/de.json +11 -0
  77. package/src/modules/customers/i18n/en.json +11 -0
  78. package/src/modules/customers/i18n/es.json +11 -0
  79. package/src/modules/customers/i18n/pl.json +11 -0
  80. package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
  81. package/src/modules/integrations/lib/credentials-service.ts +15 -1
  82. package/src/modules/messages/commands/actions.ts +28 -13
  83. package/src/modules/messages/lib/actions.ts +34 -3
  84. package/src/modules/sales/api/documents/factory.ts +55 -38
@@ -2,15 +2,25 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { Calendar, CalendarClock, Clock, Mail, Phone, StickyNote, Users } from 'lucide-react'
5
+ import { toZonedTime } from 'date-fns-tz'
5
6
  import { cn } from '@open-mercato/shared/lib/utils'
6
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
8
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
9
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
10
  import { ActivitiesDayStrip } from './ActivitiesDayStrip'
9
11
  import { ActivitiesAddNewMenu, type ActivityKind } from './ActivitiesAddNewMenu'
10
12
  import type { InteractionSummary } from './types'
11
13
 
12
14
  interface ActivitiesCardProps {
13
15
  entityId: string
16
+ /**
17
+ * Initial planned activities (from the parent route's `plannedActivitiesPreview`).
18
+ * Used as the seed value before the broader `/api/customers/interactions` fetch
19
+ * resolves, and as the fallback when the fetch fails. The card always prefers
20
+ * its own fetched window (issue #1809 — fixes E1 status alignment and E2 type
21
+ * coverage by sourcing from the same endpoint as the day strip rather than the
22
+ * 5-item server preview that excluded most types in practice).
23
+ */
14
24
  plannedActivities: InteractionSummary[]
15
25
  refreshKey?: number
16
26
  onAddNew: (kind: ActivityKind) => void
@@ -29,6 +39,20 @@ const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
29
39
  note: StickyNote,
30
40
  }
31
41
 
42
+ const USER_TIMEZONE = (() => {
43
+ try {
44
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
45
+ } catch {
46
+ return 'UTC'
47
+ }
48
+ })()
49
+
50
+ // Project a UTC instant to the user's local timezone before extracting day/month/year
51
+ // for "same day" comparisons (issue #1809 — E3 timezone drift).
52
+ function toLocalZonedDate(value: string | Date): Date {
53
+ return toZonedTime(value, USER_TIMEZONE)
54
+ }
55
+
32
56
  function startOfDay(date: Date): Date {
33
57
  const next = new Date(date)
34
58
  next.setHours(0, 0, 0, 0)
@@ -47,6 +71,11 @@ function isOverdue(activity: InteractionSummary, now: Date): boolean {
47
71
  return date.getTime() < now.getTime() && activity.status !== 'done'
48
72
  }
49
73
 
74
+ // Visible window for the day-strip + activity list. Mirrors `VISIBLE_DAYS = 5`
75
+ // in ActivitiesDayStrip with extra padding so navigation forward/back doesn't
76
+ // race the fetch.
77
+ const FETCH_WINDOW_DAYS = 31
78
+
50
79
  function formatTime(date: Date): string {
51
80
  return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
52
81
  }
@@ -80,26 +109,83 @@ export function ActivitiesCard({
80
109
  }: ActivitiesCardProps) {
81
110
  const t = useT()
82
111
  const [selectedDate, setSelectedDate] = React.useState<Date>(() => startOfDay(new Date()))
112
+ // Fetch the same broader window as the day strip via the canonical interactions
113
+ // endpoint. This single source of truth aligns the day-strip count with the
114
+ // visible event list (issue #1809 — E1) and surfaces all interaction types
115
+ // (issue #1809 — E2: the previous reliance on the server-side 5-item preview
116
+ // produced "Person view shows only Calls" because the limit happened to drop
117
+ // every non-call entry from the prefix-window).
118
+ const [fetchedEvents, setFetchedEvents] = React.useState<InteractionSummary[] | null>(null)
119
+
120
+ React.useEffect(() => {
121
+ if (!entityId) {
122
+ setFetchedEvents(null)
123
+ return
124
+ }
125
+ const controller = new AbortController()
126
+ const today = startOfDay(new Date())
127
+ const fromDate = new Date(today)
128
+ fromDate.setDate(today.getDate() - FETCH_WINDOW_DAYS)
129
+ const toDate = new Date(today)
130
+ toDate.setDate(today.getDate() + FETCH_WINDOW_DAYS)
131
+ toDate.setHours(23, 59, 59, 999)
132
+ const params = new URLSearchParams({
133
+ entityId,
134
+ from: fromDate.toISOString(),
135
+ to: toDate.toISOString(),
136
+ // Server caps at 100 (interactions querySchema). 100 is well above what
137
+ // an active CRM record accumulates in a 31-day window of meetings/calls,
138
+ // and the day strip + list naturally degrade to truncation if exceeded.
139
+ limit: '100',
140
+ sortField: 'scheduledAt',
141
+ sortDir: 'asc',
142
+ excludeInteractionType: 'task',
143
+ })
144
+ void (async () => {
145
+ try {
146
+ const payload = await readApiResultOrThrow<{ items?: InteractionSummary[] }>(
147
+ `/api/customers/interactions?${params.toString()}`,
148
+ { signal: controller.signal },
149
+ )
150
+ setFetchedEvents(Array.isArray(payload?.items) ? payload.items : [])
151
+ } catch (err) {
152
+ if ((err as { name?: string } | null)?.name !== 'AbortError') {
153
+ console.warn('[ActivitiesCard] failed to load interactions', err)
154
+ setFetchedEvents(null)
155
+ }
156
+ }
157
+ })()
158
+ return () => controller.abort()
159
+ }, [entityId, refreshKey])
160
+
161
+ // Prefer the broader fetch when it has resolved; fall back to the seed prop
162
+ // (route-supplied preview) only while the fetch is in flight or after a
163
+ // hard failure. This guarantees that the rare prop-only render path keeps
164
+ // backwards-compat with existing unit tests while live UI uses the broader fetch.
165
+ const effectiveEvents: InteractionSummary[] = fetchedEvents ?? plannedActivities
83
166
 
84
167
  const eventsForSelectedDay = React.useMemo(() => {
85
- const items = plannedActivities.filter((activity) => {
168
+ const items = effectiveEvents.filter((activity) => {
86
169
  const scheduled = activity.scheduledAt ?? activity.occurredAt
87
170
  if (!scheduled) return false
88
171
  const date = new Date(scheduled)
89
172
  if (Number.isNaN(date.getTime())) return false
90
- return isSameDay(date, selectedDate)
173
+ // Compare in the user's local timezone so a 23:30 local activity stays
174
+ // on its local-day chip instead of bleeding into the next UTC day
175
+ // (issue #1809 — E3).
176
+ return isSameDay(toLocalZonedDate(scheduled), selectedDate)
91
177
  })
92
178
  return items.sort((left, right) => {
93
179
  const leftTime = new Date(left.scheduledAt ?? left.occurredAt ?? left.createdAt).getTime()
94
180
  const rightTime = new Date(right.scheduledAt ?? right.occurredAt ?? right.createdAt).getTime()
95
181
  return leftTime - rightTime
96
182
  })
97
- }, [plannedActivities, selectedDate])
183
+ }, [effectiveEvents, selectedDate])
98
184
 
99
185
  const overdueCount = React.useMemo(() => {
100
186
  const now = new Date()
101
- return plannedActivities.filter((activity) => isOverdue(activity, now)).length
102
- }, [plannedActivities])
187
+ return effectiveEvents.filter((activity) => isOverdue(activity, now)).length
188
+ }, [effectiveEvents])
103
189
 
104
190
  return (
105
191
  <div className="flex flex-col gap-3 rounded-lg border border-border bg-card pt-4 pb-4 px-4">
@@ -124,6 +210,7 @@ export function ActivitiesCard({
124
210
  selectedDate={selectedDate}
125
211
  onSelectDate={setSelectedDate}
126
212
  refreshKey={refreshKey}
213
+ events={fetchedEvents ?? undefined}
127
214
  />
128
215
 
129
216
  {eventsForSelectedDay.length > 0 ? (
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { ChevronLeft, ChevronRight } from 'lucide-react'
5
+ import { toZonedTime } from 'date-fns-tz'
5
6
  import { cn } from '@open-mercato/shared/lib/utils'
6
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
8
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
@@ -13,6 +14,29 @@ interface ActivitiesDayStripProps {
13
14
  selectedDate: Date
14
15
  onSelectDate: (date: Date) => void
15
16
  refreshKey?: number
17
+ /**
18
+ * Optional pre-fetched events. When provided, the day strip skips its own fetch
19
+ * and uses the supplied list, ensuring its busyness count agrees with the
20
+ * activity list rendered alongside it (issue #1809 — E1 status filter alignment).
21
+ */
22
+ events?: InteractionSummary[]
23
+ }
24
+
25
+ const USER_TIMEZONE = (() => {
26
+ try {
27
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
28
+ } catch {
29
+ return 'UTC'
30
+ }
31
+ })()
32
+
33
+ // Project a UTC ISO timestamp to the user's local timezone before comparing
34
+ // "same day" (issue #1809 — E3). The browser's `new Date(iso)` treats the
35
+ // instant correctly, but `getDate()/getMonth()/getFullYear()` reflect the
36
+ // user's local day, so for activities scheduled at e.g. 23:30 local on a UTC
37
+ // boundary the day-strip and list now agree.
38
+ function toLocalZonedDate(value: string | Date): Date {
39
+ return toZonedTime(value, USER_TIMEZONE)
16
40
  }
17
41
 
18
42
  const VISIBLE_DAYS = 5
@@ -114,7 +138,10 @@ function computeDayBusyness(events: InteractionSummary[], day: Date): DayBusynes
114
138
  if (!startIso) continue
115
139
  const start = new Date(startIso)
116
140
  if (Number.isNaN(start.getTime())) continue
117
- if (!isSameDay(start, day)) continue
141
+ // Compare in the user's local timezone so an activity at 23:30 local time
142
+ // doesn't bleed into the next UTC day's chip (issue #1809 — E3).
143
+ const localStart = toLocalZonedDate(startIso)
144
+ if (!isSameDay(localStart, day)) continue
118
145
  eventCount += 1
119
146
  const durationMinutes = typeof event.duration === 'number' && event.duration > 0 ? event.duration : 30
120
147
  totalMinutes += durationMinutes
@@ -171,10 +198,14 @@ function formatDayLabel(date: Date, t: TranslateFn): string {
171
198
  return entry ? t(entry[1], entry[2]) : ''
172
199
  }
173
200
 
174
- export function ActivitiesDayStrip({ entityId, selectedDate, onSelectDate, refreshKey = 0 }: ActivitiesDayStripProps) {
201
+ export function ActivitiesDayStrip({ entityId, selectedDate, onSelectDate, refreshKey = 0, events: providedEvents }: ActivitiesDayStripProps) {
175
202
  const t = useT()
176
203
  const [anchor, setAnchor] = React.useState<Date>(() => anchorCenteredOn(selectedDate))
177
- const [events, setEvents] = React.useState<InteractionSummary[]>([])
204
+ const [fetchedEvents, setFetchedEvents] = React.useState<InteractionSummary[]>([])
205
+ // When the parent supplies `events` (preferred path — keeps day strip and
206
+ // the list in lockstep, fixes #1809 E1), skip the local fetch entirely.
207
+ const useProvidedEvents = providedEvents !== undefined
208
+ const events = useProvidedEvents ? providedEvents : fetchedEvents
178
209
 
179
210
  React.useEffect(() => {
180
211
  setAnchor((current) => {
@@ -189,6 +220,7 @@ export function ActivitiesDayStrip({ entityId, selectedDate, onSelectDate, refre
189
220
  const headerLabel = React.useMemo(() => formatMonthLabel(visibleDays[0], t), [visibleDays, t])
190
221
 
191
222
  React.useEffect(() => {
223
+ if (useProvidedEvents) return
192
224
  if (!entityId || visibleDays.length === 0) return
193
225
  const controller = new AbortController()
194
226
  const fromIso = startOfDay(visibleDays[0]).toISOString()
@@ -208,16 +240,16 @@ export function ActivitiesDayStrip({ entityId, selectedDate, onSelectDate, refre
208
240
  `/api/customers/interactions?${params.toString()}`,
209
241
  { signal: controller.signal },
210
242
  )
211
- setEvents(Array.isArray(payload?.items) ? payload.items : [])
243
+ setFetchedEvents(Array.isArray(payload?.items) ? payload.items : [])
212
244
  } catch (err) {
213
245
  if ((err as { name?: string } | null)?.name !== 'AbortError') {
214
246
  console.warn('[ActivitiesDayStrip] failed to load interactions', err)
215
247
  }
216
- setEvents([])
248
+ setFetchedEvents([])
217
249
  }
218
250
  })()
219
251
  return () => controller.abort()
220
- }, [entityId, visibleDays, refreshKey])
252
+ }, [entityId, visibleDays, refreshKey, useProvidedEvents])
221
253
 
222
254
  const todayDate = React.useMemo(() => startOfDay(new Date()), [])
223
255
 
@@ -3,7 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { Clock, Search } from 'lucide-react'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
- import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
7
7
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
8
  import type { SectionAction, TabEmptyStateConfig } from '@open-mercato/ui/backend/detail'
9
9
  import { Button } from '@open-mercato/ui/primitives/button'
@@ -105,6 +105,7 @@ export function ActivitiesSection({
105
105
  onLoadingChange,
106
106
  refreshKey = 0,
107
107
  onEditActivity,
108
+ runGuardedMutation,
108
109
  }: ActivitiesSectionProps) {
109
110
  const t = useT()
110
111
  const [filterTypes, setFilterTypes] = React.useState<string[]>([])
@@ -165,13 +166,17 @@ export function ActivitiesSection({
165
166
  setLoading(true)
166
167
  try {
167
168
  // Always fetch canonical interactions (new activities are always created here)
169
+ const taskFilterActive = filterTypes.includes('task')
168
170
  const canonicalParams = new URLSearchParams({
169
171
  entityId,
170
172
  limit: '50',
171
173
  sortField: 'occurredAt',
172
174
  sortDir: 'desc',
173
- excludeInteractionType: 'task',
174
175
  })
176
+ // Hide tasks from the activity timeline by default — they have their own tab —
177
+ // but lift the exclusion when the user explicitly toggled the Task chip on
178
+ // (mirrors `ActivityHistorySection.tsx` after the #1805 fix).
179
+ if (!taskFilterActive) canonicalParams.set('excludeInteractionType', 'task')
175
180
  if (dealId) canonicalParams.set('dealId', dealId)
176
181
  if (filterTypes.length > 0) canonicalParams.set('type', filterTypes.join(','))
177
182
  if (filterDateFrom) canonicalParams.set('from', filterDateFrom)
@@ -250,6 +255,31 @@ export function ActivitiesSection({
250
255
  setLoadedPages(1)
251
256
  }, [dealId, entityId, filterDateFrom, filterDateTo, filterTypes, useCanonicalInteractions])
252
257
 
258
+ const handleMarkDone = React.useCallback(async (activityId: string) => {
259
+ try {
260
+ const operation = () =>
261
+ apiCallOrThrow('/api/customers/interactions/complete', {
262
+ method: 'POST',
263
+ headers: { 'content-type': 'application/json' },
264
+ body: JSON.stringify({ id: activityId, occurredAt: new Date().toISOString() }),
265
+ })
266
+ if (runGuardedMutation) {
267
+ await runGuardedMutation(operation, {
268
+ id: activityId,
269
+ status: 'done',
270
+ operation: 'completeActivity',
271
+ })
272
+ } else {
273
+ await operation()
274
+ }
275
+ flash(t('customers.activities.actions.markDoneSuccess', 'Activity marked done'), 'success')
276
+ await loadActivities()
277
+ } catch (err) {
278
+ console.warn('[customers.activitiesSection] mark done failed', activityId, err)
279
+ flash(t('customers.activities.actions.markDoneError', 'Could not mark activity as done'), 'error')
280
+ }
281
+ }, [loadActivities, runGuardedMutation, t])
282
+
253
283
  const resolvedUserIdsRef = React.useRef(new Set<string>())
254
284
 
255
285
  // Resolve missing author names from user IDs
@@ -353,7 +383,11 @@ export function ActivitiesSection({
353
383
  </div>
354
384
  ) : (
355
385
  <>
356
- <ActivityTimeline activities={visibleActivities} onEdit={onEditActivity} />
386
+ <ActivityTimeline
387
+ activities={visibleActivities}
388
+ onEdit={onEditActivity}
389
+ onMarkDone={handleMarkDone}
390
+ />
357
391
  {totalCount > 0 ? (
358
392
  <div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3">
359
393
  <span className="text-xs text-muted-foreground">
@@ -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">