@aglyn/plugins-crm 1.0.0-beta.200 → 1.0.0-beta.202

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 (78) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/activity-list.d.ts +36 -5
  3. package/src/lib/components/activity-list.js +159 -137
  4. package/src/lib/components/activity-list.js.map +1 -1
  5. package/src/lib/components/activity-timeline.d.ts +84 -0
  6. package/src/lib/components/activity-timeline.js +224 -0
  7. package/src/lib/components/activity-timeline.js.map +1 -0
  8. package/src/lib/components/company-properties-card.js +53 -36
  9. package/src/lib/components/company-properties-card.js.map +1 -1
  10. package/src/lib/components/contact-associations-card.d.ts +3 -2
  11. package/src/lib/components/contact-associations-card.js +12 -12
  12. package/src/lib/components/contact-associations-card.js.map +1 -1
  13. package/src/lib/components/contact-detail-page.d.ts +4 -3
  14. package/src/lib/components/contact-detail-page.js +4 -10
  15. package/src/lib/components/contact-detail-page.js.map +1 -1
  16. package/src/lib/components/contact-properties-card.d.ts +21 -4
  17. package/src/lib/components/contact-properties-card.js +129 -29
  18. package/src/lib/components/contact-properties-card.js.map +1 -1
  19. package/src/lib/components/contact-timeline-card.d.ts +12 -6
  20. package/src/lib/components/contact-timeline-card.js +132 -174
  21. package/src/lib/components/contact-timeline-card.js.map +1 -1
  22. package/src/lib/components/contacts-section.js +197 -198
  23. package/src/lib/components/contacts-section.js.map +1 -1
  24. package/src/lib/components/crm-email-state-chip.js +4 -1
  25. package/src/lib/components/crm-email-state-chip.js.map +1 -1
  26. package/src/lib/components/deal-products-card.js +15 -13
  27. package/src/lib/components/deal-products-card.js.map +1 -1
  28. package/src/lib/components/deal-properties-card.js +7 -0
  29. package/src/lib/components/deal-properties-card.js.map +1 -1
  30. package/src/lib/components/deal-stage-card.js +31 -29
  31. package/src/lib/components/deal-stage-card.js.map +1 -1
  32. package/src/lib/components/deals-section.js +9 -14
  33. package/src/lib/components/deals-section.js.map +1 -1
  34. package/src/lib/components/fields-section.d.ts +4 -0
  35. package/src/lib/components/fields-section.js +368 -154
  36. package/src/lib/components/fields-section.js.map +1 -1
  37. package/src/lib/components/form-contact-fields-card.js +24 -20
  38. package/src/lib/components/form-contact-fields-card.js.map +1 -1
  39. package/src/lib/components/lead-campaigns-card.d.ts +1 -1
  40. package/src/lib/components/lead-campaigns-card.js +32 -35
  41. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  42. package/src/lib/components/lead-properties-card.d.ts +14 -6
  43. package/src/lib/components/lead-properties-card.js +317 -313
  44. package/src/lib/components/lead-properties-card.js.map +1 -1
  45. package/src/lib/components/lead-surfaces-note.js +13 -2
  46. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  47. package/src/lib/components/linked-deals-card.js +12 -10
  48. package/src/lib/components/linked-deals-card.js.map +1 -1
  49. package/src/lib/components/recent-activity-feed.d.ts +12 -8
  50. package/src/lib/components/recent-activity-feed.js +54 -29
  51. package/src/lib/components/recent-activity-feed.js.map +1 -1
  52. package/src/lib/components/record-activity-card.d.ts +4 -2
  53. package/src/lib/components/record-activity-card.js +29 -11
  54. package/src/lib/components/record-activity-card.js.map +1 -1
  55. package/src/lib/components/reports/activity-card.js +18 -12
  56. package/src/lib/components/reports/activity-card.js.map +1 -1
  57. package/src/lib/components/reports/lead-funnel-card.js +47 -37
  58. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  59. package/src/lib/components/reports/pipeline-card.js +22 -16
  60. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  61. package/src/lib/components/reports/report-export.d.ts +4 -11
  62. package/src/lib/components/reports/report-export.js +16 -33
  63. package/src/lib/components/reports/report-export.js.map +1 -1
  64. package/src/lib/components/reports/source-conversion-card.js +18 -12
  65. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  66. package/src/lib/components/reports/tasks-card.js +35 -25
  67. package/src/lib/components/reports/tasks-card.js.map +1 -1
  68. package/src/lib/components/tasks-section.js +23 -23
  69. package/src/lib/components/tasks-section.js.map +1 -1
  70. package/src/lib/constants/field-list-query.d.ts +23 -0
  71. package/src/lib/constants/field-list-query.js +80 -0
  72. package/src/lib/constants/field-list-query.js.map +1 -0
  73. package/src/lib/model/lead-filters.d.ts +1 -1
  74. package/src/lib/server/capture-contact.js +7 -5
  75. package/src/lib/server/capture-contact.js.map +1 -1
  76. package/src/lib/components/contact-custom-fields-card.d.ts +0 -39
  77. package/src/lib/components/contact-custom-fields-card.js +0 -204
  78. package/src/lib/components/contact-custom-fields-card.js.map +0 -1
@@ -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 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'\nimport { useCrmCampaigns } from '../hooks/use-crm-campaigns'\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 || !orgId) 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 campaigns, read because this page IS the ask: one picker on one\n * record, and nothing in the list ever paid for the campaign list. Under\n * a site, the ones placed on it; at the organization level, every one.\n */\n const siteCampaigns = useCrmCampaigns({ hostId, orgId }, { enabled: true })\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={hostId ? 'This site has no campaigns yet.' : 'There are 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","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","useCrmCampaigns","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","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","Boolean","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 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'\nimport { useCrmCampaigns } from '../hooks/use-crm-campaigns'\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 filing**\n * in the card header, through the same stale-seed guard the properties card\n * 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 || !orgId) 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 campaigns, read because this page IS the ask: one picker on one\n * record, and nothing in the list ever paid for the campaign list. Under\n * a site, the ones placed on it; at the organization level, every one.\n */\n const siteCampaigns = useCrmCampaigns({ hostId, orgId }, { enabled: true })\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 HeaderProps={{\n action: (\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 ),\n }}\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={hostId ? 'This site has no campaigns yet.' : 'There are no campaigns yet.'}\n />\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","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","useCrmCampaigns","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","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","Boolean","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","HeaderProps","action","size","disabled","ready","options","length","onClick","spacing","direction","sx","flexWrap","rowGap","map","source","label","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","value","onChange","helperText","empty","emptyText","displayName"],"mappings":"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"}
@@ -25,9 +25,10 @@ import { type CrmDetailPageProps } from '../model/crm-routes';
25
25
  * ## The cards
26
26
  *
27
27
  * Each in its own file, so a card can grow without this page growing:
28
- * properties (the editable profile), the relationship (sources, attribution,
29
- * consent, campaign filing) and the timeline. Other CRM sections add a card
30
- * here by inserting one import and one line.
28
+ * properties (the editable profile, with the org's custom fields under it),
29
+ * the relationship (sources, attribution, consent, campaign filing) and the
30
+ * timeline. Other CRM sections add a card here by inserting one import and
31
+ * one line.
31
32
  *
32
33
  * ## Delete is a detach
33
34
  *
@@ -34,7 +34,6 @@ import { useCrmScope } from "../hooks/use-crm-scope.js";
34
34
  import { contactPrimaryGroup, contactRecordFromDoc } from "../model/contact-record.js";
35
35
  import { crmRoutes } from "../model/crm-routes.js";
36
36
  import ContactAssociationsCard from "./contact-associations-card.js";
37
- import ContactCustomFieldsCard from "./contact-custom-fields-card.js";
38
37
  import ContactDuplicatesCard from "./contact-duplicates-card.js";
39
38
  import ContactKnownByCard from "./contact-known-by-card.js";
40
39
  import ContactMergeDialog, { useContactMergeDialog } from "./contact-merge-dialog.js";
@@ -84,9 +83,10 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
84
83
  * ## The cards
85
84
  *
86
85
  * Each in its own file, so a card can grow without this page growing:
87
- * properties (the editable profile), the relationship (sources, attribution,
88
- * consent, campaign filing) and the timeline. Other CRM sections add a card
89
- * here by inserting one import and one line.
86
+ * properties (the editable profile, with the org's custom fields under it),
87
+ * the relationship (sources, attribution, consent, campaign filing) and the
88
+ * timeline. Other CRM sections add a card here by inserting one import and
89
+ * one line.
90
90
  *
91
91
  * ## Delete is a detach
92
92
  *
@@ -468,12 +468,6 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
468
468
  basePath: basePath,
469
469
  bookingsHref: bookingsHref
470
470
  }),
471
- suiteIncluded ? /*#__PURE__*/ _jsx(ContactCustomFieldsCard, {
472
- hostId: hostId,
473
- org: org,
474
- contactId: id,
475
- basePath: basePath
476
- }) : null,
477
471
  /*#__PURE__*/ _jsx(ContactTimelineCard, {
478
472
  hostId: hostId,
479
473
  org: org,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-detail-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 * as Aglyn from '@aglyn/aglyn'\nimport { CONTACT_LIFECYCLE_STAGE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiDeleteOutline, mdiMerge } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport { restampCrmListFields } from '../model/list-fields-api'\nimport { crmTaskCallScope } from '../model/task-routes'\nimport { Stack, Tooltip, Typography } from '@mui/material'\nimport {\n arrayRemove,\n deleteDoc,\n deleteField,\n doc,\n updateDoc,\n} from 'firebase/firestore'\nimport { useParams, useRouter } from 'next/navigation'\nimport { useCallback, useMemo } from 'react'\nimport { useCrmActivityLogger } from '../hooks/use-crm-activity-logger'\nimport { CrmCreateSiteDefault, useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { contactPrimaryGroup, contactRecordFromDoc } from '../model/contact-record'\nimport { type CrmDetailPageProps, crmRoutes } from '../model/crm-routes'\nimport ContactAssociationsCard from './contact-associations-card'\nimport ContactCustomFieldsCard from './contact-custom-fields-card'\nimport ContactDuplicatesCard from './contact-duplicates-card'\nimport ContactKnownByCard from './contact-known-by-card'\nimport ContactMergeDialog, { useContactMergeDialog } from './contact-merge-dialog'\nimport ContactPropertiesCard from './contact-properties-card'\nimport ContactTimelineCard from './contact-timeline-card'\nimport { AddToListButton } from './add-to-list-button'\nimport { useBookingDoor } from './crm-booking-zone'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport { ContactDealsCard } from './contact-deals-card'\nimport { CrmCallButton } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmRecordInsightsZone } from './crm-record-insights-zone'\nimport { CrmSuiteNotice, crmSuiteIncluded, crmSuiteLockedReason } from './crm-suite-lock'\nimport { useErasePersonAction } from './erase-person-action'\nimport RecordFilesCard from './record-files-card'\nimport { RecordTasksCard } from './record-tasks-card'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useOrgMembers } from './use-org-members'\n\nconst contactDocsHelp = pluginDocsHelp('contacts', {\n anchor: '#the-contacts-page',\n excerpt:\n \"One person's record: the profile your team keeps on them, where they \" +\n 'came from, what they are filed under, and what the site recorded.',\n})\n\n/**\n * `/crm/contacts/{contactId}` — one person (AGL-2596).\n *\n * A ROUTE rather than the v1 drawer, because a person is the thing every\n * other CRM record points at: a deal, a task and an activity all name a\n * contact, and a link that opens a drawer on top of a list is not a link\n * anybody can paste.\n *\n * ## What this page reads\n *\n * ONE document, by id, through the scoped rules — a single-document get is\n * judged on that document's own `visibleTo`, so no `array-contains-any`\n * filter is needed here the way it is on the list. The row is then flattened\n * through the viewing group's facet ONCE (`contactRecordFromDoc`) and the\n * cards below read the flat record: none of them reaches into\n * `facets.{groupId}` for itself, which is what keeps a card from ever\n * showing another holder's notes.\n *\n * Beyond the document: the team roster (one request, for the owner picker)\n * and the site's campaigns (one listen, for the filing picker) — both paid\n * here because opening a record IS the ask for them, and neither is paid by\n * the list.\n *\n * ## The cards\n *\n * Each in its own file, so a card can grow without this page growing:\n * properties (the editable profile), the relationship (sources, attribution,\n * consent, campaign filing) and the timeline. Other CRM sections add a card\n * here by inserting one import and one line.\n *\n * ## Delete is a detach\n *\n * The row is shared by every site that has captured this person, so the\n * action in the overflow menu drops THIS group's facet, consent, attribution\n * and scope tokens, and deletes the document only when nobody else is left\n * holding it — `planContactDetach` does the counting off `visibleTo`, which\n * is what both enforcement layers evaluate, and the rules refuse a delete by\n * a caller who is not the sole holder. Not the erasure path: a privacy\n * erasure removes the person regardless of who else holds them.\n */\nexport function ContactDetailPage(props: CrmDetailPageProps) {\n const { hostId, org, id, basePath } = props\n const routes = crmRoutes(basePath)\n const router = useRouter()\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The org root, the viewing group and what this viewer may list — the\n // merge search's and the duplicates query's one clause — from the one\n // scope hook (AGL-2614); at the organization level the group is `null`\n // and resolved per person below, and the listeners carry no clause\n // (AGL-2630).\n const {\n scope,\n orgId,\n consentGroup: viewingGroup,\n visibleTo,\n } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const merge = useContactMergeDialog()\n /*\n * THE CRM (AGL-2788). This page is the CRM's, and the shell mounts no CRM\n * page for a plan without it (AGL-2851). What locks below — owner, stage\n * and company; tasks, deals, files and custom fields; a logged call or\n * activity; one-to-one email; a merge — draws only for an org whose plan\n * lacks the CRM. The shell mounted this page once the org had settled, so\n * this is the plan, not a paint of the org still loading.\n */\n const suiteIncluded = crmSuiteIncluded(org)\n\n const {\n data: row,\n status,\n fromCache,\n } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'contacts', id) : null),\n [firestore, scope, id],\n { idField: '$id' },\n )\n /*\n * The controller this page is showing. Under a site: the sites declared\n * to be one sender, or this site alone — resolved from the org document\n * the shell already passed, so it costs no read. At the ORGANIZATION\n * level: the person's own PRIMARY holder, the first capturing site's\n * group, so an org-wide member reads the profile that site keeps and an\n * edit goes back to the same facet; the \"Known by\" card says which other\n * sites hold one.\n */\n const consentGroup = useMemo(\n () => viewingGroup ?? contactPrimaryGroup(row, org as Record<string, unknown>),\n [viewingGroup, row, org],\n )\n /**\n * The SITE the page acts as, for what needs one — the lead lookup, the\n * campaign filing, the site's activity feed: the mounted site, or the\n * primary holder's, or nothing for a row no site has captured.\n */\n const siteHostId = hostId ?? (consentGroup.hostId || null)\n /*\n * The feed the act is logged in, decided by the level it was performed\n * at (AGL-2738) and so given the MOUNTED site rather than `siteHostId`:\n * at the org hub a client-direct append to the holder's own site would\n * face a gate the record write never had to pass.\n */\n const logActivity = useCrmActivityLogger(hostId)\n const record = useMemo(\n () => (row ? contactRecordFromDoc(row, consentGroup) : null),\n [row, consentGroup],\n )\n const notFound = Boolean(scope) && status !== 'loading' && !row\n /** How the site the delete detaches from is named — see `handleRemove`. */\n const siteLabel = hostId\n ? 'this site'\n : siteHostId\n ? (mount?.siteName(siteHostId) ?? siteHostId)\n : 'its site'\n\n // The roster, for the owner picker and the owner's name — read because a\n // record page is the one place both are shown.\n const members = useOrgMembers(orgId, { enabled: Boolean(record) })\n /*\n * Where this person's ORDERS are read (AGL-2622): the site's orders list,\n * narrowed to their address. The count on the header is the number; the\n * list is the rows. Built from the route params already in the URL, so\n * no document is read to draw a link, and absent off a site — the\n * org-level mount has no orders list to point at.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const ordersHref =\n params?.orgSlug && params?.host && record?.email\n ? Aglyn.siteRecordLinks({\n orgSlug: String(params.orgSlug),\n host: String(params.host),\n }).ordersByCustomer(record.email)\n : null\n /*\n * Where this person's BOOKINGS are read (AGL-2660): the site's Bookings\n * page narrowed to their address, under the same match rule the booking\n * row's own \"View in CRM\" uses. Offered only where the site has a booking\n * door — a site that never enabled Bookings should not carry the row. At\n * the organization level the capturing site's page, addressed through\n * the subdomain the mount knows it by.\n */\n const bookingDoor = useBookingDoor(siteHostId, org)\n const siteSlug = params?.host\n ? String(params.host)\n : siteHostId && mount\n ? mount.siteSubdomain(siteHostId)\n : null\n const bookingsHref =\n bookingDoor.open && params?.orgSlug && siteSlug && record?.email\n ? Aglyn.siteRecordLinks({\n orgSlug: String(params.orgSlug),\n host: siteSlug,\n }).bookingsByBooker(record.email)\n : null\n\n /*\n * Where a campaign entry on the timeline links to: the email's own report\n * on this site's Emails hub (AGL-2616). Only a campaign THIS site sent can\n * be addressed — a sibling site in the same consent group has an Emails\n * hub of its own under a subdomain this page does not know — so the\n * builder answers `null` for those and the entry draws unlinked.\n */\n const emailsHub = useEmailsHubPath()\n const campaignHref = useCallback(\n (email: Aglyn.ContactCampaignEmail) =>\n emailsHub && email.hostId === hostId\n ? `${emailsHub}/messages/${encodeURIComponent(email.campaignId)}`\n : null,\n [emailsHub, hostId],\n )\n\n const { data: user } = useUser()\n const handleRemove = useCallback(async () => {\n if (!row || !scope) return\n const confirmed = await confirm({\n title: 'Delete this contact?',\n description:\n `\"${record?.email ?? id}\" is removed from ${siteLabel}'s ` +\n 'Contacts, along with its notes, tags and timeline. Other sites ' +\n 'that captured the same person keep their own records. Their form ' +\n 'submissions, orders, bookings, and membership records are ' +\n 'separate — delete those from their own pages if the request ' +\n 'covers them.',\n confirmationText: 'Delete contact',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const ref = doc(firestore, scope[0], scope[1], 'contacts', id)\n const plan = Aglyn.planContactDetach(row, consentGroup)\n if (plan.action === 'delete') {\n await deleteDoc(ref)\n } else {\n await updateDoc(ref, {\n ...Object.fromEntries(plan.remove.map((path) => [path, deleteField()])),\n visibleTo: arrayRemove(...plan.removeTokens),\n capturedByHostIds: arrayRemove(...plan.removeHostIds),\n updatedAt: new Date(),\n })\n // The facet and the scope just let go of are what the Contacts list\n // filters and searches by; the rules keep a client from restamping\n // them, so the route does (AGL-3321).\n await restampCrmListFields(user, crmTaskCallScope(hostId, orgId), 'contacts', [id])\n }\n // `siteLabel` and not \"this site\": at the organization level the line\n // lands in a feed that spans every site, so it has to name the one.\n logActivity(`Removed contact from ${siteLabel}`, {\n type: 'contact',\n id,\n name: record?.name || record?.email,\n })\n enqueueSnackbar(\n plan.action === 'delete'\n ? 'Contact deleted'\n : `Contact removed from ${siteLabel}`,\n { variant: 'success', persist: false },\n )\n router.push(routes.section('contacts'))\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n confirm,\n consentGroup,\n enqueueSnackbar,\n firestore,\n id,\n logActivity,\n record,\n router,\n routes,\n row,\n scope,\n siteLabel,\n user,\n hostId,\n orgId,\n ])\n\n const overflowItems: RowActionsMenuItem[] = [\n /*\n * Two records for one person become one (AGL-2625). Opened empty, to be\n * searched, with the OTHER record surviving: a reader on the record they\n * want gone says \"merge this into…\". The dialog lets them switch.\n */\n {\n key: 'merge',\n label: 'Merge into…',\n icon: <MdiIcon path={mdiMerge.path} size={0.8} />,\n disabled: !row || !suiteIncluded,\n disabledReason: suiteIncluded ? 'The contact has not loaded' : crmSuiteLockedReason(),\n onClick: () => merge.open(),\n },\n {\n key: 'remove',\n label: 'Delete contact',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: !row,\n disabledReason: 'The contact has not loaded',\n onClick: () => void handleRemove(),\n },\n ]\n // The privacy erasure (AGL-2623): its item joins the overflow, its banner\n // sits under the header while a request waits, its dialog renders once.\n const erase = useErasePersonAction({\n // Filed as the site the page acts as — at the organization level the\n // person's primary holder (AGL-2630).\n hostId: siteHostId,\n orgId,\n subject: record ? { kind: 'contact', id, email: record.email } : null,\n requestedAtMs: Aglyn.readErasureRequestedAtMs(row),\n })\n\n if (notFound) {\n return (\n <CrmRecordHeader\n kind=\"Contact\"\n title={undefined}\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n >\n {/*\n * Not \"no data\". A contact that cannot be read is a different\n * situation from one with an empty profile — it may have been\n * removed from this site, or be held only by a site this reader\n * cannot see.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This contact could not be loaded. It may have been removed from this site.'}\n </Typography>\n </CrmRecordHeader>\n )\n }\n\n return (\n // A create opened from this page — a task, an activity, a deal — files\n // under the site that captured this person unless the reader says\n // otherwise (AGL-2630).\n <CrmCreateSiteDefault hostId={siteHostId}>\n <Stack spacing={3}>\n <CrmRecordHeader\n kind=\"Contact\"\n title={record ? record.name || record.email : undefined}\n // The identity, and the other addresses a merge folded into it.\n subtitle={\n record\n ? [record.email, ...record.alternateEmails.map((email) => `also ${email}`)].join(' · ')\n : undefined\n }\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n actions={\n record ? (\n <>\n <AddToListButton hostId={hostId} org={org} contactId={id} email={record.email} />\n {/* Dial the number, and log the call it was (AGL-2661). */}\n <CrmCallButton\n hostId={siteHostId}\n org={org}\n link={{ contactId: id }}\n phone={record.phone}\n suiteLocked={!suiteIncluded}\n />\n <CrmSendEmailButton\n hostId={siteHostId}\n org={org}\n contactId={id}\n email={record.email}\n name={record.name}\n emailState={record.emailState}\n suiteLocked={!suiteIncluded}\n />\n </>\n ) : null\n }\n menuItems={[...overflowItems, ...erase.menuItems]}\n // The booking door (AGL-2660): this site's services, or at the\n // organization level the capturing site's.\n booking={record ? { hostId: siteHostId, org, kind: 'contact', recordId: id } : undefined}\n loading={!record}\n chips={\n record ? (\n <>\n {/* The verdict on the address (AGL-3245), first: a bounce or a\n do-not-contact mark is what a person deciding to write must see. */}\n <CrmEmailStateChip state={record.emailState} />\n <CrmRecordChip\n label=\"Stage\"\n value={\n record.lifecycleStage\n ? CONTACT_LIFECYCLE_STAGE_LABELS[record.lifecycleStage]\n : undefined\n }\n />\n <CrmRecordChip\n label=\"Owner\"\n value={record.ownerUid ? members.memberName(record.ownerUid) : undefined}\n />\n {/*\n The last time they opened or clicked one of this site's\n campaigns (AGL-2616) — the relationship's pulse, beside the\n owner who keeps it.\n */}\n <CrmRecordChip\n label=\"Last engaged\"\n value={\n record.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(record.lastEmailEngagementAtMs).toLocaleString()}>\n <span>\n {Aglyn.activityTimeLabel(record.lastEmailEngagementAtMs, Date.now())}\n </span>\n </Tooltip>\n ) : undefined\n }\n />\n <CrmRecordChip\n label=\"Orders\"\n value={\n record.ordersCount > 0 ? (\n ordersHref ? (\n <AppLink href={ordersHref} color=\"inherit\" underline=\"hover\">\n {`${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`}\n </AppLink>\n ) : (\n `${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`\n )\n ) : undefined\n }\n />\n </>\n ) : null\n }\n >\n {erase.banner}\n </CrmRecordHeader>\n {record && row && scope ? (\n <>\n {suiteIncluded ? null : (\n <CrmSuiteNotice>\n {'Working this contact — the owner, stage and company, tasks, ' +\n 'deals, files and custom fields, logging a call or an activity, ' +\n 'one-to-one email and merging duplicates — is part of the CRM.'}\n </CrmSuiteNotice>\n )}\n {/*\n The cross-site fact, at the organization level only (AGL-2630):\n which sites know this person, and their consent for each. Under\n a site the page IS one site's view and the card would say so on\n every record.\n */}\n {/* What an assistant proposes about the person (AGL-2917), where the plan works a contact. */}\n {suiteIncluded ? (\n <CrmRecordInsightsZone\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n kind=\"contact\"\n recordId={id}\n name={record.name || record.email}\n taskLink={{ contactId: id }}\n />\n ) : null}\n {mount ? <ContactKnownByCard row={row} contactId={id} org={org} /> : null}\n <ContactPropertiesCard\n hostId={hostId}\n org={org}\n record={record}\n orgId={orgId}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n members={members}\n suiteLocked={!suiteIncluded}\n />\n <ContactAssociationsCard\n hostId={siteHostId}\n record={record}\n row={row}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n basePath={basePath}\n bookingsHref={bookingsHref}\n />\n {/*\n On a plan without the CRM, the cards that only work a record —\n custom fields, deals, tasks, files — are named in the notice above\n rather than drawn as four upsells in a row.\n */}\n {suiteIncluded ? (\n <ContactCustomFieldsCard hostId={hostId} org={org} contactId={id} basePath={basePath} />\n ) : null}\n <ContactTimelineCard\n hostId={hostId}\n org={org}\n contactId={id}\n contact={row}\n campaignHref={campaignHref}\n suiteLocked={!suiteIncluded}\n />\n {suiteIncluded ? (\n <>\n <ContactDealsCard\n hostId={hostId}\n org={org}\n basePath={basePath}\n contactId={id}\n contactName={record.name || record.email}\n />\n <RecordTasksCard hostId={hostId} org={org} basePath={basePath} contactId={id} />\n {/*\n The files are the VIEWING holder's, like everything else on this\n page: the write is a facet path, so a contract one brand filed\n never appears on a sibling brand's copy of the person.\n */}\n <RecordFilesCard\n scope={scope}\n collection=\"contacts\"\n recordId={id}\n hostId={hostId}\n facetGroupId={consentGroup.groupId}\n mediaIds={record.mediaIds}\n topic=\"contactRecord\"\n />\n </>\n ) : null}\n <ContactDuplicatesCard\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n basePath={basePath}\n onMerge={(candidate) => merge.open({ other: candidate, keep: 'current' })}\n suiteLocked={!suiteIncluded}\n />\n <ContactMergeDialog\n {...merge.state}\n onClose={merge.close}\n hostId={siteHostId}\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n memberName={members.memberName}\n // The page's own record was folded away: its address now opens\n // the survivor, which is where the reader is taken.\n onMerged={(survivorId) => {\n if (survivorId !== id) router.replace(routes.contact(survivorId))\n }}\n />\n </>\n ) : null}\n {erase.dialog}\n </Stack>\n </CrmCreateSiteDefault>\n )\n}\nContactDetailPage.displayName = 'ContactDetailPage'\n\nexport default ContactDetailPage\n"],"names":["Aglyn","CONTACT_LIFECYCLE_STAGE_LABELS","pluginDocsHelp","mdiDeleteOutline","mdiMerge","AppLink","MdiIcon","useConfirmationContext","useSnackbar","useFirestore","useFirestoreDoc","useUser","restampCrmListFields","crmTaskCallScope","Stack","Tooltip","Typography","arrayRemove","deleteDoc","deleteField","doc","updateDoc","useParams","useRouter","useCallback","useMemo","useCrmActivityLogger","CrmCreateSiteDefault","useCrmOrgMount","useCrmScope","contactPrimaryGroup","contactRecordFromDoc","crmRoutes","ContactAssociationsCard","ContactCustomFieldsCard","ContactDuplicatesCard","ContactKnownByCard","ContactMergeDialog","useContactMergeDialog","ContactPropertiesCard","ContactTimelineCard","AddToListButton","useBookingDoor","CrmSendEmailButton","ContactDealsCard","CrmCallButton","CrmEmailStateChip","CrmRecordChip","CrmRecordHeader","CrmRecordInsightsZone","CrmSuiteNotice","crmSuiteIncluded","crmSuiteLockedReason","useErasePersonAction","RecordFilesCard","RecordTasksCard","useEmailsHubPath","useOrgMembers","contactDocsHelp","anchor","excerpt","ContactDetailPage","props","hostId","org","id","basePath","routes","router","firestore","enqueueSnackbar","confirm","scope","orgId","consentGroup","viewingGroup","visibleTo","mount","merge","suiteIncluded","data","row","status","fromCache","idField","siteHostId","logActivity","record","notFound","Boolean","siteLabel","siteName","members","enabled","params","ordersHref","orgSlug","host","email","siteRecordLinks","String","ordersByCustomer","bookingDoor","siteSlug","siteSubdomain","bookingsHref","open","bookingsByBooker","emailsHub","campaignHref","encodeURIComponent","campaignId","user","handleRemove","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","ref","plan","planContactDetach","action","Object","fromEntries","remove","map","path","removeTokens","capturedByHostIds","removeHostIds","updatedAt","Date","type","name","variant","persist","push","section","error","console","allowDuplicate","overflowItems","key","label","icon","size","disabled","disabledReason","onClick","destructive","erase","subject","kind","requestedAtMs","readErasureRequestedAtMs","undefined","help","backHref","backLabel","spacing","subtitle","alternateEmails","join","actions","contactId","link","phone","suiteLocked","emailState","menuItems","booking","recordId","loading","chips","state","value","lifecycleStage","ownerUid","memberName","lastEmailEngagementAtMs","toLocaleString","span","activityTimeLabel","now","ordersCount","href","underline","ltvCents","toFixed","banner","taskLink","seed","contact","contactName","collection","facetGroupId","groupId","mediaIds","topic","current","onMerge","candidate","other","keep","onClose","close","onMerged","survivorId","replace","dialog","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-detail-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 * as Aglyn from '@aglyn/aglyn'\nimport { CONTACT_LIFECYCLE_STAGE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiDeleteOutline, mdiMerge } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport { restampCrmListFields } from '../model/list-fields-api'\nimport { crmTaskCallScope } from '../model/task-routes'\nimport { Stack, Tooltip, Typography } from '@mui/material'\nimport {\n arrayRemove,\n deleteDoc,\n deleteField,\n doc,\n updateDoc,\n} from 'firebase/firestore'\nimport { useParams, useRouter } from 'next/navigation'\nimport { useCallback, useMemo } from 'react'\nimport { useCrmActivityLogger } from '../hooks/use-crm-activity-logger'\nimport { CrmCreateSiteDefault, useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { contactPrimaryGroup, contactRecordFromDoc } from '../model/contact-record'\nimport { type CrmDetailPageProps, crmRoutes } from '../model/crm-routes'\nimport ContactAssociationsCard from './contact-associations-card'\nimport ContactDuplicatesCard from './contact-duplicates-card'\nimport ContactKnownByCard from './contact-known-by-card'\nimport ContactMergeDialog, { useContactMergeDialog } from './contact-merge-dialog'\nimport ContactPropertiesCard from './contact-properties-card'\nimport ContactTimelineCard from './contact-timeline-card'\nimport { AddToListButton } from './add-to-list-button'\nimport { useBookingDoor } from './crm-booking-zone'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport { ContactDealsCard } from './contact-deals-card'\nimport { CrmCallButton } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmRecordInsightsZone } from './crm-record-insights-zone'\nimport { CrmSuiteNotice, crmSuiteIncluded, crmSuiteLockedReason } from './crm-suite-lock'\nimport { useErasePersonAction } from './erase-person-action'\nimport RecordFilesCard from './record-files-card'\nimport { RecordTasksCard } from './record-tasks-card'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useOrgMembers } from './use-org-members'\n\nconst contactDocsHelp = pluginDocsHelp('contacts', {\n anchor: '#the-contacts-page',\n excerpt:\n \"One person's record: the profile your team keeps on them, where they \" +\n 'came from, what they are filed under, and what the site recorded.',\n})\n\n/**\n * `/crm/contacts/{contactId}` — one person (AGL-2596).\n *\n * A ROUTE rather than the v1 drawer, because a person is the thing every\n * other CRM record points at: a deal, a task and an activity all name a\n * contact, and a link that opens a drawer on top of a list is not a link\n * anybody can paste.\n *\n * ## What this page reads\n *\n * ONE document, by id, through the scoped rules — a single-document get is\n * judged on that document's own `visibleTo`, so no `array-contains-any`\n * filter is needed here the way it is on the list. The row is then flattened\n * through the viewing group's facet ONCE (`contactRecordFromDoc`) and the\n * cards below read the flat record: none of them reaches into\n * `facets.{groupId}` for itself, which is what keeps a card from ever\n * showing another holder's notes.\n *\n * Beyond the document: the team roster (one request, for the owner picker)\n * and the site's campaigns (one listen, for the filing picker) — both paid\n * here because opening a record IS the ask for them, and neither is paid by\n * the list.\n *\n * ## The cards\n *\n * Each in its own file, so a card can grow without this page growing:\n * properties (the editable profile, with the org's custom fields under it),\n * the relationship (sources, attribution, consent, campaign filing) and the\n * timeline. Other CRM sections add a card here by inserting one import and\n * one line.\n *\n * ## Delete is a detach\n *\n * The row is shared by every site that has captured this person, so the\n * action in the overflow menu drops THIS group's facet, consent, attribution\n * and scope tokens, and deletes the document only when nobody else is left\n * holding it — `planContactDetach` does the counting off `visibleTo`, which\n * is what both enforcement layers evaluate, and the rules refuse a delete by\n * a caller who is not the sole holder. Not the erasure path: a privacy\n * erasure removes the person regardless of who else holds them.\n */\nexport function ContactDetailPage(props: CrmDetailPageProps) {\n const { hostId, org, id, basePath } = props\n const routes = crmRoutes(basePath)\n const router = useRouter()\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The org root, the viewing group and what this viewer may list — the\n // merge search's and the duplicates query's one clause — from the one\n // scope hook (AGL-2614); at the organization level the group is `null`\n // and resolved per person below, and the listeners carry no clause\n // (AGL-2630).\n const {\n scope,\n orgId,\n consentGroup: viewingGroup,\n visibleTo,\n } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const merge = useContactMergeDialog()\n /*\n * THE CRM (AGL-2788). This page is the CRM's, and the shell mounts no CRM\n * page for a plan without it (AGL-2851). What locks below — owner, stage\n * and company; tasks, deals, files and custom fields; a logged call or\n * activity; one-to-one email; a merge — draws only for an org whose plan\n * lacks the CRM. The shell mounted this page once the org had settled, so\n * this is the plan, not a paint of the org still loading.\n */\n const suiteIncluded = crmSuiteIncluded(org)\n\n const {\n data: row,\n status,\n fromCache,\n } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'contacts', id) : null),\n [firestore, scope, id],\n { idField: '$id' },\n )\n /*\n * The controller this page is showing. Under a site: the sites declared\n * to be one sender, or this site alone — resolved from the org document\n * the shell already passed, so it costs no read. At the ORGANIZATION\n * level: the person's own PRIMARY holder, the first capturing site's\n * group, so an org-wide member reads the profile that site keeps and an\n * edit goes back to the same facet; the \"Known by\" card says which other\n * sites hold one.\n */\n const consentGroup = useMemo(\n () => viewingGroup ?? contactPrimaryGroup(row, org as Record<string, unknown>),\n [viewingGroup, row, org],\n )\n /**\n * The SITE the page acts as, for what needs one — the lead lookup, the\n * campaign filing, the site's activity feed: the mounted site, or the\n * primary holder's, or nothing for a row no site has captured.\n */\n const siteHostId = hostId ?? (consentGroup.hostId || null)\n /*\n * The feed the act is logged in, decided by the level it was performed\n * at (AGL-2738) and so given the MOUNTED site rather than `siteHostId`:\n * at the org hub a client-direct append to the holder's own site would\n * face a gate the record write never had to pass.\n */\n const logActivity = useCrmActivityLogger(hostId)\n const record = useMemo(\n () => (row ? contactRecordFromDoc(row, consentGroup) : null),\n [row, consentGroup],\n )\n const notFound = Boolean(scope) && status !== 'loading' && !row\n /** How the site the delete detaches from is named — see `handleRemove`. */\n const siteLabel = hostId\n ? 'this site'\n : siteHostId\n ? (mount?.siteName(siteHostId) ?? siteHostId)\n : 'its site'\n\n // The roster, for the owner picker and the owner's name — read because a\n // record page is the one place both are shown.\n const members = useOrgMembers(orgId, { enabled: Boolean(record) })\n /*\n * Where this person's ORDERS are read (AGL-2622): the site's orders list,\n * narrowed to their address. The count on the header is the number; the\n * list is the rows. Built from the route params already in the URL, so\n * no document is read to draw a link, and absent off a site — the\n * org-level mount has no orders list to point at.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const ordersHref =\n params?.orgSlug && params?.host && record?.email\n ? Aglyn.siteRecordLinks({\n orgSlug: String(params.orgSlug),\n host: String(params.host),\n }).ordersByCustomer(record.email)\n : null\n /*\n * Where this person's BOOKINGS are read (AGL-2660): the site's Bookings\n * page narrowed to their address, under the same match rule the booking\n * row's own \"View in CRM\" uses. Offered only where the site has a booking\n * door — a site that never enabled Bookings should not carry the row. At\n * the organization level the capturing site's page, addressed through\n * the subdomain the mount knows it by.\n */\n const bookingDoor = useBookingDoor(siteHostId, org)\n const siteSlug = params?.host\n ? String(params.host)\n : siteHostId && mount\n ? mount.siteSubdomain(siteHostId)\n : null\n const bookingsHref =\n bookingDoor.open && params?.orgSlug && siteSlug && record?.email\n ? Aglyn.siteRecordLinks({\n orgSlug: String(params.orgSlug),\n host: siteSlug,\n }).bookingsByBooker(record.email)\n : null\n\n /*\n * Where a campaign entry on the timeline links to: the email's own report\n * on this site's Emails hub (AGL-2616). Only a campaign THIS site sent can\n * be addressed — a sibling site in the same consent group has an Emails\n * hub of its own under a subdomain this page does not know — so the\n * builder answers `null` for those and the entry draws unlinked.\n */\n const emailsHub = useEmailsHubPath()\n const campaignHref = useCallback(\n (email: Aglyn.ContactCampaignEmail) =>\n emailsHub && email.hostId === hostId\n ? `${emailsHub}/messages/${encodeURIComponent(email.campaignId)}`\n : null,\n [emailsHub, hostId],\n )\n\n const { data: user } = useUser()\n const handleRemove = useCallback(async () => {\n if (!row || !scope) return\n const confirmed = await confirm({\n title: 'Delete this contact?',\n description:\n `\"${record?.email ?? id}\" is removed from ${siteLabel}'s ` +\n 'Contacts, along with its notes, tags and timeline. Other sites ' +\n 'that captured the same person keep their own records. Their form ' +\n 'submissions, orders, bookings, and membership records are ' +\n 'separate — delete those from their own pages if the request ' +\n 'covers them.',\n confirmationText: 'Delete contact',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const ref = doc(firestore, scope[0], scope[1], 'contacts', id)\n const plan = Aglyn.planContactDetach(row, consentGroup)\n if (plan.action === 'delete') {\n await deleteDoc(ref)\n } else {\n await updateDoc(ref, {\n ...Object.fromEntries(plan.remove.map((path) => [path, deleteField()])),\n visibleTo: arrayRemove(...plan.removeTokens),\n capturedByHostIds: arrayRemove(...plan.removeHostIds),\n updatedAt: new Date(),\n })\n // The facet and the scope just let go of are what the Contacts list\n // filters and searches by; the rules keep a client from restamping\n // them, so the route does (AGL-3321).\n await restampCrmListFields(user, crmTaskCallScope(hostId, orgId), 'contacts', [id])\n }\n // `siteLabel` and not \"this site\": at the organization level the line\n // lands in a feed that spans every site, so it has to name the one.\n logActivity(`Removed contact from ${siteLabel}`, {\n type: 'contact',\n id,\n name: record?.name || record?.email,\n })\n enqueueSnackbar(\n plan.action === 'delete'\n ? 'Contact deleted'\n : `Contact removed from ${siteLabel}`,\n { variant: 'success', persist: false },\n )\n router.push(routes.section('contacts'))\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n confirm,\n consentGroup,\n enqueueSnackbar,\n firestore,\n id,\n logActivity,\n record,\n router,\n routes,\n row,\n scope,\n siteLabel,\n user,\n hostId,\n orgId,\n ])\n\n const overflowItems: RowActionsMenuItem[] = [\n /*\n * Two records for one person become one (AGL-2625). Opened empty, to be\n * searched, with the OTHER record surviving: a reader on the record they\n * want gone says \"merge this into…\". The dialog lets them switch.\n */\n {\n key: 'merge',\n label: 'Merge into…',\n icon: <MdiIcon path={mdiMerge.path} size={0.8} />,\n disabled: !row || !suiteIncluded,\n disabledReason: suiteIncluded ? 'The contact has not loaded' : crmSuiteLockedReason(),\n onClick: () => merge.open(),\n },\n {\n key: 'remove',\n label: 'Delete contact',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: !row,\n disabledReason: 'The contact has not loaded',\n onClick: () => void handleRemove(),\n },\n ]\n // The privacy erasure (AGL-2623): its item joins the overflow, its banner\n // sits under the header while a request waits, its dialog renders once.\n const erase = useErasePersonAction({\n // Filed as the site the page acts as — at the organization level the\n // person's primary holder (AGL-2630).\n hostId: siteHostId,\n orgId,\n subject: record ? { kind: 'contact', id, email: record.email } : null,\n requestedAtMs: Aglyn.readErasureRequestedAtMs(row),\n })\n\n if (notFound) {\n return (\n <CrmRecordHeader\n kind=\"Contact\"\n title={undefined}\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n >\n {/*\n * Not \"no data\". A contact that cannot be read is a different\n * situation from one with an empty profile — it may have been\n * removed from this site, or be held only by a site this reader\n * cannot see.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This contact could not be loaded. It may have been removed from this site.'}\n </Typography>\n </CrmRecordHeader>\n )\n }\n\n return (\n // A create opened from this page — a task, an activity, a deal — files\n // under the site that captured this person unless the reader says\n // otherwise (AGL-2630).\n <CrmCreateSiteDefault hostId={siteHostId}>\n <Stack spacing={3}>\n <CrmRecordHeader\n kind=\"Contact\"\n title={record ? record.name || record.email : undefined}\n // The identity, and the other addresses a merge folded into it.\n subtitle={\n record\n ? [record.email, ...record.alternateEmails.map((email) => `also ${email}`)].join(' · ')\n : undefined\n }\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n actions={\n record ? (\n <>\n <AddToListButton hostId={hostId} org={org} contactId={id} email={record.email} />\n {/* Dial the number, and log the call it was (AGL-2661). */}\n <CrmCallButton\n hostId={siteHostId}\n org={org}\n link={{ contactId: id }}\n phone={record.phone}\n suiteLocked={!suiteIncluded}\n />\n <CrmSendEmailButton\n hostId={siteHostId}\n org={org}\n contactId={id}\n email={record.email}\n name={record.name}\n emailState={record.emailState}\n suiteLocked={!suiteIncluded}\n />\n </>\n ) : null\n }\n menuItems={[...overflowItems, ...erase.menuItems]}\n // The booking door (AGL-2660): this site's services, or at the\n // organization level the capturing site's.\n booking={record ? { hostId: siteHostId, org, kind: 'contact', recordId: id } : undefined}\n loading={!record}\n chips={\n record ? (\n <>\n {/* The verdict on the address (AGL-3245), first: a bounce or a\n do-not-contact mark is what a person deciding to write must see. */}\n <CrmEmailStateChip state={record.emailState} />\n <CrmRecordChip\n label=\"Stage\"\n value={\n record.lifecycleStage\n ? CONTACT_LIFECYCLE_STAGE_LABELS[record.lifecycleStage]\n : undefined\n }\n />\n <CrmRecordChip\n label=\"Owner\"\n value={record.ownerUid ? members.memberName(record.ownerUid) : undefined}\n />\n {/*\n The last time they opened or clicked one of this site's\n campaigns (AGL-2616) — the relationship's pulse, beside the\n owner who keeps it.\n */}\n <CrmRecordChip\n label=\"Last engaged\"\n value={\n record.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(record.lastEmailEngagementAtMs).toLocaleString()}>\n <span>\n {Aglyn.activityTimeLabel(record.lastEmailEngagementAtMs, Date.now())}\n </span>\n </Tooltip>\n ) : undefined\n }\n />\n <CrmRecordChip\n label=\"Orders\"\n value={\n record.ordersCount > 0 ? (\n ordersHref ? (\n <AppLink href={ordersHref} color=\"inherit\" underline=\"hover\">\n {`${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`}\n </AppLink>\n ) : (\n `${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`\n )\n ) : undefined\n }\n />\n </>\n ) : null\n }\n >\n {erase.banner}\n </CrmRecordHeader>\n {record && row && scope ? (\n <>\n {suiteIncluded ? null : (\n <CrmSuiteNotice>\n {'Working this contact — the owner, stage and company, tasks, ' +\n 'deals, files and custom fields, logging a call or an activity, ' +\n 'one-to-one email and merging duplicates — is part of the CRM.'}\n </CrmSuiteNotice>\n )}\n {/*\n The cross-site fact, at the organization level only (AGL-2630):\n which sites know this person, and their consent for each. Under\n a site the page IS one site's view and the card would say so on\n every record.\n */}\n {/* What an assistant proposes about the person (AGL-2917), where the plan works a contact. */}\n {suiteIncluded ? (\n <CrmRecordInsightsZone\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n kind=\"contact\"\n recordId={id}\n name={record.name || record.email}\n taskLink={{ contactId: id }}\n />\n ) : null}\n {mount ? <ContactKnownByCard row={row} contactId={id} org={org} /> : null}\n <ContactPropertiesCard\n hostId={hostId}\n org={org}\n record={record}\n orgId={orgId}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n members={members}\n suiteLocked={!suiteIncluded}\n />\n <ContactAssociationsCard\n hostId={siteHostId}\n record={record}\n row={row}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n basePath={basePath}\n bookingsHref={bookingsHref}\n />\n <ContactTimelineCard\n hostId={hostId}\n org={org}\n contactId={id}\n contact={row}\n campaignHref={campaignHref}\n suiteLocked={!suiteIncluded}\n />\n {/*\n On a plan without the CRM, the cards that only work a record —\n deals, tasks, files — are named in the notice above rather than\n drawn as upsells in a row.\n */}\n {suiteIncluded ? (\n <>\n <ContactDealsCard\n hostId={hostId}\n org={org}\n basePath={basePath}\n contactId={id}\n contactName={record.name || record.email}\n />\n <RecordTasksCard hostId={hostId} org={org} basePath={basePath} contactId={id} />\n {/*\n The files are the VIEWING holder's, like everything else on this\n page: the write is a facet path, so a contract one brand filed\n never appears on a sibling brand's copy of the person.\n */}\n <RecordFilesCard\n scope={scope}\n collection=\"contacts\"\n recordId={id}\n hostId={hostId}\n facetGroupId={consentGroup.groupId}\n mediaIds={record.mediaIds}\n topic=\"contactRecord\"\n />\n </>\n ) : null}\n <ContactDuplicatesCard\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n basePath={basePath}\n onMerge={(candidate) => merge.open({ other: candidate, keep: 'current' })}\n suiteLocked={!suiteIncluded}\n />\n <ContactMergeDialog\n {...merge.state}\n onClose={merge.close}\n hostId={siteHostId}\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n memberName={members.memberName}\n // The page's own record was folded away: its address now opens\n // the survivor, which is where the reader is taken.\n onMerged={(survivorId) => {\n if (survivorId !== id) router.replace(routes.contact(survivorId))\n }}\n />\n </>\n ) : null}\n {erase.dialog}\n </Stack>\n </CrmCreateSiteDefault>\n )\n}\nContactDetailPage.displayName = 'ContactDetailPage'\n\nexport default ContactDetailPage\n"],"names":["Aglyn","CONTACT_LIFECYCLE_STAGE_LABELS","pluginDocsHelp","mdiDeleteOutline","mdiMerge","AppLink","MdiIcon","useConfirmationContext","useSnackbar","useFirestore","useFirestoreDoc","useUser","restampCrmListFields","crmTaskCallScope","Stack","Tooltip","Typography","arrayRemove","deleteDoc","deleteField","doc","updateDoc","useParams","useRouter","useCallback","useMemo","useCrmActivityLogger","CrmCreateSiteDefault","useCrmOrgMount","useCrmScope","contactPrimaryGroup","contactRecordFromDoc","crmRoutes","ContactAssociationsCard","ContactDuplicatesCard","ContactKnownByCard","ContactMergeDialog","useContactMergeDialog","ContactPropertiesCard","ContactTimelineCard","AddToListButton","useBookingDoor","CrmSendEmailButton","ContactDealsCard","CrmCallButton","CrmEmailStateChip","CrmRecordChip","CrmRecordHeader","CrmRecordInsightsZone","CrmSuiteNotice","crmSuiteIncluded","crmSuiteLockedReason","useErasePersonAction","RecordFilesCard","RecordTasksCard","useEmailsHubPath","useOrgMembers","contactDocsHelp","anchor","excerpt","ContactDetailPage","props","hostId","org","id","basePath","routes","router","firestore","enqueueSnackbar","confirm","scope","orgId","consentGroup","viewingGroup","visibleTo","mount","merge","suiteIncluded","data","row","status","fromCache","idField","siteHostId","logActivity","record","notFound","Boolean","siteLabel","siteName","members","enabled","params","ordersHref","orgSlug","host","email","siteRecordLinks","String","ordersByCustomer","bookingDoor","siteSlug","siteSubdomain","bookingsHref","open","bookingsByBooker","emailsHub","campaignHref","encodeURIComponent","campaignId","user","handleRemove","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","ref","plan","planContactDetach","action","Object","fromEntries","remove","map","path","removeTokens","capturedByHostIds","removeHostIds","updatedAt","Date","type","name","variant","persist","push","section","error","console","allowDuplicate","overflowItems","key","label","icon","size","disabled","disabledReason","onClick","destructive","erase","subject","kind","requestedAtMs","readErasureRequestedAtMs","undefined","help","backHref","backLabel","spacing","subtitle","alternateEmails","join","actions","contactId","link","phone","suiteLocked","emailState","menuItems","booking","recordId","loading","chips","state","value","lifecycleStage","ownerUid","memberName","lastEmailEngagementAtMs","toLocaleString","span","activityTimeLabel","now","ordersCount","href","underline","ltvCents","toFixed","banner","taskLink","seed","contact","contactName","collection","facetGroupId","groupId","mediaIds","topic","current","onMerge","candidate","other","keep","onClose","close","onMerged","survivorId","replace","dialog","displayName"],"mappings":"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"}
@@ -10,8 +10,9 @@ export interface ContactPropertiesCardProps {
10
10
  record: ContactRecord;
11
11
  /**
12
12
  * The org the record belongs to, as the page resolved it — for the org's
13
- * lead source values (AGL-3298). Null while it settles, when the select
14
- * offers the starter list and the save still sends only what changed.
13
+ * lead source values (AGL-3298) and its custom contact fields (AGL-2601).
14
+ * Null while it settles, when the select offers the starter list, no
15
+ * custom field is drawn yet, and the save still sends only what changed.
15
16
  */
16
17
  orgId?: string | null;
17
18
  /** The controller whose facet the edits are saved into. */
@@ -30,13 +31,29 @@ export interface ContactPropertiesCardProps {
30
31
  * The org's plan lacks the CRM (AGL-2788), which the shell mounts no CRM
31
32
  * page for (AGL-2851). The owner, the lifecycle stage and the company
32
33
  * show, locked, and are left out of a save; `crm/contact-update` refuses
33
- * such a plan the rest of the profile, the tags and the notes too.
34
+ * such a plan the rest of the profile, the tags and the notes too. The
35
+ * custom fields are the CRM's as well, and are not drawn.
34
36
  */
35
37
  suiteLocked?: boolean;
36
38
  }
37
39
  /**
38
40
  * THE RECORD ITSELF: every field a team keeps on a person, in one card with
39
- * one Save (AGL-2596).
41
+ * one Save in its header (AGL-2596).
42
+ *
43
+ * ## Custom fields sit with the built-in ones
44
+ *
45
+ * The org's own contact fields (AGL-2601) are drawn under **More fields**,
46
+ * after the built-in properties, the way Salesforce keeps custom fields in
47
+ * the record's Details beside the standard ones — and they save with them.
48
+ * An org with no active field draws no subsection at all.
49
+ *
50
+ * A custom value is THIS holder's (`facets.{group}.custom.{key}`), so the
51
+ * draft holds only the keys the reader touched and the save sends only the
52
+ * keys that changed: the route writes each at its own dotted path, and a
53
+ * `custom` map sent whole would take every key this card did not touch out
54
+ * with it. A cleared control sends `null`, the explicit "cleared" the model
55
+ * keeps the key present with, and a required field left empty is refused
56
+ * before anything is sent.
40
57
  *
41
58
  * ## One save, one request, one guard
42
59
  *