@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
@@ -9,7 +9,6 @@ import type { EntityManager } from '@mikro-orm/postgresql'
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import type { CommandBus } from '@open-mercato/shared/lib/commands'
11
11
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
12
- import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
13
12
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
14
13
  import {
15
14
  runCrudMutationGuardAfterSuccess,
@@ -28,6 +27,7 @@ import {
28
27
  import { resolveCustomerInteractionFeatureFlags } from '../../lib/interactionFeatureFlags'
29
28
  import { resolveCustomersRequestContext } from '../../lib/interactionRequestContext'
30
29
  import { hydrateCanonicalInteractions } from '../../lib/interactionReadModel'
30
+ import { resolveCanonicalActivityTargetId } from '../../lib/legacyActivityBridge'
31
31
 
32
32
  const listSchema = z.object({
33
33
  page: z.coerce.number().min(1).default(1),
@@ -253,81 +253,6 @@ function mapLegacyActivity(activity: CustomerActivity): ActivityItem {
253
253
  }
254
254
  }
255
255
 
256
- async function loadLegacyActivityCustomValues(
257
- em: EntityManager,
258
- activity: CustomerActivity,
259
- ): Promise<Record<string, unknown> | null> {
260
- const values = await loadCustomFieldValues({
261
- em,
262
- entityId: 'customers:customer_activity',
263
- recordIds: [activity.id],
264
- tenantIdByRecord: { [activity.id]: activity.tenantId },
265
- organizationIdByRecord: { [activity.id]: activity.organizationId },
266
- tenantFallbacks: [activity.tenantId],
267
- })
268
- return values[activity.id] ?? null
269
- }
270
-
271
- async function ensureCanonicalActivityBridge(
272
- em: EntityManager,
273
- commandBus: CommandBus,
274
- commandContext: Parameters<CommandBus['execute']>[1]['ctx'],
275
- activity: CustomerActivity,
276
- ): Promise<string> {
277
- const existing = await em.findOne(CustomerInteraction, { id: activity.id, tenantId: activity.tenantId })
278
- if (existing) return existing.id
279
-
280
- const entityId = typeof activity.entity === 'string' ? activity.entity : activity.entity.id
281
- const dealId = activity.deal
282
- ? (typeof activity.deal === 'string' ? activity.deal : activity.deal.id)
283
- : null
284
- const customValues = await loadLegacyActivityCustomValues(em, activity)
285
-
286
- await commandBus.execute('customers.interactions.create', {
287
- input: {
288
- id: activity.id,
289
- tenantId: activity.tenantId,
290
- organizationId: activity.organizationId,
291
- entityId,
292
- interactionType: activity.activityType,
293
- title: activity.subject ?? null,
294
- body: activity.body ?? null,
295
- occurredAt: activity.occurredAt ?? null,
296
- status: activity.occurredAt ? 'done' : 'planned',
297
- dealId,
298
- authorUserId: activity.authorUserId ?? null,
299
- appearanceIcon: activity.appearanceIcon ?? null,
300
- appearanceColor: activity.appearanceColor ?? null,
301
- source: CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE,
302
- ...(customValues ? { customValues } : {}),
303
- },
304
- ctx: commandContext,
305
- })
306
-
307
- return activity.id
308
- }
309
-
310
- async function resolveCanonicalActivityTargetId(
311
- em: EntityManager,
312
- commandBus: CommandBus,
313
- commandContext: Parameters<CommandBus['execute']>[1]['ctx'],
314
- targetId: string,
315
- tenantId: string,
316
- ): Promise<string> {
317
- const existing = await em.findOne(CustomerInteraction, { id: targetId, tenantId })
318
- if (existing) return existing.id
319
-
320
- const legacy = await em.findOne(CustomerActivity, { id: targetId, tenantId }, { populate: ['entity', 'deal'] })
321
- if (!legacy) return targetId
322
-
323
- return ensureCanonicalActivityBridge(
324
- em,
325
- commandBus,
326
- commandContext,
327
- legacy,
328
- )
329
- }
330
-
331
256
  async function listCanonicalActivities(
332
257
  em: EntityManager,
333
258
  container: { resolve: (name: string) => unknown },
@@ -21,6 +21,7 @@ const responseSchema = z.object({
21
21
  email: z.number(),
22
22
  meeting: z.number(),
23
23
  note: z.number(),
24
+ task: z.number(),
24
25
  total: z.number(),
25
26
  }),
26
27
  })
@@ -92,7 +93,7 @@ export async function GET(req: Request) {
92
93
  .groupBy('interaction_type')
93
94
  .execute() as Array<{ interaction_type: string; count: string | number }>
94
95
 
95
- const counts: Record<string, number> = { call: 0, email: 0, meeting: 0, note: 0 }
96
+ const counts: Record<string, number> = { call: 0, email: 0, meeting: 0, note: 0, task: 0 }
96
97
  let total = 0
97
98
  for (const row of rows) {
98
99
  const count = typeof row.count === 'string' ? parseInt(row.count, 10) : row.count
@@ -22,6 +22,8 @@ import {
22
22
  defaultOkResponseSchema,
23
23
  } from '../openapi'
24
24
  import { CUSTOMER_INTERACTION_ENTITY_ID } from '../../lib/interactionCompatibility'
25
+ import { resolveCanonicalActivityTargetId } from '../../lib/legacyActivityBridge'
26
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
25
27
 
26
28
  const rawBodySchema = z.object({}).passthrough()
27
29
 
@@ -93,7 +95,32 @@ const crud = makeCrudRoute({
93
95
  schema: rawBodySchema,
94
96
  mapInput: async ({ raw, ctx }) => {
95
97
  const { translate } = await resolveTranslations()
96
- return parseScopedCommandInput(interactionUpdateSchema, raw ?? {}, ctx, translate)
98
+ const parsed = parseScopedCommandInput(interactionUpdateSchema, raw ?? {}, ctx, translate)
99
+ // Bridge legacy `customer_activities` rows into `customer_interactions`
100
+ // before the canonical update runs so historical activities (#1807)
101
+ // remain editable through the new dialog. No-op when the canonical
102
+ // record already exists.
103
+ const tenantId = ctx.auth?.tenantId ?? null
104
+ if (typeof parsed.id === 'string' && tenantId) {
105
+ try {
106
+ const em = ctx.container.resolve('em') as EntityManager
107
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
108
+ const commandContext: CommandRuntimeContext = {
109
+ container: ctx.container,
110
+ auth: ctx.auth ?? null,
111
+ organizationScope: ctx.organizationScope ?? null,
112
+ selectedOrganizationId: ctx.selectedOrganizationId ?? null,
113
+ organizationIds: ctx.organizationIds ?? null,
114
+ request: ctx.request,
115
+ }
116
+ await resolveCanonicalActivityTargetId(em, commandBus, commandContext, parsed.id, tenantId)
117
+ } catch (err) {
118
+ // Bridging is best-effort; downstream lookup will surface a 404
119
+ // when neither canonical nor legacy rows exist.
120
+ console.warn('[customers.interactions.put] legacy bridge failed', { id: parsed.id, error: err })
121
+ }
122
+ }
123
+ return parsed
97
124
  },
98
125
  response: () => ({ ok: true }),
99
126
  },
@@ -185,19 +185,24 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
185
185
  logContext: 'customers.companies-v2',
186
186
  })
187
187
 
188
- const handleEditActivity = React.useCallback((activity: { id: string; interactionType?: string; title?: string | null; body?: string | null; scheduledAt?: string | null; [key: string]: unknown }) => {
188
+ const handleEditActivity = React.useCallback((activity: { id: string; interactionType?: string; title?: string | null; body?: string | null; scheduledAt?: string | null; occurredAt?: string | null; [key: string]: unknown }) => {
189
189
  const raw = activity as Record<string, unknown>
190
190
  const durationValue = typeof raw.duration === 'number'
191
191
  ? raw.duration
192
192
  : typeof raw.durationMinutes === 'number'
193
193
  ? raw.durationMinutes as number
194
194
  : null
195
- setScheduleEditData({
195
+ // Forward `customValues` so per-type chip state (callPhoneNumber, callDirection,
196
+ // taskPriority, …) round-trips on edit (#1808 phone persistence).
197
+ // Forward `occurredAt` so historical activity edits prefill from the original
198
+ // moment instead of "today" (#1807 prefill).
199
+ const editPayload = {
196
200
  id: activity.id,
197
201
  interactionType: typeof activity.interactionType === 'string' ? activity.interactionType : undefined,
198
202
  title: typeof activity.title === 'string' ? activity.title : null,
199
203
  body: typeof activity.body === 'string' ? activity.body : null,
200
204
  scheduledAt: typeof activity.scheduledAt === 'string' ? activity.scheduledAt : null,
205
+ occurredAt: typeof activity.occurredAt === 'string' ? activity.occurredAt : null,
201
206
  durationMinutes: durationValue,
202
207
  location: typeof raw.location === 'string' ? raw.location as string : null,
203
208
  allDay: typeof raw.allDay === 'boolean' ? raw.allDay as boolean : null,
@@ -210,7 +215,12 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
210
215
  guestPermissions: raw.guestPermissions && typeof raw.guestPermissions === 'object'
211
216
  ? raw.guestPermissions as ScheduleActivityEditData['guestPermissions']
212
217
  : null,
213
- })
218
+ customValues: raw.customValues && typeof raw.customValues === 'object'
219
+ ? raw.customValues as Record<string, unknown>
220
+ : null,
221
+ phoneNumber: typeof raw.phoneNumber === 'string' ? raw.phoneNumber as string : null,
222
+ } as ScheduleActivityEditData & { customValues?: Record<string, unknown> | null; phoneNumber?: string | null }
223
+ setScheduleEditData(editPayload)
214
224
  setScheduleDialogOpen(true)
215
225
  }, [])
216
226
 
@@ -27,7 +27,7 @@ import { DealWonPopup } from '../../../../components/detail/DealWonPopup'
27
27
  import { InlineActivityComposer } from '../../../../components/detail/InlineActivityComposer'
28
28
  import { PipelineStepper } from '../../../../components/detail/PipelineStepper'
29
29
  import { PlannedActivitiesSection } from '../../../../components/detail/PlannedActivitiesSection'
30
- import { ScheduleActivityDialog } from '../../../../components/detail/ScheduleActivityDialog'
30
+ import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../../components/detail/ScheduleActivityDialog'
31
31
  import { createCustomerNotesAdapter } from '../../../../components/detail/notesAdapter'
32
32
  import type { InteractionSummary } from '../../../../components/detail/types'
33
33
  import { readMarkdownPreferenceCookie, writeMarkdownPreferenceCookie } from '../../../../lib/markdownPreference'
@@ -220,12 +220,18 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
220
220
  if (activity.entityId && activityEntities.some((entry) => entry.id === activity.entityId)) {
221
221
  setSelectedActivityEntityId(activity.entityId)
222
222
  }
223
+ // Forward `customValues` so per-type chip state (callPhoneNumber,
224
+ // callDirection, taskPriority) round-trips on edit (#1808 phone persistence).
225
+ // Forward `occurredAt` so historical activity edits prefill from the
226
+ // original moment instead of "today" (#1807 prefill).
227
+ const rawActivity = activity as unknown as Record<string, unknown>
223
228
  openScheduleEdit({
224
229
  id: activity.id,
225
230
  interactionType: activity.interactionType,
226
231
  title: activity.title ?? null,
227
232
  body: activity.body ?? null,
228
233
  scheduledAt: activity.scheduledAt ?? null,
234
+ occurredAt: activity.occurredAt ?? null,
229
235
  durationMinutes: activity.duration ?? null,
230
236
  location: activity.location ?? null,
231
237
  allDay: activity.allDay ?? null,
@@ -236,7 +242,13 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
236
242
  visibility: activity.visibility ?? null,
237
243
  linkedEntities: activity.linkedEntities ?? null,
238
244
  guestPermissions: activity.guestPermissions ?? null,
239
- })
245
+ ...(rawActivity.customValues && typeof rawActivity.customValues === 'object'
246
+ ? { customValues: rawActivity.customValues as Record<string, unknown> }
247
+ : {}),
248
+ ...(typeof rawActivity.phoneNumber === 'string'
249
+ ? { phoneNumber: rawActivity.phoneNumber as string }
250
+ : {}),
251
+ } as ScheduleActivityEditData & { customValues?: Record<string, unknown> | null; phoneNumber?: string | null })
240
252
  }, [activityEntities, openScheduleEdit])
241
253
 
242
254
  const handleViewDashboard = React.useCallback(() => {
@@ -210,19 +210,24 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
210
210
  setScheduleDialogOpen(true)
211
211
  }, [])
212
212
 
213
- const handleEditActivity = React.useCallback((activity: { id: string; interactionType?: string; title?: string | null; body?: string | null; scheduledAt?: string | null; [key: string]: unknown }) => {
213
+ const handleEditActivity = React.useCallback((activity: { id: string; interactionType?: string; title?: string | null; body?: string | null; scheduledAt?: string | null; occurredAt?: string | null; [key: string]: unknown }) => {
214
214
  const raw = activity as Record<string, unknown>
215
215
  const durationValue = typeof raw.duration === 'number'
216
216
  ? raw.duration
217
217
  : typeof raw.durationMinutes === 'number'
218
218
  ? raw.durationMinutes as number
219
219
  : null
220
- setScheduleEditData({
220
+ // Forward `customValues` so per-type chip state (callPhoneNumber, callDirection,
221
+ // taskPriority, …) round-trips on edit (#1808 phone persistence).
222
+ // Forward `occurredAt` so historical activity edits prefill from the original
223
+ // moment instead of "today" (#1807 prefill).
224
+ const editPayload = {
221
225
  id: activity.id,
222
226
  interactionType: typeof activity.interactionType === 'string' ? activity.interactionType : undefined,
223
227
  title: typeof activity.title === 'string' ? activity.title : null,
224
228
  body: typeof activity.body === 'string' ? activity.body : null,
225
229
  scheduledAt: typeof activity.scheduledAt === 'string' ? activity.scheduledAt : null,
230
+ occurredAt: typeof activity.occurredAt === 'string' ? activity.occurredAt : null,
226
231
  durationMinutes: durationValue,
227
232
  location: typeof raw.location === 'string' ? raw.location as string : null,
228
233
  allDay: typeof raw.allDay === 'boolean' ? raw.allDay as boolean : null,
@@ -235,7 +240,12 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
235
240
  guestPermissions: raw.guestPermissions && typeof raw.guestPermissions === 'object'
236
241
  ? raw.guestPermissions as ScheduleActivityEditData['guestPermissions']
237
242
  : null,
238
- })
243
+ customValues: raw.customValues && typeof raw.customValues === 'object'
244
+ ? raw.customValues as Record<string, unknown>
245
+ : null,
246
+ phoneNumber: typeof raw.phoneNumber === 'string' ? raw.phoneNumber as string : null,
247
+ } as ScheduleActivityEditData & { customValues?: Record<string, unknown> | null; phoneNumber?: string | null }
248
+ setScheduleEditData(editPayload)
239
249
  setScheduleDialogOpen(true)
240
250
  }, [])
241
251
 
@@ -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">