@aglyn/plugins-crm 1.0.0-beta.164 → 1.0.0-beta.166

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 (71) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +21 -5
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/contact-field-drawer.js +19 -2
  5. package/src/lib/components/contact-field-drawer.js.map +1 -1
  6. package/src/lib/components/crm-console-page.js +12 -7
  7. package/src/lib/components/crm-console-page.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +4 -2
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/fields-section.d.ts +2 -1
  11. package/src/lib/components/fields-section.js +6 -3
  12. package/src/lib/components/fields-section.js.map +1 -1
  13. package/src/lib/components/lead-campaigns-card.d.ts +59 -0
  14. package/src/lib/components/lead-campaigns-card.js +166 -0
  15. package/src/lib/components/lead-campaigns-card.js.map +1 -0
  16. package/src/lib/components/lead-detail-page.js +54 -4
  17. package/src/lib/components/lead-detail-page.js.map +1 -1
  18. package/src/lib/components/lead-history-card.js +5 -0
  19. package/src/lib/components/lead-history-card.js.map +1 -1
  20. package/src/lib/components/lead-properties-card.d.ts +12 -0
  21. package/src/lib/components/lead-properties-card.js +64 -6
  22. package/src/lib/components/lead-properties-card.js.map +1 -1
  23. package/src/lib/components/lead-unqualify-dialog.js +7 -1
  24. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  25. package/src/lib/components/leads-bulk-bar.d.ts +7 -2
  26. package/src/lib/components/leads-bulk-bar.js +42 -6
  27. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  28. package/src/lib/components/leads-section.js +43 -17
  29. package/src/lib/components/leads-section.js.map +1 -1
  30. package/src/lib/components/new-lead-drawer.d.ts +14 -1
  31. package/src/lib/components/new-lead-drawer.js +41 -3
  32. package/src/lib/components/new-lead-drawer.js.map +1 -1
  33. package/src/lib/components/reports/lead-counts.d.ts +16 -9
  34. package/src/lib/components/reports/lead-counts.js +29 -18
  35. package/src/lib/components/reports/lead-counts.js.map +1 -1
  36. package/src/lib/components/reports/lead-funnel-card.js +44 -40
  37. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  38. package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
  39. package/src/lib/hooks/use-campaign-filing-log.js +91 -0
  40. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
  41. package/src/lib/hooks/use-org-leads.d.ts +29 -32
  42. package/src/lib/hooks/use-org-leads.js +58 -68
  43. package/src/lib/hooks/use-org-leads.js.map +1 -1
  44. package/src/lib/model/campaign-filing-activity.d.ts +82 -0
  45. package/src/lib/model/campaign-filing-activity.js +88 -0
  46. package/src/lib/model/campaign-filing-activity.js.map +1 -0
  47. package/src/lib/model/crm-routes.d.ts +9 -6
  48. package/src/lib/model/crm-routes.js +9 -6
  49. package/src/lib/model/crm-routes.js.map +1 -1
  50. package/src/lib/server/campaign-filing-activity.d.ts +79 -0
  51. package/src/lib/server/campaign-filing-activity.js +93 -0
  52. package/src/lib/server/campaign-filing-activity.js.map +1 -0
  53. package/src/lib/server/capture-contact.js +11 -5
  54. package/src/lib/server/capture-contact.js.map +1 -1
  55. package/src/lib/server/convert-open-lead.js +6 -2
  56. package/src/lib/server/convert-open-lead.js.map +1 -1
  57. package/src/lib/server/email-send.js +3 -3
  58. package/src/lib/server/email-send.js.map +1 -1
  59. package/src/lib/server/erase-person.js +25 -7
  60. package/src/lib/server/erase-person.js.map +1 -1
  61. package/src/lib/server/lead-campaign-carry.js +28 -2
  62. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  63. package/src/lib/server/lead-create.d.ts +15 -4
  64. package/src/lib/server/lead-create.js +75 -15
  65. package/src/lib/server/lead-create.js.map +1 -1
  66. package/src/lib/server/leads-import.js +2 -2
  67. package/src/lib/server/leads-import.js.map +1 -1
  68. package/src/lib/server/record-email-state.js +10 -0
  69. package/src/lib/server/record-email-state.js.map +1 -1
  70. package/src/lib/server/record-facts.js +3 -3
  71. package/src/lib/server/record-facts.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-crm",
3
- "version": "1.0.0-beta.164",
3
+ "version": "1.0.0-beta.166",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.164",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.164",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.164",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.164",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.164",
33
- "@aglyn/shared-ui-next": "1.0.0-beta.164",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.164",
35
- "@aglyn/shared-util-email": "1.0.0-beta.164",
36
- "@aglyn/shared-util-http": "1.0.0-beta.164",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.164",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.164",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.164",
28
+ "@aglyn/aglyn": "1.0.0-beta.166",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.166",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.166",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.166",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.166",
33
+ "@aglyn/shared-ui-next": "1.0.0-beta.166",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.166",
35
+ "@aglyn/shared-util-email": "1.0.0-beta.166",
36
+ "@aglyn/shared-util-http": "1.0.0-beta.166",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.166",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.166",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.166",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/utilities": "^3.2.2",
42
42
  "@swc/helpers": "0.5.23",
@@ -33,6 +33,7 @@ import { doc, getDoc } from "firebase/firestore";
33
33
  import { useCallback, useEffect, useMemo, useState } from "react";
34
34
  import { useContactUpdate } from "../hooks/use-contact-update.js";
35
35
  import { crmRoutes } from "../model/crm-routes.js";
36
+ import { useCrmScope } from "../hooks/use-crm-scope.js";
36
37
  /**
37
38
  * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).
38
39
  *
@@ -74,6 +75,11 @@ import { crmRoutes } from "../model/crm-routes.js";
74
75
  */ export function ContactAssociationsCard(props) {
75
76
  var _record_sources;
76
77
  const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props;
78
+ // The lead's collection is the org's now (AGL-3275); the site still names
79
+ // the surface, and the scope hook resolves the org from it.
80
+ const { orgId } = useCrmScope({
81
+ hostId
82
+ });
77
83
  const firestore = useFirestore();
78
84
  const { enqueueSnackbar } = useSnackbar();
79
85
  const contactUpdate = useContactUpdate(hostId);
@@ -87,7 +93,7 @@ import { crmRoutes } from "../model/crm-routes.js";
87
93
  void (async ()=>{
88
94
  const key = await personKeyInBrowser(email);
89
95
  if (!key || !active) return;
90
- const snapshot = await getDoc(doc(firestore, 'hosts', hostId, 'leads', key)).catch(()=>null);
96
+ const snapshot = await getDoc(doc(firestore, 'orgs', orgId, 'leads', key)).catch(()=>null);
91
97
  if (active && (snapshot == null ? void 0 : snapshot.exists())) setLeadKey(key);
92
98
  })().catch(()=>undefined);
93
99
  return ()=>{
@@ -240,10 +246,20 @@ import { crmRoutes } from "../model/crm-routes.js";
240
246
  })
241
247
  ]
242
248
  }) : null,
243
- hostId ? /*#__PURE__*/ _jsx(CrmRecordAttributionZone, {
244
- hostId: hostId,
245
- recordKind: "contact",
246
- recordId: record.$id
249
+ hostId ? /*#__PURE__*/ _jsxs(Stack, {
250
+ spacing: 0.5,
251
+ children: [
252
+ /*#__PURE__*/ _jsx(CrmRecordAttributionZone, {
253
+ hostId: hostId,
254
+ recordKind: "contact",
255
+ recordId: record.$id
256
+ }),
257
+ /*#__PURE__*/ _jsx(Typography, {
258
+ variant: "caption",
259
+ color: "text.secondary",
260
+ children: 'Filing is separate — see Filed under campaigns.'
261
+ })
262
+ ]
247
263
  }) : null,
248
264
  /*#__PURE__*/ _jsxs(Stack, {
249
265
  spacing: 0.5,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-associations-card.tsx"],"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'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport {\n CONTACT_SOURCE_LABELS,\n type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { personKeyInBrowser } from '../model/person-key-web'\n/*\n * The component path and NOT the marketing barrel: that barrel is the entry\n * point the tenant's loader imports to activate the plugin's SITE half, so a\n * console card named there ships to every published page.\n */\nimport { CrmRecordAttributionZone } from './crm-attribution-zone'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useHostCampaigns,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Divider, Stack, Typography } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport type { ContactRecord } from '../model/contact-record'\nimport { crmRoutes } from '../model/crm-routes'\n\nexport interface ContactAssociationsCardProps {\n /**\n * The site whose lead, campaigns and attribution are shown — the mounted\n * site, or at the organization level the holder's own; `null` for a row\n * no site has captured, when none of the three has an address.\n */\n hostId: string | null\n /** The row, flattened through the viewing group's facet. */\n record: ContactRecord\n /** The document as read, for the consent fields the projection leaves out. */\n row: Record<string, unknown>\n consentGroup: ConsentGroup\n /** The listener's verdict on the row, for the stale-seed guard. */\n seed: { status: 'loading' | 'success' | 'error'; fromCache: boolean }\n /** The hub's own path, for the link to this person's lead. */\n basePath: string\n /**\n * The site's Bookings page narrowed to this person's address (AGL-2660),\n * or nothing where the site has no booking door — the page decides, and\n * the row is simply not drawn.\n */\n bookingsHref?: string | null\n}\n\n/**\n * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).\n *\n * Four facts that were in the v1 drawer and are not properties of the\n * person so much as of the RELATIONSHIP:\n *\n * - The SOURCES say which mechanism created the record — a form, a\n * checkout, an import.\n * - The ATTRIBUTION says which campaign led to it, recorded from the link\n * they followed and never editable. One keyed read, paid on opening the\n * page rather than per row of the list.\n * - The CONSENT is the basis this controller holds for marketing email:\n * opted in, opted out, or no record — three states that never collapse\n * into two, because \"no record\" is the commonest and is not a refusal.\n * - The FILING is which campaigns the team has PUT them in, a working set\n * kept by hand. It adds nobody to a send: a campaign's audience is its\n * lists and each email's own picker, and the helper says so where a\n * reader would otherwise assume the opposite.\n *\n * The filing is the one editable thing here and has its own Save, through\n * the same stale-seed guard the properties card uses: a campaign membership\n * written over a cached read could revert somebody else's filing. The save\n * goes to `crm/contact-update` (AGL-2804), because the filing is this\n * holder's facet and a facet is the server's to write; like every field that\n * route writes, it is refused to a plan without the CRM (AGL-2851).\n *\n * ## The lead (AGL-2612)\n *\n * A LEAD is the sales record a lead surface — a sign-up, a booking, a form\n * with lead routing — files beside the contact, under the capturing site at\n * `hosts/{hostId}/leads/{personKey}`. The lead's page links the contact\n * once converted; nothing linked back, so the two halves of one person\n * read as unrelated. One `getDoc` by the key the capture derives — the\n * address, normalized and hashed, computed here on WebCrypto — is what\n * answers \"does this site hold a lead for this person\", and it is a read\n * this page pays because opening the record IS the ask. Absence is a fact\n * too: a contact captured by an order or an import has no lead, and the\n * chip is simply not there.\n */\nexport function ContactAssociationsCard(props: ContactAssociationsCardProps) {\n const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId)\n const routes = crmRoutes(basePath)\n\n const [leadKey, setLeadKey] = useState<string | null>(null)\n const email = record.email\n useEffect(() => {\n let active = true\n setLeadKey(null)\n if (!hostId) return undefined\n void (async () => {\n const key = await personKeyInBrowser(email)\n if (!key || !active) return\n const snapshot = await getDoc(\n doc(firestore, 'hosts', hostId, 'leads', key),\n ).catch(() => null)\n if (active && snapshot?.exists()) setLeadKey(key)\n })().catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId, email])\n\n const [campaigns, setCampaigns] = useState<string[]>(record.campaignIds)\n const [saving, setSaving] = useState(false)\n const recordId = record.$id\n useEffect(() => {\n setCampaigns(record.campaignIds)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [recordId])\n\n /*\n * The site's campaigns, read because this page IS the ask: one picker on\n * one record, and nothing in the list ever paid for the campaign list.\n */\n const siteCampaigns = useHostCampaigns(hostId ?? undefined, { enabled: Boolean(hostId) })\n\n const consent = useMemo(\n () => readMarketingBasis(row, consentGroup),\n [row, consentGroup],\n )\n\n const handleSaveFiling = useCallback(async () => {\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: seed.status === 'error',\n fromCache: seed.fromCache,\n },\n // An empty selection is sent as an empty list rather than left out,\n // so \"filed under no campaign\" has one shape here and in the pass\n // that detaches a deleted campaign.\n () =>\n contactUpdate.updateOne(record.$id, {\n campaignIds: Aglyn.campaignMembershipValue(campaigns),\n }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [campaigns, contactUpdate, enqueueSnackbar, record.$id, seed.fromCache, seed.status])\n\n const sources = Object.keys(record.sources ?? {}) as ContactSource[]\n const consentDetail = [\n consent.basisAtMs\n ? `recorded ${new Date(consent.basisAtMs).toLocaleDateString()}`\n : null,\n consent.assertedBy === 'operator' ? 'entered by your team' : null,\n consent.otherGrant === 'other-host'\n ? 'opted in to another site in this workspace'\n : null,\n ].filter(Boolean)\n\n return (\n <CardDisplay\n header={'Relationship'}\n help={Aglyn.pluginDocsHelp('contactRecord', { anchor: '#what-each-site-keeps-to-itself' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Sources'}</Typography>\n {sources.length || leadKey ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source] ?? source}\n size=\"small\"\n />\n ))}\n {/*\n This site's lead for the person, when it holds one — a link,\n in the shape the lead's page uses to point back here.\n */}\n {leadKey ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.lead(leadKey)}\n size=\"small\"\n variant=\"outlined\"\n sx={{ py: 0, minHeight: 24, lineHeight: 1.5 }}\n >\n {'Lead on this site'}\n </Button>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No capture recorded.'}\n </Typography>\n )}\n </Stack>\n {/*\n The person's bookings on this site (AGL-2660): every booking\n taken with their address, past ones too, on the Bookings page —\n the appointments are that page's records, and this card only\n points at them.\n */}\n {bookingsHref ? (\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Bookings'}</Typography>\n <Typography variant=\"body2\">\n <AppLink href={bookingsHref}>{'Bookings on this site'}</AppLink>\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Every booking made with this address, upcoming and past.'}\n </Typography>\n </Stack>\n ) : null}\n {hostId ? (\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"contact\"\n recordId={record.$id}\n />\n ) : null}\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Marketing email'}</Typography>\n <Typography variant=\"body2\">\n {MARKETING_BASIS_LABELS[consent.basis]}\n {consentDetail.length ? ` · ${consentDetail.join(' · ')}` : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {consent.basis === 'unrecorded'\n ? 'Nothing was recorded either way — not a refusal, and not permission.'\n : `The basis this ${\n consentGroup.declared ? 'group' : 'site'\n } holds. Other sites in the workspace keep their own.`}\n </Typography>\n </Stack>\n <Divider />\n <CampaignPicker\n options={siteCampaigns.options}\n value={campaigns}\n onChange={setCampaigns}\n label=\"Filed under campaigns\"\n helperText=\"Your own filing. It never adds anyone to a send — a campaign mails its lists.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n emptyText=\"This site has no campaigns yet.\"\n />\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || (siteCampaigns.ready && !siteCampaigns.options.length)}\n onClick={() => void handleSaveFiling()}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nContactAssociationsCard.displayName = 'ContactAssociationsCard'\n\nexport default ContactAssociationsCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","MARKETING_BASIS_LABELS","readMarketingBasis","personKeyInBrowser","CrmRecordAttributionZone","CampaignPicker","AppLink","CardDisplay","useSnackbar","useFirestore","useHostCampaigns","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","ContactAssociationsCard","props","record","hostId","row","consentGroup","seed","basePath","bookingsHref","firestore","enqueueSnackbar","contactUpdate","routes","leadKey","setLeadKey","email","active","undefined","key","snapshot","catch","exists","campaigns","setCampaigns","campaignIds","saving","setSaving","recordId","$id","siteCampaigns","enabled","Boolean","consent","handleSaveFiling","verdict","subject","unreadable","status","fromCache","updateOne","campaignMembershipValue","ok","message","variant","persist","error","console","Error","allowDuplicate","sources","Object","keys","consentDetail","basisAtMs","Date","toLocaleDateString","assertedBy","otherGrant","filter","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","spacing","length","direction","sx","flexWrap","rowGap","map","source","label","size","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","options","value","onChange","helperText","empty","ready","emptyText","disabled","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-associations-card.tsx"],"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'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport {\n CONTACT_SOURCE_LABELS,\n type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { personKeyInBrowser } from '../model/person-key-web'\n/*\n * The component path and NOT the marketing barrel: that barrel is the entry\n * point the tenant's loader imports to activate the plugin's SITE half, so a\n * console card named there ships to every published page.\n */\nimport { CrmRecordAttributionZone } from './crm-attribution-zone'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useHostCampaigns,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Divider, Stack, Typography } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport type { ContactRecord } from '../model/contact-record'\nimport { crmRoutes } from '../model/crm-routes'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\nexport interface ContactAssociationsCardProps {\n /**\n * The site whose lead, campaigns and attribution are shown — the mounted\n * site, or at the organization level the holder's own; `null` for a row\n * no site has captured, when none of the three has an address.\n */\n hostId: string | null\n /** The row, flattened through the viewing group's facet. */\n record: ContactRecord\n /** The document as read, for the consent fields the projection leaves out. */\n row: Record<string, unknown>\n consentGroup: ConsentGroup\n /** The listener's verdict on the row, for the stale-seed guard. */\n seed: { status: 'loading' | 'success' | 'error'; fromCache: boolean }\n /** The hub's own path, for the link to this person's lead. */\n basePath: string\n /**\n * The site's Bookings page narrowed to this person's address (AGL-2660),\n * or nothing where the site has no booking door — the page decides, and\n * the row is simply not drawn.\n */\n bookingsHref?: string | null\n}\n\n/**\n * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).\n *\n * Four facts that were in the v1 drawer and are not properties of the\n * person so much as of the RELATIONSHIP:\n *\n * - The SOURCES say which mechanism created the record — a form, a\n * checkout, an import.\n * - The ATTRIBUTION says which campaign led to it, recorded from the link\n * they followed and never editable. One keyed read, paid on opening the\n * page rather than per row of the list.\n * - The CONSENT is the basis this controller holds for marketing email:\n * opted in, opted out, or no record — three states that never collapse\n * into two, because \"no record\" is the commonest and is not a refusal.\n * - The FILING is which campaigns the team has PUT them in, a working set\n * kept by hand. It adds nobody to a send: a campaign's audience is its\n * lists and each email's own picker, and the helper says so where a\n * reader would otherwise assume the opposite.\n *\n * The filing is the one editable thing here and has its own Save, through\n * the same stale-seed guard the properties card uses: a campaign membership\n * written over a cached read could revert somebody else's filing. The save\n * goes to `crm/contact-update` (AGL-2804), because the filing is this\n * holder's facet and a facet is the server's to write; like every field that\n * route writes, it is refused to a plan without the CRM (AGL-2851).\n *\n * ## The lead (AGL-2612)\n *\n * A LEAD is the sales record a lead surface — a sign-up, a booking, a form\n * with lead routing — files beside the contact, under the capturing site at\n * `hosts/{hostId}/leads/{personKey}`. The lead's page links the contact\n * once converted; nothing linked back, so the two halves of one person\n * read as unrelated. One `getDoc` by the key the capture derives — the\n * address, normalized and hashed, computed here on WebCrypto — is what\n * answers \"does this site hold a lead for this person\", and it is a read\n * this page pays because opening the record IS the ask. Absence is a fact\n * too: a contact captured by an order or an import has no lead, and the\n * chip is simply not there.\n */\nexport function ContactAssociationsCard(props: ContactAssociationsCardProps) {\n const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId)\n const routes = crmRoutes(basePath)\n\n const [leadKey, setLeadKey] = useState<string | null>(null)\n const email = record.email\n useEffect(() => {\n let active = true\n setLeadKey(null)\n if (!hostId) return undefined\n void (async () => {\n const key = await personKeyInBrowser(email)\n if (!key || !active) return\n const snapshot = await getDoc(\n doc(firestore, 'orgs', orgId, 'leads', key),\n ).catch(() => null)\n if (active && snapshot?.exists()) setLeadKey(key)\n })().catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId, email])\n\n const [campaigns, setCampaigns] = useState<string[]>(record.campaignIds)\n const [saving, setSaving] = useState(false)\n const recordId = record.$id\n useEffect(() => {\n setCampaigns(record.campaignIds)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [recordId])\n\n /*\n * The site's campaigns, read because this page IS the ask: one picker on\n * one record, and nothing in the list ever paid for the campaign list.\n */\n const siteCampaigns = useHostCampaigns(hostId ?? undefined, { enabled: Boolean(hostId) })\n\n const consent = useMemo(\n () => readMarketingBasis(row, consentGroup),\n [row, consentGroup],\n )\n\n const handleSaveFiling = useCallback(async () => {\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: seed.status === 'error',\n fromCache: seed.fromCache,\n },\n // An empty selection is sent as an empty list rather than left out,\n // so \"filed under no campaign\" has one shape here and in the pass\n // that detaches a deleted campaign.\n () =>\n contactUpdate.updateOne(record.$id, {\n campaignIds: Aglyn.campaignMembershipValue(campaigns),\n }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [campaigns, contactUpdate, enqueueSnackbar, record.$id, seed.fromCache, seed.status])\n\n const sources = Object.keys(record.sources ?? {}) as ContactSource[]\n const consentDetail = [\n consent.basisAtMs\n ? `recorded ${new Date(consent.basisAtMs).toLocaleDateString()}`\n : null,\n consent.assertedBy === 'operator' ? 'entered by your team' : null,\n consent.otherGrant === 'other-host'\n ? 'opted in to another site in this workspace'\n : null,\n ].filter(Boolean)\n\n return (\n <CardDisplay\n header={'Relationship'}\n help={Aglyn.pluginDocsHelp('contactRecord', { anchor: '#what-each-site-keeps-to-itself' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Sources'}</Typography>\n {sources.length || leadKey ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source] ?? source}\n size=\"small\"\n />\n ))}\n {/*\n This site's lead for the person, when it holds one — a link,\n in the shape the lead's page uses to point back here.\n */}\n {leadKey ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.lead(leadKey)}\n size=\"small\"\n variant=\"outlined\"\n sx={{ py: 0, minHeight: 24, lineHeight: 1.5 }}\n >\n {'Lead on this site'}\n </Button>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No capture recorded.'}\n </Typography>\n )}\n </Stack>\n {/*\n The person's bookings on this site (AGL-2660): every booking\n taken with their address, past ones too, on the Bookings page —\n the appointments are that page's records, and this card only\n points at them.\n */}\n {bookingsHref ? (\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Bookings'}</Typography>\n <Typography variant=\"body2\">\n <AppLink href={bookingsHref}>{'Bookings on this site'}</AppLink>\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Every booking made with this address, upcoming and past.'}\n </Typography>\n </Stack>\n ) : null}\n {hostId ? (\n <Stack spacing={0.5}>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"contact\"\n recordId={record.$id}\n />\n {/* Attribution is the campaign that brought the person; the\n picker below is the team's own filing (AGL-3274). */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filing is separate — see Filed under campaigns.'}\n </Typography>\n </Stack>\n ) : null}\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Marketing email'}</Typography>\n <Typography variant=\"body2\">\n {MARKETING_BASIS_LABELS[consent.basis]}\n {consentDetail.length ? ` · ${consentDetail.join(' · ')}` : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {consent.basis === 'unrecorded'\n ? 'Nothing was recorded either way — not a refusal, and not permission.'\n : `The basis this ${\n consentGroup.declared ? 'group' : 'site'\n } holds. Other sites in the workspace keep their own.`}\n </Typography>\n </Stack>\n <Divider />\n <CampaignPicker\n options={siteCampaigns.options}\n value={campaigns}\n onChange={setCampaigns}\n label=\"Filed under campaigns\"\n helperText=\"Your own filing. It never adds anyone to a send — a campaign mails its lists.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n emptyText=\"This site has no campaigns yet.\"\n />\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || (siteCampaigns.ready && !siteCampaigns.options.length)}\n onClick={() => void handleSaveFiling()}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nContactAssociationsCard.displayName = 'ContactAssociationsCard'\n\nexport default ContactAssociationsCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","MARKETING_BASIS_LABELS","readMarketingBasis","personKeyInBrowser","CrmRecordAttributionZone","CampaignPicker","AppLink","CardDisplay","useSnackbar","useFirestore","useHostCampaigns","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","ContactAssociationsCard","props","record","hostId","row","consentGroup","seed","basePath","bookingsHref","orgId","firestore","enqueueSnackbar","contactUpdate","routes","leadKey","setLeadKey","email","active","undefined","key","snapshot","catch","exists","campaigns","setCampaigns","campaignIds","saving","setSaving","recordId","$id","siteCampaigns","enabled","Boolean","consent","handleSaveFiling","verdict","subject","unreadable","status","fromCache","updateOne","campaignMembershipValue","ok","message","variant","persist","error","console","Error","allowDuplicate","sources","Object","keys","consentDetail","basisAtMs","Date","toLocaleDateString","assertedBy","otherGrant","filter","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","spacing","length","direction","sx","flexWrap","rowGap","map","source","label","size","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","options","value","onChange","helperText","empty","ready","emptyText","disabled","onClick","displayName"],"mappings":"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"}
@@ -21,13 +21,30 @@ import { useCallback, useEffect, useMemo, useState } from "react";
21
21
  /** How the object reads in the drawer's own sentences — singular, lowercase. */ const OBJECT_NOUN = {
22
22
  contact: 'contact',
23
23
  company: 'company',
24
- deal: 'deal'
24
+ deal: 'deal',
25
+ lead: 'lead'
26
+ };
27
+ /**
28
+ * Which objects a CSV import can fill a custom field on — see
29
+ * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the
30
+ * mapping menu offers. The deal and lead files carry the standard columns
31
+ * only, so their drawers must not promise an import that would silently
32
+ * drop the column.
33
+ */ const IMPORTABLE = {
34
+ contact: true,
35
+ company: true,
36
+ deal: false,
37
+ lead: false
25
38
  };
26
39
  /**
27
40
  * What the drawer says about website forms, per object — see
28
41
  * {@link ContactFieldDrawerProps.object}.
29
42
  */ export function fieldDrawerMappingNote(object) {
30
- return object === 'contact' ? 'A website form field can save its answer into this field.' : `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` + 'field is filled on the record, by CSV import or over the API.';
43
+ if (object === 'contact') {
44
+ return 'A website form field can save its answer into this field.';
45
+ }
46
+ const filled = IMPORTABLE[object] ? 'is filled on the record, by CSV import or over the API.' : 'is filled on the record or over the API.';
47
+ return `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` + `field ${filled}`;
31
48
  }
32
49
  /** How many choices a select may declare, and how long each may be. */ const OPTIONS_MAX = 50;
33
50
  const OPTION_MAX_LENGTH = 120;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"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'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n return object === 'contact'\n ? 'A website form field can save its answer into this field.'\n : `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n 'field is filled on the record, by CSV import or over the API.'\n}\n\n/** How many choices a select may declare, and how long each may be. */\nconst OPTIONS_MAX = 50\nconst OPTION_MAX_LENGTH = 120\nconst LABEL_MAX_LENGTH = 80\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */\nfunction parseOptions(text: string): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const line of text.split('\\n')) {\n const option = line.trim().slice(0, OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= OPTIONS_MAX) break\n }\n return options\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","OBJECT_NOUN","contact","company","deal","fieldDrawerMappingNote","object","OPTIONS_MAX","OPTION_MAX_LENGTH","LABEL_MAX_LENGTH","parseOptions","text","seen","Set","options","line","split","option","trim","slice","has","add","push","length","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","handleSubmit","caught","console","anchor","spacing","sx","width","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"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'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How many choices a select may declare, and how long each may be. */\nconst OPTIONS_MAX = 50\nconst OPTION_MAX_LENGTH = 120\nconst LABEL_MAX_LENGTH = 80\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */\nfunction parseOptions(text: string): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const line of text.split('\\n')) {\n const option = line.trim().slice(0, OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= OPTIONS_MAX) break\n }\n return options\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","OPTIONS_MAX","OPTION_MAX_LENGTH","LABEL_MAX_LENGTH","parseOptions","text","seen","Set","options","line","split","option","trim","slice","has","add","push","length","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","handleSubmit","caught","console","anchor","spacing","sx","width","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
@@ -60,14 +60,19 @@ import TasksSection from "./tasks-section.js";
60
60
  })) : /*#__PURE__*/ _jsx(ContactsSection, _extends({}, props));
61
61
  case 'leads':
62
62
  /*
63
- * A lead lives under its site, so at the organization level the
64
- * address names the site before the id — `leads/{hostId}/{leadId}`
65
- * (AGL-2630) and the record page is mounted UNDER that site: every
66
- * read and write it makes is host-scoped by path, exactly as on the
67
- * site's own hub. Under a site the id is the one segment it always
68
- * was.
63
+ * ONE SEGMENT, at both levels (AGL-3275).
64
+ *
65
+ * The organization level used to name the site before the id
66
+ * `leads/{hostId}/{leadId}` (AGL-2630) because a lead lived under its
67
+ * site and a person key was the same id under each site that had met
68
+ * them, so the id alone could not say which record was meant. A lead is
69
+ * one org row now, and addresses like every other CRM record.
70
+ *
71
+ * The two-segment form is still ACCEPTED, and deliberately: links to a
72
+ * lead were shared, bookmarked and mailed while the old shape was live,
73
+ * and the id it carries is the same person key either way. The site
74
+ * segment is simply dropped.
69
75
  */ return record ? !props.hostId && detail.length >= 2 ? /*#__PURE__*/ _jsx(LeadDetailPage, _extends({}, recordProps, {
70
- hostId: record,
71
76
  id: detail[1]
72
77
  })) : /*#__PURE__*/ _jsx(LeadDetailPage, _extends({}, recordProps, {
73
78
  id: record
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-console-page.tsx"],"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'use client'\n\nimport type { ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport type { ReactNode } from 'react'\nimport { CrmOrgMountProvider } from '../hooks/use-crm-org-mount'\nimport CompaniesSection from './companies-section'\nimport CompanyDetailPage from './company-detail-page'\nimport ContactDetailPage from './contact-detail-page'\nimport type { CrmConsoleSectionId } from './crm-console-sections'\nimport DealDetailPage from './deal-detail-page'\nimport DealsSection from './deals-section'\nimport FieldsSection from './fields-section'\nimport LeadDetailPage from './lead-detail-page'\nimport LeadsSection from './leads-section'\nimport ContactsSection from './contacts-section'\nimport ReportsSection from './reports-section'\nimport CrmSettingsSection from './settings-section'\nimport TasksSection from './tasks-section'\n\n/**\n * The body of one Contacts section, built only when that section is the one\n * being read (AGL-2595).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and the people list alone\n * opens four Firestore listens and an aggregate read on mount. Only the\n * returned branch is ever built.\n *\n * Three sections own a deeper route — a record beneath the list — and read\n * it off `detail`, the segments after the section id. A list section with\n * nothing after it is the list; one segment is the record; the shell has\n * already 404'd anything it could not resolve to this surface.\n */\nfunction sectionBody(\n section: CrmConsoleSectionId,\n props: ConsolePluginPageProps,\n /** The section's OWN segments — `segments[1]` onward. */\n detail: readonly string[],\n basePath: string,\n): ReactNode {\n const record = detail[0]\n const recordProps = {\n hostId: props.hostId,\n org: props.org,\n permissions: props.permissions,\n releaseFlag: props.releaseFlag,\n hostRole: props.hostRole,\n basePath,\n }\n switch (section) {\n case 'contacts':\n return record ? (\n <ContactDetailPage {...recordProps} id={record} />\n ) : (\n <ContactsSection {...props} />\n )\n case 'leads':\n /*\n * A lead lives under its site, so at the organization level the\n * address names the site before the id `leads/{hostId}/{leadId}`\n * (AGL-2630) — and the record page is mounted UNDER that site: every\n * read and write it makes is host-scoped by path, exactly as on the\n * site's own hub. Under a site the id is the one segment it always\n * was.\n */\n return record ? (\n !props.hostId && detail.length >= 2 ? (\n <LeadDetailPage {...recordProps} hostId={record} id={detail[1]} />\n ) : (\n <LeadDetailPage {...recordProps} id={record} />\n )\n ) : (\n <LeadsSection {...props} />\n )\n case 'companies':\n return record ? (\n <CompanyDetailPage {...recordProps} id={record} />\n ) : (\n <CompaniesSection\n hostId={props.hostId}\n org={props.org}\n basePath={basePath}\n />\n )\n case 'deals':\n return record ? (\n <DealDetailPage {...recordProps} id={record} />\n ) : (\n <DealsSection {...props} />\n )\n case 'tasks':\n return <TasksSection {...props} />\n case 'reports':\n // The shell's props whole, like the contacts list: the reports resolve\n // the org scope from `hostId`, the consent group from `org`, and their\n // drill-down links from `basePath` (AGL-2604).\n return <ReportsSection {...props} />\n case 'fields':\n return <FieldsSection hostId={props.hostId} org={props.org} />\n case 'settings':\n // The org-wide switches (AGL-2613): what the CRM does on its own for\n // every site in the workspace, written to the org document.\n return <CrmSettingsSection hostId={props.hostId} org={props.org} />\n default:\n return null\n }\n}\n\n/**\n * Contacts (AGL-109 → AGL-395 → AGL-2595): the CRM hub — people, companies,\n * deals, tasks, reports and fields — owned by the contacts plugin and\n * rendered by the shell's generic plugin route. The shell applies the\n * `release_crm` gate (via the nav tab) and passes the resolved `org`\n * doc, which the people section reads for the `contactsPerHost` quota.\n *\n * Sections are ROUTES, following the hubs that migrated before it (AGL-2501):\n * `/contacts/people` is the v1 list, and every other section is a URL of its\n * own, so the page builds one section's body and the others do not exist to\n * subscribe. The v1 body moved to `contacts-section.tsx` untouched; what this\n * file adds is the rail and the switch.\n */\nexport function CrmConsolePage(props: ConsolePluginPageProps) {\n const { section, sections, basePath, segments, orgMount } = props\n\n /*\n * Nothing until the URL names a section. The shell redirects a bare\n * `/contacts` to the landing section and holds a spinner while it does, so\n * this state is transient — and rendering the people list here instead\n * would pay for its listens on a URL that is already being replaced.\n */\n if (!section || !sections?.length || !basePath) return null\n\n const hub = (\n <HubSections sections={sections}>\n {sectionBody(\n section as CrmConsoleSectionId,\n props,\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n (segments ?? []).slice(1),\n basePath,\n )}\n </HubSections>\n )\n /*\n * THE ORGANIZATION-LEVEL MOUNT (AGL-2630). The shell hands an `orgMount`\n * only from `/[orgSlug]/crm`, where there is no site: the hub publishes it\n * so every section, record page and drawer beneath resolves its scope from\n * the org rather than from the `null` host it was handed. Under a site\n * there is no provider, and `useCrmOrgMount` answering `null` is how a\n * surface knows it is on one.\n */\n return orgMount ? (\n <CrmOrgMountProvider mount={orgMount}>{hub}</CrmOrgMountProvider>\n ) : (\n hub\n )\n}\nCrmConsolePage.displayName = 'CrmConsolePage'\n\nexport default CrmConsolePage\n"],"names":["HubSections","CrmOrgMountProvider","CompaniesSection","CompanyDetailPage","ContactDetailPage","DealDetailPage","DealsSection","FieldsSection","LeadDetailPage","LeadsSection","ContactsSection","ReportsSection","CrmSettingsSection","TasksSection","sectionBody","section","props","detail","basePath","record","recordProps","hostId","org","permissions","releaseFlag","hostRole","id","length","CrmConsolePage","sections","segments","orgMount","hub","slice","mount","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAGA,SAASA,WAAW,QAAQ,wBAAuB;AAEnD,SAASC,mBAAmB,QAAQ,gCAA4B;AAChE,OAAOC,sBAAsB,yBAAqB;AAClD,OAAOC,uBAAuB,2BAAuB;AACrD,OAAOC,uBAAuB,2BAAuB;AAErD,OAAOC,oBAAoB,wBAAoB;AAC/C,OAAOC,kBAAkB,qBAAiB;AAC1C,OAAOC,mBAAmB,sBAAkB;AAC5C,OAAOC,oBAAoB,wBAAoB;AAC/C,OAAOC,kBAAkB,qBAAiB;AAC1C,OAAOC,qBAAqB,wBAAoB;AAChD,OAAOC,oBAAoB,uBAAmB;AAC9C,OAAOC,wBAAwB,wBAAoB;AACnD,OAAOC,kBAAkB,qBAAiB;AAE1C;;;;;;;;;;;;;CAaC,GACD,SAASC,YACPC,OAA4B,EAC5BC,KAA6B,EAC7B,uDAAuD,GACvDC,MAAyB,EACzBC,QAAgB;IAEhB,MAAMC,SAASF,MAAM,CAAC,EAAE;IACxB,MAAMG,cAAc;QAClBC,QAAQL,MAAMK,MAAM;QACpBC,KAAKN,MAAMM,GAAG;QACdC,aAAaP,MAAMO,WAAW;QAC9BC,aAAaR,MAAMQ,WAAW;QAC9BC,UAAUT,MAAMS,QAAQ;QACxBP;IACF;IACA,OAAQH;QACN,KAAK;YACH,OAAOI,uBACL,KAACf,gCAAsBgB;gBAAaM,IAAIP;gCAExC,KAACT,8BAAoBM;QAEzB,KAAK;YACH;;;;;;;OAOC,GACD,OAAOG,SACL,CAACH,MAAMK,MAAM,IAAIJ,OAAOU,MAAM,IAAI,kBAChC,KAACnB,6BAAmBY;gBAAaC,QAAQF;gBAAQO,IAAIT,MAAM,CAAC,EAAE;gCAE9D,KAACT,6BAAmBY;gBAAaM,IAAIP;gCAGvC,KAACV,2BAAiBO;QAEtB,KAAK;YACH,OAAOG,uBACL,KAAChB,gCAAsBiB;gBAAaM,IAAIP;gCAExC,KAACjB;gBACCmB,QAAQL,MAAMK,MAAM;gBACpBC,KAAKN,MAAMM,GAAG;gBACdJ,UAAUA;;QAGhB,KAAK;YACH,OAAOC,uBACL,KAACd,6BAAmBe;gBAAaM,IAAIP;gCAErC,KAACb,2BAAiBU;QAEtB,KAAK;YACH,qBAAO,KAACH,2BAAiBG;QAC3B,KAAK;YACH,uEAAuE;YACvE,uEAAuE;YACvE,+CAA+C;YAC/C,qBAAO,KAACL,6BAAmBK;QAC7B,KAAK;YACH,qBAAO,KAACT;gBAAcc,QAAQL,MAAMK,MAAM;gBAAEC,KAAKN,MAAMM,GAAG;;QAC5D,KAAK;YACH,qEAAqE;YACrE,4DAA4D;YAC5D,qBAAO,KAACV;gBAAmBS,QAAQL,MAAMK,MAAM;gBAAEC,KAAKN,MAAMM,GAAG;;QACjE;YACE,OAAO;IACX;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASM,eAAeZ,KAA6B;IAC1D,MAAM,EAAED,OAAO,EAAEc,QAAQ,EAAEX,QAAQ,EAAEY,QAAQ,EAAEC,QAAQ,EAAE,GAAGf;IAE5D;;;;;GAKC,GACD,IAAI,CAACD,WAAW,EAACc,4BAAAA,SAAUF,MAAM,KAAI,CAACT,UAAU,OAAO;IAEvD,MAAMc,oBACJ,KAAChC;QAAY6B,UAAUA;kBACpBf,YACCC,SACAC,OAGA,CAFA,sEAAsE;QACtE,2DAA2D;QAC1Dc,mBAAAA,WAAY,EAAE,EAAEG,KAAK,CAAC,IACvBf;;IAIN;;;;;;;GAOC,GACD,OAAOa,yBACL,KAAC9B;QAAoBiC,OAAOH;kBAAWC;SAEvCA;AAEJ;AACAJ,eAAeO,WAAW,GAAG;AAE7B,eAAeP,eAAc"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-console-page.tsx"],"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'use client'\n\nimport type { ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport type { ReactNode } from 'react'\nimport { CrmOrgMountProvider } from '../hooks/use-crm-org-mount'\nimport CompaniesSection from './companies-section'\nimport CompanyDetailPage from './company-detail-page'\nimport ContactDetailPage from './contact-detail-page'\nimport type { CrmConsoleSectionId } from './crm-console-sections'\nimport DealDetailPage from './deal-detail-page'\nimport DealsSection from './deals-section'\nimport FieldsSection from './fields-section'\nimport LeadDetailPage from './lead-detail-page'\nimport LeadsSection from './leads-section'\nimport ContactsSection from './contacts-section'\nimport ReportsSection from './reports-section'\nimport CrmSettingsSection from './settings-section'\nimport TasksSection from './tasks-section'\n\n/**\n * The body of one Contacts section, built only when that section is the one\n * being read (AGL-2595).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and the people list alone\n * opens four Firestore listens and an aggregate read on mount. Only the\n * returned branch is ever built.\n *\n * Three sections own a deeper route — a record beneath the list — and read\n * it off `detail`, the segments after the section id. A list section with\n * nothing after it is the list; one segment is the record; the shell has\n * already 404'd anything it could not resolve to this surface.\n */\nfunction sectionBody(\n section: CrmConsoleSectionId,\n props: ConsolePluginPageProps,\n /** The section's OWN segments — `segments[1]` onward. */\n detail: readonly string[],\n basePath: string,\n): ReactNode {\n const record = detail[0]\n const recordProps = {\n hostId: props.hostId,\n org: props.org,\n permissions: props.permissions,\n releaseFlag: props.releaseFlag,\n hostRole: props.hostRole,\n basePath,\n }\n switch (section) {\n case 'contacts':\n return record ? (\n <ContactDetailPage {...recordProps} id={record} />\n ) : (\n <ContactsSection {...props} />\n )\n case 'leads':\n /*\n * ONE SEGMENT, at both levels (AGL-3275).\n *\n * The organization level used to name the site before the id —\n * `leads/{hostId}/{leadId}` (AGL-2630) — because a lead lived under its\n * site and a person key was the same id under each site that had met\n * them, so the id alone could not say which record was meant. A lead is\n * one org row now, and addresses like every other CRM record.\n *\n * The two-segment form is still ACCEPTED, and deliberately: links to a\n * lead were shared, bookmarked and mailed while the old shape was live,\n * and the id it carries is the same person key either way. The site\n * segment is simply dropped.\n */\n return record ? (\n !props.hostId && detail.length >= 2 ? (\n <LeadDetailPage {...recordProps} id={detail[1]} />\n ) : (\n <LeadDetailPage {...recordProps} id={record} />\n )\n ) : (\n <LeadsSection {...props} />\n )\n case 'companies':\n return record ? (\n <CompanyDetailPage {...recordProps} id={record} />\n ) : (\n <CompaniesSection\n hostId={props.hostId}\n org={props.org}\n basePath={basePath}\n />\n )\n case 'deals':\n return record ? (\n <DealDetailPage {...recordProps} id={record} />\n ) : (\n <DealsSection {...props} />\n )\n case 'tasks':\n return <TasksSection {...props} />\n case 'reports':\n // The shell's props whole, like the contacts list: the reports resolve\n // the org scope from `hostId`, the consent group from `org`, and their\n // drill-down links from `basePath` (AGL-2604).\n return <ReportsSection {...props} />\n case 'fields':\n return <FieldsSection hostId={props.hostId} org={props.org} />\n case 'settings':\n // The org-wide switches (AGL-2613): what the CRM does on its own for\n // every site in the workspace, written to the org document.\n return <CrmSettingsSection hostId={props.hostId} org={props.org} />\n default:\n return null\n }\n}\n\n/**\n * Contacts (AGL-109 → AGL-395 → AGL-2595): the CRM hub — people, companies,\n * deals, tasks, reports and fields — owned by the contacts plugin and\n * rendered by the shell's generic plugin route. The shell applies the\n * `release_crm` gate (via the nav tab) and passes the resolved `org`\n * doc, which the people section reads for the `contactsPerHost` quota.\n *\n * Sections are ROUTES, following the hubs that migrated before it (AGL-2501):\n * `/contacts/people` is the v1 list, and every other section is a URL of its\n * own, so the page builds one section's body and the others do not exist to\n * subscribe. The v1 body moved to `contacts-section.tsx` untouched; what this\n * file adds is the rail and the switch.\n */\nexport function CrmConsolePage(props: ConsolePluginPageProps) {\n const { section, sections, basePath, segments, orgMount } = props\n\n /*\n * Nothing until the URL names a section. The shell redirects a bare\n * `/contacts` to the landing section and holds a spinner while it does, so\n * this state is transient — and rendering the people list here instead\n * would pay for its listens on a URL that is already being replaced.\n */\n if (!section || !sections?.length || !basePath) return null\n\n const hub = (\n <HubSections sections={sections}>\n {sectionBody(\n section as CrmConsoleSectionId,\n props,\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n (segments ?? []).slice(1),\n basePath,\n )}\n </HubSections>\n )\n /*\n * THE ORGANIZATION-LEVEL MOUNT (AGL-2630). The shell hands an `orgMount`\n * only from `/[orgSlug]/crm`, where there is no site: the hub publishes it\n * so every section, record page and drawer beneath resolves its scope from\n * the org rather than from the `null` host it was handed. Under a site\n * there is no provider, and `useCrmOrgMount` answering `null` is how a\n * surface knows it is on one.\n */\n return orgMount ? (\n <CrmOrgMountProvider mount={orgMount}>{hub}</CrmOrgMountProvider>\n ) : (\n hub\n )\n}\nCrmConsolePage.displayName = 'CrmConsolePage'\n\nexport default CrmConsolePage\n"],"names":["HubSections","CrmOrgMountProvider","CompaniesSection","CompanyDetailPage","ContactDetailPage","DealDetailPage","DealsSection","FieldsSection","LeadDetailPage","LeadsSection","ContactsSection","ReportsSection","CrmSettingsSection","TasksSection","sectionBody","section","props","detail","basePath","record","recordProps","hostId","org","permissions","releaseFlag","hostRole","id","length","CrmConsolePage","sections","segments","orgMount","hub","slice","mount","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAGA,SAASA,WAAW,QAAQ,wBAAuB;AAEnD,SAASC,mBAAmB,QAAQ,gCAA4B;AAChE,OAAOC,sBAAsB,yBAAqB;AAClD,OAAOC,uBAAuB,2BAAuB;AACrD,OAAOC,uBAAuB,2BAAuB;AAErD,OAAOC,oBAAoB,wBAAoB;AAC/C,OAAOC,kBAAkB,qBAAiB;AAC1C,OAAOC,mBAAmB,sBAAkB;AAC5C,OAAOC,oBAAoB,wBAAoB;AAC/C,OAAOC,kBAAkB,qBAAiB;AAC1C,OAAOC,qBAAqB,wBAAoB;AAChD,OAAOC,oBAAoB,uBAAmB;AAC9C,OAAOC,wBAAwB,wBAAoB;AACnD,OAAOC,kBAAkB,qBAAiB;AAE1C;;;;;;;;;;;;;CAaC,GACD,SAASC,YACPC,OAA4B,EAC5BC,KAA6B,EAC7B,uDAAuD,GACvDC,MAAyB,EACzBC,QAAgB;IAEhB,MAAMC,SAASF,MAAM,CAAC,EAAE;IACxB,MAAMG,cAAc;QAClBC,QAAQL,MAAMK,MAAM;QACpBC,KAAKN,MAAMM,GAAG;QACdC,aAAaP,MAAMO,WAAW;QAC9BC,aAAaR,MAAMQ,WAAW;QAC9BC,UAAUT,MAAMS,QAAQ;QACxBP;IACF;IACA,OAAQH;QACN,KAAK;YACH,OAAOI,uBACL,KAACf,gCAAsBgB;gBAAaM,IAAIP;gCAExC,KAACT,8BAAoBM;QAEzB,KAAK;YACH;;;;;;;;;;;;;OAaC,GACD,OAAOG,SACL,CAACH,MAAMK,MAAM,IAAIJ,OAAOU,MAAM,IAAI,kBAChC,KAACnB,6BAAmBY;gBAAaM,IAAIT,MAAM,CAAC,EAAE;gCAE9C,KAACT,6BAAmBY;gBAAaM,IAAIP;gCAGvC,KAACV,2BAAiBO;QAEtB,KAAK;YACH,OAAOG,uBACL,KAAChB,gCAAsBiB;gBAAaM,IAAIP;gCAExC,KAACjB;gBACCmB,QAAQL,MAAMK,MAAM;gBACpBC,KAAKN,MAAMM,GAAG;gBACdJ,UAAUA;;QAGhB,KAAK;YACH,OAAOC,uBACL,KAACd,6BAAmBe;gBAAaM,IAAIP;gCAErC,KAACb,2BAAiBU;QAEtB,KAAK;YACH,qBAAO,KAACH,2BAAiBG;QAC3B,KAAK;YACH,uEAAuE;YACvE,uEAAuE;YACvE,+CAA+C;YAC/C,qBAAO,KAACL,6BAAmBK;QAC7B,KAAK;YACH,qBAAO,KAACT;gBAAcc,QAAQL,MAAMK,MAAM;gBAAEC,KAAKN,MAAMM,GAAG;;QAC5D,KAAK;YACH,qEAAqE;YACrE,4DAA4D;YAC5D,qBAAO,KAACV;gBAAmBS,QAAQL,MAAMK,MAAM;gBAAEC,KAAKN,MAAMM,GAAG;;QACjE;YACE,OAAO;IACX;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASM,eAAeZ,KAA6B;IAC1D,MAAM,EAAED,OAAO,EAAEc,QAAQ,EAAEX,QAAQ,EAAEY,QAAQ,EAAEC,QAAQ,EAAE,GAAGf;IAE5D;;;;;GAKC,GACD,IAAI,CAACD,WAAW,EAACc,4BAAAA,SAAUF,MAAM,KAAI,CAACT,UAAU,OAAO;IAEvD,MAAMc,oBACJ,KAAChC;QAAY6B,UAAUA;kBACpBf,YACCC,SACAC,OAGA,CAFA,sEAAsE;QACtE,2DAA2D;QAC1Dc,mBAAAA,WAAY,EAAE,EAAEG,KAAK,CAAC,IACvBf;;IAIN;;;;;;;GAOC,GACD,OAAOa,yBACL,KAAC9B;QAAoBiC,OAAOH;kBAAWC;SAEvCA;AAEJ;AACAJ,eAAeO,WAAW,GAAG;AAE7B,eAAeP,eAAc"}
@@ -25,7 +25,7 @@ import { useMemo } from "react";
25
25
  import { useConsoleHostRoute, useFirestore, useOrgDataScope, useOrgPlan } from "@aglyn/tenant-feature-instance";
26
26
  import { useCrmOrgMount } from "../hooks/use-crm-org-mount.js";
27
27
  import { crmRoutes } from "../model/crm-routes.js";
28
- import { openLeadsAcrossSites } from "./reports/lead-counts.js";
28
+ import { openLeadsForScope } from "./reports/lead-counts.js";
29
29
  import { scopedCollection, visibleToClause } from "./reports/report-scope.js";
30
30
  import { ReportStatTile } from "./reports/report-stat-tile.js";
31
31
  import { useAggregateRead } from "./reports/use-aggregate-read.js";
@@ -121,7 +121,9 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
121
121
  }),
122
122
  // Due today OR overdue: everything open that is due before the day ends.
123
123
  countOf(query(tasks, ...visibleToClause(tokens), where('status', '==', 'open'), where('dueAtMs', '<', day.end))),
124
- openLeadsAcrossSites(firestore, leadSites)
124
+ // One scoped count over the org collection (AGL-3275) — see the
125
+ // function's note on why summing per site would double-count.
126
+ openLeadsForScope(firestore, scope[1], tokens)
125
127
  ]).then(([contacts, newThisWeek, pipelineCents, tasksDue, leadsToWork])=>({
126
128
  contacts,
127
129
  newThisWeek,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-glance-card.tsx"],"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'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Stack, Typography } from '@mui/material'\nimport {\n getAggregateFromServer,\n getCountFromServer,\n query,\n sum,\n Timestamp,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useOrgDataScope,\n useOrgPlan,\n} from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmRoutes } from '../model/crm-routes'\nimport { openLeadsAcrossSites } from './reports/lead-counts'\nimport { scopedCollection, visibleToClause } from './reports/report-scope'\nimport { ReportStatTile } from './reports/report-stat-tile'\nimport { useAggregateRead } from './reports/use-aggregate-read'\n\nconst WEEK_MS = 7 * 24 * 60 * 60 * 1000\n\ninterface GlanceFigures {\n contacts: number\n newThisWeek: number\n pipelineCents: number\n tasksDue: number\n /** Open leads — new or being worked — on this site, or across the org's sites. */\n leadsToWork: number\n}\n\nexport interface CrmGlanceCardProps {\n /**\n * The site the card is on the dashboard of, or `null` beneath the org\n * hub's mount (AGL-2634), where the figures total every site: the\n * org-wide reads carry no scope clause, and the lead figure fans out\n * over the mount's site list.\n */\n hostId: string | null\n /** The org hub's own path, for the links when there is no site to derive them from. */\n basePath?: string\n}\n\n/**\n * CRM at a glance (AGL-2604): a `hostDashboard` widget with five numbers —\n * contacts, new this week, open pipeline value, tasks due today or overdue,\n * and leads still to work (AGL-2624) — each a link into the section that\n * explains it.\n *\n * Server aggregates and nothing else: a dashboard card mounts on every\n * visit to the site's front page, so it is the one surface on which a\n * bounded read of a thousand rows would be a cost paid for nothing. Each\n * number is a count or a sum the server takes over the same `visibleTo`\n * predicate the hub's sections query with, so the card never shows a figure\n * the section behind the link would not.\n *\n * The lead figure is two counts and a subtraction — every lead on the site,\n * less the closed ones — because a lead nobody has touched carries no status\n * field and Firestore cannot select on a field's absence; see\n * `openLeadsFromCounts`. Leads are host-scoped by path rather than by\n * `visibleTo`, so those two counts run on `hosts/{hostId}/leads` under the\n * site's own rules, the way the Leads section reads it.\n *\n * The widget is handed a host doc id and nothing else, so the org — needed\n * for the consent group whose tokens scope the reads — comes from\n * `useOrgPlan`, and the link base from `useConsoleHostRoute`, the way the\n * commerce glance card builds its store link. Nothing is read until the org\n * has settled: a first round of aggregates against the solo group, replaced\n * by a second against the real one, would be two reads for one answer.\n *\n * ## The organization level (AGL-2634)\n *\n * Mounted with no site beneath the org hub's mount, the card totals the\n * organization: the reads carry no scope clause — the rules admit an\n * org-wide member to every row — and the lead figure is the same two\n * counts per site, over every site the mount lists, summed. Nothing is\n * read until that list has settled, for the reason nothing is read under\n * a site until the org has. The org's dashboard has no widget slot yet, so\n * the mount is a capability rather than a page; what it needs from the\n * shell is a slot and the hub's `basePath`.\n */\nexport function CrmGlanceCard(props: CrmGlanceCardProps) {\n const { hostId, basePath } = props\n const mount = useCrmOrgMount()\n const consoleRoute = useConsoleHostRoute(hostId)\n const { org, ready: planReady } = useOrgPlan(hostId ?? undefined)\n const { scope } = useOrgDataScope(hostId ? { hostId } : { orgId: mount?.orgId })\n const firestore = useFirestore()\n // Under a site, the site's group's tokens once the org has settled; at\n // the org level `null` — no clause — once the site list has.\n const tokens = useMemo(() => {\n if (!hostId) return null\n if (!planReady) return undefined\n const group = Aglyn.consentGroupForHost(org ?? null, hostId)\n return [\n Aglyn.ORG_SCOPE_TOKEN,\n ...group.hostIds.map((id) => Aglyn.hostScopeToken(id)),\n ].slice(0, Aglyn.MAX_SCOPE_HOSTS)\n }, [org, planReady, hostId])\n const leadSites = useMemo(\n () =>\n hostId\n ? [hostId]\n : mount?.hostsReady\n ? mount.hosts.map((host) => host.id)\n : null,\n [hostId, mount],\n )\n const leadSitesKey = leadSites?.join('\\n') ?? ''\n // Anchored once per mount so the queries are stable across renders.\n const nowMs = useMemo(() => Date.now(), [])\n\n const figures = useAggregateRead<GlanceFigures>(() => {\n if (!scope || tokens === undefined || !leadSites) return null\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const deals = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.deals)\n const tasks = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks)\n const day = Aglyn.localDayBounds(nowMs)\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n return Promise.all([\n countOf(query(contacts, ...visibleToClause(tokens))),\n countOf(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(nowMs - WEEK_MS)),\n ),\n ),\n getAggregateFromServer(\n query(deals, ...visibleToClause(tokens), where('status', '==', 'open')),\n { amountCents: sum('amountCents') },\n ).then((snapshot) => Number(snapshot.data().amountCents ?? 0)),\n // Due today OR overdue: everything open that is due before the day ends.\n countOf(\n query(\n tasks,\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n where('dueAtMs', '<', day.end),\n ),\n ),\n openLeadsAcrossSites(firestore, leadSites),\n ]).then(([contacts, newThisWeek, pipelineCents, tasksDue, leadsToWork]) => ({\n contacts,\n newThisWeek,\n pipelineCents,\n tasksDue,\n leadsToWork,\n }))\n }, [firestore, scope, tokens, nowMs, leadSitesKey])\n\n const routes = useMemo(\n () =>\n hostId\n ? consoleRoute.base\n ? crmRoutes(`${consoleRoute.base}/crm`)\n : null\n : basePath\n ? crmRoutes(basePath)\n : null,\n [hostId, consoleRoute.base, basePath],\n )\n const hubHref = hostId\n ? consoleRoute.base\n ? `${consoleRoute.base}/crm`\n : undefined\n : basePath\n const value = figures.value\n\n return (\n <CardDisplay\n header={'CRM at a glance'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#crm-at-a-glance',\n excerpt:\n 'Contacts, new contacts this week, the value of every open deal, ' +\n 'the tasks due today or overdue, and the leads still to work — ' +\n 'each counted on the server and each a link into the CRM.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={hubHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Open CRM'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Contacts'}\n value={value ? value.contacts.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'New this week'}\n value={value ? value.newThisWeek.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'Open pipeline'}\n value={value ? money(value.pipelineCents, 'usd') : null}\n href={routes?.section('deals')}\n />\n <ReportStatTile\n label={'Tasks due'}\n value={value ? value.tasksDue.toLocaleString() : null}\n note={'today or overdue'}\n color={value?.tasksDue ? 'warning.main' : undefined}\n href={routes?.section('tasks')}\n />\n <ReportStatTile\n label={'Leads to work'}\n value={value ? value.leadsToWork.toLocaleString() : null}\n note={hostId ? 'open on this site' : 'open across every site'}\n href={routes?.section('leads')}\n />\n </Stack>\n {figures.status === 'error' ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The CRM figures could not be read. Open the CRM for the full picture.'}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCrmGlanceCard.displayName = 'CrmGlanceCard'\n\nexport default CrmGlanceCard\n"],"names":["Aglyn","money","AppLink","CardDisplay","Button","Stack","Typography","getAggregateFromServer","getCountFromServer","query","sum","Timestamp","where","useMemo","useConsoleHostRoute","useFirestore","useOrgDataScope","useOrgPlan","useCrmOrgMount","crmRoutes","openLeadsAcrossSites","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","WEEK_MS","CrmGlanceCard","props","hostId","basePath","mount","consoleRoute","org","ready","planReady","undefined","scope","orgId","firestore","tokens","group","consentGroupForHost","ORG_SCOPE_TOKEN","hostIds","map","id","hostScopeToken","slice","MAX_SCOPE_HOSTS","leadSites","hostsReady","hosts","host","leadSitesKey","join","nowMs","Date","now","figures","contacts","deals","CRM_COLLECTIONS","tasks","day","localDayBounds","countOf","target","then","snapshot","data","count","Promise","all","fromMillis","amountCents","Number","end","newThisWeek","pipelineCents","tasksDue","leadsToWork","routes","base","hubHref","value","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","size","color","spacing","direction","sx","flexWrap","label","toLocaleString","section","note","status","variant","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-glance-card.tsx"],"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'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Stack, Typography } from '@mui/material'\nimport {\n getAggregateFromServer,\n getCountFromServer,\n query,\n sum,\n Timestamp,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useOrgDataScope,\n useOrgPlan,\n} from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmRoutes } from '../model/crm-routes'\nimport { openLeadsForScope } from './reports/lead-counts'\nimport { scopedCollection, visibleToClause } from './reports/report-scope'\nimport { ReportStatTile } from './reports/report-stat-tile'\nimport { useAggregateRead } from './reports/use-aggregate-read'\n\nconst WEEK_MS = 7 * 24 * 60 * 60 * 1000\n\ninterface GlanceFigures {\n contacts: number\n newThisWeek: number\n pipelineCents: number\n tasksDue: number\n /** Open leads — new or being worked — on this site, or across the org's sites. */\n leadsToWork: number\n}\n\nexport interface CrmGlanceCardProps {\n /**\n * The site the card is on the dashboard of, or `null` beneath the org\n * hub's mount (AGL-2634), where the figures total every site: the\n * org-wide reads carry no scope clause, and the lead figure fans out\n * over the mount's site list.\n */\n hostId: string | null\n /** The org hub's own path, for the links when there is no site to derive them from. */\n basePath?: string\n}\n\n/**\n * CRM at a glance (AGL-2604): a `hostDashboard` widget with five numbers —\n * contacts, new this week, open pipeline value, tasks due today or overdue,\n * and leads still to work (AGL-2624) — each a link into the section that\n * explains it.\n *\n * Server aggregates and nothing else: a dashboard card mounts on every\n * visit to the site's front page, so it is the one surface on which a\n * bounded read of a thousand rows would be a cost paid for nothing. Each\n * number is a count or a sum the server takes over the same `visibleTo`\n * predicate the hub's sections query with, so the card never shows a figure\n * the section behind the link would not.\n *\n * The lead figure is two counts and a subtraction — every lead on the site,\n * less the closed ones — because a lead nobody has touched carries no status\n * field and Firestore cannot select on a field's absence; see\n * `openLeadsFromCounts`. Leads are host-scoped by path rather than by\n * `visibleTo`, so those two counts run on `hosts/{hostId}/leads` under the\n * site's own rules, the way the Leads section reads it.\n *\n * The widget is handed a host doc id and nothing else, so the org — needed\n * for the consent group whose tokens scope the reads — comes from\n * `useOrgPlan`, and the link base from `useConsoleHostRoute`, the way the\n * commerce glance card builds its store link. Nothing is read until the org\n * has settled: a first round of aggregates against the solo group, replaced\n * by a second against the real one, would be two reads for one answer.\n *\n * ## The organization level (AGL-2634)\n *\n * Mounted with no site beneath the org hub's mount, the card totals the\n * organization: the reads carry no scope clause — the rules admit an\n * org-wide member to every row — and the lead figure is the same two\n * counts per site, over every site the mount lists, summed. Nothing is\n * read until that list has settled, for the reason nothing is read under\n * a site until the org has. The org's dashboard has no widget slot yet, so\n * the mount is a capability rather than a page; what it needs from the\n * shell is a slot and the hub's `basePath`.\n */\nexport function CrmGlanceCard(props: CrmGlanceCardProps) {\n const { hostId, basePath } = props\n const mount = useCrmOrgMount()\n const consoleRoute = useConsoleHostRoute(hostId)\n const { org, ready: planReady } = useOrgPlan(hostId ?? undefined)\n const { scope } = useOrgDataScope(hostId ? { hostId } : { orgId: mount?.orgId })\n const firestore = useFirestore()\n // Under a site, the site's group's tokens once the org has settled; at\n // the org level `null` — no clause — once the site list has.\n const tokens = useMemo(() => {\n if (!hostId) return null\n if (!planReady) return undefined\n const group = Aglyn.consentGroupForHost(org ?? null, hostId)\n return [\n Aglyn.ORG_SCOPE_TOKEN,\n ...group.hostIds.map((id) => Aglyn.hostScopeToken(id)),\n ].slice(0, Aglyn.MAX_SCOPE_HOSTS)\n }, [org, planReady, hostId])\n const leadSites = useMemo(\n () =>\n hostId\n ? [hostId]\n : mount?.hostsReady\n ? mount.hosts.map((host) => host.id)\n : null,\n [hostId, mount],\n )\n const leadSitesKey = leadSites?.join('\\n') ?? ''\n // Anchored once per mount so the queries are stable across renders.\n const nowMs = useMemo(() => Date.now(), [])\n\n const figures = useAggregateRead<GlanceFigures>(() => {\n if (!scope || tokens === undefined || !leadSites) return null\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const deals = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.deals)\n const tasks = scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks)\n const day = Aglyn.localDayBounds(nowMs)\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n return Promise.all([\n countOf(query(contacts, ...visibleToClause(tokens))),\n countOf(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(nowMs - WEEK_MS)),\n ),\n ),\n getAggregateFromServer(\n query(deals, ...visibleToClause(tokens), where('status', '==', 'open')),\n { amountCents: sum('amountCents') },\n ).then((snapshot) => Number(snapshot.data().amountCents ?? 0)),\n // Due today OR overdue: everything open that is due before the day ends.\n countOf(\n query(\n tasks,\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n where('dueAtMs', '<', day.end),\n ),\n ),\n // One scoped count over the org collection (AGL-3275) — see the\n // function's note on why summing per site would double-count.\n openLeadsForScope(firestore, scope[1], tokens),\n ]).then(([contacts, newThisWeek, pipelineCents, tasksDue, leadsToWork]) => ({\n contacts,\n newThisWeek,\n pipelineCents,\n tasksDue,\n leadsToWork,\n }))\n }, [firestore, scope, tokens, nowMs, leadSitesKey])\n\n const routes = useMemo(\n () =>\n hostId\n ? consoleRoute.base\n ? crmRoutes(`${consoleRoute.base}/crm`)\n : null\n : basePath\n ? crmRoutes(basePath)\n : null,\n [hostId, consoleRoute.base, basePath],\n )\n const hubHref = hostId\n ? consoleRoute.base\n ? `${consoleRoute.base}/crm`\n : undefined\n : basePath\n const value = figures.value\n\n return (\n <CardDisplay\n header={'CRM at a glance'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#crm-at-a-glance',\n excerpt:\n 'Contacts, new contacts this week, the value of every open deal, ' +\n 'the tasks due today or overdue, and the leads still to work — ' +\n 'each counted on the server and each a link into the CRM.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={hubHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Open CRM'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Contacts'}\n value={value ? value.contacts.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'New this week'}\n value={value ? value.newThisWeek.toLocaleString() : null}\n href={routes?.section('contacts')}\n />\n <ReportStatTile\n label={'Open pipeline'}\n value={value ? money(value.pipelineCents, 'usd') : null}\n href={routes?.section('deals')}\n />\n <ReportStatTile\n label={'Tasks due'}\n value={value ? value.tasksDue.toLocaleString() : null}\n note={'today or overdue'}\n color={value?.tasksDue ? 'warning.main' : undefined}\n href={routes?.section('tasks')}\n />\n <ReportStatTile\n label={'Leads to work'}\n value={value ? value.leadsToWork.toLocaleString() : null}\n note={hostId ? 'open on this site' : 'open across every site'}\n href={routes?.section('leads')}\n />\n </Stack>\n {figures.status === 'error' ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The CRM figures could not be read. Open the CRM for the full picture.'}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCrmGlanceCard.displayName = 'CrmGlanceCard'\n\nexport default CrmGlanceCard\n"],"names":["Aglyn","money","AppLink","CardDisplay","Button","Stack","Typography","getAggregateFromServer","getCountFromServer","query","sum","Timestamp","where","useMemo","useConsoleHostRoute","useFirestore","useOrgDataScope","useOrgPlan","useCrmOrgMount","crmRoutes","openLeadsForScope","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","WEEK_MS","CrmGlanceCard","props","hostId","basePath","mount","consoleRoute","org","ready","planReady","undefined","scope","orgId","firestore","tokens","group","consentGroupForHost","ORG_SCOPE_TOKEN","hostIds","map","id","hostScopeToken","slice","MAX_SCOPE_HOSTS","leadSites","hostsReady","hosts","host","leadSitesKey","join","nowMs","Date","now","figures","contacts","deals","CRM_COLLECTIONS","tasks","day","localDayBounds","countOf","target","then","snapshot","data","count","Promise","all","fromMillis","amountCents","Number","end","newThisWeek","pipelineCents","tasksDue","leadsToWork","routes","base","hubHref","value","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","size","color","spacing","direction","sx","flexWrap","label","toLocaleString","section","note","status","variant","displayName"],"mappings":"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"}
@@ -2,7 +2,8 @@ import { type ConsolePluginPageProps } from '@aglyn/aglyn';
2
2
  export type ContactsFieldsSectionProps = Pick<ConsolePluginPageProps, 'hostId' | 'org'>;
3
3
  /**
4
4
  * `/crm/fields` — the custom fields a holder keeps on a person (AGL-2601),
5
- * and since AGL-2661 on a company and a deal too, one tab each.
5
+ * since AGL-2661 on a company and a deal too, and since AGL-3272 on a lead:
6
+ * one tab each, and every CRM record describable in the org's own words.
6
7
  *
7
8
  * Definitions live in `orgs/{orgId}/contactFields`, one document per field,
8
9
  * and the VALUES live under each contact facet's `custom` keyed by the
@@ -35,16 +35,19 @@ import { crmTaskCallScope } from "../model/task-routes.js";
35
35
  /** What each tab says the fields are for, above its list. */ const OBJECT_INTRO = {
36
36
  contact: 'The custom fields on a contact — text, number, date, choice, ' + 'checkbox or link. They show on every contact, as columns on the ' + 'list, and a form field can save into one.',
37
37
  company: 'The custom fields on a company. They show on every company’s page ' + 'and its edit form, as columns on the companies list, and a CSV ' + 'import can fill them.',
38
- deal: 'The custom fields on a deal. They show on every deal’s page and ' + 'its edit form, and as columns on the deals table.'
38
+ deal: 'The custom fields on a deal. They show on every deal’s page and ' + 'its edit form, and as columns on the deals table.',
39
+ lead: 'The custom fields on a lead. They show on every lead’s page and the ' + 'New lead drawer, and as columns on the leads list. They stay on the ' + 'lead when it converts — a contact’s fields are its own.'
39
40
  };
40
41
  /** The noun each tab's empty state and captions use. */ const OBJECT_NOUN = {
41
42
  contact: 'contact',
42
43
  company: 'company',
43
- deal: 'deal'
44
+ deal: 'deal',
45
+ lead: 'lead'
44
46
  };
45
47
  /**
46
48
  * `/crm/fields` — the custom fields a holder keeps on a person (AGL-2601),
47
- * and since AGL-2661 on a company and a deal too, one tab each.
49
+ * since AGL-2661 on a company and a deal too, and since AGL-3272 on a lead:
50
+ * one tab each, and every CRM record describable in the org's own words.
48
51
  *
49
52
  * Definitions live in `orgs/{orgId}/contactFields`, one document per field,
50
53
  * and the VALUES live under each contact facet's `custom` keyed by the