@open-mercato/core 0.5.1-develop.3045.b4b3320cc2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +21 -1
- package/dist/modules/api_keys/api/keys/route.js +9 -0
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/audit_logs/services/accessLogService.js +13 -0
- package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
- package/dist/modules/audit_logs/services/actionLogService.js +6 -5
- package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +27 -37
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +41 -28
- package/dist/modules/auth/api/users/route.js.map +3 -3
- package/dist/modules/auth/lib/grantChecks.js +160 -0
- package/dist/modules/auth/lib/grantChecks.js.map +7 -0
- package/dist/modules/configs/cli.js +11 -0
- package/dist/modules/configs/cli.js.map +2 -2
- package/dist/modules/configs/lib/touchGeneratedBarrels.js +46 -0
- package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +7 -0
- package/dist/modules/customers/api/activities/route.js +1 -52
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/counts/route.js +2 -1
- package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +21 -1
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
- package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
- package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
- package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
- package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
- package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/data/validators.js +74 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
- package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
- package/dist/modules/integrations/data/validators.js +2 -2
- package/dist/modules/integrations/data/validators.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +12 -1
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/messages/commands/actions.js +29 -14
- package/dist/modules/messages/commands/actions.js.map +2 -2
- package/dist/modules/messages/lib/actions.js +24 -4
- package/dist/modules/messages/lib/actions.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +49 -36
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/package.json +9 -10
- package/src/modules/api_keys/api/keys/route.ts +9 -0
- package/src/modules/audit_logs/services/accessLogService.ts +20 -0
- package/src/modules/audit_logs/services/actionLogService.ts +13 -5
- package/src/modules/auth/api/roles/acl/route.ts +32 -46
- package/src/modules/auth/api/users/route.ts +48 -33
- package/src/modules/auth/lib/grantChecks.ts +234 -0
- package/src/modules/configs/cli.ts +11 -0
- package/src/modules/configs/lib/touchGeneratedBarrels.ts +61 -0
- package/src/modules/customers/api/activities/route.ts +1 -76
- package/src/modules/customers/api/interactions/counts/route.ts +2 -1
- package/src/modules/customers/api/interactions/route.ts +28 -1
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
- package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
- package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
- package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
- package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
- package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
- package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
- package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
- package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
- package/src/modules/customers/data/validators.ts +85 -2
- package/src/modules/customers/i18n/de.json +11 -0
- package/src/modules/customers/i18n/en.json +11 -0
- package/src/modules/customers/i18n/es.json +11 -0
- package/src/modules/customers/i18n/pl.json +11 -0
- package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
- package/src/modules/integrations/data/validators.ts +8 -6
- package/src/modules/integrations/lib/credentials-service.ts +15 -1
- package/src/modules/messages/commands/actions.ts +28 -13
- package/src/modules/messages/lib/actions.ts +34 -3
- package/src/modules/sales/api/documents/factory.ts +55 -38
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}, [
|
|
183
|
+
}, [effectiveEvents, selectedDate])
|
|
98
184
|
|
|
99
185
|
const overdueCount = React.useMemo(() => {
|
|
100
186
|
const now = new Date()
|
|
101
|
-
return
|
|
102
|
-
}, [
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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">
|