@aglyn/plugins-crm 1.0.0-beta.231 → 1.0.0-beta.233
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.
- package/package.json +14 -14
- package/src/lib/components/activity-timeline.js +1 -0
- package/src/lib/components/activity-timeline.js.map +1 -1
- package/src/lib/components/assignment-rule-drawer.js +1 -0
- package/src/lib/components/assignment-rule-drawer.js.map +1 -1
- package/src/lib/components/companies-section.js +43 -8
- package/src/lib/components/companies-section.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +3 -1
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +1 -0
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +6 -1
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-duplicates-card.js +1 -0
- package/src/lib/components/contact-duplicates-card.js.map +1 -1
- package/src/lib/components/contact-field-drawer.js +1 -0
- package/src/lib/components/contact-field-drawer.js.map +1 -1
- package/src/lib/components/contact-known-by-card.js +1 -0
- package/src/lib/components/contact-known-by-card.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +1 -0
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/contacts-section.js +37 -8
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-email-check.d.ts +14 -0
- package/src/lib/components/crm-email-check.js +38 -1
- package/src/lib/components/crm-email-check.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +1 -0
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/crm-next-activity-column.d.ts +5 -4
- package/src/lib/components/crm-next-activity-column.js +5 -4
- package/src/lib/components/crm-next-activity-column.js.map +1 -1
- package/src/lib/components/crm-record-header.js +2 -0
- package/src/lib/components/crm-record-header.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +3 -1
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.js +51 -6
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/fields-section.js +4 -0
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-history-card.js +1 -0
- package/src/lib/components/lead-history-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +9 -3
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.js +1 -0
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/leads-section.js +61 -11
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/linked-deals-card.js +1 -0
- package/src/lib/components/linked-deals-card.js.map +1 -1
- package/src/lib/components/picklist-values-card.js +1 -0
- package/src/lib/components/picklist-values-card.js.map +1 -1
- package/src/lib/components/recipes-card.js +1 -0
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/reports/activity-card.js +1 -0
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/components/reports/closed-deals-card.js +1 -0
- package/src/lib/components/reports/closed-deals-card.js.map +1 -1
- package/src/lib/components/reports/contacts-trend-card.js +1 -0
- package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +1 -0
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/lead-source-card.js +1 -0
- package/src/lib/components/reports/lead-source-card.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +1 -0
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +1 -0
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/reports/tasks-card.js +1 -0
- package/src/lib/components/reports/tasks-card.js.map +1 -1
- package/src/lib/components/reports-section.js +2 -0
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/components/tasks-calendar.js +1 -0
- package/src/lib/components/tasks-calendar.js.map +1 -1
- package/src/lib/components/tasks-section.js +61 -9
- package/src/lib/components/tasks-section.js.map +1 -1
- package/src/lib/constants/company-filters.js +40 -2
- package/src/lib/constants/company-filters.js.map +1 -1
- package/src/lib/constants/contact-filters.d.ts +10 -1
- package/src/lib/constants/contact-filters.js +62 -2
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/constants/deal-filters.js +78 -0
- package/src/lib/constants/deal-filters.js.map +1 -1
- package/src/lib/hooks/use-crm-column-sort.d.ts +67 -0
- package/src/lib/hooks/use-crm-column-sort.js +156 -0
- package/src/lib/hooks/use-crm-column-sort.js.map +1 -0
- package/src/lib/hooks/use-crm-tasks.d.ts +6 -1
- package/src/lib/hooks/use-crm-tasks.js +10 -8
- package/src/lib/hooks/use-crm-tasks.js.map +1 -1
- package/src/lib/model/crm-list-query.d.ts +6 -0
- package/src/lib/model/crm-list-query.js +13 -0
- package/src/lib/model/crm-list-query.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +19 -2
- package/src/lib/model/lead-filters.js +112 -2
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/native-vocabulary.d.ts +41 -0
- package/src/lib/model/native-vocabulary.js +59 -0
- package/src/lib/model/native-vocabulary.js.map +1 -0
- package/src/lib/model/task-views.d.ts +15 -2
- package/src/lib/model/task-views.js +50 -4
- package/src/lib/model/task-views.js.map +1 -1
- package/src/lib/server/email-check.d.ts +4 -1
- package/src/lib/server/email-check.js +18 -6
- package/src/lib/server/email-check.js.map +1 -1
- package/src/lib/transfer/tasks.js +2 -1
- package/src/lib/transfer/tasks.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-duplicates-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 { type ConsentGroup, contactDisplayName, pluginDocsHelp } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Chip,\n List,\n ListItem,\n ListItemText,\n Stack,\n Typography,\n} from '@mui/material'\nimport { collection, getDocs, limit, query, where } from 'firebase/firestore'\nimport { useCallback, useState } from 'react'\nimport { crmVisibleToClause } from '../hooks/use-crm-scope'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n CONTACT_DUPLICATES_LIMIT,\n DUPLICATE_REASON_LABELS,\n type DuplicateReason,\n duplicateNameKey,\n likelyDuplicateReasons,\n} from '../model/contact-duplicates'\nimport type { ContactPick } from './contact-merge-dialog'\nimport { CrmSuiteLockedButton } from './crm-suite-lock'\n\nexport interface ContactDuplicatesCardProps {\n /** The record whose page this is. */\n current: ContactPick\n /** `['orgs', orgId]` — where the contacts live. */\n scope: readonly [string, string]\n consentGroup: ConsentGroup\n /**\n * What this viewer may list — the query's `array-contains-any` — or\n * `null` at the organization level, where the query carries no clause\n * (AGL-2630).\n */\n visibleTo: readonly string[] | null\n /** The hub's own path, for the link to a candidate's page. */\n basePath: string\n /** Opens the merge dialog with the candidate picked. */\n onMerge: (candidate: ContactPick) => void\n /**\n * The org's plan lacks the CRM (AGL-2788). Looking stays drawn; each\n * candidate's merge stands locked.\n */\n suiteLocked?: boolean\n}\n\ninterface Candidate extends ContactPick {\n reasons: DuplicateReason[]\n}\n\n/**\n * LIKELY DUPLICATES (AGL-2625) — on ask, never on mount.\n *\n * One bounded read when the button is pressed: the contacts this viewer may\n * list that carry the same normalized name, on the `(visibleTo, nameLower)`\n * index the list's own search uses. The rule — same name AND a shared phone\n * or company — is applied to that page, so a record with a different name\n * is never offered; a reader who knows two differently-named records are\n * one person merges them from the overflow menu instead.\n *\n * A button rather than a listener because the question costs a page of\n * PII, and most record pages are opened by somebody who did not ask it.\n */\nexport function ContactDuplicatesCard(props: ContactDuplicatesCardProps) {\n const {\n current,\n scope,\n consentGroup,\n visibleTo,\n basePath,\n onMerge,\n suiteLocked = false,\n } = props\n const firestore = useFirestore()\n const routes = crmRoutes(basePath)\n const groupId = consentGroup.groupId\n const [candidates, setCandidates] = useState<Candidate[] | null>(null)\n const [searching, setSearching] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n const nameKey = duplicateNameKey(current.doc, groupId)\n\n const find = useCallback(async () => {\n if (searching || !nameKey) return\n setSearching(true)\n setError(null)\n try {\n const found = await getDocs(\n query(\n collection(firestore, scope[0], scope[1], 'contacts'),\n ...crmVisibleToClause(visibleTo),\n where('nameLower', '==', nameKey),\n limit(CONTACT_DUPLICATES_LIMIT),\n ),\n )\n setCandidates(\n found.docs\n .map((snapshot) => ({ id: snapshot.id, doc: snapshot.data() }))\n .map((pick) => ({\n ...pick,\n reasons: likelyDuplicateReasons(current, pick, groupId),\n }))\n .filter((pick) => pick.reasons.length > 0),\n )\n } catch (findError) {\n console.error(findError)\n setCandidates(null)\n setError('The duplicates could not be looked for.')\n } finally {\n setSearching(false)\n }\n }, [searching, nameKey, firestore, scope, visibleTo, current, groupId])\n\n return (\n <CardDisplay\n header=\"Likely duplicates\"\n subheader=\"Other records with this name and the same phone or company.\"\n help={pluginDocsHelp('contactRecord', { anchor: '#likely-duplicates' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"outlined\"\n onClick={() => void find()}\n disabled={searching || !nameKey}\n >\n {searching ? 'Looking…' : 'Find likely duplicates'}\n </Button>\n ),\n }}\n >\n {!nameKey ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Give this contact a name first — duplicates are found by name.'}\n </Typography>\n ) : error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : candidates === null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not looked for yet. A record with a different name can still be ' +\n 'merged from the menu above.'}\n </Typography>\n ) : candidates.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No likely duplicates among the contacts you can see.'}\n </Typography>\n ) : (\n <List dense disablePadding aria-label=\"Likely duplicates\">\n {candidates.map((candidate) => (\n <ListItem\n key={candidate.id}\n disableGutters\n secondaryAction={\n suiteLocked ? (\n <CrmSuiteLockedButton>{'Merge into this record'}</CrmSuiteLockedButton>\n ) : (\n <Button size=\"small\" onClick={() => onMerge(candidate)}>\n {'Merge into this record'}\n </Button>\n )\n }\n >\n <ListItemText\n primary={\n <AppLink href={routes.contact(candidate.id)} color=\"inherit\" underline=\"hover\">\n {contactDisplayName(candidate.doc, groupId) ||\n String(candidate.doc['email'] ?? candidate.id)}\n </AppLink>\n }\n secondary={\n <Stack\n component=\"span\"\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 0.5 }}\n >\n <span>{String(candidate.doc['email'] ?? '')}</span>\n {candidate.reasons.map((reason) => (\n <Chip\n key={reason}\n size=\"small\"\n variant=\"outlined\"\n label={DUPLICATE_REASON_LABELS[reason]}\n />\n ))}\n </Stack>\n }\n slotProps={{ secondary: { component: 'div' } }}\n />\n </ListItem>\n ))}\n </List>\n )}\n </CardDisplay>\n )\n}\nContactDuplicatesCard.displayName = 'ContactDuplicatesCard'\n\nexport default ContactDuplicatesCard\n"],"names":["contactDisplayName","pluginDocsHelp","AppLink","CardDisplay","useFirestore","Button","Chip","List","ListItem","ListItemText","Stack","Typography","collection","getDocs","limit","query","where","useCallback","useState","crmVisibleToClause","crmRoutes","CONTACT_DUPLICATES_LIMIT","DUPLICATE_REASON_LABELS","duplicateNameKey","likelyDuplicateReasons","CrmSuiteLockedButton","ContactDuplicatesCard","props","current","scope","consentGroup","visibleTo","basePath","onMerge","suiteLocked","firestore","routes","groupId","candidates","setCandidates","searching","setSearching","error","setError","nameKey","doc","find","found","docs","map","snapshot","id","data","pick","reasons","filter","length","findError","console","header","subheader","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","size","variant","onClick","disabled","color","dense","disablePadding","aria-label","candidate","disableGutters","secondaryAction","primary","href","contact","underline","String","secondary","component","direction","spacing","sx","alignItems","flexWrap","rowGap","span","reason","label","slotProps","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-duplicates-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 { type ConsentGroup, contactDisplayName, pluginDocsHelp } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Chip,\n List,\n ListItem,\n ListItemText,\n Stack,\n Typography,\n} from '@mui/material'\nimport { collection, getDocs, limit, query, where } from 'firebase/firestore'\nimport { useCallback, useState } from 'react'\nimport { crmVisibleToClause } from '../hooks/use-crm-scope'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n CONTACT_DUPLICATES_LIMIT,\n DUPLICATE_REASON_LABELS,\n type DuplicateReason,\n duplicateNameKey,\n likelyDuplicateReasons,\n} from '../model/contact-duplicates'\nimport type { ContactPick } from './contact-merge-dialog'\nimport { CrmSuiteLockedButton } from './crm-suite-lock'\n\nexport interface ContactDuplicatesCardProps {\n /** The record whose page this is. */\n current: ContactPick\n /** `['orgs', orgId]` — where the contacts live. */\n scope: readonly [string, string]\n consentGroup: ConsentGroup\n /**\n * What this viewer may list — the query's `array-contains-any` — or\n * `null` at the organization level, where the query carries no clause\n * (AGL-2630).\n */\n visibleTo: readonly string[] | null\n /** The hub's own path, for the link to a candidate's page. */\n basePath: string\n /** Opens the merge dialog with the candidate picked. */\n onMerge: (candidate: ContactPick) => void\n /**\n * The org's plan lacks the CRM (AGL-2788). Looking stays drawn; each\n * candidate's merge stands locked.\n */\n suiteLocked?: boolean\n}\n\ninterface Candidate extends ContactPick {\n reasons: DuplicateReason[]\n}\n\n/**\n * LIKELY DUPLICATES (AGL-2625) — on ask, never on mount.\n *\n * One bounded read when the button is pressed: the contacts this viewer may\n * list that carry the same normalized name, on the `(visibleTo, nameLower)`\n * index the list's own search uses. The rule — same name AND a shared phone\n * or company — is applied to that page, so a record with a different name\n * is never offered; a reader who knows two differently-named records are\n * one person merges them from the overflow menu instead.\n *\n * A button rather than a listener because the question costs a page of\n * PII, and most record pages are opened by somebody who did not ask it.\n */\nexport function ContactDuplicatesCard(props: ContactDuplicatesCardProps) {\n const {\n current,\n scope,\n consentGroup,\n visibleTo,\n basePath,\n onMerge,\n suiteLocked = false,\n } = props\n const firestore = useFirestore()\n const routes = crmRoutes(basePath)\n const groupId = consentGroup.groupId\n const [candidates, setCandidates] = useState<Candidate[] | null>(null)\n const [searching, setSearching] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n const nameKey = duplicateNameKey(current.doc, groupId)\n\n const find = useCallback(async () => {\n if (searching || !nameKey) return\n setSearching(true)\n setError(null)\n try {\n const found = await getDocs(\n query(\n collection(firestore, scope[0], scope[1], 'contacts'),\n ...crmVisibleToClause(visibleTo),\n where('nameLower', '==', nameKey),\n limit(CONTACT_DUPLICATES_LIMIT),\n ),\n )\n setCandidates(\n found.docs\n .map((snapshot) => ({ id: snapshot.id, doc: snapshot.data() }))\n .map((pick) => ({\n ...pick,\n reasons: likelyDuplicateReasons(current, pick, groupId),\n }))\n .filter((pick) => pick.reasons.length > 0),\n )\n } catch (findError) {\n console.error(findError)\n setCandidates(null)\n setError('The duplicates could not be looked for.')\n } finally {\n setSearching(false)\n }\n }, [searching, nameKey, firestore, scope, visibleTo, current, groupId])\n\n return (\n <CardDisplay\n header=\"Likely duplicates\"\n subheader=\"Other records with this name and the same phone or company.\"\n help={pluginDocsHelp('contactRecord', { anchor: '#likely-duplicates' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"outlined\"\n onClick={() => void find()}\n disabled={searching || !nameKey}\n >\n {searching ? 'Looking…' : 'Find likely duplicates'}\n </Button>\n ),\n }}\n >\n {!nameKey ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Give this contact a name first — duplicates are found by name.'}\n </Typography>\n ) : error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : candidates === null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not looked for yet. A record with a different name can still be ' +\n 'merged from the menu above.'}\n </Typography>\n ) : candidates.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No likely duplicates among the contacts you can see.'}\n </Typography>\n ) : (\n <List dense disablePadding aria-label=\"Likely duplicates\">\n {candidates.map((candidate) => (\n <ListItem\n key={candidate.id}\n disableGutters\n secondaryAction={\n suiteLocked ? (\n <CrmSuiteLockedButton>{'Merge into this record'}</CrmSuiteLockedButton>\n ) : (\n <Button size=\"small\" onClick={() => onMerge(candidate)}>\n {'Merge into this record'}\n </Button>\n )\n }\n >\n <ListItemText\n primary={\n <AppLink href={routes.contact(candidate.id)} color=\"inherit\" underline=\"hover\">\n {contactDisplayName(candidate.doc, groupId) ||\n String(candidate.doc['email'] ?? candidate.id)}\n </AppLink>\n }\n secondary={\n <Stack\n useFlexGap\n component=\"span\"\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 0.5 }}\n >\n <span>{String(candidate.doc['email'] ?? '')}</span>\n {candidate.reasons.map((reason) => (\n <Chip\n key={reason}\n size=\"small\"\n variant=\"outlined\"\n label={DUPLICATE_REASON_LABELS[reason]}\n />\n ))}\n </Stack>\n }\n slotProps={{ secondary: { component: 'div' } }}\n />\n </ListItem>\n ))}\n </List>\n )}\n </CardDisplay>\n )\n}\nContactDuplicatesCard.displayName = 'ContactDuplicatesCard'\n\nexport default ContactDuplicatesCard\n"],"names":["contactDisplayName","pluginDocsHelp","AppLink","CardDisplay","useFirestore","Button","Chip","List","ListItem","ListItemText","Stack","Typography","collection","getDocs","limit","query","where","useCallback","useState","crmVisibleToClause","crmRoutes","CONTACT_DUPLICATES_LIMIT","DUPLICATE_REASON_LABELS","duplicateNameKey","likelyDuplicateReasons","CrmSuiteLockedButton","ContactDuplicatesCard","props","current","scope","consentGroup","visibleTo","basePath","onMerge","suiteLocked","firestore","routes","groupId","candidates","setCandidates","searching","setSearching","error","setError","nameKey","doc","find","found","docs","map","snapshot","id","data","pick","reasons","filter","length","findError","console","header","subheader","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","size","variant","onClick","disabled","color","dense","disablePadding","aria-label","candidate","disableGutters","secondaryAction","primary","href","contact","underline","String","secondary","useFlexGap","component","direction","spacing","sx","alignItems","flexWrap","rowGap","span","reason","label","slotProps","displayName"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How many choices a select may declare, and how long each may be. */\nconst OPTIONS_MAX = 50\nconst OPTION_MAX_LENGTH = 120\nconst LABEL_MAX_LENGTH = 80\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */\nfunction parseOptions(text: string): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const line of text.split('\\n')) {\n const option = line.trim().slice(0, OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= OPTIONS_MAX) break\n }\n return options\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","OPTIONS_MAX","OPTION_MAX_LENGTH","LABEL_MAX_LENGTH","parseOptions","text","seen","Set","options","line","split","option","trim","slice","has","add","push","length","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","handleSubmit","caught","console","anchor","spacing","sx","width","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How many choices a select may declare, and how long each may be. */\nconst OPTIONS_MAX = 50\nconst OPTION_MAX_LENGTH = 120\nconst LABEL_MAX_LENGTH = 80\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */\nfunction parseOptions(text: string): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const line of text.split('\\n')) {\n const option = line.trim().slice(0, OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= OPTIONS_MAX) break\n }\n return options\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, maxWidth: '100vw', p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","OPTIONS_MAX","OPTION_MAX_LENGTH","LABEL_MAX_LENGTH","parseOptions","text","seen","Set","options","line","split","option","trim","slice","has","add","push","length","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","handleSubmit","caught","console","anchor","spacing","sx","width","maxWidth","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-known-by-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 {\n orgContactConsentLabel,\n orgContactRow,\n pluginDocsHelp,\n type OrgContactHostConsent,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport type { CrmOrgDoc } from '../hooks/use-crm-scope'\n\nexport interface ContactKnownByCardProps {\n /** The document as read — the projection below is an allow-list over it. */\n row: Record<string, unknown>\n contactId: string\n /** The org document, the only input that can resolve a declared consent group. */\n org: CrmOrgDoc\n}\n\n/**\n * One consent verdict, rendered.\n *\n * The label always names the controller — `orgContactConsentLabel` is what\n * guarantees that — and the color follows the basis rather than replacing\n * it: a green chip with no name on it would be a bare \"consented\", which is\n * the claim the per-brand model exists to stop anybody making.\n */\nfunction ConsentChip(props: { entry: OrgContactHostConsent; siteName: string }) {\n const { entry, siteName } = props\n return (\n <Chip\n size=\"small\"\n color={\n entry.basis === 'granted'\n ? 'success'\n : entry.basis === 'declined'\n ? 'error'\n : 'default'\n }\n variant={entry.basis === 'unrecorded' ? 'outlined' : 'filled'}\n label={orgContactConsentLabel(entry, siteName)}\n />\n )\n}\nConsentChip.displayName = 'ConsentChip'\n\n/**\n * KNOWN BY — the cross-site fact, on the organization-level record\n * (AGL-2630).\n *\n * A contact document is shared by every site that has captured the person:\n * one human who touched two of an org's sites is ONE row, which is the\n * dedupe the shared address book exists for and what makes the billing unit\n * — unique people per org — mean anything. Under a site that fact is\n * invisible on purpose: a site's hub is one holder's view. This card is the\n * one place it is shown, and it shows exactly what the old org-level\n * address book showed and nothing more: which sites know the person, and\n * the person's marketing consent FOR EACH of those sites — one verdict per\n * capturing site, each naming the controller it was read for, because a\n * basis is per (person, controller) and \"consented\" with nobody attached\n * would be a different and false claim.\n *\n * Notes, tags, activity and commercial figures are each holder's own\n * records and are not projected here: `orgContactRow` is an allow-list for\n * that reason, and the card renders nothing it did not return. The rest of\n * the record page reads through the person's PRIMARY holder; this card is\n * how a reader learns which other sites hold a profile, and the link on\n * each site is how they open it there.\n *\n * Rendered only beneath the org mount — under a site the hook answers\n * `null` and so does the card.\n */\nexport function ContactKnownByCard(props: ContactKnownByCardProps) {\n const { row, contactId, org } = props\n const mount = useCrmOrgMount()\n const projected = useMemo(\n () => orgContactRow(row, contactId, (org ?? null) as Record<string, unknown> | null),\n [row, contactId, org],\n )\n if (!mount) return null\n const { capturedByHostIds, consent } = projected\n return (\n <CardDisplay\n header={'Known by'}\n help={pluginDocsHelp('contacts', { anchor: '#at-the-organization-level' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {capturedByHostIds.length ? (\n <Stack spacing={1.5}>\n {capturedByHostIds.map((hostId) => {\n const entry = consent.find((verdict) => verdict.hostId === hostId)\n const href = mount.siteHubHref(hostId)\n const name = mount.siteName(hostId)\n return (\n <Stack\n key={hostId}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n A site whose subdomain has not resolved must still be\n NAMED — the relationship is real, and hiding it would\n understate who knows this person — but not linked,\n because the route would not exist; a plain span, not an\n anchor to nowhere. Two elements are safe here: the card\n mounts only once the record's listener has answered, so\n there is no server render for a client render to disagree\n with.\n */}\n {href ? (\n <AppLink\n href={`${href}/contacts/${encodeURIComponent(contactId)}`}\n underline=\"hover\"\n >\n {name}\n </AppLink>\n ) : (\n <Typography component=\"span\" variant=\"body2\">\n {name}\n </Typography>\n )}\n {entry ? <ConsentChip entry={entry} siteName={name} /> : null}\n {entry?.declared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Consent given to ${entry.groupName ?? entry.groupId}, which ${name} is part of.`}\n </Typography>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n ) : (\n /*\n * Unattributed, not unknown. A row written before the attribution\n * existed names no site, and reading that as \"every site\" would\n * put a person in front of businesses that never met them.\n */\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n )}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The profile, notes, tags and timeline on this page are the first ' +\n \"capturing site's. Open the person in another site to see what \" +\n 'it holds.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nContactKnownByCard.displayName = 'ContactKnownByCard'\n\nexport default ContactKnownByCard\n"],"names":["orgContactConsentLabel","orgContactRow","pluginDocsHelp","AppLink","CardDisplay","Chip","Stack","Typography","useMemo","useCrmOrgMount","ConsentChip","props","entry","siteName","size","color","basis","variant","label","displayName","ContactKnownByCard","row","contactId","org","mount","projected","capturedByHostIds","consent","header","help","anchor","contentGutterX","contentGutterY","spacing","length","map","hostId","find","verdict","href","siteHubHref","name","direction","sx","alignItems","flexWrap","encodeURIComponent","underline","component","declared","groupName","groupId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,sBAAsB,EACtBC,aAAa,EACbC,cAAc,QAET,eAAc;AACrB,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACvD,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,cAAc,QAAQ,gCAA4B;AAW3D;;;;;;;CAOC,GACD,SAASC,YAAYC,KAAyD;IAC5E,MAAM,EAAEC,KAAK,EAAEC,QAAQ,EAAE,GAAGF;IAC5B,qBACE,KAACN;QACCS,MAAK;QACLC,OACEH,MAAMI,KAAK,KAAK,YACZ,YACAJ,MAAMI,KAAK,KAAK,aACd,UACA;QAERC,SAASL,MAAMI,KAAK,KAAK,eAAe,aAAa;QACrDE,OAAOlB,uBAAuBY,OAAOC;;AAG3C;AACAH,YAAYS,WAAW,GAAG;AAE1B;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,mBAAmBT,KAA8B;IAC/D,MAAM,EAAEU,GAAG,EAAEC,SAAS,EAAEC,GAAG,EAAE,GAAGZ;IAChC,MAAMa,QAAQf;IACd,MAAMgB,YAAYjB,QAChB,IAAMP,cAAcoB,KAAKC,WAAYC,cAAAA,MAAO,OAC5C;QAACF;QAAKC;QAAWC;KAAI;IAEvB,IAAI,CAACC,OAAO,OAAO;IACnB,MAAM,EAAEE,iBAAiB,EAAEC,OAAO,EAAE,GAAGF;IACvC,qBACE,KAACrB;QACCwB,QAAQ;QACRC,MAAM3B,eAAe,YAAY;YAAE4B,QAAQ;QAA6B;QACxEC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAC1B;YAAM2B,SAAS;;gBACbP,kBAAkBQ,MAAM,iBACvB,KAAC5B;oBAAM2B,SAAS;8BACbP,kBAAkBS,GAAG,CAAC,CAACC;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-known-by-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 {\n orgContactConsentLabel,\n orgContactRow,\n pluginDocsHelp,\n type OrgContactHostConsent,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport type { CrmOrgDoc } from '../hooks/use-crm-scope'\n\nexport interface ContactKnownByCardProps {\n /** The document as read — the projection below is an allow-list over it. */\n row: Record<string, unknown>\n contactId: string\n /** The org document, the only input that can resolve a declared consent group. */\n org: CrmOrgDoc\n}\n\n/**\n * One consent verdict, rendered.\n *\n * The label always names the controller — `orgContactConsentLabel` is what\n * guarantees that — and the color follows the basis rather than replacing\n * it: a green chip with no name on it would be a bare \"consented\", which is\n * the claim the per-brand model exists to stop anybody making.\n */\nfunction ConsentChip(props: { entry: OrgContactHostConsent; siteName: string }) {\n const { entry, siteName } = props\n return (\n <Chip\n size=\"small\"\n color={\n entry.basis === 'granted'\n ? 'success'\n : entry.basis === 'declined'\n ? 'error'\n : 'default'\n }\n variant={entry.basis === 'unrecorded' ? 'outlined' : 'filled'}\n label={orgContactConsentLabel(entry, siteName)}\n />\n )\n}\nConsentChip.displayName = 'ConsentChip'\n\n/**\n * KNOWN BY — the cross-site fact, on the organization-level record\n * (AGL-2630).\n *\n * A contact document is shared by every site that has captured the person:\n * one human who touched two of an org's sites is ONE row, which is the\n * dedupe the shared address book exists for and what makes the billing unit\n * — unique people per org — mean anything. Under a site that fact is\n * invisible on purpose: a site's hub is one holder's view. This card is the\n * one place it is shown, and it shows exactly what the old org-level\n * address book showed and nothing more: which sites know the person, and\n * the person's marketing consent FOR EACH of those sites — one verdict per\n * capturing site, each naming the controller it was read for, because a\n * basis is per (person, controller) and \"consented\" with nobody attached\n * would be a different and false claim.\n *\n * Notes, tags, activity and commercial figures are each holder's own\n * records and are not projected here: `orgContactRow` is an allow-list for\n * that reason, and the card renders nothing it did not return. The rest of\n * the record page reads through the person's PRIMARY holder; this card is\n * how a reader learns which other sites hold a profile, and the link on\n * each site is how they open it there.\n *\n * Rendered only beneath the org mount — under a site the hook answers\n * `null` and so does the card.\n */\nexport function ContactKnownByCard(props: ContactKnownByCardProps) {\n const { row, contactId, org } = props\n const mount = useCrmOrgMount()\n const projected = useMemo(\n () => orgContactRow(row, contactId, (org ?? null) as Record<string, unknown> | null),\n [row, contactId, org],\n )\n if (!mount) return null\n const { capturedByHostIds, consent } = projected\n return (\n <CardDisplay\n header={'Known by'}\n help={pluginDocsHelp('contacts', { anchor: '#at-the-organization-level' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {capturedByHostIds.length ? (\n <Stack spacing={1.5}>\n {capturedByHostIds.map((hostId) => {\n const entry = consent.find((verdict) => verdict.hostId === hostId)\n const href = mount.siteHubHref(hostId)\n const name = mount.siteName(hostId)\n return (\n <Stack\n useFlexGap\n key={hostId}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n A site whose subdomain has not resolved must still be\n NAMED — the relationship is real, and hiding it would\n understate who knows this person — but not linked,\n because the route would not exist; a plain span, not an\n anchor to nowhere. Two elements are safe here: the card\n mounts only once the record's listener has answered, so\n there is no server render for a client render to disagree\n with.\n */}\n {href ? (\n <AppLink\n href={`${href}/contacts/${encodeURIComponent(contactId)}`}\n underline=\"hover\"\n >\n {name}\n </AppLink>\n ) : (\n <Typography component=\"span\" variant=\"body2\">\n {name}\n </Typography>\n )}\n {entry ? <ConsentChip entry={entry} siteName={name} /> : null}\n {entry?.declared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Consent given to ${entry.groupName ?? entry.groupId}, which ${name} is part of.`}\n </Typography>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n ) : (\n /*\n * Unattributed, not unknown. A row written before the attribution\n * existed names no site, and reading that as \"every site\" would\n * put a person in front of businesses that never met them.\n */\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n )}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The profile, notes, tags and timeline on this page are the first ' +\n \"capturing site's. Open the person in another site to see what \" +\n 'it holds.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nContactKnownByCard.displayName = 'ContactKnownByCard'\n\nexport default ContactKnownByCard\n"],"names":["orgContactConsentLabel","orgContactRow","pluginDocsHelp","AppLink","CardDisplay","Chip","Stack","Typography","useMemo","useCrmOrgMount","ConsentChip","props","entry","siteName","size","color","basis","variant","label","displayName","ContactKnownByCard","row","contactId","org","mount","projected","capturedByHostIds","consent","header","help","anchor","contentGutterX","contentGutterY","spacing","length","map","hostId","find","verdict","href","siteHubHref","name","useFlexGap","direction","sx","alignItems","flexWrap","encodeURIComponent","underline","component","declared","groupName","groupId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,sBAAsB,EACtBC,aAAa,EACbC,cAAc,QAET,eAAc;AACrB,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACvD,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,cAAc,QAAQ,gCAA4B;AAW3D;;;;;;;CAOC,GACD,SAASC,YAAYC,KAAyD;IAC5E,MAAM,EAAEC,KAAK,EAAEC,QAAQ,EAAE,GAAGF;IAC5B,qBACE,KAACN;QACCS,MAAK;QACLC,OACEH,MAAMI,KAAK,KAAK,YACZ,YACAJ,MAAMI,KAAK,KAAK,aACd,UACA;QAERC,SAASL,MAAMI,KAAK,KAAK,eAAe,aAAa;QACrDE,OAAOlB,uBAAuBY,OAAOC;;AAG3C;AACAH,YAAYS,WAAW,GAAG;AAE1B;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,mBAAmBT,KAA8B;IAC/D,MAAM,EAAEU,GAAG,EAAEC,SAAS,EAAEC,GAAG,EAAE,GAAGZ;IAChC,MAAMa,QAAQf;IACd,MAAMgB,YAAYjB,QAChB,IAAMP,cAAcoB,KAAKC,WAAYC,cAAAA,MAAO,OAC5C;QAACF;QAAKC;QAAWC;KAAI;IAEvB,IAAI,CAACC,OAAO,OAAO;IACnB,MAAM,EAAEE,iBAAiB,EAAEC,OAAO,EAAE,GAAGF;IACvC,qBACE,KAACrB;QACCwB,QAAQ;QACRC,MAAM3B,eAAe,YAAY;YAAE4B,QAAQ;QAA6B;QACxEC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAC1B;YAAM2B,SAAS;;gBACbP,kBAAkBQ,MAAM,iBACvB,KAAC5B;oBAAM2B,SAAS;8BACbP,kBAAkBS,GAAG,CAAC,CAACC;4BAqCOxB;wBApC7B,MAAMA,QAAQe,QAAQU,IAAI,CAAC,CAACC,UAAYA,QAAQF,MAAM,KAAKA;wBAC3D,MAAMG,OAAOf,MAAMgB,WAAW,CAACJ;wBAC/B,MAAMK,OAAOjB,MAAMX,QAAQ,CAACuB;wBAC5B,qBACE,MAAC9B;4BACCoC,UAAU;4BAEVC,WAAU;4BACVV,SAAS;4BACTW,IAAI;gCAAEC,YAAY;gCAAUC,UAAU;4BAAO;;gCAY5CP,qBACC,KAACpC;oCACCoC,MAAM,GAAGA,KAAK,UAAU,EAAEQ,mBAAmBzB,YAAY;oCACzD0B,WAAU;8CAETP;mDAGH,KAAClC;oCAAW0C,WAAU;oCAAOhC,SAAQ;8CAClCwB;;gCAGJ7B,sBAAQ,KAACF;oCAAYE,OAAOA;oCAAOC,UAAU4B;qCAAW;gCACxD7B,CAAAA,yBAAAA,MAAOsC,QAAQ,kBACd,KAAC3C;oCAAWU,SAAQ;oCAAUF,OAAM;8CACjC,CAAC,iBAAiB,GAAEH,mBAAAA,MAAMuC,SAAS,YAAfvC,mBAAmBA,MAAMwC,OAAO,CAAC,QAAQ,EAAEX,KAAK,YAAY,CAAC;qCAElF;;2BAhCCL;oBAmCX;qBAGF;;;;WAIC,iBACD,KAAC7B;oBAAWU,SAAQ;oBAAQF,OAAM;8BAC/B;;8BAGL,KAACR;oBAAWU,SAAQ;oBAAUF,OAAM;8BACjC,sEACC,mEACA;;;;;AAKZ;AACAK,mBAAmBD,WAAW,GAAG;AAEjC,eAAeC,mBAAkB"}
|
|
@@ -84,6 +84,7 @@ import { useContactCampaignEmails } from "./use-contact-campaign-emails.js";
|
|
|
84
84
|
}),
|
|
85
85
|
title: (_interaction_summary = interaction.summary) != null ? _interaction_summary : label,
|
|
86
86
|
meta: /*#__PURE__*/ _jsxs(Stack, {
|
|
87
|
+
useFlexGap: true,
|
|
87
88
|
direction: "row",
|
|
88
89
|
spacing: 1,
|
|
89
90
|
sx: {
|
|
@@ -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 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","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 { 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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"}
|
|
@@ -22,9 +22,10 @@ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
|
|
|
22
22
|
import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
|
|
23
23
|
import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
|
|
24
24
|
import ListQueryNotices from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
|
|
25
|
-
import { CONTACT_LEAD_SOURCE_COLUMN, CONTACT_LIST_DECLARATION, CONTACT_LIST_FILTER_FIELDS, CONTACT_LIST_FILTER_HEADERS, CONTACT_PREFIX_SEARCH, contactClauseImpliesScope, contactSoloClause, contactCustomFilterFields, contactCustomFilterHeaders, contactQueryClause } from "../constants/contact-filters.js";
|
|
25
|
+
import { CONTACT_LEAD_SOURCE_COLUMN, CONTACT_LIST_DECLARATION, CONTACT_LIST_FILTER_FIELDS, CONTACT_LIST_FILTER_HEADERS, CONTACT_LIST_SORTS, CONTACT_PREFIX_SEARCH, contactClauseImpliesScope, contactSoloClause, contactCustomFilterFields, contactCustomFilterHeaders, contactQueryClause } from "../constants/contact-filters.js";
|
|
26
26
|
import { crmAskClauses, crmQueryRefusals } from "../model/crm-list-query.js";
|
|
27
27
|
import { useCrmFoldsScope, useCrmListQuery } from "../hooks/use-crm-list-query.js";
|
|
28
|
+
import { crmViewQuerySort, useCrmColumnSort } from "../hooks/use-crm-column-sort.js";
|
|
28
29
|
import { contactPrimaryGroup, contactRecordFromDoc } from "../model/contact-record.js";
|
|
29
30
|
import { useCrmOrgMount } from "../hooks/use-crm-org-mount.js";
|
|
30
31
|
import { useCrmSharingRules } from "../hooks/use-crm-sharing.js";
|
|
@@ -68,6 +69,12 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
68
69
|
* The map lives beside the `ContactSource` union so the dynamic-list rule
|
|
69
70
|
* editor and this filter cannot disagree about what `order` is called.
|
|
70
71
|
*/ const SOURCE_LABELS = CONTACT_SOURCE_LABELS;
|
|
72
|
+
/**
|
|
73
|
+
* The page sorts a column needs other than its cell's text (AGL-3680): the
|
|
74
|
+
* stage by its place in the lifecycle, not its label's spelling.
|
|
75
|
+
*/ const CONTACT_PAGE_SORTS = {
|
|
76
|
+
lifecycleStage: (row)=>row.lifecycleStage ? CONTACT_LIFECYCLE_STAGES.indexOf(row.lifecycleStage) : null
|
|
77
|
+
};
|
|
71
78
|
/**
|
|
72
79
|
* What the list keeps out of sight until a view says otherwise: the
|
|
73
80
|
* filter-only columns, which never show, and the optional columns
|
|
@@ -278,6 +285,10 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
278
285
|
]);
|
|
279
286
|
// The quick search is the grid's box, answered by the same query.
|
|
280
287
|
const [searchWords, setSearchWords] = useState([]);
|
|
288
|
+
// The header order the view asks (AGL-3680), read before the query it orders.
|
|
289
|
+
const contactSort = useMemo(()=>crmViewQuerySort(CONTACT_LIST_SORTS, views.state.sort, CONTACT_LIST_SORTS[0]), [
|
|
290
|
+
views.state.sort
|
|
291
|
+
]);
|
|
281
292
|
const paged = useCrmListQuery({
|
|
282
293
|
scope: dataScope,
|
|
283
294
|
collection: 'contacts',
|
|
@@ -288,7 +299,8 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
288
299
|
search: searchWords,
|
|
289
300
|
impliesScope,
|
|
290
301
|
soloClause: contactSoloClause,
|
|
291
|
-
prefixSearch: CONTACT_PREFIX_SEARCH
|
|
302
|
+
prefixSearch: CONTACT_PREFIX_SEARCH,
|
|
303
|
+
sort: contactSort.sort
|
|
292
304
|
});
|
|
293
305
|
const contactDocs = paged.rows;
|
|
294
306
|
const contactsStatus = paged.status;
|
|
@@ -591,6 +603,20 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
591
603
|
});
|
|
592
604
|
const grid = useCrmViewGrid(views, filterColumns, HIDDEN_COLUMNS);
|
|
593
605
|
/*
|
|
606
|
+
* EVERY HEADER SORTS (AGL-3680): the stored ones on the query
|
|
607
|
+
* (`CONTACT_LIST_SORTS`), the holder's facet values and the joined names
|
|
608
|
+
* over the page, saying so — the stage by its place in the lifecycle.
|
|
609
|
+
*/ const columnSort = useCrmColumnSort({
|
|
610
|
+
views,
|
|
611
|
+
sorts: CONTACT_LIST_SORTS,
|
|
612
|
+
defaultSort: CONTACT_LIST_SORTS[0],
|
|
613
|
+
asked: contactSort,
|
|
614
|
+
orderBy: paged.plan.orderBy,
|
|
615
|
+
rows: contacts,
|
|
616
|
+
columns: grid.columns,
|
|
617
|
+
pageSorts: CONTACT_PAGE_SORTS
|
|
618
|
+
});
|
|
619
|
+
/*
|
|
594
620
|
* A SEGMENT IS A VIEW'S TAG AND SOURCE CLAUSES, kept where a campaign
|
|
595
621
|
* can reach them (AGL-2617).
|
|
596
622
|
*
|
|
@@ -860,7 +886,10 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
860
886
|
}) : null,
|
|
861
887
|
/*#__PURE__*/ _jsx(ListQueryNotices, {
|
|
862
888
|
refused: refused,
|
|
863
|
-
notices:
|
|
889
|
+
notices: [
|
|
890
|
+
...paged.plan.notices,
|
|
891
|
+
...columnSort.notices
|
|
892
|
+
]
|
|
864
893
|
}),
|
|
865
894
|
contacts.length === 0 && !views.state.filters.length && !searchWords.some((word)=>word.trim()) ? /*#__PURE__*/ _jsx(EmptyStateComponent, {
|
|
866
895
|
label: contactsStatus === 'loading' ? 'Loading contacts…' : 'No contacts yet',
|
|
@@ -916,7 +945,8 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
916
945
|
/*#__PURE__*/ _jsx(CrmColumnOrderProvider, {
|
|
917
946
|
value: grid.columnOrder,
|
|
918
947
|
children: /*#__PURE__*/ _jsx(ListTable, {
|
|
919
|
-
rows:
|
|
948
|
+
rows: columnSort.rows,
|
|
949
|
+
columnSort: columnSort,
|
|
920
950
|
columns: grid.columns,
|
|
921
951
|
slots: CRM_LIST_SLOTS,
|
|
922
952
|
selectable: {
|
|
@@ -930,8 +960,9 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
930
960
|
* grid must not filter again itself. Opening the panel is
|
|
931
961
|
* what reads the roster and the companies its pickers name.
|
|
932
962
|
* Columns are the view's, controlled so a saved arrangement
|
|
933
|
-
* is what the grid shows
|
|
934
|
-
*
|
|
963
|
+
* is what the grid shows. The headers sort through
|
|
964
|
+
* `columnSort` (AGL-3680): a stored column re-asks the
|
|
965
|
+
* query, any other sorts the page and says so.
|
|
935
966
|
*/ filterMode: "server",
|
|
936
967
|
filterModel: gridFilter.filterModel,
|
|
937
968
|
onFilterModelChange: gridFilter.onFilterModelChange,
|
|
@@ -942,8 +973,6 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
|
|
|
942
973
|
noRowsLabel: contactsStatus === 'loading' ? 'Loading…' : 'No contacts match these filters',
|
|
943
974
|
columnVisibilityModel: grid.columnVisibilityModel,
|
|
944
975
|
onColumnVisibilityModelChange: grid.onColumnVisibilityModelChange,
|
|
945
|
-
sortingMode: "server",
|
|
946
|
-
disableColumnSorting: true,
|
|
947
976
|
loading: contactsStatus === 'loading',
|
|
948
977
|
// Paged by the footer below, so the grid must not also slice.
|
|
949
978
|
hideFooter: true
|