@open-mercato/core 0.6.5-develop.5337.1.534b781eac → 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 (350) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/bootstrap.js +46 -6
  4. package/dist/bootstrap.js.map +2 -2
  5. package/dist/generated/entities/organization/index.js +2 -0
  6. package/dist/generated/entities/organization/index.js.map +2 -2
  7. package/dist/generated/entity-fields-registry.js +1 -0
  8. package/dist/generated/entity-fields-registry.js.map +2 -2
  9. package/dist/helpers/integration/crmFixtures.js +4 -0
  10. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  11. package/dist/modules/attachments/api/library/route.js +2 -2
  12. package/dist/modules/attachments/api/library/route.js.map +2 -2
  13. package/dist/modules/attachments/api/route.js +2 -0
  14. package/dist/modules/attachments/api/route.js.map +2 -2
  15. package/dist/modules/attachments/components/AttachmentContentPreview.js +9 -5
  16. package/dist/modules/attachments/components/AttachmentContentPreview.js.map +2 -2
  17. package/dist/modules/attachments/lib/access.js +18 -0
  18. package/dist/modules/attachments/lib/access.js.map +2 -2
  19. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +3 -2
  20. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
  21. package/dist/modules/audit_logs/data/entities.js +2 -1
  22. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  23. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  24. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  25. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  26. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  27. package/dist/modules/auth/api/admin/nav.js +9 -0
  28. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  29. package/dist/modules/auth/api/login.js +4 -13
  30. package/dist/modules/auth/api/login.js.map +2 -2
  31. package/dist/modules/auth/commands/users.js +20 -14
  32. package/dist/modules/auth/commands/users.js.map +2 -2
  33. package/dist/modules/auth/data/entities.js +4 -2
  34. package/dist/modules/auth/data/entities.js.map +2 -2
  35. package/dist/modules/auth/lib/backendChrome.js +35 -2
  36. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  37. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  38. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  39. package/dist/modules/auth/migrations/Migration20260610120000.js +30 -0
  40. package/dist/modules/auth/migrations/Migration20260610120000.js.map +7 -0
  41. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  42. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  43. package/dist/modules/auth/services/authService.js +5 -3
  44. package/dist/modules/auth/services/authService.js.map +2 -2
  45. package/dist/modules/auth/services/rbacService.js +3 -2
  46. package/dist/modules/auth/services/rbacService.js.map +2 -2
  47. package/dist/modules/catalog/ai-tools/configuration-pack.js.map +1 -1
  48. package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +1 -1
  49. package/dist/modules/catalog/ai-tools/products-pack.js.map +1 -1
  50. package/dist/modules/catalog/ai-tools/variants-pack.js.map +1 -1
  51. package/dist/modules/communication_channels/data/entities.js.map +1 -1
  52. package/dist/modules/communication_channels/encryption.js.map +1 -1
  53. package/dist/modules/communication_channels/lib/thread-matcher.js.map +1 -1
  54. package/dist/modules/communication_channels/lib/thread-token.js.map +1 -1
  55. package/dist/modules/currencies/api/currencies/route.js +4 -3
  56. package/dist/modules/currencies/api/currencies/route.js.map +2 -2
  57. package/dist/modules/customer_accounts/api/admin/roles.js +2 -1
  58. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  59. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  60. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  61. package/dist/modules/customer_accounts/events.js +1 -1
  62. package/dist/modules/customer_accounts/events.js.map +1 -1
  63. package/dist/modules/customer_accounts/lib/resolveTenantContext.js.map +1 -1
  64. package/dist/modules/customers/acl.js +1 -1
  65. package/dist/modules/customers/acl.js.map +1 -1
  66. package/dist/modules/customers/ai-tools/companies-pack.js.map +1 -1
  67. package/dist/modules/customers/ai-tools/deals-pack.js.map +1 -1
  68. package/dist/modules/customers/ai-tools/people-pack.js.map +1 -1
  69. package/dist/modules/customers/api/companies/route.js +4 -4
  70. package/dist/modules/customers/api/companies/route.js.map +2 -2
  71. package/dist/modules/customers/api/deals/route.js +43 -2
  72. package/dist/modules/customers/api/deals/route.js.map +2 -2
  73. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  74. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  75. package/dist/modules/customers/api/people/route.js +4 -4
  76. package/dist/modules/customers/api/people/route.js.map +2 -2
  77. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  78. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  79. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  80. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  81. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  82. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  83. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  84. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  85. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  86. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  87. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  88. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  89. package/dist/modules/customers/cli.js +15 -9
  90. package/dist/modules/customers/cli.js.map +2 -2
  91. package/dist/modules/customers/commands/addresses.js +5 -5
  92. package/dist/modules/customers/commands/addresses.js.map +2 -2
  93. package/dist/modules/customers/commands/comments.js +5 -5
  94. package/dist/modules/customers/commands/comments.js.map +2 -2
  95. package/dist/modules/customers/commands/deals.js +2 -2
  96. package/dist/modules/customers/commands/deals.js.map +2 -2
  97. package/dist/modules/customers/commands/entity-roles.js +2 -1
  98. package/dist/modules/customers/commands/entity-roles.js.map +2 -2
  99. package/dist/modules/customers/commands/interactions.js +8 -5
  100. package/dist/modules/customers/commands/interactions.js.map +2 -2
  101. package/dist/modules/customers/commands/shared.js +21 -6
  102. package/dist/modules/customers/commands/shared.js.map +2 -2
  103. package/dist/modules/customers/commands/tags.js +3 -3
  104. package/dist/modules/customers/commands/tags.js.map +2 -2
  105. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  106. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  107. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  108. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  109. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  110. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  111. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  112. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  113. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  114. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  115. package/dist/modules/customers/components/detail/assignableStaff.js +21 -8
  116. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  117. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  118. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  119. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  120. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  121. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +1 -1
  122. package/dist/modules/data_sync/api/run.js +1 -1
  123. package/dist/modules/data_sync/api/run.js.map +2 -2
  124. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  125. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  126. package/dist/modules/directory/api/organizations/route.js +7 -0
  127. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  128. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  129. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  130. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  131. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  132. package/dist/modules/directory/commands/organizations.js +8 -1
  133. package/dist/modules/directory/commands/organizations.js.map +2 -2
  134. package/dist/modules/directory/data/entities.js +3 -0
  135. package/dist/modules/directory/data/entities.js.map +2 -2
  136. package/dist/modules/directory/data/validators.js +9 -0
  137. package/dist/modules/directory/data/validators.js.map +2 -2
  138. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  139. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  140. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  141. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  142. package/dist/modules/directory/utils/organizationScope.js +59 -27
  143. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  144. package/dist/modules/entities/api/definitions.batch.js +2 -1
  145. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  146. package/dist/modules/entities/api/entities.js +7 -0
  147. package/dist/modules/entities/api/entities.js.map +2 -2
  148. package/dist/modules/entities/api/records.js +26 -15
  149. package/dist/modules/entities/api/records.js.map +2 -2
  150. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  151. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  152. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  153. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  154. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  155. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  156. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  157. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  158. package/dist/modules/payment_gateways/api/transactions/route.js +2 -4
  159. package/dist/modules/payment_gateways/api/transactions/route.js.map +2 -2
  160. package/dist/modules/progress/api/jobs/[id]/route.js +7 -2
  161. package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
  162. package/dist/modules/progress/api/jobs/route.js +1 -1
  163. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  164. package/dist/modules/progress/lib/progressServiceImpl.js +8 -2
  165. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  166. package/dist/modules/query_index/data/entities.js +2 -1
  167. package/dist/modules/query_index/data/entities.js.map +2 -2
  168. package/dist/modules/query_index/lib/engine.js +4 -2
  169. package/dist/modules/query_index/lib/engine.js.map +2 -2
  170. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  171. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  172. package/dist/modules/resources/api/resources.js +2 -3
  173. package/dist/modules/resources/api/resources.js.map +2 -2
  174. package/dist/modules/sales/api/documents/factory.js +2 -2
  175. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  176. package/dist/modules/sales/commands/documents.js +7 -5
  177. package/dist/modules/sales/commands/documents.js.map +2 -2
  178. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  179. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  180. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  181. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  182. package/dist/modules/staff/api/team-members.js +9 -2
  183. package/dist/modules/staff/api/team-members.js.map +2 -2
  184. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  185. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  186. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  187. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  188. package/dist/modules/staff/commands/team-members.js +1 -1
  189. package/dist/modules/staff/commands/team-members.js.map +2 -2
  190. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  191. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  192. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  193. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  194. package/dist/modules/sync_excel/api/import/route.js +1 -1
  195. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  196. package/dist/modules/workflows/api/definitions/route.js +3 -2
  197. package/dist/modules/workflows/api/definitions/route.js.map +2 -2
  198. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  199. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  200. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  201. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  202. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  203. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  204. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  205. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  206. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  207. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  208. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  209. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  210. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  211. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  212. package/generated/entities/organization/index.ts +1 -0
  213. package/generated/entity-fields-registry.ts +1 -0
  214. package/package.json +11 -12
  215. package/src/bootstrap.ts +65 -7
  216. package/src/helpers/integration/crmFixtures.ts +21 -1
  217. package/src/modules/attachments/AGENTS.md +79 -0
  218. package/src/modules/attachments/api/library/route.ts +2 -2
  219. package/src/modules/attachments/api/route.ts +2 -0
  220. package/src/modules/attachments/components/AttachmentContentPreview.tsx +6 -6
  221. package/src/modules/attachments/lib/access.ts +36 -0
  222. package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +14 -2
  223. package/src/modules/audit_logs/data/entities.ts +1 -0
  224. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  225. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  226. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  227. package/src/modules/auth/api/admin/nav.ts +9 -0
  228. package/src/modules/auth/api/login.ts +13 -13
  229. package/src/modules/auth/commands/users.ts +32 -15
  230. package/src/modules/auth/data/entities.ts +13 -1
  231. package/src/modules/auth/i18n/de.json +0 -1
  232. package/src/modules/auth/i18n/en.json +0 -1
  233. package/src/modules/auth/i18n/es.json +0 -1
  234. package/src/modules/auth/i18n/pl.json +0 -1
  235. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  236. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  237. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -10
  238. package/src/modules/auth/migrations/Migration20260610120000.ts +53 -0
  239. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  240. package/src/modules/auth/services/authService.ts +24 -4
  241. package/src/modules/auth/services/rbacService.ts +11 -2
  242. package/src/modules/catalog/ai-tools/configuration-pack.ts +1 -1
  243. package/src/modules/catalog/ai-tools/prices-offers-pack.ts +1 -1
  244. package/src/modules/catalog/ai-tools/products-pack.ts +1 -1
  245. package/src/modules/catalog/ai-tools/variants-pack.ts +1 -1
  246. package/src/modules/communication_channels/data/entities.ts +2 -2
  247. package/src/modules/communication_channels/encryption.ts +1 -1
  248. package/src/modules/communication_channels/lib/adapter.ts +1 -1
  249. package/src/modules/communication_channels/lib/thread-matcher.ts +1 -1
  250. package/src/modules/communication_channels/lib/thread-token.ts +1 -1
  251. package/src/modules/currencies/api/currencies/route.ts +4 -3
  252. package/src/modules/customer_accounts/api/admin/roles.ts +2 -1
  253. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  254. package/src/modules/customer_accounts/events.ts +1 -1
  255. package/src/modules/customer_accounts/lib/resolveTenantContext.ts +2 -2
  256. package/src/modules/customers/acl.ts +1 -1
  257. package/src/modules/customers/ai-tools/companies-pack.ts +1 -1
  258. package/src/modules/customers/ai-tools/deals-pack.ts +1 -1
  259. package/src/modules/customers/ai-tools/people-pack.ts +1 -1
  260. package/src/modules/customers/api/companies/route.ts +4 -4
  261. package/src/modules/customers/api/deals/route.ts +51 -2
  262. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  263. package/src/modules/customers/api/people/route.ts +4 -4
  264. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  265. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  266. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  267. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  268. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  269. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  270. package/src/modules/customers/cli.ts +15 -15
  271. package/src/modules/customers/commands/addresses.ts +5 -5
  272. package/src/modules/customers/commands/comments.ts +5 -5
  273. package/src/modules/customers/commands/deals.ts +2 -2
  274. package/src/modules/customers/commands/entity-roles.ts +2 -1
  275. package/src/modules/customers/commands/interactions.ts +8 -5
  276. package/src/modules/customers/commands/shared.ts +26 -4
  277. package/src/modules/customers/commands/tags.ts +3 -3
  278. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  279. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  280. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  281. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  282. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  283. package/src/modules/customers/components/detail/assignableStaff.ts +32 -8
  284. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  285. package/src/modules/customers/i18n/de.json +43 -0
  286. package/src/modules/customers/i18n/en.json +43 -0
  287. package/src/modules/customers/i18n/es.json +43 -0
  288. package/src/modules/customers/i18n/pl.json +43 -0
  289. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  290. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +1 -1
  291. package/src/modules/data_sync/api/run.ts +1 -1
  292. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  293. package/src/modules/directory/api/organizations/route.ts +7 -0
  294. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  295. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  296. package/src/modules/directory/commands/organizations.ts +9 -1
  297. package/src/modules/directory/data/entities.ts +3 -0
  298. package/src/modules/directory/data/validators.ts +12 -0
  299. package/src/modules/directory/i18n/de.json +21 -0
  300. package/src/modules/directory/i18n/en.json +21 -0
  301. package/src/modules/directory/i18n/es.json +21 -0
  302. package/src/modules/directory/i18n/pl.json +21 -0
  303. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  304. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  305. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  306. package/src/modules/directory/utils/organizationScope.ts +85 -30
  307. package/src/modules/entities/api/definitions.batch.ts +11 -7
  308. package/src/modules/entities/api/entities.ts +11 -0
  309. package/src/modules/entities/api/records.ts +46 -25
  310. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  311. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  312. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  313. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  314. package/src/modules/entities/i18n/de.json +1 -0
  315. package/src/modules/entities/i18n/en.json +1 -0
  316. package/src/modules/entities/i18n/es.json +1 -0
  317. package/src/modules/entities/i18n/pl.json +1 -0
  318. package/src/modules/payment_gateways/api/transactions/route.ts +2 -5
  319. package/src/modules/progress/api/jobs/[id]/route.ts +6 -1
  320. package/src/modules/progress/api/jobs/route.ts +1 -1
  321. package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
  322. package/src/modules/query_index/data/entities.ts +1 -0
  323. package/src/modules/query_index/lib/engine.ts +11 -5
  324. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  325. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  326. package/src/modules/resources/api/resources.ts +2 -3
  327. package/src/modules/sales/api/documents/factory.ts +2 -2
  328. package/src/modules/sales/commands/documents.ts +7 -5
  329. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  330. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  331. package/src/modules/staff/AGENTS.md +1 -1
  332. package/src/modules/staff/api/team-members.ts +9 -2
  333. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  334. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  335. package/src/modules/staff/commands/team-members.ts +5 -2
  336. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  337. package/src/modules/staff/i18n/de.json +1 -0
  338. package/src/modules/staff/i18n/en.json +1 -0
  339. package/src/modules/staff/i18n/es.json +1 -0
  340. package/src/modules/staff/i18n/pl.json +1 -0
  341. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  342. package/src/modules/sync_excel/api/import/route.ts +1 -1
  343. package/src/modules/workflows/api/definitions/route.ts +3 -2
  344. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  345. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  346. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  347. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  348. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  349. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  350. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -0,0 +1,496 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
4
+ import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
8
+ import type { ExchangeRateService, RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
9
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
+ import { fetchStuckDealIds } from '../../../lib/stuckDeals'
11
+ import {
12
+ computeDelta,
13
+ convertSumsToBase,
14
+ getPreviousQuarterWindow,
15
+ getQuarterWindow,
16
+ getTrailingMonths,
17
+ type CurrencySum,
18
+ type Delta,
19
+ } from '../../../lib/dealsMetrics'
20
+
21
+ export const metadata = {
22
+ GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },
23
+ }
24
+
25
+ const OPEN_STATUSES = ['open', 'in_progress'] as const
26
+ const TRAILING_MONTHS = 6
27
+ const TOP_OWNERS = 5
28
+
29
+ const deltaSchema = z.object({
30
+ value: z.number(),
31
+ direction: z.enum(['up', 'down', 'unchanged']),
32
+ })
33
+
34
+ const stageBreakdownSchema = z.object({
35
+ stage: z.string().nullable(),
36
+ count: z.number(),
37
+ value: z.number(),
38
+ })
39
+
40
+ const ownerCountSchema = z.object({
41
+ id: z.string(),
42
+ count: z.number(),
43
+ })
44
+
45
+ const winRatePointSchema = z.object({
46
+ period: z.string(),
47
+ rate: z.number(),
48
+ })
49
+
50
+ const summaryResponseSchema = z.object({
51
+ baseCurrencyCode: z.string().nullable(),
52
+ convertedAll: z.boolean(),
53
+ missingRateCurrencies: z.array(z.string()),
54
+ pipelineValue: z.object({
55
+ value: z.number(),
56
+ delta: deltaSchema,
57
+ stages: z.array(stageBreakdownSchema),
58
+ }),
59
+ activeDeals: z.object({
60
+ value: z.number(),
61
+ delta: deltaSchema,
62
+ ownersCount: z.number(),
63
+ needAttention: z.number(),
64
+ owners: z.array(ownerCountSchema),
65
+ ownersOverflow: z.number(),
66
+ }),
67
+ wonThisQuarter: z.object({
68
+ value: z.number(),
69
+ delta: deltaSchema,
70
+ dealsClosed: z.number(),
71
+ avgDeal: z.number(),
72
+ }),
73
+ winRate: z.object({
74
+ value: z.number(),
75
+ deltaPp: z.number(),
76
+ direction: z.enum(['up', 'down', 'unchanged']),
77
+ previousValue: z.number(),
78
+ series: z.array(winRatePointSchema),
79
+ }),
80
+ })
81
+
82
+ export type DealsSummaryResponse = z.infer<typeof summaryResponseSchema>
83
+
84
+ const summaryErrorSchema = z.object({
85
+ error: z.string(),
86
+ })
87
+
88
+ export const openApi: OpenApiRouteDoc = {
89
+ tag: 'Customers',
90
+ summary: 'Deals KPI summary',
91
+ methods: {
92
+ GET: {
93
+ summary: 'Pipeline KPI metrics with period-over-period deltas for the deals list',
94
+ description:
95
+ 'Returns the four list-level KPI cards (pipeline value, active deals, won this quarter, win rate) with quarter-over-quarter deltas, per-stage open-pipeline breakdown, top owners, and a 6-month win-rate series. Values are converted to the tenant base currency where rates are available; partial conversions are disclosed via convertedAll/missingRateCurrencies.',
96
+ responses: [
97
+ { status: 200, description: 'Deals KPI summary payload', schema: summaryResponseSchema },
98
+ ],
99
+ errors: [
100
+ { status: 401, description: 'Unauthorized', schema: summaryErrorSchema },
101
+ ],
102
+ },
103
+ },
104
+ }
105
+
106
+ type OpenPipelineRow = {
107
+ stage: string | null
108
+ currency: string | null
109
+ total: string | number | null
110
+ count: string | number
111
+ owner_user_id: string | null
112
+ }
113
+
114
+ type WindowSumRow = {
115
+ currency: string | null
116
+ current_total: string | number | null
117
+ current_count: string | number
118
+ previous_total: string | number | null
119
+ previous_count: string | number
120
+ }
121
+
122
+ type WinLossRow = {
123
+ current_won: string | number
124
+ current_lost: string | number
125
+ previous_won: string | number
126
+ previous_lost: string | number
127
+ }
128
+
129
+ type WinRateMonthRow = {
130
+ period: string
131
+ won: string | number
132
+ lost: string | number
133
+ }
134
+
135
+ type OwnerCountRow = {
136
+ owner_user_id: string | null
137
+ count: string | number
138
+ }
139
+
140
+ function toNumber(value: string | number | null | undefined): number {
141
+ const parsed = Number(value ?? 0)
142
+ return Number.isFinite(parsed) ? parsed : 0
143
+ }
144
+
145
+ function winRate(won: number, lost: number): number {
146
+ const denom = won + lost
147
+ if (denom <= 0) return 0
148
+ return Math.round((100 * won) / denom)
149
+ }
150
+
151
+ function sumsByCurrency(entries: Array<{ currency: string | null; total: number }>): CurrencySum[] {
152
+ const byCurrency = new Map<string, number>()
153
+ for (const entry of entries) {
154
+ const currency = (entry.currency ?? '').toString().trim().toUpperCase()
155
+ if (!currency) continue
156
+ byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)
157
+ }
158
+ return Array.from(byCurrency.entries()).map(([currency, total]) => ({ currency, total }))
159
+ }
160
+
161
+ export async function GET(req: Request) {
162
+ const auth = await getAuthFromRequest(req)
163
+ if (!auth?.tenantId || !auth.orgId) {
164
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
165
+ }
166
+
167
+ const container = await createRequestContainer()
168
+ const em = container.resolve<CoreEntityManager>('em')
169
+
170
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
171
+ const effectiveTenantId = scope.tenantId ?? auth.tenantId
172
+ const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0
173
+ ? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)
174
+ : auth.orgId
175
+ ? [auth.orgId]
176
+ : []
177
+ if (!effectiveTenantId || orgFilterIds.length === 0) {
178
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
179
+ }
180
+
181
+ const today = new Date()
182
+ const currentQuarter = getQuarterWindow(today)
183
+ const previousQuarter = getPreviousQuarterWindow(today)
184
+ const trailingMonths = getTrailingMonths(today, TRAILING_MONTHS)
185
+ const seriesStart = trailingMonths[0]?.start ?? currentQuarter.start
186
+
187
+ const connection = em.getConnection()
188
+
189
+ const baseCurrency: Array<{ code: string }> = await connection.execute<Array<{ code: string }>>(
190
+ `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
191
+ [effectiveTenantId, orgFilterIds[0]],
192
+ )
193
+ const baseCurrencyCode = baseCurrency[0]?.code ?? null
194
+
195
+ const orgPlaceholders = orgFilterIds.map(() => '?').join(',')
196
+ const scopeWhere = `tenant_id = ? AND organization_id IN (${orgPlaceholders}) AND deleted_at IS NULL`
197
+ const scopeValues: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]
198
+ const openPlaceholders = OPEN_STATUSES.map(() => '?').join(',')
199
+
200
+ // 1) Open pipeline: per (stage, currency) sums + open-deal owner per row, so we can
201
+ // derive pipeline value (per stage + converted total) and the open owner set in one pass.
202
+ const openRows: OpenPipelineRow[] = await connection.execute<OpenPipelineRow[]>(
203
+ `SELECT
204
+ pipeline_stage AS stage,
205
+ UPPER(COALESCE(value_currency, '')) AS currency,
206
+ COALESCE(SUM(value_amount), 0) AS total,
207
+ COUNT(*) AS count,
208
+ owner_user_id
209
+ FROM customer_deals
210
+ WHERE ${scopeWhere} AND status IN (${openPlaceholders})
211
+ GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,
212
+ [...scopeValues, ...OPEN_STATUSES],
213
+ )
214
+
215
+ // 2) Open-deal value created in the current vs previous quarter (pipeline inflow delta).
216
+ const inflowRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(
217
+ `SELECT
218
+ UPPER(COALESCE(value_currency, '')) AS currency,
219
+ COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,
220
+ COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,
221
+ COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,
222
+ COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count
223
+ FROM customer_deals
224
+ WHERE ${scopeWhere} AND status IN (${openPlaceholders})
225
+ GROUP BY UPPER(COALESCE(value_currency, ''))`,
226
+ [
227
+ currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
228
+ currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
229
+ previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
230
+ previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
231
+ ...scopeValues, ...OPEN_STATUSES,
232
+ ],
233
+ )
234
+
235
+ // 3) Won value per currency for the current vs previous quarter (updated_at in window).
236
+ const wonRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(
237
+ `SELECT
238
+ UPPER(COALESCE(value_currency, '')) AS currency,
239
+ COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,
240
+ COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,
241
+ COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,
242
+ COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count
243
+ FROM customer_deals
244
+ WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')
245
+ GROUP BY UPPER(COALESCE(value_currency, ''))`,
246
+ [
247
+ currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
248
+ currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
249
+ previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
250
+ previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
251
+ ...scopeValues,
252
+ ],
253
+ )
254
+
255
+ // 4) Win/lost counts for the current vs previous quarter (win rate + delta-pp).
256
+ const winLossRows: WinLossRow[] = await connection.execute<WinLossRow[]>(
257
+ `SELECT
258
+ COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
259
+ COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
260
+ COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
261
+ COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
262
+ FROM customer_deals
263
+ WHERE ${scopeWhere}`,
264
+ [
265
+ currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
266
+ currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),
267
+ previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
268
+ previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),
269
+ ...scopeValues,
270
+ ],
271
+ )
272
+
273
+ // 5) Win-rate series over the trailing months (won/lost grouped by updated_at month).
274
+ const seriesRows: WinRateMonthRow[] = await connection.execute<WinRateMonthRow[]>(
275
+ `SELECT
276
+ to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
277
+ COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
278
+ COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost
279
+ FROM customer_deals
280
+ WHERE ${scopeWhere} AND updated_at >= ?
281
+ GROUP BY 1`,
282
+ [...scopeValues, seriesStart.toISOString()],
283
+ )
284
+
285
+ // Overdue open deals (id set) + stuck deals (id set) → union count for "need attention".
286
+ const overdueRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(
287
+ `SELECT id FROM customer_deals
288
+ WHERE ${scopeWhere} AND status = 'open' AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,
289
+ [...scopeValues],
290
+ )
291
+ // `fetchStuckDealIds` is single-org; run it for every org in scope so multi-org callers don't
292
+ // undercount stuck deals (the aggregates above already span every org in `orgFilterIds`).
293
+ const stuckIdLists = await Promise.all(
294
+ orgFilterIds.map((orgId) =>
295
+ fetchStuckDealIds(em as unknown as PgEntityManager, orgId, effectiveTenantId)),
296
+ )
297
+ const stuckIdSet = new Set<string>()
298
+ for (const list of stuckIdLists) for (const id of list) stuckIdSet.add(id)
299
+
300
+ // The stuck-deal query does not filter status, so a stuck id can be a won/lost/closed deal.
301
+ // "Need attention" is an active-deal metric — intersect with the open (OPEN_STATUSES) set so
302
+ // terminal deals never inflate the count.
303
+ let openStuckIds: string[] = []
304
+ if (stuckIdSet.size > 0) {
305
+ const stuckIdValues = Array.from(stuckIdSet)
306
+ const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')
307
+ const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(
308
+ `SELECT id FROM customer_deals
309
+ WHERE ${scopeWhere} AND status IN (${openPlaceholders}) AND id IN (${stuckPlaceholders})`,
310
+ [...scopeValues, ...OPEN_STATUSES, ...stuckIdValues],
311
+ )
312
+ openStuckIds = openStuckRows.map((row) => row.id)
313
+ }
314
+
315
+ const attentionIds = new Set<string>()
316
+ for (const row of overdueRows) attentionIds.add(row.id)
317
+ for (const id of openStuckIds) attentionIds.add(id)
318
+
319
+ // Reduce open rows: per-stage sums, distinct owners, owner counts, and a flat
320
+ // per-currency list for the converted pipeline total.
321
+ const stageMap = new Map<string, { stage: string | null; count: number; byCurrency: CurrencySum[] }>()
322
+ const openOwnerCounts = new Map<string, number>()
323
+ const openSums: Array<{ currency: string | null; total: number }> = []
324
+ for (const row of openRows) {
325
+ const stageKey = row.stage ?? '__null__'
326
+ const total = toNumber(row.total)
327
+ const count = toNumber(row.count)
328
+ const currency = (row.currency ?? '').toString().trim().toUpperCase()
329
+ if (!stageMap.has(stageKey)) {
330
+ stageMap.set(stageKey, { stage: row.stage ?? null, count: 0, byCurrency: [] })
331
+ }
332
+ const stageAgg = stageMap.get(stageKey)!
333
+ stageAgg.count += count
334
+ if (currency) stageAgg.byCurrency.push({ currency, total })
335
+ openSums.push({ currency, total })
336
+ if (row.owner_user_id) {
337
+ openOwnerCounts.set(row.owner_user_id, (openOwnerCounts.get(row.owner_user_id) ?? 0) + count)
338
+ }
339
+ }
340
+
341
+ // Collect every distinct non-base currency across all metrics and fetch rates ONCE.
342
+ const distinctCurrencies = new Set<string>()
343
+ const collect = (entries: Array<{ currency: string | null }>) => {
344
+ for (const entry of entries) {
345
+ const currency = (entry.currency ?? '').toString().trim().toUpperCase()
346
+ if (currency && currency !== baseCurrencyCode) distinctCurrencies.add(currency)
347
+ }
348
+ }
349
+ collect(openSums)
350
+ collect(inflowRows)
351
+ collect(wonRows)
352
+
353
+ let rates = new Map<string, RateResult>()
354
+ if (baseCurrencyCode && distinctCurrencies.size > 0) {
355
+ const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined
356
+ if (exchange) {
357
+ const pairs = Array.from(distinctCurrencies).map((code) => ({
358
+ fromCurrencyCode: code,
359
+ toCurrencyCode: baseCurrencyCode,
360
+ }))
361
+ try {
362
+ rates = await exchange.getRates({
363
+ pairs,
364
+ date: today,
365
+ scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },
366
+ options: { maxDaysBack: 60, autoFetch: false },
367
+ })
368
+ } catch (err) {
369
+ console.warn('[customers.deals.summary] exchange-rate lookup failed; falling back to per-currency totals', err)
370
+ }
371
+ }
372
+ }
373
+
374
+ const missingRateCurrencies = new Set<string>()
375
+ const trackMissing = (missing: string[]) => {
376
+ for (const code of missing) missingRateCurrencies.add(code)
377
+ }
378
+ let convertedAll = true
379
+
380
+ // Degraded path: when there is no base currency, fall back to the dominant currency's
381
+ // raw sum so the cards still show a number (mirrors the aggregate route's disclosure).
382
+ const dominantCurrencyTotal = (entries: Array<{ currency: string | null; total: number }>): number => {
383
+ const byCurrency = new Map<string, number>()
384
+ for (const entry of entries) {
385
+ const currency = (entry.currency ?? '').toString().trim().toUpperCase()
386
+ if (!currency) continue
387
+ byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)
388
+ }
389
+ let best = 0
390
+ for (const total of byCurrency.values()) {
391
+ if (Math.abs(total) > Math.abs(best)) best = total
392
+ }
393
+ return Math.round(best)
394
+ }
395
+
396
+ const convert = (entries: Array<{ currency: string | null; total: number }>): number => {
397
+ if (!baseCurrencyCode) {
398
+ convertedAll = false
399
+ trackMissing(sumsByCurrency(entries).map((entry) => entry.currency))
400
+ return dominantCurrencyTotal(entries)
401
+ }
402
+ const result = convertSumsToBase(sumsByCurrency(entries), baseCurrencyCode, rates)
403
+ if (!result.convertedAll) convertedAll = false
404
+ trackMissing(result.missingRateCurrencies)
405
+ return result.total
406
+ }
407
+
408
+ // Pipeline value (open deals, converted) + per-stage converted breakdown.
409
+ const pipelineValueTotal = convert(openSums)
410
+ const stages = Array.from(stageMap.values()).map((stageAgg) => ({
411
+ stage: stageAgg.stage,
412
+ count: stageAgg.count,
413
+ value: convert(stageAgg.byCurrency),
414
+ }))
415
+
416
+ // Pipeline inflow delta (open value created this vs previous quarter).
417
+ const inflowCurrent = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))
418
+ const inflowPrevious = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))
419
+ const pipelineDelta: Delta = computeDelta(inflowCurrent, inflowPrevious)
420
+
421
+ // Active deals: count of open deals, owners, need-attention, top owners.
422
+ const activeDealsCount = openRows.reduce((sum, row) => sum + toNumber(row.count), 0)
423
+ const ownersCount = openOwnerCounts.size
424
+ const inflowCurrentCount = inflowRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)
425
+ const inflowPreviousCount = inflowRows.reduce((sum, row) => sum + toNumber(row.previous_count), 0)
426
+ const activeDelta: Delta = computeDelta(inflowCurrentCount, inflowPreviousCount)
427
+ const sortedOwners = Array.from(openOwnerCounts.entries())
428
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
429
+ const owners = sortedOwners.slice(0, TOP_OWNERS).map(([id, count]) => ({ id, count }))
430
+ const ownersOverflow = Math.max(0, ownersCount - owners.length)
431
+
432
+ // Won this quarter.
433
+ const wonCurrent = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))
434
+ const wonPrevious = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))
435
+ const dealsClosed = wonRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)
436
+ const wonDelta: Delta = computeDelta(wonCurrent, wonPrevious)
437
+ const avgDeal = dealsClosed > 0 ? Math.round(wonCurrent / dealsClosed) : 0
438
+
439
+ // Win rate (current + previous quarter) and pp delta.
440
+ const winLoss = winLossRows[0]
441
+ const currentWon = toNumber(winLoss?.current_won)
442
+ const currentLost = toNumber(winLoss?.current_lost)
443
+ const previousWon = toNumber(winLoss?.previous_won)
444
+ const previousLost = toNumber(winLoss?.previous_lost)
445
+ const winRateValue = winRate(currentWon, currentLost)
446
+ const winRatePrevious = winRate(previousWon, previousLost)
447
+ const deltaPp = winRateValue - winRatePrevious
448
+ const winRateDirection = deltaPp > 0 ? 'up' : deltaPp < 0 ? 'down' : 'unchanged'
449
+
450
+ // Win-rate series over trailing months (fill missing months with 0).
451
+ const seriesByPeriod = new Map<string, { won: number; lost: number }>()
452
+ for (const row of seriesRows) {
453
+ seriesByPeriod.set(row.period, { won: toNumber(row.won), lost: toNumber(row.lost) })
454
+ }
455
+ const series = trailingMonths.map((month) => {
456
+ const point = seriesByPeriod.get(month.label)
457
+ const won = point?.won ?? 0
458
+ const lost = point?.lost ?? 0
459
+ const denom = won + lost
460
+ return { period: month.label, rate: denom > 0 ? won / denom : 0 }
461
+ })
462
+
463
+ const response: DealsSummaryResponse = {
464
+ baseCurrencyCode,
465
+ convertedAll,
466
+ missingRateCurrencies: Array.from(missingRateCurrencies),
467
+ pipelineValue: {
468
+ value: pipelineValueTotal,
469
+ delta: pipelineDelta,
470
+ stages,
471
+ },
472
+ activeDeals: {
473
+ value: activeDealsCount,
474
+ delta: activeDelta,
475
+ ownersCount,
476
+ needAttention: attentionIds.size,
477
+ owners,
478
+ ownersOverflow,
479
+ },
480
+ wonThisQuarter: {
481
+ value: wonCurrent,
482
+ delta: wonDelta,
483
+ dealsClosed,
484
+ avgDeal,
485
+ },
486
+ winRate: {
487
+ value: winRateValue,
488
+ deltaPp,
489
+ direction: winRateDirection,
490
+ previousValue: winRatePrevious,
491
+ series,
492
+ },
493
+ }
494
+
495
+ return NextResponse.json(response)
496
+ }
@@ -19,7 +19,7 @@ import {
19
19
  withScopedPayload,
20
20
  } from '../utils'
21
21
  import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'
22
- import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
22
+ import { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'
23
23
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
24
24
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
25
25
  import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
@@ -173,7 +173,7 @@ const crud = makeCrudRoute({
173
173
  if (matchingIds !== null && matchingIds.length > 0) {
174
174
  applyEntityIdRestriction(filters, matchingIds)
175
175
  } else {
176
- const searchPattern = `%${escapeLikePattern(query.search)}%`
176
+ const searchPattern = buildIlikeTerm(query.search)
177
177
  filters.$or = [
178
178
  { display_name: { $ilike: searchPattern } },
179
179
  { primary_email: { $ilike: searchPattern } },
@@ -189,9 +189,9 @@ const crud = makeCrudRoute({
189
189
  if (email) {
190
190
  filters.primary_email = { $eq: email }
191
191
  } else if (emailStartsWith) {
192
- filters.primary_email = { $ilike: `${escapeLikePattern(emailStartsWith)}%` }
192
+ filters.primary_email = { $ilike: buildIlikeTerm(emailStartsWith, 'startsWith') }
193
193
  } else if (emailContains) {
194
- filters.primary_email = { $ilike: `%${escapeLikePattern(emailContains)}%` }
194
+ filters.primary_email = { $ilike: buildIlikeTerm(emailContains) }
195
195
  }
196
196
  if (query.status) {
197
197
  filters.status = { $eq: query.status }
@@ -4,6 +4,10 @@ import type { InteractionSummary } from '../../../../../components/detail/types'
4
4
  import { useInteractionMutations } from '../../../../../components/detail/hooks/useInteractionMutations'
5
5
  import type { GuardedMutationRunner } from './types'
6
6
 
7
+ type LoadPlannedActivitiesOptions = {
8
+ cache?: boolean
9
+ }
10
+
7
11
  type UseDealActivitiesOptions = {
8
12
  dealId: string
9
13
  runMutationWithContext: GuardedMutationRunner
@@ -12,12 +16,32 @@ type UseDealActivitiesOptions = {
12
16
  type UseDealActivitiesResult = {
13
17
  plannedActivities: InteractionSummary[]
14
18
  activityRefreshKey: number
15
- loadPlannedActivities: () => Promise<void>
19
+ loadPlannedActivities: (options?: LoadPlannedActivitiesOptions) => Promise<void>
16
20
  handleActivityCreated: () => Promise<void>
17
21
  handleMarkDone: (interactionId: string) => Promise<void>
18
22
  handleCancelActivity: (interactionId: string) => Promise<void>
19
23
  }
20
24
 
25
+ type PlannedActivitiesCacheEntry = {
26
+ promise: Promise<InteractionSummary[]>
27
+ }
28
+
29
+ const plannedActivitiesCache = new Map<string, PlannedActivitiesCacheEntry>()
30
+
31
+ function fetchPlannedActivities(dealId: string, useCache: boolean): Promise<InteractionSummary[]> {
32
+ const url = `/api/customers/interactions?dealId=${encodeURIComponent(dealId)}&status=planned&excludeInteractionType=task&limit=100&sortField=scheduledAt&sortDir=asc`
33
+ const cached = plannedActivitiesCache.get(url)
34
+ if (useCache && cached) return cached.promise
35
+ const entry: PlannedActivitiesCacheEntry = {
36
+ promise: readApiResultOrThrow<{ items?: InteractionSummary[] }>(url)
37
+ .then((result) => (Array.isArray(result.items) ? result.items : [])),
38
+ }
39
+ if (useCache) plannedActivitiesCache.set(url, entry)
40
+ return entry.promise.finally(() => {
41
+ if (plannedActivitiesCache.get(url) === entry) plannedActivitiesCache.delete(url)
42
+ })
43
+ }
44
+
21
45
  export function useDealActivities({
22
46
  dealId,
23
47
  runMutationWithContext,
@@ -25,13 +49,11 @@ export function useDealActivities({
25
49
  const [plannedActivities, setPlannedActivities] = React.useState<InteractionSummary[]>([])
26
50
  const [activityRefreshKey, setActivityRefreshKey] = React.useState(0)
27
51
 
28
- const loadPlannedActivities = React.useCallback(async () => {
52
+ const loadPlannedActivities = React.useCallback(async (options: LoadPlannedActivitiesOptions = {}) => {
29
53
  if (!dealId) return
30
54
  try {
31
- const result = await readApiResultOrThrow<{ items?: InteractionSummary[] }>(
32
- `/api/customers/interactions?dealId=${encodeURIComponent(dealId)}&status=planned&excludeInteractionType=task&limit=100&sortField=scheduledAt&sortDir=asc`,
33
- )
34
- setPlannedActivities(Array.isArray(result.items) ? result.items : [])
55
+ const items = await fetchPlannedActivities(dealId, options.cache === true)
56
+ setPlannedActivities(items)
35
57
  } catch (err) {
36
58
  console.warn('[customers.deals.detail] load planned activities failed', err)
37
59
  setPlannedActivities([])
@@ -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) {