@aglyn/plugins-crm 1.0.0-beta.284 → 1.0.0-beta.287

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 (30) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/contact-associations-card.js +5 -7
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/contact-list-columns.js +5 -7
  5. package/src/lib/components/contact-list-columns.js.map +1 -1
  6. package/src/lib/components/contact-timeline-card.js +4 -3
  7. package/src/lib/components/contact-timeline-card.js.map +1 -1
  8. package/src/lib/components/erase-person-action.d.ts +6 -4
  9. package/src/lib/components/erase-person-action.js +10 -8
  10. package/src/lib/components/erase-person-action.js.map +1 -1
  11. package/src/lib/components/reports/contacts-mix-card.js +4 -6
  12. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  13. package/src/lib/components/reports/source-conversion-card.js +2 -4
  14. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  15. package/src/lib/components/use-can-erase-people.d.ts +32 -0
  16. package/src/lib/components/use-can-erase-people.js +56 -0
  17. package/src/lib/components/use-can-erase-people.js.map +1 -0
  18. package/src/lib/model/record-facts-disclosed.js +4 -6
  19. package/src/lib/model/record-facts-disclosed.js.map +1 -1
  20. package/src/lib/model/record-facts.js +3 -5
  21. package/src/lib/model/record-facts.js.map +1 -1
  22. package/src/lib/server/capture-contact.d.ts +5 -5
  23. package/src/lib/server/capture-contact.js +7 -6
  24. package/src/lib/server/capture-contact.js.map +1 -1
  25. package/src/lib/server/erase-person.d.ts +10 -7
  26. package/src/lib/server/erase-person.js +18 -13
  27. package/src/lib/server/erase-person.js.map +1 -1
  28. package/src/lib/server/org-caller.d.ts +16 -4
  29. package/src/lib/server/org-caller.js +20 -4
  30. package/src/lib/server/org-caller.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.284",
3
+ "version": "1.0.0-beta.287",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.284",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.284",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.284",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.284",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.284",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.284",
34
- "@aglyn/shared-util-email": "1.0.0-beta.284",
35
- "@aglyn/shared-util-http": "1.0.0-beta.284",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.284",
37
- "@aglyn/shared-util-tools": "1.0.0-beta.284",
38
- "@aglyn/tenant-data-admin": "1.0.0-beta.284",
39
- "@aglyn/tenant-feature-instance": "1.0.0-beta.284",
40
- "@aglyn/tenant-runtime": "1.0.0-beta.284",
28
+ "@aglyn/aglyn": "1.0.0-beta.287",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.287",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.287",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.287",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.287",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.287",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.287",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.287",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.287",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.287",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.287",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.287",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.287",
41
41
  "@dnd-kit/core": "^6.3.1",
42
42
  "@dnd-kit/utilities": "^3.2.2",
43
43
  "@swc/helpers": "0.5.23",
@@ -17,7 +17,8 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
- import { CONTACT_SOURCE_LABELS, MARKETING_BASIS_LABELS, readMarketingBasis } from "@aglyn/aglyn";
20
+ import { MARKETING_BASIS_LABELS, readMarketingBasis } from "@aglyn/aglyn";
21
+ import { labelContactSource } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
21
22
  import { personKeyInBrowser } from "../model/person-key-web.js";
22
23
  /*
23
24
  * The component path and NOT the marketing barrel: that barrel is the entry
@@ -210,13 +211,10 @@ import { useCrmCampaigns } from "../hooks/use-crm-campaigns.js";
210
211
  rowGap: 0.5
211
212
  },
212
213
  children: [
213
- sources.map((source)=>{
214
- var _CONTACT_SOURCE_LABELS_source;
215
- return /*#__PURE__*/ _jsx(Chip, {
216
- label: (_CONTACT_SOURCE_LABELS_source = CONTACT_SOURCE_LABELS[source]) != null ? _CONTACT_SOURCE_LABELS_source : source,
214
+ sources.map((source)=>/*#__PURE__*/ _jsx(Chip, {
215
+ label: labelContactSource(source),
217
216
  size: "small"
218
- }, source);
219
- }),
217
+ }, source)),
220
218
  leadKey ? /*#__PURE__*/ _jsx(Button, _extends({
221
219
  component: AppLink
222
220
  }, {
@@ -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 ContainerPicker from '@aglyn/tenant-feature-instance/components/container-picker'\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.containerMembershipValue(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 useFlexGap 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 <ContainerPicker\n kind=\"campaign\"\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","ContainerPicker","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","containerMembershipValue","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","useFlexGap","direction","sx","flexWrap","rowGap","map","source","label","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","kind","value","onChange","helperText","empty","emptyText","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 type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { labelContactSource } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\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 ContainerPicker from '@aglyn/tenant-feature-instance/components/container-picker'\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.containerMembershipValue(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 useFlexGap direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={labelContactSource(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 <ContainerPicker\n kind=\"campaign\"\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","MARKETING_BASIS_LABELS","readMarketingBasis","labelContactSource","personKeyInBrowser","CrmRecordAttributionZone","ContainerPicker","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","containerMembershipValue","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","useFlexGap","direction","sx","flexWrap","rowGap","map","source","label","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","kind","value","onChange","helperText","empty","emptyText","displayName"],"mappings":"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"}
@@ -16,7 +16,8 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
- import { activityTimeLabel, CONTACT_LIFECYCLE_STAGE_LABELS, CONTACT_SOURCE_LABELS, EMAIL_STATE_LABELS } from "@aglyn/aglyn";
19
+ import { activityTimeLabel, CONTACT_LIFECYCLE_STAGE_LABELS, EMAIL_STATE_LABELS } from "@aglyn/aglyn";
20
+ import { labelContactSource } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
20
21
  import { hiddenFilterColumns, listFilterColumn } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
21
22
  import { Chip, Stack, Tooltip, Typography } from "@mui/material";
22
23
  import { CONTACT_LIST_FILTER_FIELDS, CONTACT_LIST_FILTER_HEADERS } from "../constants/contact-filters.js";
@@ -217,13 +218,10 @@ import { nextActivityColumn } from "./crm-next-activity-column.js";
217
218
  alignItems: 'center',
218
219
  height: '100%'
219
220
  },
220
- children: Object.keys((_row_sources = row.sources) != null ? _row_sources : {}).map((source)=>{
221
- var _CONTACT_SOURCE_LABELS_source;
222
- return /*#__PURE__*/ _jsx(Chip, {
223
- label: (_CONTACT_SOURCE_LABELS_source = CONTACT_SOURCE_LABELS[source]) != null ? _CONTACT_SOURCE_LABELS_source : source,
221
+ children: Object.keys((_row_sources = row.sources) != null ? _row_sources : {}).map((source)=>/*#__PURE__*/ _jsx(Chip, {
222
+ label: labelContactSource(source),
224
223
  size: "small"
225
- }, source);
226
- })
224
+ }, source))
227
225
  });
228
226
  }
229
227
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-list-columns.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 activityTimeLabel,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_SOURCE_LABELS,\n EMAIL_STATE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn'\nimport {\n hiddenFilterColumns,\n listFilterColumn,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_LIST_FILTER_HEADERS,\n} from '../constants/contact-filters'\nimport type { ContactRecord } from '../model/contact-record'\nimport type { CrmShareChip } from '../model/crm-sharing'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { nextActivityColumn } from './crm-next-activity-column'\n\n/**\n * The filterable fields that get a column. The rest of\n * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.\n */\n/**\n * The declared filter fields that ARE table columns, so the hidden\n * filter-only columns are built for the others alone. Owner and Stage\n * joined it when the grammar learned them (AGL-2617): each is a shown\n * column already, and a second, hidden one under the same field would be\n * a duplicate the grid refuses.\n */\nexport const CONTACT_FILTER_COLUMNS = [\n 'name',\n 'ownerUid',\n 'lifecycleStage',\n 'sources',\n 'tags',\n 'updatedAt',\n 'nextTaskAtMs',\n 'emailState',\n]\n\n/**\n * The columns that ship HIDDEN, for the grid's visibility model (AGL-2616).\n *\n * \"Last engaged\" is a fact most lists do not need on screen and every list\n * can turn on from the column menu; shipping it visible would widen a\n * table that already carries seven columns for a figure only a marketing\n * reader scans.\n */\nexport const CONTACT_OPTIONAL_COLUMNS = ['lastEmailEngagementAtMs']\n\nexport interface ContactListColumnOptions {\n /** The owner's name for a uid — the roster's, or the uid itself. */\n memberName: (uid: string) => string\n /**\n * How a site reads, by document id — handed in at the ORGANIZATION level\n * only (AGL-2630), where it turns on the \"Known by\" column: the sites\n * that have captured each person. Absent under a site, where every row\n * is known by the site and the column would say so on every line.\n */\n siteName?: (hostId: string) => string\n /** The words of a row's \"Shared by…\" chip (AGL-3336); absent draws none. */\n shareChipLabel?: (chip: CrmShareChip) => string\n /**\n * One clock for every row of one paint, so two contacts engaged a second\n * apart cannot read \"just now\" and \"1 min ago\". Defaults to the paint's\n * own `Date.now()`.\n */\n nowMs?: number\n}\n\n/**\n * The contacts list's columns (AGL-2596), in their own module so the list\n * file stays about the QUERY and the toolbar while other surfaces — a bulk\n * bar, an import — grow beside it.\n *\n * Owner and Stage read off the flattened row, which is already this\n * holder's facet (`contactRecordFromDoc`), so neither column can show\n * another holder's assignment. Filtering by either is the list's: the panel\n * names the field and the list asks the holder's facet key of its query\n * (`contactQueryClause`, AGL-3321).\n */\nexport function contactListColumns(\n options: ContactListColumnOptions,\n): GridColDef[] {\n const { memberName, siteName, shareChipLabel } = options\n const nowMs = options.nowMs ?? Date.now()\n return [\n {\n field: 'name',\n headerName: 'Contact',\n flex: 1.6,\n minWidth: 220,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'),\n valueGetter: (_value, row: ContactRecord) =>\n String(row.name || row.email || ''),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack sx={{ justifyContent: 'center', height: '100%', lineHeight: 1.25 }}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Typography variant=\"body2\" sx={{ lineHeight: 1.25 }}>\n {row.name || row.email}\n </Typography>\n {/* Seen on this site only through a share (AGL-3336). */}\n {row.shareChip && shareChipLabel ? (\n <Chip size=\"small\" variant=\"outlined\" color=\"info\" label={shareChipLabel(row.shareChip)} />\n ) : null}\n </Stack>\n {row.name ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ lineHeight: 1.25 }}\n noWrap\n >\n {row.email}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 0.9,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.ownerUid ? memberName(row.ownerUid) : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.ownerUid ? (\n <Typography variant=\"body2\" noWrap>\n {memberName(row.ownerUid)}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n field: 'lifecycleStage',\n headerName: 'Stage',\n flex: 0.9,\n minWidth: 150,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lifecycleStage ? CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lifecycleStage ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage]}\n />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n /*\n * KNOWN BY (AGL-2630): the sites that have captured this person, which\n * is the cross-site fact the organization-level list exists to show —\n * one deduplicated row per person, and which of the org's sites know\n * them. `capturedByHostIds` is a top-level array precisely so the\n * question can be answered without opening a facet. Not filterable from\n * the panel: an `array-contains` on it would displace the scope clause\n * under a site, and a search or a facet filter at the org level (one\n * array clause per query, AGL-3321). A row that names no site predates attribution and says so,\n * rather than reading as \"every site\".\n */\n ...(siteName\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Known by',\n flex: 1.1,\n minWidth: 200,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: ContactRecord) =>\n row.capturedByHostIds.map(siteName).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.capturedByHostIds.length ? (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%', flexWrap: 'nowrap' }}\n >\n {row.capturedByHostIds.slice(0, 2).map((hostId) => (\n <Chip key={hostId} size=\"small\" label={siteName(hostId)} />\n ))}\n {row.capturedByHostIds.length > 2 ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`+${row.capturedByHostIds.length - 2}`}\n />\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n ),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'sources',\n headerName: 'Sources',\n flex: 1,\n minWidth: 160,\n // A map of provenance flags, not a scalar — `sources.form == true` is\n // queryable one key at a time, which is a menu of its own rather than\n // a filter on this column.\n filterable: false,\n sortable: false,\n valueGetter: (_value, row: ContactRecord) =>\n Object.keys(row.sources ?? {}).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n {Object.keys(row.sources ?? {}).map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source as ContactSource] ?? source}\n size=\"small\"\n />\n ))}\n </Stack>\n ),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 1,\n minWidth: 150,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'tags'),\n sortable: false,\n valueGetter: (_value, row: ContactRecord) => (row.tags ?? []).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n (row.tags ?? []).slice(0, 3).join(', '),\n },\n {\n field: 'updatedAt',\n headerName: 'Last activity',\n flex: 0.8,\n minWidth: 150,\n // `type: 'date'` is what gives the panel a date PICKER rather than a\n // free-text box for a value the query reads as a day.\n type: 'date',\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'updatedAt'),\n valueGetter: (_value, row: ContactRecord) => {\n const seconds = (row.updatedAt as { seconds?: number } | undefined)?.seconds\n return seconds ? new Date(seconds * 1000) : null\n },\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.interactions?.[0]\n ? new Date(row.interactions[0].atMs).toLocaleDateString()\n : '—'}\n </Typography>\n ),\n },\n // When the earliest open task against the person is due (AGL-2661).\n nextActivityColumn(nowMs),\n {\n /*\n * When the person last opened or clicked one of this holder's\n * campaigns (AGL-2616). Off the facet like Owner and Stage, so it can\n * never show another holder's readers; `filterable: false` for the\n * same reason theirs are — a facet path is not a query the panel can\n * make. A date column, so a sort on it orders by the instant and an\n * export gets a date; the cell prints the relative form the timeline\n * uses and carries the full stamp in its tooltip.\n */\n field: 'lastEmailEngagementAtMs',\n headerName: 'Last engaged',\n flex: 0.8,\n minWidth: 140,\n type: 'date',\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lastEmailEngagementAtMs ? new Date(row.lastEmailEngagementAtMs) : null,\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(row.lastEmailEngagementAtMs).toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {activityTimeLabel(row.lastEmailEngagementAtMs, nowMs)}\n </Typography>\n </Tooltip>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the same chip the record page\n * carries, so a bounced or blocked person is told apart in the list a\n * member picks recipients from. Sorted and exported by its label;\n * filtered through the bar's `emailState` clause, not the panel.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.8,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.emailState ? EMAIL_STATE_LABELS[row.emailState.status] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.emailState ? (\n <CrmEmailStateChip state={row.emailState} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n ...hiddenFilterColumns(\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_FILTER_COLUMNS,\n CONTACT_LIST_FILTER_HEADERS,\n ),\n ]\n}\n"],"names":["activityTimeLabel","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_SOURCE_LABELS","EMAIL_STATE_LABELS","hiddenFilterColumns","listFilterColumn","Chip","Stack","Tooltip","Typography","CONTACT_LIST_FILTER_FIELDS","CONTACT_LIST_FILTER_HEADERS","CrmEmailStateChip","nextActivityColumn","CONTACT_FILTER_COLUMNS","CONTACT_OPTIONAL_COLUMNS","contactListColumns","options","memberName","siteName","shareChipLabel","nowMs","Date","now","field","headerName","flex","minWidth","valueGetter","_value","row","String","name","email","renderCell","sx","justifyContent","height","lineHeight","direction","spacing","alignItems","variant","shareChip","size","color","label","noWrap","filterable","ownerUid","lifecycleStage","sortable","capturedByHostIds","map","join","length","flexWrap","slice","hostId","Object","keys","sources","source","tags","type","seconds","updatedAt","interactions","atMs","toLocaleDateString","lastEmailEngagementAtMs","title","toLocaleString","emailState","status","state"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-list-columns.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 activityTimeLabel,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n EMAIL_STATE_LABELS,\n} from '@aglyn/aglyn'\nimport { labelContactSource } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n hiddenFilterColumns,\n listFilterColumn,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_LIST_FILTER_HEADERS,\n} from '../constants/contact-filters'\nimport type { ContactRecord } from '../model/contact-record'\nimport type { CrmShareChip } from '../model/crm-sharing'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { nextActivityColumn } from './crm-next-activity-column'\n\n/**\n * The filterable fields that get a column. The rest of\n * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.\n */\n/**\n * The declared filter fields that ARE table columns, so the hidden\n * filter-only columns are built for the others alone. Owner and Stage\n * joined it when the grammar learned them (AGL-2617): each is a shown\n * column already, and a second, hidden one under the same field would be\n * a duplicate the grid refuses.\n */\nexport const CONTACT_FILTER_COLUMNS = [\n 'name',\n 'ownerUid',\n 'lifecycleStage',\n 'sources',\n 'tags',\n 'updatedAt',\n 'nextTaskAtMs',\n 'emailState',\n]\n\n/**\n * The columns that ship HIDDEN, for the grid's visibility model (AGL-2616).\n *\n * \"Last engaged\" is a fact most lists do not need on screen and every list\n * can turn on from the column menu; shipping it visible would widen a\n * table that already carries seven columns for a figure only a marketing\n * reader scans.\n */\nexport const CONTACT_OPTIONAL_COLUMNS = ['lastEmailEngagementAtMs']\n\nexport interface ContactListColumnOptions {\n /** The owner's name for a uid — the roster's, or the uid itself. */\n memberName: (uid: string) => string\n /**\n * How a site reads, by document id — handed in at the ORGANIZATION level\n * only (AGL-2630), where it turns on the \"Known by\" column: the sites\n * that have captured each person. Absent under a site, where every row\n * is known by the site and the column would say so on every line.\n */\n siteName?: (hostId: string) => string\n /** The words of a row's \"Shared by…\" chip (AGL-3336); absent draws none. */\n shareChipLabel?: (chip: CrmShareChip) => string\n /**\n * One clock for every row of one paint, so two contacts engaged a second\n * apart cannot read \"just now\" and \"1 min ago\". Defaults to the paint's\n * own `Date.now()`.\n */\n nowMs?: number\n}\n\n/**\n * The contacts list's columns (AGL-2596), in their own module so the list\n * file stays about the QUERY and the toolbar while other surfaces — a bulk\n * bar, an import — grow beside it.\n *\n * Owner and Stage read off the flattened row, which is already this\n * holder's facet (`contactRecordFromDoc`), so neither column can show\n * another holder's assignment. Filtering by either is the list's: the panel\n * names the field and the list asks the holder's facet key of its query\n * (`contactQueryClause`, AGL-3321).\n */\nexport function contactListColumns(\n options: ContactListColumnOptions,\n): GridColDef[] {\n const { memberName, siteName, shareChipLabel } = options\n const nowMs = options.nowMs ?? Date.now()\n return [\n {\n field: 'name',\n headerName: 'Contact',\n flex: 1.6,\n minWidth: 220,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'),\n valueGetter: (_value, row: ContactRecord) =>\n String(row.name || row.email || ''),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack sx={{ justifyContent: 'center', height: '100%', lineHeight: 1.25 }}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Typography variant=\"body2\" sx={{ lineHeight: 1.25 }}>\n {row.name || row.email}\n </Typography>\n {/* Seen on this site only through a share (AGL-3336). */}\n {row.shareChip && shareChipLabel ? (\n <Chip size=\"small\" variant=\"outlined\" color=\"info\" label={shareChipLabel(row.shareChip)} />\n ) : null}\n </Stack>\n {row.name ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ lineHeight: 1.25 }}\n noWrap\n >\n {row.email}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 0.9,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.ownerUid ? memberName(row.ownerUid) : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.ownerUid ? (\n <Typography variant=\"body2\" noWrap>\n {memberName(row.ownerUid)}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n field: 'lifecycleStage',\n headerName: 'Stage',\n flex: 0.9,\n minWidth: 150,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lifecycleStage ? CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lifecycleStage ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage]}\n />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n /*\n * KNOWN BY (AGL-2630): the sites that have captured this person, which\n * is the cross-site fact the organization-level list exists to show —\n * one deduplicated row per person, and which of the org's sites know\n * them. `capturedByHostIds` is a top-level array precisely so the\n * question can be answered without opening a facet. Not filterable from\n * the panel: an `array-contains` on it would displace the scope clause\n * under a site, and a search or a facet filter at the org level (one\n * array clause per query, AGL-3321). A row that names no site predates attribution and says so,\n * rather than reading as \"every site\".\n */\n ...(siteName\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Known by',\n flex: 1.1,\n minWidth: 200,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: ContactRecord) =>\n row.capturedByHostIds.map(siteName).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.capturedByHostIds.length ? (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%', flexWrap: 'nowrap' }}\n >\n {row.capturedByHostIds.slice(0, 2).map((hostId) => (\n <Chip key={hostId} size=\"small\" label={siteName(hostId)} />\n ))}\n {row.capturedByHostIds.length > 2 ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`+${row.capturedByHostIds.length - 2}`}\n />\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n ),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'sources',\n headerName: 'Sources',\n flex: 1,\n minWidth: 160,\n // A map of provenance flags, not a scalar — `sources.form == true` is\n // queryable one key at a time, which is a menu of its own rather than\n // a filter on this column.\n filterable: false,\n sortable: false,\n valueGetter: (_value, row: ContactRecord) =>\n Object.keys(row.sources ?? {}).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n {Object.keys(row.sources ?? {}).map((source) => (\n <Chip\n key={source}\n label={labelContactSource(source)}\n size=\"small\"\n />\n ))}\n </Stack>\n ),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 1,\n minWidth: 150,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'tags'),\n sortable: false,\n valueGetter: (_value, row: ContactRecord) => (row.tags ?? []).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n (row.tags ?? []).slice(0, 3).join(', '),\n },\n {\n field: 'updatedAt',\n headerName: 'Last activity',\n flex: 0.8,\n minWidth: 150,\n // `type: 'date'` is what gives the panel a date PICKER rather than a\n // free-text box for a value the query reads as a day.\n type: 'date',\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'updatedAt'),\n valueGetter: (_value, row: ContactRecord) => {\n const seconds = (row.updatedAt as { seconds?: number } | undefined)?.seconds\n return seconds ? new Date(seconds * 1000) : null\n },\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.interactions?.[0]\n ? new Date(row.interactions[0].atMs).toLocaleDateString()\n : '—'}\n </Typography>\n ),\n },\n // When the earliest open task against the person is due (AGL-2661).\n nextActivityColumn(nowMs),\n {\n /*\n * When the person last opened or clicked one of this holder's\n * campaigns (AGL-2616). Off the facet like Owner and Stage, so it can\n * never show another holder's readers; `filterable: false` for the\n * same reason theirs are — a facet path is not a query the panel can\n * make. A date column, so a sort on it orders by the instant and an\n * export gets a date; the cell prints the relative form the timeline\n * uses and carries the full stamp in its tooltip.\n */\n field: 'lastEmailEngagementAtMs',\n headerName: 'Last engaged',\n flex: 0.8,\n minWidth: 140,\n type: 'date',\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lastEmailEngagementAtMs ? new Date(row.lastEmailEngagementAtMs) : null,\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(row.lastEmailEngagementAtMs).toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {activityTimeLabel(row.lastEmailEngagementAtMs, nowMs)}\n </Typography>\n </Tooltip>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the same chip the record page\n * carries, so a bounced or blocked person is told apart in the list a\n * member picks recipients from. Sorted and exported by its label;\n * filtered through the bar's `emailState` clause, not the panel.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.8,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.emailState ? EMAIL_STATE_LABELS[row.emailState.status] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.emailState ? (\n <CrmEmailStateChip state={row.emailState} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n ...hiddenFilterColumns(\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_FILTER_COLUMNS,\n CONTACT_LIST_FILTER_HEADERS,\n ),\n ]\n}\n"],"names":["activityTimeLabel","CONTACT_LIFECYCLE_STAGE_LABELS","EMAIL_STATE_LABELS","labelContactSource","hiddenFilterColumns","listFilterColumn","Chip","Stack","Tooltip","Typography","CONTACT_LIST_FILTER_FIELDS","CONTACT_LIST_FILTER_HEADERS","CrmEmailStateChip","nextActivityColumn","CONTACT_FILTER_COLUMNS","CONTACT_OPTIONAL_COLUMNS","contactListColumns","options","memberName","siteName","shareChipLabel","nowMs","Date","now","field","headerName","flex","minWidth","valueGetter","_value","row","String","name","email","renderCell","sx","justifyContent","height","lineHeight","direction","spacing","alignItems","variant","shareChip","size","color","label","noWrap","filterable","ownerUid","lifecycleStage","sortable","capturedByHostIds","map","join","length","flexWrap","slice","hostId","Object","keys","sources","source","tags","type","seconds","updatedAt","interactions","atMs","toLocaleDateString","lastEmailEngagementAtMs","title","toLocaleString","emailState","status","state"],"mappings":"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"}
@@ -17,7 +17,8 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
- import { CONTACT_SOURCE_LABELS, pluginDocsHelp } from "@aglyn/aglyn";
20
+ import { pluginDocsHelp } from "@aglyn/aglyn";
21
+ import { labelContactSource } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
21
22
  import { mdiAccountCircleOutline, mdiAccountEditOutline, mdiAccountPlusOutline, mdiApi, mdiBullhornOutline, mdiCalendarCheckOutline, mdiCartOutline, mdiEmailNewsletter, mdiFileImportOutline, mdiFormSelect } from "@aglyn/shared-data-mdi";
22
23
  import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
23
24
  import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.component";
@@ -59,10 +60,10 @@ import { useContactCampaignEmails } from "./use-contact-campaign-emails.js";
59
60
  *
60
61
  * Everything it holds fits the collapsed row, so it has nothing to open.
61
62
  */ function CapturedRow(props) {
62
- var _CONTACT_SOURCE_LABELS_source, _interaction_summary, _INTERACTION_LINK_LABELS_source;
63
+ var _interaction_summary, _INTERACTION_LINK_LABELS_source;
63
64
  const { interaction, nowMs, href } = props;
64
65
  const source = interaction.type;
65
- const label = (_CONTACT_SOURCE_LABELS_source = CONTACT_SOURCE_LABELS[source]) != null ? _CONTACT_SOURCE_LABELS_source : String(source);
66
+ const label = labelContactSource(source) || String(source);
66
67
  const icon = SOURCE_ICONS[source];
67
68
  const when = new Date(interaction.atMs);
68
69
  return /*#__PURE__*/ _jsx(TimelineEntry, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-timeline-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 type {\n ContactCampaignEmail,\n ContactInteraction,\n ContactSource,\n CrmActivityRow,\n} from '@aglyn/aglyn'\nimport { CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiAccountCircleOutline,\n mdiAccountEditOutline,\n mdiAccountPlusOutline,\n mdiApi,\n mdiBullhornOutline,\n mdiCalendarCheckOutline,\n mdiCartOutline,\n mdiEmailNewsletter,\n mdiFileImportOutline,\n mdiFormSelect,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Button, Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport { useParams } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport { contactInteractionHref, INTERACTION_LINK_LABELS } from '../model/contact-interaction-links'\nimport { contactPrimaryGroup } from '../model/contact-record'\nimport { ActivityRow } from './activity-list'\nimport {\n type ActivityRecordLink,\n type CrmOrg,\n useActivityScope,\n useActivityWindow,\n useCanEditActivity,\n} from './activity-queries'\nimport {\n TimelineEntry,\n TimelineExpandAllButton,\n useTimelineExpansion,\n useTimelinePages,\n} from './activity-timeline'\nimport { CrmSuiteLockedButton } from './crm-suite-lock'\nimport { LogActivityDialog } from './log-activity-dialog'\nimport { useContactCampaignEmails } from './use-contact-campaign-emails'\n\n/**\n * One glyph per capture door. Typed against the source union so a door added\n * to it cannot render as the placeholder glyph.\n */\nconst SOURCE_ICONS: Record<ContactSource, { path: string }> = {\n form: mdiFormSelect,\n member: mdiAccountPlusOutline,\n order: mdiCartOutline,\n booking: mdiCalendarCheckOutline,\n newsletter: mdiEmailNewsletter,\n api: mdiApi,\n manual: mdiAccountEditOutline,\n import: mdiFileImportOutline,\n account: mdiAccountCircleOutline,\n}\n\n/**\n * One thing the platform recorded: the door it came through, what the door\n * wrote, when, and the page the person was on.\n *\n * Read-only by nature — nobody logged it, so nobody edits it. The entry\n * point sits beside the timestamp rather than in the summary, the way the\n * contacts drawer draws it: the summary is the sentence the door wrote, the\n * path is a fact the door recorded, and a long path on the sentence's line\n * pushes out the thing the row is about.\n *\n * Everything it holds fits the collapsed row, so it has nothing to open.\n */\nfunction CapturedRow(props: {\n interaction: ContactInteraction\n nowMs: number\n /** The console page the door's record is read on, when it left one. */\n href: string | null\n}) {\n const { interaction, nowMs, href } = props\n const source = interaction.type\n const label = CONTACT_SOURCE_LABELS[source] ?? String(source)\n const icon = SOURCE_ICONS[source]\n const when = new Date(interaction.atMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={icon?.path} />}\n chips={\n <>\n <Chip label={label} size=\"small\" />\n <Chip label=\"Captured\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={interaction.summary ?? label}\n meta={\n <Stack\n useFlexGap\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'baseline', flexWrap: 'wrap', rowGap: 0.25 }}\n >\n <Tooltip title={when.toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {interaction.path\n ? `${Aglyn.activityTimeLabel(interaction.atMs, nowMs)} · ${interaction.path}`\n : Aglyn.activityTimeLabel(interaction.atMs, nowMs)}\n </Typography>\n </Tooltip>\n {/*\n The record the door left, where the console reads it (AGL-2622):\n the submission in the Inbox reader, the order in its dialog. A door\n that left nothing to open — a newsletter opt-in, an import — has no\n link, and the caption is the whole entry.\n */}\n {href ? (\n <Typography variant=\"caption\">\n <AppLink href={href}>\n {INTERACTION_LINK_LABELS[source] ?? 'Open'}\n </AppLink>\n </Typography>\n ) : null}\n </Stack>\n }\n />\n )\n}\nCapturedRow.displayName = 'CapturedRow'\n\n/**\n * One campaign email the platform sent this person (AGL-2616): which email,\n * and what became of it — `sent · delivered · opened ×2 · clicked`.\n *\n * Read-only like a captured row, and drawn apart from BOTH the captured and\n * the logged rows: its glyph is the campaign's, never the envelope a logged\n * `email` activity carries, and its chips say \"Campaign\" and \"Sent\" where\n * the others say \"Captured\" and \"Logged\". The name links to the email's own\n * report when the page can address it; an email the team has deleted, or\n * one a sibling site sent, keeps its name and loses the link.\n *\n * The delivery line stays on the collapsed row beside the name, so it has\n * nothing to open.\n */\nfunction CampaignEmailRow(props: {\n email: ContactCampaignEmail\n href: string | null\n nowMs: number\n}) {\n const { email, href, nowMs } = props\n const name = email.campaignName ?? email.subject ?? 'Campaign email'\n const summary = Aglyn.campaignEmailSummary(email).join(' · ')\n const when = new Date(email.sentAtMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={mdiBullhornOutline.path} />}\n chips={\n <>\n <Chip label=\"Campaign\" size=\"small\" />\n <Chip label=\"Sent\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={\n <>\n {href ? <AppLink href={href}>{name}</AppLink> : name}\n {` · ${summary}`}\n </>\n }\n meta={\n <Tooltip title={when.toLocaleString()}>\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ alignSelf: 'flex-start' }}\n >\n {email.subject && email.subject !== name\n ? `${Aglyn.activityTimeLabel(email.sentAtMs, nowMs)} · ${email.subject}`\n : Aglyn.activityTimeLabel(email.sentAtMs, nowMs)}\n </Typography>\n </Tooltip>\n }\n />\n )\n}\nCampaignEmailRow.displayName = 'CampaignEmailRow'\n\nexport interface ContactTimelineCardProps {\n /** The site the record is read under, or `null` at the organization level. */\n hostId: string | null\n org: CrmOrg\n contactId: string\n /**\n * The RAW contact document, as the page's listener hands it back. The card\n * resolves the viewing group and reads that group's facet itself, so a\n * page cannot hand it another holder's timeline by flattening the row\n * through the wrong group.\n */\n contact: Record<string, unknown> | null | undefined\n /**\n * Where a campaign entry links to — the email's report — or `null` for\n * one the page cannot address. Handed in rather than built here because\n * the report lives on another surface whose path is the MOUNT's to know:\n * a site's record page reaches its own Emails hub, an org-level mount\n * holds the org's site list and can reach every site's. No builder means\n * no links, never a guessed one.\n */\n campaignHref?: (email: ContactCampaignEmail) => string | null\n /**\n * The org's plan lacks the CRM (AGL-2788), which the shell mounts no CRM\n * page for (AGL-2851). The history still draws; logging an activity stands\n * locked, and one already logged cannot be edited.\n */\n suiteLocked?: boolean\n}\n\n/**\n * A person's whole history in one newest-first stream (AGL-2600): what the\n * platform CAPTURED — a form, an order, a booking, a sign-up — what a person\n * LOGGED beside it — a call, an email, a meeting, a note — and, since\n * AGL-2616, the campaign mail the platform SENT them and what became of it.\n *\n * ## Why one stream\n *\n * The histories answer one question, \"what has happened with this person\",\n * and a page showing them as separate lists asks the reader to interleave\n * them by eye. The merge is `mergeContactTimeline`, pure and specced: newest\n * first, stable at a tie, an undated entry last, a campaign at the instant\n * it was sent.\n *\n * Each entry says which it is. A captured entry carries the door's label and\n * a \"Captured\" chip and offers no controls; a logged entry carries its kind\n * and a \"Logged\" chip, and its author may edit or delete it; a campaign\n * entry carries \"Campaign\" and \"Sent\", links to the email's report, and is\n * nobody's to edit.\n *\n * ## What is read\n *\n * The captured side comes off the contact document the page already holds\n * — THIS group's facet, filtered to the sites the group may see, the same\n * projection the contacts list makes — and costs no read. The logged side is\n * the bounded listener every activity surface uses, filtered to this\n * contact, a hundred at a time. The campaign side is one request to\n * `crm/contact-email-history`, which reads down this person's own delivery\n * log, capped, and never a campaign's recipients. The facet's history is\n * capped at fifty by the platform, so past the window the stream is the\n * logged side alone.\n *\n * ## How it reads\n *\n * Every entry is a collapsed row that opens in place, paged by the\n * console's footer over the merged stream; a page turned past the logged\n * window widens it by the next hundred. **Expand all** and **Log activity**\n * sit in the card header, and edit and delete stay on the entry they act on.\n */\nexport function ContactTimelineCard(props: ContactTimelineCardProps) {\n const { hostId, org, contactId, contact, campaignHref, suiteLocked = false } = props\n const scope = useActivityScope(hostId, org)\n /*\n * THIS holder's captured history, and only the visits this group may see.\n * A read off the top of the document, or of another group's facet, would\n * be another business's records — the disclosure the facet shape ended.\n * At the organization level (AGL-2630) the holder is the person's own\n * primary one, the group the record page reads the profile through.\n */\n const consentGroup = useMemo(\n () =>\n scope.consentGroup ??\n contactPrimaryGroup(contact, (org ?? null) as Record<string, unknown> | null),\n [scope.consentGroup, contact, org],\n )\n const interactions = useMemo(() => {\n const facet = Aglyn.readContactFacet(contact, consentGroup.groupId)\n return Aglyn.interactionsForGroup(facet.interactions, consentGroup.hostIds)\n }, [contact, consentGroup])\n const link = useMemo<ActivityRecordLink>(() => ({ contactId }), [contactId])\n const activities = useActivityWindow(scope, link)\n /*\n * Where a captured entry's record is READ (AGL-2622). The site console's\n * two route params are already in the URL this page is on, so the link is\n * built from them rather than from two document reads per timeline. Only\n * an interaction on THIS site links: a sibling site's order is that\n * site's record, read on that site's console, and a link built with this\n * site's segment would 404 or — worse — open the wrong site's row under\n * the same id. An interaction with no site recorded predates the stamp\n * and is left unlinked rather than guessed at. Off a site — the org-level\n * mount — there is no segment to build with, and nothing links.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const siteContext = useMemo(\n () =>\n params?.orgSlug && params?.host\n ? { orgSlug: String(params.orgSlug), host: String(params.host) }\n : null,\n [params?.orgSlug, params?.host],\n )\n const interactionHref = useCallback(\n (interaction: ContactInteraction): string | null =>\n siteContext && interaction.hostId === hostId\n ? contactInteractionHref(interaction, siteContext)\n : null,\n [siteContext, hostId],\n )\n // The campaign history is asked of a site: the mounted one, or the holder's.\n const campaigns = useContactCampaignEmails(\n hostId ?? (consentGroup.hostId || null),\n contactId,\n )\n // One member read for the whole stream, however many rows it has.\n const canEdit = useCanEditActivity(scope.orgId)\n const entries = useMemo(\n () =>\n Aglyn.mergeContactTimeline(interactions, activities.rows, campaigns.emails),\n [interactions, activities.rows, campaigns.emails],\n )\n // One clock for every row of one paint, so two rows logged a second apart\n // cannot read \"just now\" and \"1 min ago\" from being rendered across a\n // minute boundary.\n const nowMs = Date.now()\n\n const [dialogOpen, setDialogOpen] = useState(false)\n const [editing, setEditing] = useState<CrmActivityRow | null>(null)\n const openNew = useCallback(() => {\n setEditing(null)\n setDialogOpen(true)\n }, [])\n const openEdit = useCallback((activity: CrmActivityRow) => {\n setEditing(activity)\n setDialogOpen(true)\n }, [])\n const close = useCallback(() => setDialogOpen(false), [])\n\n const loggedChip = useMemo(\n () => <Chip label=\"Logged\" size=\"small\" variant=\"outlined\" />,\n [],\n )\n const expansion = useTimelineExpansion()\n const pages = useTimelinePages(entries.length, {\n hasMore: activities.hasMore,\n showMore: activities.showMore,\n loading: activities.status === 'loading',\n })\n const logButton = suiteLocked ? (\n <CrmSuiteLockedButton variant=\"contained\" color=\"primary\">\n {'Log activity'}\n </CrmSuiteLockedButton>\n ) : (\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={openNew}\n disabled={!activities.ready}\n >\n {'Log activity'}\n </Button>\n )\n\n return (\n <>\n <CardDisplay\n header={'Timeline'}\n help={pluginDocsHelp('contactActivities', { anchor: '#four-kinds-of-history' })}\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <TimelineExpandAllButton expansion={expansion} disabled={!entries.length} />\n {logButton}\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {activities.status === 'error' ? (\n <Typography variant=\"body2\" color=\"error\">\n {'Logged activity could not be loaded; what follows is what the platform captured.'}\n </Typography>\n ) : null}\n {campaigns.status === 'error' || campaigns.lookupFailed ? (\n // \"Unknown\", not \"none\": an empty campaign history and one that\n // could not be read lead a reader to opposite conclusions.\n <Typography variant=\"body2\" color=\"error\">\n {'Campaign email could not be loaded; the stream below may be missing the mail this person was sent.'}\n </Typography>\n ) : null}\n {entries.length === 0 ? (\n campaigns.status === 'loading' ? (\n // The campaign history is one request on mount; an empty state\n // before it answers would read as \"none\" for a person who was mailed.\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : (\n <EmptyStateComponent\n compact\n label={'No history yet'}\n description={'What this person does on the site, the campaigns they are sent, and what you log about them, shows here.'}\n action={logButton}\n />\n )\n ) : (\n pages.slice(entries).map((entry) =>\n entry.kind === 'captured' ? (\n <CapturedRow\n key={entry.key}\n interaction={entry.interaction}\n nowMs={nowMs}\n href={interactionHref(entry.interaction)}\n />\n ) : entry.kind === 'campaign' ? (\n <CampaignEmailRow\n key={entry.key}\n email={entry.email}\n href={campaignHref ? campaignHref(entry.email) : null}\n nowMs={nowMs}\n />\n ) : (\n <ActivityRow\n key={entry.key}\n activity={entry.activity}\n scope={scope}\n onEdit={openEdit}\n subject={loggedChip}\n nowMs={nowMs}\n editable={!suiteLocked && canEdit(entry.activity)}\n expanded={expansion.isExpanded(entry.key)}\n onToggle={() => expansion.toggle(entry.key)}\n />\n ),\n )\n )}\n {entries.length ? <ListPagination {...pages.footer} /> : null}\n </Stack>\n </CardDisplay>\n <LogActivityDialog\n open={dialogOpen}\n onClose={close}\n scope={scope}\n link={link}\n activity={editing}\n />\n </>\n )\n}\nContactTimelineCard.displayName = 'ContactTimelineCard'\n\nexport default ContactTimelineCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","pluginDocsHelp","mdiAccountCircleOutline","mdiAccountEditOutline","mdiAccountPlusOutline","mdiApi","mdiBullhornOutline","mdiCalendarCheckOutline","mdiCartOutline","mdiEmailNewsletter","mdiFileImportOutline","mdiFormSelect","AppLink","CardDisplay","MdiIcon","EmptyStateComponent","ListPagination","Button","Chip","Stack","Tooltip","Typography","useParams","useCallback","useMemo","useState","contactInteractionHref","INTERACTION_LINK_LABELS","contactPrimaryGroup","ActivityRow","useActivityScope","useActivityWindow","useCanEditActivity","TimelineEntry","TimelineExpandAllButton","useTimelineExpansion","useTimelinePages","CrmSuiteLockedButton","LogActivityDialog","useContactCampaignEmails","SOURCE_ICONS","form","member","order","booking","newsletter","api","manual","import","account","CapturedRow","props","interaction","nowMs","href","source","type","label","String","icon","when","Date","atMs","path","chips","size","variant","title","summary","meta","useFlexGap","direction","spacing","sx","alignItems","flexWrap","rowGap","toLocaleString","color","activityTimeLabel","displayName","CampaignEmailRow","email","name","campaignName","subject","campaignEmailSummary","join","sentAtMs","alignSelf","ContactTimelineCard","hostId","org","contactId","contact","campaignHref","suiteLocked","scope","consentGroup","interactions","facet","readContactFacet","groupId","interactionsForGroup","hostIds","link","activities","params","siteContext","orgSlug","host","interactionHref","campaigns","canEdit","orgId","entries","mergeContactTimeline","rows","emails","now","dialogOpen","setDialogOpen","editing","setEditing","openNew","openEdit","activity","close","loggedChip","expansion","pages","length","hasMore","showMore","loading","status","logButton","onClick","disabled","ready","header","help","anchor","HeaderProps","action","contentGutterX","contentGutterY","lookupFailed","compact","description","slice","map","entry","kind","key","onEdit","editable","expanded","isExpanded","onToggle","toggle","footer","open","onClose"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-timeline-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 type {\n ContactCampaignEmail,\n ContactInteraction,\n ContactSource,\n CrmActivityRow,\n} from '@aglyn/aglyn'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { labelContactSource } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n mdiAccountCircleOutline,\n mdiAccountEditOutline,\n mdiAccountPlusOutline,\n mdiApi,\n mdiBullhornOutline,\n mdiCalendarCheckOutline,\n mdiCartOutline,\n mdiEmailNewsletter,\n mdiFileImportOutline,\n mdiFormSelect,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Button, Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport { useParams } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport { contactInteractionHref, INTERACTION_LINK_LABELS } from '../model/contact-interaction-links'\nimport { contactPrimaryGroup } from '../model/contact-record'\nimport { ActivityRow } from './activity-list'\nimport {\n type ActivityRecordLink,\n type CrmOrg,\n useActivityScope,\n useActivityWindow,\n useCanEditActivity,\n} from './activity-queries'\nimport {\n TimelineEntry,\n TimelineExpandAllButton,\n useTimelineExpansion,\n useTimelinePages,\n} from './activity-timeline'\nimport { CrmSuiteLockedButton } from './crm-suite-lock'\nimport { LogActivityDialog } from './log-activity-dialog'\nimport { useContactCampaignEmails } from './use-contact-campaign-emails'\n\n/**\n * One glyph per capture door. Typed against the source union so a door added\n * to it cannot render as the placeholder glyph.\n */\nconst SOURCE_ICONS: Record<ContactSource, { path: string }> = {\n form: mdiFormSelect,\n member: mdiAccountPlusOutline,\n order: mdiCartOutline,\n booking: mdiCalendarCheckOutline,\n newsletter: mdiEmailNewsletter,\n api: mdiApi,\n manual: mdiAccountEditOutline,\n import: mdiFileImportOutline,\n account: mdiAccountCircleOutline,\n}\n\n/**\n * One thing the platform recorded: the door it came through, what the door\n * wrote, when, and the page the person was on.\n *\n * Read-only by nature — nobody logged it, so nobody edits it. The entry\n * point sits beside the timestamp rather than in the summary, the way the\n * contacts drawer draws it: the summary is the sentence the door wrote, the\n * path is a fact the door recorded, and a long path on the sentence's line\n * pushes out the thing the row is about.\n *\n * Everything it holds fits the collapsed row, so it has nothing to open.\n */\nfunction CapturedRow(props: {\n interaction: ContactInteraction\n nowMs: number\n /** The console page the door's record is read on, when it left one. */\n href: string | null\n}) {\n const { interaction, nowMs, href } = props\n const source = interaction.type\n const label = labelContactSource(source) || String(source)\n const icon = SOURCE_ICONS[source]\n const when = new Date(interaction.atMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={icon?.path} />}\n chips={\n <>\n <Chip label={label} size=\"small\" />\n <Chip label=\"Captured\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={interaction.summary ?? label}\n meta={\n <Stack\n useFlexGap\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'baseline', flexWrap: 'wrap', rowGap: 0.25 }}\n >\n <Tooltip title={when.toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {interaction.path\n ? `${Aglyn.activityTimeLabel(interaction.atMs, nowMs)} · ${interaction.path}`\n : Aglyn.activityTimeLabel(interaction.atMs, nowMs)}\n </Typography>\n </Tooltip>\n {/*\n The record the door left, where the console reads it (AGL-2622):\n the submission in the Inbox reader, the order in its dialog. A door\n that left nothing to open — a newsletter opt-in, an import — has no\n link, and the caption is the whole entry.\n */}\n {href ? (\n <Typography variant=\"caption\">\n <AppLink href={href}>\n {INTERACTION_LINK_LABELS[source] ?? 'Open'}\n </AppLink>\n </Typography>\n ) : null}\n </Stack>\n }\n />\n )\n}\nCapturedRow.displayName = 'CapturedRow'\n\n/**\n * One campaign email the platform sent this person (AGL-2616): which email,\n * and what became of it — `sent · delivered · opened ×2 · clicked`.\n *\n * Read-only like a captured row, and drawn apart from BOTH the captured and\n * the logged rows: its glyph is the campaign's, never the envelope a logged\n * `email` activity carries, and its chips say \"Campaign\" and \"Sent\" where\n * the others say \"Captured\" and \"Logged\". The name links to the email's own\n * report when the page can address it; an email the team has deleted, or\n * one a sibling site sent, keeps its name and loses the link.\n *\n * The delivery line stays on the collapsed row beside the name, so it has\n * nothing to open.\n */\nfunction CampaignEmailRow(props: {\n email: ContactCampaignEmail\n href: string | null\n nowMs: number\n}) {\n const { email, href, nowMs } = props\n const name = email.campaignName ?? email.subject ?? 'Campaign email'\n const summary = Aglyn.campaignEmailSummary(email).join(' · ')\n const when = new Date(email.sentAtMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={mdiBullhornOutline.path} />}\n chips={\n <>\n <Chip label=\"Campaign\" size=\"small\" />\n <Chip label=\"Sent\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={\n <>\n {href ? <AppLink href={href}>{name}</AppLink> : name}\n {` · ${summary}`}\n </>\n }\n meta={\n <Tooltip title={when.toLocaleString()}>\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ alignSelf: 'flex-start' }}\n >\n {email.subject && email.subject !== name\n ? `${Aglyn.activityTimeLabel(email.sentAtMs, nowMs)} · ${email.subject}`\n : Aglyn.activityTimeLabel(email.sentAtMs, nowMs)}\n </Typography>\n </Tooltip>\n }\n />\n )\n}\nCampaignEmailRow.displayName = 'CampaignEmailRow'\n\nexport interface ContactTimelineCardProps {\n /** The site the record is read under, or `null` at the organization level. */\n hostId: string | null\n org: CrmOrg\n contactId: string\n /**\n * The RAW contact document, as the page's listener hands it back. The card\n * resolves the viewing group and reads that group's facet itself, so a\n * page cannot hand it another holder's timeline by flattening the row\n * through the wrong group.\n */\n contact: Record<string, unknown> | null | undefined\n /**\n * Where a campaign entry links to — the email's report — or `null` for\n * one the page cannot address. Handed in rather than built here because\n * the report lives on another surface whose path is the MOUNT's to know:\n * a site's record page reaches its own Emails hub, an org-level mount\n * holds the org's site list and can reach every site's. No builder means\n * no links, never a guessed one.\n */\n campaignHref?: (email: ContactCampaignEmail) => string | null\n /**\n * The org's plan lacks the CRM (AGL-2788), which the shell mounts no CRM\n * page for (AGL-2851). The history still draws; logging an activity stands\n * locked, and one already logged cannot be edited.\n */\n suiteLocked?: boolean\n}\n\n/**\n * A person's whole history in one newest-first stream (AGL-2600): what the\n * platform CAPTURED — a form, an order, a booking, a sign-up — what a person\n * LOGGED beside it — a call, an email, a meeting, a note — and, since\n * AGL-2616, the campaign mail the platform SENT them and what became of it.\n *\n * ## Why one stream\n *\n * The histories answer one question, \"what has happened with this person\",\n * and a page showing them as separate lists asks the reader to interleave\n * them by eye. The merge is `mergeContactTimeline`, pure and specced: newest\n * first, stable at a tie, an undated entry last, a campaign at the instant\n * it was sent.\n *\n * Each entry says which it is. A captured entry carries the door's label and\n * a \"Captured\" chip and offers no controls; a logged entry carries its kind\n * and a \"Logged\" chip, and its author may edit or delete it; a campaign\n * entry carries \"Campaign\" and \"Sent\", links to the email's report, and is\n * nobody's to edit.\n *\n * ## What is read\n *\n * The captured side comes off the contact document the page already holds\n * — THIS group's facet, filtered to the sites the group may see, the same\n * projection the contacts list makes — and costs no read. The logged side is\n * the bounded listener every activity surface uses, filtered to this\n * contact, a hundred at a time. The campaign side is one request to\n * `crm/contact-email-history`, which reads down this person's own delivery\n * log, capped, and never a campaign's recipients. The facet's history is\n * capped at fifty by the platform, so past the window the stream is the\n * logged side alone.\n *\n * ## How it reads\n *\n * Every entry is a collapsed row that opens in place, paged by the\n * console's footer over the merged stream; a page turned past the logged\n * window widens it by the next hundred. **Expand all** and **Log activity**\n * sit in the card header, and edit and delete stay on the entry they act on.\n */\nexport function ContactTimelineCard(props: ContactTimelineCardProps) {\n const { hostId, org, contactId, contact, campaignHref, suiteLocked = false } = props\n const scope = useActivityScope(hostId, org)\n /*\n * THIS holder's captured history, and only the visits this group may see.\n * A read off the top of the document, or of another group's facet, would\n * be another business's records — the disclosure the facet shape ended.\n * At the organization level (AGL-2630) the holder is the person's own\n * primary one, the group the record page reads the profile through.\n */\n const consentGroup = useMemo(\n () =>\n scope.consentGroup ??\n contactPrimaryGroup(contact, (org ?? null) as Record<string, unknown> | null),\n [scope.consentGroup, contact, org],\n )\n const interactions = useMemo(() => {\n const facet = Aglyn.readContactFacet(contact, consentGroup.groupId)\n return Aglyn.interactionsForGroup(facet.interactions, consentGroup.hostIds)\n }, [contact, consentGroup])\n const link = useMemo<ActivityRecordLink>(() => ({ contactId }), [contactId])\n const activities = useActivityWindow(scope, link)\n /*\n * Where a captured entry's record is READ (AGL-2622). The site console's\n * two route params are already in the URL this page is on, so the link is\n * built from them rather than from two document reads per timeline. Only\n * an interaction on THIS site links: a sibling site's order is that\n * site's record, read on that site's console, and a link built with this\n * site's segment would 404 or — worse — open the wrong site's row under\n * the same id. An interaction with no site recorded predates the stamp\n * and is left unlinked rather than guessed at. Off a site — the org-level\n * mount — there is no segment to build with, and nothing links.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const siteContext = useMemo(\n () =>\n params?.orgSlug && params?.host\n ? { orgSlug: String(params.orgSlug), host: String(params.host) }\n : null,\n [params?.orgSlug, params?.host],\n )\n const interactionHref = useCallback(\n (interaction: ContactInteraction): string | null =>\n siteContext && interaction.hostId === hostId\n ? contactInteractionHref(interaction, siteContext)\n : null,\n [siteContext, hostId],\n )\n // The campaign history is asked of a site: the mounted one, or the holder's.\n const campaigns = useContactCampaignEmails(\n hostId ?? (consentGroup.hostId || null),\n contactId,\n )\n // One member read for the whole stream, however many rows it has.\n const canEdit = useCanEditActivity(scope.orgId)\n const entries = useMemo(\n () =>\n Aglyn.mergeContactTimeline(interactions, activities.rows, campaigns.emails),\n [interactions, activities.rows, campaigns.emails],\n )\n // One clock for every row of one paint, so two rows logged a second apart\n // cannot read \"just now\" and \"1 min ago\" from being rendered across a\n // minute boundary.\n const nowMs = Date.now()\n\n const [dialogOpen, setDialogOpen] = useState(false)\n const [editing, setEditing] = useState<CrmActivityRow | null>(null)\n const openNew = useCallback(() => {\n setEditing(null)\n setDialogOpen(true)\n }, [])\n const openEdit = useCallback((activity: CrmActivityRow) => {\n setEditing(activity)\n setDialogOpen(true)\n }, [])\n const close = useCallback(() => setDialogOpen(false), [])\n\n const loggedChip = useMemo(\n () => <Chip label=\"Logged\" size=\"small\" variant=\"outlined\" />,\n [],\n )\n const expansion = useTimelineExpansion()\n const pages = useTimelinePages(entries.length, {\n hasMore: activities.hasMore,\n showMore: activities.showMore,\n loading: activities.status === 'loading',\n })\n const logButton = suiteLocked ? (\n <CrmSuiteLockedButton variant=\"contained\" color=\"primary\">\n {'Log activity'}\n </CrmSuiteLockedButton>\n ) : (\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={openNew}\n disabled={!activities.ready}\n >\n {'Log activity'}\n </Button>\n )\n\n return (\n <>\n <CardDisplay\n header={'Timeline'}\n help={pluginDocsHelp('contactActivities', { anchor: '#four-kinds-of-history' })}\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <TimelineExpandAllButton expansion={expansion} disabled={!entries.length} />\n {logButton}\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {activities.status === 'error' ? (\n <Typography variant=\"body2\" color=\"error\">\n {'Logged activity could not be loaded; what follows is what the platform captured.'}\n </Typography>\n ) : null}\n {campaigns.status === 'error' || campaigns.lookupFailed ? (\n // \"Unknown\", not \"none\": an empty campaign history and one that\n // could not be read lead a reader to opposite conclusions.\n <Typography variant=\"body2\" color=\"error\">\n {'Campaign email could not be loaded; the stream below may be missing the mail this person was sent.'}\n </Typography>\n ) : null}\n {entries.length === 0 ? (\n campaigns.status === 'loading' ? (\n // The campaign history is one request on mount; an empty state\n // before it answers would read as \"none\" for a person who was mailed.\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : (\n <EmptyStateComponent\n compact\n label={'No history yet'}\n description={'What this person does on the site, the campaigns they are sent, and what you log about them, shows here.'}\n action={logButton}\n />\n )\n ) : (\n pages.slice(entries).map((entry) =>\n entry.kind === 'captured' ? (\n <CapturedRow\n key={entry.key}\n interaction={entry.interaction}\n nowMs={nowMs}\n href={interactionHref(entry.interaction)}\n />\n ) : entry.kind === 'campaign' ? (\n <CampaignEmailRow\n key={entry.key}\n email={entry.email}\n href={campaignHref ? campaignHref(entry.email) : null}\n nowMs={nowMs}\n />\n ) : (\n <ActivityRow\n key={entry.key}\n activity={entry.activity}\n scope={scope}\n onEdit={openEdit}\n subject={loggedChip}\n nowMs={nowMs}\n editable={!suiteLocked && canEdit(entry.activity)}\n expanded={expansion.isExpanded(entry.key)}\n onToggle={() => expansion.toggle(entry.key)}\n />\n ),\n )\n )}\n {entries.length ? <ListPagination {...pages.footer} /> : null}\n </Stack>\n </CardDisplay>\n <LogActivityDialog\n open={dialogOpen}\n onClose={close}\n scope={scope}\n link={link}\n activity={editing}\n />\n </>\n )\n}\nContactTimelineCard.displayName = 'ContactTimelineCard'\n\nexport default ContactTimelineCard\n"],"names":["Aglyn","pluginDocsHelp","labelContactSource","mdiAccountCircleOutline","mdiAccountEditOutline","mdiAccountPlusOutline","mdiApi","mdiBullhornOutline","mdiCalendarCheckOutline","mdiCartOutline","mdiEmailNewsletter","mdiFileImportOutline","mdiFormSelect","AppLink","CardDisplay","MdiIcon","EmptyStateComponent","ListPagination","Button","Chip","Stack","Tooltip","Typography","useParams","useCallback","useMemo","useState","contactInteractionHref","INTERACTION_LINK_LABELS","contactPrimaryGroup","ActivityRow","useActivityScope","useActivityWindow","useCanEditActivity","TimelineEntry","TimelineExpandAllButton","useTimelineExpansion","useTimelinePages","CrmSuiteLockedButton","LogActivityDialog","useContactCampaignEmails","SOURCE_ICONS","form","member","order","booking","newsletter","api","manual","import","account","CapturedRow","props","interaction","nowMs","href","source","type","label","String","icon","when","Date","atMs","path","chips","size","variant","title","summary","meta","useFlexGap","direction","spacing","sx","alignItems","flexWrap","rowGap","toLocaleString","color","activityTimeLabel","displayName","CampaignEmailRow","email","name","campaignName","subject","campaignEmailSummary","join","sentAtMs","alignSelf","ContactTimelineCard","hostId","org","contactId","contact","campaignHref","suiteLocked","scope","consentGroup","interactions","facet","readContactFacet","groupId","interactionsForGroup","hostIds","link","activities","params","siteContext","orgSlug","host","interactionHref","campaigns","canEdit","orgId","entries","mergeContactTimeline","rows","emails","now","dialogOpen","setDialogOpen","editing","setEditing","openNew","openEdit","activity","close","loggedChip","expansion","pages","length","hasMore","showMore","loading","status","logButton","onClick","disabled","ready","header","help","anchor","HeaderProps","action","contentGutterX","contentGutterY","lookupFailed","compact","description","slice","map","entry","kind","key","onEdit","editable","expanded","isExpanded","onToggle","toggle","footer","open","onClose"],"mappings":"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"}
@@ -49,10 +49,12 @@ export interface ErasePersonAction {
49
49
  * action knows — who may, whether a request is already waiting, what the
50
50
  * dialog says, what the route answers — lives here.
51
51
  *
52
- * Offered to workspace admins and owners only, and the item is present but
53
- * disabled for everyone else with the reason as its tooltip: an absent item
54
- * and an inapplicable one look alike, and a site editor who cannot find
55
- * the action should learn who can rather than conclude it does not exist.
52
+ * Offered to whoever holds "Privacy requests" (`org.privacy`, AGL-4047) over
53
+ * the whole workspace — owners and admins by default — and the item is
54
+ * present but disabled for everyone else with the reason as its tooltip: an
55
+ * absent item and an inapplicable one look alike, and a site editor who
56
+ * cannot find the action should learn who can rather than conclude it does
57
+ * not exist.
56
58
  *
57
59
  * Takes the site from the page and the workspace from `useOrgDataScope`,
58
60
  * never a route, so an org-level mount can offer the same item.
@@ -23,7 +23,7 @@ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
23
23
  import { Alert, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, List, ListItem, ListItemText, Stack, TextField, Typography } from "@mui/material";
24
24
  import { useEffect, useMemo, useState } from "react";
25
25
  import { useCrmOrgMount } from "../hooks/use-crm-org-mount.js";
26
- import { useCanManageCrmSettings } from "./settings-section.js";
26
+ import { useCanErasePeople } from "./use-can-erase-people.js";
27
27
  import { useCrmApi } from "./use-crm-api.js";
28
28
  /**
29
29
  * **Erase this person** on a record page (AGL-2623).
@@ -34,16 +34,18 @@ import { useCrmApi } from "./use-crm-api.js";
34
34
  * action knows — who may, whether a request is already waiting, what the
35
35
  * dialog says, what the route answers — lives here.
36
36
  *
37
- * Offered to workspace admins and owners only, and the item is present but
38
- * disabled for everyone else with the reason as its tooltip: an absent item
39
- * and an inapplicable one look alike, and a site editor who cannot find
40
- * the action should learn who can rather than conclude it does not exist.
37
+ * Offered to whoever holds "Privacy requests" (`org.privacy`, AGL-4047) over
38
+ * the whole workspace — owners and admins by default — and the item is
39
+ * present but disabled for everyone else with the reason as its tooltip: an
40
+ * absent item and an inapplicable one look alike, and a site editor who
41
+ * cannot find the action should learn who can rather than conclude it does
42
+ * not exist.
41
43
  *
42
44
  * Takes the site from the page and the workspace from `useOrgDataScope`,
43
45
  * never a route, so an org-level mount can offer the same item.
44
46
  */ export function useErasePersonAction(props) {
45
47
  const { hostId, orgId, subject, requestedAtMs } = props;
46
- const { canManage, ready } = useCanManageCrmSettings(orgId != null ? orgId : undefined);
48
+ const { canErase, ready } = useCanErasePeople(orgId != null ? orgId : undefined);
47
49
  const orgMount = useCrmOrgMount();
48
50
  // A site to file from, or the org to file as (AGL-2634).
49
51
  const canFile = Boolean(hostId) || Boolean(orgMount);
@@ -54,7 +56,7 @@ import { useCrmApi } from "./use-crm-api.js";
54
56
  const [filedAtMs, setFiledAtMs] = useState(null);
55
57
  const pendingSince = requestedAtMs != null ? requestedAtMs : filedAtMs;
56
58
  const menuItems = useMemo(()=>{
57
- const disabledReason = !subject ? 'The record has not loaded' : !canFile ? 'No site has captured this person to file from' : pendingSince ? 'An erasure is already pending for this person' : !ready ? 'Checking your workspace role' : !canManage ? 'Only a workspace admin can erase a person' : undefined;
59
+ const disabledReason = !subject ? 'The record has not loaded' : !canFile ? 'No site has captured this person to file from' : pendingSince ? 'An erasure is already pending for this person' : !ready ? 'Checking your workspace role' : !canErase ? 'Your role does not include "Privacy requests" — ask an organization admin' : undefined;
58
60
  return [
59
61
  {
60
62
  key: 'erase-person',
@@ -70,7 +72,7 @@ import { useCrmApi } from "./use-crm-api.js";
70
72
  }
71
73
  ];
72
74
  }, [
73
- canManage,
75
+ canErase,
74
76
  canFile,
75
77
  pendingSince,
76
78
  ready,