@open-mercato/core 0.9.0 → 0.9.1-develop.7459.1.99e3accd30

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 (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
  3. package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
  4. package/dist/generated/entities/customer_interaction/index.js +2 -0
  5. package/dist/generated/entities/customer_interaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/commands/categories.js +1 -1
  9. package/dist/modules/catalog/commands/categories.js.map +2 -2
  10. package/dist/modules/catalog/commands/productDeleteChildren.js +389 -0
  11. package/dist/modules/catalog/commands/productDeleteChildren.js.map +7 -0
  12. package/dist/modules/catalog/commands/products.js +52 -17
  13. package/dist/modules/catalog/commands/products.js.map +2 -2
  14. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +4 -6
  15. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  16. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +4 -6
  17. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +2 -2
  18. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
  19. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
  20. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
  21. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  22. package/dist/modules/communication_channels/lib/inbound-message-origin.js +1 -1
  23. package/dist/modules/communication_channels/lib/inbound-message-origin.js.map +2 -2
  24. package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
  25. package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
  27. package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
  28. package/dist/modules/customers/api/activity-types/route.js +151 -0
  29. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  30. package/dist/modules/customers/api/deals/[id]/people/route.js +58 -5
  31. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  32. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  33. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  34. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  35. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  36. package/dist/modules/customers/api/interactions/route.js +3 -0
  37. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  38. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  39. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  40. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  41. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +0 -23
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/page.js +15 -17
  45. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  46. package/dist/modules/customers/calendar-event-types.js +548 -0
  47. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  48. package/dist/modules/customers/commands/dictionaries.js +70 -10
  49. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  50. package/dist/modules/customers/commands/interactions.js +187 -22
  51. package/dist/modules/customers/commands/interactions.js.map +3 -3
  52. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  53. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  54. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  55. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  56. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  57. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  58. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  59. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  60. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  61. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  62. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  63. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  64. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  65. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  66. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  67. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  68. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  69. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  70. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  71. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  72. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  73. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  74. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  75. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  76. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  77. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  78. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  79. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  80. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  81. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  82. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  83. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  84. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  85. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  86. package/dist/modules/customers/components/calendar/types.js +1 -0
  87. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  88. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +78 -343
  89. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  90. package/dist/modules/customers/components/detail/CreatePersonDialog.js +12 -11
  91. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  92. package/dist/modules/customers/components/detail/DealForm.js +7 -3
  93. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  94. package/dist/modules/customers/components/detail/DealPeopleSection.js +206 -0
  95. package/dist/modules/customers/components/detail/DealPeopleSection.js.map +7 -0
  96. package/dist/modules/customers/components/detail/LinkedPeopleSection.js +342 -0
  97. package/dist/modules/customers/components/detail/LinkedPeopleSection.js.map +7 -0
  98. package/dist/modules/customers/components/detail/PersonCard.js +5 -2
  99. package/dist/modules/customers/components/detail/PersonCard.js.map +2 -2
  100. package/dist/modules/customers/components/linking/adapters/personAdapter.js +3 -0
  101. package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
  102. package/dist/modules/customers/data/entities.js +6 -0
  103. package/dist/modules/customers/data/entities.js.map +2 -2
  104. package/dist/modules/customers/data/validators.js +39 -10
  105. package/dist/modules/customers/data/validators.js.map +2 -2
  106. package/dist/modules/customers/di.js +20 -1
  107. package/dist/modules/customers/di.js.map +2 -2
  108. package/dist/modules/customers/extension-points.js +13 -0
  109. package/dist/modules/customers/extension-points.js.map +2 -2
  110. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  111. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  112. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  113. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  114. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  115. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  116. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  117. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  118. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  119. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  120. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  121. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  122. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  123. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  124. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  125. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  126. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  127. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  128. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  129. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  130. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  131. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  132. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  133. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  134. package/dist/modules/messages/subscribers/message-notification.js +13 -1
  135. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  136. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
  137. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
  138. package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
  139. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  140. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  141. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  142. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  143. package/dist/modules/sales/components/documents/LineItemDialog.js +159 -81
  144. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  145. package/dist/modules/sales/components/documents/lineItemUtils.js +7 -0
  146. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  147. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  148. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  149. package/dist/modules/sync_excel/lib/adapters/customers.js +28 -25
  150. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  151. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  152. package/generated/entities/customer_interaction/index.ts +1 -0
  153. package/generated/entity-fields-registry.ts +2 -0
  154. package/package.json +9 -8
  155. package/src/modules/auth/i18n/de.json +3 -0
  156. package/src/modules/auth/i18n/en.json +3 -0
  157. package/src/modules/auth/i18n/es.json +3 -0
  158. package/src/modules/auth/i18n/ko.json +3 -0
  159. package/src/modules/auth/i18n/pl.json +3 -0
  160. package/src/modules/catalog/commands/categories.ts +1 -1
  161. package/src/modules/catalog/commands/productDeleteChildren.ts +557 -0
  162. package/src/modules/catalog/commands/products.ts +58 -17
  163. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +4 -6
  164. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +4 -6
  165. package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
  166. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
  167. package/src/modules/communication_channels/i18n/de.json +4 -0
  168. package/src/modules/communication_channels/i18n/en.json +4 -0
  169. package/src/modules/communication_channels/i18n/es.json +4 -0
  170. package/src/modules/communication_channels/i18n/ko.json +4 -0
  171. package/src/modules/communication_channels/i18n/pl.json +4 -0
  172. package/src/modules/communication_channels/lib/inbound-message-origin.ts +2 -6
  173. package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
  174. package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
  175. package/src/modules/customers/api/activity-types/route.ts +154 -0
  176. package/src/modules/customers/api/deals/[id]/people/route.ts +82 -3
  177. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  178. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  179. package/src/modules/customers/api/interactions/route.ts +4 -0
  180. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  181. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  182. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +0 -29
  183. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -17
  184. package/src/modules/customers/calendar-event-types.ts +674 -0
  185. package/src/modules/customers/commands/dictionaries.ts +84 -8
  186. package/src/modules/customers/commands/interactions.ts +244 -21
  187. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  188. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  189. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  190. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  191. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  192. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  193. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  194. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  195. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  196. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  197. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  198. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  199. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  200. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  201. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  202. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  203. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  204. package/src/modules/customers/components/calendar/types.ts +1 -0
  205. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +94 -388
  206. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +39 -19
  207. package/src/modules/customers/components/detail/DealForm.tsx +7 -3
  208. package/src/modules/customers/components/detail/DealPeopleSection.tsx +256 -0
  209. package/src/modules/customers/components/detail/LinkedPeopleSection.tsx +474 -0
  210. package/src/modules/customers/components/detail/PersonCard.tsx +12 -4
  211. package/src/modules/customers/components/detail/types.ts +1 -0
  212. package/src/modules/customers/components/linking/adapters/personAdapter.tsx +4 -0
  213. package/src/modules/customers/data/entities.ts +8 -1
  214. package/src/modules/customers/data/validators.ts +42 -7
  215. package/src/modules/customers/di.ts +21 -0
  216. package/src/modules/customers/extension-points.ts +13 -0
  217. package/src/modules/customers/i18n/de.json +104 -8
  218. package/src/modules/customers/i18n/en.json +104 -8
  219. package/src/modules/customers/i18n/es.json +104 -8
  220. package/src/modules/customers/i18n/ko.json +104 -8
  221. package/src/modules/customers/i18n/pl.json +104 -8
  222. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  223. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  224. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  225. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  226. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  227. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  228. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  229. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  230. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  231. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  232. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  233. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  234. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  235. package/src/modules/messages/subscribers/message-notification.ts +13 -3
  236. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
  237. package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
  238. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  239. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  240. package/src/modules/sales/components/documents/LineItemDialog.tsx +194 -111
  241. package/src/modules/sales/components/documents/lineItemUtils.ts +18 -0
  242. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
  243. package/src/modules/sync_excel/lib/adapters/customers.ts +33 -27
@@ -0,0 +1,474 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Users, Link2, Plus, Filter } from 'lucide-react'
5
+ import { EmptyState } from '@open-mercato/ui/backend/EmptyState'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { Badge } from '@open-mercato/ui/primitives/badge'
8
+ import { Input } from '@open-mercato/ui/primitives/input'
9
+ import {
10
+ readVersionedIdSet,
11
+ writeVersionedIdSet,
12
+ } from '@open-mercato/shared/lib/browser/versionedPreference'
13
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+ import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
15
+ import type { TabEmptyStateConfig, Translator } from './types'
16
+ import { PersonCard } from './PersonCard'
17
+ import {
18
+ LinkEntityDialog,
19
+ type LinkEntityAdapter,
20
+ type LinkEntityConfirmInput,
21
+ } from '../linking/LinkEntityDialog'
22
+
23
+ type GuardedMutationRunner = <T>(
24
+ operation: () => Promise<T>,
25
+ mutationPayload?: Record<string, unknown>,
26
+ ) => Promise<T>
27
+
28
+ export type LinkedPersonSummary = {
29
+ id: string
30
+ displayName: string
31
+ primaryEmail?: string | null
32
+ primaryPhone?: string | null
33
+ status?: string | null
34
+ lifecycleStage?: string | null
35
+ jobTitle?: string | null
36
+ department?: string | null
37
+ createdAt?: string | null
38
+ organizationId?: string | null
39
+ temperature?: string | null
40
+ source?: string | null
41
+ linkedAt?: string | null
42
+ }
43
+
44
+ export type LinkedPeopleSortMode = 'name-asc' | 'name-desc' | 'recent'
45
+
46
+ export const ALL_LINKED_PEOPLE_SORTS: LinkedPeopleSortMode[] = ['name-asc', 'name-desc', 'recent']
47
+
48
+ export type LinkedPeoplePage = {
49
+ items?: LinkedPersonSummary[]
50
+ page?: number
51
+ total?: number
52
+ totalPages?: number
53
+ }
54
+
55
+ export type LinkedPeopleSectionProps<
56
+ TDetails = unknown,
57
+ TLinkSettings = Record<string, unknown>,
58
+ > = {
59
+ /** Stable id of the owning record; scopes browser-local preferences such as starred people. */
60
+ scopeId: string
61
+ /** Records the host already knows about, used until the first page load resolves. */
62
+ fallbackPeople?: LinkedPersonSummary[]
63
+ loadPage: (params: {
64
+ page: number
65
+ pageSize: number
66
+ sort: LinkedPeopleSortMode
67
+ search: string
68
+ }) => Promise<LinkedPeoplePage>
69
+ /** Owns messaging and optimistic-lock handling for the unlink write. */
70
+ onUnlink: (personId: string) => Promise<void>
71
+ linkAdapter: LinkEntityAdapter<TDetails, TLinkSettings>
72
+ /** Controlled link-dialog state; falls back to internal state when omitted. */
73
+ linkDialogOpen?: boolean
74
+ onLinkDialogOpenChange?: (open: boolean) => void
75
+ linkedIds?: string[]
76
+ /** Owns messaging for the link write; rejecting keeps the link dialog open. */
77
+ onLinkConfirm: (input: LinkEntityConfirmInput<TLinkSettings>) => Promise<void>
78
+ sectionTitle: string
79
+ sectionSubtitle?: string
80
+ searchPlaceholder: string
81
+ linkActionLabel: string
82
+ addActionLabel?: string
83
+ onAddPerson?: () => void
84
+ emptyLabel: string
85
+ emptyState: TabEmptyStateConfig
86
+ onEmptyStateAction?: () => void
87
+ /**
88
+ * Which sort options to offer, in order. Defaults to all three.
89
+ *
90
+ * A host whose `linkedAt` is not trustworthy must omit `recent`: the deal side does so until
91
+ * `syncDealPeople` stops re-dating every surviving link on each write, because the ordering
92
+ * would otherwise collapse into an arbitrary label tie-break.
93
+ */
94
+ availableSorts?: LinkedPeopleSortMode[]
95
+ /** Forwarded to `PersonCard`; a host without a durable `linkedAt` passes `false`. */
96
+ showLinkedDate?: boolean
97
+ /** Bump to force a reload after the host mutates the links itself. */
98
+ refreshKey?: number
99
+ disabled?: boolean
100
+ translator?: Translator
101
+ runGuardedMutation?: GuardedMutationRunner
102
+ onLoadingChange?: (isLoading: boolean) => void
103
+ header?: React.ReactNode
104
+ renderFooter?: (ctx: {
105
+ people: LinkedPersonSummary[]
106
+ starredIds: Set<string>
107
+ }) => React.ReactNode
108
+ }
109
+
110
+ const LINKED_PEOPLE_PAGE_SIZE = 20
111
+ const STARRED_PEOPLE_STORAGE_VERSION = 1
112
+
113
+ const SORT_LABELS: Record<LinkedPeopleSortMode, { key: string; fallback: string }> = {
114
+ 'name-asc': { key: 'customers.linkedPeople.sortNameAsc', fallback: 'Sort: Name A-Z' },
115
+ 'name-desc': { key: 'customers.linkedPeople.sortNameDesc', fallback: 'Sort: Name Z-A' },
116
+ recent: { key: 'customers.linkedPeople.sortRecent', fallback: 'Sort: Recently linked' },
117
+ }
118
+
119
+ export function LinkedPeopleSection<
120
+ TDetails = unknown,
121
+ TLinkSettings = Record<string, unknown>,
122
+ >({
123
+ scopeId,
124
+ fallbackPeople,
125
+ loadPage,
126
+ onUnlink,
127
+ linkAdapter,
128
+ linkDialogOpen: controlledLinkDialogOpen,
129
+ onLinkDialogOpenChange,
130
+ linkedIds,
131
+ onLinkConfirm,
132
+ sectionTitle,
133
+ sectionSubtitle,
134
+ searchPlaceholder,
135
+ linkActionLabel,
136
+ addActionLabel,
137
+ onAddPerson,
138
+ emptyLabel,
139
+ emptyState,
140
+ onEmptyStateAction,
141
+ availableSorts = ALL_LINKED_PEOPLE_SORTS,
142
+ showLinkedDate = true,
143
+ refreshKey = 0,
144
+ disabled = false,
145
+ translator,
146
+ runGuardedMutation,
147
+ onLoadingChange,
148
+ header,
149
+ renderFooter,
150
+ }: LinkedPeopleSectionProps<TDetails, TLinkSettings>) {
151
+ const tHook = useT()
152
+ const fallbackTranslator = React.useMemo<Translator>(
153
+ () => createTranslatorWithFallback(tHook),
154
+ [tHook],
155
+ )
156
+ const translate: Translator = translator ?? fallbackTranslator
157
+ const knownPeople = React.useMemo(() => fallbackPeople ?? [], [fallbackPeople])
158
+ const sortOptions = availableSorts.length > 0 ? availableSorts : ALL_LINKED_PEOPLE_SORTS
159
+
160
+ const [removingId, setRemovingId] = React.useState<string | null>(null)
161
+ const [internalLinkDialogOpen, setInternalLinkDialogOpen] = React.useState(false)
162
+ const linkDialogOpen = controlledLinkDialogOpen ?? internalLinkDialogOpen
163
+ const setLinkDialogOpen = React.useCallback(
164
+ (open: boolean) => {
165
+ setInternalLinkDialogOpen(open)
166
+ onLinkDialogOpenChange?.(open)
167
+ },
168
+ [onLinkDialogOpenChange],
169
+ )
170
+ const [searchQuery, setSearchQuery] = React.useState('')
171
+ const [sortMode, setSortMode] = React.useState<LinkedPeopleSortMode>(sortOptions[0])
172
+ const [filtersOpen, setFiltersOpen] = React.useState(true)
173
+ const [visiblePeople, setVisiblePeople] = React.useState<LinkedPersonSummary[]>([])
174
+ const [listPage, setListPage] = React.useState(1)
175
+ const [listTotalPages, setListTotalPages] = React.useState(1)
176
+ const [listTotalCount, setListTotalCount] = React.useState(knownPeople.length)
177
+ const [listLoading, setListLoading] = React.useState(true)
178
+ const [starredIds, setStarredIds] = React.useState<Set<string>>(
179
+ () => readVersionedIdSet(`om:starred-people:${scopeId}`, STARRED_PEOPLE_STORAGE_VERSION),
180
+ )
181
+
182
+ const toggleStar = React.useCallback(
183
+ (personId: string) => {
184
+ setStarredIds((prev) => {
185
+ const next = new Set(prev)
186
+ if (next.has(personId)) next.delete(personId)
187
+ else next.add(personId)
188
+ writeVersionedIdSet(`om:starred-people:${scopeId}`, STARRED_PEOPLE_STORAGE_VERSION, next)
189
+ return next
190
+ })
191
+ },
192
+ [scopeId],
193
+ )
194
+
195
+ const displayedPeople = React.useMemo(
196
+ () => (visiblePeople.length > 0 ? visiblePeople : knownPeople),
197
+ [knownPeople, visiblePeople],
198
+ )
199
+ const totalLinkedPeople = listTotalCount > 0 ? listTotalCount : displayedPeople.length
200
+
201
+ const loadVisiblePeople = React.useCallback(async () => {
202
+ setListLoading(true)
203
+ try {
204
+ const payload = await loadPage({
205
+ page: listPage,
206
+ pageSize: LINKED_PEOPLE_PAGE_SIZE,
207
+ sort: sortMode,
208
+ search: searchQuery.trim(),
209
+ })
210
+ const nextTotalCount = typeof payload.total === 'number' ? payload.total : 0
211
+ setVisiblePeople(Array.isArray(payload.items) ? payload.items : [])
212
+ setListPage(typeof payload.page === 'number' ? payload.page : listPage)
213
+ setListTotalCount((current) =>
214
+ searchQuery.trim().length > 0 ? Math.max(current, nextTotalCount) : nextTotalCount,
215
+ )
216
+ setListTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : 1)
217
+ } catch {
218
+ setVisiblePeople([])
219
+ if (searchQuery.trim().length === 0) {
220
+ setListTotalCount(0)
221
+ }
222
+ setListTotalPages(1)
223
+ } finally {
224
+ setListLoading(false)
225
+ }
226
+ }, [listPage, loadPage, searchQuery, sortMode])
227
+
228
+ React.useEffect(() => {
229
+ void loadVisiblePeople()
230
+ }, [loadVisiblePeople, refreshKey])
231
+
232
+ React.useEffect(() => {
233
+ setListPage(1)
234
+ }, [searchQuery, sortMode])
235
+
236
+ const handleLinkConfirm = React.useCallback(
237
+ async (input: LinkEntityConfirmInput<TLinkSettings>) => {
238
+ onLoadingChange?.(true)
239
+ try {
240
+ await onLinkConfirm(input)
241
+ await loadVisiblePeople()
242
+ } finally {
243
+ onLoadingChange?.(false)
244
+ }
245
+ },
246
+ [loadVisiblePeople, onLinkConfirm, onLoadingChange],
247
+ )
248
+
249
+ const handleRemove = React.useCallback(
250
+ async (personId: string) => {
251
+ if (!personId || removingId) return
252
+ setRemovingId(personId)
253
+ onLoadingChange?.(true)
254
+ try {
255
+ await onUnlink(personId)
256
+ await loadVisiblePeople()
257
+ } catch {
258
+ // onUnlink owns user-facing error reporting
259
+ } finally {
260
+ setRemovingId(null)
261
+ onLoadingChange?.(false)
262
+ }
263
+ },
264
+ [loadVisiblePeople, onLoadingChange, onUnlink, removingId],
265
+ )
266
+
267
+ const linkAction = (
268
+ <Button
269
+ type="button"
270
+ variant="outline"
271
+ size="sm"
272
+ onClick={() => setLinkDialogOpen(true)}
273
+ disabled={disabled}
274
+ >
275
+ <Link2 className="mr-1.5 h-4 w-4" />
276
+ {linkActionLabel}
277
+ </Button>
278
+ )
279
+ const addPersonAction = onAddPerson && addActionLabel ? (
280
+ <Button type="button" size="sm" onClick={onAddPerson} disabled={disabled}>
281
+ <Plus className="mr-1.5 h-4 w-4" />
282
+ {addActionLabel}
283
+ </Button>
284
+ ) : null
285
+
286
+ const linkDialog = (
287
+ <LinkEntityDialog
288
+ open={linkDialogOpen}
289
+ onOpenChange={setLinkDialogOpen}
290
+ adapter={linkAdapter}
291
+ initialSelectedIds={linkedIds ?? []}
292
+ onConfirm={handleLinkConfirm}
293
+ runGuardedMutation={runGuardedMutation}
294
+ />
295
+ )
296
+
297
+ if (!listLoading && totalLinkedPeople === 0) {
298
+ const emptyStateAction = onEmptyStateAction ?? onAddPerson
299
+ return (
300
+ <>
301
+ <EmptyState
302
+ icon={<Users className="h-10 w-10 text-muted-foreground" />}
303
+ title={emptyState.title}
304
+ actionLabel={emptyStateAction ? emptyState.actionLabel : undefined}
305
+ onAction={emptyStateAction}
306
+ >
307
+ <p className="text-sm text-muted-foreground">{emptyLabel}</p>
308
+ <div className="mt-4">{linkAction}</div>
309
+ </EmptyState>
310
+ {linkDialog}
311
+ </>
312
+ )
313
+ }
314
+
315
+ return (
316
+ <>
317
+ <div className="space-y-4">
318
+ {header}
319
+
320
+ <section className="rounded-lg border bg-card px-4 py-4 sm:px-5">
321
+ <div className="flex flex-col gap-4">
322
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
323
+ <div className="space-y-1">
324
+ <div className="flex items-center gap-2">
325
+ <h3 className="text-base font-semibold">{sectionTitle}</h3>
326
+ <Badge
327
+ variant="secondary"
328
+ className="rounded-full px-2 py-0 text-xs font-semibold"
329
+ >
330
+ {totalLinkedPeople}
331
+ </Badge>
332
+ </div>
333
+ {sectionSubtitle ? (
334
+ <p className="text-xs text-muted-foreground">{sectionSubtitle}</p>
335
+ ) : null}
336
+ </div>
337
+ <div className="flex flex-wrap items-center gap-2 xl:justify-end">
338
+ {linkAction}
339
+ {addPersonAction}
340
+ </div>
341
+ </div>
342
+
343
+ {totalLinkedPeople > 0 ? (
344
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
345
+ {filtersOpen ? (
346
+ <div className="min-w-0 flex-1">
347
+ <Input
348
+ type="text"
349
+ size="lg"
350
+ value={searchQuery}
351
+ onChange={(event) => setSearchQuery(event.target.value)}
352
+ placeholder={searchPlaceholder}
353
+ />
354
+ </div>
355
+ ) : null}
356
+ <div className="flex flex-wrap items-center gap-2 lg:justify-end">
357
+ <Button
358
+ type="button"
359
+ variant="outline"
360
+ size="sm"
361
+ onClick={() => setFiltersOpen((current) => !current)}
362
+ className="h-10"
363
+ >
364
+ <Filter className="mr-1.5 h-4 w-4" />
365
+ {translate('customers.linkedPeople.filter', 'Filters')}
366
+ </Button>
367
+ {/*
368
+ Deliberately a native <select> rather than the DS `Select`. That primitive is
369
+ Radix-based (a button + listbox), and this element is the one the extraction
370
+ gate runs through: `CompanyPeopleSection.test.tsx` must keep passing
371
+ unmodified, and it drives the sort with `getByDisplayValue` + `fireEvent
372
+ .change` — native-select APIs. Swapping it would also change the company
373
+ tab's keyboard and mobile-picker behaviour, which is the one thing this PR
374
+ asserts it does not do. Worth migrating, but as its own change.
375
+ */}
376
+ {filtersOpen && sortOptions.length > 1 ? (
377
+ <select
378
+ value={sortMode}
379
+ onChange={(event) =>
380
+ setSortMode(event.target.value as LinkedPeopleSortMode)
381
+ }
382
+ className="h-10 min-w-44 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
383
+ >
384
+ {sortOptions.map((option) => (
385
+ <option key={option} value={option}>
386
+ {translate(SORT_LABELS[option].key, SORT_LABELS[option].fallback)}
387
+ </option>
388
+ ))}
389
+ </select>
390
+ ) : null}
391
+ </div>
392
+ </div>
393
+ ) : null}
394
+
395
+ {listLoading ? (
396
+ <p className="py-6 text-center text-sm text-muted-foreground">
397
+ {translate('customers.linkedPeople.loading', 'Loading people…')}
398
+ </p>
399
+ ) : visiblePeople.length > 0 ? (
400
+ <>
401
+ <div
402
+ className="grid items-start gap-4"
403
+ style={{
404
+ gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 19.5rem), 1fr))',
405
+ }}
406
+ >
407
+ {visiblePeople.map((person) => (
408
+ <PersonCard
409
+ key={person.id}
410
+ person={person}
411
+ isStarred={starredIds.has(person.id)}
412
+ onToggleStar={toggleStar}
413
+ onUnlink={handleRemove}
414
+ showLinkedDate={showLinkedDate}
415
+ />
416
+ ))}
417
+ </div>
418
+ {listTotalPages > 1 ? (
419
+ <div className="flex items-center justify-between border-t border-border/60 pt-3 text-sm text-muted-foreground">
420
+ <span>
421
+ {translate(
422
+ 'customers.linkedPeople.pageSummary',
423
+ 'Page {{page}} of {{total}}',
424
+ {
425
+ page: listPage,
426
+ total: listTotalPages,
427
+ },
428
+ )}
429
+ </span>
430
+ <div className="flex items-center gap-2">
431
+ <Button
432
+ type="button"
433
+ variant="outline"
434
+ size="sm"
435
+ onClick={() => setListPage((current) => Math.max(1, current - 1))}
436
+ disabled={listPage <= 1}
437
+ >
438
+ {translate('customers.linkedPeople.previous', 'Previous')}
439
+ </Button>
440
+ <Button
441
+ type="button"
442
+ variant="outline"
443
+ size="sm"
444
+ onClick={() =>
445
+ setListPage((current) => Math.min(listTotalPages, current + 1))
446
+ }
447
+ disabled={listPage >= listTotalPages}
448
+ >
449
+ {translate('customers.linkedPeople.next', 'Next')}
450
+ </Button>
451
+ </div>
452
+ </div>
453
+ ) : null}
454
+ </>
455
+ ) : totalLinkedPeople > 0 ? (
456
+ <p className="py-6 text-center text-sm text-muted-foreground">
457
+ {translate(
458
+ 'customers.linkedPeople.noSearchResults',
459
+ 'No people match your search.',
460
+ )}
461
+ </p>
462
+ ) : null}
463
+ </div>
464
+ </section>
465
+
466
+ {renderFooter?.({ people: displayedPeople, starredIds })}
467
+ </div>
468
+
469
+ {linkDialog}
470
+ </>
471
+ )
472
+ }
473
+
474
+ export default LinkedPeopleSection
@@ -10,7 +10,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
10
10
  import { Avatar } from '@open-mercato/ui/primitives/avatar'
11
11
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
12
12
  import { Popover, PopoverContent, PopoverTrigger } from '@open-mercato/ui/primitives/popover'
13
- import type { CompanyPersonSummary } from './CompanyPeopleSection'
13
+ import type { LinkedPersonSummary } from './LinkedPeopleSection'
14
14
  import { formatDate, formatFallbackLabel } from './utils'
15
15
 
16
16
  const sourceColorMap: Record<string, string> = {
@@ -52,20 +52,28 @@ function splitSourceTags(source: string | null | undefined): string[] {
52
52
  }
53
53
 
54
54
  interface PersonCardProps {
55
- person: CompanyPersonSummary
55
+ person: LinkedPersonSummary
56
56
  isStarred?: boolean
57
57
  onToggleStar?: (personId: string) => void
58
58
  onUnlink?: (personId: string) => void
59
+ /**
60
+ * Render the "Linked {date}" line. Defaults to `true`; a host whose `linkedAt` is not
61
+ * durable passes `false` rather than showing a date that is wrong after the first write.
62
+ */
63
+ showLinkedDate?: boolean
59
64
  }
60
65
 
61
- export function PersonCard({ person, isStarred, onToggleStar, onUnlink }: PersonCardProps) {
66
+ export function PersonCard({ person, isStarred, onToggleStar, onUnlink, showLinkedDate = true }: PersonCardProps) {
62
67
  const t = useT()
63
68
  const sourceTags = React.useMemo(() => splitSourceTags(person.source), [person.source])
64
69
  const temperature = React.useMemo(() => {
65
70
  const value = typeof person.temperature === 'string' ? person.temperature.trim().toLowerCase() : ''
66
71
  return value in temperatureConfig ? temperatureConfig[value as keyof typeof temperatureConfig] : null
67
72
  }, [person.temperature])
68
- const linkedDate = React.useMemo(() => formatDate(person.linkedAt ?? person.createdAt), [person.createdAt, person.linkedAt])
73
+ const linkedDate = React.useMemo(
74
+ () => (showLinkedDate ? formatDate(person.linkedAt ?? person.createdAt) : null),
75
+ [person.createdAt, person.linkedAt, showLinkedDate],
76
+ )
69
77
 
70
78
  return (
71
79
  <div className="min-w-0 overflow-hidden rounded-lg border bg-card p-4">
@@ -156,6 +156,7 @@ export type InteractionSummary = {
156
156
  } | null
157
157
  _integrations?: Record<string, unknown>
158
158
  pinned?: boolean
159
+ timezone?: string | null
159
160
  duration?: number | null
160
161
  createdAt: string
161
162
  updatedAt: string
@@ -34,6 +34,7 @@ type PersonAdapterOptions = {
34
34
  selectedEmptyHint: string
35
35
  confirmButtonLabel: string
36
36
  excludeLinkedCompanyId?: string
37
+ excludeLinkedDealId?: string
37
38
  excludeIds?: string[]
38
39
  defaultAvatarIcon?: React.ReactNode
39
40
  pageSize?: number
@@ -258,6 +259,9 @@ export function createPersonLinkAdapter(
258
259
  if (options.excludeLinkedCompanyId) {
259
260
  params.set('excludeLinkedCompanyId', options.excludeLinkedCompanyId)
260
261
  }
262
+ if (options.excludeLinkedDealId) {
263
+ params.set('excludeLinkedDealId', options.excludeLinkedDealId)
264
+ }
261
265
  const payload = await readApiResultOrThrow<Record<string, unknown>>(
262
266
  `/api/customers/people?${params.toString()}`,
263
267
  )
@@ -1,6 +1,7 @@
1
1
  import { Collection, OptionalProps } from '@mikro-orm/core'
2
2
  import { Entity, Index, ManyToOne, OneToMany, OneToOne, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
3
3
  import type { DictionaryEntrySortMode } from '@open-mercato/core/modules/dictionaries/lib/entrySort'
4
+ import type { CalendarEventTypeBehavior } from '../calendar-event-types'
4
5
 
5
6
  export type CustomerEntityKind = 'person' | 'company'
6
7
  export type CustomerAddressFormat = 'line_first' | 'street_first'
@@ -571,7 +572,7 @@ export class CustomerActivity {
571
572
  `create index "customer_interactions_email_channel_idx" on "customer_interactions" ("channel_id", "entity_id") where "interaction_type" = 'email' and "channel_id" is not null and "deleted_at" is null`,
572
573
  })
573
574
  export class CustomerInteraction {
574
- [OptionalProps]?: 'status' | 'pinned' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'durationMinutes' | 'location' | 'allDay' | 'recurrenceRule' | 'recurrenceEnd' | 'participants' | 'reminderMinutes' | 'visibility' | 'linkedEntities' | 'guestPermissions' | 'externalMessageId' | 'channelProviderKey' | 'channelId'
575
+ [OptionalProps]?: 'status' | 'pinned' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'timezone' | 'durationMinutes' | 'location' | 'allDay' | 'recurrenceRule' | 'recurrenceEnd' | 'participants' | 'reminderMinutes' | 'visibility' | 'linkedEntities' | 'guestPermissions' | 'externalMessageId' | 'channelProviderKey' | 'channelId'
575
576
 
576
577
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
577
578
  id!: string
@@ -661,6 +662,9 @@ export class CustomerInteraction {
661
662
  @Property({ name: 'deal_id', type: 'uuid', nullable: true })
662
663
  dealId?: string | null
663
664
 
665
+ @Property({ name: 'timezone', type: 'text', nullable: true })
666
+ timezone?: string | null
667
+
664
668
  @Property({ name: 'duration_minutes', type: 'int', nullable: true })
665
669
  durationMinutes?: number | null
666
670
 
@@ -953,6 +957,9 @@ export class CustomerDictionaryEntry {
953
957
  @Property({ type: 'text', nullable: true })
954
958
  icon?: string | null
955
959
 
960
+ @Property({ name: 'activity_type_behavior', type: 'jsonb', nullable: true })
961
+ activityTypeBehavior?: CalendarEventTypeBehavior | null
962
+
956
963
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
957
964
  createdAt: Date = new Date()
958
965