@aglyn/plugins-crm 1.0.0-beta.160 → 1.0.0-beta.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contact-detail-page.js +5 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +24 -2
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contacts-section.js +5 -0
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-email-state-chip.d.ts +17 -0
- package/src/lib/components/crm-email-state-chip.js +61 -0
- package/src/lib/components/crm-email-state-chip.js.map +1 -0
- package/src/lib/components/crm-send-email-button.d.ts +7 -1
- package/src/lib/components/crm-send-email-button.js +7 -3
- package/src/lib/components/crm-send-email-button.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +8 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/leads-section.js +143 -23
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/constants/contact-filters.js +19 -1
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +6 -0
- package/src/lib/model/contact-record.js +1 -0
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +22 -1
- package/src/lib/model/lead-filters.js +48 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/server/email-send.js +10 -1
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/record-email-state.d.ts +52 -0
- package/src/lib/server/record-email-state.js +118 -0
- package/src/lib/server/record-email-state.js.map +1 -0
- package/src/lib/server/record-timeline.d.ts +5 -1
- package/src/lib/server/record-timeline.js +26 -1
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/leads-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { ConsolePluginPageProps, CrmLeadFields, CrmLeadStatus } from '@aglyn/aglyn'\nimport {\n mdiAccountArrowRight,\n mdiAccountCancelOutline,\n mdiAccountConvertOutline,\n mdiAccountTieOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n type OrgMemberOptions,\n useOrgMemberOptions,\n} from '../hooks/use-org-member-options'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmSavedView } from '../hooks/use-crm-saved-view'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { useCrmViewGrid } from '../hooks/use-crm-view-grid'\nimport { CRM_LIST_SLOTS, CrmColumnOrderProvider } from './crm-column-menu'\nimport { useOrgLeads } from '../hooks/use-org-leads'\nimport CrmViewsControl from './crm-views-control'\nimport RowActionsMenu from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Box,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControl,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n limit,\n orderBy,\n query,\n serverTimestamp,\n updateDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useId, useMemo, useState } from 'react'\nimport { downloadTextFile } from '../model/contacts-csv'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n LEAD_FILTER_LABELS,\n LEAD_FILTERS,\n type LeadFilter,\n leadMatchesFilter,\n} from '../model/lead-filters'\nimport { type LeadCsvOptions, leadsCsv } from '../model/leads-csv'\nimport { LeadConvertDialog } from './lead-convert-dialog'\nimport { leadSourceLabel, leadSources, leadTimeLabel } from './lead-history-card'\nimport { LeadImportButton } from './lead-import-drawer'\nimport NewLeadDrawer, { type NewLeadValues } from './new-lead-drawer'\nimport { useCrmApi } from './use-crm-api'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { CONVERT_PENDING_ERASURE_REASON } from './lead-properties-card'\nimport { LeadStatusChip } from './lead-status-chip'\nimport LeadSurfacesNote from './lead-surfaces-note'\nimport { LeadUnqualifyDialog } from './lead-unqualify-dialog'\nimport LeadsBulkBar from './leads-bulk-bar'\nimport OrgLeadSurfacesNote from './org-lead-surfaces-note'\n\n/**\n * How many leads the section reads: the newest by last seen, plus one probe\n * row so \"there are more\" is a fact rather than a guess at the boundary.\n *\n * A CEILING and a client-side filter rather than a paged status query, and\n * the reason is the lead documents themselves. Every lead the capture door\n * writes carries NO `status` — the field exists only once somebody in the\n * CRM has touched the lead — and Firestore cannot select documents by a\n * field's absence: `where('status','in',[…])`, `!=` and `not-in` all skip a\n * document without the field. A server-side \"open leads\" query would\n * therefore hide every lead nobody has worked yet, which is the entire\n * population the section exists to show on the day it ships. So the query\n * is the one order every lead can satisfy (`lastSeenAtMs`, stamped on every\n * capture), and the status filter narrows the loaded window — said out loud\n * beneath the table whenever the window is not the whole collection.\n *\n * The window is then PAGED in memory under the shared footer, the way the\n * workspace pickers page a slice of a window they cannot re-key: the rows\n * are already in the snapshot, so turning a page costs nothing, and the\n * footer's count is exact because it counts the filtered window rather than\n * a collection nobody has measured.\n */\nconst LEADS_WINDOW = 200\n\n/**\n * One row of the list. `$id` keys the grid — the document id under a site,\n * `{hostId}/{leadId}` at the organization level, where a lead's id is a\n * person key the same on every site that met the person — and `leadId`\n * and `hostId` say which document, under which site, a write or a link\n * names.\n */\ntype LeadRow = Record<string, unknown> &\n CrmLeadFields & { $id: string; leadId: string; hostId: string }\n\n/**\n * `/crm/leads` — the people a site has met but not yet qualified (AGL-2608).\n *\n * A section of its own, the way Salesforce keeps Leads apart from Contacts:\n * a lead is a capture — a form, a booking, a sign-up — that somebody has\n * still to work, and it converts into a contact, a company and a deal when\n * it is real. Reads `hosts/{hostId}/leads`, host-scoped by path, so there is\n * no `visibleTo` filter; the Firestore rules admit any member of the site to\n * read it and an admin, editor or author to update it, which is what makes\n * the inline status and owner changes client-direct writes.\n *\n * At the ORGANIZATION level (AGL-2630) there is no one site to read: the\n * section opens the same query under every site the org has (`useOrgLeads`)\n * and lists the merged window with a Site column, every row naming the site\n * its writes and its link go to. The per-site notes — which of a site's\n * forms file a lead — belong to a site's own hub and are not drawn here.\n */\nexport function CrmLeadsSection(props: ConsolePluginPageProps) {\n const { hostId, org, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId, createHostId } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const roster = useOrgMemberOptions(orgId)\n const routes = crmRoutes(basePath ?? '')\n\n // Under a site: the site's own window, rows keyed by document id.\n const site = useFirestoreCollection<Record<string, unknown> & CrmLeadFields & { $id: string }>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('lastSeenAtMs', 'desc'),\n limit(LEADS_WINDOW + 1),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n // At the organization level: every site's window, merged.\n const orgHostIds = useMemo(\n () => (hostId ? [] : (mount?.hosts ?? []).map((host) => host.id)),\n [hostId, mount?.hosts],\n )\n const orgLeads = useOrgLeads({ hostIds: orgHostIds, windowSize: LEADS_WINDOW })\n const leadDocs = useMemo<LeadRow[]>(\n () =>\n hostId\n ? site.data.map((row) => ({ ...row, leadId: row.$id, hostId }))\n : orgLeads.data,\n [hostId, site.data, orgLeads.data],\n )\n const status = hostId\n ? site.status\n : mount?.hostsReady && !orgHostIds.length\n ? 'success'\n : orgLeads.status\n const truncated = hostId ? leadDocs.length > LEADS_WINDOW : orgLeads.truncated\n const window = useMemo(() => leadDocs.slice(0, LEADS_WINDOW), [leadDocs])\n\n /*\n * The `Show` filter is the saved VIEW'S (AGL-2617): a saved view of leads\n * holds the status beside the columns and the sort, and the select below\n * writes into it. Unset reads as `open`, which is what the section opened\n * on before views existed and the one reading a query cannot express.\n */\n const views = useCrmSavedView({\n section: 'leads',\n hostId,\n org: props.org,\n basePath: basePath ?? '',\n })\n const filter: LeadFilter = useMemo(() => {\n const value = views.state.filters.find((clause) => clause.field === 'status')?.value\n return (LEAD_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadFilter)\n : 'open'\n }, [views.state.filters])\n const setFilter = useCallback(\n (next: LeadFilter) =>\n views.setFilters(\n next === 'open' ? [] : [{ field: 'status', op: 'equals', value: next }],\n ),\n [views.setFilters],\n )\n // The label's id, so the filter's combobox is named \"Show\" rather than\n // after the option it shows — see `LeadOwnerSelect`.\n const filterLabelId = useId()\n const rows = useMemo(\n () => window.filter((lead) => leadMatchesFilter(lead, filter)),\n [window, filter],\n )\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n // A new filter starts on page one: page three of the open leads is not a\n // page of the unqualified ones, and an out-of-range page renders empty.\n useEffect(() => {\n setPage(0)\n }, [filter])\n const pageRows = useMemo(\n () => rows.slice(page * pageSize, (page + 1) * pageSize),\n [rows, page, pageSize],\n )\n\n /*\n * The ticked rows, for the bulk bar (AGL-2662). Cleared when the filter\n * changes: a selection made on the open leads is not a selection of the\n * unqualified ones, and the bar's count would be over rows no longer\n * listed.\n */\n const [selectedIds, setSelectedIds] = useState<string[]>([])\n useEffect(() => setSelectedIds([]), [filter])\n // How the file names the owner and, at the org level, the site.\n const csvOptions: LeadCsvOptions = useMemo(\n () => ({\n ownerEmail: roster.emailFor,\n ...(hostId ? {} : { siteName: (id: string) => mount?.siteName(id) }),\n }),\n [roster.emailFor, hostId, mount],\n )\n // The listed window — every row the filter admits, not just the page.\n const handleExport = useCallback(() => {\n downloadTextFile('leads.csv', 'text/csv', leadsCsv(rows, csvOptions))\n }, [rows, csvOptions])\n\n const [assigning, setAssigning] = useState<LeadRow | null>(null)\n const [unqualifying, setUnqualifying] = useState<LeadRow | null>(null)\n // The row whose conversion dialog is open (AGL-2641) — the same dialog\n // the lead's page opens, fed the row so the list is one click shorter.\n const [converting, setConverting] = useState<LeadRow | null>(null)\n\n /*==========================================\n * NEW LEAD (AGL-3231) — Salesforce's New Lead, in a drawer over the\n * list. The route files the lead through the one lead door under the\n * mounted site, or at the organization level under the site the drawer's\n * picker named; the drawer holds its submit until one is known. It makes\n * a lead and nothing else — the conversion is what makes the contact.\n *=========================================*/\n const crmApi = useCrmApi(createHostId)\n const [createOpen, setCreateOpen] = useState(false)\n const [createBusy, setCreateBusy] = useState(false)\n const [createError, setCreateError] = useState<string | null>(null)\n const handleCreate = useCallback(\n async (values: NewLeadValues) => {\n setCreateBusy(true)\n setCreateError(null)\n try {\n const { response, payload } = await crmApi('leads-create', {\n email: values.email,\n ...(values.name ? { name: values.name } : {}),\n ...(values.company ? { company: values.company } : {}),\n ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),\n ...(values.phone ? { phone: values.phone } : {}),\n ...(values.website ? { website: values.website } : {}),\n ...(values.leadSource ? { leadSource: values.leadSource } : {}),\n ...(values.address ? { address: values.address } : {}),\n ...(values.tags.length ? { tags: values.tags } : {}),\n ...(values.ownerUid ? { ownerUid: values.ownerUid } : {}),\n ...(values.notes ? { notes: values.notes } : {}),\n status: values.status,\n })\n if (!response.ok) {\n // The route's own sentence, shown above the form unchanged.\n setCreateError(String(payload['error'] ?? 'The lead could not be added.'))\n return\n }\n // The activity entry is the route's: it verified the caller and\n // performed the write.\n enqueueSnackbar(\n payload['created']\n ? 'Lead added'\n : 'This site already held a lead for that address — it was updated',\n { variant: 'success', persist: false },\n )\n setCreateOpen(false)\n } catch (error) {\n console.error(error)\n setCreateError('The lead could not be added.')\n } finally {\n setCreateBusy(false)\n }\n },\n [crmApi, enqueueSnackbar],\n )\n\n const writeLead = useCallback(\n async (lead: LeadRow, fields: Record<string, unknown>, done: string) => {\n try {\n await updateDoc(doc(firestore, 'hosts', lead.hostId, 'leads', lead.leadId), {\n ...fields,\n updatedAt: serverTimestamp(),\n })\n enqueueSnackbar(done, { variant: 'success', persist: false })\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n }\n },\n [firestore, enqueueSnackbar],\n )\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'name',\n headerName: 'Lead',\n flex: 1.4,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) => String(row['name'] || row['email'] || ''),\n renderCell: ({ row }: { row: LeadRow }) => (\n <Stack spacing={0} sx={{ minWidth: 0, justifyContent: 'center', height: '100%' }}>\n <Typography variant=\"body2\" noWrap>\n {String(row['name'] || row['email'] || row.$id)}\n </Typography>\n {row['name'] ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {String(row['email'] ?? '')}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n // The lead's own profile (AGL-3231): the two facts a work queue is\n // scanned by, beside the person.\n {\n field: 'company',\n headerName: 'Company',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => String(row.company ?? ''),\n },\n {\n field: 'jobTitle',\n headerName: 'Title',\n flex: 0.9,\n minWidth: 130,\n valueGetter: (_value, row: LeadRow) => String(row.jobTitle ?? ''),\n },\n {\n field: 'status',\n headerName: 'Status',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => Aglyn.crmLeadStatus(row),\n renderCell: ({ row }: { row: LeadRow }) => (\n <InlineStatus\n lead={row}\n onChange={(next) => {\n if (next === 'unqualified') {\n setUnqualifying(row)\n return\n }\n void writeLead(\n row,\n {\n status: next,\n ...(Aglyn.crmLeadStatus(row) === 'unqualified'\n ? { unqualifiedReason: deleteField() }\n : {}),\n },\n 'Status updated',\n )\n }}\n />\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n row.ownerUid ? roster.labelFor(row.ownerUid) : 'Unassigned',\n },\n // Only at the organization level, where a row can be any site's.\n ...(hostId\n ? []\n : [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 0.9,\n minWidth: 140,\n valueGetter: (_value: unknown, row: LeadRow) =>\n mount?.siteName(row.hostId) ?? row.hostId,\n } satisfies GridColDef,\n ]),\n {\n field: 'sources',\n headerName: 'Source',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n leadSources(row).map(leadSourceLabel).join(', '),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 0.9,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => (row.tags ?? []).join(', '),\n },\n {\n field: 'lastSeenAtMs',\n headerName: 'Last seen',\n flex: 0.9,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) =>\n leadTimeLabel(row['lastSeenAtMs'] ?? row['createdAt']),\n },\n {\n field: 'actions',\n headerName: '',\n width: 56,\n sortable: false,\n filterable: false,\n disableColumnMenu: true,\n renderCell: ({ row }: { row: LeadRow }) => {\n /*\n * Why Convert is refused, in the order the lead's page refuses it:\n * a converted lead has its contact already, a closed one was\n * judged not real, and a person with an erasure pending must not\n * be captured again — a conversion is a capture (AGL-2623).\n */\n const converted = Boolean(row.convertedContactId)\n const erasurePending = Aglyn.readErasureRequestedAtMs(row) !== null\n const convertRefusal = converted\n ? 'This lead was converted'\n : !Aglyn.isCrmLeadOpen(row)\n ? 'This lead was unqualified'\n : erasurePending\n ? CONVERT_PENDING_ERASURE_REASON\n : null\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{ display: 'flex', alignItems: 'center', height: '100%' }}\n >\n <RowActionsMenu\n label={String(row['email'] ?? row.$id)}\n items={[\n {\n key: 'open',\n label: 'Open lead',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: routes.lead(row.leadId, hostId ? null : row.hostId),\n },\n {\n key: 'convert',\n label: 'Convert…',\n icon: <MdiIcon path={mdiAccountConvertOutline.path} size={0.8} />,\n onClick: () => setConverting(row),\n disabled: convertRefusal !== null,\n disabledReason: convertRefusal ?? undefined,\n },\n {\n key: 'assign',\n label: 'Assign owner',\n icon: <MdiIcon path={mdiAccountTieOutline.path} size={0.8} />,\n onClick: () => setAssigning(row),\n },\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />,\n onClick: () => setUnqualifying(row),\n disabled: !Aglyn.isCrmLeadOpen(row) || Boolean(row.convertedContactId),\n disabledReason: row.convertedContactId\n ? 'This lead was converted'\n : 'This lead is already closed',\n },\n ]}\n />\n </Box>\n )\n },\n },\n ],\n [roster, routes, writeLead, hostId, mount],\n )\n /* The column and sort models are the view's (AGL-2617). */\n const grid = useCrmViewGrid(views, columns)\n\n return (\n <>\n <CardDisplay\n header={'Leads'}\n help={Aglyn.pluginDocsHelp('contacts', { anchor: '#whats-in-the-crm-area' })}\n actions={\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n {/* The saved view this list is showing, beside the status it narrows to (AGL-2617). */}\n <CrmViewsControl controller={views} allLabel=\"All leads\" />\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={filterLabelId}>{'Show'}</InputLabel>\n <Select\n labelId={filterLabelId}\n label=\"Show\"\n value={filter}\n onChange={(event) => setFilter(event.target.value as LeadFilter)}\n >\n {LEAD_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n <LeadImportButton hostId={hostId} />\n <Button size=\"small\" onClick={handleExport} disabled={!rows.length}>\n {'Export CSV'}\n </Button>\n <Button size=\"small\" variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'New lead'}\n </Button>\n </Stack>\n }\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/* Which surfaces file a lead, by name (AGL-2612) — under a site its own, at the org level every site's (AGL-2638). */}\n {hostId ? <LeadSurfacesNote hostId={hostId} /> : <OrgLeadSurfacesNote />}\n {status === 'success' && window.length === 0 ? (\n <EmptyStateComponent\n label={'No leads yet'}\n description={'Bookings and lead-routed forms on your site become leads on their own — or add one with New lead, or bring a list in with Import CSV.'}\n />\n ) : status === 'success' && rows.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No ${LEAD_FILTER_LABELS[filter].toLowerCase()} leads among the ` +\n `${window.length.toLocaleString()} most recently seen.`}\n </Typography>\n ) : (\n <>\n <LeadsBulkBar\n rows={rows}\n selected={selectedIds}\n onSelectedChange={setSelectedIds}\n roster={roster}\n csv={csvOptions}\n orgId={orgId}\n hostId={hostId}\n />\n <CrmColumnOrderProvider value={grid.columnOrder}>\n <ListTable\n rows={pageRows}\n columns={grid.columns}\n slots={CRM_LIST_SLOTS}\n selectable={{ selected: selectedIds, onChange: setSelectedIds }}\n loading={status === 'loading'}\n onOpen={(_id, row: LeadRow) =>\n router.push(routes.lead(row.leadId, hostId ? null : row.hostId))\n }\n // Columns and sort are the view's, controlled (AGL-2617).\n columnVisibilityModel={grid.columnVisibilityModel}\n onColumnVisibilityModelChange={grid.onColumnVisibilityModelChange}\n sortModel={grid.sortModel}\n onSortModelChange={grid.onSortModelChange}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n </CrmColumnOrderProvider>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={pageRows.length}\n count={rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`Showing the ${LEADS_WINDOW.toLocaleString()} most recently seen ` +\n 'leads. The status filter narrows these; older leads are still ' +\n 'listed in the Inbox and reached by campaign audiences.'}\n </Alert>\n ) : null}\n </Stack>\n </CardDisplay>\n <NewLeadDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n hostId={hostId ?? null}\n org={org as Record<string, unknown> | undefined}\n busy={createBusy}\n error={createError}\n roster={roster}\n onSubmit={(values) => void handleCreate(values)}\n />\n <AssignOwnerDialog\n lead={assigning}\n roster={roster}\n onClose={() => setAssigning(null)}\n onAssign={(uid) => {\n if (!assigning) return\n void writeLead(\n assigning,\n { ownerUid: uid || deleteField() },\n uid ? 'Owner assigned' : 'Owner cleared',\n )\n setAssigning(null)\n }}\n />\n <LeadUnqualifyDialog\n open={Boolean(unqualifying)}\n onClose={() => setUnqualifying(null)}\n hostId={unqualifying?.hostId ?? hostId ?? ''}\n leadId={unqualifying?.leadId ?? ''}\n leadLabel={String(unqualifying?.['name'] || unqualifying?.['email'] || '')}\n />\n {/* The row's site, not the mounted one: at the organization level a\n lead is its own site's record, and the conversion is that site's\n capture (AGL-2641). Under a site the two are the same. */}\n <LeadConvertDialog\n open={Boolean(converting)}\n onClose={() => setConverting(null)}\n hostId={converting?.hostId ?? hostId ?? ''}\n orgId={orgId}\n org={org as Record<string, unknown> | undefined}\n leadId={converting?.leadId ?? ''}\n lead={converting ?? {}}\n basePath={basePath ?? ''}\n roster={roster}\n />\n </>\n )\n}\nCrmLeadsSection.displayName = 'CrmLeadsSection'\n\n/**\n * The status, editable in place for a lead that is still open.\n *\n * A converted lead shows the chip alone: its status IS the conversion, and\n * the route stamped it. The select stops its click from reaching the row, or\n * every status change would also open the record.\n */\nfunction InlineStatus(props: {\n lead: LeadRow\n onChange: (next: CrmLeadStatus) => void\n}) {\n const { lead, onChange } = props\n if (lead.convertedContactId) {\n return (\n <Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>\n <LeadStatusChip lead={lead} />\n </Box>\n )\n }\n const status = Aglyn.crmLeadStatus(lead)\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{ display: 'flex', alignItems: 'center', height: '100%', width: '100%' }}\n >\n <Select\n size=\"small\"\n variant=\"standard\"\n disableUnderline\n value={status}\n onChange={(event) => onChange(event.target.value as CrmLeadStatus)}\n renderValue={() => <LeadStatusChip lead={lead} />}\n sx={{ width: '100%' }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{Aglyn.CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n <MenuItem value=\"unqualified\">{`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}</MenuItem>\n </Select>\n </Box>\n )\n}\nInlineStatus.displayName = 'InlineStatus'\n\n/** Hand a lead to a team member. */\nfunction AssignOwnerDialog(props: {\n lead: LeadRow | null\n roster: OrgMemberOptions\n onClose: () => void\n onAssign: (uid: string) => void\n}) {\n const { lead, roster, onClose, onAssign } = props\n const [uid, setUid] = useState<string | null>(null)\n const current = uid ?? String(lead?.ownerUid ?? '')\n return (\n <Dialog\n open={Boolean(lead)}\n onClose={onClose}\n maxWidth=\"xs\"\n fullWidth\n slotProps={{ transition: { onExited: () => setUid(null) } }}\n >\n <DialogTitle>{`Assign ${String(lead?.['name'] || lead?.['email'] || 'lead')}`}</DialogTitle>\n <DialogContent>\n <Box sx={{ pt: 1 }}>\n <LeadOwnerSelect value={current} onChange={setUid} roster={roster} size=\"medium\" />\n </Box>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Cancel'}</Button>\n <Button variant=\"contained\" onClick={() => onAssign(current)}>\n {'Assign'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nAssignOwnerDialog.displayName = 'AssignOwnerDialog'\n\nexport default CrmLeadsSection\n"],"names":["Aglyn","mdiAccountArrowRight","mdiAccountCancelOutline","mdiAccountConvertOutline","mdiAccountTieOutline","CardDisplay","MdiIcon","ListPagination","ListTable","useOrgMemberOptions","useCrmOrgMount","useCrmSavedView","useCrmScope","useCrmViewGrid","CRM_LIST_SLOTS","CrmColumnOrderProvider","useOrgLeads","CrmViewsControl","RowActionsMenu","TABLE_PAGE_SIZE_DEFAULT","EmptyStateComponent","useSnackbar","useFirestore","useFirestoreCollection","Alert","Box","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControl","InputLabel","MenuItem","Select","Stack","Typography","collection","deleteField","doc","limit","orderBy","query","serverTimestamp","updateDoc","useRouter","useCallback","useEffect","useId","useMemo","useState","downloadTextFile","crmRoutes","LEAD_FILTER_LABELS","LEAD_FILTERS","leadMatchesFilter","leadsCsv","LeadConvertDialog","leadSourceLabel","leadSources","leadTimeLabel","LeadImportButton","NewLeadDrawer","useCrmApi","LeadOwnerSelect","CONVERT_PENDING_ERASURE_REASON","LeadStatusChip","LeadSurfacesNote","LeadUnqualifyDialog","LeadsBulkBar","OrgLeadSurfacesNote","LEADS_WINDOW","CrmLeadsSection","props","unqualifying","converting","hostId","org","basePath","firestore","router","enqueueSnackbar","orgId","createHostId","mount","roster","routes","site","idField","orgHostIds","hosts","map","host","id","orgLeads","hostIds","windowSize","leadDocs","data","row","leadId","$id","status","hostsReady","length","truncated","window","slice","views","section","filter","value","state","filters","find","clause","field","includes","setFilter","next","setFilters","op","filterLabelId","rows","lead","page","setPage","pageSize","setPageSize","pageRows","selectedIds","setSelectedIds","csvOptions","ownerEmail","emailFor","siteName","handleExport","assigning","setAssigning","setUnqualifying","setConverting","crmApi","createOpen","setCreateOpen","createBusy","setCreateBusy","createError","setCreateError","handleCreate","values","response","payload","email","name","company","jobTitle","phone","website","leadSource","address","tags","ownerUid","notes","ok","String","variant","persist","error","console","writeLead","fields","done","updatedAt","Error","message","columns","headerName","flex","minWidth","valueGetter","_value","renderCell","spacing","sx","justifyContent","height","noWrap","color","crmLeadStatus","InlineStatus","onChange","unqualifiedReason","labelFor","join","width","sortable","filterable","disableColumnMenu","converted","Boolean","convertedContactId","erasurePending","readErasureRequestedAtMs","convertRefusal","isCrmLeadOpen","onClick","event","stopPropagation","display","alignItems","label","items","key","icon","path","size","href","disabled","disabledReason","undefined","grid","header","help","pluginDocsHelp","anchor","actions","direction","controller","allLabel","labelId","target","option","contentGutterX","contentGutterY","description","toLowerCase","toLocaleString","selected","onSelectedChange","csv","columnOrder","slots","selectable","loading","onOpen","_id","push","columnVisibilityModel","onColumnVisibilityModelChange","sortModel","onSortModelChange","hideFooter","rowCount","count","onPageChange","onPageSizeChange","severity","open","onClose","busy","onSubmit","AssignOwnerDialog","onAssign","uid","leadLabel","displayName","disableUnderline","renderValue","CRM_LEAD_STATUS_LABELS","new","working","unqualified","setUid","current","maxWidth","fullWidth","slotProps","transition","onExited","pt"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/leads-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { ConsolePluginPageProps, CrmLeadFields, CrmLeadStatus } from '@aglyn/aglyn'\nimport {\n mdiAccountArrowRight,\n mdiAccountCancelOutline,\n mdiAccountConvertOutline,\n mdiAccountTieOutline,\n mdiMagnify,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n type OrgMemberOptions,\n useOrgMemberOptions,\n} from '../hooks/use-org-member-options'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmSavedView } from '../hooks/use-crm-saved-view'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { useCrmViewGrid } from '../hooks/use-crm-view-grid'\nimport { CRM_LIST_SLOTS, CrmColumnOrderProvider } from './crm-column-menu'\nimport { useOrgLeads } from '../hooks/use-org-leads'\nimport CrmViewsControl from './crm-views-control'\nimport RowActionsMenu from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Box,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControl,\n InputAdornment,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n limit,\n orderBy,\n query,\n serverTimestamp,\n updateDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useId, useMemo, useState } from 'react'\nimport { downloadTextFile } from '../model/contacts-csv'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n LEAD_FILTER_LABELS,\n LEAD_EMAIL_FILTER_LABELS,\n LEAD_EMAIL_FILTERS,\n LEAD_FILTERS,\n type LeadEmailFilter,\n type LeadFilter,\n leadMatchesEmailFilter,\n leadMatchesFilter,\n leadMatchesSearch,\n} from '../model/lead-filters'\nimport { type LeadCsvOptions, leadsCsv } from '../model/leads-csv'\nimport { LeadConvertDialog } from './lead-convert-dialog'\nimport { leadSourceLabel, leadSources, leadTimeLabel } from './lead-history-card'\nimport { LeadImportButton } from './lead-import-drawer'\nimport NewLeadDrawer, { type NewLeadValues } from './new-lead-drawer'\nimport { useCrmApi } from './use-crm-api'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { CONVERT_PENDING_ERASURE_REASON } from './lead-properties-card'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { LeadStatusChip } from './lead-status-chip'\nimport LeadSurfacesNote from './lead-surfaces-note'\nimport { LeadUnqualifyDialog } from './lead-unqualify-dialog'\nimport LeadsBulkBar from './leads-bulk-bar'\nimport OrgLeadSurfacesNote from './org-lead-surfaces-note'\n\n/**\n * How many leads the section reads: the newest by last seen, plus one probe\n * row so \"there are more\" is a fact rather than a guess at the boundary.\n *\n * A CEILING and a client-side filter rather than a paged status query, and\n * the reason is the lead documents themselves. Every lead the capture door\n * writes carries NO `status` — the field exists only once somebody in the\n * CRM has touched the lead — and Firestore cannot select documents by a\n * field's absence: `where('status','in',[…])`, `!=` and `not-in` all skip a\n * document without the field. A server-side \"open leads\" query would\n * therefore hide every lead nobody has worked yet, which is the entire\n * population the section exists to show on the day it ships. So the query\n * is the one order every lead can satisfy (`lastSeenAtMs`, stamped on every\n * capture), and the status filter narrows the loaded window — said out loud\n * beneath the table whenever the window is not the whole collection.\n *\n * The window is then PAGED in memory under the shared footer, the way the\n * workspace pickers page a slice of a window they cannot re-key: the rows\n * are already in the snapshot, so turning a page costs nothing, and the\n * footer's count is exact because it counts the filtered window rather than\n * a collection nobody has measured.\n */\nconst LEADS_WINDOW = 200\n\n/**\n * One row of the list. `$id` keys the grid — the document id under a site,\n * `{hostId}/{leadId}` at the organization level, where a lead's id is a\n * person key the same on every site that met the person — and `leadId`\n * and `hostId` say which document, under which site, a write or a link\n * names.\n */\ntype LeadRow = Record<string, unknown> &\n CrmLeadFields & { $id: string; leadId: string; hostId: string }\n\n/**\n * `/crm/leads` — the people a site has met but not yet qualified (AGL-2608).\n *\n * A section of its own, the way Salesforce keeps Leads apart from Contacts:\n * a lead is a capture — a form, a booking, a sign-up — that somebody has\n * still to work, and it converts into a contact, a company and a deal when\n * it is real. Reads `hosts/{hostId}/leads`, host-scoped by path, so there is\n * no `visibleTo` filter; the Firestore rules admit any member of the site to\n * read it and an admin, editor or author to update it, which is what makes\n * the inline status and owner changes client-direct writes.\n *\n * At the ORGANIZATION level (AGL-2630) there is no one site to read: the\n * section opens the same query under every site the org has (`useOrgLeads`)\n * and lists the merged window with a Site column, every row naming the site\n * its writes and its link go to. The per-site notes — which of a site's\n * forms file a lead — belong to a site's own hub and are not drawn here.\n */\nexport function CrmLeadsSection(props: ConsolePluginPageProps) {\n const { hostId, org, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { orgId, createHostId } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const roster = useOrgMemberOptions(orgId)\n const routes = crmRoutes(basePath ?? '')\n\n // Under a site: the site's own window, rows keyed by document id.\n const site = useFirestoreCollection<Record<string, unknown> & CrmLeadFields & { $id: string }>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('lastSeenAtMs', 'desc'),\n limit(LEADS_WINDOW + 1),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n // At the organization level: every site's window, merged.\n const orgHostIds = useMemo(\n () => (hostId ? [] : (mount?.hosts ?? []).map((host) => host.id)),\n [hostId, mount?.hosts],\n )\n const orgLeads = useOrgLeads({ hostIds: orgHostIds, windowSize: LEADS_WINDOW })\n const leadDocs = useMemo<LeadRow[]>(\n () =>\n hostId\n ? site.data.map((row) => ({ ...row, leadId: row.$id, hostId }))\n : orgLeads.data,\n [hostId, site.data, orgLeads.data],\n )\n const status = hostId\n ? site.status\n : mount?.hostsReady && !orgHostIds.length\n ? 'success'\n : orgLeads.status\n const truncated = hostId ? leadDocs.length > LEADS_WINDOW : orgLeads.truncated\n const window = useMemo(() => leadDocs.slice(0, LEADS_WINDOW), [leadDocs])\n\n /*\n * The `Show` filter is the saved VIEW'S (AGL-2617): a saved view of leads\n * holds the status beside the columns and the sort, and the select below\n * writes into it. Unset reads as `open`, which is what the section opened\n * on before views existed and the one reading a query cannot express.\n */\n const views = useCrmSavedView({\n section: 'leads',\n hostId,\n org: props.org,\n basePath: basePath ?? '',\n })\n const filter: LeadFilter = useMemo(() => {\n const value = views.state.filters.find((clause) => clause.field === 'status')?.value\n return (LEAD_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadFilter)\n : 'open'\n }, [views.state.filters])\n /*\n * The `Email` filter (AGL-3245) is the view's too, as an `emailState`\n * clause beside the status one. Each setter keeps the other's clause:\n * narrowing to bounced leads does not reopen the unqualified ones.\n */\n const emailFilter: LeadEmailFilter = useMemo(() => {\n const value = views.state.filters.find((clause) => clause.field === 'emailState')?.value\n return (LEAD_EMAIL_FILTERS as readonly string[]).includes(value ?? '')\n ? (value as LeadEmailFilter)\n : 'any'\n }, [views.state.filters])\n const setFilter = useCallback(\n (next: LeadFilter) =>\n views.setFilters([\n ...views.state.filters.filter((clause) => clause.field !== 'status'),\n ...(next === 'open' ? [] : [{ field: 'status', op: 'equals', value: next }]),\n ]),\n [views.setFilters, views.state.filters],\n )\n const setEmailFilter = useCallback(\n (next: LeadEmailFilter) =>\n views.setFilters([\n ...views.state.filters.filter((clause) => clause.field !== 'emailState'),\n ...(next === 'any' ? [] : [{ field: 'emailState', op: 'equals', value: next }]),\n ]),\n [views.setFilters, views.state.filters],\n )\n // The label's id, so the filter's combobox is named \"Show\" rather than\n // after the option it shows — see `LeadOwnerSelect`.\n const filterLabelId = useId()\n const emailFilterLabelId = useId()\n /*\n * The search box is the SECTION'S, not the grid's (AGL-3246). The grid's\n * quick filter runs over the rows the grid holds, and the grid holds one\n * PAGE of the window — so a lead on page three answered \"no match\" while\n * the footer below went on counting the unfiltered window. The term\n * narrows the whole loaded window here, beside the status filter and\n * before the footer's count and the page slice, over the fields a person\n * types to find a lead: name, email, company, title and tags.\n */\n const [search, setSearch] = useState('')\n const rows = useMemo(\n () =>\n window.filter(\n (lead) =>\n leadMatchesFilter(lead, filter) &&\n leadMatchesEmailFilter(lead, emailFilter) &&\n leadMatchesSearch(lead, search),\n ),\n [window, filter, emailFilter, search],\n )\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n // A new filter or search term starts on page one: page three of the open\n // leads is not a page of the unqualified ones, and an out-of-range page\n // renders empty.\n useEffect(() => {\n setPage(0)\n }, [filter, emailFilter, search])\n const pageRows = useMemo(\n () => rows.slice(page * pageSize, (page + 1) * pageSize),\n [rows, page, pageSize],\n )\n\n /*\n * The ticked rows, for the bulk bar (AGL-2662). Cleared when the filter or\n * the search term changes: a selection made on the open leads is not a\n * selection of the unqualified ones, and the bar's count would be over\n * rows no longer listed.\n */\n const [selectedIds, setSelectedIds] = useState<string[]>([])\n useEffect(() => setSelectedIds([]), [filter, emailFilter, search])\n // How the file names the owner and, at the org level, the site.\n const csvOptions: LeadCsvOptions = useMemo(\n () => ({\n ownerEmail: roster.emailFor,\n ...(hostId ? {} : { siteName: (id: string) => mount?.siteName(id) }),\n }),\n [roster.emailFor, hostId, mount],\n )\n // The listed window — every row the filter and the search admit, not just\n // the page.\n const handleExport = useCallback(() => {\n downloadTextFile('leads.csv', 'text/csv', leadsCsv(rows, csvOptions))\n }, [rows, csvOptions])\n\n const [assigning, setAssigning] = useState<LeadRow | null>(null)\n const [unqualifying, setUnqualifying] = useState<LeadRow | null>(null)\n // The row whose conversion dialog is open (AGL-2641) — the same dialog\n // the lead's page opens, fed the row so the list is one click shorter.\n const [converting, setConverting] = useState<LeadRow | null>(null)\n\n /*==========================================\n * NEW LEAD (AGL-3231) — Salesforce's New Lead, in a drawer over the\n * list. The route files the lead through the one lead door under the\n * mounted site, or at the organization level under the site the drawer's\n * picker named; the drawer holds its submit until one is known. It makes\n * a lead and nothing else — the conversion is what makes the contact.\n *=========================================*/\n const crmApi = useCrmApi(createHostId)\n const [createOpen, setCreateOpen] = useState(false)\n const [createBusy, setCreateBusy] = useState(false)\n const [createError, setCreateError] = useState<string | null>(null)\n const handleCreate = useCallback(\n async (values: NewLeadValues) => {\n setCreateBusy(true)\n setCreateError(null)\n try {\n const { response, payload } = await crmApi('leads-create', {\n email: values.email,\n ...(values.name ? { name: values.name } : {}),\n ...(values.company ? { company: values.company } : {}),\n ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),\n ...(values.phone ? { phone: values.phone } : {}),\n ...(values.website ? { website: values.website } : {}),\n ...(values.leadSource ? { leadSource: values.leadSource } : {}),\n ...(values.address ? { address: values.address } : {}),\n ...(values.tags.length ? { tags: values.tags } : {}),\n ...(values.ownerUid ? { ownerUid: values.ownerUid } : {}),\n ...(values.notes ? { notes: values.notes } : {}),\n status: values.status,\n })\n if (!response.ok) {\n // The route's own sentence, shown above the form unchanged.\n setCreateError(String(payload['error'] ?? 'The lead could not be added.'))\n return\n }\n // The activity entry is the route's: it verified the caller and\n // performed the write.\n enqueueSnackbar(\n payload['created']\n ? 'Lead added'\n : 'This site already held a lead for that address — it was updated',\n { variant: 'success', persist: false },\n )\n setCreateOpen(false)\n } catch (error) {\n console.error(error)\n setCreateError('The lead could not be added.')\n } finally {\n setCreateBusy(false)\n }\n },\n [crmApi, enqueueSnackbar],\n )\n\n const writeLead = useCallback(\n async (lead: LeadRow, fields: Record<string, unknown>, done: string) => {\n try {\n await updateDoc(doc(firestore, 'hosts', lead.hostId, 'leads', lead.leadId), {\n ...fields,\n updatedAt: serverTimestamp(),\n })\n enqueueSnackbar(done, { variant: 'success', persist: false })\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n }\n },\n [firestore, enqueueSnackbar],\n )\n\n const columns = useMemo<GridColDef[]>(\n () => [\n {\n field: 'name',\n headerName: 'Lead',\n flex: 1.4,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) => String(row['name'] || row['email'] || ''),\n renderCell: ({ row }: { row: LeadRow }) => (\n <Stack spacing={0} sx={{ minWidth: 0, justifyContent: 'center', height: '100%' }}>\n <Typography variant=\"body2\" noWrap>\n {String(row['name'] || row['email'] || row.$id)}\n </Typography>\n {row['name'] ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {String(row['email'] ?? '')}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n // The lead's own profile (AGL-3231): the two facts a work queue is\n // scanned by, beside the person.\n {\n field: 'company',\n headerName: 'Company',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => String(row.company ?? ''),\n },\n {\n field: 'jobTitle',\n headerName: 'Title',\n flex: 0.9,\n minWidth: 130,\n valueGetter: (_value, row: LeadRow) => String(row.jobTitle ?? ''),\n },\n {\n field: 'status',\n headerName: 'Status',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => Aglyn.crmLeadStatus(row),\n renderCell: ({ row }: { row: LeadRow }) => (\n <InlineStatus\n lead={row}\n onChange={(next) => {\n if (next === 'unqualified') {\n setUnqualifying(row)\n return\n }\n void writeLead(\n row,\n {\n status: next,\n ...(Aglyn.crmLeadStatus(row) === 'unqualified'\n ? { unqualifiedReason: deleteField() }\n : {}),\n },\n 'Status updated',\n )\n }}\n />\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the chip the lead's page\n * carries, so a bounced lead is told apart in the queue it is worked\n * from; its label for the sort and the export.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.9,\n minWidth: 150,\n valueGetter: (_value, row: LeadRow) => {\n const state = Aglyn.readEmailState(row)\n return state ? Aglyn.EMAIL_STATE_LABELS[state.status] : ''\n },\n renderCell: ({ row }: { row: LeadRow }) => {\n const state = Aglyn.readEmailState(row)\n return state ? (\n <CrmEmailStateChip state={state} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n )\n },\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n row.ownerUid ? roster.labelFor(row.ownerUid) : 'Unassigned',\n },\n // Only at the organization level, where a row can be any site's.\n ...(hostId\n ? []\n : [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 0.9,\n minWidth: 140,\n valueGetter: (_value: unknown, row: LeadRow) =>\n mount?.siteName(row.hostId) ?? row.hostId,\n } satisfies GridColDef,\n ]),\n {\n field: 'sources',\n headerName: 'Source',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) =>\n leadSources(row).map(leadSourceLabel).join(', '),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 0.9,\n minWidth: 140,\n valueGetter: (_value, row: LeadRow) => (row.tags ?? []).join(', '),\n },\n {\n field: 'lastSeenAtMs',\n headerName: 'Last seen',\n flex: 0.9,\n minWidth: 160,\n valueGetter: (_value, row: LeadRow) =>\n leadTimeLabel(row['lastSeenAtMs'] ?? row['createdAt']),\n },\n {\n field: 'actions',\n headerName: '',\n width: 56,\n sortable: false,\n filterable: false,\n disableColumnMenu: true,\n renderCell: ({ row }: { row: LeadRow }) => {\n /*\n * Why Convert is refused, in the order the lead's page refuses it:\n * a converted lead has its contact already, a closed one was\n * judged not real, and a person with an erasure pending must not\n * be captured again — a conversion is a capture (AGL-2623).\n */\n const converted = Boolean(row.convertedContactId)\n const erasurePending = Aglyn.readErasureRequestedAtMs(row) !== null\n const convertRefusal = converted\n ? 'This lead was converted'\n : !Aglyn.isCrmLeadOpen(row)\n ? 'This lead was unqualified'\n : erasurePending\n ? CONVERT_PENDING_ERASURE_REASON\n : null\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{ display: 'flex', alignItems: 'center', height: '100%' }}\n >\n <RowActionsMenu\n label={String(row['email'] ?? row.$id)}\n items={[\n {\n key: 'open',\n label: 'Open lead',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: routes.lead(row.leadId, hostId ? null : row.hostId),\n },\n {\n key: 'convert',\n label: 'Convert…',\n icon: <MdiIcon path={mdiAccountConvertOutline.path} size={0.8} />,\n onClick: () => setConverting(row),\n disabled: convertRefusal !== null,\n disabledReason: convertRefusal ?? undefined,\n },\n {\n key: 'assign',\n label: 'Assign owner',\n icon: <MdiIcon path={mdiAccountTieOutline.path} size={0.8} />,\n onClick: () => setAssigning(row),\n },\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />,\n onClick: () => setUnqualifying(row),\n disabled: !Aglyn.isCrmLeadOpen(row) || Boolean(row.convertedContactId),\n disabledReason: row.convertedContactId\n ? 'This lead was converted'\n : 'This lead is already closed',\n },\n ]}\n />\n </Box>\n )\n },\n },\n ],\n [roster, routes, writeLead, hostId, mount],\n )\n /* The column and sort models are the view's (AGL-2617). */\n const grid = useCrmViewGrid(views, columns)\n\n return (\n <>\n <CardDisplay\n header={'Leads'}\n help={Aglyn.pluginDocsHelp('contacts', { anchor: '#whats-in-the-crm-area' })}\n actions={\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n {/* The saved view this list is showing, beside the status it narrows to (AGL-2617). */}\n <CrmViewsControl controller={views} allLabel=\"All leads\" />\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={filterLabelId}>{'Show'}</InputLabel>\n <Select\n labelId={filterLabelId}\n label=\"Show\"\n value={filter}\n onChange={(event) => setFilter(event.target.value as LeadFilter)}\n >\n {LEAD_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {/* The verdict on the address (AGL-3245): the bounced, the blocked, the ones who left. */}\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <InputLabel id={emailFilterLabelId}>{'Email'}</InputLabel>\n <Select\n labelId={emailFilterLabelId}\n label=\"Email\"\n value={emailFilter}\n onChange={(event) => setEmailFilter(event.target.value as LeadEmailFilter)}\n >\n {LEAD_EMAIL_FILTERS.map((option) => (\n <MenuItem key={option} value={option}>\n {LEAD_EMAIL_FILTER_LABELS[option]}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n <TextField\n size=\"small\"\n value={search}\n onChange={(event) => setSearch(event.target.value)}\n placeholder=\"Search leads\"\n slotProps={{\n input: {\n startAdornment: (\n <InputAdornment position=\"start\">\n <MdiIcon path={mdiMagnify.path} size={0.8} />\n </InputAdornment>\n ),\n },\n htmlInput: { 'aria-label': 'Search leads', type: 'search' },\n }}\n sx={{ minWidth: 200 }}\n />\n <LeadImportButton hostId={hostId} />\n <Button size=\"small\" onClick={handleExport} disabled={!rows.length}>\n {'Export CSV'}\n </Button>\n <Button size=\"small\" variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'New lead'}\n </Button>\n </Stack>\n }\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/* Which surfaces file a lead, by name (AGL-2612) — under a site its own, at the org level every site's (AGL-2638). */}\n {hostId ? <LeadSurfacesNote hostId={hostId} /> : <OrgLeadSurfacesNote />}\n {status === 'success' && window.length === 0 ? (\n <EmptyStateComponent\n label={'No leads yet'}\n description={'Bookings and lead-routed forms on your site become leads on their own — or add one with New lead, or bring a list in with Import CSV.'}\n />\n ) : status === 'success' && rows.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`No ${LEAD_FILTER_LABELS[filter].toLowerCase()} leads` +\n (search.trim() ? ` match “${search.trim()}”` : '') +\n ` among the ${window.length.toLocaleString()} most recently seen.`}\n </Typography>\n ) : (\n <>\n <LeadsBulkBar\n rows={rows}\n selected={selectedIds}\n onSelectedChange={setSelectedIds}\n roster={roster}\n csv={csvOptions}\n orgId={orgId}\n hostId={hostId}\n />\n <CrmColumnOrderProvider value={grid.columnOrder}>\n <ListTable\n rows={pageRows}\n columns={grid.columns}\n slots={CRM_LIST_SLOTS}\n selectable={{ selected: selectedIds, onChange: setSelectedIds }}\n loading={status === 'loading'}\n onOpen={(_id, row: LeadRow) =>\n router.push(routes.lead(row.leadId, hostId ? null : row.hostId))\n }\n // Columns and sort are the view's, controlled (AGL-2617).\n columnVisibilityModel={grid.columnVisibilityModel}\n onColumnVisibilityModelChange={grid.onColumnVisibilityModelChange}\n sortModel={grid.sortModel}\n onSortModelChange={grid.onSortModelChange}\n // The search is the section's, above: the grid's own box\n // would search this page alone.\n quickFilter={false}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n </CrmColumnOrderProvider>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={pageRows.length}\n count={rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`Showing the ${LEADS_WINDOW.toLocaleString()} most recently seen ` +\n 'leads. The search box and the status filter narrow these ' +\n `${LEADS_WINDOW.toLocaleString()} only; older leads are still ` +\n 'listed in the Inbox and reached by campaign audiences.'}\n </Alert>\n ) : null}\n </Stack>\n </CardDisplay>\n <NewLeadDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n hostId={hostId ?? null}\n org={org as Record<string, unknown> | undefined}\n busy={createBusy}\n error={createError}\n roster={roster}\n onSubmit={(values) => void handleCreate(values)}\n />\n <AssignOwnerDialog\n lead={assigning}\n roster={roster}\n onClose={() => setAssigning(null)}\n onAssign={(uid) => {\n if (!assigning) return\n void writeLead(\n assigning,\n { ownerUid: uid || deleteField() },\n uid ? 'Owner assigned' : 'Owner cleared',\n )\n setAssigning(null)\n }}\n />\n <LeadUnqualifyDialog\n open={Boolean(unqualifying)}\n onClose={() => setUnqualifying(null)}\n hostId={unqualifying?.hostId ?? hostId ?? ''}\n leadId={unqualifying?.leadId ?? ''}\n leadLabel={String(unqualifying?.['name'] || unqualifying?.['email'] || '')}\n />\n {/* The row's site, not the mounted one: at the organization level a\n lead is its own site's record, and the conversion is that site's\n capture (AGL-2641). Under a site the two are the same. */}\n <LeadConvertDialog\n open={Boolean(converting)}\n onClose={() => setConverting(null)}\n hostId={converting?.hostId ?? hostId ?? ''}\n orgId={orgId}\n org={org as Record<string, unknown> | undefined}\n leadId={converting?.leadId ?? ''}\n lead={converting ?? {}}\n basePath={basePath ?? ''}\n roster={roster}\n />\n </>\n )\n}\nCrmLeadsSection.displayName = 'CrmLeadsSection'\n\n/**\n * The status, editable in place for a lead that is still open.\n *\n * A converted lead shows the chip alone: its status IS the conversion, and\n * the route stamped it. The select stops its click from reaching the row, or\n * every status change would also open the record.\n */\nfunction InlineStatus(props: {\n lead: LeadRow\n onChange: (next: CrmLeadStatus) => void\n}) {\n const { lead, onChange } = props\n if (lead.convertedContactId) {\n return (\n <Box sx={{ display: 'flex', alignItems: 'center', height: '100%' }}>\n <LeadStatusChip lead={lead} />\n </Box>\n )\n }\n const status = Aglyn.crmLeadStatus(lead)\n return (\n <Box\n onClick={(event) => event.stopPropagation()}\n sx={{ display: 'flex', alignItems: 'center', height: '100%', width: '100%' }}\n >\n <Select\n size=\"small\"\n variant=\"standard\"\n disableUnderline\n value={status}\n onChange={(event) => onChange(event.target.value as CrmLeadStatus)}\n renderValue={() => <LeadStatusChip lead={lead} />}\n sx={{ width: '100%' }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{Aglyn.CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n <MenuItem value=\"unqualified\">{`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}</MenuItem>\n </Select>\n </Box>\n )\n}\nInlineStatus.displayName = 'InlineStatus'\n\n/** Hand a lead to a team member. */\nfunction AssignOwnerDialog(props: {\n lead: LeadRow | null\n roster: OrgMemberOptions\n onClose: () => void\n onAssign: (uid: string) => void\n}) {\n const { lead, roster, onClose, onAssign } = props\n const [uid, setUid] = useState<string | null>(null)\n const current = uid ?? String(lead?.ownerUid ?? '')\n return (\n <Dialog\n open={Boolean(lead)}\n onClose={onClose}\n maxWidth=\"xs\"\n fullWidth\n slotProps={{ transition: { onExited: () => setUid(null) } }}\n >\n <DialogTitle>{`Assign ${String(lead?.['name'] || lead?.['email'] || 'lead')}`}</DialogTitle>\n <DialogContent>\n <Box sx={{ pt: 1 }}>\n <LeadOwnerSelect value={current} onChange={setUid} roster={roster} size=\"medium\" />\n </Box>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose}>{'Cancel'}</Button>\n <Button variant=\"contained\" onClick={() => onAssign(current)}>\n {'Assign'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nAssignOwnerDialog.displayName = 'AssignOwnerDialog'\n\nexport default CrmLeadsSection\n"],"names":["Aglyn","mdiAccountArrowRight","mdiAccountCancelOutline","mdiAccountConvertOutline","mdiAccountTieOutline","mdiMagnify","CardDisplay","MdiIcon","ListPagination","ListTable","useOrgMemberOptions","useCrmOrgMount","useCrmSavedView","useCrmScope","useCrmViewGrid","CRM_LIST_SLOTS","CrmColumnOrderProvider","useOrgLeads","CrmViewsControl","RowActionsMenu","TABLE_PAGE_SIZE_DEFAULT","EmptyStateComponent","useSnackbar","useFirestore","useFirestoreCollection","Alert","Box","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControl","InputAdornment","InputLabel","MenuItem","Select","Stack","TextField","Typography","collection","deleteField","doc","limit","orderBy","query","serverTimestamp","updateDoc","useRouter","useCallback","useEffect","useId","useMemo","useState","downloadTextFile","crmRoutes","LEAD_FILTER_LABELS","LEAD_EMAIL_FILTER_LABELS","LEAD_EMAIL_FILTERS","LEAD_FILTERS","leadMatchesEmailFilter","leadMatchesFilter","leadMatchesSearch","leadsCsv","LeadConvertDialog","leadSourceLabel","leadSources","leadTimeLabel","LeadImportButton","NewLeadDrawer","useCrmApi","LeadOwnerSelect","CONVERT_PENDING_ERASURE_REASON","CrmEmailStateChip","LeadStatusChip","LeadSurfacesNote","LeadUnqualifyDialog","LeadsBulkBar","OrgLeadSurfacesNote","LEADS_WINDOW","CrmLeadsSection","props","unqualifying","converting","hostId","org","basePath","firestore","router","enqueueSnackbar","orgId","createHostId","mount","roster","routes","site","idField","orgHostIds","hosts","map","host","id","orgLeads","hostIds","windowSize","leadDocs","data","row","leadId","$id","status","hostsReady","length","truncated","window","slice","views","section","filter","value","state","filters","find","clause","field","includes","emailFilter","setFilter","next","setFilters","op","setEmailFilter","filterLabelId","emailFilterLabelId","search","setSearch","rows","lead","page","setPage","pageSize","setPageSize","pageRows","selectedIds","setSelectedIds","csvOptions","ownerEmail","emailFor","siteName","handleExport","assigning","setAssigning","setUnqualifying","setConverting","crmApi","createOpen","setCreateOpen","createBusy","setCreateBusy","createError","setCreateError","handleCreate","values","response","payload","email","name","company","jobTitle","phone","website","leadSource","address","tags","ownerUid","notes","ok","String","variant","persist","error","console","writeLead","fields","done","updatedAt","Error","message","columns","headerName","flex","minWidth","valueGetter","_value","renderCell","spacing","sx","justifyContent","height","noWrap","color","crmLeadStatus","InlineStatus","onChange","unqualifiedReason","readEmailState","EMAIL_STATE_LABELS","labelFor","join","width","sortable","filterable","disableColumnMenu","converted","Boolean","convertedContactId","erasurePending","readErasureRequestedAtMs","convertRefusal","isCrmLeadOpen","onClick","event","stopPropagation","display","alignItems","label","items","key","icon","path","size","href","disabled","disabledReason","undefined","grid","header","help","pluginDocsHelp","anchor","actions","direction","controller","allLabel","labelId","target","option","placeholder","slotProps","input","startAdornment","position","htmlInput","type","contentGutterX","contentGutterY","description","toLowerCase","trim","toLocaleString","selected","onSelectedChange","csv","columnOrder","slots","selectable","loading","onOpen","_id","push","columnVisibilityModel","onColumnVisibilityModelChange","sortModel","onSortModelChange","quickFilter","hideFooter","rowCount","count","onPageChange","onPageSizeChange","severity","open","onClose","busy","onSubmit","AssignOwnerDialog","onAssign","uid","leadLabel","displayName","disableUnderline","renderValue","CRM_LEAD_STATUS_LABELS","new","working","unqualified","setUid","current","maxWidth","fullWidth","transition","onExited","pt"],"mappings":"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"}
|
|
@@ -193,6 +193,23 @@
|
|
|
193
193
|
'equals',
|
|
194
194
|
'isNotEmpty'
|
|
195
195
|
]
|
|
196
|
+
},
|
|
197
|
+
/*
|
|
198
|
+
* The verdict on the address (AGL-3245) — window-only, on the shared row,
|
|
199
|
+
* and `nullable` because most records carry none: "is empty" is the
|
|
200
|
+
* filter for people nothing has been said about.
|
|
201
|
+
*/ {
|
|
202
|
+
column: 'emailState',
|
|
203
|
+
kind: 'exact',
|
|
204
|
+
path: 'emailState.status',
|
|
205
|
+
windowOnly: true,
|
|
206
|
+
presence: 'nullable',
|
|
207
|
+
operators: [
|
|
208
|
+
'equals',
|
|
209
|
+
'isAnyOf',
|
|
210
|
+
'isEmpty',
|
|
211
|
+
'isNotEmpty'
|
|
212
|
+
]
|
|
196
213
|
}
|
|
197
214
|
];
|
|
198
215
|
/**
|
|
@@ -288,7 +305,8 @@
|
|
|
288
305
|
[CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',
|
|
289
306
|
[CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',
|
|
290
307
|
[CRM_CONTACT_VIEW_FIELDS.source]: 'Source',
|
|
291
|
-
[CRM_CONTACT_VIEW_FIELDS.company]: 'Company'
|
|
308
|
+
[CRM_CONTACT_VIEW_FIELDS.company]: 'Company',
|
|
309
|
+
emailState: 'Email'
|
|
292
310
|
};
|
|
293
311
|
|
|
294
312
|
//# sourceMappingURL=contact-filters.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/contact-filters.ts"],"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\nimport {\n type ContactFieldDefinition,\n CRM_CONTACT_VIEW_FIELDS,\n crmContactCustomColumn,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/*\n * Contacts (`orgs/{orgId}/contacts`, read scoped to a host).\n *\n * The card's listener is `limit(1000)` and stays that way — nobody needs\n * forty thousand rows streamed into a table. What that cap must NOT do is\n * decide what a search can find, and it did: an org with 40,000 contacts\n * searched 1,000 of them and answered \"no contacts match\" for the rest. The\n * head-count already learned this lesson (AGL-1706, a server aggregate); the\n * search is the same mistake one field over.\n *\n * ⛔ `marketingConsent`, `ordersCount` and `ltvCents` are written only when\n * they apply, so a `false`/`0` filter would return nothing rather than\n * everyone else. `isNotEmpty` is exact on them and is what the menu offers.\n */\nexport const CONTACT_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: 'nameLower',\n tokensPath: 'nameTokens',\n reversedPath: 'nameReversed',\n },\n {\n // `normalizeContactEmail` lower-cases before every write, so the stored\n // value IS its own normalized key.\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n },\n {\n /*\n * Already an array of lower-cased tags, so `contains` is a plain\n * `array-contains` — the one place the word-level caveat does not apply,\n * because a tag is matched whole rather than by word prefix.\n */\n column: 'tags',\n kind: 'text',\n path: 'tags',\n tokensPath: 'tags',\n // Ordered by the timestamp the list already sorts on, not by the tag\n // array — `orderBy` on the same field an `array-contains` matches is a\n // composite index nobody wants to explain.\n containsOrderBy: 'updatedAt',\n // `isAnyOf` is a saved segment's \"any of these tags\" (AGL-2617). The\n // query cannot serve it — `array-contains-any` is the scope clause's\n // — so the translator refuses it and the list matches it over the\n // window, which the caption says.\n operators: ['contains', 'isAnyOf', 'isNotEmpty'],\n },\n {\n /*\n * The forms a person came in through — the top-level `formIds` mirror\n * of the interactions' `formId` (AGL-2612), matched whole and as typed\n * because a form id is minted with mixed case. The form's own page links\n * here with its id; nobody types one. Ordered by the list's own\n * timestamp for the reason `tags` is, served by the\n * `(formIds CONTAINS, updatedAt DESC)` index.\n *\n * ⚠️ An `array-contains` on the mirror cannot share a query with the\n * `visibleTo` scope clause, so the list drops that clause for this one\n * filter and the rules admit the read to an org-wide member only — the\n * caveat the company contacts card states, stated again on the list.\n */\n column: 'formIds',\n kind: 'text',\n path: 'formIds',\n tokensPath: 'formIds',\n verbatimTokens: true,\n containsOrderBy: 'updatedAt',\n operators: ['contains'],\n },\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n { column: 'ordersCount', kind: 'number', path: 'ordersCount' },\n { column: 'ltvCents', kind: 'number', path: 'ltvCents' },\n { column: 'createdAt', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'updatedAt', kind: 'date', path: 'updatedAt', presence: 'always' },\n /*\n * When the earliest open task is due (AGL-2661) — window-only, because\n * the value is on the SHARED row but a record written before the field\n * existed has none, and Firestore cannot query for absence. `nullable`\n * is honest: the writer stores `null` for nothing scheduled, and the\n * matcher reads absent the same way, so \"is empty\" is the \"No next\n * activity\" filter over the loaded window.\n */\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n windowOnly: true,\n presence: 'nullable',\n },\n /*\n * THE FACET FIELDS (AGL-2617) — window-only, every one of them.\n *\n * An owner, a stage, a company and the capture sources live on the\n * viewing group's facet of the shared contact row, and a facet path is\n * per group: a `where` on it would need an index per group, and the\n * translator would send a scoped member a query the rules deny. So these\n * are declared for the menu and for a saved view, matched over the loaded\n * window against the flattened row (`ContactRecord` carries each at the\n * top), and never put on a query — `windowOnly` is what holds that line.\n * The paths are the ROW'S, which is what the matcher reads.\n *\n * The values are picked, not typed — a uid from the roster, a stage from\n * the fixed list, a source from its labels, a company from the picker —\n * so `equals` and `isAnyOf` are what they offer; the section supplies\n * the choices. The column names are the contract `CRM_CONTACT_VIEW_FIELDS`\n * states, because the dynamic-list translator reads a view by them.\n */\n {\n column: CRM_CONTACT_VIEW_FIELDS.owner,\n kind: 'exact',\n path: 'ownerUid',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.stage,\n kind: 'exact',\n path: 'lifecycleStage',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n // The `sources` presence map, matched on its keys — see `keysOf`.\n column: CRM_CONTACT_VIEW_FIELDS.source,\n kind: 'exact',\n path: 'sources',\n keysOf: true,\n windowOnly: true,\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.company,\n kind: 'exact',\n path: 'companyId',\n windowOnly: true,\n operators: ['equals', 'isNotEmpty'],\n },\n]\n\n/**\n * One filter field per active custom contact field (AGL-2617).\n *\n * A custom value lives under the facet's `custom` map, so every one is\n * window-only for the reason the facet fields above are, and its column is\n * the one the table shows it under — `crmContactCustomColumn(key)` — so a\n * clause and a column agree on the name and a saved view's columns and\n * filters name the same thing. The kind follows the definition's type, and\n * `nullable` is how a cleared value is stored, which is what makes both\n * empty operators honest here.\n */\nexport function contactCustomFilterFields(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'retiredAt'>[],\n): ListFilterField[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => {\n const column = crmContactCustomColumn(definition.key)\n const path = `custom.${definition.key}`\n switch (definition.type) {\n case 'number':\n return { column, kind: 'number', path, windowOnly: true, presence: 'nullable' }\n case 'date':\n return { column, kind: 'date', path, windowOnly: true, presence: 'nullable' }\n case 'checkbox':\n return { column, kind: 'boolean', path, windowOnly: true }\n case 'select':\n return {\n column,\n kind: 'exact',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n }\n default:\n return {\n column,\n kind: 'text',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['contains', 'equals', 'startsWith', 'isEmpty', 'isNotEmpty'],\n }\n }\n })\n}\n\n/** The custom fields' headers, keyed by the column each filters as. */\nexport function contactCustomFilterHeaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[],\n): Record<string, string> {\n return Object.fromEntries(\n definitions.map((definition) => [\n crmContactCustomColumn(definition.key),\n definition.label || definition.key,\n ]),\n )\n}\n\n/**\n * How every filterable contact field reads — on a chip, in the add-filter\n * picker, and as the header of a filter-only hidden column.\n */\nexport const CONTACT_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Contact',\n email: 'Email',\n tags: 'Tags',\n formIds: 'Form ID',\n hostId: 'Site ID',\n ordersCount: 'Orders',\n ltvCents: 'Lifetime value (cents)',\n createdAt: 'Created',\n updatedAt: 'Updated',\n nextTaskAtMs: 'Next activity',\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'Source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',\n}\n"],"names":["CRM_CONTACT_VIEW_FIELDS","crmContactCustomColumn","CONTACT_LIST_FILTER_FIELDS","column","kind","path","lowerPath","tokensPath","reversedPath","presence","containsOrderBy","operators","verbatimTokens","windowOnly","owner","stage","source","keysOf","company","contactCustomFilterFields","definitions","filter","definition","retiredAt","map","key","type","contactCustomFilterHeaders","Object","fromEntries","label","CONTACT_LIST_FILTER_HEADERS","name","email","tags","formIds","hostId","ordersCount","ltvCents","createdAt","updatedAt","nextTaskAtMs"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/contact-filters.ts"],"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\nimport {\n type ContactFieldDefinition,\n CRM_CONTACT_VIEW_FIELDS,\n crmContactCustomColumn,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/*\n * Contacts (`orgs/{orgId}/contacts`, read scoped to a host).\n *\n * The card's listener is `limit(1000)` and stays that way — nobody needs\n * forty thousand rows streamed into a table. What that cap must NOT do is\n * decide what a search can find, and it did: an org with 40,000 contacts\n * searched 1,000 of them and answered \"no contacts match\" for the rest. The\n * head-count already learned this lesson (AGL-1706, a server aggregate); the\n * search is the same mistake one field over.\n *\n * ⛔ `marketingConsent`, `ordersCount` and `ltvCents` are written only when\n * they apply, so a `false`/`0` filter would return nothing rather than\n * everyone else. `isNotEmpty` is exact on them and is what the menu offers.\n */\nexport const CONTACT_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: 'nameLower',\n tokensPath: 'nameTokens',\n reversedPath: 'nameReversed',\n },\n {\n // `normalizeContactEmail` lower-cases before every write, so the stored\n // value IS its own normalized key.\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n },\n {\n /*\n * Already an array of lower-cased tags, so `contains` is a plain\n * `array-contains` — the one place the word-level caveat does not apply,\n * because a tag is matched whole rather than by word prefix.\n */\n column: 'tags',\n kind: 'text',\n path: 'tags',\n tokensPath: 'tags',\n // Ordered by the timestamp the list already sorts on, not by the tag\n // array — `orderBy` on the same field an `array-contains` matches is a\n // composite index nobody wants to explain.\n containsOrderBy: 'updatedAt',\n // `isAnyOf` is a saved segment's \"any of these tags\" (AGL-2617). The\n // query cannot serve it — `array-contains-any` is the scope clause's\n // — so the translator refuses it and the list matches it over the\n // window, which the caption says.\n operators: ['contains', 'isAnyOf', 'isNotEmpty'],\n },\n {\n /*\n * The forms a person came in through — the top-level `formIds` mirror\n * of the interactions' `formId` (AGL-2612), matched whole and as typed\n * because a form id is minted with mixed case. The form's own page links\n * here with its id; nobody types one. Ordered by the list's own\n * timestamp for the reason `tags` is, served by the\n * `(formIds CONTAINS, updatedAt DESC)` index.\n *\n * ⚠️ An `array-contains` on the mirror cannot share a query with the\n * `visibleTo` scope clause, so the list drops that clause for this one\n * filter and the rules admit the read to an org-wide member only — the\n * caveat the company contacts card states, stated again on the list.\n */\n column: 'formIds',\n kind: 'text',\n path: 'formIds',\n tokensPath: 'formIds',\n verbatimTokens: true,\n containsOrderBy: 'updatedAt',\n operators: ['contains'],\n },\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n { column: 'ordersCount', kind: 'number', path: 'ordersCount' },\n { column: 'ltvCents', kind: 'number', path: 'ltvCents' },\n { column: 'createdAt', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'updatedAt', kind: 'date', path: 'updatedAt', presence: 'always' },\n /*\n * When the earliest open task is due (AGL-2661) — window-only, because\n * the value is on the SHARED row but a record written before the field\n * existed has none, and Firestore cannot query for absence. `nullable`\n * is honest: the writer stores `null` for nothing scheduled, and the\n * matcher reads absent the same way, so \"is empty\" is the \"No next\n * activity\" filter over the loaded window.\n */\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n windowOnly: true,\n presence: 'nullable',\n },\n /*\n * THE FACET FIELDS (AGL-2617) — window-only, every one of them.\n *\n * An owner, a stage, a company and the capture sources live on the\n * viewing group's facet of the shared contact row, and a facet path is\n * per group: a `where` on it would need an index per group, and the\n * translator would send a scoped member a query the rules deny. So these\n * are declared for the menu and for a saved view, matched over the loaded\n * window against the flattened row (`ContactRecord` carries each at the\n * top), and never put on a query — `windowOnly` is what holds that line.\n * The paths are the ROW'S, which is what the matcher reads.\n *\n * The values are picked, not typed — a uid from the roster, a stage from\n * the fixed list, a source from its labels, a company from the picker —\n * so `equals` and `isAnyOf` are what they offer; the section supplies\n * the choices. The column names are the contract `CRM_CONTACT_VIEW_FIELDS`\n * states, because the dynamic-list translator reads a view by them.\n */\n {\n column: CRM_CONTACT_VIEW_FIELDS.owner,\n kind: 'exact',\n path: 'ownerUid',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.stage,\n kind: 'exact',\n path: 'lifecycleStage',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n // The `sources` presence map, matched on its keys — see `keysOf`.\n column: CRM_CONTACT_VIEW_FIELDS.source,\n kind: 'exact',\n path: 'sources',\n keysOf: true,\n windowOnly: true,\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.company,\n kind: 'exact',\n path: 'companyId',\n windowOnly: true,\n operators: ['equals', 'isNotEmpty'],\n },\n /*\n * The verdict on the address (AGL-3245) — window-only, on the shared row,\n * and `nullable` because most records carry none: \"is empty\" is the\n * filter for people nothing has been said about.\n */\n {\n column: 'emailState',\n kind: 'exact',\n path: 'emailState.status',\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n },\n]\n\n/**\n * One filter field per active custom contact field (AGL-2617).\n *\n * A custom value lives under the facet's `custom` map, so every one is\n * window-only for the reason the facet fields above are, and its column is\n * the one the table shows it under — `crmContactCustomColumn(key)` — so a\n * clause and a column agree on the name and a saved view's columns and\n * filters name the same thing. The kind follows the definition's type, and\n * `nullable` is how a cleared value is stored, which is what makes both\n * empty operators honest here.\n */\nexport function contactCustomFilterFields(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'retiredAt'>[],\n): ListFilterField[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => {\n const column = crmContactCustomColumn(definition.key)\n const path = `custom.${definition.key}`\n switch (definition.type) {\n case 'number':\n return { column, kind: 'number', path, windowOnly: true, presence: 'nullable' }\n case 'date':\n return { column, kind: 'date', path, windowOnly: true, presence: 'nullable' }\n case 'checkbox':\n return { column, kind: 'boolean', path, windowOnly: true }\n case 'select':\n return {\n column,\n kind: 'exact',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n }\n default:\n return {\n column,\n kind: 'text',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['contains', 'equals', 'startsWith', 'isEmpty', 'isNotEmpty'],\n }\n }\n })\n}\n\n/** The custom fields' headers, keyed by the column each filters as. */\nexport function contactCustomFilterHeaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[],\n): Record<string, string> {\n return Object.fromEntries(\n definitions.map((definition) => [\n crmContactCustomColumn(definition.key),\n definition.label || definition.key,\n ]),\n )\n}\n\n/**\n * How every filterable contact field reads — on a chip, in the add-filter\n * picker, and as the header of a filter-only hidden column.\n */\nexport const CONTACT_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Contact',\n email: 'Email',\n tags: 'Tags',\n formIds: 'Form ID',\n hostId: 'Site ID',\n ordersCount: 'Orders',\n ltvCents: 'Lifetime value (cents)',\n createdAt: 'Created',\n updatedAt: 'Updated',\n nextTaskAtMs: 'Next activity',\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'Source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',\n emailState: 'Email',\n}\n"],"names":["CRM_CONTACT_VIEW_FIELDS","crmContactCustomColumn","CONTACT_LIST_FILTER_FIELDS","column","kind","path","lowerPath","tokensPath","reversedPath","presence","containsOrderBy","operators","verbatimTokens","windowOnly","owner","stage","source","keysOf","company","contactCustomFilterFields","definitions","filter","definition","retiredAt","map","key","type","contactCustomFilterHeaders","Object","fromEntries","label","CONTACT_LIST_FILTER_HEADERS","name","email","tags","formIds","hostId","ordersCount","ltvCents","createdAt","updatedAt","nextTaskAtMs","emailState"],"mappings":"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"}
|
|
@@ -98,6 +98,12 @@ export interface ContactRecord {
|
|
|
98
98
|
* sees the same next step.
|
|
99
99
|
*/
|
|
100
100
|
nextTaskAtMs: number | null;
|
|
101
|
+
/**
|
|
102
|
+
* The last verdict on the address (AGL-3245) — bounced, blocked,
|
|
103
|
+
* unsubscribed, do-not-contact — or `null` when nothing is known. SHARED
|
|
104
|
+
* like the address it is about.
|
|
105
|
+
*/
|
|
106
|
+
emailState: Aglyn.EmailState | null;
|
|
101
107
|
createdAt?: unknown;
|
|
102
108
|
updatedAt?: unknown;
|
|
103
109
|
}
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage) ? facet.lifecycleStage : '',
|
|
53
53
|
lastEmailEngagementAtMs: typeof facet.lastEmailEngagementAtMs === 'number' && Number.isFinite(facet.lastEmailEngagementAtMs) && facet.lastEmailEngagementAtMs > 0 ? facet.lastEmailEngagementAtMs : null,
|
|
54
54
|
nextTaskAtMs: Aglyn.readNextTaskAtMs(row),
|
|
55
|
+
emailState: Aglyn.readEmailState(row),
|
|
55
56
|
createdAt: row['createdAt'],
|
|
56
57
|
updatedAt: row['updatedAt']
|
|
57
58
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-record.ts"],"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\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n AglynPostalAddress,\n ConsentGroup,\n ContactInteraction,\n ContactLifecycleStage,\n ContactSource,\n} from '@aglyn/aglyn'\n\n/**\n * One contact, as the VIEWING GROUP may see it (AGL-2596).\n *\n * A contact document is shared by every site in the org, and almost nothing\n * on it is legitimately shared: the notes, tags, timeline, profile and\n * commercial figures are the holder's own records and live in that holder's\n * facet. This is the row after the facet read — flat, so the list, the\n * record page and the export can render fields by name without any of them\n * reaching into `facets.{groupId}` for itself. One projection rather than a\n * facet read at every field is what keeps a surface from ever showing\n * another holder's records: there is no field on this shape that came from\n * the top of the document except the two that ARE shared, the address and\n * the canonical name.\n */\nexport interface ContactRecord {\n $id: string\n /**\n * The facet this row was flattened through — the viewing group under a\n * site, the person's primary holder at the org level (AGL-2630). A write\n * that edits the row goes back to the same facet, so a surface that has\n * only the flat record can still address the holder's own fields.\n */\n groupId: string\n /**\n * A site of that holder's group — the viewing site under a site, and at\n * the organization level the site the primary holder was resolved from —\n * which is where a stage move for this row is made and announced. `''` for\n * a row no site holds.\n */\n holderHostId: string\n /** Every site that has captured this person, sorted — the org-level \"Known by\". */\n capturedByHostIds: string[]\n /** The shared identity and the dedupe key. */\n email: string\n /** The other addresses a merge folded into this record (AGL-2625). */\n alternateEmails: string[]\n /** What THIS holder sees — their own override, or the canonical name. */\n name: string\n /** The canonical name: the identity of last resort, shared by every holder. */\n canonicalName: string\n /** This holder's own name for the person, when they have set one. */\n nameOverride: string\n sources: Partial<Record<ContactSource, true>>\n /** This holder's timeline, narrowed to the sites the group covers. */\n interactions: ContactInteraction[]\n tags: string[]\n notes: string\n campaignIds: string[]\n ltvCents: number\n ordersCount: number\n phone: string\n jobTitle: string\n companyName: string\n companyId: string\n /**\n * What linking this person to a company has to know — this holder's link,\n * the shared mirror and the other holders' ids — so the properties card\n * and the bulk bar can plan a link from the row without the document. Not\n * a display field: the mirror is an index every reader of the document\n * already holds, and nothing renders it.\n */\n companyLink: Aglyn.ContactCompanyLinkState\n address: AglynPostalAddress | null\n /** This holder's custom field values, keyed by definition key (AGL-2601). */\n custom?: Record<string, Aglyn.ContactCustomValue>\n /** This holder's attached org-library files, by media id (AGL-2662). */\n mediaIds?: string[]\n ownerUid: string\n /** Empty when the holder has not placed the person in the funnel. */\n lifecycleStage: ContactLifecycleStage | ''\n /**\n * When the person last opened or clicked one of this holder's campaigns\n * (AGL-2616), or `null` when they never have since the stamp shipped.\n */\n lastEmailEngagementAtMs: number | null\n /**\n * When the earliest open task against this person is due (AGL-2661), or\n * `null` when nothing is scheduled. SHARED, not the holder's: a task is\n * filed against the contact document, and every holder reading the row\n * sees the same next step.\n */\n nextTaskAtMs: number | null\n createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * `NO_HOLDER_GROUP` and `contactPrimaryGroup` moved to `@aglyn/aglyn`\n * under AGL-2662 so the server's whole-collection export can flatten a\n * contact the way the organization-level list does — the console app may\n * not import this plugin. Re-exported here, so every caller is unchanged.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from '@aglyn/aglyn'\n\n/** A document off the wire, flattened through one group's facet. */\nexport function contactRecordFromDoc(\n row: Record<string, any>,\n group: ConsentGroup,\n): ContactRecord {\n const facet = Aglyn.readContactFacet(row, group.groupId)\n return {\n $id: String(row['$id'] ?? ''),\n groupId: group.groupId,\n holderHostId: group.hostId,\n capturedByHostIds: Aglyn.contactCaptureHostIds(row),\n email: typeof row['email'] === 'string' ? row['email'] : '',\n alternateEmails: Array.isArray(row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD])\n ? (row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD] as unknown[]).filter(\n (email): email is string => typeof email === 'string',\n )\n : [],\n name: Aglyn.contactDisplayName(row, group.groupId),\n canonicalName: typeof row['name'] === 'string' ? row['name'] : '',\n nameOverride: facet.name ?? '',\n sources: facet.sources,\n interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),\n tags: facet.tags ?? [],\n notes: facet.notes ?? '',\n campaignIds: Aglyn.readContactCampaignIds(row, group.groupId),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n companyName: facet.companyName ?? '',\n companyId: facet.companyId ?? '',\n companyLink: Aglyn.readContactCompanyLink(row, group.groupId),\n address: facet.address ?? null,\n custom: facet.custom ?? {},\n mediaIds: facet.mediaIds ?? [],\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n lastEmailEngagementAtMs:\n typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? facet.lastEmailEngagementAtMs\n : null,\n nextTaskAtMs: Aglyn.readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n createdAt: row['createdAt'],\n updatedAt: row['updatedAt'],\n }\n}\n\n/**\n * Tags as typed — comma-separated — into the shape the facet stores:\n * lower-cased, trimmed, deduplicated and capped, so a tag typed on the\n * record page and one typed in the create drawer are the same tag to the\n * segment filter that matches on them.\n */\nexport function parseContactTags(input: string): string[] {\n return [\n ...new Set(\n input\n .split(',')\n .map((tag) => tag.trim().toLowerCase().slice(0, 40))\n .filter(Boolean)\n .slice(0, 20),\n ),\n ]\n}\n"],"names":["Aglyn","contactPrimaryGroup","NO_HOLDER_GROUP","contactRecordFromDoc","row","group","facet","readContactFacet","groupId","$id","String","holderHostId","hostId","capturedByHostIds","contactCaptureHostIds","email","alternateEmails","Array","isArray","CONTACT_ALTERNATE_EMAILS_FIELD","filter","name","contactDisplayName","canonicalName","nameOverride","sources","interactions","interactionsForGroup","hostIds","tags","notes","campaignIds","readContactCampaignIds","ltvCents","ordersCount","phone","jobTitle","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","readNextTaskAtMs","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-record.ts"],"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\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n AglynPostalAddress,\n ConsentGroup,\n ContactInteraction,\n ContactLifecycleStage,\n ContactSource,\n} from '@aglyn/aglyn'\n\n/**\n * One contact, as the VIEWING GROUP may see it (AGL-2596).\n *\n * A contact document is shared by every site in the org, and almost nothing\n * on it is legitimately shared: the notes, tags, timeline, profile and\n * commercial figures are the holder's own records and live in that holder's\n * facet. This is the row after the facet read — flat, so the list, the\n * record page and the export can render fields by name without any of them\n * reaching into `facets.{groupId}` for itself. One projection rather than a\n * facet read at every field is what keeps a surface from ever showing\n * another holder's records: there is no field on this shape that came from\n * the top of the document except the two that ARE shared, the address and\n * the canonical name.\n */\nexport interface ContactRecord {\n $id: string\n /**\n * The facet this row was flattened through — the viewing group under a\n * site, the person's primary holder at the org level (AGL-2630). A write\n * that edits the row goes back to the same facet, so a surface that has\n * only the flat record can still address the holder's own fields.\n */\n groupId: string\n /**\n * A site of that holder's group — the viewing site under a site, and at\n * the organization level the site the primary holder was resolved from —\n * which is where a stage move for this row is made and announced. `''` for\n * a row no site holds.\n */\n holderHostId: string\n /** Every site that has captured this person, sorted — the org-level \"Known by\". */\n capturedByHostIds: string[]\n /** The shared identity and the dedupe key. */\n email: string\n /** The other addresses a merge folded into this record (AGL-2625). */\n alternateEmails: string[]\n /** What THIS holder sees — their own override, or the canonical name. */\n name: string\n /** The canonical name: the identity of last resort, shared by every holder. */\n canonicalName: string\n /** This holder's own name for the person, when they have set one. */\n nameOverride: string\n sources: Partial<Record<ContactSource, true>>\n /** This holder's timeline, narrowed to the sites the group covers. */\n interactions: ContactInteraction[]\n tags: string[]\n notes: string\n campaignIds: string[]\n ltvCents: number\n ordersCount: number\n phone: string\n jobTitle: string\n companyName: string\n companyId: string\n /**\n * What linking this person to a company has to know — this holder's link,\n * the shared mirror and the other holders' ids — so the properties card\n * and the bulk bar can plan a link from the row without the document. Not\n * a display field: the mirror is an index every reader of the document\n * already holds, and nothing renders it.\n */\n companyLink: Aglyn.ContactCompanyLinkState\n address: AglynPostalAddress | null\n /** This holder's custom field values, keyed by definition key (AGL-2601). */\n custom?: Record<string, Aglyn.ContactCustomValue>\n /** This holder's attached org-library files, by media id (AGL-2662). */\n mediaIds?: string[]\n ownerUid: string\n /** Empty when the holder has not placed the person in the funnel. */\n lifecycleStage: ContactLifecycleStage | ''\n /**\n * When the person last opened or clicked one of this holder's campaigns\n * (AGL-2616), or `null` when they never have since the stamp shipped.\n */\n lastEmailEngagementAtMs: number | null\n /**\n * When the earliest open task against this person is due (AGL-2661), or\n * `null` when nothing is scheduled. SHARED, not the holder's: a task is\n * filed against the contact document, and every holder reading the row\n * sees the same next step.\n */\n nextTaskAtMs: number | null\n /**\n * The last verdict on the address (AGL-3245) — bounced, blocked,\n * unsubscribed, do-not-contact — or `null` when nothing is known. SHARED\n * like the address it is about.\n */\n emailState: Aglyn.EmailState | null\n createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * `NO_HOLDER_GROUP` and `contactPrimaryGroup` moved to `@aglyn/aglyn`\n * under AGL-2662 so the server's whole-collection export can flatten a\n * contact the way the organization-level list does — the console app may\n * not import this plugin. Re-exported here, so every caller is unchanged.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from '@aglyn/aglyn'\n\n/** A document off the wire, flattened through one group's facet. */\nexport function contactRecordFromDoc(\n row: Record<string, any>,\n group: ConsentGroup,\n): ContactRecord {\n const facet = Aglyn.readContactFacet(row, group.groupId)\n return {\n $id: String(row['$id'] ?? ''),\n groupId: group.groupId,\n holderHostId: group.hostId,\n capturedByHostIds: Aglyn.contactCaptureHostIds(row),\n email: typeof row['email'] === 'string' ? row['email'] : '',\n alternateEmails: Array.isArray(row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD])\n ? (row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD] as unknown[]).filter(\n (email): email is string => typeof email === 'string',\n )\n : [],\n name: Aglyn.contactDisplayName(row, group.groupId),\n canonicalName: typeof row['name'] === 'string' ? row['name'] : '',\n nameOverride: facet.name ?? '',\n sources: facet.sources,\n interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),\n tags: facet.tags ?? [],\n notes: facet.notes ?? '',\n campaignIds: Aglyn.readContactCampaignIds(row, group.groupId),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n companyName: facet.companyName ?? '',\n companyId: facet.companyId ?? '',\n companyLink: Aglyn.readContactCompanyLink(row, group.groupId),\n address: facet.address ?? null,\n custom: facet.custom ?? {},\n mediaIds: facet.mediaIds ?? [],\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n lastEmailEngagementAtMs:\n typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? facet.lastEmailEngagementAtMs\n : null,\n nextTaskAtMs: Aglyn.readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\n createdAt: row['createdAt'],\n updatedAt: row['updatedAt'],\n }\n}\n\n/**\n * Tags as typed — comma-separated — into the shape the facet stores:\n * lower-cased, trimmed, deduplicated and capped, so a tag typed on the\n * record page and one typed in the create drawer are the same tag to the\n * segment filter that matches on them.\n */\nexport function parseContactTags(input: string): string[] {\n return [\n ...new Set(\n input\n .split(',')\n .map((tag) => tag.trim().toLowerCase().slice(0, 40))\n .filter(Boolean)\n .slice(0, 20),\n ),\n ]\n}\n"],"names":["Aglyn","contactPrimaryGroup","NO_HOLDER_GROUP","contactRecordFromDoc","row","group","facet","readContactFacet","groupId","$id","String","holderHostId","hostId","capturedByHostIds","contactCaptureHostIds","email","alternateEmails","Array","isArray","CONTACT_ALTERNATE_EMAILS_FIELD","filter","name","contactDisplayName","canonicalName","nameOverride","sources","interactions","interactionsForGroup","hostIds","tags","notes","campaignIds","readContactCampaignIds","ltvCents","ordersCount","phone","jobTitle","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","readNextTaskAtMs","emailState","readEmailState","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type CrmLeadFields, type CrmLeadStatus } from '@aglyn/aglyn';
|
|
17
|
+
import { type EmailStateStatus, type CrmLeadFields, type CrmLeadStatus } from '@aglyn/aglyn';
|
|
18
18
|
/**
|
|
19
19
|
* What the Leads section's `Show` control offers (AGL-2608): the two
|
|
20
20
|
* aggregate views and each status on its own. `open` is the default — the
|
|
@@ -27,3 +27,24 @@ export declare const LEAD_FILTERS: readonly LeadFilter[];
|
|
|
27
27
|
export declare const LEAD_FILTER_LABELS: Record<LeadFilter, string>;
|
|
28
28
|
/** Whether a lead belongs in a filter's view. An absent status is `new`. */
|
|
29
29
|
export declare function leadMatchesFilter(lead: Pick<CrmLeadFields, 'status'>, filter: LeadFilter): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The Leads section's `Email` control (AGL-3245): every lead, the ones a
|
|
32
|
+
* member must not email — any verdict but `ok` — the ones nothing has been
|
|
33
|
+
* said about, or one verdict on its own. Beside `Show`, not inside it: a
|
|
34
|
+
* bounce does not move a lead out of Open, and a queue of bounced leads is
|
|
35
|
+
* a queue of people to reach some other way.
|
|
36
|
+
*/
|
|
37
|
+
export type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus;
|
|
38
|
+
export declare const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[];
|
|
39
|
+
export declare const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string>;
|
|
40
|
+
/** Whether a lead belongs in an email filter's view. */
|
|
41
|
+
export declare function leadMatchesEmailFilter(lead: Readonly<Record<string, unknown>>, filter: LeadEmailFilter): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a lead answers a search term.
|
|
44
|
+
*
|
|
45
|
+
* Case-insensitive, and every whitespace-separated word of the term must
|
|
46
|
+
* appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`
|
|
47
|
+
* both find Morgan Lamphere, `morgan smith` does not. A blank term matches
|
|
48
|
+
* every lead, so the box emptied is the list unfiltered.
|
|
49
|
+
*/
|
|
50
|
+
export declare function leadMatchesSearch(lead: Readonly<Record<string, unknown>>, term: string): boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
15
|
* See the License for the specific language governing permissions and
|
|
15
16
|
* limitations under the License.
|
|
16
|
-
*/ import { CRM_LEAD_STATUS_LABELS, crmLeadStatus, isCrmLeadOpen } from "@aglyn/aglyn";
|
|
17
|
+
*/ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, readEmailState } from "@aglyn/aglyn";
|
|
17
18
|
export const LEAD_FILTERS = [
|
|
18
19
|
'open',
|
|
19
20
|
'new',
|
|
@@ -35,5 +36,51 @@ export const LEAD_FILTER_LABELS = {
|
|
|
35
36
|
if (filter === 'open') return isCrmLeadOpen(lead);
|
|
36
37
|
return crmLeadStatus(lead) === filter;
|
|
37
38
|
}
|
|
39
|
+
export const LEAD_EMAIL_FILTERS = [
|
|
40
|
+
'any',
|
|
41
|
+
'problem',
|
|
42
|
+
...EMAIL_STATE_STATUSES,
|
|
43
|
+
'none'
|
|
44
|
+
];
|
|
45
|
+
export const LEAD_EMAIL_FILTER_LABELS = _extends({
|
|
46
|
+
any: 'Any',
|
|
47
|
+
problem: 'Cannot be emailed',
|
|
48
|
+
none: 'Nothing known'
|
|
49
|
+
}, EMAIL_STATE_LABELS);
|
|
50
|
+
/** Whether a lead belongs in an email filter's view. */ export function leadMatchesEmailFilter(lead, filter) {
|
|
51
|
+
if (filter === 'any') return true;
|
|
52
|
+
const state = readEmailState(lead);
|
|
53
|
+
if (filter === 'none') return state === null;
|
|
54
|
+
if (filter === 'problem') return emailStateForbidsEmail(state);
|
|
55
|
+
return (state == null ? void 0 : state.status) === filter;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The fields the Leads section's search box reads (AGL-3246): who the lead
|
|
59
|
+
* is and where they work — the words a person types to find one — plus the
|
|
60
|
+
* tags, which are how an import names its batch (`sal-15`). `name` and
|
|
61
|
+
* `email` are the capture door's fields rather than the CRM's, so the row is
|
|
62
|
+
* read as a record rather than through `CrmLeadFields`.
|
|
63
|
+
*/ const LEAD_SEARCH_FIELDS = [
|
|
64
|
+
'name',
|
|
65
|
+
'email',
|
|
66
|
+
'company',
|
|
67
|
+
'jobTitle'
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* Whether a lead answers a search term.
|
|
71
|
+
*
|
|
72
|
+
* Case-insensitive, and every whitespace-separated word of the term must
|
|
73
|
+
* appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`
|
|
74
|
+
* both find Morgan Lamphere, `morgan smith` does not. A blank term matches
|
|
75
|
+
* every lead, so the box emptied is the list unfiltered.
|
|
76
|
+
*/ export function leadMatchesSearch(lead, term) {
|
|
77
|
+
const words = term.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
78
|
+
if (!words.length) return true;
|
|
79
|
+
const values = [
|
|
80
|
+
...LEAD_SEARCH_FIELDS.map((field)=>lead[field]),
|
|
81
|
+
...Array.isArray(lead['tags']) ? lead['tags'] : []
|
|
82
|
+
].filter((value)=>typeof value === 'string' && value !== '').map((value)=>value.toLowerCase());
|
|
83
|
+
return words.every((word)=>values.some((value)=>value.includes(word)));
|
|
84
|
+
}
|
|
38
85
|
|
|
39
86
|
//# sourceMappingURL=lead-filters.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.ts"],"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\nimport {\n CRM_LEAD_STATUS_LABELS,\n type CrmLeadFields,\n type CrmLeadStatus,\n crmLeadStatus,\n isCrmLeadOpen,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n"],"names":["CRM_LEAD_STATUS_LABELS","crmLeadStatus","isCrmLeadOpen","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.ts"],"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\nimport {\n EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some","includes"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EAItBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,cAAc,QACT,eAAc;AAWrB,OAAO,MAAMC,eAAsC;IACjD;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,OAAO,MAAMC,qBAAiD;IAC5DC,MAAM;IACNC,KAAK;IACLC,KAAKT,uBAAuBS,GAAG;IAC/BC,SAASV,uBAAuBU,OAAO;IACvCC,WAAWX,uBAAuBW,SAAS;IAC3CC,aAAaZ,uBAAuBY,WAAW;AACjD,EAAC;AAED,0EAA0E,GAC1E,OAAO,SAASC,kBACdC,IAAmC,EACnCC,MAAkB;IAElB,IAAIA,WAAW,OAAO,OAAO;IAC7B,IAAIA,WAAW,QAAQ,OAAOZ,cAAcW;IAC5C,OAAOZ,cAAcY,UAAUC;AACjC;AAWA,OAAO,MAAMC,qBAAiD;IAC5D;IACA;OACGjB;IACH;CACD,CAAA;AAED,OAAO,MAAMkB,2BAA4D;IACvEC,KAAK;IACLC,SAAS;IACTC,MAAM;GACHtB,oBACJ;AAED,sDAAsD,GACtD,OAAO,SAASuB,uBACdP,IAAuC,EACvCC,MAAuB;IAEvB,IAAIA,WAAW,OAAO,OAAO;IAC7B,MAAMO,QAAQlB,eAAeU;IAC7B,IAAIC,WAAW,QAAQ,OAAOO,UAAU;IACxC,IAAIP,WAAW,WAAW,OAAOd,uBAAuBqB;IACxD,OAAOA,CAAAA,yBAAAA,MAAOC,MAAM,MAAKR;AAC3B;AAEA;;;;;;CAMC,GACD,MAAMS,qBAAqB;IAAC;IAAQ;IAAS;IAAW;CAAW;AAEnE;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdX,IAAuC,EACvCY,IAAY;IAEZ,MAAMC,QAAQD,KAAKE,IAAI,GAAGC,WAAW,GAAGC,KAAK,CAAC,OAAOf,MAAM,CAACgB;IAC5D,IAAI,CAACJ,MAAMK,MAAM,EAAE,OAAO;IAC1B,MAAMC,SAAS;WACVT,mBAAmBU,GAAG,CAAC,CAACC,QAAUrB,IAAI,CAACqB,MAAM;WAC5CC,MAAMC,OAAO,CAACvB,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,OAAO,GAAG,EAAE;KACpD,CACEC,MAAM,CAAC,CAACuB,QAA2B,OAAOA,UAAU,YAAYA,UAAU,IAC1EJ,GAAG,CAAC,CAACI,QAAUA,MAAMT,WAAW;IACnC,OAAOF,MAAMY,KAAK,CAAC,CAACC,OAASP,OAAOQ,IAAI,CAAC,CAACH,QAAUA,MAAMI,QAAQ,CAACF;AACrE"}
|