@aglyn/plugins-crm 1.0.0-beta.230 → 1.0.0-beta.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/package.json +14 -13
  2. package/src/lib/components/activity-timeline.js +1 -0
  3. package/src/lib/components/activity-timeline.js.map +1 -1
  4. package/src/lib/components/assignment-rule-drawer.js +1 -0
  5. package/src/lib/components/assignment-rule-drawer.js.map +1 -1
  6. package/src/lib/components/contact-associations-card.js +1 -0
  7. package/src/lib/components/contact-associations-card.js.map +1 -1
  8. package/src/lib/components/contact-duplicates-card.js +1 -0
  9. package/src/lib/components/contact-duplicates-card.js.map +1 -1
  10. package/src/lib/components/contact-field-drawer.js +1 -0
  11. package/src/lib/components/contact-field-drawer.js.map +1 -1
  12. package/src/lib/components/contact-known-by-card.js +1 -0
  13. package/src/lib/components/contact-known-by-card.js.map +1 -1
  14. package/src/lib/components/contact-timeline-card.js +1 -0
  15. package/src/lib/components/contact-timeline-card.js.map +1 -1
  16. package/src/lib/components/crm-glance-card.js +1 -0
  17. package/src/lib/components/crm-glance-card.js.map +1 -1
  18. package/src/lib/components/crm-next-activity-column.d.ts +1 -13
  19. package/src/lib/components/crm-next-activity-column.js +1 -19
  20. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  21. package/src/lib/components/crm-record-header.js +2 -0
  22. package/src/lib/components/crm-record-header.js.map +1 -1
  23. package/src/lib/components/deals-section.js +1 -0
  24. package/src/lib/components/deals-section.js.map +1 -1
  25. package/src/lib/components/fields-section.js +4 -0
  26. package/src/lib/components/fields-section.js.map +1 -1
  27. package/src/lib/components/lead-history-card.js +1 -0
  28. package/src/lib/components/lead-history-card.js.map +1 -1
  29. package/src/lib/components/lead-status-options.d.ts +2 -14
  30. package/src/lib/components/lead-status-options.js +1 -13
  31. package/src/lib/components/lead-status-options.js.map +1 -1
  32. package/src/lib/components/lead-surfaces-note.js +1 -0
  33. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  34. package/src/lib/components/lead-unqualify-dialog.d.ts +1 -2
  35. package/src/lib/components/lead-unqualify-dialog.js +2 -1
  36. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  37. package/src/lib/components/linked-deals-card.js +1 -0
  38. package/src/lib/components/linked-deals-card.js.map +1 -1
  39. package/src/lib/components/picklist-values-card.js +1 -0
  40. package/src/lib/components/picklist-values-card.js.map +1 -1
  41. package/src/lib/components/recipes-card.js +1 -0
  42. package/src/lib/components/recipes-card.js.map +1 -1
  43. package/src/lib/components/reports/activity-card.js +1 -0
  44. package/src/lib/components/reports/activity-card.js.map +1 -1
  45. package/src/lib/components/reports/closed-deals-card.js +1 -0
  46. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  47. package/src/lib/components/reports/contacts-trend-card.js +1 -0
  48. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  49. package/src/lib/components/reports/lead-funnel-card.js +1 -0
  50. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  51. package/src/lib/components/reports/lead-source-card.js +1 -0
  52. package/src/lib/components/reports/lead-source-card.js.map +1 -1
  53. package/src/lib/components/reports/pipeline-card.js +1 -0
  54. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  55. package/src/lib/components/reports/source-conversion-card.js +1 -0
  56. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  57. package/src/lib/components/reports/tasks-card.js +1 -0
  58. package/src/lib/components/reports/tasks-card.js.map +1 -1
  59. package/src/lib/components/reports-section.js +2 -0
  60. package/src/lib/components/reports-section.js.map +1 -1
  61. package/src/lib/components/settings-section.d.ts +18 -0
  62. package/src/lib/components/settings-section.js +97 -1
  63. package/src/lib/components/settings-section.js.map +1 -1
  64. package/src/lib/components/tasks-calendar.js +1 -0
  65. package/src/lib/components/tasks-calendar.js.map +1 -1
  66. package/src/lib/constants/company-filters.d.ts +2 -2
  67. package/src/lib/constants/company-filters.js +1 -1
  68. package/src/lib/constants/company-filters.js.map +1 -1
  69. package/src/lib/constants/contact-filters.d.ts +3 -3
  70. package/src/lib/constants/contact-filters.js +2 -1
  71. package/src/lib/constants/contact-filters.js.map +1 -1
  72. package/src/lib/constants/deal-filters.d.ts +3 -3
  73. package/src/lib/constants/deal-filters.js +1 -1
  74. package/src/lib/constants/deal-filters.js.map +1 -1
  75. package/src/lib/constants/field-list-query.d.ts +1 -1
  76. package/src/lib/constants/field-list-query.js +1 -1
  77. package/src/lib/constants/field-list-query.js.map +1 -1
  78. package/src/lib/declarations.server.js +9 -0
  79. package/src/lib/declarations.server.js.map +1 -1
  80. package/src/lib/model/contacts-list-seed.d.ts +1 -1
  81. package/src/lib/model/contacts-list-seed.js.map +1 -1
  82. package/src/lib/model/crm-list-query.d.ts +4 -4
  83. package/src/lib/model/crm-list-query.js +2 -2
  84. package/src/lib/model/crm-list-query.js.map +1 -1
  85. package/src/lib/model/crm-next-activity.d.ts +13 -0
  86. package/src/lib/model/crm-next-activity.js +19 -0
  87. package/src/lib/model/crm-next-activity.js.map +1 -1
  88. package/src/lib/model/lead-filters.d.ts +6 -5
  89. package/src/lib/model/lead-filters.js +3 -2
  90. package/src/lib/model/lead-filters.js.map +1 -1
  91. package/src/lib/model/lead-status-choices.d.ts +32 -0
  92. package/src/lib/model/lead-status-choices.js +31 -0
  93. package/src/lib/model/lead-status-choices.js.map +1 -0
  94. package/src/lib/model/task-views.d.ts +2 -2
  95. package/src/lib/model/task-views.js.map +1 -1
  96. package/src/lib/server/person-records.d.ts +27 -0
  97. package/src/lib/server/person-records.js +127 -4
  98. package/src/lib/server/person-records.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-crm",
3
- "version": "1.0.0-beta.230",
3
+ "version": "1.0.0-beta.232",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.230",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.230",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.230",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.230",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.230",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.230",
34
- "@aglyn/shared-util-email": "1.0.0-beta.230",
35
- "@aglyn/shared-util-http": "1.0.0-beta.230",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.230",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.230",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.230",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.230",
28
+ "@aglyn/aglyn": "1.0.0-beta.232",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.232",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.232",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.232",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.232",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.232",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.232",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.232",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.232",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.232",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.232",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.232",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.232",
40
41
  "@dnd-kit/core": "^6.3.1",
41
42
  "@dnd-kit/utilities": "^3.2.2",
42
43
  "@swc/helpers": "0.5.23",
@@ -182,6 +182,7 @@ TimelineExpandAllButton.displayName = 'TimelineExpandAllButton';
182
182
  },
183
183
  children: [
184
184
  chips ? /*#__PURE__*/ _jsx(Stack, {
185
+ useFlexGap: true,
185
186
  direction: "row",
186
187
  spacing: 1,
187
188
  sx: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-timeline.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 mdiChevronDown,\n mdiChevronRight,\n mdiUnfoldLessHorizontal,\n mdiUnfoldMoreHorizontal,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport type { ListPaginationProps } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { Box, Button, Collapse, IconButton, Stack, Typography } from '@mui/material'\nimport { type ReactNode, useCallback, useEffect, useId, useMemo, useState } from 'react'\n\n/*\n * THE ROW GRAMMAR OF EVERY CRM HISTORY: the Timeline on a contact, the\n * Activity card on a lead, a company and a deal, and the Recent activity\n * feed under the contacts list.\n *\n * Salesforce's activity timeline is the model. Each entry is a collapsed\n * row — a chevron, the kind, a one-line heading and the who/whom/when under\n * it — that opens in place to what it holds: an email's body, a note's whole\n * text. The card header carries \"Expand all\", the list is paged by the\n * console's own footer, and nothing a row showed before is dropped: what\n * does not fit on the collapsed row is in the opened one.\n */\n\n/** Which entries of one history are open. */\nexport interface TimelineExpansion {\n isExpanded: (key: string) => boolean\n toggle: (key: string) => void\n /** Every entry is open and none has been closed since. */\n allExpanded: boolean\n setAllExpanded: (expanded: boolean) => void\n}\n\n/**\n * The open/closed state of a history's entries, held by the CARD so that\n * the header's \"Expand all\" and each row's chevron act on one state.\n *\n * Kept as a default plus the keys that differ from it, so \"Expand all\"\n * reaches the entries of pages the reader has not turned to yet, and a row\n * closed after it stays closed.\n */\nexport function useTimelineExpansion(): TimelineExpansion {\n const [state, setState] = useState<{ all: boolean; flipped: ReadonlySet<string> }>(\n () => ({ all: false, flipped: new Set<string>() }),\n )\n const isExpanded = useCallback(\n (key: string) => state.all !== state.flipped.has(key),\n [state],\n )\n const toggle = useCallback((key: string) => {\n setState((current) => {\n const flipped = new Set(current.flipped)\n if (flipped.has(key)) flipped.delete(key)\n else flipped.add(key)\n return { all: current.all, flipped }\n })\n }, [])\n const setAllExpanded = useCallback((all: boolean) => {\n setState({ all, flipped: new Set<string>() })\n }, [])\n return useMemo(\n () => ({\n isExpanded,\n toggle,\n allExpanded: state.all && state.flipped.size === 0,\n setAllExpanded,\n }),\n [isExpanded, toggle, state, setAllExpanded],\n )\n}\n\n/** The card header's \"Expand all\" / \"Collapse all\". */\nexport function TimelineExpandAllButton(props: {\n expansion: TimelineExpansion\n disabled?: boolean\n}) {\n const { expansion, disabled } = props\n const open = expansion.allExpanded\n return (\n <Button\n size=\"small\"\n disabled={disabled}\n startIcon={\n <MdiIcon\n path={(open ? mdiUnfoldLessHorizontal : mdiUnfoldMoreHorizontal).path}\n size={0.8}\n />\n }\n onClick={() => expansion.setAllExpanded(!open)}\n >\n {open ? 'Collapse all' : 'Expand all'}\n </Button>\n )\n}\nTimelineExpandAllButton.displayName = 'TimelineExpandAllButton'\n\n/** The listener window a history pages over. */\nexport interface TimelineWindow {\n /** A further page exists past the rows the listener holds. */\n hasMore?: boolean\n /** Widens the listener by one step. */\n showMore?: () => void\n /** The listener is reading; the page asked for is held rather than pulled back. */\n loading?: boolean\n}\n\n/**\n * Newest-first pages of a history, over rows a listener window holds.\n *\n * The window is the query's bound — a hundred activities on a record, a\n * page's worth on the feed — and a page the reader turns to past what it\n * holds widens it, so the footer's Next is live while the probe row says\n * more exists and the count stays \"of more than\" until it does not. Paged\n * in memory otherwise: the contact timeline merges three sources that share\n * no cursor, so only a slice of the merged stream can be turned.\n */\nexport function useTimelinePages(total: number, reader: TimelineWindow = {}) {\n const { hasMore = false, showMore, loading = false } = reader\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1)\n // A page left empty by a delete shows the last one with rows; while the\n // window is reading, or holds less than exists, the page asked for stands.\n const shown = hasMore || loading ? page : Math.min(page, lastPage)\n const start = shown * pageSize\n const short = start + pageSize > total\n useEffect(() => {\n if (short && hasMore && !loading && showMore) showMore()\n }, [short, hasMore, loading, showMore])\n const slice = useCallback(\n <T,>(rows: readonly T[]): T[] => rows.slice(start, start + pageSize),\n [start, pageSize],\n )\n const footer: ListPaginationProps = {\n page: shown,\n pageSize,\n rowCount: Math.max(0, Math.min(pageSize, total - start)),\n count: hasMore ? undefined : total,\n hasMore,\n onPageChange: setPage,\n onPageSizeChange: setPageSize,\n }\n return { page: shown, pageSize, slice, footer }\n}\n\nexport interface TimelineEntryProps {\n /** The glyph for the entry's kind. */\n icon: ReactNode\n /** The chips that say what the entry is: its kind, its direction, its delivery state. */\n chips?: ReactNode\n /** The one line a collapsed entry reads as: a subject, a summary, a note's first line. */\n title?: ReactNode\n /** A subject reads as one; a note's own words as body text. */\n titleVariant?: 'subtitle2' | 'body2'\n /** The line under it: who, to or from whom, when. */\n meta?: ReactNode\n /** What the entry opens to. Absent, there is nothing more to show and no chevron. */\n body?: ReactNode\n /** Controls on this one entry — edit, delete. */\n actions?: ReactNode\n expanded?: boolean\n onToggle?: () => void\n /** Names the chevron, \"Expand {label}\", so a screen reader knows which entry. */\n label?: string\n}\n\n/**\n * One entry of a history: collapsed to its heading and caption, opened in\n * place to its body.\n *\n * An entry with no body has no chevron — a spacer keeps its glyph in line\n * with the rows that have one — and its heading wraps rather than\n * truncating, since there is nowhere else to read the rest of it.\n */\nexport function TimelineEntry(props: TimelineEntryProps) {\n const {\n icon,\n chips,\n title,\n titleVariant = 'body2',\n meta,\n body,\n actions,\n expanded = false,\n onToggle,\n label,\n } = props\n const bodyId = useId()\n const expandable = Boolean(body) && Boolean(onToggle)\n return (\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'flex-start' }}\n data-testid=\"timeline-entry\"\n data-expanded={expandable ? String(expanded) : undefined}\n >\n {expandable ? (\n <IconButton\n size=\"small\"\n aria-expanded={expanded}\n aria-controls={bodyId}\n aria-label={`${expanded ? 'Collapse' : 'Expand'} ${label ?? 'entry'}`}\n onClick={onToggle}\n sx={{ mt: -0.25 }}\n >\n <MdiIcon path={(expanded ? mdiChevronDown : mdiChevronRight).path} size={0.8} />\n </IconButton>\n ) : (\n <Box aria-hidden sx={{ width: 30, flexShrink: 0 }} />\n )}\n <Stack\n sx={{\n color: 'text.secondary',\n pt: 0.25,\n fontSize: (theme) => theme.typography.h6.fontSize,\n }}\n >\n {icon}\n </Stack>\n <Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>\n {chips ? (\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 0.5 }}\n >\n {chips}\n </Stack>\n ) : null}\n {title ? (\n <Typography\n variant={titleVariant}\n component=\"div\"\n noWrap={expandable && !expanded}\n sx={{ overflowWrap: 'anywhere' }}\n >\n {title}\n </Typography>\n ) : null}\n {meta}\n {expandable ? (\n <Collapse in={expanded} unmountOnExit>\n <Box id={bodyId}>{body}</Box>\n </Collapse>\n ) : (\n body\n )}\n </Stack>\n {actions ? (\n <Stack direction=\"row\" spacing={0.5}>\n {actions}\n </Stack>\n ) : null}\n </Stack>\n )\n}\nTimelineEntry.displayName = 'TimelineEntry'\n"],"names":["mdiChevronDown","mdiChevronRight","mdiUnfoldLessHorizontal","mdiUnfoldMoreHorizontal","MdiIcon","TABLE_PAGE_SIZE_DEFAULT","Box","Button","Collapse","IconButton","Stack","Typography","useCallback","useEffect","useId","useMemo","useState","useTimelineExpansion","state","setState","all","flipped","Set","isExpanded","key","has","toggle","current","delete","add","setAllExpanded","allExpanded","size","TimelineExpandAllButton","props","expansion","disabled","open","startIcon","path","onClick","displayName","useTimelinePages","total","reader","hasMore","showMore","loading","page","setPage","pageSize","setPageSize","lastPage","Math","max","ceil","shown","min","start","short","slice","rows","footer","rowCount","count","undefined","onPageChange","onPageSizeChange","TimelineEntry","icon","chips","title","titleVariant","meta","body","actions","expanded","onToggle","label","bodyId","expandable","Boolean","direction","spacing","sx","alignItems","data-testid","data-expanded","String","aria-expanded","aria-controls","aria-label","mt","aria-hidden","width","flexShrink","color","pt","fontSize","theme","typography","h6","flex","minWidth","flexWrap","rowGap","variant","component","noWrap","overflowWrap","in","unmountOnExit","id"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-timeline.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 mdiChevronDown,\n mdiChevronRight,\n mdiUnfoldLessHorizontal,\n mdiUnfoldMoreHorizontal,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport type { ListPaginationProps } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { Box, Button, Collapse, IconButton, Stack, Typography } from '@mui/material'\nimport { type ReactNode, useCallback, useEffect, useId, useMemo, useState } from 'react'\n\n/*\n * THE ROW GRAMMAR OF EVERY CRM HISTORY: the Timeline on a contact, the\n * Activity card on a lead, a company and a deal, and the Recent activity\n * feed under the contacts list.\n *\n * Salesforce's activity timeline is the model. Each entry is a collapsed\n * row — a chevron, the kind, a one-line heading and the who/whom/when under\n * it — that opens in place to what it holds: an email's body, a note's whole\n * text. The card header carries \"Expand all\", the list is paged by the\n * console's own footer, and nothing a row showed before is dropped: what\n * does not fit on the collapsed row is in the opened one.\n */\n\n/** Which entries of one history are open. */\nexport interface TimelineExpansion {\n isExpanded: (key: string) => boolean\n toggle: (key: string) => void\n /** Every entry is open and none has been closed since. */\n allExpanded: boolean\n setAllExpanded: (expanded: boolean) => void\n}\n\n/**\n * The open/closed state of a history's entries, held by the CARD so that\n * the header's \"Expand all\" and each row's chevron act on one state.\n *\n * Kept as a default plus the keys that differ from it, so \"Expand all\"\n * reaches the entries of pages the reader has not turned to yet, and a row\n * closed after it stays closed.\n */\nexport function useTimelineExpansion(): TimelineExpansion {\n const [state, setState] = useState<{ all: boolean; flipped: ReadonlySet<string> }>(\n () => ({ all: false, flipped: new Set<string>() }),\n )\n const isExpanded = useCallback(\n (key: string) => state.all !== state.flipped.has(key),\n [state],\n )\n const toggle = useCallback((key: string) => {\n setState((current) => {\n const flipped = new Set(current.flipped)\n if (flipped.has(key)) flipped.delete(key)\n else flipped.add(key)\n return { all: current.all, flipped }\n })\n }, [])\n const setAllExpanded = useCallback((all: boolean) => {\n setState({ all, flipped: new Set<string>() })\n }, [])\n return useMemo(\n () => ({\n isExpanded,\n toggle,\n allExpanded: state.all && state.flipped.size === 0,\n setAllExpanded,\n }),\n [isExpanded, toggle, state, setAllExpanded],\n )\n}\n\n/** The card header's \"Expand all\" / \"Collapse all\". */\nexport function TimelineExpandAllButton(props: {\n expansion: TimelineExpansion\n disabled?: boolean\n}) {\n const { expansion, disabled } = props\n const open = expansion.allExpanded\n return (\n <Button\n size=\"small\"\n disabled={disabled}\n startIcon={\n <MdiIcon\n path={(open ? mdiUnfoldLessHorizontal : mdiUnfoldMoreHorizontal).path}\n size={0.8}\n />\n }\n onClick={() => expansion.setAllExpanded(!open)}\n >\n {open ? 'Collapse all' : 'Expand all'}\n </Button>\n )\n}\nTimelineExpandAllButton.displayName = 'TimelineExpandAllButton'\n\n/** The listener window a history pages over. */\nexport interface TimelineWindow {\n /** A further page exists past the rows the listener holds. */\n hasMore?: boolean\n /** Widens the listener by one step. */\n showMore?: () => void\n /** The listener is reading; the page asked for is held rather than pulled back. */\n loading?: boolean\n}\n\n/**\n * Newest-first pages of a history, over rows a listener window holds.\n *\n * The window is the query's bound — a hundred activities on a record, a\n * page's worth on the feed — and a page the reader turns to past what it\n * holds widens it, so the footer's Next is live while the probe row says\n * more exists and the count stays \"of more than\" until it does not. Paged\n * in memory otherwise: the contact timeline merges three sources that share\n * no cursor, so only a slice of the merged stream can be turned.\n */\nexport function useTimelinePages(total: number, reader: TimelineWindow = {}) {\n const { hasMore = false, showMore, loading = false } = reader\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1)\n // A page left empty by a delete shows the last one with rows; while the\n // window is reading, or holds less than exists, the page asked for stands.\n const shown = hasMore || loading ? page : Math.min(page, lastPage)\n const start = shown * pageSize\n const short = start + pageSize > total\n useEffect(() => {\n if (short && hasMore && !loading && showMore) showMore()\n }, [short, hasMore, loading, showMore])\n const slice = useCallback(\n <T,>(rows: readonly T[]): T[] => rows.slice(start, start + pageSize),\n [start, pageSize],\n )\n const footer: ListPaginationProps = {\n page: shown,\n pageSize,\n rowCount: Math.max(0, Math.min(pageSize, total - start)),\n count: hasMore ? undefined : total,\n hasMore,\n onPageChange: setPage,\n onPageSizeChange: setPageSize,\n }\n return { page: shown, pageSize, slice, footer }\n}\n\nexport interface TimelineEntryProps {\n /** The glyph for the entry's kind. */\n icon: ReactNode\n /** The chips that say what the entry is: its kind, its direction, its delivery state. */\n chips?: ReactNode\n /** The one line a collapsed entry reads as: a subject, a summary, a note's first line. */\n title?: ReactNode\n /** A subject reads as one; a note's own words as body text. */\n titleVariant?: 'subtitle2' | 'body2'\n /** The line under it: who, to or from whom, when. */\n meta?: ReactNode\n /** What the entry opens to. Absent, there is nothing more to show and no chevron. */\n body?: ReactNode\n /** Controls on this one entry — edit, delete. */\n actions?: ReactNode\n expanded?: boolean\n onToggle?: () => void\n /** Names the chevron, \"Expand {label}\", so a screen reader knows which entry. */\n label?: string\n}\n\n/**\n * One entry of a history: collapsed to its heading and caption, opened in\n * place to its body.\n *\n * An entry with no body has no chevron — a spacer keeps its glyph in line\n * with the rows that have one — and its heading wraps rather than\n * truncating, since there is nowhere else to read the rest of it.\n */\nexport function TimelineEntry(props: TimelineEntryProps) {\n const {\n icon,\n chips,\n title,\n titleVariant = 'body2',\n meta,\n body,\n actions,\n expanded = false,\n onToggle,\n label,\n } = props\n const bodyId = useId()\n const expandable = Boolean(body) && Boolean(onToggle)\n return (\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'flex-start' }}\n data-testid=\"timeline-entry\"\n data-expanded={expandable ? String(expanded) : undefined}\n >\n {expandable ? (\n <IconButton\n size=\"small\"\n aria-expanded={expanded}\n aria-controls={bodyId}\n aria-label={`${expanded ? 'Collapse' : 'Expand'} ${label ?? 'entry'}`}\n onClick={onToggle}\n sx={{ mt: -0.25 }}\n >\n <MdiIcon path={(expanded ? mdiChevronDown : mdiChevronRight).path} size={0.8} />\n </IconButton>\n ) : (\n <Box aria-hidden sx={{ width: 30, flexShrink: 0 }} />\n )}\n <Stack\n sx={{\n color: 'text.secondary',\n pt: 0.25,\n fontSize: (theme) => theme.typography.h6.fontSize,\n }}\n >\n {icon}\n </Stack>\n <Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>\n {chips ? (\n <Stack\n useFlexGap\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 0.5 }}\n >\n {chips}\n </Stack>\n ) : null}\n {title ? (\n <Typography\n variant={titleVariant}\n component=\"div\"\n noWrap={expandable && !expanded}\n sx={{ overflowWrap: 'anywhere' }}\n >\n {title}\n </Typography>\n ) : null}\n {meta}\n {expandable ? (\n <Collapse in={expanded} unmountOnExit>\n <Box id={bodyId}>{body}</Box>\n </Collapse>\n ) : (\n body\n )}\n </Stack>\n {actions ? (\n <Stack direction=\"row\" spacing={0.5}>\n {actions}\n </Stack>\n ) : null}\n </Stack>\n )\n}\nTimelineEntry.displayName = 'TimelineEntry'\n"],"names":["mdiChevronDown","mdiChevronRight","mdiUnfoldLessHorizontal","mdiUnfoldMoreHorizontal","MdiIcon","TABLE_PAGE_SIZE_DEFAULT","Box","Button","Collapse","IconButton","Stack","Typography","useCallback","useEffect","useId","useMemo","useState","useTimelineExpansion","state","setState","all","flipped","Set","isExpanded","key","has","toggle","current","delete","add","setAllExpanded","allExpanded","size","TimelineExpandAllButton","props","expansion","disabled","open","startIcon","path","onClick","displayName","useTimelinePages","total","reader","hasMore","showMore","loading","page","setPage","pageSize","setPageSize","lastPage","Math","max","ceil","shown","min","start","short","slice","rows","footer","rowCount","count","undefined","onPageChange","onPageSizeChange","TimelineEntry","icon","chips","title","titleVariant","meta","body","actions","expanded","onToggle","label","bodyId","expandable","Boolean","direction","spacing","sx","alignItems","data-testid","data-expanded","String","aria-expanded","aria-controls","aria-label","mt","aria-hidden","width","flexShrink","color","pt","fontSize","theme","typography","h6","flex","minWidth","useFlexGap","flexWrap","rowGap","variant","component","noWrap","overflowWrap","in","unmountOnExit","id"],"mappings":"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"}
@@ -137,6 +137,7 @@ import { useCallback, useEffect, useState } from "react";
137
137
  spacing: 2,
138
138
  sx: {
139
139
  width: 380,
140
+ maxWidth: '100vw',
140
141
  p: 3
141
142
  },
142
143
  children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/assignment-rule-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_SOURCE_LABELS,\n type ContactSource,\n type CrmAssignmentRule,\n newAssignmentRuleId,\n readCrmAssignmentRule,\n crmMemberPickerLabel,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControl,\n FormControlLabel,\n FormHelperText,\n FormLabel,\n MenuItem,\n Radio,\n RadioGroup,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useState } from 'react'\nimport type { OrgMemberOption } from '../hooks/use-org-member-directory'\n\n/** The `value` of the source picker's \"any door\" entry. */\nconst ANY_SOURCE = ''\n\nexport interface AssignmentRuleDrawerProps {\n open: boolean\n onClose: () => void\n /** The roster the member target is picked from. */\n members: readonly OrgMemberOption[]\n membersLoading: boolean\n /** How many people the round-robin pool holds, for the target's helper text. */\n poolSize: number\n /** The ids the org already holds, so the new one is minted apart from them. */\n existingIds: readonly string[]\n /** Receives the rule; closing on success is the caller's job. */\n onSubmit: (rule: CrmAssignmentRule) => Promise<void> | void\n}\n\n/**\n * The one form an assignment rule is written through (AGL-2618).\n *\n * ## Every condition is optional, and none is a form above the list\n *\n * A rule names the captures it claims — a source, a form, an email domain,\n * a tag — and a field left blank is not a condition, so a rule with every\n * field blank is the catch-all, which is a rule a team writes on purpose\n * (\"everything else goes to Sam\"). The drawer says so rather than refusing\n * it. The email domain is typed the way a person sees one in an address,\n * `@acme.com` or `acme.com`, and the reader strips the `@`; what it cannot\n * read as a domain is refused here, with the field, rather than stored as\n * a condition nothing will ever match.\n *\n * ## The target is a member, or the rotation\n *\n * A member is picked from the roster by name, never typed as an address —\n * an address is resolved at run time and a rule must not depend on a\n * resolution that can change. The rotation is offered whatever the pool\n * holds, with the pool's size beside it: an empty pool is a rule the pass\n * steps over until somebody fills the pool, which the card below explains,\n * and refusing the rule here would make the two cards order-dependent.\n */\nexport function AssignmentRuleDrawer(props: AssignmentRuleDrawerProps) {\n const { open, onClose, members, membersLoading, poolSize, existingIds, onSubmit } =\n props\n const [source, setSource] = useState<string>(ANY_SOURCE)\n const [formId, setFormId] = useState('')\n const [emailDomain, setEmailDomain] = useState('')\n const [tag, setTag] = useState('')\n const [target, setTarget] = useState<'member' | 'roundRobin'>('member')\n const [memberUid, setMemberUid] = useState('')\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, so a drawer that was cancelled halfway does not\n // offer the abandoned draft as the next rule.\n useEffect(() => {\n if (!open) return\n setSource(ANY_SOURCE)\n setFormId('')\n setEmailDomain('')\n setTag('')\n setTarget('member')\n setMemberUid('')\n setBusy(false)\n setError(null)\n }, [open])\n\n const domainTyped = emailDomain.trim().length > 0\n const domainReadable =\n !domainTyped ||\n readCrmAssignmentRule({\n id: 'probe',\n when: { emailDomain },\n assign: { roundRobin: true },\n })?.when.emailDomain !== undefined\n const canSubmit =\n !busy && domainReadable && (target === 'roundRobin' || memberUid.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit) return\n const rule = readCrmAssignmentRule({\n id: newAssignmentRuleId(existingIds),\n when: {\n ...(source ? { source } : {}),\n ...(formId.trim() ? { formId: formId.trim() } : {}),\n ...(emailDomain.trim() ? { emailDomain: emailDomain.trim() } : {}),\n ...(tag.trim() ? { tag: tag.trim() } : {}),\n },\n assign: target === 'roundRobin' ? { roundRobin: true } : { memberUid },\n })\n if (!rule) {\n setError('The rule could not be read back. Check each field.')\n return\n }\n setBusy(true)\n setError(null)\n try {\n await onSubmit(rule)\n } catch (caught) {\n console.error(caught)\n setError('Could not save the rule. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, existingIds, source, formId, emailDomain, tag, target, memberUid, onSubmit])\n\n const conditions = [source, formId.trim(), emailDomain.trim(), tag.trim()].filter(Boolean)\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 380, p: 3 }}>\n <Typography variant=\"h6\">{'New assignment rule'}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'When a new contact matches every condition below, it is assigned ' +\n 'as the rule says. Leave every condition blank for a rule that ' +\n 'catches everything the rules above it did not.'}\n </Typography>\n <TextField\n select\n size=\"small\"\n label=\"Source\"\n value={source}\n onChange={(event) => setSource(event.target.value)}\n fullWidth\n >\n <MenuItem value={ANY_SOURCE}>{'Any source'}</MenuItem>\n {(Object.keys(CONTACT_SOURCE_LABELS) as ContactSource[]).map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_SOURCE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n size=\"small\"\n label=\"Form\"\n value={formId}\n onChange={(event) => setFormId(event.target.value)}\n helperText=\"The form's id, from its address in Forms. Blank for any form.\"\n slotProps={{ htmlInput: { maxLength: 128, spellCheck: false } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Email domain\"\n value={emailDomain}\n onChange={(event) => setEmailDomain(event.target.value)}\n error={!domainReadable}\n helperText={\n domainReadable\n ? 'acme.com — the domain of the captured address. Public mailboxes such as gmail.com count.'\n : 'That does not look like a domain.'\n }\n slotProps={{ htmlInput: { maxLength: 253, spellCheck: false } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Tag\"\n value={tag}\n onChange={(event) => setTag(event.target.value)}\n helperText=\"A tag the capture carries, or the contact already has.\"\n slotProps={{ htmlInput: { maxLength: 60 } }}\n fullWidth\n />\n <FormControl>\n <FormLabel id=\"assignment-rule-target\">{'Assign to'}</FormLabel>\n <RadioGroup\n aria-labelledby=\"assignment-rule-target\"\n value={target}\n onChange={(event) =>\n setTarget(event.target.value === 'roundRobin' ? 'roundRobin' : 'member')\n }\n >\n <FormControlLabel value=\"member\" control={<Radio size=\"small\" />} label=\"A team member\" />\n <FormControlLabel\n value=\"roundRobin\"\n control={<Radio size=\"small\" />}\n label=\"Round robin — the next member of the pool\"\n />\n </RadioGroup>\n {target === 'roundRobin' ? (\n <FormHelperText>\n {poolSize > 0\n ? `The pool has ${poolSize} ${poolSize === 1 ? 'member' : 'members'}; each new contact goes to the next in turn.`\n : 'The pool is empty. This rule is skipped until somebody is added to it below.'}\n </FormHelperText>\n ) : null}\n </FormControl>\n {target === 'member' ? (\n <TextField\n select\n size=\"small\"\n label=\"Member\"\n value={memberUid}\n onChange={(event) => setMemberUid(event.target.value)}\n disabled={membersLoading}\n helperText={\n membersLoading\n ? 'Loading the team…'\n : members.length\n ? undefined\n : 'The team could not be listed.'\n }\n fullWidth\n >\n {members.map((member) => (\n <MenuItem key={member.uid} value={member.uid}>\n {crmMemberPickerLabel(member)}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n {conditions.length === 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No conditions: this rule matches every new contact the rules above it did not claim.'}\n </Typography>\n ) : null}\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button variant=\"contained\" color=\"primary\" disabled={!canSubmit} onClick={handleSubmit}>\n {'Add rule'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nAssignmentRuleDrawer.displayName = 'AssignmentRuleDrawer'\n\nexport default AssignmentRuleDrawer\n"],"names":["CONTACT_SOURCE_LABELS","newAssignmentRuleId","readCrmAssignmentRule","crmMemberPickerLabel","Button","Drawer","FormControl","FormControlLabel","FormHelperText","FormLabel","MenuItem","Radio","RadioGroup","Stack","TextField","Typography","useCallback","useEffect","useState","ANY_SOURCE","AssignmentRuleDrawer","props","open","onClose","members","membersLoading","poolSize","existingIds","onSubmit","source","setSource","formId","setFormId","emailDomain","setEmailDomain","tag","setTag","target","setTarget","memberUid","setMemberUid","busy","setBusy","error","setError","domainTyped","trim","length","domainReadable","id","when","assign","roundRobin","undefined","canSubmit","handleSubmit","rule","caught","console","conditions","filter","Boolean","anchor","spacing","sx","width","p","variant","color","select","size","label","value","onChange","event","fullWidth","Object","keys","map","option","helperText","slotProps","htmlInput","maxLength","spellCheck","aria-labelledby","control","disabled","member","uid","direction","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/assignment-rule-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_SOURCE_LABELS,\n type ContactSource,\n type CrmAssignmentRule,\n newAssignmentRuleId,\n readCrmAssignmentRule,\n crmMemberPickerLabel,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControl,\n FormControlLabel,\n FormHelperText,\n FormLabel,\n MenuItem,\n Radio,\n RadioGroup,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useState } from 'react'\nimport type { OrgMemberOption } from '../hooks/use-org-member-directory'\n\n/** The `value` of the source picker's \"any door\" entry. */\nconst ANY_SOURCE = ''\n\nexport interface AssignmentRuleDrawerProps {\n open: boolean\n onClose: () => void\n /** The roster the member target is picked from. */\n members: readonly OrgMemberOption[]\n membersLoading: boolean\n /** How many people the round-robin pool holds, for the target's helper text. */\n poolSize: number\n /** The ids the org already holds, so the new one is minted apart from them. */\n existingIds: readonly string[]\n /** Receives the rule; closing on success is the caller's job. */\n onSubmit: (rule: CrmAssignmentRule) => Promise<void> | void\n}\n\n/**\n * The one form an assignment rule is written through (AGL-2618).\n *\n * ## Every condition is optional, and none is a form above the list\n *\n * A rule names the captures it claims — a source, a form, an email domain,\n * a tag — and a field left blank is not a condition, so a rule with every\n * field blank is the catch-all, which is a rule a team writes on purpose\n * (\"everything else goes to Sam\"). The drawer says so rather than refusing\n * it. The email domain is typed the way a person sees one in an address,\n * `@acme.com` or `acme.com`, and the reader strips the `@`; what it cannot\n * read as a domain is refused here, with the field, rather than stored as\n * a condition nothing will ever match.\n *\n * ## The target is a member, or the rotation\n *\n * A member is picked from the roster by name, never typed as an address —\n * an address is resolved at run time and a rule must not depend on a\n * resolution that can change. The rotation is offered whatever the pool\n * holds, with the pool's size beside it: an empty pool is a rule the pass\n * steps over until somebody fills the pool, which the card below explains,\n * and refusing the rule here would make the two cards order-dependent.\n */\nexport function AssignmentRuleDrawer(props: AssignmentRuleDrawerProps) {\n const { open, onClose, members, membersLoading, poolSize, existingIds, onSubmit } =\n props\n const [source, setSource] = useState<string>(ANY_SOURCE)\n const [formId, setFormId] = useState('')\n const [emailDomain, setEmailDomain] = useState('')\n const [tag, setTag] = useState('')\n const [target, setTarget] = useState<'member' | 'roundRobin'>('member')\n const [memberUid, setMemberUid] = useState('')\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, so a drawer that was cancelled halfway does not\n // offer the abandoned draft as the next rule.\n useEffect(() => {\n if (!open) return\n setSource(ANY_SOURCE)\n setFormId('')\n setEmailDomain('')\n setTag('')\n setTarget('member')\n setMemberUid('')\n setBusy(false)\n setError(null)\n }, [open])\n\n const domainTyped = emailDomain.trim().length > 0\n const domainReadable =\n !domainTyped ||\n readCrmAssignmentRule({\n id: 'probe',\n when: { emailDomain },\n assign: { roundRobin: true },\n })?.when.emailDomain !== undefined\n const canSubmit =\n !busy && domainReadable && (target === 'roundRobin' || memberUid.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit) return\n const rule = readCrmAssignmentRule({\n id: newAssignmentRuleId(existingIds),\n when: {\n ...(source ? { source } : {}),\n ...(formId.trim() ? { formId: formId.trim() } : {}),\n ...(emailDomain.trim() ? { emailDomain: emailDomain.trim() } : {}),\n ...(tag.trim() ? { tag: tag.trim() } : {}),\n },\n assign: target === 'roundRobin' ? { roundRobin: true } : { memberUid },\n })\n if (!rule) {\n setError('The rule could not be read back. Check each field.')\n return\n }\n setBusy(true)\n setError(null)\n try {\n await onSubmit(rule)\n } catch (caught) {\n console.error(caught)\n setError('Could not save the rule. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, existingIds, source, formId, emailDomain, tag, target, memberUid, onSubmit])\n\n const conditions = [source, formId.trim(), emailDomain.trim(), tag.trim()].filter(Boolean)\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 380, maxWidth: '100vw', p: 3 }}>\n <Typography variant=\"h6\">{'New assignment rule'}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'When a new contact matches every condition below, it is assigned ' +\n 'as the rule says. Leave every condition blank for a rule that ' +\n 'catches everything the rules above it did not.'}\n </Typography>\n <TextField\n select\n size=\"small\"\n label=\"Source\"\n value={source}\n onChange={(event) => setSource(event.target.value)}\n fullWidth\n >\n <MenuItem value={ANY_SOURCE}>{'Any source'}</MenuItem>\n {(Object.keys(CONTACT_SOURCE_LABELS) as ContactSource[]).map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_SOURCE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n size=\"small\"\n label=\"Form\"\n value={formId}\n onChange={(event) => setFormId(event.target.value)}\n helperText=\"The form's id, from its address in Forms. Blank for any form.\"\n slotProps={{ htmlInput: { maxLength: 128, spellCheck: false } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Email domain\"\n value={emailDomain}\n onChange={(event) => setEmailDomain(event.target.value)}\n error={!domainReadable}\n helperText={\n domainReadable\n ? 'acme.com — the domain of the captured address. Public mailboxes such as gmail.com count.'\n : 'That does not look like a domain.'\n }\n slotProps={{ htmlInput: { maxLength: 253, spellCheck: false } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Tag\"\n value={tag}\n onChange={(event) => setTag(event.target.value)}\n helperText=\"A tag the capture carries, or the contact already has.\"\n slotProps={{ htmlInput: { maxLength: 60 } }}\n fullWidth\n />\n <FormControl>\n <FormLabel id=\"assignment-rule-target\">{'Assign to'}</FormLabel>\n <RadioGroup\n aria-labelledby=\"assignment-rule-target\"\n value={target}\n onChange={(event) =>\n setTarget(event.target.value === 'roundRobin' ? 'roundRobin' : 'member')\n }\n >\n <FormControlLabel value=\"member\" control={<Radio size=\"small\" />} label=\"A team member\" />\n <FormControlLabel\n value=\"roundRobin\"\n control={<Radio size=\"small\" />}\n label=\"Round robin — the next member of the pool\"\n />\n </RadioGroup>\n {target === 'roundRobin' ? (\n <FormHelperText>\n {poolSize > 0\n ? `The pool has ${poolSize} ${poolSize === 1 ? 'member' : 'members'}; each new contact goes to the next in turn.`\n : 'The pool is empty. This rule is skipped until somebody is added to it below.'}\n </FormHelperText>\n ) : null}\n </FormControl>\n {target === 'member' ? (\n <TextField\n select\n size=\"small\"\n label=\"Member\"\n value={memberUid}\n onChange={(event) => setMemberUid(event.target.value)}\n disabled={membersLoading}\n helperText={\n membersLoading\n ? 'Loading the team…'\n : members.length\n ? undefined\n : 'The team could not be listed.'\n }\n fullWidth\n >\n {members.map((member) => (\n <MenuItem key={member.uid} value={member.uid}>\n {crmMemberPickerLabel(member)}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n {conditions.length === 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No conditions: this rule matches every new contact the rules above it did not claim.'}\n </Typography>\n ) : null}\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button variant=\"contained\" color=\"primary\" disabled={!canSubmit} onClick={handleSubmit}>\n {'Add rule'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nAssignmentRuleDrawer.displayName = 'AssignmentRuleDrawer'\n\nexport default AssignmentRuleDrawer\n"],"names":["CONTACT_SOURCE_LABELS","newAssignmentRuleId","readCrmAssignmentRule","crmMemberPickerLabel","Button","Drawer","FormControl","FormControlLabel","FormHelperText","FormLabel","MenuItem","Radio","RadioGroup","Stack","TextField","Typography","useCallback","useEffect","useState","ANY_SOURCE","AssignmentRuleDrawer","props","open","onClose","members","membersLoading","poolSize","existingIds","onSubmit","source","setSource","formId","setFormId","emailDomain","setEmailDomain","tag","setTag","target","setTarget","memberUid","setMemberUid","busy","setBusy","error","setError","domainTyped","trim","length","domainReadable","id","when","assign","roundRobin","undefined","canSubmit","handleSubmit","rule","caught","console","conditions","filter","Boolean","anchor","spacing","sx","width","maxWidth","p","variant","color","select","size","label","value","onChange","event","fullWidth","Object","keys","map","option","helperText","slotProps","htmlInput","maxLength","spellCheck","aria-labelledby","control","disabled","member","uid","direction","onClick","displayName"],"mappings":"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"}
@@ -202,6 +202,7 @@ import { useCrmCampaigns } from "../hooks/use-crm-campaigns.js";
202
202
  children: 'Sources'
203
203
  }),
204
204
  sources.length || leadKey ? /*#__PURE__*/ _jsxs(Stack, {
205
+ useFlexGap: true,
205
206
  direction: "row",
206
207
  spacing: 0.5,
207
208
  sx: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-associations-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport {\n CONTACT_SOURCE_LABELS,\n type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { personKeyInBrowser } from '../model/person-key-web'\n/*\n * The component path and NOT the marketing barrel: that barrel is the entry\n * point the tenant's loader imports to activate the plugin's SITE half, so a\n * console card named there ships to every published page.\n */\nimport { CrmRecordAttributionZone } from './crm-attribution-zone'\nimport ContainerPicker from '@aglyn/tenant-feature-instance/components/container-picker'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Divider, Stack, Typography } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport type { ContactRecord } from '../model/contact-record'\nimport { crmRoutes } from '../model/crm-routes'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { useCrmCampaigns } from '../hooks/use-crm-campaigns'\n\nexport interface ContactAssociationsCardProps {\n /**\n * The site whose lead, campaigns and attribution are shown — the mounted\n * site, or at the organization level the holder's own; `null` for a row\n * no site has captured, when none of the three has an address.\n */\n hostId: string | null\n /** The row, flattened through the viewing group's facet. */\n record: ContactRecord\n /** The document as read, for the consent fields the projection leaves out. */\n row: Record<string, unknown>\n consentGroup: ConsentGroup\n /** The listener's verdict on the row, for the stale-seed guard. */\n seed: { status: 'loading' | 'success' | 'error'; fromCache: boolean }\n /** The hub's own path, for the link to this person's lead. */\n basePath: string\n /**\n * The site's Bookings page narrowed to this person's address (AGL-2660),\n * or nothing where the site has no booking door — the page decides, and\n * the row is simply not drawn.\n */\n bookingsHref?: string | null\n}\n\n/**\n * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).\n *\n * Four facts that were in the v1 drawer and are not properties of the\n * person so much as of the RELATIONSHIP:\n *\n * - The SOURCES say which mechanism created the record — a form, a\n * checkout, an import.\n * - The ATTRIBUTION says which campaign led to it, recorded from the link\n * they followed and never editable. One keyed read, paid on opening the\n * page rather than per row of the list.\n * - The CONSENT is the basis this controller holds for marketing email:\n * opted in, opted out, or no record — three states that never collapse\n * into two, because \"no record\" is the commonest and is not a refusal.\n * - The FILING is which campaigns the team has PUT them in, a working set\n * kept by hand. It adds nobody to a send: a campaign's audience is its\n * lists and each email's own picker, and the helper says so where a\n * reader would otherwise assume the opposite.\n *\n * The filing is the one editable thing here and has its own **Save filing**\n * in the card header, through the same stale-seed guard the properties card\n * uses: a campaign membership\n * written over a cached read could revert somebody else's filing. The save\n * goes to `crm/contact-update` (AGL-2804), because the filing is this\n * holder's facet and a facet is the server's to write; like every field that\n * route writes, it is refused to a plan without the CRM (AGL-2851).\n *\n * ## The lead (AGL-2612)\n *\n * A LEAD is the sales record a lead surface — a sign-up, a booking, a form\n * with lead routing — files beside the contact, under the capturing site at\n * `hosts/{hostId}/leads/{personKey}`. The lead's page links the contact\n * once converted; nothing linked back, so the two halves of one person\n * read as unrelated. One `getDoc` by the key the capture derives — the\n * address, normalized and hashed, computed here on WebCrypto — is what\n * answers \"does this site hold a lead for this person\", and it is a read\n * this page pays because opening the record IS the ask. Absence is a fact\n * too: a contact captured by an order or an import has no lead, and the\n * chip is simply not there.\n */\nexport function ContactAssociationsCard(props: ContactAssociationsCardProps) {\n const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId)\n const routes = crmRoutes(basePath)\n\n const [leadKey, setLeadKey] = useState<string | null>(null)\n const email = record.email\n useEffect(() => {\n let active = true\n setLeadKey(null)\n if (!hostId || !orgId) return undefined\n void (async () => {\n const key = await personKeyInBrowser(email)\n if (!key || !active) return\n const snapshot = await getDoc(\n doc(firestore, 'orgs', orgId, 'leads', key),\n ).catch(() => null)\n if (active && snapshot?.exists()) setLeadKey(key)\n })().catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId, email])\n\n const [campaigns, setCampaigns] = useState<string[]>(record.campaignIds)\n const [saving, setSaving] = useState(false)\n const recordId = record.$id\n useEffect(() => {\n setCampaigns(record.campaignIds)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [recordId])\n\n /*\n * The campaigns, read because this page IS the ask: one picker on one\n * record, and nothing in the list ever paid for the campaign list. Under\n * a site, the ones placed on it; at the organization level, every one.\n */\n const siteCampaigns = useCrmCampaigns({ hostId, orgId }, { enabled: true })\n\n const consent = useMemo(\n () => readMarketingBasis(row, consentGroup),\n [row, consentGroup],\n )\n\n const handleSaveFiling = useCallback(async () => {\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: seed.status === 'error',\n fromCache: seed.fromCache,\n },\n // An empty selection is sent as an empty list rather than left out,\n // so \"filed under no campaign\" has one shape here and in the pass\n // that detaches a deleted campaign.\n () =>\n contactUpdate.updateOne(record.$id, {\n campaignIds: Aglyn.containerMembershipValue(campaigns),\n }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [campaigns, contactUpdate, enqueueSnackbar, record.$id, seed.fromCache, seed.status])\n\n const sources = Object.keys(record.sources ?? {}) as ContactSource[]\n const consentDetail = [\n consent.basisAtMs\n ? `recorded ${new Date(consent.basisAtMs).toLocaleDateString()}`\n : null,\n consent.assertedBy === 'operator' ? 'entered by your team' : null,\n consent.otherGrant === 'other-host'\n ? 'opted in to another site in this workspace'\n : null,\n ].filter(Boolean)\n\n return (\n <CardDisplay\n header={'Relationship'}\n help={Aglyn.pluginDocsHelp('contactRecord', { anchor: '#what-each-site-keeps-to-itself' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || (siteCampaigns.ready && !siteCampaigns.options.length)}\n onClick={() => void handleSaveFiling()}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Sources'}</Typography>\n {sources.length || leadKey ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source] ?? source}\n size=\"small\"\n />\n ))}\n {/*\n This site's lead for the person, when it holds one — a link,\n in the shape the lead's page uses to point back here.\n */}\n {leadKey ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.lead(leadKey)}\n size=\"small\"\n variant=\"outlined\"\n sx={{ py: 0, minHeight: 24, lineHeight: 1.5 }}\n >\n {'Lead on this site'}\n </Button>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No capture recorded.'}\n </Typography>\n )}\n </Stack>\n {/*\n The person's bookings on this site (AGL-2660): every booking\n taken with their address, past ones too, on the Bookings page —\n the appointments are that page's records, and this card only\n points at them.\n */}\n {bookingsHref ? (\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Bookings'}</Typography>\n <Typography variant=\"body2\">\n <AppLink href={bookingsHref}>{'Bookings on this site'}</AppLink>\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Every booking made with this address, upcoming and past.'}\n </Typography>\n </Stack>\n ) : null}\n {hostId ? (\n <Stack spacing={0.5}>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"contact\"\n recordId={record.$id}\n />\n {/* Attribution is the campaign that brought the person; the\n picker below is the team's own filing (AGL-3274). */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filing is separate — see Filed under campaigns.'}\n </Typography>\n </Stack>\n ) : null}\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Marketing email'}</Typography>\n <Typography variant=\"body2\">\n {MARKETING_BASIS_LABELS[consent.basis]}\n {consentDetail.length ? ` · ${consentDetail.join(' · ')}` : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {consent.basis === 'unrecorded'\n ? 'Nothing was recorded either way — not a refusal, and not permission.'\n : `The basis this ${\n consentGroup.declared ? 'group' : 'site'\n } holds. Other sites in the workspace keep their own.`}\n </Typography>\n </Stack>\n <Divider />\n <ContainerPicker\n kind=\"campaign\"\n options={siteCampaigns.options}\n value={campaigns}\n onChange={setCampaigns}\n label=\"Filed under campaigns\"\n helperText=\"Your own filing. It never adds anyone to a send — a campaign mails its lists.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n emptyText={hostId ? 'This site has no campaigns yet.' : 'There are no campaigns yet.'}\n />\n </Stack>\n </CardDisplay>\n )\n}\nContactAssociationsCard.displayName = 'ContactAssociationsCard'\n\nexport default ContactAssociationsCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","MARKETING_BASIS_LABELS","readMarketingBasis","personKeyInBrowser","CrmRecordAttributionZone","ContainerPicker","AppLink","CardDisplay","useSnackbar","useFirestore","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","useCrmCampaigns","ContactAssociationsCard","props","record","hostId","row","consentGroup","seed","basePath","bookingsHref","orgId","firestore","enqueueSnackbar","contactUpdate","routes","leadKey","setLeadKey","email","active","undefined","key","snapshot","catch","exists","campaigns","setCampaigns","campaignIds","saving","setSaving","recordId","$id","siteCampaigns","enabled","consent","handleSaveFiling","verdict","subject","unreadable","status","fromCache","updateOne","containerMembershipValue","ok","message","variant","persist","error","console","Error","allowDuplicate","sources","Object","keys","consentDetail","basisAtMs","Date","toLocaleDateString","assertedBy","otherGrant","filter","Boolean","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","HeaderProps","action","size","disabled","ready","options","length","onClick","spacing","direction","sx","flexWrap","rowGap","map","source","label","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","kind","value","onChange","helperText","empty","emptyText","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-associations-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport {\n CONTACT_SOURCE_LABELS,\n type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { personKeyInBrowser } from '../model/person-key-web'\n/*\n * The component path and NOT the marketing barrel: that barrel is the entry\n * point the tenant's loader imports to activate the plugin's SITE half, so a\n * console card named there ships to every published page.\n */\nimport { CrmRecordAttributionZone } from './crm-attribution-zone'\nimport ContainerPicker from '@aglyn/tenant-feature-instance/components/container-picker'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Divider, Stack, Typography } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport type { ContactRecord } from '../model/contact-record'\nimport { crmRoutes } from '../model/crm-routes'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { useCrmCampaigns } from '../hooks/use-crm-campaigns'\n\nexport interface ContactAssociationsCardProps {\n /**\n * The site whose lead, campaigns and attribution are shown — the mounted\n * site, or at the organization level the holder's own; `null` for a row\n * no site has captured, when none of the three has an address.\n */\n hostId: string | null\n /** The row, flattened through the viewing group's facet. */\n record: ContactRecord\n /** The document as read, for the consent fields the projection leaves out. */\n row: Record<string, unknown>\n consentGroup: ConsentGroup\n /** The listener's verdict on the row, for the stale-seed guard. */\n seed: { status: 'loading' | 'success' | 'error'; fromCache: boolean }\n /** The hub's own path, for the link to this person's lead. */\n basePath: string\n /**\n * The site's Bookings page narrowed to this person's address (AGL-2660),\n * or nothing where the site has no booking door — the page decides, and\n * the row is simply not drawn.\n */\n bookingsHref?: string | null\n}\n\n/**\n * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).\n *\n * Four facts that were in the v1 drawer and are not properties of the\n * person so much as of the RELATIONSHIP:\n *\n * - The SOURCES say which mechanism created the record — a form, a\n * checkout, an import.\n * - The ATTRIBUTION says which campaign led to it, recorded from the link\n * they followed and never editable. One keyed read, paid on opening the\n * page rather than per row of the list.\n * - The CONSENT is the basis this controller holds for marketing email:\n * opted in, opted out, or no record — three states that never collapse\n * into two, because \"no record\" is the commonest and is not a refusal.\n * - The FILING is which campaigns the team has PUT them in, a working set\n * kept by hand. It adds nobody to a send: a campaign's audience is its\n * lists and each email's own picker, and the helper says so where a\n * reader would otherwise assume the opposite.\n *\n * The filing is the one editable thing here and has its own **Save filing**\n * in the card header, through the same stale-seed guard the properties card\n * uses: a campaign membership\n * written over a cached read could revert somebody else's filing. The save\n * goes to `crm/contact-update` (AGL-2804), because the filing is this\n * holder's facet and a facet is the server's to write; like every field that\n * route writes, it is refused to a plan without the CRM (AGL-2851).\n *\n * ## The lead (AGL-2612)\n *\n * A LEAD is the sales record a lead surface — a sign-up, a booking, a form\n * with lead routing — files beside the contact, under the capturing site at\n * `hosts/{hostId}/leads/{personKey}`. The lead's page links the contact\n * once converted; nothing linked back, so the two halves of one person\n * read as unrelated. One `getDoc` by the key the capture derives — the\n * address, normalized and hashed, computed here on WebCrypto — is what\n * answers \"does this site hold a lead for this person\", and it is a read\n * this page pays because opening the record IS the ask. Absence is a fact\n * too: a contact captured by an order or an import has no lead, and the\n * chip is simply not there.\n */\nexport function ContactAssociationsCard(props: ContactAssociationsCardProps) {\n const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId)\n const routes = crmRoutes(basePath)\n\n const [leadKey, setLeadKey] = useState<string | null>(null)\n const email = record.email\n useEffect(() => {\n let active = true\n setLeadKey(null)\n if (!hostId || !orgId) return undefined\n void (async () => {\n const key = await personKeyInBrowser(email)\n if (!key || !active) return\n const snapshot = await getDoc(\n doc(firestore, 'orgs', orgId, 'leads', key),\n ).catch(() => null)\n if (active && snapshot?.exists()) setLeadKey(key)\n })().catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId, email])\n\n const [campaigns, setCampaigns] = useState<string[]>(record.campaignIds)\n const [saving, setSaving] = useState(false)\n const recordId = record.$id\n useEffect(() => {\n setCampaigns(record.campaignIds)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [recordId])\n\n /*\n * The campaigns, read because this page IS the ask: one picker on one\n * record, and nothing in the list ever paid for the campaign list. Under\n * a site, the ones placed on it; at the organization level, every one.\n */\n const siteCampaigns = useCrmCampaigns({ hostId, orgId }, { enabled: true })\n\n const consent = useMemo(\n () => readMarketingBasis(row, consentGroup),\n [row, consentGroup],\n )\n\n const handleSaveFiling = useCallback(async () => {\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: seed.status === 'error',\n fromCache: seed.fromCache,\n },\n // An empty selection is sent as an empty list rather than left out,\n // so \"filed under no campaign\" has one shape here and in the pass\n // that detaches a deleted campaign.\n () =>\n contactUpdate.updateOne(record.$id, {\n campaignIds: Aglyn.containerMembershipValue(campaigns),\n }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [campaigns, contactUpdate, enqueueSnackbar, record.$id, seed.fromCache, seed.status])\n\n const sources = Object.keys(record.sources ?? {}) as ContactSource[]\n const consentDetail = [\n consent.basisAtMs\n ? `recorded ${new Date(consent.basisAtMs).toLocaleDateString()}`\n : null,\n consent.assertedBy === 'operator' ? 'entered by your team' : null,\n consent.otherGrant === 'other-host'\n ? 'opted in to another site in this workspace'\n : null,\n ].filter(Boolean)\n\n return (\n <CardDisplay\n header={'Relationship'}\n help={Aglyn.pluginDocsHelp('contactRecord', { anchor: '#what-each-site-keeps-to-itself' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || (siteCampaigns.ready && !siteCampaigns.options.length)}\n onClick={() => void handleSaveFiling()}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Sources'}</Typography>\n {sources.length || leadKey ? (\n <Stack useFlexGap direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source] ?? source}\n size=\"small\"\n />\n ))}\n {/*\n This site's lead for the person, when it holds one — a link,\n in the shape the lead's page uses to point back here.\n */}\n {leadKey ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.lead(leadKey)}\n size=\"small\"\n variant=\"outlined\"\n sx={{ py: 0, minHeight: 24, lineHeight: 1.5 }}\n >\n {'Lead on this site'}\n </Button>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No capture recorded.'}\n </Typography>\n )}\n </Stack>\n {/*\n The person's bookings on this site (AGL-2660): every booking\n taken with their address, past ones too, on the Bookings page —\n the appointments are that page's records, and this card only\n points at them.\n */}\n {bookingsHref ? (\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Bookings'}</Typography>\n <Typography variant=\"body2\">\n <AppLink href={bookingsHref}>{'Bookings on this site'}</AppLink>\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Every booking made with this address, upcoming and past.'}\n </Typography>\n </Stack>\n ) : null}\n {hostId ? (\n <Stack spacing={0.5}>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"contact\"\n recordId={record.$id}\n />\n {/* Attribution is the campaign that brought the person; the\n picker below is the team's own filing (AGL-3274). */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filing is separate — see Filed under campaigns.'}\n </Typography>\n </Stack>\n ) : null}\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Marketing email'}</Typography>\n <Typography variant=\"body2\">\n {MARKETING_BASIS_LABELS[consent.basis]}\n {consentDetail.length ? ` · ${consentDetail.join(' · ')}` : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {consent.basis === 'unrecorded'\n ? 'Nothing was recorded either way — not a refusal, and not permission.'\n : `The basis this ${\n consentGroup.declared ? 'group' : 'site'\n } holds. Other sites in the workspace keep their own.`}\n </Typography>\n </Stack>\n <Divider />\n <ContainerPicker\n kind=\"campaign\"\n options={siteCampaigns.options}\n value={campaigns}\n onChange={setCampaigns}\n label=\"Filed under campaigns\"\n helperText=\"Your own filing. It never adds anyone to a send — a campaign mails its lists.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n emptyText={hostId ? 'This site has no campaigns yet.' : 'There are no campaigns yet.'}\n />\n </Stack>\n </CardDisplay>\n )\n}\nContactAssociationsCard.displayName = 'ContactAssociationsCard'\n\nexport default ContactAssociationsCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","MARKETING_BASIS_LABELS","readMarketingBasis","personKeyInBrowser","CrmRecordAttributionZone","ContainerPicker","AppLink","CardDisplay","useSnackbar","useFirestore","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","useCrmCampaigns","ContactAssociationsCard","props","record","hostId","row","consentGroup","seed","basePath","bookingsHref","orgId","firestore","enqueueSnackbar","contactUpdate","routes","leadKey","setLeadKey","email","active","undefined","key","snapshot","catch","exists","campaigns","setCampaigns","campaignIds","saving","setSaving","recordId","$id","siteCampaigns","enabled","consent","handleSaveFiling","verdict","subject","unreadable","status","fromCache","updateOne","containerMembershipValue","ok","message","variant","persist","error","console","Error","allowDuplicate","sources","Object","keys","consentDetail","basisAtMs","Date","toLocaleDateString","assertedBy","otherGrant","filter","Boolean","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","HeaderProps","action","size","disabled","ready","options","length","onClick","spacing","useFlexGap","direction","sx","flexWrap","rowGap","map","source","label","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","kind","value","onChange","helperText","empty","emptyText","displayName"],"mappings":"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"}
@@ -131,6 +131,7 @@ import { CrmSuiteLockedButton } from "./crm-suite-lock.js";
131
131
  children: contactDisplayName(candidate.doc, groupId) || String((_candidate_doc_email = candidate.doc['email']) != null ? _candidate_doc_email : candidate.id)
132
132
  }),
133
133
  secondary: /*#__PURE__*/ _jsxs(Stack, {
134
+ useFlexGap: true,
134
135
  component: "span",
135
136
  direction: "row",
136
137
  spacing: 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":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAA4BA,kBAAkB,EAAEC,cAAc,QAAQ,eAAc;AACpF,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,MAAM,EACNC,IAAI,EACJC,IAAI,EACJC,QAAQ,EACRC,YAAY,EACZC,KAAK,EACLC,UAAU,QACL,gBAAe;AACtB,SAASC,UAAU,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7E,SAASC,WAAW,EAAEC,QAAQ,QAAQ,QAAO;AAC7C,SAASC,kBAAkB,QAAQ,4BAAwB;AAC3D,SAASC,SAAS,QAAQ,yBAAqB;AAC/C,SACEC,wBAAwB,EACxBC,uBAAuB,EAEvBC,gBAAgB,EAChBC,sBAAsB,QACjB,iCAA6B;AAEpC,SAASC,oBAAoB,QAAQ,sBAAkB;AA6BvD;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,sBAAsBC,KAAiC;IACrE,MAAM,EACJC,OAAO,EACPC,KAAK,EACLC,YAAY,EACZC,SAAS,EACTC,QAAQ,EACRC,OAAO,EACPC,cAAc,KAAK,EACpB,GAAGP;IACJ,MAAMQ,YAAY/B;IAClB,MAAMgC,SAAShB,UAAUY;IACzB,MAAMK,UAAUP,aAAaO,OAAO;IACpC,MAAM,CAACC,YAAYC,cAAc,GAAGrB,SAA6B;IACjE,MAAM,CAACsB,WAAWC,aAAa,GAAGvB,SAAS;IAC3C,MAAM,CAACwB,OAAOC,SAAS,GAAGzB,SAAwB;IAElD,MAAM0B,UAAUrB,iBAAiBK,QAAQiB,GAAG,EAAER;IAE9C,MAAMS,OAAO7B,YAAY;QACvB,IAAIuB,aAAa,CAACI,SAAS;QAC3BH,aAAa;QACbE,SAAS;QACT,IAAI;YACF,MAAMI,QAAQ,MAAMlC,QAClBE,MACEH,WAAWuB,WAAWN,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAE,gBACvCV,mBAAmBY,YACtBf,MAAM,aAAa,MAAM4B,UACzB9B,MAAMO;YAGVkB,cACEQ,MAAMC,IAAI,CACPC,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,IAAID,SAASC,EAAE;oBAAEN,KAAKK,SAASE,IAAI;gBAAG,CAAA,GAC3DH,GAAG,CAAC,CAACI,OAAU,aACXA;oBACHC,SAAS9B,uBAAuBI,SAASyB,MAAMhB;oBAEhDkB,MAAM,CAAC,CAACF,OAASA,KAAKC,OAAO,CAACE,MAAM,GAAG;QAE9C,EAAE,OAAOC,WAAW;YAClBC,QAAQhB,KAAK,CAACe;YACdlB,cAAc;YACdI,SAAS;QACX,SAAU;YACRF,aAAa;QACf;IACF,GAAG;QAACD;QAAWI;QAAST;QAAWN;QAAOE;QAAWH;QAASS;KAAQ;IAEtE,qBACE,KAAClC;QACCwD,QAAO;QACPC,WAAU;QACVC,MAAM5D,eAAe,iBAAiB;YAAE6D,QAAQ;QAAqB;QACrEC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,KAAC7D;gBACC8D,MAAK;gBACLC,SAAQ;gBACRC,SAAS,IAAM,KAAKvB;gBACpBwB,UAAU9B,aAAa,CAACI;0BAEvBJ,YAAY,aAAa;;QAGhC;kBAEC,CAACI,wBACA,KAACjC;YAAWyD,SAAQ;YAAQG,OAAM;sBAC/B;aAED7B,sBACF,KAAC/B;YAAWyD,SAAQ;YAAQG,OAAM;sBAC/B7B;aAEDJ,eAAe,qBACjB,KAAC3B;YAAWyD,SAAQ;YAAQG,OAAM;sBAC/B,qEACC;aAEFjC,WAAWkB,MAAM,KAAK,kBACxB,KAAC7C;YAAWyD,SAAQ;YAAQG,OAAM;sBAC/B;2BAGH,KAAChE;YAAKiE,KAAK;YAACC,cAAc;YAACC,cAAW;sBACnCpC,WAAWW,GAAG,CAAC,CAAC0B;oBAkBEA,sBAUKA;qCA3BtB,KAACnE;oBAECoE,cAAc;oBACdC,iBACE3C,4BACE,KAACT;kCAAsB;uCAEvB,KAACpB;wBAAO8D,MAAK;wBAAQE,SAAS,IAAMpC,QAAQ0C;kCACzC;;8BAKP,cAAA,KAAClE;wBACCqE,uBACE,KAAC5E;4BAAQ6E,MAAM3C,OAAO4C,OAAO,CAACL,UAAUxB,EAAE;4BAAGoB,OAAM;4BAAUU,WAAU;sCACpEjF,mBAAmB2E,UAAU9B,GAAG,EAAER,YACjC6C,QAAOP,uBAAAA,UAAU9B,GAAG,CAAC,QAAQ,YAAtB8B,uBAA0BA,UAAUxB,EAAE;;wBAGnDgC,yBACE,MAACzE;4BACC0E,WAAU;4BACVC,WAAU;4BACVC,SAAS;4BACTC,IAAI;gCAAEC,YAAY;gCAAUC,UAAU;gCAAQC,QAAQ;4BAAI;;8CAE1D,KAACC;8CAAMT,QAAOP,wBAAAA,UAAU9B,GAAG,CAAC,QAAQ,YAAtB8B,wBAA0B;;gCACvCA,UAAUrB,OAAO,CAACL,GAAG,CAAC,CAAC2C,uBACtB,KAACtF;wCAEC6D,MAAK;wCACLC,SAAQ;wCACRyB,OAAOvE,uBAAuB,CAACsE,OAAO;uCAHjCA;;;wBAQbE,WAAW;4BAAEX,WAAW;gCAAEC,WAAW;4BAAM;wBAAE;;mBArC1CT,UAAUxB,EAAE;;;;AA6C/B;AACAzB,sBAAsBqE,WAAW,GAAG;AAEpC,eAAerE,sBAAqB"}
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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"}
@@ -157,6 +157,7 @@ const LABEL_MAX_LENGTH = 80;
157
157
  spacing: 2,
158
158
  sx: {
159
159
  width: 360,
160
+ maxWidth: '100vw',
160
161
  p: 3
161
162
  },
162
163
  children: [
@@ -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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"}
@@ -90,6 +90,7 @@ ConsentChip.displayName = 'ConsentChip';
90
90
  const href = mount.siteHubHref(hostId);
91
91
  const name = mount.siteName(hostId);
92
92
  return /*#__PURE__*/ _jsxs(Stack, {
93
+ useFlexGap: true,
93
94
  direction: "row",
94
95
  spacing: 1,
95
96
  sx: {
@@ -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;4BAoCOxB;wBAnC7B,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;4BAECoC,WAAU;4BACVT,SAAS;4BACTU,IAAI;gCAAEC,YAAY;gCAAUC,UAAU;4BAAO;;gCAY5CN,qBACC,KAACpC;oCACCoC,MAAM,GAAGA,KAAK,UAAU,EAAEO,mBAAmBxB,YAAY;oCACzDyB,WAAU;8CAETN;mDAGH,KAAClC;oCAAWyC,WAAU;oCAAO/B,SAAQ;8CAClCwB;;gCAGJ7B,sBAAQ,KAACF;oCAAYE,OAAOA;oCAAOC,UAAU4B;qCAAW;gCACxD7B,CAAAA,yBAAAA,MAAOqC,QAAQ,kBACd,KAAC1C;oCAAWU,SAAQ;oCAAUF,OAAM;8CACjC,CAAC,iBAAiB,GAAEH,mBAAAA,MAAMsC,SAAS,YAAftC,mBAAmBA,MAAMuC,OAAO,CAAC,QAAQ,EAAEV,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"}
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"}