@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.5

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 (237) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bootstrap.js +46 -6
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/organization/index.js +2 -0
  5. package/dist/generated/entities/organization/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/crmFixtures.js +4 -0
  9. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +2 -0
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/lib/access.js +18 -0
  13. package/dist/modules/attachments/lib/access.js.map +2 -2
  14. package/dist/modules/audit_logs/data/entities.js +2 -1
  15. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  16. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  17. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  18. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  19. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  20. package/dist/modules/auth/api/admin/nav.js +9 -0
  21. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  22. package/dist/modules/auth/api/login.js +4 -13
  23. package/dist/modules/auth/api/login.js.map +2 -2
  24. package/dist/modules/auth/data/entities.js +3 -1
  25. package/dist/modules/auth/data/entities.js.map +2 -2
  26. package/dist/modules/auth/lib/backendChrome.js +35 -2
  27. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  28. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  29. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  30. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  31. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  32. package/dist/modules/auth/services/authService.js +5 -3
  33. package/dist/modules/auth/services/authService.js.map +2 -2
  34. package/dist/modules/auth/services/rbacService.js +3 -2
  35. package/dist/modules/auth/services/rbacService.js.map +2 -2
  36. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  37. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  38. package/dist/modules/customers/api/deals/route.js +43 -2
  39. package/dist/modules/customers/api/deals/route.js.map +2 -2
  40. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  41. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  45. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  46. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  47. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  48. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  49. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  50. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  51. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  52. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  53. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  54. package/dist/modules/customers/cli.js +15 -9
  55. package/dist/modules/customers/cli.js.map +2 -2
  56. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  57. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  58. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  59. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  60. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  61. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  62. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  63. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  64. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  65. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  66. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  67. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  68. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  69. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  70. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  71. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  72. package/dist/modules/directory/api/organizations/route.js +7 -0
  73. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  74. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  75. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  76. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  77. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  78. package/dist/modules/directory/commands/organizations.js +8 -1
  79. package/dist/modules/directory/commands/organizations.js.map +2 -2
  80. package/dist/modules/directory/data/entities.js +3 -0
  81. package/dist/modules/directory/data/entities.js.map +2 -2
  82. package/dist/modules/directory/data/validators.js +9 -0
  83. package/dist/modules/directory/data/validators.js.map +2 -2
  84. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  85. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  86. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  87. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  88. package/dist/modules/directory/utils/organizationScope.js +59 -27
  89. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  90. package/dist/modules/entities/api/definitions.batch.js +2 -1
  91. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  92. package/dist/modules/entities/api/entities.js +7 -0
  93. package/dist/modules/entities/api/entities.js.map +2 -2
  94. package/dist/modules/entities/api/records.js +26 -15
  95. package/dist/modules/entities/api/records.js.map +2 -2
  96. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  97. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  98. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  99. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  100. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  101. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  102. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  103. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  104. package/dist/modules/query_index/data/entities.js +2 -1
  105. package/dist/modules/query_index/data/entities.js.map +2 -2
  106. package/dist/modules/query_index/lib/engine.js +4 -2
  107. package/dist/modules/query_index/lib/engine.js.map +2 -2
  108. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  109. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  110. package/dist/modules/sales/commands/documents.js +7 -5
  111. package/dist/modules/sales/commands/documents.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  113. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  114. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  115. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  116. package/dist/modules/staff/api/team-members.js +9 -2
  117. package/dist/modules/staff/api/team-members.js.map +2 -2
  118. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  119. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  120. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  121. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  122. package/dist/modules/staff/commands/team-members.js +1 -1
  123. package/dist/modules/staff/commands/team-members.js.map +2 -2
  124. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  125. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  126. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  127. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  128. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  129. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  130. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  131. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  132. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  133. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  134. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  135. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  136. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  137. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  138. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  139. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  140. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  141. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  142. package/generated/entities/organization/index.ts +1 -0
  143. package/generated/entity-fields-registry.ts +1 -0
  144. package/package.json +11 -12
  145. package/src/bootstrap.ts +65 -7
  146. package/src/helpers/integration/crmFixtures.ts +21 -1
  147. package/src/modules/attachments/AGENTS.md +79 -0
  148. package/src/modules/attachments/api/route.ts +2 -0
  149. package/src/modules/attachments/lib/access.ts +36 -0
  150. package/src/modules/audit_logs/data/entities.ts +1 -0
  151. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  152. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  153. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  154. package/src/modules/auth/api/admin/nav.ts +9 -0
  155. package/src/modules/auth/api/login.ts +13 -13
  156. package/src/modules/auth/data/entities.ts +2 -0
  157. package/src/modules/auth/i18n/de.json +0 -1
  158. package/src/modules/auth/i18n/en.json +0 -1
  159. package/src/modules/auth/i18n/es.json +0 -1
  160. package/src/modules/auth/i18n/pl.json +0 -1
  161. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  162. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  163. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -0
  164. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  165. package/src/modules/auth/services/authService.ts +24 -4
  166. package/src/modules/auth/services/rbacService.ts +11 -2
  167. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  168. package/src/modules/customers/api/deals/route.ts +51 -2
  169. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  170. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  171. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  172. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  173. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  174. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  175. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  176. package/src/modules/customers/cli.ts +15 -15
  177. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  178. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  179. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  180. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  181. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  182. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  183. package/src/modules/customers/i18n/de.json +43 -0
  184. package/src/modules/customers/i18n/en.json +43 -0
  185. package/src/modules/customers/i18n/es.json +43 -0
  186. package/src/modules/customers/i18n/pl.json +43 -0
  187. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  188. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  189. package/src/modules/directory/api/organizations/route.ts +7 -0
  190. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  191. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  192. package/src/modules/directory/commands/organizations.ts +9 -1
  193. package/src/modules/directory/data/entities.ts +3 -0
  194. package/src/modules/directory/data/validators.ts +12 -0
  195. package/src/modules/directory/i18n/de.json +21 -0
  196. package/src/modules/directory/i18n/en.json +21 -0
  197. package/src/modules/directory/i18n/es.json +21 -0
  198. package/src/modules/directory/i18n/pl.json +21 -0
  199. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  200. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  201. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  202. package/src/modules/directory/utils/organizationScope.ts +85 -30
  203. package/src/modules/entities/api/definitions.batch.ts +11 -7
  204. package/src/modules/entities/api/entities.ts +11 -0
  205. package/src/modules/entities/api/records.ts +46 -25
  206. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  207. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  208. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  209. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  210. package/src/modules/entities/i18n/de.json +1 -0
  211. package/src/modules/entities/i18n/en.json +1 -0
  212. package/src/modules/entities/i18n/es.json +1 -0
  213. package/src/modules/entities/i18n/pl.json +1 -0
  214. package/src/modules/query_index/data/entities.ts +1 -0
  215. package/src/modules/query_index/lib/engine.ts +11 -5
  216. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  217. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  218. package/src/modules/sales/commands/documents.ts +7 -5
  219. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  220. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  221. package/src/modules/staff/api/team-members.ts +9 -2
  222. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  223. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  224. package/src/modules/staff/commands/team-members.ts +5 -2
  225. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  226. package/src/modules/staff/i18n/de.json +1 -0
  227. package/src/modules/staff/i18n/en.json +1 -0
  228. package/src/modules/staff/i18n/es.json +1 -0
  229. package/src/modules/staff/i18n/pl.json +1 -0
  230. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  231. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  232. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  233. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  234. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  235. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  236. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  237. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -3,13 +3,40 @@ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import type { DealDetailPayload } from './types'
5
5
 
6
+ type LoadDataOptions = {
7
+ cache?: boolean
8
+ }
9
+
6
10
  type UseDealDataResult = {
7
11
  data: DealDetailPayload | null
8
12
  setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>
9
13
  isLoading: boolean
10
14
  error: string | null
11
15
  isNotFound: boolean
12
- loadData: () => Promise<void>
16
+ loadData: (options?: LoadDataOptions) => Promise<void>
17
+ }
18
+
19
+ type DealDataCacheEntry = {
20
+ promise: Promise<DealDetailPayload>
21
+ }
22
+
23
+ const dealDataCache = new Map<string, DealDataCacheEntry>()
24
+
25
+ function fetchDealData(id: string, errorMessage: string, useCache: boolean): Promise<DealDetailPayload> {
26
+ const url = `/api/customers/deals/${encodeURIComponent(id)}?include=stages&view=lite`
27
+ const cached = dealDataCache.get(url)
28
+ if (useCache && cached) return cached.promise
29
+ const entry: DealDataCacheEntry = {
30
+ promise: readApiResultOrThrow<DealDetailPayload>(
31
+ url,
32
+ undefined,
33
+ { errorMessage },
34
+ ),
35
+ }
36
+ if (useCache) dealDataCache.set(url, entry)
37
+ return entry.promise.finally(() => {
38
+ if (dealDataCache.get(url) === entry) dealDataCache.delete(url)
39
+ })
13
40
  }
14
41
 
15
42
  export function useDealData(id: string): UseDealDataResult {
@@ -20,7 +47,7 @@ export function useDealData(id: string): UseDealDataResult {
20
47
  const [isNotFound, setIsNotFound] = React.useState(false)
21
48
  const initialLoadDoneRef = React.useRef(false)
22
49
 
23
- const loadData = React.useCallback(async () => {
50
+ const loadData = React.useCallback(async (options: LoadDataOptions = {}) => {
24
51
  if (!id) {
25
52
  setIsNotFound(true)
26
53
  setIsLoading(false)
@@ -31,10 +58,10 @@ export function useDealData(id: string): UseDealDataResult {
31
58
  }
32
59
  setError(null)
33
60
  try {
34
- const payload = await readApiResultOrThrow<DealDetailPayload>(
35
- `/api/customers/deals/${encodeURIComponent(id)}?include=stages&view=lite`,
36
- undefined,
37
- { errorMessage: t('customers.deals.detail.error.load', 'Failed to load deal.') },
61
+ const payload = await fetchDealData(
62
+ id,
63
+ t('customers.deals.detail.error.load', 'Failed to load deal.'),
64
+ options.cache === true,
38
65
  )
39
66
  setData(payload)
40
67
  } catch (loadError) {
@@ -105,7 +105,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
105
105
  } = useDealActivities({ dealId: id, runMutationWithContext })
106
106
 
107
107
  React.useEffect(() => {
108
- void Promise.all([loadData(), loadPlannedActivities()])
108
+ void Promise.all([loadData({ cache: true }), loadPlannedActivities({ cache: true })])
109
109
  }, [loadData, loadPlannedActivities])
110
110
 
111
111
  const activityEntities = React.useMemo(
@@ -294,6 +294,20 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
294
294
  })
295
295
  }, [closeLostPopup, data, openScheduleEdit, selectedActivityEntity, t])
296
296
 
297
+ const currentPipelineName = data
298
+ ? data.pipelineName ?? wonStats?.pipelineName ?? lostStats?.pipelineName ?? null
299
+ : wonStats?.pipelineName ?? lostStats?.pipelineName ?? null
300
+ const formPipelineOptions = React.useMemo(
301
+ () => data?.deal.pipelineId
302
+ ? [{
303
+ id: data.deal.pipelineId,
304
+ name: currentPipelineName ?? t('customers.deals.detail.pipeline.defaultName', 'Current pipeline'),
305
+ isDefault: false,
306
+ }]
307
+ : [],
308
+ [currentPipelineName, data?.deal.pipelineId, t],
309
+ )
310
+
297
311
  if (isLoading) {
298
312
  return (
299
313
  <Page>
@@ -338,7 +352,6 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
338
352
  }
339
353
 
340
354
  const amountLabel = formatCurrency(data.deal.valueAmount, data.deal.valueCurrency)
341
- const currentPipelineName = data.pipelineName ?? wonStats?.pipelineName ?? lostStats?.pipelineName ?? null
342
355
  const dealName = data.deal.title || t('customers.deals.detail.untitled', 'Untitled deal')
343
356
 
344
357
  const zone1Content = (
@@ -353,6 +366,8 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
353
366
  showVersionHistory={false}
354
367
  showCancelAction={false}
355
368
  onDirtyChange={setIsDirty}
369
+ initialPipelineOptions={formPipelineOptions}
370
+ initialPipelineStageOptions={data.pipelineStages}
356
371
  collapsibleGroups={{ pageType: 'deal-detail-v3', chevronPosition: 'right' }}
357
372
  sortableGroups={{ pageType: 'deal-detail-v3' }}
358
373
  initialValues={{
@@ -21,13 +21,20 @@ import { coalesceLastOperations } from '@open-mercato/ui/backend/operations/stor
21
21
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
22
22
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
23
23
  import { Button } from '@open-mercato/ui/primitives/button'
24
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
25
+ import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
26
+ import { Avatar, AvatarStack } from '@open-mercato/ui/primitives/avatar'
27
+ import { Tag } from '@open-mercato/ui/primitives/tag'
28
+ import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'
29
+ import { Briefcase, AlertTriangle, X } from 'lucide-react'
30
+ import { formatRelativeTime } from '@open-mercato/shared/lib/time'
24
31
  import { ViewTabsRow } from './pipeline/components/ViewTabsRow'
32
+ import { DealsKpiStrip } from '../../../components/DealsKpiStrip'
25
33
  import { E } from '#generated/entities.ids.generated'
26
34
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
27
35
  import { useT } from '@open-mercato/shared/lib/i18n/context'
28
36
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
29
37
  import {
30
- DictionaryValue,
31
38
  type CustomerDictionaryKind,
32
39
  type CustomerDictionaryMap,
33
40
  } from '../../../lib/dictionaries'
@@ -149,20 +156,6 @@ function extractIdsFromParams(params: URLSearchParams | null | undefined, key: s
149
156
  return normalizeIdCandidates(values)
150
157
  }
151
158
 
152
- function formatCurrency(amount: number | null | undefined, currency: string | null | undefined, fallback: string): string {
153
- if (typeof amount !== 'number' || Number.isNaN(amount)) return fallback
154
- try {
155
- if (currency && currency.trim().length) {
156
- const formatter = new Intl.NumberFormat(undefined, { style: 'currency', currency })
157
- return formatter.format(amount)
158
- }
159
- const formatter = new Intl.NumberFormat(undefined, { style: 'decimal', maximumFractionDigits: 2 })
160
- return formatter.format(amount)
161
- } catch {
162
- return currency ? `${amount} ${currency}` : String(amount)
163
- }
164
- }
165
-
166
159
  function formatDateValue(value: string | null | undefined, fallback: string): string {
167
160
  if (!value) return fallback
168
161
  const date = new Date(value)
@@ -170,6 +163,30 @@ function formatDateValue(value: string | null | undefined, fallback: string): st
170
163
  return date.toLocaleDateString()
171
164
  }
172
165
 
166
+ const STATUS_BADGE_VARIANTS: ReadonlySet<StatusBadgeVariant> = new Set([
167
+ 'success',
168
+ 'warning',
169
+ 'error',
170
+ 'info',
171
+ 'neutral',
172
+ ])
173
+
174
+ function coerceStatusBadgeVariant(
175
+ tone: ReturnType<typeof mapDictionaryColorToTone>,
176
+ ): StatusBadgeVariant {
177
+ if (tone && STATUS_BADGE_VARIANTS.has(tone as StatusBadgeVariant)) {
178
+ return tone as StatusBadgeVariant
179
+ }
180
+ return 'neutral'
181
+ }
182
+
183
+ const groupedAmountFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
184
+
185
+ function formatGroupedAmount(amount: number | null | undefined): string | null {
186
+ if (typeof amount !== 'number' || Number.isNaN(amount)) return null
187
+ return groupedAmountFormatter.format(amount)
188
+ }
189
+
173
190
  export default function CustomersDealsPage() {
174
191
  const t = useT()
175
192
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
@@ -192,6 +209,7 @@ export default function CustomersDealsPage() {
192
209
  const [isLoading, setIsLoading] = React.useState(false)
193
210
  const [reloadToken, setReloadToken] = React.useState(0)
194
211
  const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
212
+ const [needsAttentionOnly, setNeedsAttentionOnly] = React.useState(() => searchParams?.get('needsAttention') === 'true')
195
213
  // One-shot URL hydration used as the hook's initial value. The hook is the
196
214
  // single source of truth from this point on — the page MUST NOT keep a
197
215
  // parallel `useState<AdvancedFilterTree>` (see spec "Migration & Backward
@@ -323,12 +341,13 @@ export default function CustomersDealsPage() {
323
341
  if (search.trim().length) params.set('search', search.trim())
324
342
  if (selectedPersonIds.length) params.set('personId', selectedPersonIds.join(','))
325
343
  if (selectedCompanyIds.length) params.set('companyId', selectedCompanyIds.join(','))
344
+ if (needsAttentionOnly) params.set('needsAttention', 'true')
326
345
  const advancedParams = serializeTree(advancedFilterState)
327
346
  for (const [key, val] of Object.entries(advancedParams)) {
328
347
  params.set(key, val)
329
348
  }
330
349
  return params.toString()
331
- }, [advancedFilterState, page, pageSize, search, selectedCompanyIds, selectedPersonIds, sorting])
350
+ }, [advancedFilterState, needsAttentionOnly, page, pageSize, search, selectedCompanyIds, selectedPersonIds, sorting])
332
351
 
333
352
  const currentParams = React.useMemo(
334
353
  () => Object.fromEntries(new URLSearchParams(queryParams)),
@@ -400,6 +419,7 @@ export default function CustomersDealsPage() {
400
419
  if (search.trim().length) params.set('search', search.trim())
401
420
  if (selectedPersonIds.length) selectedPersonIds.forEach((id) => params.append('personId', id))
402
421
  if (selectedCompanyIds.length) selectedCompanyIds.forEach((id) => params.append('companyId', id))
422
+ if (needsAttentionOnly) params.set('needsAttention', 'true')
403
423
  if (page > 1) params.set('page', String(page))
404
424
  const advancedParams = serializeTree(advancedFilterState)
405
425
  for (const [key, val] of Object.entries(advancedParams)) {
@@ -409,7 +429,7 @@ export default function CustomersDealsPage() {
409
429
  if (queryRef.current === next) return
410
430
  queryRef.current = next
411
431
  router.replace(next ? `${pathname}?${next}` : pathname, { scroll: false })
412
- }, [pathname, router, page, search, selectedPersonIds, selectedCompanyIds, advancedFilterState])
432
+ }, [pathname, router, page, search, selectedPersonIds, selectedCompanyIds, needsAttentionOnly, advancedFilterState])
413
433
 
414
434
  const handleRefresh = React.useCallback(() => {
415
435
  void Promise.all([
@@ -498,6 +518,16 @@ export default function CustomersDealsPage() {
498
518
  setPage(1)
499
519
  }, [])
500
520
 
521
+ const handleNeedsAttentionFilter = React.useCallback(() => {
522
+ setNeedsAttentionOnly(true)
523
+ setPage(1)
524
+ }, [])
525
+
526
+ const handleNeedsAttentionClear = React.useCallback(() => {
527
+ setNeedsAttentionOnly(false)
528
+ setPage(1)
529
+ }, [])
530
+
501
531
  const handleBulkDelete = React.useCallback(async (selectedRows: DealRow[]) => {
502
532
  const confirmed = await confirm({
503
533
  title: t('customers.deals.list.bulkDelete.title', 'Delete {count} deals?', { count: selectedRows.length }),
@@ -586,15 +616,27 @@ export default function CustomersDealsPage() {
586
616
  })
587
617
  const currentUserId = useCurrentUserId()
588
618
  const [ownerFilterOptions, setOwnerFilterOptions] = React.useState<AdvancedFilterOption[]>([])
619
+ // Single staff load drives both the owner FILTER options and the owner-name
620
+ // map shared with the OWNER cell + the KPI strip (userId → display name).
621
+ // No per-row fetch — see spec audit "Owner names" resolution.
622
+ const [ownerNames, setOwnerNames] = React.useState<Record<string, string>>({})
589
623
  React.useEffect(() => {
590
624
  const controller = new AbortController()
591
625
  let cancelled = false
592
626
  void fetchAssignableStaffMembers('', { pageSize: 100, signal: controller.signal })
593
627
  .then((items) => {
594
- if (!cancelled) setOwnerFilterOptions(mapAssignableStaffToFilterOptions(items))
628
+ if (cancelled) return
629
+ setOwnerFilterOptions(mapAssignableStaffToFilterOptions(items))
630
+ const names: Record<string, string> = {}
631
+ for (const item of items) {
632
+ if (item.userId) names[item.userId] = item.displayName
633
+ }
634
+ setOwnerNames(names)
595
635
  })
596
636
  .catch(() => {
597
- if (!cancelled) setOwnerFilterOptions([])
637
+ if (cancelled) return
638
+ setOwnerFilterOptions([])
639
+ setOwnerNames({})
598
640
  })
599
641
  return () => {
600
642
  cancelled = true
@@ -614,32 +656,20 @@ export default function CustomersDealsPage() {
614
656
  return mapAssignableStaffToFilterOptions(items)
615
657
  }, [])
616
658
 
659
+ const startOfToday = React.useMemo(() => {
660
+ const today = new Date()
661
+ today.setHours(0, 0, 0, 0)
662
+ return today
663
+ }, [])
664
+ const isDealOverdue = React.useCallback(
665
+ (row: DealRow): boolean =>
666
+ !!row.expectedCloseAt && new Date(row.expectedCloseAt) < startOfToday && row.status === 'open',
667
+ [startOfToday],
668
+ )
669
+
617
670
  const columns = React.useMemo<ColumnDef<DealRow>[]>(() => {
618
671
  const noValue = <span className="text-muted-foreground text-sm">{t('customers.deals.list.noValue')}</span>
619
- const renderDictionaryCell = (kind: DictionaryKey, value: string | null | undefined) => (
620
- <DictionaryValue
621
- value={value}
622
- map={dictionaryMaps[kind]}
623
- fallback={value ? <span className="text-sm">{value}</span> : noValue}
624
- className="text-sm"
625
- iconWrapperClassName="inline-flex h-6 w-6 items-center justify-center rounded border border-border bg-card"
626
- iconClassName="h-4 w-4"
627
- colorClassName="h-3 w-3 rounded-full"
628
- />
629
- )
630
- const renderAssociationSummary = (
631
- items: { id: string; label: string }[],
632
- fallbackLabel: string,
633
- ) => {
634
- if (!items.length) return noValue
635
- const labels = normalizeCollectionLabels(
636
- items.map((entry) => (entry.label && entry.label.trim().length ? entry.label : fallbackLabel)),
637
- )
638
- if (!labels.length) return noValue
639
- return (
640
- <CollectionPreviewCell labels={labels} maxVisible={1} />
641
- )
642
- }
672
+ const unknownOwner = t('customers.deals.list.unknownOwner')
643
673
 
644
674
  const customColumns = customFieldDefs
645
675
  .filter((def) => supportsCustomFieldColumn(def))
@@ -695,7 +725,20 @@ export default function CustomersDealsPage() {
695
725
  filterGroup: 'Deal',
696
726
  maxWidth: '280px',
697
727
  },
698
- cell: ({ row }) => <span className="font-medium text-sm">{row.original.title}</span>,
728
+ cell: ({ row }) => (
729
+ <div className="flex items-center gap-2 min-w-0">
730
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
731
+ <Briefcase className="h-4 w-4" />
732
+ </span>
733
+ <span className="font-medium text-foreground truncate">{row.original.title}</span>
734
+ {isDealOverdue(row.original) ? (
735
+ <AlertTriangle
736
+ className="h-4 w-4 shrink-0 text-status-warning-text"
737
+ aria-label={t('customers.deals.list.close.overdue')}
738
+ />
739
+ ) : null}
740
+ </div>
741
+ ),
699
742
  },
700
743
  {
701
744
  accessorKey: 'status',
@@ -707,7 +750,14 @@ export default function CustomersDealsPage() {
707
750
  filterKey: 'status',
708
751
  filterGroup: 'Deal',
709
752
  },
710
- cell: ({ row }) => renderDictionaryCell('deal-statuses', row.original.status),
753
+ cell: ({ row }) => {
754
+ const status = row.original.status
755
+ if (!status) return noValue
756
+ const entry = dictionaryMaps['deal-statuses']?.[status]
757
+ const label = entry?.label ?? status
758
+ const variant = coerceStatusBadgeVariant(mapDictionaryColorToTone(entry?.color))
759
+ return <StatusBadge variant={variant} dot>{label}</StatusBadge>
760
+ },
711
761
  },
712
762
  {
713
763
  accessorKey: 'pipelineStage',
@@ -719,7 +769,12 @@ export default function CustomersDealsPage() {
719
769
  filterKey: 'pipeline_stage',
720
770
  filterGroup: 'Deal',
721
771
  },
722
- cell: ({ row }) => renderDictionaryCell('pipeline-stages', row.original.pipelineStage),
772
+ cell: ({ row }) => {
773
+ const stage = row.original.pipelineStage
774
+ if (!stage) return noValue
775
+ const label = dictionaryMaps['pipeline-stages']?.[stage]?.label ?? stage
776
+ return <span className="text-foreground">{label}</span>
777
+ },
723
778
  },
724
779
  {
725
780
  accessorKey: 'pipelineId',
@@ -744,11 +799,17 @@ export default function CustomersDealsPage() {
744
799
  filterKey: 'value_amount',
745
800
  filterGroup: 'Deal',
746
801
  },
747
- cell: ({ row }) => (
748
- <span className="text-sm font-medium">
749
- {formatCurrency(row.original.valueAmount ?? null, row.original.valueCurrency ?? null, t('customers.deals.list.noValue'))}
750
- </span>
751
- ),
802
+ cell: ({ row }) => {
803
+ const amount = formatGroupedAmount(row.original.valueAmount ?? null)
804
+ if (amount === null) return noValue
805
+ const currency = row.original.valueCurrency
806
+ return (
807
+ <div className="flex flex-col">
808
+ <span className="font-medium text-foreground">{amount}</span>
809
+ {currency ? <span className="text-xs text-muted-foreground">{currency}</span> : null}
810
+ </div>
811
+ )
812
+ },
752
813
  },
753
814
  {
754
815
  accessorKey: 'probability',
@@ -762,7 +823,7 @@ export default function CustomersDealsPage() {
762
823
  cell: ({ row }) => {
763
824
  const value = row.original.probability
764
825
  if (typeof value === 'number' && Number.isFinite(value)) {
765
- return <span className="text-sm">{`${Math.min(Math.max(value, 0), 100)}%`}</span>
826
+ return <span className="font-medium text-foreground">{`${Math.min(Math.max(value, 0), 100)}%`}</span>
766
827
  }
767
828
  return noValue
768
829
  },
@@ -776,11 +837,37 @@ export default function CustomersDealsPage() {
776
837
  filterGroup: 'Activity',
777
838
  filterIconName: 'calendar',
778
839
  },
779
- cell: ({ row }) => (
780
- <span className="text-sm">
781
- {formatDateValue(row.original.expectedCloseAt ?? null, t('customers.deals.list.noValue'))}
782
- </span>
783
- ),
840
+ cell: ({ row }) => {
841
+ const expectedCloseAt = row.original.expectedCloseAt
842
+ if (!expectedCloseAt) return noValue
843
+ let subtitle: React.ReactNode = null
844
+ if (isDealOverdue(row.original)) {
845
+ subtitle = (
846
+ <span className="text-xs text-status-error-text">{t('customers.deals.list.close.overdue')}</span>
847
+ )
848
+ } else if (row.original.status === 'win') {
849
+ subtitle = (
850
+ <span className="text-xs text-muted-foreground">{t('customers.deals.list.close.won')}</span>
851
+ )
852
+ } else if (row.original.status === 'loose') {
853
+ subtitle = (
854
+ <span className="text-xs text-muted-foreground">{t('customers.deals.list.close.lost')}</span>
855
+ )
856
+ } else {
857
+ const relative = formatRelativeTime(expectedCloseAt, { translate: t })
858
+ if (relative) {
859
+ subtitle = <span className="text-xs text-muted-foreground">{relative}</span>
860
+ }
861
+ }
862
+ return (
863
+ <div className="flex flex-col">
864
+ <span className="text-foreground">
865
+ {formatDateValue(expectedCloseAt, t('customers.deals.list.noValue'))}
866
+ </span>
867
+ {subtitle}
868
+ </div>
869
+ )
870
+ },
784
871
  },
785
872
  {
786
873
  accessorKey: 'ownerUserId',
@@ -793,9 +880,18 @@ export default function CustomersDealsPage() {
793
880
  filterGroup: 'CRM',
794
881
  filterIconName: 'user-round',
795
882
  filterKey: 'owner_user_id',
796
- hidden: true,
797
883
  },
798
- cell: ({ row }) => row.original.ownerUserId ?? null,
884
+ cell: ({ row }) => {
885
+ const ownerUserId = row.original.ownerUserId
886
+ if (!ownerUserId) return noValue
887
+ const label = ownerNames[ownerUserId]?.trim() || unknownOwner
888
+ return (
889
+ <div className="flex items-center gap-2 min-w-0">
890
+ <Avatar label={label} size="sm" />
891
+ <span className="text-foreground truncate">{label}</span>
892
+ </div>
893
+ )
894
+ },
799
895
  },
800
896
  {
801
897
  accessorKey: 'companies',
@@ -811,7 +907,22 @@ export default function CustomersDealsPage() {
811
907
  row.companies.map((entry) => (entry.label && entry.label.trim().length ? entry.label : t('customers.deals.list.unnamedCompany'))),
812
908
  ).join(', '),
813
909
  },
814
- cell: ({ row }) => renderAssociationSummary(row.original.companies, t('customers.deals.list.unnamedCompany')),
910
+ cell: ({ row }) => {
911
+ const companies = row.original.companies
912
+ if (!companies.length) return noValue
913
+ const first = companies[0]
914
+ const firstLabel =
915
+ first.label && first.label.trim().length ? first.label : t('customers.deals.list.unnamedCompany')
916
+ const overflow = companies.length - 1
917
+ return (
918
+ <div className="flex items-center gap-1.5 min-w-0">
919
+ <Tag variant="neutral" className="max-w-36">
920
+ <span className="truncate">{firstLabel}</span>
921
+ </Tag>
922
+ {overflow > 0 ? <Tag variant="neutral">{`+${overflow}`}</Tag> : null}
923
+ </div>
924
+ )
925
+ },
815
926
  },
816
927
  {
817
928
  accessorKey: 'people',
@@ -827,7 +938,30 @@ export default function CustomersDealsPage() {
827
938
  row.people.map((entry) => (entry.label && entry.label.trim().length ? entry.label : t('customers.deals.list.unnamedPerson'))),
828
939
  ).join(', '),
829
940
  },
830
- cell: ({ row }) => renderAssociationSummary(row.original.people, t('customers.deals.list.unnamedPerson')),
941
+ cell: ({ row }) => {
942
+ const people = row.original.people
943
+ if (!people.length) return noValue
944
+ const labels = normalizeCollectionLabels(
945
+ people.map((person) =>
946
+ person.label && person.label.trim().length ? person.label : t('customers.deals.list.unnamedPerson')),
947
+ )
948
+ const tooltip = labels.join(', ')
949
+ return (
950
+ <SimpleTooltip content={tooltip} side="top">
951
+ <span className="inline-flex">
952
+ <AvatarStack max={4} size="sm">
953
+ {people.map((person) => (
954
+ <Avatar
955
+ key={person.id}
956
+ label={person.label || t('customers.deals.list.unnamedPerson')}
957
+ size="sm"
958
+ />
959
+ ))}
960
+ </AvatarStack>
961
+ </span>
962
+ </SimpleTooltip>
963
+ )
964
+ },
831
965
  },
832
966
  {
833
967
  accessorKey: 'updatedAt',
@@ -846,7 +980,7 @@ export default function CustomersDealsPage() {
846
980
  },
847
981
  ...customColumns,
848
982
  ]
849
- }, [customFieldDefs, dictionaryMaps, dictionaryOptions, loadOwnerFilterOptions, pipelineNames, resolvedOwnerFilterOptions, t])
983
+ }, [customFieldDefs, dictionaryMaps, dictionaryOptions, isDealOverdue, loadOwnerFilterOptions, ownerNames, pipelineNames, resolvedOwnerFilterOptions, t])
850
984
 
851
985
  const { advancedFilterFields } = useAutoDiscoveredFields({ columns, customFieldDefs })
852
986
 
@@ -920,9 +1054,19 @@ export default function CustomersDealsPage() {
920
1054
  <Page>
921
1055
  <PageBody>
922
1056
  <ViewTabsRow active="list" className="mb-4" />
1057
+ <DealsKpiStrip
1058
+ ownerNames={ownerNames}
1059
+ stageDictionary={dictionaryMaps['pipeline-stages'] ?? {}}
1060
+ pipelineCount={Object.keys(pipelineNames).length}
1061
+ scopeVersion={scopeVersion}
1062
+ reloadToken={reloadToken}
1063
+ onNeedsAttentionClick={handleNeedsAttentionFilter}
1064
+ className="mb-4"
1065
+ />
923
1066
  <DataTable<DealRow>
924
1067
  stickyFirstColumn
925
1068
  stickyActionsColumn
1069
+ actionsColumnAlign="center"
926
1070
  title={t('customers.deals.list.title')}
927
1071
  actions={(
928
1072
  <Button asChild>
@@ -1016,6 +1160,29 @@ export default function CustomersDealsPage() {
1016
1160
  }}
1017
1161
  activeFilterChips={(
1018
1162
  <>
1163
+ {needsAttentionOnly ? (
1164
+ <div
1165
+ className="flex items-center gap-2 overflow-x-auto border-b border-border bg-background px-4 py-2"
1166
+ data-testid="active-filter-chips"
1167
+ >
1168
+ <div
1169
+ className="inline-flex items-center gap-1"
1170
+ data-testid="active-filter-chip"
1171
+ aria-label={t('customers.deals.list.filters.needsAttention')}
1172
+ >
1173
+ <Tag variant="warning" dot>{t('customers.deals.list.filters.needsAttention')}</Tag>
1174
+ <IconButton
1175
+ type="button"
1176
+ variant="ghost"
1177
+ size="xs"
1178
+ aria-label={t('customers.deals.list.filters.needsAttentionRemove')}
1179
+ onClick={handleNeedsAttentionClear}
1180
+ >
1181
+ <X className="size-3" />
1182
+ </IconButton>
1183
+ </div>
1184
+ </div>
1185
+ ) : null}
1019
1186
  <ActiveFilterChips
1020
1187
  tree={associationFilterTree}
1021
1188
  fields={associationFilterFields}
@@ -1033,11 +1200,32 @@ export default function CustomersDealsPage() {
1033
1200
  </>
1034
1201
  )}
1035
1202
  filterAwareEmptyState={{
1036
- active: advancedFilterState.root.children.length > 0,
1203
+ active: needsAttentionOnly || associationFilterTree.root.children.length > 0 || advancedFilterState.root.children.length > 0,
1037
1204
  entityNamePlural: t('customers.deals.entityPlural', 'deals'),
1038
- canRemoveLast: filterPanel.tree.root.children.length > 0,
1039
- onClearAll: handleAdvancedFilterClear,
1040
- onRemoveLast: () => filterPanel.dispatch({ type: 'removeLast' }),
1205
+ canRemoveLast: needsAttentionOnly || associationFilterTree.root.children.length > 0 || filterPanel.tree.root.children.length > 0,
1206
+ onClearAll: () => {
1207
+ handleAdvancedFilterClear()
1208
+ setSelectedPersonIds([])
1209
+ setSelectedCompanyIds([])
1210
+ setNeedsAttentionOnly(false)
1211
+ },
1212
+ onRemoveLast: () => {
1213
+ if (needsAttentionOnly) {
1214
+ handleNeedsAttentionClear()
1215
+ return
1216
+ }
1217
+ if (selectedCompanyIds.length > 0) {
1218
+ setSelectedCompanyIds([])
1219
+ setPage(1)
1220
+ return
1221
+ }
1222
+ if (selectedPersonIds.length > 0) {
1223
+ setSelectedPersonIds([])
1224
+ setPage(1)
1225
+ return
1226
+ }
1227
+ filterPanel.dispatch({ type: 'removeLast' })
1228
+ },
1041
1229
  }}
1042
1230
  emptyState={(
1043
1231
  <ListEmptyState
@@ -2542,6 +2542,7 @@ export default function DealsKanbanPage(): React.ReactElement {
2542
2542
  return (
2543
2543
  <Page>
2544
2544
  <PageBody>
2545
+ <ViewTabsRow active="kanban" className="mb-4" />
2545
2546
  <div className="flex flex-col gap-2">
2546
2547
  <Breadcrumb>
2547
2548
  <BreadcrumbList>
@@ -2710,8 +2711,6 @@ export default function DealsKanbanPage(): React.ReactElement {
2710
2711
  ) : null}
2711
2712
  </div>
2712
2713
 
2713
- <ViewTabsRow active="kanban" className="mt-4" />
2714
-
2715
2714
  <FilterBarRow
2716
2715
  leadingChips={leadingChipsNode}
2717
2716
  chips={filterChips}
@@ -37,6 +37,7 @@ import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../
37
37
  import { PersonDetailHeader } from '../../../../components/detail/PersonDetailHeader'
38
38
  import { ChangelogTab } from '../../../../components/detail/ChangelogTab'
39
39
  import { PersonDetailTabs, resolveLegacyTab, type PersonTabId } from '../../../../components/detail/PersonDetailTabs'
40
+ import { AddressesSection } from '../../../../components/detail/AddressesSection'
40
41
  import { PersonCompaniesSection } from '../../../../components/detail/PersonCompaniesSection'
41
42
  import { MobilePersonDetail } from '../../../../components/detail/MobilePersonDetail'
42
43
  import type { TagsSectionController } from '@open-mercato/ui/backend/detail'
@@ -541,6 +542,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
541
542
  activitiesCount={interactionCount}
542
543
  dealsCount={dealCount}
543
544
  companiesCount={companyCount}
545
+ addressesCount={data?.counts?.addresses ?? 0}
544
546
  tasksCount={todoCount}
545
547
  sectionAction={sectionAction}
546
548
  >
@@ -619,6 +621,22 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
619
621
  )
620
622
  }
621
623
 
624
+ if (activeTab === 'addresses') {
625
+ return (
626
+ <AddressesSection
627
+ entityId={personId}
628
+ emptyLabel={t('customers.people.detail.empty.addresses', 'No addresses linked to this person.')}
629
+ addActionLabel={t('customers.people.detail.addresses.add', 'Add address')}
630
+ emptyState={{
631
+ title: t('customers.people.detail.emptyState.addresses.title', 'No addresses yet'),
632
+ actionLabel: t('customers.people.detail.emptyState.addresses.action', 'Add address'),
633
+ }}
634
+ onActionChange={handleSectionActionChange}
635
+ translator={detailTranslator}
636
+ />
637
+ )
638
+ }
639
+
622
640
  if (activeTab === 'tasks') {
623
641
  return (
624
642
  <TasksSection