@open-mercato/core 0.4.11-develop.1309.4b37381a7a → 0.4.11-develop.1347.c693e6dfee

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 (60) hide show
  1. package/dist/modules/customers/api/companies/[id]/route.js +3 -2
  2. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  3. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js +59 -91
  4. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js.map +2 -2
  5. package/dist/modules/customers/api/interactions/tasks/route.js +115 -0
  6. package/dist/modules/customers/api/interactions/tasks/route.js.map +7 -0
  7. package/dist/modules/customers/api/people/[id]/route.js +3 -2
  8. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  9. package/dist/modules/customers/api/todos/route.js +14 -134
  10. package/dist/modules/customers/api/todos/route.js.map +2 -2
  11. package/dist/modules/customers/backend/customer-tasks/page.js +10 -0
  12. package/dist/modules/customers/backend/customer-tasks/page.js.map +7 -0
  13. package/dist/modules/customers/backend/customer-tasks/page.meta.js +25 -0
  14. package/dist/modules/customers/backend/customer-tasks/page.meta.js.map +7 -0
  15. package/dist/modules/customers/commands/interactions.js +40 -4
  16. package/dist/modules/customers/commands/interactions.js.map +2 -2
  17. package/dist/modules/customers/components/CustomerTodosTable.js +77 -47
  18. package/dist/modules/customers/components/CustomerTodosTable.js.map +2 -2
  19. package/dist/modules/customers/components/detail/TasksSection.js +4 -4
  20. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  21. package/dist/modules/customers/components/detail/hooks/usePersonTasks.js +2 -1
  22. package/dist/modules/customers/components/detail/hooks/usePersonTasks.js.map +2 -2
  23. package/dist/modules/customers/components/detail/utils.js +3 -0
  24. package/dist/modules/customers/components/detail/utils.js.map +2 -2
  25. package/dist/modules/customers/data/entities.js +2 -2
  26. package/dist/modules/customers/data/entities.js.map +2 -2
  27. package/dist/modules/customers/data/validators.js +2 -2
  28. package/dist/modules/customers/data/validators.js.map +2 -2
  29. package/dist/modules/customers/lib/interactionCompatibility.js +12 -2
  30. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  31. package/dist/modules/customers/lib/todoCompatibility.js +167 -4
  32. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  33. package/dist/modules/customers/migrations/Migration20260401172819.js +45 -0
  34. package/dist/modules/customers/migrations/Migration20260401172819.js.map +7 -0
  35. package/dist/modules/customers/search.js +3 -2
  36. package/dist/modules/customers/search.js.map +2 -2
  37. package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js +10 -2
  38. package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js.map +2 -2
  39. package/package.json +3 -3
  40. package/src/modules/customers/api/companies/[id]/route.ts +6 -5
  41. package/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +69 -126
  42. package/src/modules/customers/api/interactions/tasks/route.ts +122 -0
  43. package/src/modules/customers/api/people/[id]/route.ts +3 -2
  44. package/src/modules/customers/api/todos/route.ts +13 -181
  45. package/src/modules/customers/backend/customer-tasks/page.meta.ts +23 -0
  46. package/src/modules/customers/backend/customer-tasks/page.tsx +12 -0
  47. package/src/modules/customers/commands/interactions.ts +50 -2
  48. package/src/modules/customers/components/CustomerTodosTable.tsx +91 -66
  49. package/src/modules/customers/components/detail/TasksSection.tsx +8 -8
  50. package/src/modules/customers/components/detail/hooks/usePersonTasks.ts +2 -1
  51. package/src/modules/customers/components/detail/types.ts +6 -0
  52. package/src/modules/customers/components/detail/utils.ts +3 -0
  53. package/src/modules/customers/data/entities.ts +2 -2
  54. package/src/modules/customers/data/validators.ts +2 -2
  55. package/src/modules/customers/lib/interactionCompatibility.ts +16 -0
  56. package/src/modules/customers/lib/todoCompatibility.ts +229 -10
  57. package/src/modules/customers/migrations/.snapshot-open-mercato.json +1 -1
  58. package/src/modules/customers/migrations/Migration20260401172819.ts +45 -0
  59. package/src/modules/customers/search.ts +3 -2
  60. package/src/modules/customers/widgets/dashboard/customer-todos/widget.client.tsx +24 -23
@@ -1,9 +1,18 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
1
2
  import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
2
3
  import type { EntityId } from '@open-mercato/shared/modules/entities'
3
4
  import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
4
- import type { CustomerTodoLink } from '../data/entities'
5
+ import {
6
+ CustomerInteraction,
7
+ CustomerTodoLink,
8
+ } from '../data/entities'
5
9
  import type { InteractionRecord } from './interactionCompatibility'
6
- import { CUSTOMER_INTERACTION_TASK_SOURCE } from './interactionCompatibility'
10
+ import {
11
+ CUSTOMER_INTERACTION_TASK_SOURCE,
12
+ EXAMPLE_TODO_SOURCE,
13
+ resolveExampleIntegrationHref,
14
+ } from './interactionCompatibility'
15
+ import { hydrateCanonicalInteractions, loadCustomerSummaries } from './interactionReadModel'
7
16
 
8
17
  export type CustomerTodoRow = {
9
18
  id: string
@@ -20,6 +29,8 @@ export type CustomerTodoRow = {
20
29
  organizationId: string
21
30
  tenantId: string
22
31
  createdAt: string
32
+ externalHref?: string | null
33
+ _integrations?: Record<string, unknown>
23
34
  customer: {
24
35
  id: string | null
25
36
  displayName: string | null
@@ -44,6 +55,29 @@ type CustomerSummary = {
44
55
  kind: string | null
45
56
  }
46
57
 
58
+ type CustomersAuthLike = {
59
+ tenantId: string | null
60
+ orgId?: string | null
61
+ sub?: string | null
62
+ userId?: string | null
63
+ keyId?: string | null
64
+ }
65
+
66
+ type CustomersContainerLike = {
67
+ resolve: (name: string) => unknown
68
+ }
69
+
70
+ export type CanonicalTodoListResult = {
71
+ items: CustomerTodoRow[]
72
+ bridgeIds: Set<string>
73
+ }
74
+
75
+ function resolveLegacyTodoSource(source: string | null | undefined): string {
76
+ return typeof source === 'string' && source.trim().length > 0
77
+ ? source
78
+ : EXAMPLE_TODO_SOURCE
79
+ }
80
+
47
81
  function extractTodoTitle(record: Record<string, unknown>): string | null {
48
82
  const candidates = ['title', 'subject', 'name', 'summary', 'text', 'description']
49
83
  for (const key of candidates) {
@@ -88,6 +122,64 @@ function readCustomField(record: Record<string, unknown>, key: string): unknown
88
122
  return undefined
89
123
  }
90
124
 
125
+ export function normalizeTodoSearch(value: string | undefined): string | null {
126
+ if (typeof value !== 'string') return null
127
+ const trimmed = value.trim().toLowerCase()
128
+ return trimmed.length > 0 ? trimmed : null
129
+ }
130
+
131
+ export function sortTodoRows(rows: CustomerTodoRow[]): CustomerTodoRow[] {
132
+ return [...rows].sort((left, right) => {
133
+ const leftTime = new Date(left.createdAt).getTime()
134
+ const rightTime = new Date(right.createdAt).getTime()
135
+ if (leftTime === rightTime) {
136
+ return right.id.localeCompare(left.id)
137
+ }
138
+ return rightTime - leftTime
139
+ })
140
+ }
141
+
142
+ export function filterTodoRows(rows: CustomerTodoRow[], search: string | null): CustomerTodoRow[] {
143
+ if (!search) return rows
144
+ return rows.filter((row) => {
145
+ const haystack = [
146
+ row.customer.displayName,
147
+ row.todoTitle,
148
+ row.todoDescription,
149
+ ]
150
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
151
+ .join(' ')
152
+ .toLowerCase()
153
+ return haystack.includes(search)
154
+ })
155
+ }
156
+
157
+ export function paginateTodoRows(
158
+ rows: CustomerTodoRow[],
159
+ page: number,
160
+ pageSize: number,
161
+ exportAll: boolean,
162
+ ): { items: CustomerTodoRow[]; total: number; page: number; pageSize: number; totalPages: number } {
163
+ const total = rows.length
164
+ if (exportAll) {
165
+ return {
166
+ items: rows,
167
+ total,
168
+ page: 1,
169
+ pageSize: total,
170
+ totalPages: 1,
171
+ }
172
+ }
173
+ const start = (page - 1) * pageSize
174
+ return {
175
+ items: rows.slice(start, start + pageSize),
176
+ total,
177
+ page,
178
+ pageSize,
179
+ totalPages: Math.max(1, Math.ceil(total / pageSize)),
180
+ }
181
+ }
182
+
91
183
  export async function resolveLegacyTodoDetails(
92
184
  queryEngine: QueryEngine,
93
185
  links: CustomerTodoLink[],
@@ -103,10 +195,7 @@ export async function resolveLegacyTodoDetails(
103
195
 
104
196
  const idsBySource = new Map<string, Set<string>>()
105
197
  for (const link of links) {
106
- const source =
107
- typeof link.todoSource === 'string' && link.todoSource.trim().length > 0
108
- ? link.todoSource
109
- : 'example:todo'
198
+ const source = resolveLegacyTodoSource(link.todoSource)
110
199
  const id =
111
200
  typeof link.todoId === 'string' && link.todoId.trim().length > 0
112
201
  ? link.todoId
@@ -264,6 +353,136 @@ export async function resolveLegacyTodoDetails(
264
353
  return details
265
354
  }
266
355
 
356
+ export async function listLegacyTodoRows(
357
+ em: EntityManager,
358
+ queryEngine: QueryEngine,
359
+ tenantId: string,
360
+ organizationIds: string[] | null,
361
+ entityId: string | undefined,
362
+ ): Promise<CustomerTodoRow[]> {
363
+ const where: Record<string, unknown> = { tenantId }
364
+ if (organizationIds && organizationIds.length > 0) {
365
+ where.organizationId = { $in: organizationIds }
366
+ }
367
+ if (entityId) {
368
+ where.entity = entityId
369
+ }
370
+
371
+ const links = await em.find(CustomerTodoLink, where, {
372
+ populate: ['entity'],
373
+ orderBy: { createdAt: 'desc' },
374
+ })
375
+ const details = await resolveLegacyTodoDetails(
376
+ queryEngine,
377
+ links,
378
+ tenantId,
379
+ organizationIds ?? [],
380
+ )
381
+
382
+ return links.map((link) => {
383
+ const source = resolveLegacyTodoSource(link.todoSource)
384
+ return mapLegacyTodoLinkToRow(
385
+ link,
386
+ details.get(`${source}:${link.todoId}`) ?? null,
387
+ )
388
+ })
389
+ }
390
+
391
+ export async function listCanonicalTodoRows(
392
+ em: EntityManager,
393
+ container: CustomersContainerLike,
394
+ auth: CustomersAuthLike,
395
+ selectedOrganizationId: string | null,
396
+ organizationIds: string[] | null,
397
+ options?: {
398
+ entityId?: string
399
+ includeDeleted?: boolean
400
+ source?: string | string[] | null
401
+ },
402
+ ): Promise<CanonicalTodoListResult> {
403
+ const where: Record<string, unknown> = {
404
+ tenantId: auth.tenantId,
405
+ interactionType: 'task',
406
+ }
407
+ if (!options?.includeDeleted) {
408
+ where.deletedAt = null
409
+ }
410
+ if (organizationIds && organizationIds.length > 0) {
411
+ where.organizationId = { $in: organizationIds }
412
+ }
413
+ if (options?.entityId) {
414
+ where.entity = options.entityId
415
+ }
416
+ if (options?.source) {
417
+ where.source = Array.isArray(options.source) ? { $in: options.source } : options.source
418
+ }
419
+
420
+ const interactions = await em.find(CustomerInteraction, where, {
421
+ orderBy: { createdAt: 'desc' },
422
+ })
423
+ const activeInteractions = interactions.filter((interaction) => !interaction.deletedAt)
424
+ const groups = new Map<string, CustomerInteraction[]>()
425
+
426
+ for (const interaction of activeInteractions) {
427
+ const organizationId =
428
+ typeof interaction.organizationId === 'string' && interaction.organizationId.trim().length > 0
429
+ ? interaction.organizationId
430
+ : selectedOrganizationId ?? ''
431
+ const bucket = groups.get(organizationId)
432
+ if (bucket) {
433
+ bucket.push(interaction)
434
+ } else {
435
+ groups.set(organizationId, [interaction])
436
+ }
437
+ }
438
+
439
+ const rowByInteractionId = new Map<string, CustomerTodoRow>()
440
+
441
+ for (const [groupOrganizationId, groupedInteractions] of groups.entries()) {
442
+ const scopedOrganizationId = groupOrganizationId.length > 0 ? groupOrganizationId : null
443
+ const hydrated = await hydrateCanonicalInteractions({
444
+ em,
445
+ container,
446
+ auth: {
447
+ ...auth,
448
+ orgId: auth.orgId ?? null,
449
+ },
450
+ selectedOrganizationId: scopedOrganizationId,
451
+ interactions: groupedInteractions,
452
+ })
453
+ const customerIds = Array.from(
454
+ new Set(
455
+ hydrated
456
+ .map((interaction) => interaction.entityId ?? null)
457
+ .filter((value): value is string => !!value),
458
+ ),
459
+ )
460
+ const customerSummaries = await loadCustomerSummaries(
461
+ em,
462
+ customerIds,
463
+ auth.tenantId,
464
+ scopedOrganizationId,
465
+ )
466
+
467
+ for (const interaction of hydrated) {
468
+ rowByInteractionId.set(
469
+ interaction.id,
470
+ mapInteractionRecordToTodoRow(
471
+ interaction,
472
+ interaction.entityId ? customerSummaries.get(interaction.entityId) ?? null : null,
473
+ ),
474
+ )
475
+ }
476
+ }
477
+
478
+ return {
479
+ items: activeInteractions
480
+ .map((interaction) => rowByInteractionId.get(interaction.id) ?? null)
481
+ .filter((row): row is CustomerTodoRow => !!row),
482
+ bridgeIds: new Set(interactions.map((interaction) => interaction.id)),
483
+ }
484
+ }
485
+
267
486
  export function mapLegacyTodoLinkToRow(
268
487
  link: CustomerTodoLink,
269
488
  detail: LegacyTodoDetail | null,
@@ -278,10 +497,7 @@ export function mapLegacyTodoLinkToRow(
278
497
  return {
279
498
  id: link.id,
280
499
  todoId: link.todoId,
281
- todoSource:
282
- typeof link.todoSource === 'string' && link.todoSource.trim().length > 0
283
- ? link.todoSource
284
- : 'example:todo',
500
+ todoSource: resolveLegacyTodoSource(link.todoSource),
285
501
  todoTitle: detail?.title ?? null,
286
502
  todoIsDone: detail?.isDone ?? null,
287
503
  todoPriority: detail?.priority ?? null,
@@ -293,6 +509,7 @@ export function mapLegacyTodoLinkToRow(
293
509
  organizationId: link.organizationId,
294
510
  tenantId: link.tenantId,
295
511
  createdAt: link.createdAt.toISOString(),
512
+ _integrations: undefined,
296
513
  customer: entity,
297
514
  }
298
515
  }
@@ -337,6 +554,8 @@ export function mapInteractionRecordToTodoRow(
337
554
  organizationId: interaction.organizationId ?? '',
338
555
  tenantId: interaction.tenantId ?? '',
339
556
  createdAt: interaction.createdAt,
557
+ externalHref: resolveExampleIntegrationHref(interaction),
558
+ _integrations: interaction._integrations ?? undefined,
340
559
  customer: customer ?? {
341
560
  id: interaction.entityId ?? null,
342
561
  displayName: null,
@@ -4045,7 +4045,7 @@
4045
4045
  "length": null,
4046
4046
  "precision": null,
4047
4047
  "scale": null,
4048
- "default": "'example:todo'",
4048
+ "default": "'customers:interaction'",
4049
4049
  "comment": null,
4050
4050
  "enumItems": [],
4051
4051
  "mappedType": "text"
@@ -0,0 +1,45 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260401172819 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "customer_todo_links" alter column "todo_source" set default 'customers:interaction';`);
7
+ this.addSql(`
8
+ update "role_acls" as ra
9
+ set
10
+ "features_json" = case
11
+ when ra."features_json" is null or jsonb_typeof(ra."features_json") <> 'array'
12
+ then '["customers.interactions.view"]'::jsonb
13
+ else ra."features_json" || '"customers.interactions.view"'::jsonb
14
+ end,
15
+ "updated_at" = now()
16
+ where ra."deleted_at" is null
17
+ and ra."features_json" is not null
18
+ and jsonb_typeof(ra."features_json") = 'array'
19
+ and ra."features_json" ? 'example.todos.view'
20
+ and ra."features_json" ? 'customers.activities.view'
21
+ and not (ra."features_json" ? 'customers.interactions.view');
22
+ `);
23
+ this.addSql(`
24
+ update "user_acls" as ua
25
+ set
26
+ "features_json" = case
27
+ when ua."features_json" is null or jsonb_typeof(ua."features_json") <> 'array'
28
+ then '["customers.interactions.view"]'::jsonb
29
+ else ua."features_json" || '"customers.interactions.view"'::jsonb
30
+ end,
31
+ "updated_at" = now()
32
+ where ua."deleted_at" is null
33
+ and ua."features_json" is not null
34
+ and jsonb_typeof(ua."features_json") = 'array'
35
+ and ua."features_json" ? 'example.todos.view'
36
+ and ua."features_json" ? 'customers.activities.view'
37
+ and not (ua."features_json" ? 'customers.interactions.view');
38
+ `);
39
+ }
40
+
41
+ override async down(): Promise<void> {
42
+ this.addSql(`alter table "customer_todo_links" alter column "todo_source" set default 'example:todo';`);
43
+ }
44
+
45
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  SearchResultLink,
7
7
  SearchIndexSource,
8
8
  } from '@open-mercato/shared/modules/search'
9
+ import { CUSTOMER_INTERACTION_TASK_SOURCE, EXAMPLE_TODO_SOURCE } from './lib/interactionCompatibility'
9
10
 
10
11
  // =============================================================================
11
12
  // Context Types
@@ -346,9 +347,9 @@ async function getLinkedTodo(ctx: SearchContext) {
346
347
  if (todoCache.has(ctx.record)) {
347
348
  return todoCache.get(ctx.record)
348
349
  }
349
- const sourceRaw = typeof ctx.record.todo_source === 'string' ? ctx.record.todo_source : 'example:todo'
350
+ const sourceRaw = typeof ctx.record.todo_source === 'string' ? ctx.record.todo_source : EXAMPLE_TODO_SOURCE
350
351
  const [moduleId, entityName] = sourceRaw.split(':')
351
- const entityId = moduleId && entityName ? `${moduleId}:${entityName}` : 'example:todo'
352
+ const entityId = moduleId && entityName ? `${moduleId}:${entityName}` : CUSTOMER_INTERACTION_TASK_SOURCE
352
353
  const todo = await loadRecord(ctx, entityId, ctx.record.todo_id as string ?? ctx.record.todoId as string)
353
354
  todoCache.set(ctx.record, todo ?? null)
354
355
  return todo ?? null
@@ -7,6 +7,8 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
7
7
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
8
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
9
  import { DEFAULT_SETTINGS, hydrateCustomerTodoSettings, type CustomerTodoWidgetSettings } from './config'
10
+ import { resolveExampleIntegrationHref } from '../../../lib/interactionCompatibility'
11
+ import { resolveTodoHref } from '../../../components/detail/utils'
10
12
 
11
13
  type TodoLinkSummary = {
12
14
  id: string
@@ -14,6 +16,12 @@ type TodoLinkSummary = {
14
16
  todoSource: string
15
17
  todoTitle: string | null
16
18
  createdAt: string
19
+ _integrations?: {
20
+ example?: {
21
+ href?: string | null
22
+ }
23
+ [key: string]: unknown
24
+ }
17
25
  entity: {
18
26
  id: string | null
19
27
  displayName: string | null
@@ -21,23 +29,6 @@ type TodoLinkSummary = {
21
29
  }
22
30
  }
23
31
 
24
- // SPEC-046b: To enable canonical interactions mode, switch from
25
- // /api/customers/dashboard/widgets/customer-todos to
26
- // /api/customers/interactions?status=planned&pageSize={pageSize}
27
- //
28
- // Response mapping (InteractionSummary → TodoLinkSummary):
29
- // interaction.id → id, todoId
30
- // interaction.interactionType → todoSource
31
- // interaction.title → todoTitle
32
- // interaction.createdAt → createdAt
33
- // interaction.entityId → entity.id (kind/displayName need enrichment
34
- // or a follow-up customer lookup)
35
- //
36
- // The main gap is the `entity` sub-object: the interactions API returns a flat
37
- // entityId but not customer displayName/kind. Options:
38
- // a) Add an enricher to the interactions list API that resolves entity details
39
- // b) Batch-fetch customer details client-side after loading interactions
40
- // c) Add a dedicated /api/customers/interactions/widget endpoint
41
32
  async function loadTodos(settings: CustomerTodoWidgetSettings): Promise<TodoLinkSummary[]> {
42
33
  const params = new URLSearchParams({
43
34
  limit: String(settings.pageSize),
@@ -67,6 +58,9 @@ async function loadTodos(settings: CustomerTodoWidgetSettings): Promise<TodoLink
67
58
  todoSource: typeof data.todoSource === 'string' ? data.todoSource : '',
68
59
  todoTitle: typeof data.todoTitle === 'string' ? data.todoTitle : null,
69
60
  createdAt: typeof data.createdAt === 'string' ? data.createdAt : '',
61
+ _integrations: data._integrations && typeof data._integrations === 'object'
62
+ ? (data._integrations as TodoLinkSummary['_integrations'])
63
+ : undefined,
70
64
  entity: {
71
65
  id: typeof entity.id === 'string' ? entity.id : null,
72
66
  displayName: typeof entity.displayName === 'string' ? entity.displayName : null,
@@ -84,8 +78,8 @@ function formatDate(value: string | null, locale?: string): string {
84
78
  return date.toLocaleString(locale ?? undefined)
85
79
  }
86
80
 
87
- function resolveDetailHref(entity: { id: string | null; kind: string | null }): string | null {
88
- if (!entity.id) return null
81
+ function resolveDetailHref(entity: { id: string | null; kind: string | null } | null | undefined): string | null {
82
+ if (!entity?.id) return null
89
83
  if (entity.kind === 'company') return `/backend/customers/companies-v2/${encodeURIComponent(entity.id)}`
90
84
  if (entity.kind === 'person') return `/backend/customers/people-v2/${encodeURIComponent(entity.id)}`
91
85
  return `/backend/customers/people-v2/${encodeURIComponent(entity.id)}`
@@ -171,6 +165,8 @@ const CustomerTodosWidget: React.FC<DashboardWidgetComponentProps<CustomerTodoWi
171
165
  {items.map((item) => {
172
166
  const createdLabel = formatDate(item.createdAt, locale)
173
167
  const href = resolveDetailHref(item.entity)
168
+ const exampleHref = resolveExampleIntegrationHref(item)
169
+ const taskHref = exampleHref ?? resolveTodoHref(item.todoSource, item.todoId)
174
170
  return (
175
171
  <li key={item.id} className="rounded-md border p-3">
176
172
  <div className="flex items-start justify-between gap-3 text-sm font-medium">
@@ -185,13 +181,18 @@ const CustomerTodosWidget: React.FC<DashboardWidgetComponentProps<CustomerTodoWi
185
181
  <p className="text-xs text-muted-foreground">{item.todoSource}</p>
186
182
  ) : null}
187
183
  </div>
188
- {href ? (
189
- <div className="mt-2 text-xs">
184
+ <div className="mt-2 flex flex-wrap gap-3 text-xs">
185
+ {href ? (
190
186
  <Link className="text-primary hover:underline" href={href}>
191
187
  {t('customers.widgets.common.viewRecord')}
192
188
  </Link>
193
- </div>
194
- ) : null}
189
+ ) : null}
190
+ {taskHref ? (
191
+ <Link className="text-primary hover:underline" href={taskHref}>
192
+ {t('customers.workPlan.customerTodos.table.actions.openTask')}
193
+ </Link>
194
+ ) : null}
195
+ </div>
195
196
  </li>
196
197
  )
197
198
  })}