@aglyn/plugins-crm 1.0.0-beta.219 → 1.0.0-beta.221
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/README.md +2 -0
- package/package.json +14 -15
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/contact-associations-card.js +4 -3
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/crm-booking-zone.d.ts +8 -2
- package/src/lib/components/crm-booking-zone.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +2 -1
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/deal-products-card.js +27 -16
- package/src/lib/components/deal-products-card.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +4 -4
- package/src/lib/components/lead-campaigns-card.js +9 -8
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +4 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-status-chip.js +3 -1
- package/src/lib/components/lead-status-chip.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +7 -9
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +5 -1
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +5 -4
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/reports/activity-card.js +4 -3
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/components/reports/closed-deals-card.js +4 -3
- package/src/lib/components/reports/closed-deals-card.js.map +1 -1
- package/src/lib/components/reports/contacts-mix-card.js +3 -2
- package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
- package/src/lib/components/reports/contacts-trend-card.js +2 -1
- package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
- package/src/lib/components/reports/forecast-card.js +3 -2
- package/src/lib/components/reports/forecast-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +8 -6
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +4 -3
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/components/reports/report-format.d.ts +1 -1
- package/src/lib/components/reports/report-format.js.map +1 -1
- package/src/lib/components/reports/report-scope.d.ts +1 -1
- package/src/lib/components/reports/report-scope.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +2 -1
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/reports/tasks-card.js +2 -1
- package/src/lib/components/reports/tasks-card.js.map +1 -1
- package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
- package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
- package/src/lib/components/reports-section.js +4 -4
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +2 -0
- package/src/lib/constants/bundle-common.js +1 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +4 -0
- package/src/lib/declarations.console-server.js +12 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/declarations.server.d.ts +6 -0
- package/src/lib/declarations.server.js +51 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
- package/src/lib/hooks/use-crm-campaigns.js +3 -3
- package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
- package/src/lib/model/campaign-filing-activity.d.ts +1 -1
- package/src/lib/model/campaign-filing-activity.js +1 -1
- package/src/lib/model/campaign-filing-activity.js.map +1 -1
- package/src/lib/model/contact-record.js +1 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-digest.d.ts +45 -3
- package/src/lib/model/crm-digest.js +75 -11
- package/src/lib/model/crm-digest.js.map +1 -1
- package/src/lib/model/crm-inbound.d.ts +162 -0
- package/src/lib/model/crm-inbound.js +208 -0
- package/src/lib/model/crm-inbound.js.map +1 -0
- package/src/lib/model/crm-lead-import.d.ts +1 -1
- package/src/lib/model/crm-lead-import.js +4 -4
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/crm-reports.d.ts +467 -0
- package/src/lib/model/crm-reports.js +667 -0
- package/src/lib/model/crm-reports.js.map +1 -0
- package/src/lib/model/lead-filters.d.ts +4 -3
- package/src/lib/model/lead-filters.js +7 -5
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/plugin.js +36 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/crm-leads.js +1 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +1 -1
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.d.ts +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -11
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/capture-contact.js +19 -5
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +33 -0
- package/src/lib/server/contact-email-engagement.js +146 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-update.js +3 -3
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/container-detach.d.ts +31 -0
- package/src/lib/server/container-detach.js +115 -0
- package/src/lib/server/container-detach.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +162 -0
- package/src/lib/server/crm-inbound-email.js +259 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-records-meter.d.ts +50 -0
- package/src/lib/server/crm-records-meter.js +126 -0
- package/src/lib/server/crm-records-meter.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +2 -8
- package/src/lib/server/daily-digest-route.js +15 -25
- package/src/lib/server/daily-digest-route.js.map +1 -1
- package/src/lib/server/inbound-address.js +5 -3
- package/src/lib/server/inbound-address.js.map +1 -1
- package/src/lib/server/inbound-route.js +7 -4
- package/src/lib/server/inbound-route.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.js +3 -3
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +1 -1
- package/src/lib/server/lead-create.js +10 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-nurturing.d.ts +23 -0
- package/src/lib/server/lead-nurturing.js +82 -0
- package/src/lib/server/lead-nurturing.js.map +1 -0
- package/src/lib/server/leads-import.js +7 -7
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +23 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +12 -0
- package/src/lib/server/record-timeline.js +127 -15
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.js +4 -2
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +22 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/usage-axes.js +8 -1
- package/src/lib/usage-axes.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/activity-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportExport } from './report-export'\nimport { reportFilename } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many activities and how many completed tasks the leaderboard is\n * grouped from.\n *\n * The tiles are server counts and never meet these bounds; the table needs\n * each activity's kind and author and each task's completer, so it reads\n * the period's newest thousand of each and says when there were more —\n * the contacts list's bound, and the one every window on this page shares.\n */\nconst ACTIVITY_CEILING = 1000\nconst TASK_DONE_CEILING = 1000\n\ntype ActivityRow = Aglyn.CrmActivityRow\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The table's columns, which are also the CSV's. */\nconst COLUMNS = [\n 'Teammate',\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]),\n 'Activities',\n 'Tasks done',\n] as const\n\nexport interface ActivityCardProps {\n report: CrmReportScope\n}\n\n/**\n * Activity by teammate (AGL-2624): the calls, emails, meetings and notes\n * each member logged in the period, and the tasks they ticked off,\n * busiest first.\n *\n * ## Two windows, two indexes, one table\n *\n * Activities are counted within the period on `atMs`; completed tasks are\n * counted the same way on `completedAtMs`, under `status == 'done'`. Each\n * count is also drawn as a window, newest first, for the leaderboard.\n *\n * ## Why that is four indexes and not one\n *\n * The counts and the windows do not share an index, and neither do the two\n * audiences.\n *\n * A COUNT here names no `orderBy`. Firestore then orders implicitly by the\n * range field, ASCENDING, so a count wants `completedAtMs` ascending — while\n * the window beside it asks for `desc` explicitly and wants the descending\n * one. A composite index serves one direction or its exact reverse, never\n * both, so declaring only the direction the window reads leaves the count\n * with nothing: `FAILED_PRECONDITION`, one column of dashes, and a card that\n * otherwise looks fine.\n *\n * Then the visibility clause doubles it. `visibleToClause` contributes\n * nothing when the reader's tokens are null, which is every organization-level\n * read, so the shape arriving at Firestore has no leading array field — a\n * different index, not a prefix of the one with it. `(visibleTo, status,\n * completedAtMs)` serves a site and `(status, completedAtMs)` the org above\n * it; one without the other is a card that works for exactly one audience.\n *\n * The two reads are independent so that a missing task index degrades ONE\n * column to a dash and a notice rather than the whole card: the activities\n * were read, and the leaderboard draws them.\n *\n * A task ordered by `completedAtMs` is a task the complete route stamped;\n * a task marked done some other way, without the stamp, is not in the\n * window and the caption says what the column is grouped from.\n *\n * ## Names\n *\n * Every activity carries the name it was signed with, for the reason\n * `CrmActivity.byName` gives — a scoped reader cannot resolve a colleague's\n * uid. The roster is read too, through the members route any member may\n * ask, and wins when it knows the uid, so a renamed teammate reads by\n * their current name; the signed name covers a uid the roster no longer\n * has, and the uid itself covers the rest.\n */\nexport function ActivityCard(props: ActivityCardProps) {\n const { report } = props\n const { scope, tokens, range, period, routes } = report\n const firestore = useFirestore()\n const roster = useOrgMemberOptions(scope[1])\n\n const activitiesBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.activities),\n ...visibleToClause(tokens),\n where('atMs', '>=', from),\n where('atMs', '<', to),\n )\n const tasksDoneBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'done'),\n where('completedAtMs', '>=', from),\n where('completedAtMs', '<', to),\n )\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n const activityCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(activitiesBetween(range.from, range.to)),\n countOf(activitiesBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:counts') },\n )\n const taskCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(tasksDoneBetween(range.from, range.to)),\n countOf(tasksDoneBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-counts') },\n )\n\n const activityWindow = useWindowRead<ActivityRow>(\n () =>\n query(\n activitiesBetween(range.from, range.to),\n orderBy('atMs', 'desc'),\n limit(ACTIVITY_CEILING + 1),\n ),\n ACTIVITY_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:window') },\n )\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n tasksDoneBetween(range.from, range.to),\n orderBy('completedAtMs', 'desc'),\n limit(TASK_DONE_CEILING + 1),\n ),\n TASK_DONE_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-window') },\n )\n\n const rows = useMemo(\n () => Aglyn.activityLeaderboard(activityWindow.rows, taskWindow.rows),\n [activityWindow, taskWindow],\n )\n const nameOf = (row: Aglyn.ActivityLeaderboardRow): string => {\n if (!row.uid) return 'No teammate'\n return Aglyn.findOrgMember(roster.options, row.uid)?.label ?? row.name ?? row.uid\n }\n const teammates = rows.filter((row) => row.uid).length\n\n const activitiesRead = activityWindow.status === 'success'\n const tasksRead = taskWindow.status === 'success'\n const settled = activityWindow.status !== 'loading' && taskWindow.status !== 'loading'\n const caption = [\n activityWindow.truncated\n ? `Grouped from the ${ACTIVITY_CEILING.toLocaleString()} most recent activities in the period`\n : null,\n taskWindow.truncated\n ? `the ${TASK_DONE_CEILING.toLocaleString()} most recently completed tasks`\n : null,\n ]\n .filter(Boolean)\n .join(' and ')\n const activityFigures = activityCounts.value\n const taskFigures = taskCounts.value\n\n return (\n <CardDisplay\n header={'Activity by teammate'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#activity-by-teammate',\n excerpt:\n 'The calls, emails, meetings and notes each teammate logged in ' +\n 'the period, and the tasks they completed, busiest first. ' +\n 'Grouped from the period’s newest thousand of each.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <ReportExport\n filename={reportFilename('activity', period)}\n columns={COLUMNS}\n rows={() =>\n rows.map((row) => [\n nameOf(row),\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => row.kinds[kind]),\n row.activities,\n tasksRead ? row.tasksDone : '',\n ])\n }\n disabled={!activitiesRead || !rows.length}\n />\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Activities logged'}\n value={activityFigures ? activityFigures.current.toLocaleString() : null}\n deltaPct={\n activityFigures\n ? Aglyn.deltaPercent(activityFigures.current, activityFigures.previous)\n : null\n }\n note={'counted on the server'}\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Tasks completed'}\n value={taskFigures ? taskFigures.current.toLocaleString() : null}\n deltaPct={\n taskFigures\n ? Aglyn.deltaPercent(taskFigures.current, taskFigures.previous)\n : null\n }\n note={'ticked off in the period'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Teammates active'}\n value={settled ? teammates.toLocaleString() : null}\n note={'logged or completed something'}\n />\n </Stack>\n {activityCounts.status === 'error' || activityWindow.status === 'error' ? (\n <Alert severity=\"warning\">{'The activities could not be read.'}</Alert>\n ) : null}\n {taskCounts.status === 'error' || taskWindow.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The completed-task counts could not be read; the Tasks done column is left blank.'}\n </Alert>\n ) : null}\n <Section title={'Who did what'}>\n {rows.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.uid || '$nobody'}>\n <TableCell>{nameOf(row)}</TableCell>\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <TableCell key={kind} align=\"right\">\n {row.kinds[kind].toLocaleString()}\n </TableCell>\n ))}\n <TableCell align=\"right\">{row.activities.toLocaleString()}</TableCell>\n <TableCell align=\"right\">\n {tasksRead ? row.tasksDone.toLocaleString() : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled ? 'Nothing logged or completed in this period.' : 'Reading…'}\n </Typography>\n )}\n {rows.some((row) => !row.uid) ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No teammate — activities an automation logged, and tasks completed with nobody assigned.'}\n </Typography>\n ) : null}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${caption}; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nActivityCard.displayName = 'ActivityCard'\n\nexport default ActivityCard\n"],"names":["Aglyn","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","getCountFromServer","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","ACTIVITY_CEILING","TASK_DONE_CEILING","COLUMNS","CRM_ACTIVITY_KINDS","map","kind","CRM_ACTIVITY_KIND_LABELS","ActivityCard","props","report","scope","tokens","range","period","routes","firestore","roster","activitiesBetween","from","to","CRM_COLLECTIONS","activities","tasksDoneBetween","tasks","countOf","target","then","snapshot","data","count","activityCounts","Promise","all","previousFrom","previousTo","current","previous","cacheKey","taskCounts","activityWindow","taskWindow","rows","activityLeaderboard","nameOf","row","uid","findOrgMember","options","label","name","teammates","filter","length","activitiesRead","status","tasksRead","settled","caption","truncated","toLocaleString","Boolean","join","activityFigures","value","taskFigures","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","filename","columns","kinds","tasksDone","disabled","spacing","direction","sx","flexWrap","deltaPct","deltaPercent","note","href","section","severity","title","size","column","index","align","variant","color","some","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/activity-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportExport } from './report-export'\nimport { reportFilename } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\nimport {\n activityLeaderboard,\n type ActivityLeaderboardRow,\n deltaPercent,\n} from '../../model/crm-reports'\n\n/**\n * How many activities and how many completed tasks the leaderboard is\n * grouped from.\n *\n * The tiles are server counts and never meet these bounds; the table needs\n * each activity's kind and author and each task's completer, so it reads\n * the period's newest thousand of each and says when there were more —\n * the contacts list's bound, and the one every window on this page shares.\n */\nconst ACTIVITY_CEILING = 1000\nconst TASK_DONE_CEILING = 1000\n\ntype ActivityRow = Aglyn.CrmActivityRow\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The table's columns, which are also the CSV's. */\nconst COLUMNS = [\n 'Teammate',\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]),\n 'Activities',\n 'Tasks done',\n] as const\n\nexport interface ActivityCardProps {\n report: CrmReportScope\n}\n\n/**\n * Activity by teammate (AGL-2624): the calls, emails, meetings and notes\n * each member logged in the period, and the tasks they ticked off,\n * busiest first.\n *\n * ## Two windows, two indexes, one table\n *\n * Activities are counted within the period on `atMs`; completed tasks are\n * counted the same way on `completedAtMs`, under `status == 'done'`. Each\n * count is also drawn as a window, newest first, for the leaderboard.\n *\n * ## Why that is four indexes and not one\n *\n * The counts and the windows do not share an index, and neither do the two\n * audiences.\n *\n * A COUNT here names no `orderBy`. Firestore then orders implicitly by the\n * range field, ASCENDING, so a count wants `completedAtMs` ascending — while\n * the window beside it asks for `desc` explicitly and wants the descending\n * one. A composite index serves one direction or its exact reverse, never\n * both, so declaring only the direction the window reads leaves the count\n * with nothing: `FAILED_PRECONDITION`, one column of dashes, and a card that\n * otherwise looks fine.\n *\n * Then the visibility clause doubles it. `visibleToClause` contributes\n * nothing when the reader's tokens are null, which is every organization-level\n * read, so the shape arriving at Firestore has no leading array field — a\n * different index, not a prefix of the one with it. `(visibleTo, status,\n * completedAtMs)` serves a site and `(status, completedAtMs)` the org above\n * it; one without the other is a card that works for exactly one audience.\n *\n * The two reads are independent so that a missing task index degrades ONE\n * column to a dash and a notice rather than the whole card: the activities\n * were read, and the leaderboard draws them.\n *\n * A task ordered by `completedAtMs` is a task the complete route stamped;\n * a task marked done some other way, without the stamp, is not in the\n * window and the caption says what the column is grouped from.\n *\n * ## Names\n *\n * Every activity carries the name it was signed with, for the reason\n * `CrmActivity.byName` gives — a scoped reader cannot resolve a colleague's\n * uid. The roster is read too, through the members route any member may\n * ask, and wins when it knows the uid, so a renamed teammate reads by\n * their current name; the signed name covers a uid the roster no longer\n * has, and the uid itself covers the rest.\n */\nexport function ActivityCard(props: ActivityCardProps) {\n const { report } = props\n const { scope, tokens, range, period, routes } = report\n const firestore = useFirestore()\n const roster = useOrgMemberOptions(scope[1])\n\n const activitiesBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.activities),\n ...visibleToClause(tokens),\n where('atMs', '>=', from),\n where('atMs', '<', to),\n )\n const tasksDoneBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'done'),\n where('completedAtMs', '>=', from),\n where('completedAtMs', '<', to),\n )\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n const activityCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(activitiesBetween(range.from, range.to)),\n countOf(activitiesBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:counts') },\n )\n const taskCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(tasksDoneBetween(range.from, range.to)),\n countOf(tasksDoneBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-counts') },\n )\n\n const activityWindow = useWindowRead<ActivityRow>(\n () =>\n query(\n activitiesBetween(range.from, range.to),\n orderBy('atMs', 'desc'),\n limit(ACTIVITY_CEILING + 1),\n ),\n ACTIVITY_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:window') },\n )\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n tasksDoneBetween(range.from, range.to),\n orderBy('completedAtMs', 'desc'),\n limit(TASK_DONE_CEILING + 1),\n ),\n TASK_DONE_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-window') },\n )\n\n const rows = useMemo(\n () => activityLeaderboard(activityWindow.rows, taskWindow.rows),\n [activityWindow, taskWindow],\n )\n const nameOf = (row: ActivityLeaderboardRow): string => {\n if (!row.uid) return 'No teammate'\n return Aglyn.findOrgMember(roster.options, row.uid)?.label ?? row.name ?? row.uid\n }\n const teammates = rows.filter((row) => row.uid).length\n\n const activitiesRead = activityWindow.status === 'success'\n const tasksRead = taskWindow.status === 'success'\n const settled = activityWindow.status !== 'loading' && taskWindow.status !== 'loading'\n const caption = [\n activityWindow.truncated\n ? `Grouped from the ${ACTIVITY_CEILING.toLocaleString()} most recent activities in the period`\n : null,\n taskWindow.truncated\n ? `the ${TASK_DONE_CEILING.toLocaleString()} most recently completed tasks`\n : null,\n ]\n .filter(Boolean)\n .join(' and ')\n const activityFigures = activityCounts.value\n const taskFigures = taskCounts.value\n\n return (\n <CardDisplay\n header={'Activity by teammate'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#activity-by-teammate',\n excerpt:\n 'The calls, emails, meetings and notes each teammate logged in ' +\n 'the period, and the tasks they completed, busiest first. ' +\n 'Grouped from the period’s newest thousand of each.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <ReportExport\n filename={reportFilename('activity', period)}\n columns={COLUMNS}\n rows={() =>\n rows.map((row) => [\n nameOf(row),\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => row.kinds[kind]),\n row.activities,\n tasksRead ? row.tasksDone : '',\n ])\n }\n disabled={!activitiesRead || !rows.length}\n />\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Activities logged'}\n value={activityFigures ? activityFigures.current.toLocaleString() : null}\n deltaPct={\n activityFigures\n ? deltaPercent(activityFigures.current, activityFigures.previous)\n : null\n }\n note={'counted on the server'}\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Tasks completed'}\n value={taskFigures ? taskFigures.current.toLocaleString() : null}\n deltaPct={\n taskFigures\n ? deltaPercent(taskFigures.current, taskFigures.previous)\n : null\n }\n note={'ticked off in the period'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Teammates active'}\n value={settled ? teammates.toLocaleString() : null}\n note={'logged or completed something'}\n />\n </Stack>\n {activityCounts.status === 'error' || activityWindow.status === 'error' ? (\n <Alert severity=\"warning\">{'The activities could not be read.'}</Alert>\n ) : null}\n {taskCounts.status === 'error' || taskWindow.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The completed-task counts could not be read; the Tasks done column is left blank.'}\n </Alert>\n ) : null}\n <Section title={'Who did what'}>\n {rows.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.uid || '$nobody'}>\n <TableCell>{nameOf(row)}</TableCell>\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <TableCell key={kind} align=\"right\">\n {row.kinds[kind].toLocaleString()}\n </TableCell>\n ))}\n <TableCell align=\"right\">{row.activities.toLocaleString()}</TableCell>\n <TableCell align=\"right\">\n {tasksRead ? row.tasksDone.toLocaleString() : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled ? 'Nothing logged or completed in this period.' : 'Reading…'}\n </Typography>\n )}\n {rows.some((row) => !row.uid) ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No teammate — activities an automation logged, and tasks completed with nobody assigned.'}\n </Typography>\n ) : null}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${caption}; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nActivityCard.displayName = 'ActivityCard'\n\nexport default ActivityCard\n"],"names":["Aglyn","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","getCountFromServer","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","activityLeaderboard","deltaPercent","ACTIVITY_CEILING","TASK_DONE_CEILING","COLUMNS","CRM_ACTIVITY_KINDS","map","kind","CRM_ACTIVITY_KIND_LABELS","ActivityCard","props","report","scope","tokens","range","period","routes","firestore","roster","activitiesBetween","from","to","CRM_COLLECTIONS","activities","tasksDoneBetween","tasks","countOf","target","then","snapshot","data","count","activityCounts","Promise","all","previousFrom","previousTo","current","previous","cacheKey","taskCounts","activityWindow","taskWindow","rows","nameOf","row","uid","findOrgMember","options","label","name","teammates","filter","length","activitiesRead","status","tasksRead","settled","caption","truncated","toLocaleString","Boolean","join","activityFigures","value","taskFigures","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","filename","columns","kinds","tasksDone","disabled","spacing","direction","sx","flexWrap","deltaPct","note","href","section","severity","title","size","column","index","align","variant","color","some","displayName"],"mappings":"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"}
|
|
@@ -28,6 +28,7 @@ import { weekLabel } from "./report-format.js";
|
|
|
28
28
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
29
29
|
import { ReportStatTile } from "./report-stat-tile.js";
|
|
30
30
|
import { useAggregateRead, useWindowRead } from "./use-aggregate-read.js";
|
|
31
|
+
import { bucketByWeek, currencyOfDeals } from "../../model/crm-reports.js";
|
|
31
32
|
/**
|
|
32
33
|
* How many closed deals of each outcome the weekly chart reads.
|
|
33
34
|
*
|
|
@@ -92,8 +93,8 @@ import { useAggregateRead, useWindowRead } from "./use-aggregate-read.js";
|
|
|
92
93
|
const wonStatus = wonWindow.status;
|
|
93
94
|
const lostStatus = lostWindow.status;
|
|
94
95
|
const chart = useMemo(()=>{
|
|
95
|
-
const wonWeeks =
|
|
96
|
-
const lostWeeks =
|
|
96
|
+
const wonWeeks = bucketByWeek(wonWindow.rows, (deal)=>deal.closedAtMs, range.from, range.to);
|
|
97
|
+
const lostWeeks = bucketByWeek(lostWindow.rows, (deal)=>deal.closedAtMs, range.from, range.to);
|
|
97
98
|
return {
|
|
98
99
|
labels: wonWeeks.map((week)=>weekLabel(week.start)),
|
|
99
100
|
won: wonWeeks.map((week)=>week.items.length),
|
|
@@ -102,7 +103,7 @@ import { useAggregateRead, useWindowRead } from "./use-aggregate-read.js";
|
|
|
102
103
|
return total + Math.max(0, Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0));
|
|
103
104
|
}, 0)),
|
|
104
105
|
lost: lostWeeks.map((week)=>week.items.length),
|
|
105
|
-
currency:
|
|
106
|
+
currency: currencyOfDeals(wonWindow.rows).currency
|
|
106
107
|
};
|
|
107
108
|
}, [
|
|
108
109
|
wonWindow,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/closed-deals-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n measuredRate,\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport {\n count,\n getAggregateFromServer,\n getCountFromServer,\n limit,\n orderBy,\n query,\n sum,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many closed deals of each outcome the weekly chart reads.\n *\n * The tiles are server aggregates and never meet this bound; the chart\n * needs each deal's `closedAtMs` to place it in a week, so it reads them,\n * most recent first, and says when the period held more.\n */\nconst CLOSED_DEAL_CEILING = 500\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface ClosedDealsCardProps {\n report: CrmReportScope\n}\n\n/**\n * Deals won and lost in the period, and the win rate over what closed\n * (AGL-2604).\n *\n * Won and lost are TWO queries rather than one over `status in [won,\n * lost]`: each is an equality the `(visibleTo, status, closedAtMs)` index\n * serves directly, and a card that drew one series from a merged read\n * would have to split it again by hand. The range is on `closedAtMs`, the\n * moment the deal was closed — not `updatedAt`, which a note added last\n * week would move.\n */\nexport function ClosedDealsCard(props: ClosedDealsCardProps) {\n const { report } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const closedInPeriod = (status: Aglyn.CrmDealStatus) =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', status),\n where('closedAtMs', '>=', range.from),\n where('closedAtMs', '<', range.to),\n )\n\n const closed = useAggregateRead(\n () =>\n Promise.all([\n getAggregateFromServer(closedInPeriod('won'), {\n count: count(),\n amountCents: sum('amountCents'),\n }).then((snapshot) => ({\n count: Number(snapshot.data().count ?? 0),\n amountCents: Number(snapshot.data().amountCents ?? 0),\n })),\n getCountFromServer(closedInPeriod('lost')).then(\n (snapshot) => snapshot.data().count,\n ),\n ]).then(([won, lost]) => ({ won, lost })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:totals') },\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('won'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('lost'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n const wonStatus = wonWindow.status\n const lostStatus = lostWindow.status\n\n const chart = useMemo(() => {\n const wonWeeks = Aglyn.bucketByWeek(\n wonWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n const lostWeeks = Aglyn.bucketByWeek(\n lostWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n return {\n labels: wonWeeks.map((week) => weekLabel(week.start)),\n won: wonWeeks.map((week) => week.items.length),\n wonCents: wonWeeks.map((week) =>\n week.items.reduce(\n (total, deal) => total + Math.max(0, Number(deal.amountCents ?? 0)),\n 0,\n ),\n ),\n lost: lostWeeks.map((week) => week.items.length),\n currency: Aglyn.currencyOfDeals(wonWindow.rows).currency,\n }\n }, [wonWindow, lostWindow, range])\n\n const figures = closed.value\n const winRate = figures\n ? measuredRate(figures.won.count, figures.won.count + figures.lost, 'closed')\n : null\n const chartRead = wonStatus === 'success' && lostStatus === 'success'\n\n return (\n <CardDisplay\n header={'Won and lost'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost',\n excerpt:\n 'Deals closed in the period, by outcome and by week, with the ' +\n 'value of what was won and the win rate over everything that ' +\n 'closed. Placed by the moment each deal was closed.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Won'}\n value={figures ? figures.won.count.toLocaleString() : null}\n note={\n figures\n ? `${money(figures.won.amountCents, chart.currency)} closed won`\n : undefined\n }\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Lost'}\n value={figures ? figures.lost.toLocaleString() : null}\n note={'in the period'}\n riseIsGood={false}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Win rate'}\n value={winRate ? percent(winRate.value) : null}\n note={\n winRate\n ? `${winRate.numerator.toLocaleString()} of ${winRate.denominator.toLocaleString()} ${winRate.denominatorLabel}`\n : figures\n ? 'nothing closed in the period'\n : undefined\n }\n />\n </Stack>\n {closed.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The closed-deal totals could not be read.'}\n </Alert>\n ) : null}\n <Section title={'Won vs lost per week'}>\n <ReportBarChart\n labels={chart.labels}\n series={[\n { name: 'Won', color: 'success.main', values: chart.won },\n { name: 'Lost', color: 'error.main', values: chart.lost },\n ]}\n format={(value, seriesIndex, barIndex) =>\n seriesIndex === 0\n ? `Won: ${value.toLocaleString()} · ${money(chart.wonCents[barIndex] ?? 0, chart.currency)}`\n : `Lost: ${value.toLocaleString()}`\n }\n emptyText={chartRead ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`The chart places the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nClosedDealsCard.displayName = 'ClosedDealsCard'\n\nexport default ClosedDealsCard\n"],"names":["Aglyn","money","CardDisplay","measuredRate","percent","Section","Alert","Stack","Typography","count","getAggregateFromServer","getCountFromServer","limit","orderBy","query","sum","where","useMemo","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","CLOSED_DEAL_CEILING","ClosedDealsCard","props","report","scope","tokens","range","routes","firestore","closedInPeriod","status","from","to","closed","Promise","all","amountCents","then","snapshot","Number","data","won","lost","cacheKey","wonWindow","lostWindow","wonStatus","lostStatus","chart","wonWeeks","bucketByWeek","rows","deal","closedAtMs","lostWeeks","labels","map","week","start","items","length","wonCents","reduce","total","Math","max","currency","currencyOfDeals","figures","value","winRate","chartRead","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","note","undefined","href","section","riseIsGood","numerator","denominator","denominatorLabel","severity","title","series","name","color","values","format","seriesIndex","barIndex","emptyText","truncated","variant","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/closed-deals-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n measuredRate,\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport {\n count,\n getAggregateFromServer,\n getCountFromServer,\n limit,\n orderBy,\n query,\n sum,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\nimport { bucketByWeek, currencyOfDeals } from '../../model/crm-reports'\n\n/**\n * How many closed deals of each outcome the weekly chart reads.\n *\n * The tiles are server aggregates and never meet this bound; the chart\n * needs each deal's `closedAtMs` to place it in a week, so it reads them,\n * most recent first, and says when the period held more.\n */\nconst CLOSED_DEAL_CEILING = 500\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface ClosedDealsCardProps {\n report: CrmReportScope\n}\n\n/**\n * Deals won and lost in the period, and the win rate over what closed\n * (AGL-2604).\n *\n * Won and lost are TWO queries rather than one over `status in [won,\n * lost]`: each is an equality the `(visibleTo, status, closedAtMs)` index\n * serves directly, and a card that drew one series from a merged read\n * would have to split it again by hand. The range is on `closedAtMs`, the\n * moment the deal was closed — not `updatedAt`, which a note added last\n * week would move.\n */\nexport function ClosedDealsCard(props: ClosedDealsCardProps) {\n const { report } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const closedInPeriod = (status: Aglyn.CrmDealStatus) =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', status),\n where('closedAtMs', '>=', range.from),\n where('closedAtMs', '<', range.to),\n )\n\n const closed = useAggregateRead(\n () =>\n Promise.all([\n getAggregateFromServer(closedInPeriod('won'), {\n count: count(),\n amountCents: sum('amountCents'),\n }).then((snapshot) => ({\n count: Number(snapshot.data().count ?? 0),\n amountCents: Number(snapshot.data().amountCents ?? 0),\n })),\n getCountFromServer(closedInPeriod('lost')).then(\n (snapshot) => snapshot.data().count,\n ),\n ]).then(([won, lost]) => ({ won, lost })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:totals') },\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('won'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () =>\n query(\n closedInPeriod('lost'),\n orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n ),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n const wonStatus = wonWindow.status\n const lostStatus = lostWindow.status\n\n const chart = useMemo(() => {\n const wonWeeks = bucketByWeek(\n wonWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n const lostWeeks = bucketByWeek(\n lostWindow.rows,\n (deal) => deal.closedAtMs,\n range.from,\n range.to,\n )\n return {\n labels: wonWeeks.map((week) => weekLabel(week.start)),\n won: wonWeeks.map((week) => week.items.length),\n wonCents: wonWeeks.map((week) =>\n week.items.reduce(\n (total, deal) => total + Math.max(0, Number(deal.amountCents ?? 0)),\n 0,\n ),\n ),\n lost: lostWeeks.map((week) => week.items.length),\n currency: currencyOfDeals(wonWindow.rows).currency,\n }\n }, [wonWindow, lostWindow, range])\n\n const figures = closed.value\n const winRate = figures\n ? measuredRate(figures.won.count, figures.won.count + figures.lost, 'closed')\n : null\n const chartRead = wonStatus === 'success' && lostStatus === 'success'\n\n return (\n <CardDisplay\n header={'Won and lost'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost',\n excerpt:\n 'Deals closed in the period, by outcome and by week, with the ' +\n 'value of what was won and the win rate over everything that ' +\n 'closed. Placed by the moment each deal was closed.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Won'}\n value={figures ? figures.won.count.toLocaleString() : null}\n note={\n figures\n ? `${money(figures.won.amountCents, chart.currency)} closed won`\n : undefined\n }\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Lost'}\n value={figures ? figures.lost.toLocaleString() : null}\n note={'in the period'}\n riseIsGood={false}\n href={routes.section('deals')}\n />\n <ReportStatTile\n label={'Win rate'}\n value={winRate ? percent(winRate.value) : null}\n note={\n winRate\n ? `${winRate.numerator.toLocaleString()} of ${winRate.denominator.toLocaleString()} ${winRate.denominatorLabel}`\n : figures\n ? 'nothing closed in the period'\n : undefined\n }\n />\n </Stack>\n {closed.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The closed-deal totals could not be read.'}\n </Alert>\n ) : null}\n <Section title={'Won vs lost per week'}>\n <ReportBarChart\n labels={chart.labels}\n series={[\n { name: 'Won', color: 'success.main', values: chart.won },\n { name: 'Lost', color: 'error.main', values: chart.lost },\n ]}\n format={(value, seriesIndex, barIndex) =>\n seriesIndex === 0\n ? `Won: ${value.toLocaleString()} · ${money(chart.wonCents[barIndex] ?? 0, chart.currency)}`\n : `Lost: ${value.toLocaleString()}`\n }\n emptyText={chartRead ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`The chart places the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nClosedDealsCard.displayName = 'ClosedDealsCard'\n\nexport default ClosedDealsCard\n"],"names":["Aglyn","money","CardDisplay","measuredRate","percent","Section","Alert","Stack","Typography","count","getAggregateFromServer","getCountFromServer","limit","orderBy","query","sum","where","useMemo","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","bucketByWeek","currencyOfDeals","CLOSED_DEAL_CEILING","ClosedDealsCard","props","report","scope","tokens","range","routes","firestore","closedInPeriod","status","from","to","closed","Promise","all","amountCents","then","snapshot","Number","data","won","lost","cacheKey","wonWindow","lostWindow","wonStatus","lostStatus","chart","wonWeeks","rows","deal","closedAtMs","lostWeeks","labels","map","week","start","items","length","wonCents","reduce","total","Math","max","currency","figures","value","winRate","chartRead","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","note","undefined","href","section","riseIsGood","numerator","denominator","denominatorLabel","severity","title","series","name","color","values","format","seriesIndex","barIndex","emptyText","truncated","variant","displayName"],"mappings":"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"}
|
|
@@ -27,6 +27,7 @@ import { ReportBreakdown } from "./report-breakdown.js";
|
|
|
27
27
|
import { plural } from "./report-format.js";
|
|
28
28
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
29
29
|
import { useWindowRead } from "./use-aggregate-read.js";
|
|
30
|
+
import { funnelFromStages, tally } from "../../model/crm-reports.js";
|
|
30
31
|
/**
|
|
31
32
|
* How many contacts the source mix and the funnel are read from.
|
|
32
33
|
*
|
|
@@ -71,7 +72,7 @@ import { useWindowRead } from "./use-aggregate-read.js";
|
|
|
71
72
|
// A person captured two ways counts under both sources: the chart asks
|
|
72
73
|
// "how many people came through each door", and a person who came
|
|
73
74
|
// through two did.
|
|
74
|
-
const sources =
|
|
75
|
+
const sources = tally(facets.flatMap((facet)=>Object.keys(facet.sources).filter((source)=>facet.sources[source])), (source)=>source);
|
|
75
76
|
const stageCounts = {};
|
|
76
77
|
let unstaged = 0;
|
|
77
78
|
for (const facet of facets){
|
|
@@ -85,7 +86,7 @@ import { useWindowRead } from "./use-aggregate-read.js";
|
|
|
85
86
|
}
|
|
86
87
|
return {
|
|
87
88
|
sources,
|
|
88
|
-
funnel:
|
|
89
|
+
funnel: funnelFromStages(stageCounts),
|
|
89
90
|
unstaged
|
|
90
91
|
};
|
|
91
92
|
}, [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-mix-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { contactPrimaryGroup } from '../../model/contact-record'\nimport { ReportBreakdown } from './report-breakdown'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { type AggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many contacts the source mix and the funnel are read from.\n *\n * These two charts need a FIELD off each contact — which facet sources it\n * carries, which lifecycle stage — and Firestore aggregates cannot group by\n * a map key, so the only way to draw them is to read documents. A thousand\n * is the same bound the contacts list carries, and past it the card SAYS it\n * is a sample rather than quietly drawing a thousand as if it were the org.\n */\nconst CONTACT_SAMPLE_CEILING = 1000\n\nexport interface ContactsMixCardProps {\n report: CrmReportScope\n /** The section's count of every contact this reader may see — what decides whether the read is a sample. */\n totalContacts: AggregateRead<number>\n}\n\n/**\n * Contacts by source, and the lifecycle funnel, from the newest thousand\n * contacts (AGL-2604).\n *\n * Both are read through THIS group's facet, the way every other field on\n * the contacts page is: a source is which capture surface this holder saw\n * the person through, and a stage is where this business's sales team put\n * them. Reading either off the top of the document would chart another\n * holder's records — the disclosure the facet shape exists to end.\n *\n * Newest by `createdAt` rather than by `updatedAt`, so that the sample is\n * \"the last thousand people captured\" — a population a reader can reason\n * about — and not \"the thousand most recently edited\", which is whichever\n * ones a bulk tag touched last.\n */\nexport function ContactsMixCard(props: ContactsMixCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, groupId, org, nowMs } = report\n const firestore = useFirestore()\n\n const sample = useWindowRead<Record<string, unknown>>(\n () =>\n query(\n scopedCollection(firestore, scope, 'contacts'),\n ...visibleToClause(tokens),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_SAMPLE_CEILING + 1),\n ),\n CONTACT_SAMPLE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'contacts:sample') },\n )\n const status = sample.status\n\n const mix = useMemo(() => {\n // Through the viewing group's facet under a site; at the organization\n // level (AGL-2630) through each person's own primary holder, so the mix\n // is of the profiles the capturing sites keep.\n const facets = sample.rows.map((row) =>\n Aglyn.readContactFacet(\n row,\n groupId ?? contactPrimaryGroup(row, org).groupId,\n ),\n )\n // A person captured two ways counts under both sources: the chart asks\n // \"how many people came through each door\", and a person who came\n // through two did.\n const sources = Aglyn.tally(\n facets.flatMap((facet) =>\n (Object.keys(facet.sources) as Aglyn.ContactSource[]).filter(\n (source) => facet.sources[source],\n ),\n ),\n (source) => source,\n )\n const stageCounts: Partial<Record<Aglyn.ContactLifecycleStage, number>> = {}\n let unstaged = 0\n for (const facet of facets) {\n const stage = facet.lifecycleStage\n if (Aglyn.isContactLifecycleStage(stage)) {\n stageCounts[stage] = (stageCounts[stage] ?? 0) + 1\n } else {\n unstaged += 1\n }\n }\n return { sources, funnel: Aglyn.funnelFromStages(stageCounts), unstaged }\n }, [sample, groupId, org])\n\n // A sample only once the read has settled: while the window is empty\n // every org \"exceeds\" zero rows.\n const sampled =\n status === 'success' &&\n (sample.truncated ||\n (totalContacts.value !== null && totalContacts.value > sample.rows.length))\n\n return (\n <CardDisplay\n header={'Sources and lifecycle'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#sources-and-lifecycle',\n excerpt:\n 'Which capture surfaces your contacts came through, and how many ' +\n 'have reached each lifecycle stage. Read from the newest thousand ' +\n 'contacts, through this site’s own view of each person.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {sampled ? (\n <Alert severity=\"info\">\n {`Read from the newest ${sample.rows.length.toLocaleString()} contacts${\n totalContacts.value !== null\n ? ` of ${totalContacts.value.toLocaleString()}`\n : ''\n } — a sample, not the whole list.`}\n </Alert>\n ) : null}\n <Section title={'By source'}>\n <ReportBreakdown\n rows={mix.sources.map((row) => ({\n key: row.key,\n label:\n Aglyn.CONTACT_SOURCE_LABELS[row.key as Aglyn.ContactSource] ??\n row.key,\n value: row.count,\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to count yet.'\n }\n />\n {mix.sources.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A person captured two ways counts under both.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Lifecycle funnel'}>\n <ReportBreakdown\n rows={mix.funnel.steps.map((step) => ({\n key: step.stage,\n label: step.label,\n value: step.reached,\n note:\n `${plural(step.count, 'person', 'people')} here now` +\n (step.conversion === null\n ? ''\n : ` · ${percent(step.conversion)} of the step before`),\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to place yet.'\n }\n />\n {sample.rows.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Each step counts everyone who reached it or went further. ${plural(\n mix.unstaged,\n 'contact',\n )} with no stage yet · ${mix.funnel.other.toLocaleString()} marked other.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsMixCard.displayName = 'ContactsMixCard'\n\nexport default ContactsMixCard\n"],"names":["Aglyn","CardDisplay","percent","Section","Alert","Stack","Typography","limit","orderBy","query","useMemo","useFirestore","contactPrimaryGroup","ReportBreakdown","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","CONTACT_SAMPLE_CEILING","ContactsMixCard","props","report","totalContacts","scope","tokens","groupId","org","nowMs","firestore","sample","cacheKey","status","mix","facets","rows","map","row","readContactFacet","sources","tally","flatMap","facet","Object","keys","filter","source","stageCounts","unstaged","stage","lifecycleStage","isContactLifecycleStage","funnel","funnelFromStages","sampled","truncated","value","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","toLocaleString","title","key","label","CONTACT_SOURCE_LABELS","count","emptyText","variant","color","steps","step","reached","note","conversion","other","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-mix-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { contactPrimaryGroup } from '../../model/contact-record'\nimport { ReportBreakdown } from './report-breakdown'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { type AggregateRead, useWindowRead } from './use-aggregate-read'\nimport { funnelFromStages, tally } from '../../model/crm-reports'\n\n/**\n * How many contacts the source mix and the funnel are read from.\n *\n * These two charts need a FIELD off each contact — which facet sources it\n * carries, which lifecycle stage — and Firestore aggregates cannot group by\n * a map key, so the only way to draw them is to read documents. A thousand\n * is the same bound the contacts list carries, and past it the card SAYS it\n * is a sample rather than quietly drawing a thousand as if it were the org.\n */\nconst CONTACT_SAMPLE_CEILING = 1000\n\nexport interface ContactsMixCardProps {\n report: CrmReportScope\n /** The section's count of every contact this reader may see — what decides whether the read is a sample. */\n totalContacts: AggregateRead<number>\n}\n\n/**\n * Contacts by source, and the lifecycle funnel, from the newest thousand\n * contacts (AGL-2604).\n *\n * Both are read through THIS group's facet, the way every other field on\n * the contacts page is: a source is which capture surface this holder saw\n * the person through, and a stage is where this business's sales team put\n * them. Reading either off the top of the document would chart another\n * holder's records — the disclosure the facet shape exists to end.\n *\n * Newest by `createdAt` rather than by `updatedAt`, so that the sample is\n * \"the last thousand people captured\" — a population a reader can reason\n * about — and not \"the thousand most recently edited\", which is whichever\n * ones a bulk tag touched last.\n */\nexport function ContactsMixCard(props: ContactsMixCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, groupId, org, nowMs } = report\n const firestore = useFirestore()\n\n const sample = useWindowRead<Record<string, unknown>>(\n () =>\n query(\n scopedCollection(firestore, scope, 'contacts'),\n ...visibleToClause(tokens),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_SAMPLE_CEILING + 1),\n ),\n CONTACT_SAMPLE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'contacts:sample') },\n )\n const status = sample.status\n\n const mix = useMemo(() => {\n // Through the viewing group's facet under a site; at the organization\n // level (AGL-2630) through each person's own primary holder, so the mix\n // is of the profiles the capturing sites keep.\n const facets = sample.rows.map((row) =>\n Aglyn.readContactFacet(\n row,\n groupId ?? contactPrimaryGroup(row, org).groupId,\n ),\n )\n // A person captured two ways counts under both sources: the chart asks\n // \"how many people came through each door\", and a person who came\n // through two did.\n const sources = tally(\n facets.flatMap((facet) =>\n (Object.keys(facet.sources) as Aglyn.ContactSource[]).filter(\n (source) => facet.sources[source],\n ),\n ),\n (source) => source,\n )\n const stageCounts: Partial<Record<Aglyn.ContactLifecycleStage, number>> = {}\n let unstaged = 0\n for (const facet of facets) {\n const stage = facet.lifecycleStage\n if (Aglyn.isContactLifecycleStage(stage)) {\n stageCounts[stage] = (stageCounts[stage] ?? 0) + 1\n } else {\n unstaged += 1\n }\n }\n return { sources, funnel: funnelFromStages(stageCounts), unstaged }\n }, [sample, groupId, org])\n\n // A sample only once the read has settled: while the window is empty\n // every org \"exceeds\" zero rows.\n const sampled =\n status === 'success' &&\n (sample.truncated ||\n (totalContacts.value !== null && totalContacts.value > sample.rows.length))\n\n return (\n <CardDisplay\n header={'Sources and lifecycle'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#sources-and-lifecycle',\n excerpt:\n 'Which capture surfaces your contacts came through, and how many ' +\n 'have reached each lifecycle stage. Read from the newest thousand ' +\n 'contacts, through this site’s own view of each person.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {sampled ? (\n <Alert severity=\"info\">\n {`Read from the newest ${sample.rows.length.toLocaleString()} contacts${\n totalContacts.value !== null\n ? ` of ${totalContacts.value.toLocaleString()}`\n : ''\n } — a sample, not the whole list.`}\n </Alert>\n ) : null}\n <Section title={'By source'}>\n <ReportBreakdown\n rows={mix.sources.map((row) => ({\n key: row.key,\n label:\n Aglyn.CONTACT_SOURCE_LABELS[row.key as Aglyn.ContactSource] ??\n row.key,\n value: row.count,\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to count yet.'\n }\n />\n {mix.sources.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A person captured two ways counts under both.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Lifecycle funnel'}>\n <ReportBreakdown\n rows={mix.funnel.steps.map((step) => ({\n key: step.stage,\n label: step.label,\n value: step.reached,\n note:\n `${plural(step.count, 'person', 'people')} here now` +\n (step.conversion === null\n ? ''\n : ` · ${percent(step.conversion)} of the step before`),\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to place yet.'\n }\n />\n {sample.rows.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Each step counts everyone who reached it or went further. ${plural(\n mix.unstaged,\n 'contact',\n )} with no stage yet · ${mix.funnel.other.toLocaleString()} marked other.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsMixCard.displayName = 'ContactsMixCard'\n\nexport default ContactsMixCard\n"],"names":["Aglyn","CardDisplay","percent","Section","Alert","Stack","Typography","limit","orderBy","query","useMemo","useFirestore","contactPrimaryGroup","ReportBreakdown","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","funnelFromStages","tally","CONTACT_SAMPLE_CEILING","ContactsMixCard","props","report","totalContacts","scope","tokens","groupId","org","nowMs","firestore","sample","cacheKey","status","mix","facets","rows","map","row","readContactFacet","sources","flatMap","facet","Object","keys","filter","source","stageCounts","unstaged","stage","lifecycleStage","isContactLifecycleStage","funnel","sampled","truncated","value","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","toLocaleString","title","key","label","CONTACT_SOURCE_LABELS","count","emptyText","variant","color","steps","step","reached","note","conversion","other","displayName"],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
19
|
-
import {
|
|
19
|
+
import { pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
20
|
import { CardDisplay } from "@aglyn/shared-ui-jsx";
|
|
21
21
|
import { Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
|
|
22
22
|
import { Alert, Stack, Typography } from "@mui/material";
|
|
@@ -27,6 +27,7 @@ import { weekLabel } from "./report-format.js";
|
|
|
27
27
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
28
28
|
import { ReportStatTile } from "./report-stat-tile.js";
|
|
29
29
|
import { useAggregateRead } from "./use-aggregate-read.js";
|
|
30
|
+
import { deltaPercent, weekBuckets } from "../../model/crm-reports.js";
|
|
30
31
|
/**
|
|
31
32
|
* New contacts over the period, against the period before, week by week
|
|
32
33
|
* (AGL-2604).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-trend-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { deltaPercent, pluginDocsHelp, weekBuckets } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { getCountFromServer, query, Timestamp, where } from 'firebase/firestore'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { type AggregateRead, useAggregateRead } from './use-aggregate-read'\n\nexport interface ContactsTrendCardProps {\n report: CrmReportScope\n /** The section's one count of every contact this reader may see. */\n totalContacts: AggregateRead<number>\n}\n\ninterface ContactsTrend {\n /** New contacts in the period — the sum of the weeks. */\n current: number\n /** New contacts in the period before it. */\n previous: number\n weekly: Array<{ start: number; end: number; count: number }>\n}\n\n/**\n * New contacts over the period, against the period before, week by week\n * (AGL-2604).\n *\n * ## Every number here is a server count\n *\n * One `getCountFromServer` per week of the period and one for the previous\n * period — fourteen reads for ninety days, billed as index entries rather\n * than documents — and NO document is downloaded to be counted. The period\n * figure is the sum of its weeks rather than a fifteenth read, so the tile\n * and the bars cannot disagree. The contacts list's head-count learned the\n * same lesson (AGL-1706): a count taken from a capped listener saturates at\n * the cap, and the org this page is for is the one with more contacts than\n * any listener should carry.\n *\n * The range is on `createdAt`, the timestamp every contact writer stamps —\n * the server upsert with `serverTimestamp()`, the console's add-by-hand with\n * a `Date` — so \"new\" means first captured, not last touched.\n */\nexport function ContactsTrendCard(props: ContactsTrendCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const trend = useAggregateRead<ContactsTrend>(() => {\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const createdBetween = (from: number, to: number) =>\n getCountFromServer(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(from)),\n where('createdAt', '<', Timestamp.fromMillis(to)),\n ),\n ).then((snapshot) => snapshot.data().count)\n const buckets = weekBuckets(range.from, range.to)\n return Promise.all([\n createdBetween(range.previousFrom, range.previousTo),\n ...buckets.map((bucket) => createdBetween(bucket.start, bucket.end)),\n ]).then(([previous, ...weekly]) => ({\n previous,\n current: weekly.reduce((sum, count) => sum + count, 0),\n weekly: buckets.map((bucket, index) => ({\n ...bucket,\n count: weekly[index] ?? 0,\n })),\n }))\n }, [firestore, scope, tokens, range], { cacheKey: reportCacheKey(report, 'contacts:trend') })\n\n const figures = trend.value\n return (\n <CardDisplay\n header={'Contacts'}\n help={pluginDocsHelp('crmReports', {\n anchor: '#contacts',\n excerpt:\n 'New contacts in the period against the period before it, week ' +\n 'by week, and how many contacts this site can see in all. Counted ' +\n 'on the server by when each contact was first captured.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'New contacts'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? deltaPercent(figures.current, figures.previous) : null}\n note={\n figures\n ? figures.previous\n ? `${figures.previous.toLocaleString()} in the previous period`\n : 'none in the previous period'\n : undefined\n }\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Total contacts'}\n value={\n totalContacts.value === null\n ? null\n : totalContacts.value.toLocaleString()\n }\n note={'visible to this site'}\n href={routes.section('contacts')}\n />\n </Stack>\n {trend.status === 'error' || totalContacts.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The contact counts could not be read. The rest of the page is unaffected.'}\n </Alert>\n ) : null}\n <Section title={'New contacts per week'}>\n <ReportBarChart\n labels={(figures?.weekly ?? []).map((week) => weekLabel(week.start))}\n series={[\n {\n name: 'New contacts',\n color: 'primary.main',\n values: (figures?.weekly ?? []).map((week) => week.count),\n },\n ]}\n emptyText={\n trend.status === 'loading' ? 'Counting…' : 'Nothing in this period.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Each bar is seven days from the start of the period; the last ' +\n 'may be shorter.'}\n </Typography>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsTrendCard.displayName = 'ContactsTrendCard'\n\nexport default ContactsTrendCard\n"],"names":["deltaPercent","pluginDocsHelp","weekBuckets","CardDisplay","Section","Alert","Stack","Typography","getCountFromServer","query","Timestamp","where","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","ContactsTrendCard","props","report","totalContacts","scope","tokens","range","routes","firestore","trend","contacts","createdBetween","from","to","fromMillis","then","snapshot","data","count","buckets","Promise","all","previousFrom","previousTo","map","bucket","start","end","previous","weekly","current","reduce","sum","index","cacheKey","figures","value","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","deltaPct","note","undefined","href","section","status","severity","title","labels","week","series","name","color","values","emptyText","variant","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-trend-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { getCountFromServer, query, Timestamp, where } from 'firebase/firestore'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { type AggregateRead, useAggregateRead } from './use-aggregate-read'\nimport { deltaPercent, weekBuckets } from '../../model/crm-reports'\n\nexport interface ContactsTrendCardProps {\n report: CrmReportScope\n /** The section's one count of every contact this reader may see. */\n totalContacts: AggregateRead<number>\n}\n\ninterface ContactsTrend {\n /** New contacts in the period — the sum of the weeks. */\n current: number\n /** New contacts in the period before it. */\n previous: number\n weekly: Array<{ start: number; end: number; count: number }>\n}\n\n/**\n * New contacts over the period, against the period before, week by week\n * (AGL-2604).\n *\n * ## Every number here is a server count\n *\n * One `getCountFromServer` per week of the period and one for the previous\n * period — fourteen reads for ninety days, billed as index entries rather\n * than documents — and NO document is downloaded to be counted. The period\n * figure is the sum of its weeks rather than a fifteenth read, so the tile\n * and the bars cannot disagree. The contacts list's head-count learned the\n * same lesson (AGL-1706): a count taken from a capped listener saturates at\n * the cap, and the org this page is for is the one with more contacts than\n * any listener should carry.\n *\n * The range is on `createdAt`, the timestamp every contact writer stamps —\n * the server upsert with `serverTimestamp()`, the console's add-by-hand with\n * a `Date` — so \"new\" means first captured, not last touched.\n */\nexport function ContactsTrendCard(props: ContactsTrendCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const trend = useAggregateRead<ContactsTrend>(() => {\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const createdBetween = (from: number, to: number) =>\n getCountFromServer(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(from)),\n where('createdAt', '<', Timestamp.fromMillis(to)),\n ),\n ).then((snapshot) => snapshot.data().count)\n const buckets = weekBuckets(range.from, range.to)\n return Promise.all([\n createdBetween(range.previousFrom, range.previousTo),\n ...buckets.map((bucket) => createdBetween(bucket.start, bucket.end)),\n ]).then(([previous, ...weekly]) => ({\n previous,\n current: weekly.reduce((sum, count) => sum + count, 0),\n weekly: buckets.map((bucket, index) => ({\n ...bucket,\n count: weekly[index] ?? 0,\n })),\n }))\n }, [firestore, scope, tokens, range], { cacheKey: reportCacheKey(report, 'contacts:trend') })\n\n const figures = trend.value\n return (\n <CardDisplay\n header={'Contacts'}\n help={pluginDocsHelp('crmReports', {\n anchor: '#contacts',\n excerpt:\n 'New contacts in the period against the period before it, week ' +\n 'by week, and how many contacts this site can see in all. Counted ' +\n 'on the server by when each contact was first captured.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'New contacts'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? deltaPercent(figures.current, figures.previous) : null}\n note={\n figures\n ? figures.previous\n ? `${figures.previous.toLocaleString()} in the previous period`\n : 'none in the previous period'\n : undefined\n }\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Total contacts'}\n value={\n totalContacts.value === null\n ? null\n : totalContacts.value.toLocaleString()\n }\n note={'visible to this site'}\n href={routes.section('contacts')}\n />\n </Stack>\n {trend.status === 'error' || totalContacts.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The contact counts could not be read. The rest of the page is unaffected.'}\n </Alert>\n ) : null}\n <Section title={'New contacts per week'}>\n <ReportBarChart\n labels={(figures?.weekly ?? []).map((week) => weekLabel(week.start))}\n series={[\n {\n name: 'New contacts',\n color: 'primary.main',\n values: (figures?.weekly ?? []).map((week) => week.count),\n },\n ]}\n emptyText={\n trend.status === 'loading' ? 'Counting…' : 'Nothing in this period.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Each bar is seven days from the start of the period; the last ' +\n 'may be shorter.'}\n </Typography>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsTrendCard.displayName = 'ContactsTrendCard'\n\nexport default ContactsTrendCard\n"],"names":["pluginDocsHelp","CardDisplay","Section","Alert","Stack","Typography","getCountFromServer","query","Timestamp","where","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","deltaPercent","weekBuckets","ContactsTrendCard","props","report","totalContacts","scope","tokens","range","routes","firestore","trend","contacts","createdBetween","from","to","fromMillis","then","snapshot","data","count","buckets","Promise","all","previousFrom","previousTo","map","bucket","start","end","previous","weekly","current","reduce","sum","index","cacheKey","figures","value","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","deltaPct","note","undefined","href","section","status","severity","title","labels","week","series","name","color","values","emptyText","variant","displayName"],"mappings":"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"}
|
|
@@ -27,6 +27,7 @@ import { collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-col
|
|
|
27
27
|
import { plural } from "./report-format.js";
|
|
28
28
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
29
29
|
import { useWindowRead } from "./use-aggregate-read.js";
|
|
30
|
+
import { currencyOfDeals, forecastByCloseMonth } from "../../model/crm-reports.js";
|
|
30
31
|
/**
|
|
31
32
|
* The same window the pipeline card reads, under the SAME cache key and
|
|
32
33
|
* the same dependencies, so the two cards cost one read between them —
|
|
@@ -73,12 +74,12 @@ const PIPELINE_CEILING = 20;
|
|
|
73
74
|
], {
|
|
74
75
|
cacheKey: reportCacheKey(report, 'pipeline:pipelines')
|
|
75
76
|
});
|
|
76
|
-
const forecast = useMemo(()=>
|
|
77
|
+
const forecast = useMemo(()=>forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs), [
|
|
77
78
|
dealWindow.rows,
|
|
78
79
|
pipelineWindow.rows,
|
|
79
80
|
nowMs
|
|
80
81
|
]);
|
|
81
|
-
const currency = useMemo(()=>
|
|
82
|
+
const currency = useMemo(()=>currencyOfDeals(dealWindow.rows), [
|
|
82
83
|
dealWindow.rows
|
|
83
84
|
]);
|
|
84
85
|
const read = dealWindow.status === 'success' && pipelineWindow.status === 'success';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/forecast-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Box,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\n\n/**\n * The same window the pipeline card reads, under the SAME cache key and\n * the same dependencies, so the two cards cost one read between them —\n * `useAggregateRead` joins a read already in the air for the key. The\n * numbers must match the pipeline card's query exactly or the key would\n * name two different questions.\n */\nconst OPEN_DEAL_CEILING = 1000\nconst PIPELINE_CEILING = 20\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface ForecastCardProps {\n report: CrmReportScope\n}\n\n/** `Sep 2026` — the month a column is. */\nfunction monthLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })\n}\n\n/**\n * Forecast by close month (AGL-2620): the open pipeline laid out by the\n * month each deal is expected to close, for the next six, per pipeline,\n * at face value and weighted by the stage.\n *\n * Not period-scoped, like the pipeline card and for the same reason: the\n * forecast is what is open NOW, looked at forward. Deals with no expected\n * close are their own row rather than a footnote — that row is the size\n * of the pipeline nobody has scheduled, which is the number the picker\n * of a close date most needs to see — and deals dated before this month\n * (overdue) or past the sixth (later) are rows too, so the column adds up\n * to the pipeline.\n */\nexport function ForecastCard(props: ForecastCardProps) {\n const { report } = props\n const { scope, tokens, nowMs } = report\n const firestore = useFirestore()\n\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(scopedCollection(firestore, scope, 'pipelines'), ...visibleToClause(tokens)),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n\n const forecast = useMemo(\n () => Aglyn.forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs),\n [dealWindow.rows, pipelineWindow.rows, nowMs],\n )\n const currency = useMemo(() => Aglyn.currencyOfDeals(dealWindow.rows), [dealWindow.rows])\n const read = dealWindow.status === 'success' && pipelineWindow.status === 'success'\n const failed = dealWindow.status === 'error' || pipelineWindow.status === 'error'\n\n // A pipeline with nothing open is still a column; an unknown pipeline id\n // is a column named for what it is.\n const columns = forecast.pipelines\n const manyColumns = columns.length > 1\n\n const rows: Array<{ key: string; label: string; cellOf: (row: Aglyn.PipelineForecast) => Aglyn.ForecastCell; total: Aglyn.ForecastCell; muted?: boolean }> = [\n {\n key: 'overdue',\n label: 'Before this month',\n cellOf: (row) => row.overdue,\n total: forecast.overdue,\n muted: true,\n },\n ...forecast.buckets.map((bucket, index) => ({\n key: bucket.key,\n label: monthLabel(bucket.start),\n cellOf: (row: Aglyn.PipelineForecast) => row.months[index],\n total: forecast.months[index],\n })),\n {\n key: 'later',\n label: 'Later',\n cellOf: (row) => row.later,\n total: forecast.later,\n muted: true,\n },\n {\n key: 'undated',\n label: 'No expected close',\n cellOf: (row) => row.undated,\n total: forecast.undated,\n muted: true,\n },\n ].filter((row) => row.key !== 'overdue' && row.key !== 'later' ? true : row.total.count > 0)\n\n const cell = (value: Aglyn.ForecastCell, code: string) =>\n value.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.disabled\">\n {'—'}\n </Typography>\n ) : (\n <Stack sx={{ lineHeight: 1.2 }}>\n <Typography variant=\"body2\">{money(value.amountCents, code)}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(value.weightedCents, code)} weighted · ${plural(value.count, 'deal')}`}\n </Typography>\n </Stack>\n )\n\n return (\n <CardDisplay\n header={'Forecast by close month'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#forecast-by-close-month',\n excerpt:\n 'Every open deal by the month it is expected to close, for the ' +\n 'next six months and per pipeline, at face value and weighted by ' +\n 'its stage. Deals with no expected close are their own row.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Typography variant=\"body2\" color=\"warning.main\">\n {'The forecast could not be read.'}\n </Typography>\n ) : !read ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ) : forecast.total.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : (\n <Box>\n <ScrollTable size=\"small\" aria-label=\"Forecast by close month\">\n <TableHead>\n <TableRow>\n <TableCell>{'Expected close'}</TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {column.name || 'Pipeline not visible'}\n </TableCell>\n ))}\n {manyColumns ? <TableCell align=\"right\">{'All pipelines'}</TableCell> : null}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key} data-forecast-row={row.key}>\n <TableCell>\n <Typography variant=\"body2\" color={row.muted ? 'text.secondary' : undefined}>\n {row.label}\n </Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(row.cellOf(column), currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(row.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n ))}\n <TableRow>\n <TableCell>\n <Typography variant=\"subtitle2\">{'Open pipeline'}</Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(column.total, currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(forecast.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n </TableBody>\n </ScrollTable>\n </Box>\n )}\n {read && currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the figures add them as numbers and show ${currency.currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {read && dealWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`From the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`}\n </Typography>\n ) : null}\n {read && pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are named.`}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nForecastCard.displayName = 'ForecastCard'\n\nexport default ForecastCard\n"],"names":["Aglyn","money","CardDisplay","ScrollTable","Box","Stack","TableBody","TableCell","TableHead","TableRow","Typography","limit","orderBy","query","where","useMemo","useFirestore","collectionCeiling","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","OPEN_DEAL_CEILING","PIPELINE_CEILING","monthLabel","startMs","Date","toLocaleDateString","undefined","month","year","ForecastCard","props","report","scope","tokens","nowMs","firestore","dealWindow","cacheKey","pipelineWindow","forecast","forecastByCloseMonth","rows","currency","currencyOfDeals","read","status","failed","columns","pipelines","manyColumns","length","key","label","cellOf","row","overdue","total","muted","buckets","map","bucket","index","start","months","later","undated","filter","count","cell","value","code","variant","color","sx","lineHeight","amountCents","weightedCents","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","size","aria-label","column","align","name","pipelineId","data-forecast-row","mixed","toUpperCase","truncated","toLocaleString","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/forecast-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Box,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\nimport {\n currencyOfDeals,\n forecastByCloseMonth,\n type ForecastCell,\n type PipelineForecast,\n} from '../../model/crm-reports'\n\n/**\n * The same window the pipeline card reads, under the SAME cache key and\n * the same dependencies, so the two cards cost one read between them —\n * `useAggregateRead` joins a read already in the air for the key. The\n * numbers must match the pipeline card's query exactly or the key would\n * name two different questions.\n */\nconst OPEN_DEAL_CEILING = 1000\nconst PIPELINE_CEILING = 20\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface ForecastCardProps {\n report: CrmReportScope\n}\n\n/** `Sep 2026` — the month a column is. */\nfunction monthLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })\n}\n\n/**\n * Forecast by close month (AGL-2620): the open pipeline laid out by the\n * month each deal is expected to close, for the next six, per pipeline,\n * at face value and weighted by the stage.\n *\n * Not period-scoped, like the pipeline card and for the same reason: the\n * forecast is what is open NOW, looked at forward. Deals with no expected\n * close are their own row rather than a footnote — that row is the size\n * of the pipeline nobody has scheduled, which is the number the picker\n * of a close date most needs to see — and deals dated before this month\n * (overdue) or past the sixth (later) are rows too, so the column adds up\n * to the pipeline.\n */\nexport function ForecastCard(props: ForecastCardProps) {\n const { report } = props\n const { scope, tokens, nowMs } = report\n const firestore = useFirestore()\n\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(scopedCollection(firestore, scope, 'pipelines'), ...visibleToClause(tokens)),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n\n const forecast = useMemo(\n () => forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs),\n [dealWindow.rows, pipelineWindow.rows, nowMs],\n )\n const currency = useMemo(() => currencyOfDeals(dealWindow.rows), [dealWindow.rows])\n const read = dealWindow.status === 'success' && pipelineWindow.status === 'success'\n const failed = dealWindow.status === 'error' || pipelineWindow.status === 'error'\n\n // A pipeline with nothing open is still a column; an unknown pipeline id\n // is a column named for what it is.\n const columns = forecast.pipelines\n const manyColumns = columns.length > 1\n\n const rows: Array<{ key: string; label: string; cellOf: (row: PipelineForecast) => ForecastCell; total: ForecastCell; muted?: boolean }> = [\n {\n key: 'overdue',\n label: 'Before this month',\n cellOf: (row) => row.overdue,\n total: forecast.overdue,\n muted: true,\n },\n ...forecast.buckets.map((bucket, index) => ({\n key: bucket.key,\n label: monthLabel(bucket.start),\n cellOf: (row: PipelineForecast) => row.months[index],\n total: forecast.months[index],\n })),\n {\n key: 'later',\n label: 'Later',\n cellOf: (row) => row.later,\n total: forecast.later,\n muted: true,\n },\n {\n key: 'undated',\n label: 'No expected close',\n cellOf: (row) => row.undated,\n total: forecast.undated,\n muted: true,\n },\n ].filter((row) => row.key !== 'overdue' && row.key !== 'later' ? true : row.total.count > 0)\n\n const cell = (value: ForecastCell, code: string) =>\n value.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.disabled\">\n {'—'}\n </Typography>\n ) : (\n <Stack sx={{ lineHeight: 1.2 }}>\n <Typography variant=\"body2\">{money(value.amountCents, code)}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(value.weightedCents, code)} weighted · ${plural(value.count, 'deal')}`}\n </Typography>\n </Stack>\n )\n\n return (\n <CardDisplay\n header={'Forecast by close month'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#forecast-by-close-month',\n excerpt:\n 'Every open deal by the month it is expected to close, for the ' +\n 'next six months and per pipeline, at face value and weighted by ' +\n 'its stage. Deals with no expected close are their own row.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Typography variant=\"body2\" color=\"warning.main\">\n {'The forecast could not be read.'}\n </Typography>\n ) : !read ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ) : forecast.total.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : (\n <Box>\n <ScrollTable size=\"small\" aria-label=\"Forecast by close month\">\n <TableHead>\n <TableRow>\n <TableCell>{'Expected close'}</TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {column.name || 'Pipeline not visible'}\n </TableCell>\n ))}\n {manyColumns ? <TableCell align=\"right\">{'All pipelines'}</TableCell> : null}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key} data-forecast-row={row.key}>\n <TableCell>\n <Typography variant=\"body2\" color={row.muted ? 'text.secondary' : undefined}>\n {row.label}\n </Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(row.cellOf(column), currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(row.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n ))}\n <TableRow>\n <TableCell>\n <Typography variant=\"subtitle2\">{'Open pipeline'}</Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(column.total, currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(forecast.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n </TableBody>\n </ScrollTable>\n </Box>\n )}\n {read && currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the figures add them as numbers and show ${currency.currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {read && dealWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`From the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`}\n </Typography>\n ) : null}\n {read && pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are named.`}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nForecastCard.displayName = 'ForecastCard'\n\nexport default ForecastCard\n"],"names":["Aglyn","money","CardDisplay","ScrollTable","Box","Stack","TableBody","TableCell","TableHead","TableRow","Typography","limit","orderBy","query","where","useMemo","useFirestore","collectionCeiling","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","currencyOfDeals","forecastByCloseMonth","OPEN_DEAL_CEILING","PIPELINE_CEILING","monthLabel","startMs","Date","toLocaleDateString","undefined","month","year","ForecastCard","props","report","scope","tokens","nowMs","firestore","dealWindow","cacheKey","pipelineWindow","forecast","rows","currency","read","status","failed","columns","pipelines","manyColumns","length","key","label","cellOf","row","overdue","total","muted","buckets","map","bucket","index","start","months","later","undated","filter","count","cell","value","code","variant","color","sx","lineHeight","amountCents","weightedCents","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","size","aria-label","column","align","name","pipelineId","data-forecast-row","mixed","toUpperCase","truncated","toLocaleString","displayName"],"mappings":"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"}
|
|
@@ -31,6 +31,7 @@ import { plural, reportFilename } from "./report-format.js";
|
|
|
31
31
|
import { reportCacheKey } from "./report-scope.js";
|
|
32
32
|
import { ReportStatTile } from "./report-stat-tile.js";
|
|
33
33
|
import { useAggregateRead, useWindowRead } from "./use-aggregate-read.js";
|
|
34
|
+
import { deltaPercent, leadFunnel } from "../../model/crm-reports.js";
|
|
34
35
|
/**
|
|
35
36
|
* How many of the period's leads the funnel is placed from — the Leads
|
|
36
37
|
* section's own window, over the period rather than over everything.
|
|
@@ -58,7 +59,9 @@ const LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {
|
|
|
58
59
|
* `visibleTo` is the site's tokens under a site, and `null` at the
|
|
59
60
|
* organization level where an org-wide member reads without a clause. The
|
|
60
61
|
* range stays a single-field one; with the scope clause it composes with the
|
|
61
|
-
* `visibleTo` + `firstSeenAtMs`
|
|
62
|
+
* `visibleTo` + `firstSeenAtMs` entries in `cloud/firebase-firestore.indexes.json`
|
|
63
|
+
* — the descending one for the window, the ascending one for the counts,
|
|
64
|
+
* which name no order (AGL-3443).
|
|
62
65
|
*/ function leadsBetween(firestore, orgId, visibleTo, from, to) {
|
|
63
66
|
return query(collection(firestore, 'orgs', orgId, 'leads'), ...visibleTo ? [
|
|
64
67
|
where('visibleTo', 'array-contains-any', [
|
|
@@ -162,7 +165,7 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
|
|
|
162
165
|
* out a quiet one has nothing left to balance. One aggregate and one
|
|
163
166
|
* bounded read, unscoped, as an org-wide member's reads are.
|
|
164
167
|
*/ function OrgLeadFunnelCard(props) {
|
|
165
|
-
var _ref
|
|
168
|
+
var _ref;
|
|
166
169
|
const { report } = props;
|
|
167
170
|
const { range } = report;
|
|
168
171
|
const firestore = useFirestore();
|
|
@@ -170,7 +173,6 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
|
|
|
170
173
|
const hostIds = useMemo(()=>(mount == null ? void 0 : mount.hostsReady) ? mount.hosts.map((host)=>host.id) : null, [
|
|
171
174
|
mount
|
|
172
175
|
]);
|
|
173
|
-
const key = (_ref = hostIds == null ? void 0 : hostIds.join('\n')) != null ? _ref : '';
|
|
174
176
|
/*
|
|
175
177
|
* ONE READ, not one per site (AGL-3275). The sites share a collection now,
|
|
176
178
|
* so a lead two brands in a consent group both hold would be counted once
|
|
@@ -209,7 +211,7 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
|
|
|
209
211
|
}, [
|
|
210
212
|
merged
|
|
211
213
|
]);
|
|
212
|
-
const sites = (
|
|
214
|
+
const sites = (_ref = hostIds == null ? void 0 : hostIds.length) != null ? _ref : 0;
|
|
213
215
|
const figures = captured.value;
|
|
214
216
|
const sampled = window.status === 'success' && (window.truncated || figures !== null && figures.current > window.rows.length);
|
|
215
217
|
return /*#__PURE__*/ _jsx(LeadFunnelBody, {
|
|
@@ -229,7 +231,7 @@ OrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard';
|
|
|
229
231
|
const { report, subheader, captured, window, caption, errorText } = props;
|
|
230
232
|
const { period, routes } = report;
|
|
231
233
|
const status = window.status;
|
|
232
|
-
const funnel = useMemo(()=>
|
|
234
|
+
const funnel = useMemo(()=>leadFunnel(window.rows), [
|
|
233
235
|
window
|
|
234
236
|
]);
|
|
235
237
|
const read = status === 'success';
|
|
@@ -300,7 +302,7 @@ OrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard';
|
|
|
300
302
|
/*#__PURE__*/ _jsx(ReportStatTile, {
|
|
301
303
|
label: 'Leads captured',
|
|
302
304
|
value: figures ? figures.current.toLocaleString() : null,
|
|
303
|
-
deltaPct: figures ?
|
|
305
|
+
deltaPct: figures ? deltaPercent(figures.current, figures.previous) : null,
|
|
304
306
|
note: 'first seen in the period',
|
|
305
307
|
href: routes.section('leads')
|
|
306
308
|
}),
|