@aglyn/plugins-inbox 1.0.0-beta.199 → 1.0.0-beta.201
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 +10 -10
- package/src/lib/components/contacts-card.component.d.ts +19 -8
- package/src/lib/components/contacts-card.component.js +291 -243
- package/src/lib/components/contacts-card.component.js.map +1 -1
- package/src/lib/components/submissions-card.component.js +144 -158
- package/src/lib/components/submissions-card.component.js.map +1 -1
- package/src/lib/constants/list-queries.d.ts +86 -0
- package/src/lib/constants/list-queries.js +369 -0
- package/src/lib/constants/list-queries.js.map +1 -0
- package/src/lib/model/submission-presenter.d.ts +10 -0
- package/src/lib/model/submission-presenter.js +5 -41
- package/src/lib/model/submission-presenter.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n normalizeContactEmail,\n pluginDocsHelp,\n type ConsolePluginOrgMount,\n} from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { useListRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-rows-filter'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { orgSiteNames } from './inbox-org-sites'\n\n/**\n * How many members and how many leads the contacts table reads.\n *\n * A ceiling rather than a page size — see the two queries, which explain why\n * this one table cannot be paged by the server without breaking the dedupe\n * between them.\n */\nconst CONTACT_CEILING = 200\n\n/*\n * What the contacts grid's Filters panel offers (AGL-3317). The card holds\n * both capped windows, so it answers the panel over every contact they read;\n * the notice under the table says when a cap truncated one. Type reads\n * `typeKey`: `member`, or `lead` with the lead's `source` when it has one,\n * which is what the column draws. On the organization's Inbox, Site matches\n * any site that captured the lead.\n */\nconst CONTACT_FILTER_FIELDS = [\n inMemoryListField('email', 'text'),\n inMemoryListField('contactKind', 'select', 'typeKey'),\n]\nconst ORG_CONTACT_FILTER_FIELDS = [\n ...CONTACT_FILTER_FIELDS,\n {\n ...inMemoryListField('capturedByHostIds', 'select'),\n tokensPath: 'capturedByHostIds',\n verbatimTokens: true,\n },\n]\nconst CONTACT_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Email',\n contactKind: 'Type',\n capturedByHostIds: 'Site',\n}\nconst CONTACT_SEARCH_FIELDS = ['email', 'displayName', 'name', 'source'] as const\n\n/** How a contact's type reads, from its `typeKey`. */\nconst contactTypeLabel = (typeKey: string): string =>\n typeKey === 'member'\n ? 'Member'\n : typeKey.startsWith('lead:')\n ? `Lead · ${typeKey.slice('lead:'.length)}`\n : 'Lead'\n\n/**\n * The Members & leads section of the Inbox (AGL-109): everybody a site\n * collected, in one list.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n *\n * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the\n * organization's leads with the sites each was captured by. A member signs up\n * to ONE site and lives under it, so members are listed once a site is\n * picked, which is the page handing this card that site.\n */\nexport function ContactsCard({\n hostId,\n orgMount,\n}: {\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*==========================================\n * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.\n *\n * Both reads were `limit(200)` with no `orderBy` and a client sort on top —\n * the same document-id sample the submissions list used to take, and both\n * now name the order the rows are rendered in. `createdAt` is safe on both:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction (every other membership path\n * updates an existing document), `recordVisitorLead` is the only writer\n * that creates a lead and stamps it on every `tx.create`, and neither\n * collection is in `IMPORTABLE_FIELDS`.\n *\n * What they must NOT do is page the query, and the reason is the dedupe\n * below: a lead is hidden when a MEMBER already exists on the same address.\n * That test is only correct while both windows are whole. On a ten-row\n * server page it would compare a page of leads against a page of members, so\n * somebody who signed up after leaving their address would render as a\n * Member on one page and again as a Lead on another — one person counted\n * twice, in a list a site owner uses to count people.\n *\n * So the CEILING stays and the page is a slice of the assembled rows. The\n * probe row makes \"there are more contacts than these\" a fact rather than a\n * guess from `length === CONTACT_CEILING`, which is wrong at exactly the\n * count that equals the ceiling.\n *=========================================*/\n const { data: memberDocs } = useFirestoreCollection<any>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n */\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: hostId == null ? orgMount?.orgId : undefined,\n })\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n /*\n * Every site's, on the organization's Inbox: an org-wide member\n * reads the collection unscoped — the rules short-circuit on\n * their reach, as for the CRM's own org list — so a clause here\n * would only narrow what they may already read.\n */\n ...(hostId\n ? [\n where(\n 'visibleTo',\n 'array-contains-any',\n scopeTokensForHost(hostId),\n ),\n ]\n : []),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const leads = (leadDocs ?? []).slice(0, CONTACT_CEILING)\n const contactsTruncated =\n (memberDocs?.length ?? 0) > CONTACT_CEILING ||\n (leadDocs?.length ?? 0) > CONTACT_CEILING\n /*\n * One person renders once, whichever way they came in.\n *\n * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so\n * somebody could appear as a Member on one row and a Lead on another. The\n * comparison is now the same NORMALIZATION a lead document is keyed by —\n * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an\n * already-normalized address cannot merge or split anything the normalizer\n * did not, so the two agree by construction.\n *\n * `personKey` itself cannot run here: it needs `node:crypto` and this is a\n * client component. That is why the shared half is the normalizer rather\n * than the digest.\n */\n const dedupedLeads = useMemo(() => {\n const memberKeys = new Set(\n siteMembers\n .map((member: any) => normalizeContactEmail(member.email))\n .filter(Boolean),\n )\n return leads.filter(\n (lead: any) => !memberKeys.has(normalizeContactEmail(lead.email)),\n )\n }, [leads, siteMembers])\n /*\n * ONE list of two collections — members first, then the leads that are not\n * already members — so the grid pages the concatenation rather than either\n * read, and a page stays whole across the seam between them. Both reads are\n * ceilinged and complete below the ceiling, so the grid's count is the whole\n * deduped list.\n */\n const contacts = useMemo(\n () => [\n ...siteMembers.map((member: any) => ({\n ...member,\n contactKind: 'member',\n typeKey: 'member',\n })),\n ...dedupedLeads.map((lead: any) => ({\n ...lead,\n contactKind: 'lead',\n typeKey: lead.source ? `lead:${lead.source}` : 'lead',\n })),\n ],\n [siteMembers, dedupedLeads],\n )\n /*\n * A lead's source is open-ended, so the Type choices are the ones the rows\n * hold, member and plain lead first.\n */\n const contactFilterOptions = useMemo(() => {\n const leadSources = [\n ...new Set(\n contacts\n .map((contact: any) => contact.typeKey as string)\n .filter((typeKey) => typeKey.startsWith('lead:')),\n ),\n ].sort()\n return {\n contactKind: ['member', 'lead', ...leadSources].map((value) => ({\n value,\n label: contactTypeLabel(value),\n })),\n ...(hostId == null\n ? {\n capturedByHostIds: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }\n }, [contacts, hostId, orgMount])\n const contactFilter = useListRowsFilter({\n rows: contacts,\n fields: hostId == null ? ORG_CONTACT_FILTER_FIELDS : CONTACT_FILTER_FIELDS,\n options: contactFilterOptions,\n headers: CONTACT_FILTER_HEADERS,\n search: CONTACT_SEARCH_FIELDS,\n })\n /*\n * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a\n * client delete. A member's password hash lives in a document no client can\n * reach, so deleting the profile from here would leave it behind: the route\n * deletes both, and the rules refuse a client delete of any member that\n * has one.\n */\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n // Members are listed only under a site, so there is one to delete from.\n if (!hostId) return\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const response = await authorizedFetch(user, '/api/membership/admin-remove', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, memberId: member.$id }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n } catch {\n enqueueSnackbar('The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n },\n [confirm, user, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * The site a lead's attribution is asked of. Under a site it is that site;\n * across every site a lead is one org row that several sites may have met,\n * and the credit is filed by the site whose capture made it — the first in\n * `capturedByHostIds`, the site the CRM's own lead page asks as well.\n */\n const originSite = (contact: any): string | null => {\n if (hostId) return hostId\n const first = Array.isArray(contact?.capturedByHostIds)\n ? contact.capturedByHostIds[0]\n : null\n return typeof first === 'string' && first ? first : null\n }\n const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null\n\n const contactActions = (contact: any): RowActionsMenuItem[] =>\n contact.contactKind === 'member'\n ? [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(contact)(),\n },\n ]\n : [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(contact)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(contact) as string,\n },\n ]\n : []),\n // A lead no site captured has no site to ask for its campaign.\n ...(originSite(contact)\n ? [\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(contact),\n },\n ]\n : []),\n ]\n const contactColumns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name =\n contact.contactKind === 'member' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n /*\n WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both\n lead writers — `'signup'` and `'booking'` — since AGL-109, so a site\n owner can tell a membership sign-up from a booking. Falls back to the\n bare label for a row written before the field, or by a future writer\n that omits it.\n */\n field: 'contactKind',\n headerName: 'Type',\n width: 170,\n valueGetter: (_value, contact) => contact.typeKey,\n renderCell: ({ row: contact }) =>\n contact.contactKind === 'member' ? (\n <Chip label={contactTypeLabel(contact.typeKey)} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={contactTypeLabel(contact.typeKey)} size=\"small\" variant=\"outlined\" />\n ),\n },\n /*\n KNOWN BY, on the organization's Inbox: every site that captured the\n person, as the CRM's organization Leads list names them. A lead is one\n org row, so \"which site\" is a set, never one answer.\n */\n ...(hostId == null\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Site',\n flex: 1,\n minWidth: 160,\n // Sorted and drawn by name; the filter matches the ids.\n valueGetter: (_value: unknown, contact: any) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n renderCell: ({ row: contact }: { row: any }) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'createdAt',\n headerName: 'Joined',\n width: 200,\n // Sorted on the instant, drawn as a local string.\n valueGetter: (_value, contact) => contact.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={contactActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={hostId == null ? 'Leads' : 'Site Members & Leads'}\n help={pluginDocsHelp('forms', {\n anchor: '#filter-the-inbox',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: leads.length ? 2 : 1 }}\n >\n {'Every site’s leads, newest first. Members sign up to one site ' +\n 'each — choose a site to list its members beside its leads.'}\n </Typography>\n ) : null}\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {hostId == null\n ? 'No leads on any site yet.'\n : 'No members yet — visitors can join at /signup on your ' +\n 'site; sign-ups also appear here as leads.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...contactFilter.chipsProps} />\n <ListTable\n aria-label=\"Site members and leads\"\n rows={contactFilter.rows}\n columns={contactFilter.filterColumns(contactColumns)}\n // The panel and the search are the grid's; the card answers\n // them over every contact both windows read.\n {...contactFilter.gridProps}\n noRowsLabel=\"No contacts match these filters\"\n // A member and a lead are two collections, so an id alone could\n // name one of each.\n getRowId={(contact: any) => `${contact.contactKind}:${contact.$id}`}\n rowHeight={TABLE_ROW_HEIGHT}\n />\n {contactsTruncated ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {hostId == null\n ? `Paging the ${CONTACT_CEILING} newest leads. Your ` +\n 'sites hold more than that — the CRM’s Leads list ' +\n 'reaches every one of them.'\n : `Paging the ${CONTACT_CEILING} newest members and the ` +\n `${CONTACT_CEILING} newest leads. This site has more ` +\n 'than that — the campaign audiences still reach ' +\n 'everyone, whether or not they are listed here.'}\n </Alert>\n ) : null}\n </>\n )}\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id && leadOriginSite ? (\n <InboxRecordAttributionZone\n hostId={leadOriginSite}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["normalizeContactEmail","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListRowActions","ListTable","listActionsColumn","inMemoryListField","useListRowsFilter","TABLE_ROW_HEIGHT","useSnackbar","authorizedFetch","useFirestore","useFirestoreCollection","useOrgDataScope","useUser","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","orgSiteNames","CONTACT_CEILING","CONTACT_FILTER_FIELDS","ORG_CONTACT_FILTER_FIELDS","tokensPath","verbatimTokens","CONTACT_FILTER_HEADERS","email","contactKind","capturedByHostIds","CONTACT_SEARCH_FIELDS","contactTypeLabel","typeKey","startsWith","slice","length","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","memberDocs","idField","orgId","undefined","siteMembers","leadDocs","leads","contactsTruncated","dedupedLeads","memberKeys","Set","map","member","filter","Boolean","lead","has","contacts","source","contactFilterOptions","leadSources","sort","value","label","hosts","host","id","name","contactFilter","rows","fields","options","headers","search","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","response","method","body","JSON","stringify","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","Array","isArray","leadOriginSite","contactActions","key","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","mb","chipsProps","aria-label","columns","filterColumns","gridProps","noRowsLabel","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SACEA,qBAAqB,EACrBC,cAAc,QAET,eAAc;AACrB,2EAA2E;AAC3E,0EAA0E;AAC1E,6EAA6E;AAC7E,wEAAwE;AACxE,SAASC,0BAA0B,QAAQ,8BAA0B;AACrE,8EAA8E;AAC9E,8EAA8E;AAC9E,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SACEC,oBAAoB,EACpBC,uBAAuB,EACvBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,WAAW,EAAEC,OAAO,EAAEC,sBAAsB,QAAQ,uBAAsB;AACnF,OAAOC,qBAAqB,8DAA6D;AACzF,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,SAASC,iBAAiB,QAAQ,8CAA6C;AAC/E,SAASC,iBAAiB,QAAQ,kDAAiD;AAEnF,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,YAAY,EACZC,sBAAsB,EACtBC,eAAe,EACfC,OAAO,QACF,iCAAgC;AACvC,SACEC,KAAK,EACLC,MAAM,EACNC,IAAI,EACJC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,WAAW,EACXC,KAAK,EACLC,UAAU,QACL,gBAAe;AAEtB,SAASC,UAAU,EAAEC,KAAK,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7E,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,qBAAqB,QAAQ,gCAA4B;AAClE,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,YAAY,QAAQ,uBAAmB;AAEhD;;;;;;CAMC,GACD,MAAMC,kBAAkB;AAExB;;;;;;;CAOC,GACD,MAAMC,wBAAwB;IAC5B9B,kBAAkB,SAAS;IAC3BA,kBAAkB,eAAe,UAAU;CAC5C;AACD,MAAM+B,4BAA4B;OAC7BD;IACH,aACK9B,kBAAkB,qBAAqB;QAC1CgC,YAAY;QACZC,gBAAgB;;CAEnB;AACD,MAAMC,yBAA2D;IAC/DC,OAAO;IACPC,aAAa;IACbC,mBAAmB;AACrB;AACA,MAAMC,wBAAwB;IAAC;IAAS;IAAe;IAAQ;CAAS;AAExE,oDAAoD,GACpD,MAAMC,mBAAmB,CAACC,UACxBA,YAAY,WACR,WACAA,QAAQC,UAAU,CAAC,WACjB,CAAC,OAAO,EAAED,QAAQE,KAAK,CAAC,QAAQC,MAAM,GAAG,GACzC;AAER;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,aAAa,EAC3BC,MAAM,EACNC,QAAQ,EAKT;;IACC,MAAMC,YAAY1C;IAClB,MAAM,EAAE2C,MAAMC,IAAI,EAAE,GAAGzC;IACvB,MAAM,EAAE0C,eAAe,EAAE,GAAG/C;IAC5B,MAAM,EAAEgD,OAAO,EAAE,GAAGxD;IACpB,sEAAsE;IACtE,oEAAoE;IACpE,MAAMyD,eAAe1B;IACrB,MAAM2B,WAAW,CAACC,UAChBF,eACI/D,iBAAiB,QAAQ+D,cAAcG,OAAOD,QAAQE,GAAG,KACzD;IAEN;;;;;;;;;;;;;;;;;;;;;;;;6CAwB2C,GAC3C,MAAM,EAAER,MAAMS,UAAU,EAAE,GAAGnD,uBAC3B,IACEuC,SACIxB,MACEH,WAAW6B,WAAW,SAASF,QAAQ,gBACvCzB,QAAQ,aAAa,SACrBD,MAAMU,kBAAkB,MAE1B,MACN;QAACkB;QAAWF;KAAO,EACnB;QAAEa,SAAS;IAAM;IAEnB;;;;GAIC,GACD,MAAM,EAAEC,KAAK,EAAE,GAAGpD,gBAAgB;QAChCsC,MAAM,EAAEA,iBAAAA,SAAUe;QAClBD,OAAOd,UAAU,OAAOC,4BAAAA,SAAUa,KAAK,GAAGC;IAC5C;IACA,MAAMC,cAAc,CAACJ,qBAAAA,aAAc,EAAE,EAAEf,KAAK,CAAC,GAAGb;IAChD,MAAM,EAAEmB,MAAMc,QAAQ,EAAE,GAAGxD,uBACzB,IACEqD,QACItC,MACEH,WAAW6B,WAAW,QAAQY,OAAO,UACrC;;;;;aAKC,MACGd,SACA;YACEvB,MACE,aACA,sBACAK,mBAAmBkB;SAEtB,GACD,EAAE,EACNzB,QAAQ,aAAa,SACrBD,MAAMU,kBAAkB,MAE1B,MACN;QAACkB;QAAWY;QAAOd;KAAO,EAC1B;QAAEa,SAAS;IAAM;IAEnB,MAAMK,QAAQ,CAACD,mBAAAA,WAAY,EAAE,EAAEpB,KAAK,CAAC,GAAGb;IACxC,MAAMmC,oBACJ,SAACP,8BAAAA,WAAYd,MAAM,mBAAI,KAAKd,mBAC5B,UAACiC,4BAAAA,SAAUnB,MAAM,oBAAI,KAAKd;IAC5B;;;;;;;;;;;;;GAaC,GACD,MAAMoC,eAAezC,QAAQ;QAC3B,MAAM0C,aAAa,IAAIC,IACrBN,YACGO,GAAG,CAAC,CAACC,SAAgBnF,sBAAsBmF,OAAOlC,KAAK,GACvDmC,MAAM,CAACC;QAEZ,OAAOR,MAAMO,MAAM,CACjB,CAACE,OAAc,CAACN,WAAWO,GAAG,CAACvF,sBAAsBsF,KAAKrC,KAAK;IAEnE,GAAG;QAAC4B;QAAOF;KAAY;IACvB;;;;;;GAMC,GACD,MAAMa,WAAWlD,QACf,IAAM;eACDqC,YAAYO,GAAG,CAAC,CAACC,SAAiB,aAChCA;oBACHjC,aAAa;oBACbI,SAAS;;eAERyB,aAAaG,GAAG,CAAC,CAACI,OAAe,aAC/BA;oBACHpC,aAAa;oBACbI,SAASgC,KAAKG,MAAM,GAAG,CAAC,KAAK,EAAEH,KAAKG,MAAM,EAAE,GAAG;;SAElD,EACD;QAACd;QAAaI;KAAa;IAE7B;;;GAGC,GACD,MAAMW,uBAAuBpD,QAAQ;;QACnC,MAAMqD,cAAc;eACf,IAAIV,IACLO,SACGN,GAAG,CAAC,CAACd,UAAiBA,QAAQd,OAAO,EACrC8B,MAAM,CAAC,CAAC9B,UAAYA,QAAQC,UAAU,CAAC;SAE7C,CAACqC,IAAI;QACN,OAAO;YACL1C,aAAa;gBAAC;gBAAU;mBAAWyC;aAAY,CAACT,GAAG,CAAC,CAACW,QAAW,CAAA;oBAC9DA;oBACAC,OAAOzC,iBAAiBwC;gBAC1B,CAAA;WACIlC,UAAU,OACV;YACER,mBAAmB,SAACS,4BAAAA,SAAUmC,KAAK,mBAAI,EAAE,EAAEb,GAAG,CAAC,CAACc,OAAU,CAAA;oBACxDH,OAAOG,KAAKC,EAAE;oBACdH,OAAOE,KAAKE,IAAI,IAAIF,KAAKC,EAAE;gBAC7B,CAAA;QACF,IACA,CAAC;IAET,GAAG;QAACT;QAAU7B;QAAQC;KAAS;IAC/B,MAAMuC,gBAAgBpF,kBAAkB;QACtCqF,MAAMZ;QACNa,QAAQ1C,UAAU,OAAOd,4BAA4BD;QACrD0D,SAASZ;QACTa,SAASvD;QACTwD,QAAQpD;IACV;IACA;;;;;;GAMC,GACD,MAAMqD,qBAAqBpE,YACzB,CAAC8C,SAAgB;YACf,wEAAwE;YACxE,IAAI,CAACxB,QAAQ;YACb,MAAM+C,YAAY,MAAMzC,QAAQ;gBAC9B0C,OAAO;gBACPC,aAAa,CAAC,CAAC,EAAEzB,OAAOlC,KAAK,CAAC,qCAAqC,CAAC;gBACpE4D,kBAAkB;gBAClBC,yBAAyB;oBAAEC,OAAO;gBAAQ;YAC5C,GACGC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;YACf,IAAI,CAACP,WAAW;YAChB,IAAI;gBACF,MAAMQ,WAAW,MAAMhG,gBAAgB6C,MAAM,gCAAgC;oBAC3EoD,QAAQ;oBACRZ,SAAS;wBAAE,gBAAgB;oBAAmB;oBAC9Ca,MAAMC,KAAKC,SAAS,CAAC;wBAAE3D;wBAAQ4D,UAAUpC,OAAOb,GAAG;oBAAC;gBACtD;gBACA,MAAMkD,UAAU,MAAMN,SAASO,IAAI,GAAGR,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;gBACpD,IAAI,CAACC,SAASQ,EAAE,EAAE;;oBAChB,OAAO,KAAK1D,wBAAgBwD,2BAAAA,QAASG,KAAK,mBAAI,8BAA8B;wBAC1EC,SAAS;wBACTC,gBAAgB;oBAClB;gBACF;gBACA7D,gBAAgB,kBAAkB;oBAAE4D,SAAS;oBAAWE,SAAS;gBAAM;YACzE,EAAE,eAAM;gBACN9D,gBAAgB,8BAA8B;oBAC5C4D,SAAS;oBACTC,gBAAgB;gBAClB;YACF;QACF,GACA;QAAC5D;QAASF;QAAMJ;QAAQK;KAAgB;IAG1C;;;;;;;;GAQC,GACD,MAAM,CAAC+D,YAAYC,cAAc,GAAGzF,SAAqB;IACzD;;;;;GAKC,GACD,MAAM0F,aAAa,CAAC7D;QAClB,IAAIT,QAAQ,OAAOA;QACnB,MAAMuE,QAAQC,MAAMC,OAAO,CAAChE,2BAAAA,QAASjB,iBAAiB,IAClDiB,QAAQjB,iBAAiB,CAAC,EAAE,GAC5B;QACJ,OAAO,OAAO+E,UAAU,YAAYA,QAAQA,QAAQ;IACtD;IACA,MAAMG,iBAAiBN,aAAaE,WAAWF,cAAc;IAE7D,MAAMO,iBAAiB,CAAClE,UACtBA,QAAQlB,WAAW,KAAK,WACpB;YACE;gBACEqF,KAAK;gBACLzC,OAAO;gBACP0C,oBAAM,KAAChI;oBAAQiI,MAAMpI,wBAAwBoI,IAAI;oBAAEC,MAAM;;gBACzDC,aAAa;gBACbC,SAAS,IAAM,KAAKnC,mBAAmBrC;YACzC;SACD,GACD;YACE,oEAAoE;YACpE,2DAA2D;eACvDD,SAASC,WACT;gBACE;oBACEmE,KAAK;oBACLzC,OAAO;oBACP0C,oBAAM,KAAChI;wBAAQiI,MAAMrI,qBAAqBqI,IAAI;wBAAEC,MAAM;;oBACtDG,MAAM1E,SAASC;gBACjB;aACD,GACD,EAAE;YACN,+DAA+D;eAC3D6D,WAAW7D,WACX;gBACE;oBACEmE,KAAK;oBACLzC,OAAO;oBACP0C,oBAAM,KAAChI;wBAAQiI,MAAMnI,mBAAmBmI,IAAI;wBAAEC,MAAM;;oBACpDE,SAAS,IAAMZ,cAAc5D;gBAC/B;aACD,GACD,EAAE;SACP;IACP,MAAM0E,iBAA+B;QACnC;YACEC,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACV;;;;OAIC,GACDC,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAE;gBAC3B,MAAM8B,OACJ9B,QAAQlB,WAAW,KAAK,WAAWkB,QAAQiF,WAAW,GAAGjF,QAAQ8B,IAAI;gBACvE,qBACE,MAACpE;oBAAMwH,WAAU;oBAAMC,SAAS;oBAAGC,IAAI;wBAAEC,YAAY;wBAAYP,UAAU;oBAAE;;sCAC3E,KAACnH;4BAAW6F,SAAQ;4BAAQ8B,MAAM;sCAC/BtF,QAAQnB,KAAK;;wBAEfiD,qBACC,KAACnE;4BAAW6F,SAAQ;4BAAUb,OAAM;4BAAiB2C,MAAM;sCACxDxD;6BAED;;;YAGV;QACF;QACA;YACE;;;;;;OAMC,GACD6C,OAAO;YACPC,YAAY;YACZW,OAAO;YACPC,aAAa,CAACC,QAAQzF,UAAYA,QAAQd,OAAO;YACjD6F,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAE,GAC3BA,QAAQlB,WAAW,KAAK,yBACtB,KAACzB;oBAAKqE,OAAOzC,iBAAiBe,QAAQd,OAAO;oBAAGyD,OAAM;oBAAU2B,MAAK;mCAErE,KAACjH;oBAAKqE,OAAOzC,iBAAiBe,QAAQd,OAAO;oBAAGoF,MAAK;oBAAQd,SAAQ;;QAE3E;QACA;;;;KAIC,MACGjE,UAAU,OACV;YACE;gBACEoF,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACV,wDAAwD;gBACxDU,aAAa,CAACC,QAAiBzF,UAC7B1B,aAAakB,UAAUQ,QAAQjB,iBAAiB;gBAClDgG,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAgB,GACzC1B,aAAakB,UAAUQ,QAAQjB,iBAAiB;YACpD;SACD,GACD,EAAE;QACN;YACE4F,OAAO;YACPC,YAAY;YACZW,OAAO;YACP,kDAAkD;YAClDC,aAAa,CAACC,QAAQzF;;oBAAYA,mCAAAA,2BAAAA,4BAAAA;gCAAAA,qBAAAA,QAAQ0F,SAAS,sBAAjB1F,6BAAAA,mBAAmB2F,MAAM,sBAAzB3F,4BAAAA,gCAAAA,yCAAAA,oCAAAA,0BAA+B4F,OAAO,qBAAtC5F,uCAAAA,6CAA8C;;YAChF+E,YAAY,CAAC,EAAEC,KAAKhF,OAAO,EAAE;;oBAC3BA,2BAAAA;gCAAAA,qBAAAA,QAAQ0F,SAAS,sBAAjB1F,4BAAAA,mBAAmB2F,MAAM,qBAAzB3F,+BAAAA,oBAA8B6F,cAAc,qBAAM;;QACtD;QACApJ,kBACE,CAACuD;gBAEiBA;iCADhB,KAACzD;gBACCmF,OAAOzB,QAAOD,iBAAAA,QAAQnB,KAAK,YAAbmB,iBAAiBA,QAAQE,GAAG;gBAC1C4F,OAAO5B,eAAelE;;WAG1B;YAAEuF,OAAO;QAAG;KAEf;IAED,qBACE;;0BACE,MAACpJ;gBACC4J,QAAQxG,UAAU,OAAO,UAAU;gBACnCyG,MAAMnK,eAAe,SAAS;oBAC5BoK,QAAQ;gBACV;gBACAC,cAAc;gBACdC,cAAc;gBACdC,iBAAgB;;oBAEf7G,UAAU,qBACT,KAAC5B;wBACC6F,SAAQ;wBACRb,OAAM;wBACNyC,IAAI;4BAAEiB,IAAI5F,MAAMpB,MAAM,GAAG,IAAI;wBAAE;kCAE9B,mEACC;yBAEF;oBACHkB,YAAYlB,MAAM,KAAK,KAAKoB,MAAMpB,MAAM,KAAK,kBAC5C,KAAC1B;wBAAW6F,SAAQ;wBAAQb,OAAM;kCAC/BpD,UAAU,OACP,8BACA,2DACA;uCAGN;;0CACE,KAACjD,8BAAoByF,cAAcuE,UAAU;0CAC7C,KAAC9J;gCACC+J,cAAW;gCACXvE,MAAMD,cAAcC,IAAI;gCACxBwE,SAASzE,cAAc0E,aAAa,CAAC/B;+BAGjC3C,cAAc2E,SAAS;gCAC3BC,aAAY;gCACZ,gEAAgE;gCAChE,oBAAoB;gCACpBC,UAAU,CAAC5G,UAAiB,GAAGA,QAAQlB,WAAW,CAAC,CAAC,EAAEkB,QAAQE,GAAG,EAAE;gCACnE2G,WAAWjK;;4BAEZ8D,kCACC,KAACvD;gCAAM2J,UAAS;gCAAO1B,IAAI;oCAAE2B,IAAI;gCAAE;0CAChCxH,UAAU,OACP,CAAC,WAAW,EAAEhB,gBAAgB,oBAAoB,CAAC,GACnD,sDACA,+BACA,CAAC,WAAW,EAAEA,gBAAgB,wBAAwB,CAAC,GACvD,GAAGA,gBAAgB,kCAAkC,CAAC,GACtD,oDACA;iCAEJ;;;;;0BAYV,MAACjB;gBACC0J,MAAM/F,QAAQ0C;gBACdsD,SAAS,IAAMrD,cAAc;gBAC7BsD,UAAS;gBACTC,SAAS;;kCAET,KAAC1J;2CAAakG,8BAAAA,WAAY9E,KAAK,oBAAI;;kCACnC,KAACrB;kCACEmG,CAAAA,8BAAAA,WAAYzD,GAAG,KAAI+D,+BAClB,KAACnI;4BACCyD,QAAQ0E;4BACRmD,YAAW;4BACXC,UAAUpH,OAAO0D,WAAWzD,GAAG;6BAE/B;;kCAEN,KAAC3C;kCACC,cAAA,KAACH;4BAAOoG,SAAQ;4BAAYgB,SAAS,IAAMZ,cAAc;sCACtD;;;;;;;AAMb;AACAtE,aAAa2F,WAAW,GAAG;AAE3B,eAAe3F,aAAY"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.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 { pluginDocsHelp, type ConsolePluginOrgMount } from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport {\n type ListFilterOption,\n listFilterGridColumns,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n useFirestore,\n useOrgDataScope,\n useScopeTokens,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n LEAD_FILTER_HEADERS,\n LEAD_LIST_QUERY,\n LEAD_SOURCE_OPTIONS,\n ORG_LEAD_LIST_QUERY,\n SITE_MEMBER_FILTER_HEADERS,\n SITE_MEMBER_LIST_QUERY,\n leadListBase,\n LEAD_ADDRESS_SEARCH_COLUMN,\n LEAD_ADDRESS_SEARCH_NOTICE,\n leadListQueryFor,\n leadSourceLabel,\n} from '../constants/list-queries'\nimport { orgSiteNames } from './inbox-org-sites'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n/** Which of the two collections the card lists. */\nexport type ContactsView = 'members' | 'leads'\n\n/** The organization's Inbox's Source and Site are picked, so the panel shows selects. */\nconst ORG_LEAD_SELECT_FIELDS = ['sources', 'capturedByHostIds']\nconst NO_SELECT_FIELDS: readonly string[] = []\n\n/**\n * Every surface a lead was captured by: `sources`, which every capture\n * `arrayUnion`s, or the single `source` a lead written before it carries.\n */\nconst leadSources = (lead: any): string[] =>\n Array.isArray(lead?.sources)\n ? lead.sources.filter((source: unknown): source is string => typeof source === 'string')\n : typeof lead?.source === 'string' && lead.source\n ? [lead.source]\n : []\n\n/**\n * The Members & leads section of the Inbox (AGL-109): the people a site\n * collected — its members, who signed up to it, and its leads, whom a form,\n * a booking or a teammate captured.\n *\n * ## Two lists, one table (AGL-3321)\n *\n * Members and leads are two collections, and the table shows one at a time,\n * chosen by the toggle above it. Each is its own Firestore query, every\n * filter and search word on it, paged by the server — so a page is a page of\n * the answer, and a site with thousands of either reaches all of them.\n *\n * It used to read the two hundred newest of each and interleave them, hiding\n * a lead whose address matched a member. That dedupe is only correct while\n * both reads are whole, which is exactly what a paged query is not, so it is\n * gone: a person who left their address and later signed up is a lead in one\n * list and a member in the other, which is what they are in each collection.\n *\n * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the\n * organization's leads with the sites each was captured by. A member signs up\n * to ONE site and lives under it, so Members waits for a site to be picked,\n * which is the page handing this card that site.\n */\nexport function ContactsCard({\n hostId,\n orgMount,\n}: {\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*\n * The list shown. Members exist only under a site, so the organization's\n * Inbox shows leads whatever was chosen, and the toggle says why.\n */\n const [chosen, setChosen] = useState<ContactsView>('members')\n const view: ContactsView = hostId ? chosen : 'leads'\n const [searchWords, setSearchWords] = useState<string[]>([])\n const gridFilter = useListGridFilter({\n selectFields: view === 'leads' && hostId == null ? ORG_LEAD_SELECT_FIELDS : NO_SELECT_FIELDS,\n search: { words: searchWords, onChange: setSearchWords },\n })\n /*\n * A switch of list starts it clean: the two lists filter by different\n * fields, and a clause carried across would be one the other list cannot\n * ask — refused on arrival, for a question the reader asked of the list\n * they just left.\n */\n const switchView = (next: ContactsView | null) => {\n if (!next || next === view) return\n gridFilter.setClauses([])\n setSearchWords([])\n setChosen(next)\n }\n const request = { clauses: gridFilter.clauses, search: gridFilter.searchWords }\n\n /*==========================================\n * SITE MEMBERS — `hosts/{hostId}/siteMembers`, newest first\n * (`SITE_MEMBER_LIST_QUERY`). `createdAt` is safe to order on:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction, and the collection is not in\n * `IMPORTABLE_FIELDS`. No query opens while the other list is shown.\n *=========================================*/\n const members = useListQuery<any>({\n collection:\n hostId && view === 'members'\n ? collection(firestore, 'hosts', hostId, 'siteMembers')\n : null,\n declaration: SITE_MEMBER_LIST_QUERY,\n request: view === 'members' ? request : { clauses: [] },\n deps: [firestore, hostId, view],\n idField: '$id',\n })\n\n /*==========================================\n * LEADS — `orgs/{orgId}/leads`, newest first (`LEAD_LIST_QUERY`). The\n * silo is the org's (AGL-3275), narrowed to this site by `visibleTo`;\n * reading `hosts/{hostId}/leads` showed a site its pre-migration rows and\n * nothing captured since. `addHostLead`, every lead door's one writer,\n * stamps `createdAt` on every create.\n *\n * Every site's, on the organization's Inbox: an org-wide member reads the\n * collection unscoped — the rules short-circuit on their reach, as for the\n * CRM's own org list — so a clause there would only narrow what they may\n * already read.\n *=========================================*/\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: hostId == null ? orgMount?.orgId : undefined,\n })\n /*\n * Whether the search may fold into the scope clause (`scopedSearch`): a\n * query without `visibleTo` is one the rules prove only for an ORG-WIDE\n * member. Anyone else keeps the clause, and the plan refuses their search\n * by name rather than sending a query the rules deny. Until the reach has\n * answered, the answer is no. Asked only while Leads is shown: the member\n * document is a read, and Members has no scope to fold into.\n */\n const reach = useScopeTokens(hostId && orgId && view === 'leads' ? orgId : undefined)\n const foldsScope = hostId == null || (reach.loaded && reach.orgWide)\n const leadDeclaration = hostId ? LEAD_LIST_QUERY : ORG_LEAD_LIST_QUERY\n const leadBase = useMemo(\n () => leadListBase(hostId ? scopeTokensForHost(hostId) : null),\n [hostId],\n )\n const leadQuery = useMemo(\n () =>\n leadListQueryFor(\n leadDeclaration,\n foldsScope,\n view === 'leads' ? { ...request, base: leadBase } : { clauses: [] },\n ),\n // The request is data; its JSON is its identity.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [leadDeclaration, foldsScope, view, leadBase, JSON.stringify(request)],\n )\n const leads = useListQuery<any>({\n collection:\n orgId && view === 'leads' ? collection(firestore, 'orgs', orgId, 'leads') : null,\n declaration: leadQuery.declaration,\n request: leadQuery.request,\n deps: [firestore, orgId, hostId, view],\n idField: '$id',\n })\n\n const shown = view === 'members' ? members : leads\n const declaration = view === 'members' ? SITE_MEMBER_LIST_QUERY : leadDeclaration\n const headers = view === 'members' ? SITE_MEMBER_FILTER_HEADERS : LEAD_FILTER_HEADERS\n const options = useMemo<Readonly<Record<string, readonly ListFilterOption[]>>>(\n () =>\n view === 'leads' && hostId == null\n ? {\n sources: LEAD_SOURCE_OPTIONS,\n capturedByHostIds: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {},\n [view, hostId, orgMount],\n )\n /*\n * A site collaborator's search rides the query as an address-prefix clause\n * (`leadListQueryFor`); a refusal of it is the search's, and when it is\n * served the list says what it matched and how it is now ordered.\n */\n const refusals = useMemo(\n () =>\n listQueryRefusals(\n shown.plan.refused.map((entry) =>\n entry.clause !== 'search' && entry.clause.field === LEAD_ADDRESS_SEARCH_COLUMN\n ? { ...entry, clause: 'search' as const }\n : entry,\n ),\n { fields: declaration.fields, headers, options },\n ),\n [shown.plan.refused, declaration, headers, options],\n )\n const addressSearched =\n view === 'leads' &&\n leadQuery.addressSearch &&\n leads.plan.served.some((clause) => clause.field === LEAD_ADDRESS_SEARCH_COLUMN)\n const notices = useMemo(\n () => (addressSearched ? [...shown.plan.notices, LEAD_ADDRESS_SEARCH_NOTICE] : shown.plan.notices),\n [addressSearched, shown.plan.notices],\n )\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n\n /*\n * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a\n * client delete. A member's password hash lives in a document no client can\n * reach, so deleting the profile from here would leave it behind: the route\n * deletes both, and the rules refuse a client delete of any member that\n * has one.\n */\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n // Members are listed only under a site, so there is one to delete from.\n if (!hostId) return\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const response = await authorizedFetch(user, '/api/membership/admin-remove', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, memberId: member.$id }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n } catch {\n enqueueSnackbar('The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n },\n [confirm, user, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * The site a lead's attribution is asked of. Under a site it is that site;\n * across every site a lead is one org row that several sites may have met,\n * and the credit is filed by the site whose capture made it — the first in\n * `capturedByHostIds`, the site the CRM's own lead page asks as well.\n */\n const originSite = (contact: any): string | null => {\n if (hostId) return hostId\n const first = Array.isArray(contact?.capturedByHostIds)\n ? contact.capturedByHostIds[0]\n : null\n return typeof first === 'string' && first ? first : null\n }\n const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null\n\n const memberActions = (member: any): RowActionsMenuItem[] => [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(member)(),\n },\n ]\n const leadActions = (lead: any): RowActionsMenuItem[] => [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(lead)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(lead) as string,\n },\n ]\n : []),\n // A lead no site captured has no site to ask for its campaign.\n ...(originSite(lead)\n ? [\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(lead),\n },\n ]\n : []),\n ]\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name = view === 'members' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n /*\n WHERE A LEAD CAME FROM (AGL-2338): every surface that captured the\n person — a form, a booking, a lead added by hand — one chip each. A lead\n written before `sources` carries the one `source` it was captured by,\n and a row with neither simply shows none.\n */\n ...(view === 'leads'\n ? [\n {\n field: 'sources',\n headerName: 'Source',\n width: 200,\n sortable: false,\n valueGetter: (_value: unknown, lead: any) =>\n leadSources(lead).map(leadSourceLabel).join(', '),\n renderCell: ({ row: lead }: { row: any }) => (\n <Stack direction=\"row\" spacing={0.5} sx={{ alignItems: 'center', height: '100%' }}>\n {[...new Set(leadSources(lead).map(leadSourceLabel))].map((label) => (\n <Chip key={label} label={label} size=\"small\" variant=\"outlined\" />\n ))}\n </Stack>\n ),\n } satisfies GridColDef,\n ]\n : []),\n /*\n KNOWN BY, on the organization's Inbox: every site that captured the\n person, as the CRM's organization Leads list names them. A lead is one\n org row, so \"which site\" is a set, never one answer.\n */\n ...(view === 'leads' && hostId == null\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Site',\n flex: 1,\n minWidth: 160,\n sortable: false,\n renderCell: ({ row: contact }: { row: any }) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'createdAt',\n headerName: view === 'members' ? 'Joined' : 'Captured',\n width: 200,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={view === 'members' ? memberActions(contact) : leadActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n const empty = shown.rows.length === 0 && !shown.hasMore && !filtering\n const emptyLabel =\n view === 'members'\n ? 'No members yet — visitors can join at /signup on your site.'\n : hostId == null\n ? 'No leads on any site yet.'\n : 'No leads yet — the forms and bookings that file leads add them here.'\n\n return (\n <>\n <CardDisplay\n header={hostId == null ? 'Leads' : 'Site Members & Leads'}\n help={pluginDocsHelp('forms', {\n anchor: '#filter-the-inbox',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={1.5} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n value={view}\n onChange={(_event, next: ContactsView | null) => switchView(next)}\n aria-label=\"Which people to list\"\n >\n {/*\n Disabled on the organization's Inbox rather than hidden: a\n member signs up to one site, and the line beside the toggle\n says how to reach a site's members, where a missing button\n would say nothing.\n */}\n <ToggleButton value=\"members\" disabled={hostId == null}>\n {'Members'}\n </ToggleButton>\n <ToggleButton value=\"leads\">{'Leads'}</ToggleButton>\n </ToggleButtonGroup>\n {hostId == null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Every site’s leads, newest first. Members sign up to one ' +\n 'site each — choose a site to list its members.'}\n </Typography>\n ) : null}\n </Stack>\n {empty ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {emptyLabel}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={declaration.fields}\n headers={headers}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={options}\n />\n <ListQueryNotices refused={refusals} notices={notices} />\n <ListTable\n // One grid per list, so the other list's search box and\n // panel state never carry across the toggle.\n key={view}\n aria-label={view === 'members' ? 'Site members' : 'Leads'}\n rows={shown.rows}\n columns={listFilterGridColumns(columns, declaration.fields, options, headers)}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search go to the query; the grid neither\n // filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n noRowsLabel={\n view === 'members'\n ? 'No members match these filters'\n : 'No leads match these filters'\n }\n />\n <ListPagination\n page={shown.page}\n pageSize={shown.pageSize}\n rowCount={shown.rows.length}\n hasMore={shown.hasMore}\n onPageChange={shown.setPage}\n onPageSizeChange={shown.setPageSize}\n />\n </>\n )}\n </Stack>\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id && leadOriginSite ? (\n <InboxRecordAttributionZone\n hostId={leadOriginSite}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","scopeTokensForHost","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","authorizedFetch","useFirestore","useOrgDataScope","useScopeTokens","useUser","useListQuery","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","useCallback","useMemo","useState","LEAD_FILTER_HEADERS","LEAD_LIST_QUERY","LEAD_SOURCE_OPTIONS","ORG_LEAD_LIST_QUERY","SITE_MEMBER_FILTER_HEADERS","SITE_MEMBER_LIST_QUERY","leadListBase","LEAD_ADDRESS_SEARCH_COLUMN","LEAD_ADDRESS_SEARCH_NOTICE","leadListQueryFor","leadSourceLabel","orgSiteNames","useRecordRouteContext","ORG_LEAD_SELECT_FIELDS","NO_SELECT_FIELDS","leadSources","lead","Array","isArray","sources","filter","source","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","chosen","setChosen","view","searchWords","setSearchWords","gridFilter","selectFields","search","words","onChange","switchView","next","setClauses","request","clauses","members","declaration","deps","idField","orgId","undefined","reach","foldsScope","loaded","orgWide","leadDeclaration","leadBase","leadQuery","base","JSON","stringify","leads","shown","headers","options","capturedByHostIds","hosts","map","host","value","id","label","name","refusals","plan","refused","entry","clause","field","fields","addressSearched","addressSearch","served","some","notices","filtering","length","word","trim","handleDeleteMember","member","confirmed","title","description","email","confirmationText","confirmationButtonProps","color","then","catch","response","method","body","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","leadOriginSite","memberActions","key","icon","path","size","destructive","onClick","leadActions","href","columns","headerName","flex","minWidth","renderCell","row","displayName","direction","spacing","sx","alignItems","noWrap","width","sortable","valueGetter","_value","join","height","Set","createdAt","toDate","toLocaleString","items","empty","rows","hasMore","emptyLabel","header","help","anchor","contentGutterX","contentGutterY","contentBordered","flexWrap","exclusive","_event","aria-label","disabled","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","noRowsLabel","page","pageSize","rowCount","onPageChange","setPage","onPageSizeChange","setPageSize","open","Boolean","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
|
|
@@ -29,81 +29,48 @@ import { mdiAccountArrowRight, mdiDeleteOutline, mdiEmailOpenOutline, mdiEmailOu
|
|
|
29
29
|
import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
30
30
|
import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
|
|
31
31
|
import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
|
|
32
|
+
import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
|
|
32
33
|
import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
|
|
33
|
-
import {
|
|
34
|
+
import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
|
|
34
35
|
import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
35
|
-
import {
|
|
36
|
+
import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
|
|
36
37
|
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
37
|
-
import { useFirestore, useFirestoreCollection,
|
|
38
|
+
import { useFirestore, useFirestoreCollection, usePluginApiPost } from "@aglyn/tenant-feature-instance";
|
|
39
|
+
import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
|
|
38
40
|
import { Avatar, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, Stack, Tooltip, Typography } from "@mui/material";
|
|
39
|
-
import { collection, collectionGroup, deleteDoc, doc, getDoc, limit, orderBy, query, updateDoc
|
|
41
|
+
import { collection, collectionGroup, deleteDoc, doc, getDoc, limit, orderBy, query, updateDoc } from "firebase/firestore";
|
|
40
42
|
import { useSearchParams } from "next/navigation";
|
|
41
43
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
42
44
|
import { relativeTime, routingChips, senderHue, submissionSender } from "../model/submission-presenter.js";
|
|
45
|
+
import { FORM_SCOPED_SUBMISSION_LIST_QUERY, ORG_SUBMISSION_LIST_QUERY, SUBMISSION_FILTER_HEADERS, SUBMISSION_LIST_QUERY, SUBMISSION_READ_OPTIONS, formSubmissionBase, orgSubmissionBase } from "../constants/list-queries.js";
|
|
43
46
|
import { orgSiteName } from "./inbox-org-sites.js";
|
|
44
47
|
import SubmissionListAssignment from "./submission-list-assignment.component.js";
|
|
45
48
|
import SubmissionReply from "./submission-reply.component.js";
|
|
46
49
|
import { useRecordRouteContext } from "./use-record-route-context.js";
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* (`readKey`), and Site on the organization's Inbox.
|
|
57
|
-
*/ const FORM_FIELD = _extends({}, inMemoryListField('formId', 'select'), {
|
|
58
|
-
operators: [
|
|
59
|
-
'equals'
|
|
60
|
-
]
|
|
61
|
-
});
|
|
62
|
-
const SUBMISSION_BASE_FIELDS = [
|
|
63
|
-
inMemoryListField('from', 'text', 'senderLabel'),
|
|
64
|
-
inMemoryListField('message', 'text', 'messageText'),
|
|
65
|
-
inMemoryListField('read', 'select', 'readKey')
|
|
66
|
-
];
|
|
67
|
-
const SUBMISSION_FILTER_HEADERS = {
|
|
68
|
-
from: 'From',
|
|
69
|
-
message: 'Message',
|
|
70
|
-
read: 'Read',
|
|
71
|
-
formId: 'Form',
|
|
72
|
-
hostId: 'Site'
|
|
73
|
-
};
|
|
74
|
-
const READ_OPTIONS = [
|
|
75
|
-
{
|
|
76
|
-
value: 'unread',
|
|
77
|
-
label: 'Unread'
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
value: 'read',
|
|
81
|
-
label: 'Read'
|
|
82
|
-
}
|
|
83
|
-
];
|
|
84
|
-
const SUBMISSION_SEARCH_FIELDS = [
|
|
85
|
-
'senderLabel',
|
|
86
|
-
'formName',
|
|
87
|
-
'messageText'
|
|
50
|
+
/**
|
|
51
|
+
* The forms plugin's recount route (AGL-3330), by address: this plugin
|
|
52
|
+
* shows the submissions a form counts and may not import the plugin that
|
|
53
|
+
* keeps the counts.
|
|
54
|
+
*/ const FORM_STATS_API_PATH = '/api/forms/stats';
|
|
55
|
+
/** Read, Form and Site are picked, so the panel shows selects. */ const SUBMISSION_SELECT_FIELDS = [
|
|
56
|
+
'read',
|
|
57
|
+
'formId',
|
|
58
|
+
'hostId'
|
|
88
59
|
];
|
|
89
60
|
const SUBMISSION_HIDDEN_COLUMNS = {
|
|
90
61
|
read: false,
|
|
91
62
|
formId: false
|
|
92
63
|
};
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return _extends({}, submission, {
|
|
97
|
-
senderLabel: submissionSender(submission.fields).label,
|
|
98
|
-
messageText: Object.entries((_submission_fields = submission.fields) != null ? _submission_fields : {}).map(([key, value])=>`${key}: ${value}`).join(' · '),
|
|
99
|
-
readKey: submission.read ? 'read' : 'unread'
|
|
100
|
-
});
|
|
64
|
+
/** Every field of a message, as one line — what the Message column draws. */ const messageTextOf = (submission)=>{
|
|
65
|
+
var _ref;
|
|
66
|
+
return Object.entries((_ref = submission == null ? void 0 : submission.fields) != null ? _ref : {}).map(([key, value])=>`${key}: ${value}`).join(' · ');
|
|
101
67
|
};
|
|
102
68
|
export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
103
69
|
var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
104
|
-
var
|
|
70
|
+
var _reader_createdAt_toDate, _reader_createdAt;
|
|
105
71
|
const firestore = useFirestore();
|
|
106
72
|
const { enqueueSnackbar } = useSnackbar();
|
|
73
|
+
const postPluginApi = usePluginApiPost();
|
|
107
74
|
const { confirm } = useConfirmationContext();
|
|
108
75
|
/** Scoped to one form: the subject is fixed and nothing may widen it. */ const scoped = Boolean(formId);
|
|
109
76
|
/** Every site of this organization at once, when no site is named. */ const orgId = hostId == null ? (_ref = orgMount == null ? void 0 : orgMount.orgId) != null ? _ref : null : null;
|
|
@@ -157,28 +124,40 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
157
124
|
}), [
|
|
158
125
|
formDocs
|
|
159
126
|
]);
|
|
127
|
+
/** Form is offered where the site's catalog is read. */ const offersForm = !scoped && Boolean(hostId) && forms.length > 0;
|
|
160
128
|
/*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* submissions.
|
|
164
|
-
*
|
|
165
|
-
* `formFilter` is a PRIMITIVE, deliberately: `usePagedCollection` reopens
|
|
166
|
-
* its listener when a dep changes, and an object identity would tear down
|
|
167
|
-
* and reopen on every render. It also resets the reader to page 1, which
|
|
168
|
-
* is what switching subjects should do.
|
|
169
|
-
*/ const [clauses, setClauses] = useState([]);
|
|
170
|
-
const formFilter = ((_clauses_find = clauses.find((clause)=>clause.field === 'formId' && clause.op === 'equals')) == null ? void 0 : _clauses_find.value) || null;
|
|
171
|
-
/**
|
|
172
|
-
* The form the query is actually narrowed to.
|
|
129
|
+
* WHAT THIS CARD'S LIST CAN BE ASKED (AGL-3321) — every clause and the
|
|
130
|
+
* search on the query, see `SUBMISSION_LIST_QUERY`:
|
|
173
131
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
132
|
+
* - a site's Inbox: From, Read and — where the site has forms — Form;
|
|
133
|
+
* - a card scoped to one form: that form is the list's own scope, and
|
|
134
|
+
* Form has nothing left to pick, so it is not offered. The scope wins
|
|
135
|
+
* over any clause: it is the base, and no clause can reach it;
|
|
136
|
+
* - every site's, on the organization's Inbox: From, Read and Site,
|
|
137
|
+
* under the `orgId` clause the rules require of the group read.
|
|
138
|
+
*/ const declaration = useMemo(()=>{
|
|
139
|
+
if (scoped) return FORM_SCOPED_SUBMISSION_LIST_QUERY;
|
|
140
|
+
if (hostId == null) return ORG_SUBMISSION_LIST_QUERY;
|
|
141
|
+
return offersForm ? SUBMISSION_LIST_QUERY : _extends({}, SUBMISSION_LIST_QUERY, {
|
|
142
|
+
fields: SUBMISSION_LIST_QUERY.fields.filter((field)=>field.column !== 'formId')
|
|
143
|
+
});
|
|
144
|
+
}, [
|
|
145
|
+
scoped,
|
|
146
|
+
hostId,
|
|
147
|
+
offersForm
|
|
148
|
+
]);
|
|
149
|
+
const base = useMemo(()=>formId ? formSubmissionBase(formId) : hostId == null && orgId ? orgSubmissionBase(orgId) : [], [
|
|
150
|
+
formId,
|
|
151
|
+
hostId,
|
|
152
|
+
orgId
|
|
153
|
+
]);
|
|
154
|
+
const gridFilter = useListGridFilter({
|
|
155
|
+
selectFields: SUBMISSION_SELECT_FIELDS
|
|
156
|
+
});
|
|
179
157
|
/*
|
|
180
158
|
* The inbox WALKS its submissions instead of sampling them (AGL-2501,
|
|
181
|
-
* AGL-2292)
|
|
159
|
+
* AGL-2292), and every filter and search word is a predicate on the walk
|
|
160
|
+
* (AGL-3321), so a page is a page of the matches.
|
|
182
161
|
*
|
|
183
162
|
* `limit(200)` carried no `orderBy`, so Firestore answered it in
|
|
184
163
|
* DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred
|
|
@@ -193,51 +172,34 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
193
172
|
* that creates one and stamps `createdAt: serverTimestamp()` on every add,
|
|
194
173
|
* the v1 API only ever reads and deletes, and `formSubmissions` is absent
|
|
195
174
|
* from `IMPORTABLE_FIELDS`, so no restore path can make one without it.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
hostId,
|
|
219
|
-
orgId,
|
|
220
|
-
activeForm
|
|
221
|
-
], {
|
|
175
|
+
*
|
|
176
|
+
* EVERY SITE'S SUBMISSIONS IN ONE QUERY on the organization's Inbox
|
|
177
|
+
* (AGL-3303), not a listener per site: the collection group, newest first.
|
|
178
|
+
* The `orgId` clause is not a filter this card could drop. The rules admit
|
|
179
|
+
* a group read only when it is narrowed to an org the reader spans, so an
|
|
180
|
+
* unfiltered group query is refused outright. Every submission carries
|
|
181
|
+
* `orgId`: the submit route stamps it, and the rows written before it did
|
|
182
|
+
* were stamped once (AGL-3303). A row without one would be in no
|
|
183
|
+
* organization's list, though its own site's Inbox lists it either way.
|
|
184
|
+
*/ const { rows: submissions, hasMore: hasMoreSubmissions, page: submissionPage, setPage: setSubmissionPage, pageSize: submissionPageSize, setPageSize: setSubmissionPageSize, plan } = useListQuery({
|
|
185
|
+
collection: hostId ? collection(firestore, 'hosts', hostId, 'formSubmissions') : orgId ? collectionGroup(firestore, 'formSubmissions') : null,
|
|
186
|
+
declaration,
|
|
187
|
+
request: {
|
|
188
|
+
clauses: gridFilter.clauses,
|
|
189
|
+
search: gridFilter.searchWords,
|
|
190
|
+
base
|
|
191
|
+
},
|
|
192
|
+
deps: [
|
|
193
|
+
firestore,
|
|
194
|
+
hostId,
|
|
195
|
+
orgId
|
|
196
|
+
],
|
|
222
197
|
idField: '$id'
|
|
223
198
|
});
|
|
224
|
-
/** Form is offered where the site's catalog is read. */ const offersForm = !scoped && Boolean(hostId) && forms.length > 0;
|
|
225
|
-
const submissionFields = useMemo(()=>[
|
|
226
|
-
...SUBMISSION_BASE_FIELDS,
|
|
227
|
-
...offersForm ? [
|
|
228
|
-
FORM_FIELD
|
|
229
|
-
] : [],
|
|
230
|
-
...hostId == null ? [
|
|
231
|
-
inMemoryListField('hostId', 'select')
|
|
232
|
-
] : []
|
|
233
|
-
], [
|
|
234
|
-
offersForm,
|
|
235
|
-
hostId
|
|
236
|
-
]);
|
|
237
199
|
const submissionOptions = useMemo(()=>{
|
|
238
200
|
var _ref;
|
|
239
201
|
return _extends({
|
|
240
|
-
read:
|
|
202
|
+
read: SUBMISSION_READ_OPTIONS
|
|
241
203
|
}, offersForm ? {
|
|
242
204
|
formId: forms.map((form)=>({
|
|
243
205
|
value: String(form.$id),
|
|
@@ -255,32 +217,18 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
255
217
|
hostId,
|
|
256
218
|
orgMount
|
|
257
219
|
]);
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
220
|
+
const refusals = useMemo(()=>listQueryRefusals(plan.refused, {
|
|
221
|
+
fields: declaration.fields,
|
|
222
|
+
headers: SUBMISSION_FILTER_HEADERS,
|
|
223
|
+
options: submissionOptions
|
|
224
|
+
}), [
|
|
225
|
+
plan.refused,
|
|
226
|
+
declaration,
|
|
227
|
+
submissionOptions
|
|
263
228
|
]);
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
hasMore: hasMoreSubmissions,
|
|
268
|
-
page: submissionPage,
|
|
269
|
-
setPage: setSubmissionPage,
|
|
270
|
-
pageSize: submissionPageSize,
|
|
271
|
-
setPageSize: setSubmissionPageSize
|
|
272
|
-
}, {
|
|
273
|
-
fields: submissionFields,
|
|
274
|
-
options: submissionOptions,
|
|
275
|
-
headers: SUBMISSION_FILTER_HEADERS,
|
|
276
|
-
search: SUBMISSION_SEARCH_FIELDS,
|
|
277
|
-
served: servedByQuery,
|
|
278
|
-
clauses,
|
|
279
|
-
onChange: setClauses
|
|
280
|
-
});
|
|
281
|
-
// The rows on screen: the page, or the page of the matches.
|
|
282
|
-
const submissions = submissionFilter.rows;
|
|
283
|
-
/** Narrowed by nothing but the served Form clause. */ const onlyForm = Boolean(formFilter) && clauses.every(servedByQuery) && submissionFilter.gridFilter.searchWords.length === 0;
|
|
229
|
+
const searching = gridFilter.searchWords.some((word)=>word.trim());
|
|
230
|
+
const filtering = gridFilter.clauses.length > 0 || searching;
|
|
231
|
+
/** Narrowed by nothing but a Form clause. */ const onlyForm = !searching && gridFilter.clauses.length > 0 && gridFilter.clauses.every((clause)=>clause.field === 'formId');
|
|
284
232
|
// Mail reader (AGL-104): opening a submission shows the full message and
|
|
285
233
|
// marks it read.
|
|
286
234
|
const [reader, setReader] = useState(null);
|
|
@@ -370,11 +318,25 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
370
318
|
variant: 'success',
|
|
371
319
|
persist: false
|
|
372
320
|
});
|
|
321
|
+
/*
|
|
322
|
+
* The form counted this submission when it arrived (AGL-3330), and an
|
|
323
|
+
* increment cannot see a delete: the forms plugin recounts the form
|
|
324
|
+
* from the rows that are left. Best effort — the delete has happened
|
|
325
|
+
* either way, and the recount script catches a follow-up that failed.
|
|
326
|
+
*/ if (typeof submission.formId === 'string' && submission.formId) {
|
|
327
|
+
void postPluginApi(FORM_STATS_API_PATH, {
|
|
328
|
+
hostId: site,
|
|
329
|
+
formIds: [
|
|
330
|
+
submission.formId
|
|
331
|
+
]
|
|
332
|
+
});
|
|
333
|
+
}
|
|
373
334
|
}, [
|
|
374
335
|
confirm,
|
|
375
336
|
firestore,
|
|
376
337
|
siteOf,
|
|
377
|
-
enqueueSnackbar
|
|
338
|
+
enqueueSnackbar,
|
|
339
|
+
postPluginApi
|
|
378
340
|
]);
|
|
379
341
|
/*
|
|
380
342
|
* One row per submission, newest first: who sent it, what it said and how
|
|
@@ -433,7 +395,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
433
395
|
headerName: 'From',
|
|
434
396
|
flex: 1,
|
|
435
397
|
minWidth: 220,
|
|
436
|
-
valueGetter: (_value, submission)=>submission.
|
|
398
|
+
valueGetter: (_value, submission)=>submissionSender(submission.fields).label,
|
|
437
399
|
renderCell: ({ row: submission })=>{
|
|
438
400
|
var _submission_formName;
|
|
439
401
|
const sender = submissionSender(submission.fields);
|
|
@@ -511,7 +473,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
511
473
|
headerName: 'Message',
|
|
512
474
|
flex: 2,
|
|
513
475
|
minWidth: 260,
|
|
514
|
-
valueGetter: (_value, submission)=>submission
|
|
476
|
+
valueGetter: (_value, submission)=>messageTextOf(submission),
|
|
515
477
|
renderCell: ({ value })=>/*#__PURE__*/ _jsx(Typography, {
|
|
516
478
|
variant: "body2",
|
|
517
479
|
noWrap: true,
|
|
@@ -543,6 +505,17 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
543
505
|
}
|
|
544
506
|
},
|
|
545
507
|
/*
|
|
508
|
+
READ, as the panel picks it — never drawn: the unread dot and the bold
|
|
509
|
+
row already say it. A boolean field has no hidden column of its own (the
|
|
510
|
+
grid's boolean operators are not the select's), so it is declared here,
|
|
511
|
+
kept hidden and out of Manage columns.
|
|
512
|
+
*/ {
|
|
513
|
+
field: 'read',
|
|
514
|
+
headerName: 'Read',
|
|
515
|
+
hideable: false,
|
|
516
|
+
valueGetter: (_value, submission)=>String(Boolean(submission.read))
|
|
517
|
+
},
|
|
518
|
+
/*
|
|
546
519
|
One overflow menu, as the Members & leads rows have: two inline buttons
|
|
547
520
|
had no room for a third, and the contact link is the one a reader
|
|
548
521
|
reaches for after reading. Present but disabled for a submission with no
|
|
@@ -591,28 +564,27 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
591
564
|
},
|
|
592
565
|
children: 'Every site’s submissions, newest first. Choose a site to ' + 'narrow them by form.'
|
|
593
566
|
}) : null,
|
|
594
|
-
|
|
567
|
+
submissions.length === 0 && !hasMoreSubmissions && !filtering ? /*#__PURE__*/ _jsx(Typography, {
|
|
595
568
|
variant: "body2",
|
|
596
569
|
color: "text.secondary",
|
|
597
570
|
children: scoped ? 'No submissions carry this form’s id yet. Messages this ' + 'form’s design collected before it became a form entity are ' + 'in the Inbox, filed under the name they were sent with.' : hostId == null ? 'No form submissions on any site yet.' : 'No form submissions yet. Add a Contact Form element to a ' + 'screen — visitor messages arrive here.'
|
|
598
571
|
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
599
572
|
children: [
|
|
600
|
-
/*#__PURE__*/ _jsx(ListFilterChips,
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
/*#__PURE__*/ _jsx(ListTable, _extends({
|
|
573
|
+
/*#__PURE__*/ _jsx(ListFilterChips, {
|
|
574
|
+
fields: declaration.fields,
|
|
575
|
+
headers: SUBMISSION_FILTER_HEADERS,
|
|
576
|
+
clauses: gridFilter.clauses,
|
|
577
|
+
onChange: gridFilter.setClauses,
|
|
578
|
+
options: submissionOptions
|
|
579
|
+
}),
|
|
580
|
+
/*#__PURE__*/ _jsx(ListQueryNotices, {
|
|
581
|
+
refused: refusals,
|
|
582
|
+
notices: plan.notices
|
|
583
|
+
}),
|
|
584
|
+
/*#__PURE__*/ _jsx(ListTable, {
|
|
613
585
|
"aria-label": scoped ? 'Submissions to this form' : 'Form submissions',
|
|
614
586
|
rows: submissions,
|
|
615
|
-
columns:
|
|
587
|
+
columns: listFilterGridColumns(submissionColumns, declaration.fields, submissionOptions, SUBMISSION_FILTER_HEADERS),
|
|
616
588
|
initialState: {
|
|
617
589
|
columns: {
|
|
618
590
|
columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS
|
|
@@ -630,9 +602,23 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
630
602
|
}
|
|
631
603
|
},
|
|
632
604
|
// Paged by the footer below, so the grid must not also slice.
|
|
633
|
-
hideFooter: true
|
|
634
|
-
|
|
635
|
-
|
|
605
|
+
hideFooter: true,
|
|
606
|
+
// The panel and the search go to the query; the grid neither
|
|
607
|
+
// filters nor sorts the page it holds.
|
|
608
|
+
filterMode: "server",
|
|
609
|
+
filterModel: gridFilter.filterModel,
|
|
610
|
+
onFilterModelChange: gridFilter.onFilterModelChange,
|
|
611
|
+
quickFilter: true,
|
|
612
|
+
disableColumnSorting: true
|
|
613
|
+
}),
|
|
614
|
+
/*#__PURE__*/ _jsx(ListPagination, {
|
|
615
|
+
page: submissionPage,
|
|
616
|
+
pageSize: submissionPageSize,
|
|
617
|
+
rowCount: submissions.length,
|
|
618
|
+
hasMore: hasMoreSubmissions,
|
|
619
|
+
onPageChange: setSubmissionPage,
|
|
620
|
+
onPageSizeChange: setSubmissionPageSize
|
|
621
|
+
})
|
|
636
622
|
]
|
|
637
623
|
})
|
|
638
624
|
]
|