@aglyn/plugins-crm 1.0.0-beta.296 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/activity-link-fields.d.ts +34 -0
  3. package/src/lib/components/activity-link-fields.js +141 -0
  4. package/src/lib/components/activity-link-fields.js.map +1 -0
  5. package/src/lib/components/activity-list.d.ts +8 -0
  6. package/src/lib/components/activity-list.js +4 -2
  7. package/src/lib/components/activity-list.js.map +1 -1
  8. package/src/lib/components/activity-queries.d.ts +7 -2
  9. package/src/lib/components/activity-queries.js +7 -3
  10. package/src/lib/components/activity-queries.js.map +1 -1
  11. package/src/lib/components/company-contact-roles-card.d.ts +42 -0
  12. package/src/lib/components/company-contact-roles-card.js +135 -0
  13. package/src/lib/components/company-contact-roles-card.js.map +1 -0
  14. package/src/lib/components/company-detail-page.js +12 -1
  15. package/src/lib/components/company-detail-page.js.map +1 -1
  16. package/src/lib/components/company-edit-drawer.js +9 -6
  17. package/src/lib/components/company-edit-drawer.js.map +1 -1
  18. package/src/lib/components/company-picker.js +4 -2
  19. package/src/lib/components/company-picker.js.map +1 -1
  20. package/src/lib/components/contact-associations-card.js +15 -1
  21. package/src/lib/components/contact-associations-card.js.map +1 -1
  22. package/src/lib/components/contact-detail-page.js +13 -14
  23. package/src/lib/components/contact-detail-page.js.map +1 -1
  24. package/src/lib/components/contact-list-columns.js +5 -4
  25. package/src/lib/components/contact-list-columns.js.map +1 -1
  26. package/src/lib/components/contact-properties-card.js +41 -12
  27. package/src/lib/components/contact-properties-card.js.map +1 -1
  28. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  29. package/src/lib/components/contact-roles-editor.js +270 -0
  30. package/src/lib/components/contact-roles-editor.js.map +1 -0
  31. package/src/lib/components/contacts-section.js +6 -4
  32. package/src/lib/components/contacts-section.js.map +1 -1
  33. package/src/lib/components/crm-console-sections.js +1 -1
  34. package/src/lib/components/crm-console-sections.js.map +1 -1
  35. package/src/lib/components/crm-record-picker.d.ts +19 -0
  36. package/src/lib/components/crm-record-picker.js +40 -11
  37. package/src/lib/components/crm-record-picker.js.map +1 -1
  38. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  39. package/src/lib/components/crm-related-to-picker.js +135 -0
  40. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  41. package/src/lib/components/crm-site-picker.d.ts +13 -1
  42. package/src/lib/components/crm-site-picker.js +15 -6
  43. package/src/lib/components/crm-site-picker.js.map +1 -1
  44. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  45. package/src/lib/components/crm-transfer-buttons.js +9 -6
  46. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  47. package/src/lib/components/deal-contact-roles-card.js +21 -239
  48. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  49. package/src/lib/components/deal-detail-page.js +2 -1
  50. package/src/lib/components/deal-detail-page.js.map +1 -1
  51. package/src/lib/components/deal-edit-drawer.js +11 -7
  52. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  53. package/src/lib/components/deals-section.js +5 -4
  54. package/src/lib/components/deals-section.js.map +1 -1
  55. package/src/lib/components/lead-campaigns-card.js +26 -14
  56. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  57. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  58. package/src/lib/components/log-activity-dialog.js +96 -32
  59. package/src/lib/components/log-activity-dialog.js.map +1 -1
  60. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  61. package/src/lib/components/new-contact-drawer.js +27 -15
  62. package/src/lib/components/new-contact-drawer.js.map +1 -1
  63. package/src/lib/components/picklist-values-card.js +7 -4
  64. package/src/lib/components/picklist-values-card.js.map +1 -1
  65. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  66. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  67. package/src/lib/components/record-activity-card.d.ts +11 -2
  68. package/src/lib/components/record-activity-card.js +90 -5
  69. package/src/lib/components/record-activity-card.js.map +1 -1
  70. package/src/lib/components/settings-section.d.ts +4 -2
  71. package/src/lib/components/settings-section.js +7 -5
  72. package/src/lib/components/settings-section.js.map +1 -1
  73. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  74. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  75. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  76. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  77. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  78. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  79. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  80. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  81. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  82. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  83. package/src/lib/hooks/use-crm-scope.js +12 -5
  84. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  85. package/src/lib/model/contact-holder.d.ts +12 -0
  86. package/src/lib/model/contact-holder.js +20 -1
  87. package/src/lib/model/contact-holder.js.map +1 -1
  88. package/src/lib/model/contact-record.d.ts +5 -0
  89. package/src/lib/model/contact-record.js +7 -1
  90. package/src/lib/model/contact-record.js.map +1 -1
  91. package/src/lib/model/contact-update.d.ts +6 -0
  92. package/src/lib/model/contact-update.js +1 -0
  93. package/src/lib/model/contact-update.js.map +1 -1
  94. package/src/lib/model/crm-activity-link.d.ts +117 -0
  95. package/src/lib/model/crm-activity-link.js +113 -0
  96. package/src/lib/model/crm-activity-link.js.map +1 -0
  97. package/src/lib/model/crm-api.d.ts +4 -1
  98. package/src/lib/model/crm-api.js +9 -5
  99. package/src/lib/model/crm-api.js.map +1 -1
  100. package/src/lib/model/deal-board-model.js +5 -3
  101. package/src/lib/model/deal-board-model.js.map +1 -1
  102. package/src/lib/model/deal-contact-roles.d.ts +15 -1
  103. package/src/lib/model/deal-contact-roles.js +17 -3
  104. package/src/lib/model/deal-contact-roles.js.map +1 -1
  105. package/src/lib/model/settle-stage-names.d.ts +25 -0
  106. package/src/lib/model/settle-stage-names.js +29 -0
  107. package/src/lib/model/settle-stage-names.js.map +1 -0
  108. package/src/lib/native-nav.js +1 -1
  109. package/src/lib/native-nav.js.map +1 -1
  110. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  111. package/src/lib/server/add-contact-by-hand.js +212 -0
  112. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  113. package/src/lib/server/api-v1/contacts.js +61 -23
  114. package/src/lib/server/api-v1/contacts.js.map +1 -1
  115. package/src/lib/server/api-v1/crm-activities.js +13 -3
  116. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  117. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  118. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  119. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  120. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  121. package/src/lib/server/api-v1/crm-shared.js +23 -11
  122. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  123. package/src/lib/server/api-v1/openapi.js +3 -3
  124. package/src/lib/server/api-v1/openapi.js.map +1 -1
  125. package/src/lib/server/automation-steps.js +11 -9
  126. package/src/lib/server/automation-steps.js.map +1 -1
  127. package/src/lib/server/campaign-filing-activity.js +7 -2
  128. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  129. package/src/lib/server/contact-update.d.ts +4 -0
  130. package/src/lib/server/contact-update.js +71 -10
  131. package/src/lib/server/contact-update.js.map +1 -1
  132. package/src/lib/server/convert-host-lead.js +13 -2
  133. package/src/lib/server/convert-host-lead.js.map +1 -1
  134. package/src/lib/server/convert-open-lead.js +8 -1
  135. package/src/lib/server/convert-open-lead.js.map +1 -1
  136. package/src/lib/server/crm-activity-company.d.ts +25 -0
  137. package/src/lib/server/crm-activity-company.js +34 -0
  138. package/src/lib/server/crm-activity-company.js.map +1 -0
  139. package/src/lib/server/crm-inbound-email.js +21 -15
  140. package/src/lib/server/crm-inbound-email.js.map +1 -1
  141. package/src/lib/server/crm-sharing.js +9 -2
  142. package/src/lib/server/crm-sharing.js.map +1 -1
  143. package/src/lib/server/email-send.js +10 -8
  144. package/src/lib/server/email-send.js.map +1 -1
  145. package/src/lib/server/merge-contacts.js +6 -2
  146. package/src/lib/server/merge-contacts.js.map +1 -1
  147. package/src/lib/server/org-caller.d.ts +8 -0
  148. package/src/lib/server/org-caller.js +10 -0
  149. package/src/lib/server/org-caller.js.map +1 -1
  150. package/src/lib/server/record-timeline.d.ts +3 -1
  151. package/src/lib/server/record-timeline.js +11 -3
  152. package/src/lib/server/record-timeline.js.map +1 -1
  153. package/src/lib/server/task-reminders-route.d.ts +7 -5
  154. package/src/lib/server/task-reminders-route.js +82 -15
  155. package/src/lib/server/task-reminders-route.js.map +1 -1
  156. package/src/lib/server.js +152 -43
  157. package/src/lib/server.js.map +1 -1
  158. package/src/lib/transfer/contacts.js +83 -48
  159. package/src/lib/transfer/contacts.js.map +1 -1
  160. package/src/lib/transfer/fields.js +1 -2
  161. package/src/lib/transfer/fields.js.map +1 -1
@@ -0,0 +1,113 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { ORG_CONTACT_HOLDER_ID, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { contactPrimaryGroup } from "./contact-holder.js";
19
+ /** A stored id, or `''` for anything that is not one. */ const idOf = (value)=>typeof value === 'string' ? value.trim() : '';
20
+ /** The company an activity rolls up to, or `null` when nothing names one. */ export function crmActivityCompanyId(link, sources) {
21
+ var _sources_deal;
22
+ const named = idOf(link.companyId) || idOf((_sources_deal = sources.deal) == null ? void 0 : _sources_deal['companyId']);
23
+ if (named) return named;
24
+ const { contact, org } = sources;
25
+ if (!contact) return null;
26
+ const groupId = sources.groupId || contactPrimaryGroup(contact, org).groupId;
27
+ return idOf(readContactFacet(contact, groupId).companyId) || idOf(readContactFacet(contact, ORG_CONTACT_HOLDER_ID).companyId) || null;
28
+ }
29
+ /**
30
+ * `link` with its Account stamped. A link with no company to find comes back
31
+ * as it went in, with no blank key: Firestore refuses `undefined`.
32
+ */ export function stampCrmActivityCompany(link, sources) {
33
+ const companyId = crmActivityCompanyId(link, sources);
34
+ return companyId && companyId !== link.companyId ? _extends({}, link, {
35
+ companyId
36
+ }) : link;
37
+ }
38
+ /**
39
+ * {@link stampCrmActivityCompany}, reading what the writer does not hold.
40
+ *
41
+ * The deal is read first and the contact only when the deal names no company,
42
+ * so an activity costs the fewest reads that answer it, and none when a
43
+ * company was named.
44
+ *
45
+ * **Never throws.** The rollup is bookkeeping beside the entry, and a record
46
+ * this reader may not open, or a read that fails, must not cost the person
47
+ * the call they are logging. The entry is written with the links it has, and
48
+ * the company's timeline is the one thing it misses.
49
+ */ export async function resolveCrmActivityCompany(link, sources, reads) {
50
+ var _sources_deal, _sources_contact;
51
+ if (link.companyId) return link;
52
+ const attempt = async (read)=>{
53
+ try {
54
+ return await read();
55
+ } catch (error) {
56
+ console.warn('[crm] a record could not be read for the activity rollup', error);
57
+ return null;
58
+ }
59
+ };
60
+ const dealId = idOf(link.dealId);
61
+ const deal = (_sources_deal = sources.deal) != null ? _sources_deal : dealId ? await attempt(()=>reads.deal(dealId)) : null;
62
+ const contactId = idOf(link.contactId);
63
+ const contact = (_sources_contact = sources.contact) != null ? _sources_contact : contactId && !idOf(deal == null ? void 0 : deal['companyId']) ? await attempt(()=>reads.contact(contactId)) : null;
64
+ return stampCrmActivityCompany(link, _extends({}, sources, {
65
+ deal,
66
+ contact
67
+ }));
68
+ }
69
+ /**
70
+ * The fields as they open on `link`: a new activity's record, or an activity
71
+ * being edited. A deal outranks the company stamped beside it, because the
72
+ * deal is the What and the company only its rollup.
73
+ *
74
+ * Opened from a deal, **Name** defaults to the deal's Primary, so the entry
75
+ * lands on that person's timeline too. A link that already names a contact
76
+ * keeps them.
77
+ */ export function crmActivityPicksFrom(link, primaryContactId) {
78
+ const contactId = idOf(link.contactId) || idOf(primaryContactId);
79
+ const dealId = idOf(link.dealId);
80
+ const companyId = idOf(link.companyId);
81
+ return {
82
+ contactId: contactId || null,
83
+ related: dealId ? {
84
+ kind: 'deal',
85
+ id: dealId
86
+ } : companyId ? {
87
+ kind: 'company',
88
+ id: companyId
89
+ } : null
90
+ };
91
+ }
92
+ /**
93
+ * The links the fields name, before the Account is stamped. A lead's own log
94
+ * keeps its lead: a person not yet converted is not a contact the field could
95
+ * name.
96
+ */ export function crmActivityLinkFromPicks(picks, leadId) {
97
+ const { contactId, related } = picks;
98
+ return _extends({}, contactId ? {
99
+ contactId
100
+ } : {}, (related == null ? void 0 : related.kind) === 'deal' ? {
101
+ dealId: related.id
102
+ } : {}, (related == null ? void 0 : related.kind) === 'company' ? {
103
+ companyId: related.id
104
+ } : {}, leadId ? {
105
+ leadId
106
+ } : {});
107
+ }
108
+ /** Whether two sets of picks name the same records. */ export function crmActivityPicksEqual(a, b) {
109
+ var _a_related, _b_related, _a_related1, _b_related1;
110
+ return a.contactId === b.contactId && ((_a_related = a.related) == null ? void 0 : _a_related.kind) === ((_b_related = b.related) == null ? void 0 : _b_related.kind) && ((_a_related1 = a.related) == null ? void 0 : _a_related1.id) === ((_b_related1 = b.related) == null ? void 0 : _b_related1.id);
111
+ }
112
+
113
+ //# sourceMappingURL=crm-activity-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-activity-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { ORG_CONTACT_HOLDER_ID, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport type { CrmActivityLink } from '@aglyn/aglyn/app-utils/crm'\nimport { contactPrimaryGroup } from './contact-holder'\n\n/**\n * WHO, WHAT AND WHICH ACCOUNT AN ACTIVITY IS ABOUT (AGL-4123).\n *\n * Salesforce's activity model, on the three links an activity already had:\n * `contactId` is its Who (the **Name** field), `dealId` or `companyId` its\n * What (the **Related To** field), and `companyId` its Account, the rollup\n * that puts a call with a person, or about a deal, on the timeline of the\n * company behind them. Each timeline lists by its own field, so an entry\n * that carries all three shows on all three pages.\n *\n * Every writer of `crmActivities` stamps the Account here. A rollup a writer\n * spelled for itself is the one that would disagree: a company's timeline is\n * only as complete as the least careful door that writes to it.\n *\n * ## The order\n *\n * A company the writer named stands: it was said on purpose. Otherwise the\n * deal's, because the deal is the thing the entry is about; otherwise the\n * contact's.\n *\n * ## A contact's company is one holder's knowledge\n *\n * Which company a person is at lives in a holder's facet, and that is what is\n * read. `companyIds` is the facet's mirror for queries, and carries every\n * holder's link, so reading it here would file one business's call under a\n * company only a sibling brand knows the person by. A writer standing on a\n * site names that site's group; one standing at the organization names none\n * and reads the person's primary holder, the one their page shows. The facet\n * of a person the organization added itself (AGL-4122) is the one exception:\n * it is the whole org's, so it answers for any site that has no link of its\n * own.\n */\n\n/** What the Account is worked out from, as the writer already holds it. */\nexport interface CrmActivityCompanySources {\n /** The deal the activity names, when the writer has read it. */\n deal?: Record<string, unknown> | null\n /** The contact the activity names, when the writer has read it. */\n contact?: Record<string, unknown> | null\n /** The holder whose facet is read; absent, the contact's primary holder. */\n groupId?: string | null\n /** The org document, for the primary holder when {@link groupId} is absent. */\n org?: Record<string, unknown> | null\n}\n\n/** A link as a stamp hands it back: what went in, and the company it found. */\nexport type StampedCrmActivityLink<Link extends CrmActivityLink> = Link &\n Pick<CrmActivityLink, 'companyId'>\n\n/** A stored id, or `''` for anything that is not one. */\nconst idOf = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/** The company an activity rolls up to, or `null` when nothing names one. */\nexport function crmActivityCompanyId(\n link: Pick<CrmActivityLink, 'companyId'>,\n sources: CrmActivityCompanySources,\n): string | null {\n const named = idOf(link.companyId) || idOf(sources.deal?.['companyId'])\n if (named) return named\n const { contact, org } = sources\n if (!contact) return null\n const groupId = sources.groupId || contactPrimaryGroup(contact, org).groupId\n return (\n idOf(readContactFacet(contact, groupId).companyId) ||\n idOf(readContactFacet(contact, ORG_CONTACT_HOLDER_ID).companyId) ||\n null\n )\n}\n\n/**\n * `link` with its Account stamped. A link with no company to find comes back\n * as it went in, with no blank key: Firestore refuses `undefined`.\n */\nexport function stampCrmActivityCompany<Link extends CrmActivityLink>(\n link: Link,\n sources: CrmActivityCompanySources,\n): StampedCrmActivityLink<Link> {\n const companyId = crmActivityCompanyId(link, sources)\n return companyId && companyId !== link.companyId ? { ...link, companyId } : link\n}\n\n/** Each side's own way to read a record by id: the Admin SDK's, or the client's. */\nexport interface CrmActivityRecordReads {\n deal(id: string): Promise<Record<string, unknown> | null>\n contact(id: string): Promise<Record<string, unknown> | null>\n}\n\n/**\n * {@link stampCrmActivityCompany}, reading what the writer does not hold.\n *\n * The deal is read first and the contact only when the deal names no company,\n * so an activity costs the fewest reads that answer it, and none when a\n * company was named.\n *\n * **Never throws.** The rollup is bookkeeping beside the entry, and a record\n * this reader may not open, or a read that fails, must not cost the person\n * the call they are logging. The entry is written with the links it has, and\n * the company's timeline is the one thing it misses.\n */\nexport async function resolveCrmActivityCompany<Link extends CrmActivityLink>(\n link: Link,\n sources: CrmActivityCompanySources,\n reads: CrmActivityRecordReads,\n): Promise<StampedCrmActivityLink<Link>> {\n if (link.companyId) return link\n const attempt = async (read: () => Promise<Record<string, unknown> | null>) => {\n try {\n return await read()\n } catch (error) {\n console.warn('[crm] a record could not be read for the activity rollup', error)\n return null\n }\n }\n const dealId = idOf(link.dealId)\n const deal = sources.deal ?? (dealId ? await attempt(() => reads.deal(dealId)) : null)\n const contactId = idOf(link.contactId)\n const contact =\n sources.contact ??\n (contactId && !idOf(deal?.['companyId'])\n ? await attempt(() => reads.contact(contactId))\n : null)\n return stampCrmActivityCompany(link, { ...sources, deal, contact })\n}\n\n/** What **Related To** names: one company or one deal, Salesforce's What. */\nexport interface CrmActivityRelated {\n kind: 'company' | 'deal'\n id: string\n}\n\n/** The two fields of the log dialog, as a person leaves them. */\nexport interface CrmActivityPicks {\n /** **Name**: the contact it was with, Salesforce's Who. */\n contactId: string | null\n /** **Related To**. */\n related: CrmActivityRelated | null\n}\n\n/**\n * The fields as they open on `link`: a new activity's record, or an activity\n * being edited. A deal outranks the company stamped beside it, because the\n * deal is the What and the company only its rollup.\n *\n * Opened from a deal, **Name** defaults to the deal's Primary, so the entry\n * lands on that person's timeline too. A link that already names a contact\n * keeps them.\n */\nexport function crmActivityPicksFrom(\n link: Pick<CrmActivityLink, 'contactId' | 'companyId' | 'dealId'>,\n primaryContactId?: string | null,\n): CrmActivityPicks {\n const contactId = idOf(link.contactId) || idOf(primaryContactId)\n const dealId = idOf(link.dealId)\n const companyId = idOf(link.companyId)\n return {\n contactId: contactId || null,\n related: dealId\n ? { kind: 'deal', id: dealId }\n : companyId\n ? { kind: 'company', id: companyId }\n : null,\n }\n}\n\n/**\n * The links the fields name, before the Account is stamped. A lead's own log\n * keeps its lead: a person not yet converted is not a contact the field could\n * name.\n */\nexport function crmActivityLinkFromPicks(\n picks: CrmActivityPicks,\n leadId?: string | null,\n): CrmActivityLink {\n const { contactId, related } = picks\n return {\n ...(contactId ? { contactId } : {}),\n ...(related?.kind === 'deal' ? { dealId: related.id } : {}),\n ...(related?.kind === 'company' ? { companyId: related.id } : {}),\n ...(leadId ? { leadId } : {}),\n }\n}\n\n/** Whether two sets of picks name the same records. */\nexport function crmActivityPicksEqual(a: CrmActivityPicks, b: CrmActivityPicks): boolean {\n return (\n a.contactId === b.contactId &&\n a.related?.kind === b.related?.kind &&\n a.related?.id === b.related?.id\n )\n}\n"],"names":["ORG_CONTACT_HOLDER_ID","readContactFacet","contactPrimaryGroup","idOf","value","trim","crmActivityCompanyId","link","sources","named","companyId","deal","contact","org","groupId","stampCrmActivityCompany","resolveCrmActivityCompany","reads","attempt","read","error","console","warn","dealId","contactId","crmActivityPicksFrom","primaryContactId","related","kind","id","crmActivityLinkFromPicks","picks","leadId","crmActivityPicksEqual","a","b"],"mappings":"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"}
@@ -44,6 +44,9 @@ export interface ContactStageResult {
44
44
  * "Not a site admin or editor" rather than a generic failure; a network
45
45
  * failure throws as `fetch` would.
46
46
  */
47
- export declare function setContactStage(user: MaybeTokenSource, hostId: string, contactId: string, stage: ContactLifecycleStage | null, options?: {
47
+ export declare function setContactStage(user: MaybeTokenSource,
48
+ /** The site, or `null` for a person no site holds (AGL-4122), named by `orgId`. */
49
+ hostId: string | null, contactId: string, stage: ContactLifecycleStage | null, options?: {
48
50
  fetchImpl?: typeof fetch;
51
+ orgId?: string | null;
49
52
  }): Promise<ContactStageResult>;
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
17
18
  import { crmApiUrl } from "../constants/api-routes.js";
18
19
  /**
19
20
  * Moves one contact to a lifecycle stage — or, with `null`, clears it — as
@@ -22,17 +23,20 @@ import { crmApiUrl } from "../constants/api-routes.js";
22
23
  * Throws with the route's own message on a refusal, so a control can show
23
24
  * "Not a site admin or editor" rather than a generic failure; a network
24
25
  * failure throws as `fetch` would.
25
- */ export async function setContactStage(user, hostId, contactId, stage, options = {}) {
26
+ */ export async function setContactStage(user, /** The site, or `null` for a person no site holds (AGL-4122), named by `orgId`. */ hostId, contactId, stage, options = {}) {
26
27
  const response = await authorizedFetch(user, crmApiUrl('contactStage'), {
27
28
  method: 'POST',
28
29
  headers: {
29
30
  'Content-Type': 'application/json'
30
31
  },
31
- body: JSON.stringify({
32
- hostId,
32
+ body: JSON.stringify(_extends({
33
+ hostId
34
+ }, hostId ? {} : {
35
+ orgId: options.orgId
36
+ }, {
33
37
  contactId,
34
38
  lifecycleStage: stage
35
- })
39
+ }))
36
40
  }, options.fetchImpl ? {
37
41
  fetchImpl: options.fetchImpl
38
42
  } : {});
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ContactLifecycleStage } from '@aglyn/aglyn'\nimport {\n authorizedFetch,\n type MaybeTokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport { crmApiUrl } from '../constants/api-routes'\n\n/**\n * The console's client half of the CRM server routes (AGL-2605).\n *\n * A stage change goes through the server rather than a client-direct\n * Firestore write, and the reason is the event: `contactStageChanged` can\n * only be emitted by a server path that performed the write, so a record\n * page that wrote the facet itself would move the person and tell no\n * automation. A CLEAR comes the same way (AGL-2804), because a facet is the\n * server's to write. This is the one function a stage control calls.\n */\n\n/** What `crm/contact-stage` answers. */\nexport interface ContactStageResult {\n ok: true\n /** False when the contact was already in that stage — nothing moved. */\n changed: boolean\n /** The stage the contact now holds, `''` after a clear. */\n lifecycleStage: ContactLifecycleStage | ''\n /** The stage before the move, `''` when the contact had none. */\n previousStage: string\n}\n\n/**\n * Moves one contact to a lifecycle stage — or, with `null`, clears it — as\n * the signed-in user.\n *\n * Throws with the route's own message on a refusal, so a control can show\n * \"Not a site admin or editor\" rather than a generic failure; a network\n * failure throws as `fetch` would.\n */\nexport async function setContactStage(\n user: MaybeTokenSource,\n hostId: string,\n contactId: string,\n stage: ContactLifecycleStage | null,\n options: { fetchImpl?: typeof fetch } = {},\n): Promise<ContactStageResult> {\n const response = await authorizedFetch(\n user,\n crmApiUrl('contactStage'),\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, contactId, lifecycleStage: stage }),\n },\n options.fetchImpl ? { fetchImpl: options.fetchImpl } : {},\n )\n const payload = (await response.json().catch(() => ({}))) as\n | ContactStageResult\n | { error?: string }\n if (!response.ok) {\n throw new Error(\n (payload as { error?: string }).error ?? 'The stage could not be changed',\n )\n }\n return payload as ContactStageResult\n}\n"],"names":["authorizedFetch","crmApiUrl","setContactStage","user","hostId","contactId","stage","options","response","method","headers","body","JSON","stringify","lifecycleStage","fetchImpl","payload","json","catch","ok","Error","error"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SACEA,eAAe,QAEV,2CAA0C;AACjD,SAASC,SAAS,QAAQ,6BAAyB;AAwBnD;;;;;;;CAOC,GACD,OAAO,eAAeC,gBACpBC,IAAsB,EACtBC,MAAc,EACdC,SAAiB,EACjBC,KAAmC,EACnCC,UAAwC,CAAC,CAAC;IAE1C,MAAMC,WAAW,MAAMR,gBACrBG,MACAF,UAAU,iBACV;QACEQ,QAAQ;QACRC,SAAS;YAAE,gBAAgB;QAAmB;QAC9CC,MAAMC,KAAKC,SAAS,CAAC;YAAET;YAAQC;YAAWS,gBAAgBR;QAAM;IAClE,GACAC,QAAQQ,SAAS,GAAG;QAAEA,WAAWR,QAAQQ,SAAS;IAAC,IAAI,CAAC;IAE1D,MAAMC,UAAW,MAAMR,SAASS,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;IAGrD,IAAI,CAACV,SAASW,EAAE,EAAE;YAEd;QADF,MAAM,IAAIC,OACR,iBAAA,AAACJ,QAA+BK,KAAK,YAArC,iBAAyC;IAE7C;IACA,OAAOL;AACT"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ContactLifecycleStage } from '@aglyn/aglyn'\nimport {\n authorizedFetch,\n type MaybeTokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport { crmApiUrl } from '../constants/api-routes'\n\n/**\n * The console's client half of the CRM server routes (AGL-2605).\n *\n * A stage change goes through the server rather than a client-direct\n * Firestore write, and the reason is the event: `contactStageChanged` can\n * only be emitted by a server path that performed the write, so a record\n * page that wrote the facet itself would move the person and tell no\n * automation. A CLEAR comes the same way (AGL-2804), because a facet is the\n * server's to write. This is the one function a stage control calls.\n */\n\n/** What `crm/contact-stage` answers. */\nexport interface ContactStageResult {\n ok: true\n /** False when the contact was already in that stage — nothing moved. */\n changed: boolean\n /** The stage the contact now holds, `''` after a clear. */\n lifecycleStage: ContactLifecycleStage | ''\n /** The stage before the move, `''` when the contact had none. */\n previousStage: string\n}\n\n/**\n * Moves one contact to a lifecycle stage — or, with `null`, clears it — as\n * the signed-in user.\n *\n * Throws with the route's own message on a refusal, so a control can show\n * \"Not a site admin or editor\" rather than a generic failure; a network\n * failure throws as `fetch` would.\n */\nexport async function setContactStage(\n user: MaybeTokenSource,\n /** The site, or `null` for a person no site holds (AGL-4122), named by `orgId`. */\n hostId: string | null,\n contactId: string,\n stage: ContactLifecycleStage | null,\n options: { fetchImpl?: typeof fetch; orgId?: string | null } = {},\n): Promise<ContactStageResult> {\n const response = await authorizedFetch(\n user,\n crmApiUrl('contactStage'),\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n ...(hostId ? {} : { orgId: options.orgId }),\n contactId,\n lifecycleStage: stage,\n }),\n },\n options.fetchImpl ? { fetchImpl: options.fetchImpl } : {},\n )\n const payload = (await response.json().catch(() => ({}))) as\n | ContactStageResult\n | { error?: string }\n if (!response.ok) {\n throw new Error(\n (payload as { error?: string }).error ?? 'The stage could not be changed',\n )\n }\n return payload as ContactStageResult\n}\n"],"names":["authorizedFetch","crmApiUrl","setContactStage","user","hostId","contactId","stage","options","response","method","headers","body","JSON","stringify","orgId","lifecycleStage","fetchImpl","payload","json","catch","ok","Error","error"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,eAAe,QAEV,2CAA0C;AACjD,SAASC,SAAS,QAAQ,6BAAyB;AAwBnD;;;;;;;CAOC,GACD,OAAO,eAAeC,gBACpBC,IAAsB,EACtB,iFAAiF,GACjFC,MAAqB,EACrBC,SAAiB,EACjBC,KAAmC,EACnCC,UAA+D,CAAC,CAAC;IAEjE,MAAMC,WAAW,MAAMR,gBACrBG,MACAF,UAAU,iBACV;QACEQ,QAAQ;QACRC,SAAS;YAAE,gBAAgB;QAAmB;QAC9CC,MAAMC,KAAKC,SAAS,CAAC;YACnBT;WACIA,SAAS,CAAC,IAAI;YAAEU,OAAOP,QAAQO,KAAK;QAAC;YACzCT;YACAU,gBAAgBT;;IAEpB,GACAC,QAAQS,SAAS,GAAG;QAAEA,WAAWT,QAAQS,SAAS;IAAC,IAAI,CAAC;IAE1D,MAAMC,UAAW,MAAMT,SAASU,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;IAGrD,IAAI,CAACX,SAASY,EAAE,EAAE;YAEd;QADF,MAAM,IAAIC,OACR,iBAAA,AAACJ,QAA+BK,KAAK,YAArC,iBAAyC;IAE7C;IACA,OAAOL;AACT"}
@@ -292,9 +292,11 @@ function addAmount(totals, currency, cents) {
292
292
  return renumberStages(sorted);
293
293
  }
294
294
  export function renameStage(stages, stageId, name) {
295
- const trimmed = String(name != null ? name : '').trim();
296
- return stages.map((stage)=>stage.id === stageId && trimmed ? _extends({}, stage, {
297
- name: trimmed
295
+ // As typed: a trailing space is how the second word of a name begins, so a
296
+ // trim here ate every space. `settleStageNames` tidies them for the save.
297
+ const typed = String(name != null ? name : '');
298
+ return stages.map((stage)=>stage.id === stageId ? _extends({}, stage, {
299
+ name: typed
298
300
  }) : stage);
299
301
  }
300
302
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/deal-board-model.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The pipeline's arithmetic and the stage editor's rules (AGL-2598), as pure\n * functions — no Firestore, no React — so the board, the table, the detail\n * page and the server route read one definition of \"what is this pipeline\n * worth\" and \"which stage edits are legal\".\n */\n\n// The module itself and never the `@aglyn/aglyn` barrel: this file is\n// reached from the server entry through the deal-stage route, and the barrel\n// carries client-only context modules the App Router graph refuses on a\n// server path (AGL-405/1349).\nimport {\n type CrmDeal,\n type CrmDealStage,\n type CrmForecastCategory,\n type CrmPipeline,\n DEFAULT_DEAL_STAGES,\n dealStageById,\n isCrmForecastCategory,\n isPipelineArchived,\n weightedDealAmountCents,\n} from '@aglyn/aglyn/app-utils/crm'\n\n/** A deal as the console reads it: the model plus its document id. */\nexport type DealDoc = CrmDeal & {\n $id: string\n /**\n * The contact's and the company's names, copied onto the deal when the\n * link is made. A board of two hundred cards cannot afford a keyed read per\n * card to caption each one, and the caption is allowed to lag a rename —\n * the link itself is the id, which never changes.\n */\n contactName?: string\n companyName?: string\n}\n\n/** A pipeline as the console reads it. */\nexport type PipelineDoc = CrmPipeline & { $id: string }\n\n/** The document id the seeded default pipeline is written under. */\nexport const DEFAULT_PIPELINE_ID = 'default'\n\n/** Name of the pipeline every org starts with. */\nexport const DEFAULT_PIPELINE_NAME = 'Sales'\n\nexport const PIPELINE_NAME_MAX = 60\n\n/** The pipelines a picker offers and a new deal may land in: the ones not archived. */\nexport function activePipelines<T extends Pick<CrmPipeline, 'archivedAt'>>(\n pipelines: readonly T[],\n): T[] {\n return pipelines.filter((pipeline) => !isPipelineArchived(pipeline))\n}\n\n/**\n * The pipeline a new deal lands in when nobody picks one: the active one\n * marked default, else the first active one, else nothing. An archived\n * pipeline is never the default even if its flag was left on — archiving\n * clears the flag, but a document written by an older console may not\n * have had it cleared, and a default nobody can create a deal in is a\n * board that opens on a pipeline the picker does not list.\n */\nexport function defaultPipelineOf<T extends Pick<CrmPipeline, 'archivedAt' | 'isDefault'>>(\n pipelines: readonly T[],\n): T | null {\n const active = activePipelines(pipelines)\n return active.find((pipeline) => pipeline.isDefault) ?? active[0] ?? null\n}\n\n/**\n * Why a pipeline cannot be created or renamed to `name`, or `null`.\n *\n * Names are unique among the ACTIVE pipelines, compared without case: the\n * picker lists them by name alone, and two rows reading \"Sales\" would be a\n * choice nobody can make. An archived pipeline's name is free to reuse —\n * \"Sales 2025\" retired and \"Sales\" started over is the point of archiving.\n */\nexport function pipelineNameProblem(\n name: string,\n pipelines: ReadonlyArray<Pick<CrmPipeline, 'name' | 'archivedAt'> & { $id: string }>,\n ownId?: string,\n): string | null {\n const trimmed = String(name ?? '').trim()\n if (!trimmed) return 'A pipeline needs a name.'\n const taken = activePipelines(pipelines).find(\n (pipeline) =>\n pipeline.$id !== ownId &&\n String(pipeline.name ?? '').trim().toLowerCase() === trimmed.toLowerCase(),\n )\n if (taken) return `Another pipeline is already called \"${taken.name}\".`\n return null\n}\n\n/**\n * Why a pipeline cannot be archived, or `null` when it can.\n *\n * `openDeals` is the server's count, asked for before this is called, the\n * way `stageRemovalRefusal` asks: an open deal in an archived pipeline is\n * a card on no board, and the count is taken at the click because a deal\n * can be created into the pipeline from another tab meanwhile. The default\n * pipeline cannot go while it is the default — a new deal has to land\n * somewhere — and the last active one cannot go at all, for the same\n * reason from the other side.\n */\nexport function pipelineArchiveRefusal(\n pipeline: Pick<CrmPipeline, 'archivedAt' | 'isDefault'> & { $id: string },\n pipelines: ReadonlyArray<Pick<CrmPipeline, 'archivedAt'> & { $id: string }>,\n openDeals: number,\n): string | null {\n if (isPipelineArchived(pipeline)) return 'This pipeline is already archived.'\n if (activePipelines(pipelines).filter((entry) => entry.$id !== pipeline.$id).length === 0) {\n return 'The last pipeline cannot be archived.'\n }\n if (pipeline.isDefault) {\n return 'This is the default pipeline. Make another one the default first.'\n }\n if (openDeals > 0) {\n return (\n `${openDeals.toLocaleString()} open ${openDeals === 1 ? 'deal is' : 'deals are'} ` +\n 'in this pipeline. Close or move them first, so none is left on a board nobody opens.'\n )\n }\n return null\n}\n\n/** What every pipeline write stamps besides its name. */\nexport interface PipelineWriteContext {\n visibleTo: readonly string[]\n hostId: string\n uid: string\n nowMs: number\n}\n\n/**\n * The document a new pipeline is created as: the name, a COPY of the\n * default stages, not the default, active. A copy for the reason\n * `DEFAULT_DEAL_STAGES` gives — a second pipeline seeded later must start\n * from the original set, whatever the first one's editor did to its own.\n */\nexport function newPipelineDocument(\n name: string,\n context: PipelineWriteContext,\n): Record<string, unknown> {\n return {\n name: String(name ?? '').trim().slice(0, PIPELINE_NAME_MAX),\n stages: DEFAULT_DEAL_STAGES.map((stage) => ({ ...stage })),\n isDefault: false,\n archivedAt: null,\n visibleTo: [...context.visibleTo],\n hostId: context.hostId,\n createdByUid: context.uid,\n createdAt: new Date(context.nowMs),\n updatedAt: new Date(context.nowMs),\n }\n}\n\n/**\n * Currencies the amount picker offers, lowercase ISO 4217 as the model\n * stores them. Short on purpose: a picker of a hundred and eighty currencies\n * is a scroll, and a deal in one outside this list is entered through the\n * API with whatever code it carries — the display below formats any code\n * `Intl` knows.\n */\nexport const DEAL_CURRENCIES = [\n 'usd',\n 'eur',\n 'gbp',\n 'cad',\n 'aud',\n 'nzd',\n 'chf',\n 'jpy',\n 'sek',\n 'nok',\n 'dkk',\n 'mxn',\n 'brl',\n 'inr',\n 'sgd',\n] as const\n\nexport const DEFAULT_DEAL_CURRENCY = 'usd'\n\n/** How a deal's `status` reads on screen. */\nexport const DEAL_STATUS_LABELS: Record<CrmDeal['status'], string> = {\n open: 'Open',\n won: 'Won',\n lost: 'Lost',\n}\n\n/** Cents → the reader's locale, or a plain number when the code is unknown. */\nexport function formatMoney(cents: number, currency: string | undefined): string {\n const code = String(currency || DEFAULT_DEAL_CURRENCY).toUpperCase()\n const amount = (Number(cents) || 0) / 100\n try {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: code,\n }).format(amount)\n } catch {\n return `${amount.toFixed(2)} ${code}`\n }\n}\n\n/**\n * A typed amount, as cents, or `null` when it is not a number.\n *\n * Accepts what people paste from an invoice — a thousands separator, a\n * currency symbol, surrounding space — and refuses a negative: a deal is\n * worth zero or more, and a negative amount here is a typo rather than a\n * refund. Rounded rather than truncated so `19.995` does not lose a cent.\n */\nexport function parseAmountInput(input: string): number | null {\n const cleaned = String(input ?? '')\n .replace(/[^0-9.-]/g, '')\n .trim()\n if (!cleaned) return null\n const value = Number(cleaned)\n if (!Number.isFinite(value) || value < 0) return null\n return Math.round(value * 100)\n}\n\n/** Cents → the string the amount field shows, `''` for nothing. */\nexport function amountInputValue(cents: number | undefined | null): string {\n if (typeof cents !== 'number' || !Number.isFinite(cents)) return ''\n return (cents / 100).toFixed(2)\n}\n\n/**\n * Epoch ms from whatever a timestamp field holds after a round trip — a\n * Firestore `Timestamp`, a `Date` a client just wrote, or a number.\n */\nexport function timestampMs(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n if (value instanceof Date) return value.getTime()\n const seconds = (value as { seconds?: unknown }).seconds\n if (typeof seconds === 'number') return seconds * 1000\n const toMillis = (value as { toMillis?: () => number }).toMillis\n if (typeof toMillis === 'function') return toMillis.call(value)\n return null\n}\n\n/** Whole days a deal has sat in its current stage, never negative. */\nexport function daysInStage(\n deal: Pick<CrmDeal, 'stageChangedAtMs' | 'createdAt'>,\n nowMs: number,\n): number {\n const since =\n typeof deal.stageChangedAtMs === 'number'\n ? deal.stageChangedAtMs\n : timestampMs(deal.createdAt)\n if (!since) return 0\n return Math.max(0, Math.floor((nowMs - since) / 86_400_000))\n}\n\n/** A sum per currency, because two currencies never add into one number. */\nexport type AmountByCurrency = Record<string, number>\n\nfunction addAmount(\n totals: AmountByCurrency,\n currency: string | undefined,\n cents: number,\n): void {\n const code = String(currency || DEFAULT_DEAL_CURRENCY).toLowerCase()\n totals[code] = (totals[code] ?? 0) + cents\n}\n\nexport interface BoardSummary {\n /** Deals whose `status` is `open`, whatever stage they sit in. */\n openCount: number\n /** Every open deal's full amount, per currency. */\n valueByCurrency: AmountByCurrency\n /** Every open deal's amount times its stage's probability, per currency. */\n weightedByCurrency: AmountByCurrency\n}\n\n/**\n * The three figures above the board.\n *\n * Closed deals are excluded from both totals rather than counted at full or\n * zero value: the summary describes what is still in play, and a won deal\n * counted in \"pipeline value\" would make the pipeline look larger every time\n * something closed. `weightedDealAmountCents` already answers zero for a\n * stage the pipeline no longer has.\n */\nexport function boardSummary(\n deals: readonly (Pick<CrmDeal, 'status' | 'amountCents' | 'currency' | 'stageId'> &\n Partial<Pick<CrmDeal, 'probability'>>)[],\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): BoardSummary {\n const summary: BoardSummary = {\n openCount: 0,\n valueByCurrency: {},\n weightedByCurrency: {},\n }\n for (const deal of deals) {\n if (deal.status !== 'open') continue\n summary.openCount += 1\n const amount = Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0))\n addAmount(summary.valueByCurrency, deal.currency, amount)\n addAmount(\n summary.weightedByCurrency,\n deal.currency,\n weightedDealAmountCents(deal, dealStageById(pipeline, deal.stageId)),\n )\n }\n return summary\n}\n\n/** `$1,200.00 · €300.00` — one figure per currency, largest first. */\nexport function formatAmountByCurrency(totals: AmountByCurrency): string {\n const entries = Object.entries(totals).sort((a, b) => b[1] - a[1])\n if (!entries.length) return formatMoney(0, DEFAULT_DEAL_CURRENCY)\n return entries.map(([code, cents]) => formatMoney(cents, code)).join(' · ')\n}\n\n/** The pipeline's stages in `order`, ties broken by position. */\nexport function sortedStages(\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): CrmDealStage[] {\n return [...(pipeline?.stages ?? [])]\n .map((stage, index) => ({ stage, index }))\n .sort((a, b) => a.stage.order - b.stage.order || a.index - b.index)\n .map(({ stage }) => stage)\n}\n\n/** The stages a deal can be dragged between — everything that is not closed. */\nexport function openStages(\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): CrmDealStage[] {\n return sortedStages(pipeline).filter((stage) => stage.kind === 'open')\n}\n\n/** The stage whose `kind` is `won` or `lost`, or null when the pipeline lacks one. */\nexport function closingStage(\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n kind: 'won' | 'lost',\n): CrmDealStage | null {\n return sortedStages(pipeline).find((stage) => stage.kind === kind) ?? null\n}\n\n/**\n * The id a new stage is stored under: the name as a slug, made unique\n * against the stages already there.\n *\n * A slug rather than a random id because the id appears in every deal that\n * sits in the stage and in every automation filter that names it, so a\n * reader looking at `stageId == \"demo-booked\"` should be able to tell which\n * stage that is without opening the pipeline. Uniqueness is what keeps a\n * second \"Demo\" from silently becoming the first.\n */\nexport function newStageId(\n name: string,\n existing: readonly CrmDealStage[],\n): string {\n const base =\n String(name ?? '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 40) || 'stage'\n const taken = new Set(existing.map((stage) => stage.id))\n if (!taken.has(base)) return base\n let suffix = 2\n while (taken.has(`${base}-${suffix}`)) suffix += 1\n return `${base}-${suffix}`\n}\n\n/**\n * Stages re-numbered 0..n in the order they are GIVEN.\n *\n * Positional on purpose: every caller hands this an array it has already\n * arranged — a swap, a splice, a filter over the sorted stages — and the\n * stored `order` on each entry is the arrangement being replaced. Sorting by\n * it here would put a swapped pair straight back where it was.\n */\nexport function renumberStages(\n stages: readonly CrmDealStage[],\n): CrmDealStage[] {\n return stages.map((stage, order) => ({ ...stage, order }))\n}\n\n/** 0–100, whole numbers; anything else becomes the nearest bound. */\nexport function clampProbability(value: unknown): number {\n const number = Math.round(Number(value))\n if (!Number.isFinite(number)) return 0\n return Math.min(100, Math.max(0, number))\n}\n\n/**\n * Add an open stage in front of the closing stages.\n *\n * Won and Lost stay last: a pipeline reads left to right as a progression,\n * and a stage added after \"Won\" would be a step past the end.\n */\nexport function addStage(\n stages: readonly CrmDealStage[],\n name: string,\n probability = 50,\n): CrmDealStage[] {\n const trimmed = String(name ?? '').trim()\n if (!trimmed) return [...stages]\n const sorted = sortedStages({ stages: [...stages] })\n const firstClosed = sorted.findIndex((stage) => stage.kind !== 'open')\n const at = firstClosed === -1 ? sorted.length : firstClosed\n const stage: CrmDealStage = {\n id: newStageId(trimmed, sorted),\n name: trimmed,\n order: at,\n probability: clampProbability(probability),\n kind: 'open',\n forecastCategory: 'pipeline',\n }\n sorted.splice(at, 0, stage)\n return renumberStages(sorted)\n}\n\nexport function renameStage(\n stages: readonly CrmDealStage[],\n stageId: string,\n name: string,\n): CrmDealStage[] {\n const trimmed = String(name ?? '').trim()\n return stages.map((stage) =>\n stage.id === stageId && trimmed ? { ...stage, name: trimmed } : stage,\n )\n}\n\n/**\n * Set an OPEN stage's probability. Won is 100 and Lost is 0 by definition —\n * `weightedDealAmountCents` reads the status before the stage, so a Won\n * stage at 60% would change nothing and only mislead the editor.\n */\nexport function setStageProbability(\n stages: readonly CrmDealStage[],\n stageId: string,\n probability: unknown,\n): CrmDealStage[] {\n return stages.map((stage) =>\n stage.id === stageId && stage.kind === 'open'\n ? { ...stage, probability: clampProbability(probability) }\n : stage,\n )\n}\n\n/**\n * Set an OPEN stage's forecast category (AGL-3516). Won is Closed and Lost\n * is Omitted by definition, for the reason {@link setStageProbability}\n * gives; a deal's own category is changed on the deal.\n */\nexport function setStageForecastCategory(\n stages: readonly CrmDealStage[],\n stageId: string,\n category: CrmForecastCategory | string,\n): CrmDealStage[] {\n if (!isCrmForecastCategory(category)) return [...stages]\n return stages.map((stage) =>\n stage.id === stageId && stage.kind === 'open'\n ? { ...stage, forecastCategory: category }\n : stage,\n )\n}\n\n/**\n * Swap an open stage with its open neighbor. The closing stages are not in\n * the reorderable range, so an open stage can never be moved past Won.\n */\nexport function moveStage(\n stages: readonly CrmDealStage[],\n stageId: string,\n direction: 'up' | 'down',\n): CrmDealStage[] {\n const sorted = sortedStages({ stages: [...stages] })\n const index = sorted.findIndex((stage) => stage.id === stageId)\n if (index === -1 || sorted[index].kind !== 'open') return sorted\n const target = direction === 'up' ? index - 1 : index + 1\n if (target < 0 || target >= sorted.length) return sorted\n if (sorted[target].kind !== 'open') return sorted\n const swapped = [...sorted]\n const moving = swapped[index]\n swapped[index] = swapped[target]\n swapped[target] = moving\n return renumberStages(swapped)\n}\n\n/**\n * Why a stage cannot be removed, or `null` when it can.\n *\n * `dealsInStage` is the server's count, asked for before this is called: a\n * stage with deals in it cannot go, because every one of those deals would\n * be left pointing at a stage that no longer exists and worth nothing to the\n * forecast. The closing stages cannot go at all — a deal has to be able to\n * be won and lost.\n */\nexport function stageRemovalRefusal(\n stages: readonly CrmDealStage[],\n stageId: string,\n dealsInStage: number,\n): string | null {\n const stage = stages.find((entry) => entry.id === stageId)\n if (!stage) return 'This stage is no longer in the pipeline.'\n if (stage.kind !== 'open') {\n return `Every pipeline keeps its ${stage.kind === 'won' ? 'Won' : 'Lost'} stage.`\n }\n if (openStages({ stages: [...stages] }).length <= 1) {\n return 'A pipeline needs at least one open stage.'\n }\n if (dealsInStage > 0) {\n return (\n `${dealsInStage.toLocaleString()} ${dealsInStage === 1 ? 'deal is' : 'deals are'} ` +\n 'in this stage. Move them first, so none is left in a stage that no longer exists.'\n )\n }\n return null\n}\n\nexport function removeStage(\n stages: readonly CrmDealStage[],\n stageId: string,\n): CrmDealStage[] {\n return renumberStages(stages.filter((stage) => stage.id !== stageId))\n}\n\n/**\n * What a stored set of stages has to satisfy before it is written back:\n * unique ids, a name on every stage, exactly one Won and one Lost, at least\n * one open stage. The editor refuses a save that fails this rather than\n * writing a pipeline the board cannot draw.\n */\nexport function stagesProblem(stages: readonly CrmDealStage[]): string | null {\n if (!stages.length) return 'A pipeline needs stages.'\n const ids = new Set<string>()\n for (const stage of stages) {\n if (!stage.id) return 'Every stage needs an id.'\n if (ids.has(stage.id)) return `Two stages share the id \"${stage.id}\".`\n ids.add(stage.id)\n if (!String(stage.name ?? '').trim()) return 'Every stage needs a name.'\n }\n const won = stages.filter((stage) => stage.kind === 'won').length\n const lost = stages.filter((stage) => stage.kind === 'lost').length\n if (won !== 1) return 'A pipeline has exactly one Won stage.'\n if (lost !== 1) return 'A pipeline has exactly one Lost stage.'\n if (!stages.some((stage) => stage.kind === 'open')) {\n return 'A pipeline needs at least one open stage.'\n }\n return null\n}\n\n/**\n * The event a stage change emits, and the flat payload a workflow filter\n * reads (AGL-2598). Strings and numbers only — a workflow expression\n * evaluates over scalars, and an absent link is the empty string rather than\n * a missing key so `contactId == \"\"` is a filter somebody can write.\n */\nexport type DealEventName = 'dealStageChanged' | 'dealWon' | 'dealLost'\n\nexport function dealEventName(status: CrmDeal['status']): DealEventName {\n if (status === 'won') return 'dealWon'\n if (status === 'lost') return 'dealLost'\n return 'dealStageChanged'\n}\n\nexport function dealEventPayload(\n dealId: string,\n deal: Pick<\n CrmDeal,\n | 'title'\n | 'amountCents'\n | 'currency'\n | 'stageId'\n | 'ownerUid'\n | 'contactId'\n | 'companyId'\n | 'lostReason'\n >,\n previousStageId: string,\n): Record<string, string | number> {\n return {\n dealId,\n title: String(deal.title ?? ''),\n amountCents: Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0)),\n currency: String(deal.currency || DEFAULT_DEAL_CURRENCY).toLowerCase(),\n stageId: String(deal.stageId ?? ''),\n previousStageId: String(previousStageId ?? ''),\n ownerUid: String(deal.ownerUid ?? ''),\n contactId: String(deal.contactId ?? ''),\n companyId: String(deal.companyId ?? ''),\n ...(deal.lostReason ? { lostReason: String(deal.lostReason) } : {}),\n }\n}\n\n/** Initials for an avatar: the first letters of the first two words. */\nexport function initialsFor(label: string): string {\n const words = String(label ?? '')\n .trim()\n .split(/[\\s@._-]+/)\n .filter(Boolean)\n const letters = words\n .slice(0, 2)\n .map((word) => word[0])\n .join('')\n return (letters || '?').toUpperCase()\n}\n\n/**\n * A stable hue for one person, so an owner keeps one color across cards and\n * sessions. Hashing the label rather than picking from a palette is what\n * makes the same uid the same color on every machine.\n */\nexport function hueFor(label: string): number {\n let hash = 0\n for (let index = 0; index < label.length; index += 1) {\n hash = (hash * 31 + label.charCodeAt(index)) % 360\n }\n return hash\n}\n\n/** `2026-09-05` for a date input, from epoch ms; `''` for none. */\nexport function dateInputValue(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const at = new Date(ms)\n const offset = at.getTimezoneOffset() * 60_000\n return new Date(ms - offset).toISOString().slice(0, 10)\n}\n\n/**\n * Epoch ms at local noon of a typed `YYYY-MM-DD`, or `null`.\n *\n * Noon rather than midnight so the day survives a timezone round trip: a\n * close date stored at local midnight and read back one timezone west shows\n * the day before.\n */\nexport function dateInputMs(value: string): number | null {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(String(value ?? '').trim())\n if (!match) return null\n const at = new Date(\n Number(match[1]),\n Number(match[2]) - 1,\n Number(match[3]),\n 12,\n 0,\n 0,\n 0,\n )\n return Number.isNaN(at.getTime()) ? null : at.getTime()\n}\n"],"names":["DEFAULT_DEAL_STAGES","dealStageById","isCrmForecastCategory","isPipelineArchived","weightedDealAmountCents","DEFAULT_PIPELINE_ID","DEFAULT_PIPELINE_NAME","PIPELINE_NAME_MAX","activePipelines","pipelines","filter","pipeline","defaultPipelineOf","active","find","isDefault","pipelineNameProblem","name","ownId","trimmed","String","trim","taken","$id","toLowerCase","pipelineArchiveRefusal","openDeals","entry","length","toLocaleString","newPipelineDocument","context","slice","stages","map","stage","archivedAt","visibleTo","hostId","createdByUid","uid","createdAt","Date","nowMs","updatedAt","DEAL_CURRENCIES","DEFAULT_DEAL_CURRENCY","DEAL_STATUS_LABELS","open","won","lost","formatMoney","cents","currency","code","toUpperCase","amount","Number","Intl","NumberFormat","undefined","style","format","toFixed","parseAmountInput","input","cleaned","replace","value","isFinite","Math","round","amountInputValue","timestampMs","getTime","seconds","toMillis","call","daysInStage","deal","since","stageChangedAtMs","max","floor","addAmount","totals","boardSummary","deals","summary","openCount","valueByCurrency","weightedByCurrency","status","amountCents","stageId","formatAmountByCurrency","entries","Object","sort","a","b","join","sortedStages","index","order","openStages","kind","closingStage","newStageId","existing","base","Set","id","has","suffix","renumberStages","clampProbability","number","min","addStage","probability","sorted","firstClosed","findIndex","at","forecastCategory","splice","renameStage","setStageProbability","setStageForecastCategory","category","moveStage","direction","target","swapped","moving","stageRemovalRefusal","dealsInStage","removeStage","stagesProblem","ids","add","some","dealEventName","dealEventPayload","dealId","previousStageId","title","ownerUid","contactId","companyId","lostReason","initialsFor","label","words","split","Boolean","letters","word","hueFor","hash","charCodeAt","dateInputValue","ms","offset","getTimezoneOffset","toISOString","dateInputMs","match","exec","isNaN"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/deal-board-model.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The pipeline's arithmetic and the stage editor's rules (AGL-2598), as pure\n * functions — no Firestore, no React — so the board, the table, the detail\n * page and the server route read one definition of \"what is this pipeline\n * worth\" and \"which stage edits are legal\".\n */\n\n// The module itself and never the `@aglyn/aglyn` barrel: this file is\n// reached from the server entry through the deal-stage route, and the barrel\n// carries client-only context modules the App Router graph refuses on a\n// server path (AGL-405/1349).\nimport {\n type CrmDeal,\n type CrmDealStage,\n type CrmForecastCategory,\n type CrmPipeline,\n DEFAULT_DEAL_STAGES,\n dealStageById,\n isCrmForecastCategory,\n isPipelineArchived,\n weightedDealAmountCents,\n} from '@aglyn/aglyn/app-utils/crm'\n\n/** A deal as the console reads it: the model plus its document id. */\nexport type DealDoc = CrmDeal & {\n $id: string\n /**\n * The contact's and the company's names, copied onto the deal when the\n * link is made. A board of two hundred cards cannot afford a keyed read per\n * card to caption each one, and the caption is allowed to lag a rename —\n * the link itself is the id, which never changes.\n */\n contactName?: string\n companyName?: string\n}\n\n/** A pipeline as the console reads it. */\nexport type PipelineDoc = CrmPipeline & { $id: string }\n\n/** The document id the seeded default pipeline is written under. */\nexport const DEFAULT_PIPELINE_ID = 'default'\n\n/** Name of the pipeline every org starts with. */\nexport const DEFAULT_PIPELINE_NAME = 'Sales'\n\nexport const PIPELINE_NAME_MAX = 60\n\n/** The pipelines a picker offers and a new deal may land in: the ones not archived. */\nexport function activePipelines<T extends Pick<CrmPipeline, 'archivedAt'>>(\n pipelines: readonly T[],\n): T[] {\n return pipelines.filter((pipeline) => !isPipelineArchived(pipeline))\n}\n\n/**\n * The pipeline a new deal lands in when nobody picks one: the active one\n * marked default, else the first active one, else nothing. An archived\n * pipeline is never the default even if its flag was left on — archiving\n * clears the flag, but a document written by an older console may not\n * have had it cleared, and a default nobody can create a deal in is a\n * board that opens on a pipeline the picker does not list.\n */\nexport function defaultPipelineOf<T extends Pick<CrmPipeline, 'archivedAt' | 'isDefault'>>(\n pipelines: readonly T[],\n): T | null {\n const active = activePipelines(pipelines)\n return active.find((pipeline) => pipeline.isDefault) ?? active[0] ?? null\n}\n\n/**\n * Why a pipeline cannot be created or renamed to `name`, or `null`.\n *\n * Names are unique among the ACTIVE pipelines, compared without case: the\n * picker lists them by name alone, and two rows reading \"Sales\" would be a\n * choice nobody can make. An archived pipeline's name is free to reuse —\n * \"Sales 2025\" retired and \"Sales\" started over is the point of archiving.\n */\nexport function pipelineNameProblem(\n name: string,\n pipelines: ReadonlyArray<Pick<CrmPipeline, 'name' | 'archivedAt'> & { $id: string }>,\n ownId?: string,\n): string | null {\n const trimmed = String(name ?? '').trim()\n if (!trimmed) return 'A pipeline needs a name.'\n const taken = activePipelines(pipelines).find(\n (pipeline) =>\n pipeline.$id !== ownId &&\n String(pipeline.name ?? '').trim().toLowerCase() === trimmed.toLowerCase(),\n )\n if (taken) return `Another pipeline is already called \"${taken.name}\".`\n return null\n}\n\n/**\n * Why a pipeline cannot be archived, or `null` when it can.\n *\n * `openDeals` is the server's count, asked for before this is called, the\n * way `stageRemovalRefusal` asks: an open deal in an archived pipeline is\n * a card on no board, and the count is taken at the click because a deal\n * can be created into the pipeline from another tab meanwhile. The default\n * pipeline cannot go while it is the default — a new deal has to land\n * somewhere — and the last active one cannot go at all, for the same\n * reason from the other side.\n */\nexport function pipelineArchiveRefusal(\n pipeline: Pick<CrmPipeline, 'archivedAt' | 'isDefault'> & { $id: string },\n pipelines: ReadonlyArray<Pick<CrmPipeline, 'archivedAt'> & { $id: string }>,\n openDeals: number,\n): string | null {\n if (isPipelineArchived(pipeline)) return 'This pipeline is already archived.'\n if (activePipelines(pipelines).filter((entry) => entry.$id !== pipeline.$id).length === 0) {\n return 'The last pipeline cannot be archived.'\n }\n if (pipeline.isDefault) {\n return 'This is the default pipeline. Make another one the default first.'\n }\n if (openDeals > 0) {\n return (\n `${openDeals.toLocaleString()} open ${openDeals === 1 ? 'deal is' : 'deals are'} ` +\n 'in this pipeline. Close or move them first, so none is left on a board nobody opens.'\n )\n }\n return null\n}\n\n/** What every pipeline write stamps besides its name. */\nexport interface PipelineWriteContext {\n visibleTo: readonly string[]\n hostId: string\n uid: string\n nowMs: number\n}\n\n/**\n * The document a new pipeline is created as: the name, a COPY of the\n * default stages, not the default, active. A copy for the reason\n * `DEFAULT_DEAL_STAGES` gives — a second pipeline seeded later must start\n * from the original set, whatever the first one's editor did to its own.\n */\nexport function newPipelineDocument(\n name: string,\n context: PipelineWriteContext,\n): Record<string, unknown> {\n return {\n name: String(name ?? '').trim().slice(0, PIPELINE_NAME_MAX),\n stages: DEFAULT_DEAL_STAGES.map((stage) => ({ ...stage })),\n isDefault: false,\n archivedAt: null,\n visibleTo: [...context.visibleTo],\n hostId: context.hostId,\n createdByUid: context.uid,\n createdAt: new Date(context.nowMs),\n updatedAt: new Date(context.nowMs),\n }\n}\n\n/**\n * Currencies the amount picker offers, lowercase ISO 4217 as the model\n * stores them. Short on purpose: a picker of a hundred and eighty currencies\n * is a scroll, and a deal in one outside this list is entered through the\n * API with whatever code it carries — the display below formats any code\n * `Intl` knows.\n */\nexport const DEAL_CURRENCIES = [\n 'usd',\n 'eur',\n 'gbp',\n 'cad',\n 'aud',\n 'nzd',\n 'chf',\n 'jpy',\n 'sek',\n 'nok',\n 'dkk',\n 'mxn',\n 'brl',\n 'inr',\n 'sgd',\n] as const\n\nexport const DEFAULT_DEAL_CURRENCY = 'usd'\n\n/** How a deal's `status` reads on screen. */\nexport const DEAL_STATUS_LABELS: Record<CrmDeal['status'], string> = {\n open: 'Open',\n won: 'Won',\n lost: 'Lost',\n}\n\n/** Cents → the reader's locale, or a plain number when the code is unknown. */\nexport function formatMoney(cents: number, currency: string | undefined): string {\n const code = String(currency || DEFAULT_DEAL_CURRENCY).toUpperCase()\n const amount = (Number(cents) || 0) / 100\n try {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: code,\n }).format(amount)\n } catch {\n return `${amount.toFixed(2)} ${code}`\n }\n}\n\n/**\n * A typed amount, as cents, or `null` when it is not a number.\n *\n * Accepts what people paste from an invoice — a thousands separator, a\n * currency symbol, surrounding space — and refuses a negative: a deal is\n * worth zero or more, and a negative amount here is a typo rather than a\n * refund. Rounded rather than truncated so `19.995` does not lose a cent.\n */\nexport function parseAmountInput(input: string): number | null {\n const cleaned = String(input ?? '')\n .replace(/[^0-9.-]/g, '')\n .trim()\n if (!cleaned) return null\n const value = Number(cleaned)\n if (!Number.isFinite(value) || value < 0) return null\n return Math.round(value * 100)\n}\n\n/** Cents → the string the amount field shows, `''` for nothing. */\nexport function amountInputValue(cents: number | undefined | null): string {\n if (typeof cents !== 'number' || !Number.isFinite(cents)) return ''\n return (cents / 100).toFixed(2)\n}\n\n/**\n * Epoch ms from whatever a timestamp field holds after a round trip — a\n * Firestore `Timestamp`, a `Date` a client just wrote, or a number.\n */\nexport function timestampMs(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n if (value instanceof Date) return value.getTime()\n const seconds = (value as { seconds?: unknown }).seconds\n if (typeof seconds === 'number') return seconds * 1000\n const toMillis = (value as { toMillis?: () => number }).toMillis\n if (typeof toMillis === 'function') return toMillis.call(value)\n return null\n}\n\n/** Whole days a deal has sat in its current stage, never negative. */\nexport function daysInStage(\n deal: Pick<CrmDeal, 'stageChangedAtMs' | 'createdAt'>,\n nowMs: number,\n): number {\n const since =\n typeof deal.stageChangedAtMs === 'number'\n ? deal.stageChangedAtMs\n : timestampMs(deal.createdAt)\n if (!since) return 0\n return Math.max(0, Math.floor((nowMs - since) / 86_400_000))\n}\n\n/** A sum per currency, because two currencies never add into one number. */\nexport type AmountByCurrency = Record<string, number>\n\nfunction addAmount(\n totals: AmountByCurrency,\n currency: string | undefined,\n cents: number,\n): void {\n const code = String(currency || DEFAULT_DEAL_CURRENCY).toLowerCase()\n totals[code] = (totals[code] ?? 0) + cents\n}\n\nexport interface BoardSummary {\n /** Deals whose `status` is `open`, whatever stage they sit in. */\n openCount: number\n /** Every open deal's full amount, per currency. */\n valueByCurrency: AmountByCurrency\n /** Every open deal's amount times its stage's probability, per currency. */\n weightedByCurrency: AmountByCurrency\n}\n\n/**\n * The three figures above the board.\n *\n * Closed deals are excluded from both totals rather than counted at full or\n * zero value: the summary describes what is still in play, and a won deal\n * counted in \"pipeline value\" would make the pipeline look larger every time\n * something closed. `weightedDealAmountCents` already answers zero for a\n * stage the pipeline no longer has.\n */\nexport function boardSummary(\n deals: readonly (Pick<CrmDeal, 'status' | 'amountCents' | 'currency' | 'stageId'> &\n Partial<Pick<CrmDeal, 'probability'>>)[],\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): BoardSummary {\n const summary: BoardSummary = {\n openCount: 0,\n valueByCurrency: {},\n weightedByCurrency: {},\n }\n for (const deal of deals) {\n if (deal.status !== 'open') continue\n summary.openCount += 1\n const amount = Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0))\n addAmount(summary.valueByCurrency, deal.currency, amount)\n addAmount(\n summary.weightedByCurrency,\n deal.currency,\n weightedDealAmountCents(deal, dealStageById(pipeline, deal.stageId)),\n )\n }\n return summary\n}\n\n/** `$1,200.00 · €300.00` — one figure per currency, largest first. */\nexport function formatAmountByCurrency(totals: AmountByCurrency): string {\n const entries = Object.entries(totals).sort((a, b) => b[1] - a[1])\n if (!entries.length) return formatMoney(0, DEFAULT_DEAL_CURRENCY)\n return entries.map(([code, cents]) => formatMoney(cents, code)).join(' · ')\n}\n\n/** The pipeline's stages in `order`, ties broken by position. */\nexport function sortedStages(\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): CrmDealStage[] {\n return [...(pipeline?.stages ?? [])]\n .map((stage, index) => ({ stage, index }))\n .sort((a, b) => a.stage.order - b.stage.order || a.index - b.index)\n .map(({ stage }) => stage)\n}\n\n/** The stages a deal can be dragged between — everything that is not closed. */\nexport function openStages(\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n): CrmDealStage[] {\n return sortedStages(pipeline).filter((stage) => stage.kind === 'open')\n}\n\n/** The stage whose `kind` is `won` or `lost`, or null when the pipeline lacks one. */\nexport function closingStage(\n pipeline: Pick<CrmPipeline, 'stages'> | null | undefined,\n kind: 'won' | 'lost',\n): CrmDealStage | null {\n return sortedStages(pipeline).find((stage) => stage.kind === kind) ?? null\n}\n\n/**\n * The id a new stage is stored under: the name as a slug, made unique\n * against the stages already there.\n *\n * A slug rather than a random id because the id appears in every deal that\n * sits in the stage and in every automation filter that names it, so a\n * reader looking at `stageId == \"demo-booked\"` should be able to tell which\n * stage that is without opening the pipeline. Uniqueness is what keeps a\n * second \"Demo\" from silently becoming the first.\n */\nexport function newStageId(\n name: string,\n existing: readonly CrmDealStage[],\n): string {\n const base =\n String(name ?? '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 40) || 'stage'\n const taken = new Set(existing.map((stage) => stage.id))\n if (!taken.has(base)) return base\n let suffix = 2\n while (taken.has(`${base}-${suffix}`)) suffix += 1\n return `${base}-${suffix}`\n}\n\n/**\n * Stages re-numbered 0..n in the order they are GIVEN.\n *\n * Positional on purpose: every caller hands this an array it has already\n * arranged — a swap, a splice, a filter over the sorted stages — and the\n * stored `order` on each entry is the arrangement being replaced. Sorting by\n * it here would put a swapped pair straight back where it was.\n */\nexport function renumberStages(\n stages: readonly CrmDealStage[],\n): CrmDealStage[] {\n return stages.map((stage, order) => ({ ...stage, order }))\n}\n\n/** 0–100, whole numbers; anything else becomes the nearest bound. */\nexport function clampProbability(value: unknown): number {\n const number = Math.round(Number(value))\n if (!Number.isFinite(number)) return 0\n return Math.min(100, Math.max(0, number))\n}\n\n/**\n * Add an open stage in front of the closing stages.\n *\n * Won and Lost stay last: a pipeline reads left to right as a progression,\n * and a stage added after \"Won\" would be a step past the end.\n */\nexport function addStage(\n stages: readonly CrmDealStage[],\n name: string,\n probability = 50,\n): CrmDealStage[] {\n const trimmed = String(name ?? '').trim()\n if (!trimmed) return [...stages]\n const sorted = sortedStages({ stages: [...stages] })\n const firstClosed = sorted.findIndex((stage) => stage.kind !== 'open')\n const at = firstClosed === -1 ? sorted.length : firstClosed\n const stage: CrmDealStage = {\n id: newStageId(trimmed, sorted),\n name: trimmed,\n order: at,\n probability: clampProbability(probability),\n kind: 'open',\n forecastCategory: 'pipeline',\n }\n sorted.splice(at, 0, stage)\n return renumberStages(sorted)\n}\n\nexport function renameStage(\n stages: readonly CrmDealStage[],\n stageId: string,\n name: string,\n): CrmDealStage[] {\n // As typed: a trailing space is how the second word of a name begins, so a\n // trim here ate every space. `settleStageNames` tidies them for the save.\n const typed = String(name ?? '')\n return stages.map((stage) => (stage.id === stageId ? { ...stage, name: typed } : stage))\n}\n\n/**\n * Set an OPEN stage's probability. Won is 100 and Lost is 0 by definition —\n * `weightedDealAmountCents` reads the status before the stage, so a Won\n * stage at 60% would change nothing and only mislead the editor.\n */\nexport function setStageProbability(\n stages: readonly CrmDealStage[],\n stageId: string,\n probability: unknown,\n): CrmDealStage[] {\n return stages.map((stage) =>\n stage.id === stageId && stage.kind === 'open'\n ? { ...stage, probability: clampProbability(probability) }\n : stage,\n )\n}\n\n/**\n * Set an OPEN stage's forecast category (AGL-3516). Won is Closed and Lost\n * is Omitted by definition, for the reason {@link setStageProbability}\n * gives; a deal's own category is changed on the deal.\n */\nexport function setStageForecastCategory(\n stages: readonly CrmDealStage[],\n stageId: string,\n category: CrmForecastCategory | string,\n): CrmDealStage[] {\n if (!isCrmForecastCategory(category)) return [...stages]\n return stages.map((stage) =>\n stage.id === stageId && stage.kind === 'open'\n ? { ...stage, forecastCategory: category }\n : stage,\n )\n}\n\n/**\n * Swap an open stage with its open neighbor. The closing stages are not in\n * the reorderable range, so an open stage can never be moved past Won.\n */\nexport function moveStage(\n stages: readonly CrmDealStage[],\n stageId: string,\n direction: 'up' | 'down',\n): CrmDealStage[] {\n const sorted = sortedStages({ stages: [...stages] })\n const index = sorted.findIndex((stage) => stage.id === stageId)\n if (index === -1 || sorted[index].kind !== 'open') return sorted\n const target = direction === 'up' ? index - 1 : index + 1\n if (target < 0 || target >= sorted.length) return sorted\n if (sorted[target].kind !== 'open') return sorted\n const swapped = [...sorted]\n const moving = swapped[index]\n swapped[index] = swapped[target]\n swapped[target] = moving\n return renumberStages(swapped)\n}\n\n/**\n * Why a stage cannot be removed, or `null` when it can.\n *\n * `dealsInStage` is the server's count, asked for before this is called: a\n * stage with deals in it cannot go, because every one of those deals would\n * be left pointing at a stage that no longer exists and worth nothing to the\n * forecast. The closing stages cannot go at all — a deal has to be able to\n * be won and lost.\n */\nexport function stageRemovalRefusal(\n stages: readonly CrmDealStage[],\n stageId: string,\n dealsInStage: number,\n): string | null {\n const stage = stages.find((entry) => entry.id === stageId)\n if (!stage) return 'This stage is no longer in the pipeline.'\n if (stage.kind !== 'open') {\n return `Every pipeline keeps its ${stage.kind === 'won' ? 'Won' : 'Lost'} stage.`\n }\n if (openStages({ stages: [...stages] }).length <= 1) {\n return 'A pipeline needs at least one open stage.'\n }\n if (dealsInStage > 0) {\n return (\n `${dealsInStage.toLocaleString()} ${dealsInStage === 1 ? 'deal is' : 'deals are'} ` +\n 'in this stage. Move them first, so none is left in a stage that no longer exists.'\n )\n }\n return null\n}\n\nexport function removeStage(\n stages: readonly CrmDealStage[],\n stageId: string,\n): CrmDealStage[] {\n return renumberStages(stages.filter((stage) => stage.id !== stageId))\n}\n\n/**\n * What a stored set of stages has to satisfy before it is written back:\n * unique ids, a name on every stage, exactly one Won and one Lost, at least\n * one open stage. The editor refuses a save that fails this rather than\n * writing a pipeline the board cannot draw.\n */\nexport function stagesProblem(stages: readonly CrmDealStage[]): string | null {\n if (!stages.length) return 'A pipeline needs stages.'\n const ids = new Set<string>()\n for (const stage of stages) {\n if (!stage.id) return 'Every stage needs an id.'\n if (ids.has(stage.id)) return `Two stages share the id \"${stage.id}\".`\n ids.add(stage.id)\n if (!String(stage.name ?? '').trim()) return 'Every stage needs a name.'\n }\n const won = stages.filter((stage) => stage.kind === 'won').length\n const lost = stages.filter((stage) => stage.kind === 'lost').length\n if (won !== 1) return 'A pipeline has exactly one Won stage.'\n if (lost !== 1) return 'A pipeline has exactly one Lost stage.'\n if (!stages.some((stage) => stage.kind === 'open')) {\n return 'A pipeline needs at least one open stage.'\n }\n return null\n}\n\n/**\n * The event a stage change emits, and the flat payload a workflow filter\n * reads (AGL-2598). Strings and numbers only — a workflow expression\n * evaluates over scalars, and an absent link is the empty string rather than\n * a missing key so `contactId == \"\"` is a filter somebody can write.\n */\nexport type DealEventName = 'dealStageChanged' | 'dealWon' | 'dealLost'\n\nexport function dealEventName(status: CrmDeal['status']): DealEventName {\n if (status === 'won') return 'dealWon'\n if (status === 'lost') return 'dealLost'\n return 'dealStageChanged'\n}\n\nexport function dealEventPayload(\n dealId: string,\n deal: Pick<\n CrmDeal,\n | 'title'\n | 'amountCents'\n | 'currency'\n | 'stageId'\n | 'ownerUid'\n | 'contactId'\n | 'companyId'\n | 'lostReason'\n >,\n previousStageId: string,\n): Record<string, string | number> {\n return {\n dealId,\n title: String(deal.title ?? ''),\n amountCents: Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0)),\n currency: String(deal.currency || DEFAULT_DEAL_CURRENCY).toLowerCase(),\n stageId: String(deal.stageId ?? ''),\n previousStageId: String(previousStageId ?? ''),\n ownerUid: String(deal.ownerUid ?? ''),\n contactId: String(deal.contactId ?? ''),\n companyId: String(deal.companyId ?? ''),\n ...(deal.lostReason ? { lostReason: String(deal.lostReason) } : {}),\n }\n}\n\n/** Initials for an avatar: the first letters of the first two words. */\nexport function initialsFor(label: string): string {\n const words = String(label ?? '')\n .trim()\n .split(/[\\s@._-]+/)\n .filter(Boolean)\n const letters = words\n .slice(0, 2)\n .map((word) => word[0])\n .join('')\n return (letters || '?').toUpperCase()\n}\n\n/**\n * A stable hue for one person, so an owner keeps one color across cards and\n * sessions. Hashing the label rather than picking from a palette is what\n * makes the same uid the same color on every machine.\n */\nexport function hueFor(label: string): number {\n let hash = 0\n for (let index = 0; index < label.length; index += 1) {\n hash = (hash * 31 + label.charCodeAt(index)) % 360\n }\n return hash\n}\n\n/** `2026-09-05` for a date input, from epoch ms; `''` for none. */\nexport function dateInputValue(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const at = new Date(ms)\n const offset = at.getTimezoneOffset() * 60_000\n return new Date(ms - offset).toISOString().slice(0, 10)\n}\n\n/**\n * Epoch ms at local noon of a typed `YYYY-MM-DD`, or `null`.\n *\n * Noon rather than midnight so the day survives a timezone round trip: a\n * close date stored at local midnight and read back one timezone west shows\n * the day before.\n */\nexport function dateInputMs(value: string): number | null {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(String(value ?? '').trim())\n if (!match) return null\n const at = new Date(\n Number(match[1]),\n Number(match[2]) - 1,\n Number(match[3]),\n 12,\n 0,\n 0,\n 0,\n )\n return Number.isNaN(at.getTime()) ? null : at.getTime()\n}\n"],"names":["DEFAULT_DEAL_STAGES","dealStageById","isCrmForecastCategory","isPipelineArchived","weightedDealAmountCents","DEFAULT_PIPELINE_ID","DEFAULT_PIPELINE_NAME","PIPELINE_NAME_MAX","activePipelines","pipelines","filter","pipeline","defaultPipelineOf","active","find","isDefault","pipelineNameProblem","name","ownId","trimmed","String","trim","taken","$id","toLowerCase","pipelineArchiveRefusal","openDeals","entry","length","toLocaleString","newPipelineDocument","context","slice","stages","map","stage","archivedAt","visibleTo","hostId","createdByUid","uid","createdAt","Date","nowMs","updatedAt","DEAL_CURRENCIES","DEFAULT_DEAL_CURRENCY","DEAL_STATUS_LABELS","open","won","lost","formatMoney","cents","currency","code","toUpperCase","amount","Number","Intl","NumberFormat","undefined","style","format","toFixed","parseAmountInput","input","cleaned","replace","value","isFinite","Math","round","amountInputValue","timestampMs","getTime","seconds","toMillis","call","daysInStage","deal","since","stageChangedAtMs","max","floor","addAmount","totals","boardSummary","deals","summary","openCount","valueByCurrency","weightedByCurrency","status","amountCents","stageId","formatAmountByCurrency","entries","Object","sort","a","b","join","sortedStages","index","order","openStages","kind","closingStage","newStageId","existing","base","Set","id","has","suffix","renumberStages","clampProbability","number","min","addStage","probability","sorted","firstClosed","findIndex","at","forecastCategory","splice","renameStage","typed","setStageProbability","setStageForecastCategory","category","moveStage","direction","target","swapped","moving","stageRemovalRefusal","dealsInStage","removeStage","stagesProblem","ids","add","some","dealEventName","dealEventPayload","dealId","previousStageId","title","ownerUid","contactId","companyId","lostReason","initialsFor","label","words","split","Boolean","letters","word","hueFor","hash","charCodeAt","dateInputValue","ms","offset","getTimezoneOffset","toISOString","dateInputMs","match","exec","isNaN"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { type CrmDealContactRole, type CrmPicklist } from '@aglyn/aglyn';
18
18
  /** Why a contact cannot be added to the deal, or `null` when it can. */
19
- export declare function dealContactRoleAddProblem(roles: readonly CrmDealContactRole[], contactId: string): string | null;
19
+ export declare function dealContactRoleAddProblem(roles: readonly CrmDealContactRole[], contactId: string, noun?: 'deal' | 'company'): string | null;
20
20
  /** `roles` with one contact added — as the Primary, when asked, which every other row then is not. */
21
21
  export declare function dealContactRolesAdded(roles: readonly CrmDealContactRole[], added: {
22
22
  contactId: string;
@@ -43,3 +43,17 @@ export declare function dealContactRolesSave(deal: {
43
43
  ok: false;
44
44
  error: string;
45
45
  };
46
+ /**
47
+ * What a save of a company's card writes: the roles judged against the org's
48
+ * list. A company has no `contactId` beside them, so nothing else moves with
49
+ * the Primary.
50
+ */
51
+ export declare function companyContactRolesSave(company: {
52
+ contactRoles?: unknown;
53
+ }, next: readonly CrmDealContactRole[], picklist: CrmPicklist): {
54
+ ok: true;
55
+ contactRoles: CrmDealContactRole[];
56
+ } | {
57
+ ok: false;
58
+ error: string;
59
+ };
@@ -18,11 +18,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  /*
19
19
  * THE DEAL PAGE'S CONTACT ROLES CARD (AGL-3521), as pure functions: what
20
20
  * each edit makes of the list, and the fields one save writes.
21
- */ /** Why a contact cannot be added to the deal, or `null` when it can. */ export function dealContactRoleAddProblem(roles, contactId) {
21
+ */ /** Why a contact cannot be added to the deal, or `null` when it can. */ export function dealContactRoleAddProblem(roles, contactId, noun = 'deal') {
22
22
  if (!contactId) return 'Pick a contact.';
23
- if (roles.some((row)=>row.contactId === contactId)) return 'That contact is already on this deal.';
23
+ if (roles.some((row)=>row.contactId === contactId)) {
24
+ return `That contact is already on this ${noun}.`;
25
+ }
24
26
  if (roles.length >= DEAL_CONTACT_ROLES_MAX) {
25
- return `A deal names at most ${DEAL_CONTACT_ROLES_MAX} contacts.`;
27
+ return `A ${noun} names at most ${DEAL_CONTACT_ROLES_MAX} contacts.`;
26
28
  }
27
29
  return null;
28
30
  }
@@ -69,5 +71,17 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
69
71
  primaryChanged: fields.contactId !== before
70
72
  });
71
73
  }
74
+ /**
75
+ * What a save of a company's card writes: the roles judged against the org's
76
+ * list. A company has no `contactId` beside them, so nothing else moves with
77
+ * the Primary.
78
+ */ export function companyContactRolesSave(company, next, picklist) {
79
+ const judged = judgeDealContactRoles(picklist, next, dealContactRolesOf(company));
80
+ if (judged.ok === false) return judged;
81
+ return {
82
+ ok: true,
83
+ contactRoles: judged.roles
84
+ };
85
+ }
72
86
 
73
87
  //# sourceMappingURL=deal-contact-roles.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/deal-contact-roles.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type CrmDealContactRole,\n type CrmPicklist,\n DEAL_CONTACT_ROLES_MAX,\n dealContactRoleFields,\n dealContactRolesOf,\n judgeDealContactRoles,\n} from '@aglyn/aglyn'\n\n/*\n * THE DEAL PAGE'S CONTACT ROLES CARD (AGL-3521), as pure functions: what\n * each edit makes of the list, and the fields one save writes.\n */\n\n/** Why a contact cannot be added to the deal, or `null` when it can. */\nexport function dealContactRoleAddProblem(\n roles: readonly CrmDealContactRole[],\n contactId: string,\n): string | null {\n if (!contactId) return 'Pick a contact.'\n if (roles.some((row) => row.contactId === contactId)) return 'That contact is already on this deal.'\n if (roles.length >= DEAL_CONTACT_ROLES_MAX) {\n return `A deal names at most ${DEAL_CONTACT_ROLES_MAX} contacts.`\n }\n return null\n}\n\n/** `roles` with one contact added — as the Primary, when asked, which every other row then is not. */\nexport function dealContactRolesAdded(\n roles: readonly CrmDealContactRole[],\n added: { contactId: string; role?: string; primary: boolean },\n): CrmDealContactRole[] {\n const row: CrmDealContactRole = {\n contactId: added.contactId,\n ...(added.role ? { role: added.role } : {}),\n primary: added.primary,\n }\n return [...roles.map((entry) => (added.primary ? { ...entry, primary: false } : entry)), row]\n}\n\n/** `roles` with one contact's role set — `''` for none. */\nexport function dealContactRolesWithRole(\n roles: readonly CrmDealContactRole[],\n contactId: string,\n role: string,\n): CrmDealContactRole[] {\n return roles.map((row) => {\n if (row.contactId !== contactId) return row\n return { contactId: row.contactId, ...(role ? { role } : {}), primary: row.primary }\n })\n}\n\n/**\n * What a save of the card writes: the roles judged against the org's list\n * (a contact's current role kept), `contactId` as their Primary — `null`\n * for a field the caller deletes — and whether the Primary changed, which\n * is when the name the drawer copied beside `contactId` goes stale.\n */\nexport function dealContactRolesSave(\n deal: { contactId?: unknown; contactRoles?: unknown },\n next: readonly CrmDealContactRole[],\n picklist: CrmPicklist,\n):\n | {\n ok: true\n contactRoles: CrmDealContactRole[]\n contactId: string | null\n primaryChanged: boolean\n }\n | { ok: false; error: string } {\n const judged = judgeDealContactRoles(picklist, next, dealContactRolesOf(deal))\n if (judged.ok === false) return judged\n const fields = dealContactRoleFields(judged.roles)\n const before = typeof deal.contactId === 'string' && deal.contactId ? deal.contactId : null\n return { ok: true, ...fields, primaryChanged: fields.contactId !== before }\n}\n"],"names":["DEAL_CONTACT_ROLES_MAX","dealContactRoleFields","dealContactRolesOf","judgeDealContactRoles","dealContactRoleAddProblem","roles","contactId","some","row","length","dealContactRolesAdded","added","role","primary","map","entry","dealContactRolesWithRole","dealContactRolesSave","deal","next","picklist","judged","ok","fields","before","primaryChanged"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAGEA,sBAAsB,EACtBC,qBAAqB,EACrBC,kBAAkB,EAClBC,qBAAqB,QAChB,eAAc;AAErB;;;CAGC,GAED,sEAAsE,GACtE,OAAO,SAASC,0BACdC,KAAoC,EACpCC,SAAiB;IAEjB,IAAI,CAACA,WAAW,OAAO;IACvB,IAAID,MAAME,IAAI,CAAC,CAACC,MAAQA,IAAIF,SAAS,KAAKA,YAAY,OAAO;IAC7D,IAAID,MAAMI,MAAM,IAAIT,wBAAwB;QAC1C,OAAO,CAAC,qBAAqB,EAAEA,uBAAuB,UAAU,CAAC;IACnE;IACA,OAAO;AACT;AAEA,oGAAoG,GACpG,OAAO,SAASU,sBACdL,KAAoC,EACpCM,KAA6D;IAE7D,MAAMH,MAA0B;QAC9BF,WAAWK,MAAML,SAAS;OACtBK,MAAMC,IAAI,GAAG;QAAEA,MAAMD,MAAMC,IAAI;IAAC,IAAI,CAAC;QACzCC,SAASF,MAAME,OAAO;;IAExB,OAAO;WAAIR,MAAMS,GAAG,CAAC,CAACC,QAAWJ,MAAME,OAAO,GAAG,aAAKE;gBAAOF,SAAS;iBAAUE;QAASP;KAAI;AAC/F;AAEA,yDAAyD,GACzD,OAAO,SAASQ,yBACdX,KAAoC,EACpCC,SAAiB,EACjBM,IAAY;IAEZ,OAAOP,MAAMS,GAAG,CAAC,CAACN;QAChB,IAAIA,IAAIF,SAAS,KAAKA,WAAW,OAAOE;QACxC,OAAO;YAAEF,WAAWE,IAAIF,SAAS;WAAMM,OAAO;YAAEA;QAAK,IAAI,CAAC;YAAIC,SAASL,IAAIK,OAAO;;IACpF;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASI,qBACdC,IAAqD,EACrDC,IAAmC,EACnCC,QAAqB;IASrB,MAAMC,SAASlB,sBAAsBiB,UAAUD,MAAMjB,mBAAmBgB;IACxE,IAAIG,OAAOC,EAAE,KAAK,OAAO,OAAOD;IAChC,MAAME,SAAStB,sBAAsBoB,OAAOhB,KAAK;IACjD,MAAMmB,SAAS,OAAON,KAAKZ,SAAS,KAAK,YAAYY,KAAKZ,SAAS,GAAGY,KAAKZ,SAAS,GAAG;IACvF,OAAO;QAAEgB,IAAI;OAASC;QAAQE,gBAAgBF,OAAOjB,SAAS,KAAKkB;;AACrE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/deal-contact-roles.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type CrmDealContactRole,\n type CrmPicklist,\n DEAL_CONTACT_ROLES_MAX,\n dealContactRoleFields,\n dealContactRolesOf,\n judgeDealContactRoles,\n} from '@aglyn/aglyn'\n\n/*\n * THE DEAL PAGE'S CONTACT ROLES CARD (AGL-3521), as pure functions: what\n * each edit makes of the list, and the fields one save writes.\n */\n\n/** Why a contact cannot be added to the deal, or `null` when it can. */\nexport function dealContactRoleAddProblem(\n roles: readonly CrmDealContactRole[],\n contactId: string,\n noun: 'deal' | 'company' = 'deal',\n): string | null {\n if (!contactId) return 'Pick a contact.'\n if (roles.some((row) => row.contactId === contactId)) {\n return `That contact is already on this ${noun}.`\n }\n if (roles.length >= DEAL_CONTACT_ROLES_MAX) {\n return `A ${noun} names at most ${DEAL_CONTACT_ROLES_MAX} contacts.`\n }\n return null\n}\n\n/** `roles` with one contact added — as the Primary, when asked, which every other row then is not. */\nexport function dealContactRolesAdded(\n roles: readonly CrmDealContactRole[],\n added: { contactId: string; role?: string; primary: boolean },\n): CrmDealContactRole[] {\n const row: CrmDealContactRole = {\n contactId: added.contactId,\n ...(added.role ? { role: added.role } : {}),\n primary: added.primary,\n }\n return [...roles.map((entry) => (added.primary ? { ...entry, primary: false } : entry)), row]\n}\n\n/** `roles` with one contact's role set — `''` for none. */\nexport function dealContactRolesWithRole(\n roles: readonly CrmDealContactRole[],\n contactId: string,\n role: string,\n): CrmDealContactRole[] {\n return roles.map((row) => {\n if (row.contactId !== contactId) return row\n return { contactId: row.contactId, ...(role ? { role } : {}), primary: row.primary }\n })\n}\n\n/**\n * What a save of the card writes: the roles judged against the org's list\n * (a contact's current role kept), `contactId` as their Primary — `null`\n * for a field the caller deletes — and whether the Primary changed, which\n * is when the name the drawer copied beside `contactId` goes stale.\n */\nexport function dealContactRolesSave(\n deal: { contactId?: unknown; contactRoles?: unknown },\n next: readonly CrmDealContactRole[],\n picklist: CrmPicklist,\n):\n | {\n ok: true\n contactRoles: CrmDealContactRole[]\n contactId: string | null\n primaryChanged: boolean\n }\n | { ok: false; error: string } {\n const judged = judgeDealContactRoles(picklist, next, dealContactRolesOf(deal))\n if (judged.ok === false) return judged\n const fields = dealContactRoleFields(judged.roles)\n const before = typeof deal.contactId === 'string' && deal.contactId ? deal.contactId : null\n return { ok: true, ...fields, primaryChanged: fields.contactId !== before }\n}\n\n/**\n * What a save of a company's card writes: the roles judged against the org's\n * list. A company has no `contactId` beside them, so nothing else moves with\n * the Primary.\n */\nexport function companyContactRolesSave(\n company: { contactRoles?: unknown },\n next: readonly CrmDealContactRole[],\n picklist: CrmPicklist,\n): { ok: true; contactRoles: CrmDealContactRole[] } | { ok: false; error: string } {\n const judged = judgeDealContactRoles(picklist, next, dealContactRolesOf(company))\n if (judged.ok === false) return judged\n return { ok: true, contactRoles: judged.roles }\n}\n"],"names":["DEAL_CONTACT_ROLES_MAX","dealContactRoleFields","dealContactRolesOf","judgeDealContactRoles","dealContactRoleAddProblem","roles","contactId","noun","some","row","length","dealContactRolesAdded","added","role","primary","map","entry","dealContactRolesWithRole","dealContactRolesSave","deal","next","picklist","judged","ok","fields","before","primaryChanged","companyContactRolesSave","company","contactRoles"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAGEA,sBAAsB,EACtBC,qBAAqB,EACrBC,kBAAkB,EAClBC,qBAAqB,QAChB,eAAc;AAErB;;;CAGC,GAED,sEAAsE,GACtE,OAAO,SAASC,0BACdC,KAAoC,EACpCC,SAAiB,EACjBC,OAA2B,MAAM;IAEjC,IAAI,CAACD,WAAW,OAAO;IACvB,IAAID,MAAMG,IAAI,CAAC,CAACC,MAAQA,IAAIH,SAAS,KAAKA,YAAY;QACpD,OAAO,CAAC,gCAAgC,EAAEC,KAAK,CAAC,CAAC;IACnD;IACA,IAAIF,MAAMK,MAAM,IAAIV,wBAAwB;QAC1C,OAAO,CAAC,EAAE,EAAEO,KAAK,eAAe,EAAEP,uBAAuB,UAAU,CAAC;IACtE;IACA,OAAO;AACT;AAEA,oGAAoG,GACpG,OAAO,SAASW,sBACdN,KAAoC,EACpCO,KAA6D;IAE7D,MAAMH,MAA0B;QAC9BH,WAAWM,MAAMN,SAAS;OACtBM,MAAMC,IAAI,GAAG;QAAEA,MAAMD,MAAMC,IAAI;IAAC,IAAI,CAAC;QACzCC,SAASF,MAAME,OAAO;;IAExB,OAAO;WAAIT,MAAMU,GAAG,CAAC,CAACC,QAAWJ,MAAME,OAAO,GAAG,aAAKE;gBAAOF,SAAS;iBAAUE;QAASP;KAAI;AAC/F;AAEA,yDAAyD,GACzD,OAAO,SAASQ,yBACdZ,KAAoC,EACpCC,SAAiB,EACjBO,IAAY;IAEZ,OAAOR,MAAMU,GAAG,CAAC,CAACN;QAChB,IAAIA,IAAIH,SAAS,KAAKA,WAAW,OAAOG;QACxC,OAAO;YAAEH,WAAWG,IAAIH,SAAS;WAAMO,OAAO;YAAEA;QAAK,IAAI,CAAC;YAAIC,SAASL,IAAIK,OAAO;;IACpF;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASI,qBACdC,IAAqD,EACrDC,IAAmC,EACnCC,QAAqB;IASrB,MAAMC,SAASnB,sBAAsBkB,UAAUD,MAAMlB,mBAAmBiB;IACxE,IAAIG,OAAOC,EAAE,KAAK,OAAO,OAAOD;IAChC,MAAME,SAASvB,sBAAsBqB,OAAOjB,KAAK;IACjD,MAAMoB,SAAS,OAAON,KAAKb,SAAS,KAAK,YAAYa,KAAKb,SAAS,GAAGa,KAAKb,SAAS,GAAG;IACvF,OAAO;QAAEiB,IAAI;OAASC;QAAQE,gBAAgBF,OAAOlB,SAAS,KAAKmB;;AACrE;AAEA;;;;CAIC,GACD,OAAO,SAASE,wBACdC,OAAmC,EACnCR,IAAmC,EACnCC,QAAqB;IAErB,MAAMC,SAASnB,sBAAsBkB,UAAUD,MAAMlB,mBAAmB0B;IACxE,IAAIN,OAAOC,EAAE,KAAK,OAAO,OAAOD;IAChC,OAAO;QAAEC,IAAI;QAAMM,cAAcP,OAAOjB,KAAK;IAAC;AAChD"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { CrmDealStage } from '@aglyn/aglyn';
18
+ /**
19
+ * The stages as they are stored: every name trimmed, as a save writes them.
20
+ *
21
+ * Its own module because only the stage editor needs it, and everything in
22
+ * `deal-board-model` that a lazy surface reaches for is paid by every console
23
+ * page that loads the plugin.
24
+ */
25
+ export declare function settleStageNames(stages: readonly CrmDealStage[]): CrmDealStage[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ /**
18
+ * The stages as they are stored: every name trimmed, as a save writes them.
19
+ *
20
+ * Its own module because only the stage editor needs it, and everything in
21
+ * `deal-board-model` that a lazy surface reaches for is paid by every console
22
+ * page that loads the plugin.
23
+ */ export function settleStageNames(stages) {
24
+ return stages.map((stage)=>_extends({}, stage, {
25
+ name: stage.name.trim()
26
+ }));
27
+ }
28
+
29
+ //# sourceMappingURL=settle-stage-names.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/settle-stage-names.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { CrmDealStage } from '@aglyn/aglyn'\n\n/**\n * The stages as they are stored: every name trimmed, as a save writes them.\n *\n * Its own module because only the stage editor needs it, and everything in\n * `deal-board-model` that a lazy surface reaches for is paid by every console\n * page that loads the plugin.\n */\nexport function settleStageNames(stages: readonly CrmDealStage[]): CrmDealStage[] {\n return stages.map((stage) => ({ ...stage, name: stage.name.trim() }))\n}\n"],"names":["settleStageNames","stages","map","stage","name","trim"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAID;;;;;;CAMC,GACD,OAAO,SAASA,iBAAiBC,MAA+B;IAC9D,OAAOA,OAAOC,GAAG,CAAC,CAACC,QAAW,aAAKA;YAAOC,MAAMD,MAAMC,IAAI,CAACC,IAAI;;AACjE"}
@@ -43,7 +43,7 @@
43
43
  nativeNavHubSection(scope, '/crm', 'contacts', 'Contacts', 'crm.contacts'),
44
44
  nativeNavHubSection(scope, '/crm', 'leads', 'Leads', 'crm.leads'),
45
45
  nativeNavHubSection(scope, '/crm', 'companies', 'Companies', 'crm.companies'),
46
- nativeNavHubSection(scope, '/crm', 'deals', 'Deals', 'crm.deals'),
46
+ nativeNavHubSection(scope, '/crm', 'deals', 'Deals / Opportunities', 'crm.deals'),
47
47
  nativeNavHubSection(scope, '/crm', 'tasks', 'Tasks', 'crm.tasks'),
48
48
  nativeNavHubSection(scope, '/crm', 'reports', 'Reports', 'crm.reports'),
49
49
  nativeNavHubSection(scope, '/crm', 'fields', 'Fields', 'crm.fields'),