@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.
Files changed (136) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -15
  3. package/src/lib/components/company-import-drawer.d.ts +1 -1
  4. package/src/lib/components/contact-associations-card.js +4 -3
  5. package/src/lib/components/contact-associations-card.js.map +1 -1
  6. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  7. package/src/lib/components/crm-booking-zone.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +2 -1
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/deal-products-card.js +27 -16
  11. package/src/lib/components/deal-products-card.js.map +1 -1
  12. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  13. package/src/lib/components/lead-campaigns-card.js +9 -8
  14. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +4 -0
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-status-chip.js +3 -1
  18. package/src/lib/components/lead-status-chip.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.js +7 -9
  20. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  21. package/src/lib/components/leads-section.js +5 -1
  22. package/src/lib/components/leads-section.js.map +1 -1
  23. package/src/lib/components/new-lead-drawer.js +5 -4
  24. package/src/lib/components/new-lead-drawer.js.map +1 -1
  25. package/src/lib/components/reports/activity-card.js +4 -3
  26. package/src/lib/components/reports/activity-card.js.map +1 -1
  27. package/src/lib/components/reports/closed-deals-card.js +4 -3
  28. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  29. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  30. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  31. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  32. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  33. package/src/lib/components/reports/forecast-card.js +3 -2
  34. package/src/lib/components/reports/forecast-card.js.map +1 -1
  35. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  36. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  37. package/src/lib/components/reports/pipeline-card.js +4 -3
  38. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  39. package/src/lib/components/reports/report-format.d.ts +1 -1
  40. package/src/lib/components/reports/report-format.js.map +1 -1
  41. package/src/lib/components/reports/report-scope.d.ts +1 -1
  42. package/src/lib/components/reports/report-scope.js.map +1 -1
  43. package/src/lib/components/reports/source-conversion-card.js +2 -1
  44. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  45. package/src/lib/components/reports/tasks-card.js +2 -1
  46. package/src/lib/components/reports/tasks-card.js.map +1 -1
  47. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  48. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  49. package/src/lib/components/reports-section.js +4 -4
  50. package/src/lib/components/reports-section.js.map +1 -1
  51. package/src/lib/constants/bundle-common.d.ts +2 -0
  52. package/src/lib/constants/bundle-common.js +1 -0
  53. package/src/lib/constants/bundle-common.js.map +1 -1
  54. package/src/lib/declarations.console-server.d.ts +4 -0
  55. package/src/lib/declarations.console-server.js +12 -0
  56. package/src/lib/declarations.console-server.js.map +1 -1
  57. package/src/lib/declarations.server.d.ts +6 -0
  58. package/src/lib/declarations.server.js +51 -1
  59. package/src/lib/declarations.server.js.map +1 -1
  60. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  61. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  62. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  63. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  64. package/src/lib/model/campaign-filing-activity.js +1 -1
  65. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  66. package/src/lib/model/contact-record.js +1 -1
  67. package/src/lib/model/contact-record.js.map +1 -1
  68. package/src/lib/model/crm-digest.d.ts +45 -3
  69. package/src/lib/model/crm-digest.js +75 -11
  70. package/src/lib/model/crm-digest.js.map +1 -1
  71. package/src/lib/model/crm-inbound.d.ts +162 -0
  72. package/src/lib/model/crm-inbound.js +208 -0
  73. package/src/lib/model/crm-inbound.js.map +1 -0
  74. package/src/lib/model/crm-lead-import.d.ts +1 -1
  75. package/src/lib/model/crm-lead-import.js +4 -4
  76. package/src/lib/model/crm-lead-import.js.map +1 -1
  77. package/src/lib/model/crm-reports.d.ts +467 -0
  78. package/src/lib/model/crm-reports.js +667 -0
  79. package/src/lib/model/crm-reports.js.map +1 -0
  80. package/src/lib/model/lead-filters.d.ts +4 -3
  81. package/src/lib/model/lead-filters.js +7 -5
  82. package/src/lib/model/lead-filters.js.map +1 -1
  83. package/src/lib/plugin.js +36 -0
  84. package/src/lib/plugin.js.map +1 -1
  85. package/src/lib/server/api-v1/crm-leads.js +1 -0
  86. package/src/lib/server/api-v1/crm-leads.js.map +1 -1
  87. package/src/lib/server/api-v1/openapi.js +1 -1
  88. package/src/lib/server/api-v1/openapi.js.map +1 -1
  89. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  90. package/src/lib/server/campaign-filing-activity.js +7 -11
  91. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  92. package/src/lib/server/capture-contact.js +19 -5
  93. package/src/lib/server/capture-contact.js.map +1 -1
  94. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  95. package/src/lib/server/contact-email-engagement.js +146 -0
  96. package/src/lib/server/contact-email-engagement.js.map +1 -0
  97. package/src/lib/server/contact-update.js +3 -3
  98. package/src/lib/server/contact-update.js.map +1 -1
  99. package/src/lib/server/container-detach.d.ts +31 -0
  100. package/src/lib/server/container-detach.js +115 -0
  101. package/src/lib/server/container-detach.js.map +1 -0
  102. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  103. package/src/lib/server/crm-inbound-email.js +259 -0
  104. package/src/lib/server/crm-inbound-email.js.map +1 -0
  105. package/src/lib/server/crm-records-meter.d.ts +50 -0
  106. package/src/lib/server/crm-records-meter.js +126 -0
  107. package/src/lib/server/crm-records-meter.js.map +1 -0
  108. package/src/lib/server/daily-digest-route.d.ts +2 -8
  109. package/src/lib/server/daily-digest-route.js +15 -25
  110. package/src/lib/server/daily-digest-route.js.map +1 -1
  111. package/src/lib/server/inbound-address.js +5 -3
  112. package/src/lib/server/inbound-address.js.map +1 -1
  113. package/src/lib/server/inbound-route.js +7 -4
  114. package/src/lib/server/inbound-route.js.map +1 -1
  115. package/src/lib/server/lead-campaign-carry.js +3 -3
  116. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  117. package/src/lib/server/lead-create.d.ts +1 -1
  118. package/src/lib/server/lead-create.js +10 -15
  119. package/src/lib/server/lead-create.js.map +1 -1
  120. package/src/lib/server/lead-nurturing.d.ts +23 -0
  121. package/src/lib/server/lead-nurturing.js +82 -0
  122. package/src/lib/server/lead-nurturing.js.map +1 -0
  123. package/src/lib/server/leads-import.js +7 -7
  124. package/src/lib/server/leads-import.js.map +1 -1
  125. package/src/lib/server/record-email-state.js +23 -0
  126. package/src/lib/server/record-email-state.js.map +1 -1
  127. package/src/lib/server/record-timeline.d.ts +12 -0
  128. package/src/lib/server/record-timeline.js +127 -15
  129. package/src/lib/server/record-timeline.js.map +1 -1
  130. package/src/lib/server/task-reminders-route.js +4 -2
  131. package/src/lib/server/task-reminders-route.js.map +1 -1
  132. package/src/lib/server/task-routes.d.ts +2 -0
  133. package/src/lib/server/task-routes.js +22 -3
  134. package/src/lib/server/task-routes.js.map +1 -1
  135. package/src/lib/usage-axes.js +8 -1
  136. package/src/lib/usage-axes.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/tasks-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 { AppLink, 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 Button,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n documentId,\n getCountFromServer,\n getDocs,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\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 open tasks the by-assignee table is grouped from.\n *\n * The tiles are counts the server takes; the table needs each task's\n * assignee and due state, so it reads the soonest-due thousand and says\n * when there were more.\n */\nconst OPEN_TASK_CEILING = 1000\n/** One `in` query's worth of member documents, for the assignee names. */\nconst ASSIGNEE_NAME_CEILING = 30\n\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The by-assignee table's columns, which are also the CSV's. */\nconst ASSIGNEE_COLUMNS = ['Assignee', 'Overdue', 'Today', 'Upcoming', 'No date', 'Open'] as const\n\ninterface AssigneeLoad {\n uid: string\n overdue: number\n today: number\n upcoming: number\n undated: number\n open: number\n}\n\nexport interface TasksCardProps {\n report: CrmReportScope\n}\n\n/**\n * The task load: open, overdue and due today, and who is carrying what\n * (AGL-2604).\n *\n * \"Today\" and \"overdue\" are decided on the LOCAL calendar day, the same day\n * `taskDueState` decides them on, so the count here and the chip on the\n * tasks list agree about the same task at 11 p.m. The counts are range\n * queries on `dueAtMs`, which the `(visibleTo, status, dueAtMs)` index\n * serves; a task with no due date is in the open count and in none of the\n * dated ones.\n *\n * The table reads open tasks ordered by due date. An ordered read admits\n * only documents that CARRY the field, so a task written without `dueAtMs`\n * at all — rather than with `null`, which sorts first — is counted in the\n * tile and absent from the table. The model types the field as optional,\n * so the table says what it is grouped from.\n */\nexport function TasksCard(props: TasksCardProps) {\n const { report } = props\n const { scope, tokens, nowMs, routes } = report\n const firestore = useFirestore()\n const day = useMemo(() => Aglyn.localDayBounds(nowMs), [nowMs])\n\n const openTasks = () =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n )\n\n const counts = useAggregateRead(\n () =>\n Promise.all([\n getCountFromServer(openTasks()),\n getCountFromServer(query(openTasks(), where('dueAtMs', '<', day.start))),\n getCountFromServer(\n query(\n openTasks(),\n where('dueAtMs', '>=', day.start),\n where('dueAtMs', '<', day.end),\n ),\n ),\n ]).then(([open, overdue, today]) => ({\n open: open.data().count,\n overdue: overdue.data().count,\n today: today.data().count,\n })),\n [firestore, scope, tokens, day],\n { cacheKey: reportCacheKey(report, 'tasks:counts') },\n )\n\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n openTasks(),\n orderBy('dueAtMs', 'asc'),\n limit(OPEN_TASK_CEILING + 1),\n ),\n OPEN_TASK_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'tasks:open') },\n )\n const tasksStatus = taskWindow.status\n\n const load = useMemo(() => {\n const byAssignee = new Map<string, AssigneeLoad>()\n for (const task of taskWindow.rows) {\n const uid = task.assigneeUid || ''\n const row = byAssignee.get(uid) ?? {\n uid,\n overdue: 0,\n today: 0,\n upcoming: 0,\n undated: 0,\n open: 0,\n }\n const state = Aglyn.taskDueState(task, nowMs)\n if (state === 'overdue') row.overdue += 1\n else if (state === 'today') row.today += 1\n else if (state === 'upcoming') row.upcoming += 1\n else row.undated += 1\n row.open += 1\n byAssignee.set(uid, row)\n }\n return [...byAssignee.values()].sort(\n (a, b) => b.open - a.open || a.uid.localeCompare(b.uid),\n )\n }, [taskWindow, nowMs])\n\n /*\n * The assignees' names, from their member documents — one `in` query for\n * up to thirty, which is Firestore's cap on the operator and more assignees\n * than a tasks list has. Anyone past it, or a member document the reader\n * cannot open, shows as their id rather than blocking the table.\n */\n const uids = load.map((row) => row.uid).filter(Boolean).slice(0, ASSIGNEE_NAME_CEILING)\n const uidKey = uids.join(',')\n const names = useAggregateRead<Record<string, string>>(\n () =>\n uids.length\n ? getDocs(\n query(\n scopedCollection(firestore, scope, 'members'),\n where(documentId(), 'in', uids),\n ),\n ).then((snapshot) =>\n Object.fromEntries(\n snapshot.docs.map((member) => [\n member.id,\n String(member.get('displayName') || member.get('email') || member.id),\n ]),\n ),\n )\n : Promise.resolve({}),\n [firestore, scope, uidKey],\n { cacheKey: reportCacheKey(report, `tasks:names:${uidKey}`) },\n )\n\n const figures = counts.value\n const assigneeName = (row: AssigneeLoad): string =>\n row.uid ? names.value?.[row.uid] ?? row.uid : 'Unassigned'\n return (\n <CardDisplay\n header={'Tasks'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#tasks',\n excerpt:\n 'Open tasks, how many are overdue or due today, and the open ' +\n 'load per assignee. Today and overdue are decided on your calendar ' +\n 'day, the same way the tasks list decides them.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <ReportExport\n filename={reportFilename('tasks-by-assignee')}\n columns={ASSIGNEE_COLUMNS}\n rows={() =>\n load.map((row) => [\n assigneeName(row),\n row.overdue,\n row.today,\n row.upcoming,\n row.undated,\n row.open,\n ])\n }\n disabled={tasksStatus !== 'success' || !load.length}\n />\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('tasks')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open tasks'}\n </Button>\n </Stack>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Open tasks'}\n value={figures ? figures.open.toLocaleString() : null}\n note={'counted on the server'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Overdue'}\n value={figures ? figures.overdue.toLocaleString() : null}\n note={'due before today'}\n color={figures?.overdue ? 'error.main' : undefined}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Due today'}\n value={figures ? figures.today.toLocaleString() : null}\n note={'on your calendar day'}\n href={routes.section('tasks')}\n />\n </Stack>\n {counts.status === 'error' ? (\n <Alert severity=\"warning\">{'The task counts could not be read.'}</Alert>\n ) : null}\n <Section title={'Open tasks by assignee'}>\n {load.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {ASSIGNEE_COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {load.map((row) => (\n <TableRow key={row.uid || '$unassigned'}>\n <TableCell>{assigneeName(row)}</TableCell>\n <TableCell\n align=\"right\"\n sx={row.overdue ? { color: 'error.main' } : undefined}\n >\n {row.overdue.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">{row.today.toLocaleString()}</TableCell>\n <TableCell align=\"right\">{row.upcoming.toLocaleString()}</TableCell>\n <TableCell align=\"right\">{row.undated.toLocaleString()}</TableCell>\n <TableCell align=\"right\">{row.open.toLocaleString()}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {tasksStatus === 'loading' ? 'Reading…' : 'No open tasks.'}\n </Typography>\n )}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {taskWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Grouped from the ${OPEN_TASK_CEILING.toLocaleString()} soonest-due open tasks; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nTasksCard.displayName = 'TasksCard'\n\nexport default TasksCard\n"],"names":["Aglyn","AppLink","CardDisplay","Section","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","documentId","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","OPEN_TASK_CEILING","ASSIGNEE_NAME_CEILING","ASSIGNEE_COLUMNS","TasksCard","props","report","scope","tokens","nowMs","routes","firestore","day","localDayBounds","openTasks","CRM_COLLECTIONS","tasks","counts","Promise","all","start","end","then","open","overdue","today","data","count","cacheKey","taskWindow","tasksStatus","status","load","byAssignee","Map","task","rows","uid","assigneeUid","row","get","upcoming","undated","state","taskDueState","set","values","sort","a","b","localeCompare","uids","map","filter","Boolean","slice","uidKey","join","names","length","snapshot","Object","fromEntries","docs","member","id","String","resolve","figures","value","assigneeName","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","direction","spacing","filename","columns","disabled","component","componentVariant","nativeButton","href","section","size","color","sx","flexWrap","label","toLocaleString","note","undefined","severity","title","column","index","align","variant","truncated","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,OAAO,QAAQ,6DAA4D;AACpF,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SACEC,UAAU,EACVC,kBAAkB,EAClBC,OAAO,EACPC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,YAAY,QAAQ,qBAAiB;AAC9C,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAEEC,cAAc,EACdC,gBAAgB,EAChBC,eAAe,QACV,oBAAgB;AACvB,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,0BAAsB;AAEtE;;;;;;CAMC,GACD,MAAMC,oBAAoB;AAC1B,wEAAwE,GACxE,MAAMC,wBAAwB;AAI9B,+DAA+D,GAC/D,MAAMC,mBAAmB;IAAC;IAAY;IAAW;IAAS;IAAY;IAAW;CAAO;AAexF;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,UAAUC,KAAqB;IAC7C,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAM,EAAEE,KAAK,EAAEC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAE,GAAGJ;IACzC,MAAMK,YAAYnB;IAClB,MAAMoB,MAAMrB,QAAQ,IAAMpB,MAAM0C,cAAc,CAACJ,QAAQ;QAACA;KAAM;IAE9D,MAAMK,YAAY,IAChBzB,MACEO,iBAAiBe,WAAWJ,OAAOpC,MAAM4C,eAAe,CAACC,KAAK,MAC3DnB,gBAAgBW,SACnBlB,MAAM,UAAU,MAAM;IAG1B,MAAM2B,SAASlB,iBACb,IACEmB,QAAQC,GAAG,CAAC;YACVlC,mBAAmB6B;YACnB7B,mBAAmBI,MAAMyB,aAAaxB,MAAM,WAAW,KAAKsB,IAAIQ,KAAK;YACrEnC,mBACEI,MACEyB,aACAxB,MAAM,WAAW,MAAMsB,IAAIQ,KAAK,GAChC9B,MAAM,WAAW,KAAKsB,IAAIS,GAAG;SAGlC,EAAEC,IAAI,CAAC,CAAC,CAACC,MAAMC,SAASC,MAAM,GAAM,CAAA;gBACnCF,MAAMA,KAAKG,IAAI,GAAGC,KAAK;gBACvBH,SAASA,QAAQE,IAAI,GAAGC,KAAK;gBAC7BF,OAAOA,MAAMC,IAAI,GAAGC,KAAK;YAC3B,CAAA,IACF;QAAChB;QAAWJ;QAAOC;QAAQI;KAAI,EAC/B;QAAEgB,UAAUjC,eAAeW,QAAQ;IAAgB;IAGrD,MAAMuB,aAAa7B,cACjB,IACEX,MACEyB,aACA1B,QAAQ,WAAW,QACnBD,MAAMc,oBAAoB,KAE9BA,mBACA;QAACU;QAAWJ;QAAOC;QAAQC;KAAM,EACjC;QAAEmB,UAAUjC,eAAeW,QAAQ;IAAc;IAEnD,MAAMwB,cAAcD,WAAWE,MAAM;IAErC,MAAMC,OAAOzC,QAAQ;QACnB,MAAM0C,aAAa,IAAIC;QACvB,KAAK,MAAMC,QAAQN,WAAWO,IAAI,CAAE;gBAEtBH;YADZ,MAAMI,MAAMF,KAAKG,WAAW,IAAI;YAChC,MAAMC,OAAMN,kBAAAA,WAAWO,GAAG,CAACH,gBAAfJ,kBAAuB;gBACjCI;gBACAb,SAAS;gBACTC,OAAO;gBACPgB,UAAU;gBACVC,SAAS;gBACTnB,MAAM;YACR;YACA,MAAMoB,QAAQxE,MAAMyE,YAAY,CAACT,MAAM1B;YACvC,IAAIkC,UAAU,WAAWJ,IAAIf,OAAO,IAAI;iBACnC,IAAImB,UAAU,SAASJ,IAAId,KAAK,IAAI;iBACpC,IAAIkB,UAAU,YAAYJ,IAAIE,QAAQ,IAAI;iBAC1CF,IAAIG,OAAO,IAAI;YACpBH,IAAIhB,IAAI,IAAI;YACZU,WAAWY,GAAG,CAACR,KAAKE;QACtB;QACA,OAAO;eAAIN,WAAWa,MAAM;SAAG,CAACC,IAAI,CAClC,CAACC,GAAGC,IAAMA,EAAE1B,IAAI,GAAGyB,EAAEzB,IAAI,IAAIyB,EAAEX,GAAG,CAACa,aAAa,CAACD,EAAEZ,GAAG;IAE1D,GAAG;QAACR;QAAYpB;KAAM;IAEtB;;;;;GAKC,GACD,MAAM0C,OAAOnB,KAAKoB,GAAG,CAAC,CAACb,MAAQA,IAAIF,GAAG,EAAEgB,MAAM,CAACC,SAASC,KAAK,CAAC,GAAGrD;IACjE,MAAMsD,SAASL,KAAKM,IAAI,CAAC;IACzB,MAAMC,QAAQ3D,iBACZ,IACEoD,KAAKQ,MAAM,GACPzE,QACEG,MACEO,iBAAiBe,WAAWJ,OAAO,YACnCjB,MAAMN,cAAc,MAAMmE,QAE5B7B,IAAI,CAAC,CAACsC,WACNC,OAAOC,WAAW,CAChBF,SAASG,IAAI,CAACX,GAAG,CAAC,CAACY,SAAW;oBAC5BA,OAAOC,EAAE;oBACTC,OAAOF,OAAOxB,GAAG,CAAC,kBAAkBwB,OAAOxB,GAAG,CAAC,YAAYwB,OAAOC,EAAE;iBACrE,MAGL/C,QAAQiD,OAAO,CAAC,CAAC,IACvB;QAACxD;QAAWJ;QAAOiD;KAAO,EAC1B;QAAE5B,UAAUjC,eAAeW,QAAQ,CAAC,YAAY,EAAEkD,QAAQ;IAAE;IAG9D,MAAMY,UAAUnD,OAAOoD,KAAK;IAC5B,MAAMC,eAAe,CAAC/B;;YACVmB;eAAVnB,IAAIF,GAAG,YAAGqB,eAAAA,MAAMW,KAAK,qBAAXX,YAAa,CAACnB,IAAIF,GAAG,CAAC,mBAAIE,IAAIF,GAAG,GAAG;;IAChD,qBACE,KAAChE;QACCkG,QAAQ;QACRC,MAAMrG,MAAMsG,cAAc,CAAC,cAAc;YACvCC,QAAQ;YACRC,SACE,iEACA,uEACA;QACJ;QACAC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,MAACrG;gBAAMsG,WAAU;gBAAMC,SAAS;;kCAC9B,KAACxF;wBACCyF,UAAUxF,eAAe;wBACzByF,SAAShF;wBACTiC,MAAM,IACJJ,KAAKoB,GAAG,CAAC,CAACb,MAAQ;oCAChB+B,aAAa/B;oCACbA,IAAIf,OAAO;oCACXe,IAAId,KAAK;oCACTc,IAAIE,QAAQ;oCACZF,IAAIG,OAAO;oCACXH,IAAIhB,IAAI;iCACT;wBAEH6D,UAAUtD,gBAAgB,aAAa,CAACE,KAAK2B,MAAM;;kCAErD,KAAClF;wBACC4G,WAAWjH;uBACN;wBAAEkH,kBAAkB;wBAASC,cAAc;oBAAM;wBACtDC,MAAM9E,OAAO+E,OAAO,CAAC;wBACrBC,MAAK;wBACLC,OAAM;kCAEL;;;;QAIT;kBAEA,cAAA,MAACjH;YAAMuG,SAAS;;8BACd,MAACvG;oBAAMsG,WAAU;oBAAMC,SAAS;oBAAGW,IAAI;wBAAEC,UAAU;oBAAO;;sCACxD,KAAC/F;4BACCgG,OAAO;4BACPzB,OAAOD,UAAUA,QAAQ7C,IAAI,CAACwE,cAAc,KAAK;4BACjDC,MAAM;4BACNR,MAAM9E,OAAO+E,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCgG,OAAO;4BACPzB,OAAOD,UAAUA,QAAQ5C,OAAO,CAACuE,cAAc,KAAK;4BACpDC,MAAM;4BACNL,OAAOvB,CAAAA,2BAAAA,QAAS5C,OAAO,IAAG,eAAeyE;4BACzCT,MAAM9E,OAAO+E,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCgG,OAAO;4BACPzB,OAAOD,UAAUA,QAAQ3C,KAAK,CAACsE,cAAc,KAAK;4BAClDC,MAAM;4BACNR,MAAM9E,OAAO+E,OAAO,CAAC;;;;gBAGxBxE,OAAOc,MAAM,KAAK,wBACjB,KAACvD;oBAAM0H,UAAS;8BAAW;qBACzB;8BACJ,MAAC5H;oBAAQ6H,OAAO;;wBACbnE,KAAK2B,MAAM,iBACV,MAACpF;4BAAYmH,MAAK;;8CAChB,KAAC7G;8CACC,cAAA,KAACC;kDACEqB,iBAAiBiD,GAAG,CAAC,CAACgD,QAAQC,sBAC7B,KAACzH;gDAAuB0H,OAAOD,QAAQ,UAAU;0DAC9CD;+CADaA;;;8CAMtB,KAACzH;8CACEqD,KAAKoB,GAAG,CAAC,CAACb,oBACT,MAACzD;;8DACC,KAACF;8DAAW0F,aAAa/B;;8DACzB,KAAC3D;oDACC0H,OAAM;oDACNV,IAAIrD,IAAIf,OAAO,GAAG;wDAAEmE,OAAO;oDAAa,IAAIM;8DAE3C1D,IAAIf,OAAO,CAACuE,cAAc;;8DAE7B,KAACnH;oDAAU0H,OAAM;8DAAS/D,IAAId,KAAK,CAACsE,cAAc;;8DAClD,KAACnH;oDAAU0H,OAAM;8DAAS/D,IAAIE,QAAQ,CAACsD,cAAc;;8DACrD,KAACnH;oDAAU0H,OAAM;8DAAS/D,IAAIG,OAAO,CAACqD,cAAc;;8DACpD,KAACnH;oDAAU0H,OAAM;8DAAS/D,IAAIhB,IAAI,CAACwE,cAAc;;;2CAXpCxD,IAAIF,GAAG,IAAI;;;2CAiBhC,KAACtD;4BAAWwH,SAAQ;4BAAQZ,OAAM;sCAC/B7D,gBAAgB,YAAY,aAAa;;wBAI7CD,WAAW2E,SAAS,iBACnB,KAACzH;4BAAWwH,SAAQ;4BAAUZ,OAAM;sCACjC,CAAC,iBAAiB,EAAE1F,kBAAkB8F,cAAc,GAAG,6DAA6D,CAAC;6BAEtH;;;;;;AAKd;AACA3F,UAAUqG,WAAW,GAAG;AAExB,eAAerG,UAAS"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/tasks-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 { AppLink, 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 Button,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n documentId,\n getCountFromServer,\n getDocs,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\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 { localDayBounds } from '../../model/crm-reports'\n\n/**\n * How many open tasks the by-assignee table is grouped from.\n *\n * The tiles are counts the server takes; the table needs each task's\n * assignee and due state, so it reads the soonest-due thousand and says\n * when there were more.\n */\nconst OPEN_TASK_CEILING = 1000\n/** One `in` query's worth of member documents, for the assignee names. */\nconst ASSIGNEE_NAME_CEILING = 30\n\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The by-assignee table's columns, which are also the CSV's. */\nconst ASSIGNEE_COLUMNS = ['Assignee', 'Overdue', 'Today', 'Upcoming', 'No date', 'Open'] as const\n\ninterface AssigneeLoad {\n uid: string\n overdue: number\n today: number\n upcoming: number\n undated: number\n open: number\n}\n\nexport interface TasksCardProps {\n report: CrmReportScope\n}\n\n/**\n * The task load: open, overdue and due today, and who is carrying what\n * (AGL-2604).\n *\n * \"Today\" and \"overdue\" are decided on the LOCAL calendar day, the same day\n * `taskDueState` decides them on, so the count here and the chip on the\n * tasks list agree about the same task at 11 p.m. The counts are range\n * queries on `dueAtMs`, which the `(visibleTo, status, dueAtMs)` index\n * serves; a task with no due date is in the open count and in none of the\n * dated ones.\n *\n * The table reads open tasks ordered by due date. An ordered read admits\n * only documents that CARRY the field, so a task written without `dueAtMs`\n * at all — rather than with `null`, which sorts first — is counted in the\n * tile and absent from the table. The model types the field as optional,\n * so the table says what it is grouped from.\n */\nexport function TasksCard(props: TasksCardProps) {\n const { report } = props\n const { scope, tokens, nowMs, routes } = report\n const firestore = useFirestore()\n const day = useMemo(() => localDayBounds(nowMs), [nowMs])\n\n const openTasks = () =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n )\n\n const counts = useAggregateRead(\n () =>\n Promise.all([\n getCountFromServer(openTasks()),\n getCountFromServer(query(openTasks(), where('dueAtMs', '<', day.start))),\n getCountFromServer(\n query(\n openTasks(),\n where('dueAtMs', '>=', day.start),\n where('dueAtMs', '<', day.end),\n ),\n ),\n ]).then(([open, overdue, today]) => ({\n open: open.data().count,\n overdue: overdue.data().count,\n today: today.data().count,\n })),\n [firestore, scope, tokens, day],\n { cacheKey: reportCacheKey(report, 'tasks:counts') },\n )\n\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n openTasks(),\n orderBy('dueAtMs', 'asc'),\n limit(OPEN_TASK_CEILING + 1),\n ),\n OPEN_TASK_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'tasks:open') },\n )\n const tasksStatus = taskWindow.status\n\n const load = useMemo(() => {\n const byAssignee = new Map<string, AssigneeLoad>()\n for (const task of taskWindow.rows) {\n const uid = task.assigneeUid || ''\n const row = byAssignee.get(uid) ?? {\n uid,\n overdue: 0,\n today: 0,\n upcoming: 0,\n undated: 0,\n open: 0,\n }\n const state = Aglyn.taskDueState(task, nowMs)\n if (state === 'overdue') row.overdue += 1\n else if (state === 'today') row.today += 1\n else if (state === 'upcoming') row.upcoming += 1\n else row.undated += 1\n row.open += 1\n byAssignee.set(uid, row)\n }\n return [...byAssignee.values()].sort(\n (a, b) => b.open - a.open || a.uid.localeCompare(b.uid),\n )\n }, [taskWindow, nowMs])\n\n /*\n * The assignees' names, from their member documents — one `in` query for\n * up to thirty, which is Firestore's cap on the operator and more assignees\n * than a tasks list has. Anyone past it, or a member document the reader\n * cannot open, shows as their id rather than blocking the table.\n */\n const uids = load.map((row) => row.uid).filter(Boolean).slice(0, ASSIGNEE_NAME_CEILING)\n const uidKey = uids.join(',')\n const names = useAggregateRead<Record<string, string>>(\n () =>\n uids.length\n ? getDocs(\n query(\n scopedCollection(firestore, scope, 'members'),\n where(documentId(), 'in', uids),\n ),\n ).then((snapshot) =>\n Object.fromEntries(\n snapshot.docs.map((member) => [\n member.id,\n String(member.get('displayName') || member.get('email') || member.id),\n ]),\n ),\n )\n : Promise.resolve({}),\n [firestore, scope, uidKey],\n { cacheKey: reportCacheKey(report, `tasks:names:${uidKey}`) },\n )\n\n const figures = counts.value\n const assigneeName = (row: AssigneeLoad): string =>\n row.uid ? names.value?.[row.uid] ?? row.uid : 'Unassigned'\n return (\n <CardDisplay\n header={'Tasks'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#tasks',\n excerpt:\n 'Open tasks, how many are overdue or due today, and the open ' +\n 'load per assignee. Today and overdue are decided on your calendar ' +\n 'day, the same way the tasks list decides them.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <ReportExport\n filename={reportFilename('tasks-by-assignee')}\n columns={ASSIGNEE_COLUMNS}\n rows={() =>\n load.map((row) => [\n assigneeName(row),\n row.overdue,\n row.today,\n row.upcoming,\n row.undated,\n row.open,\n ])\n }\n disabled={tasksStatus !== 'success' || !load.length}\n />\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('tasks')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open tasks'}\n </Button>\n </Stack>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Open tasks'}\n value={figures ? figures.open.toLocaleString() : null}\n note={'counted on the server'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Overdue'}\n value={figures ? figures.overdue.toLocaleString() : null}\n note={'due before today'}\n color={figures?.overdue ? 'error.main' : undefined}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Due today'}\n value={figures ? figures.today.toLocaleString() : null}\n note={'on your calendar day'}\n href={routes.section('tasks')}\n />\n </Stack>\n {counts.status === 'error' ? (\n <Alert severity=\"warning\">{'The task counts could not be read.'}</Alert>\n ) : null}\n <Section title={'Open tasks by assignee'}>\n {load.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {ASSIGNEE_COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {load.map((row) => (\n <TableRow key={row.uid || '$unassigned'}>\n <TableCell>{assigneeName(row)}</TableCell>\n <TableCell\n align=\"right\"\n sx={row.overdue ? { color: 'error.main' } : undefined}\n >\n {row.overdue.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">{row.today.toLocaleString()}</TableCell>\n <TableCell align=\"right\">{row.upcoming.toLocaleString()}</TableCell>\n <TableCell align=\"right\">{row.undated.toLocaleString()}</TableCell>\n <TableCell align=\"right\">{row.open.toLocaleString()}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {tasksStatus === 'loading' ? 'Reading…' : 'No open tasks.'}\n </Typography>\n )}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {taskWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Grouped from the ${OPEN_TASK_CEILING.toLocaleString()} soonest-due open tasks; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nTasksCard.displayName = 'TasksCard'\n\nexport default TasksCard\n"],"names":["Aglyn","AppLink","CardDisplay","Section","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","documentId","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","localDayBounds","OPEN_TASK_CEILING","ASSIGNEE_NAME_CEILING","ASSIGNEE_COLUMNS","TasksCard","props","report","scope","tokens","nowMs","routes","firestore","day","openTasks","CRM_COLLECTIONS","tasks","counts","Promise","all","start","end","then","open","overdue","today","data","count","cacheKey","taskWindow","tasksStatus","status","load","byAssignee","Map","task","rows","uid","assigneeUid","row","get","upcoming","undated","state","taskDueState","set","values","sort","a","b","localeCompare","uids","map","filter","Boolean","slice","uidKey","join","names","length","snapshot","Object","fromEntries","docs","member","id","String","resolve","figures","value","assigneeName","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","direction","spacing","filename","columns","disabled","component","componentVariant","nativeButton","href","section","size","color","sx","flexWrap","label","toLocaleString","note","undefined","severity","title","column","index","align","variant","truncated","displayName"],"mappings":"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"}
@@ -27,6 +27,7 @@ import { useOrgMemberOptions } from "../../hooks/use-org-member-options.js";
27
27
  import { ReportBreakdown } from "./report-breakdown.js";
28
28
  import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
29
29
  import { useWindowRead } from "./use-aggregate-read.js";
30
+ import { currencyOfDeals, wonLostByOwner } from "../../model/crm-reports.js";
30
31
  /**
31
32
  * The same ceiling the Won and lost card reads its chart under, spelled
32
33
  * from one constant so the two cards share the read rather than each
@@ -78,11 +79,11 @@ import { useWindowRead } from "./use-aggregate-read.js";
78
79
  ], {
79
80
  cacheKey: reportCacheKey(report, 'closed:lost')
80
81
  });
81
- const owners = useMemo(()=>Aglyn.wonLostByOwner(wonWindow.rows, lostWindow.rows), [
82
+ const owners = useMemo(()=>wonLostByOwner(wonWindow.rows, lostWindow.rows), [
82
83
  wonWindow.rows,
83
84
  lostWindow.rows
84
85
  ]);
85
- const currency = useMemo(()=>Aglyn.currencyOfDeals(wonWindow.rows).currency, [
86
+ const currency = useMemo(()=>currencyOfDeals(wonWindow.rows).currency, [
86
87
  wonWindow.rows
87
88
  ]);
88
89
  const read = wonWindow.status === 'success' && lostWindow.status === 'success';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/won-lost-by-owner-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 { percent } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportBreakdown } from './report-breakdown'\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 ceiling the Won and lost card reads its chart under, spelled\n * from one constant so the two cards share the read rather than each\n * paying for its own.\n */\nconst CLOSED_DEAL_CEILING = 500\n\n/** How many owners the breakdown draws before folding the rest into one row. */\nconst OWNER_ROW_CEILING = 8\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface WonLostByOwnerCardProps {\n report: CrmReportScope\n}\n\n/**\n * Who closed what (AGL-2662): the period's wins and losses, by the person\n * who owned the deal at the close.\n *\n * ## It costs no read\n *\n * The two windows are the SAME queries the Won and lost card builds, under\n * the same cache keys — `closed:won` and `closed:lost` — so\n * `useAggregateRead` either answers this card from the remembered window or\n * joins the other card's in-flight promise. Two cards, one pair of reads.\n * A key of its own here would have doubled the most expensive read on the\n * page to ask a different question of the same rows.\n *\n * ## Why the rows can under-report a very busy period\n *\n * The window is bounded, and the bound is on the deals rather than on the\n * owners — so a period with more than {@link CLOSED_DEAL_CEILING} closes of\n * an outcome is tallied over the most recent of them, and the caption says\n * so. A server-side group-by would need one aggregate per owner, and the\n * owner list is not known before the read.\n */\nexport function WonLostByOwnerCard(props: WonLostByOwnerCardProps) {\n const { report } = props\n const { scope, tokens, range } = report\n const firestore = useFirestore()\n // The roster resolves a uid into the name the team reads; a uid it no\n // longer carries — somebody who has left — is named by its own id rather\n // than dropped, because their closed deals still happened.\n const roster = useOrgMemberOptions(scope[1])\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 orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () => closedInPeriod('won'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () => closedInPeriod('lost'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n\n const owners = useMemo(\n () => Aglyn.wonLostByOwner(wonWindow.rows, lostWindow.rows),\n [wonWindow.rows, lostWindow.rows],\n )\n const currency = useMemo(\n () => Aglyn.currencyOfDeals(wonWindow.rows).currency,\n [wonWindow.rows],\n )\n\n const read = wonWindow.status === 'success' && lostWindow.status === 'success'\n const failed = wonWindow.status === 'error' || lostWindow.status === 'error'\n\n const rows = useMemo(() => {\n const named = (uid: string) =>\n uid\n ? (Aglyn.findOrgMember(roster.options, uid)?.label ?? uid)\n : 'Unassigned'\n const shown = owners.slice(0, OWNER_ROW_CEILING)\n const folded = owners.slice(OWNER_ROW_CEILING)\n const rest = folded.reduce(\n (total, owner) => ({\n won: total.won + owner.won,\n lost: total.lost + owner.lost,\n wonAmountCents: total.wonAmountCents + owner.wonAmountCents,\n }),\n { won: 0, lost: 0, wonAmountCents: 0 },\n )\n return [\n ...shown.map((owner) => ({\n key: owner.uid || 'unassigned',\n label: named(owner.uid),\n value: owner.won,\n display: `${owner.won.toLocaleString()} won`,\n note:\n `${owner.lost.toLocaleString()} lost · ` +\n `${owner.winRate === null ? 'no rate' : `${percent(owner.winRate)} win rate`} · ` +\n `${money(owner.wonAmountCents, currency)}`,\n color: 'success.main',\n })),\n ...(folded.length\n ? [\n {\n key: '$rest',\n label: `${folded.length} more`,\n value: rest.won,\n display: `${rest.won.toLocaleString()} won`,\n note: `${rest.lost.toLocaleString()} lost · ${money(rest.wonAmountCents, currency)}`,\n color: 'success.main',\n },\n ]\n : []),\n ]\n }, [owners, roster.options, currency])\n\n return (\n <CardDisplay\n header={'Won and lost by owner'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost-by-owner',\n excerpt:\n 'Closed deals grouped by the person who owned them at the close, ' +\n 'ranked by how much closed rather than by win rate, with the deals ' +\n 'nobody owned kept as a row of their own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Alert severity=\"warning\">\n {'The closed deals could not be read, so this is not an empty period.'}\n </Alert>\n ) : null}\n <ReportBreakdown\n rows={rows}\n emptyText={read ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Counted over the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; this period held more.`}\n </Typography>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to whoever owned the deal when it closed, not to whoever created it.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nWonLostByOwnerCard.displayName = 'WonLostByOwnerCard'\n\nexport default WonLostByOwnerCard\n"],"names":["Aglyn","money","CardDisplay","percent","Alert","Stack","Typography","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportBreakdown","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","CLOSED_DEAL_CEILING","OWNER_ROW_CEILING","WonLostByOwnerCard","props","report","scope","tokens","range","firestore","roster","closedInPeriod","status","from","to","wonWindow","cacheKey","lostWindow","owners","wonLostByOwner","rows","currency","currencyOfDeals","read","failed","named","uid","findOrgMember","options","label","shown","slice","folded","rest","reduce","total","owner","won","lost","wonAmountCents","map","key","value","display","toLocaleString","note","winRate","color","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","emptyText","truncated","variant","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/won-lost-by-owner-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 { percent } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportBreakdown } from './report-breakdown'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\nimport { currencyOfDeals, wonLostByOwner } from '../../model/crm-reports'\n\n/**\n * The same ceiling the Won and lost card reads its chart under, spelled\n * from one constant so the two cards share the read rather than each\n * paying for its own.\n */\nconst CLOSED_DEAL_CEILING = 500\n\n/** How many owners the breakdown draws before folding the rest into one row. */\nconst OWNER_ROW_CEILING = 8\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\n\nexport interface WonLostByOwnerCardProps {\n report: CrmReportScope\n}\n\n/**\n * Who closed what (AGL-2662): the period's wins and losses, by the person\n * who owned the deal at the close.\n *\n * ## It costs no read\n *\n * The two windows are the SAME queries the Won and lost card builds, under\n * the same cache keys — `closed:won` and `closed:lost` — so\n * `useAggregateRead` either answers this card from the remembered window or\n * joins the other card's in-flight promise. Two cards, one pair of reads.\n * A key of its own here would have doubled the most expensive read on the\n * page to ask a different question of the same rows.\n *\n * ## Why the rows can under-report a very busy period\n *\n * The window is bounded, and the bound is on the deals rather than on the\n * owners — so a period with more than {@link CLOSED_DEAL_CEILING} closes of\n * an outcome is tallied over the most recent of them, and the caption says\n * so. A server-side group-by would need one aggregate per owner, and the\n * owner list is not known before the read.\n */\nexport function WonLostByOwnerCard(props: WonLostByOwnerCardProps) {\n const { report } = props\n const { scope, tokens, range } = report\n const firestore = useFirestore()\n // The roster resolves a uid into the name the team reads; a uid it no\n // longer carries — somebody who has left — is named by its own id rather\n // than dropped, because their closed deals still happened.\n const roster = useOrgMemberOptions(scope[1])\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 orderBy('closedAtMs', 'desc'),\n limit(CLOSED_DEAL_CEILING + 1),\n )\n\n const wonWindow = useWindowRead<DealRow>(\n () => closedInPeriod('won'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:won') },\n )\n const lostWindow = useWindowRead<DealRow>(\n () => closedInPeriod('lost'),\n CLOSED_DEAL_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'closed:lost') },\n )\n\n const owners = useMemo(\n () => wonLostByOwner(wonWindow.rows, lostWindow.rows),\n [wonWindow.rows, lostWindow.rows],\n )\n const currency = useMemo(\n () => currencyOfDeals(wonWindow.rows).currency,\n [wonWindow.rows],\n )\n\n const read = wonWindow.status === 'success' && lostWindow.status === 'success'\n const failed = wonWindow.status === 'error' || lostWindow.status === 'error'\n\n const rows = useMemo(() => {\n const named = (uid: string) =>\n uid\n ? (Aglyn.findOrgMember(roster.options, uid)?.label ?? uid)\n : 'Unassigned'\n const shown = owners.slice(0, OWNER_ROW_CEILING)\n const folded = owners.slice(OWNER_ROW_CEILING)\n const rest = folded.reduce(\n (total, owner) => ({\n won: total.won + owner.won,\n lost: total.lost + owner.lost,\n wonAmountCents: total.wonAmountCents + owner.wonAmountCents,\n }),\n { won: 0, lost: 0, wonAmountCents: 0 },\n )\n return [\n ...shown.map((owner) => ({\n key: owner.uid || 'unassigned',\n label: named(owner.uid),\n value: owner.won,\n display: `${owner.won.toLocaleString()} won`,\n note:\n `${owner.lost.toLocaleString()} lost · ` +\n `${owner.winRate === null ? 'no rate' : `${percent(owner.winRate)} win rate`} · ` +\n `${money(owner.wonAmountCents, currency)}`,\n color: 'success.main',\n })),\n ...(folded.length\n ? [\n {\n key: '$rest',\n label: `${folded.length} more`,\n value: rest.won,\n display: `${rest.won.toLocaleString()} won`,\n note: `${rest.lost.toLocaleString()} lost · ${money(rest.wonAmountCents, currency)}`,\n color: 'success.main',\n },\n ]\n : []),\n ]\n }, [owners, roster.options, currency])\n\n return (\n <CardDisplay\n header={'Won and lost by owner'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#won-and-lost-by-owner',\n excerpt:\n 'Closed deals grouped by the person who owned them at the close, ' +\n 'ranked by how much closed rather than by win rate, with the deals ' +\n 'nobody owned kept as a row of their own.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Alert severity=\"warning\">\n {'The closed deals could not be read, so this is not an empty period.'}\n </Alert>\n ) : null}\n <ReportBreakdown\n rows={rows}\n emptyText={read ? 'Nothing closed in this period.' : 'Reading…'}\n />\n {wonWindow.truncated || lostWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Counted over the ${CLOSED_DEAL_CEILING} most recently closed of each outcome; this period held more.`}\n </Typography>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to whoever owned the deal when it closed, not to whoever created it.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nWonLostByOwnerCard.displayName = 'WonLostByOwnerCard'\n\nexport default WonLostByOwnerCard\n"],"names":["Aglyn","money","CardDisplay","percent","Alert","Stack","Typography","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportBreakdown","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","currencyOfDeals","wonLostByOwner","CLOSED_DEAL_CEILING","OWNER_ROW_CEILING","WonLostByOwnerCard","props","report","scope","tokens","range","firestore","roster","closedInPeriod","status","from","to","wonWindow","cacheKey","lostWindow","owners","rows","currency","read","failed","named","uid","findOrgMember","options","label","shown","slice","folded","rest","reduce","total","owner","won","lost","wonAmountCents","map","key","value","display","toLocaleString","note","winRate","color","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","emptyText","truncated","variant","displayName"],"mappings":"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"}
@@ -15,7 +15,6 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
- import * as Aglyn from "@aglyn/aglyn";
19
18
  import { mdiRefresh } from "@aglyn/shared-data-mdi";
20
19
  import { MdiIcon } from "@aglyn/shared-ui-jsx";
21
20
  import { CardColumns } from "@aglyn/shared-ui-jsx/components/card-columns";
@@ -37,6 +36,7 @@ import { WonLostByOwnerCard } from "./reports/won-lost-by-owner-card.js";
37
36
  import { reportCacheKey, reportCachePrefix, scopedCollection, visibleToClause } from "./reports/report-scope.js";
38
37
  import { TasksCard } from "./reports/tasks-card.js";
39
38
  import { invalidateAggregateReads, useAggregateRead } from "./reports/use-aggregate-read.js";
39
+ import { CRM_REPORT_PERIOD_LABELS, CRM_REPORT_PERIODS, crmReportRange } from "../model/crm-reports.js";
40
40
  /**
41
41
  * `/crm/reports` — the CRM in aggregate (AGL-2604, AGL-2624).
42
42
  *
@@ -101,7 +101,7 @@ import { invalidateAggregateReads, useAggregateRead } from "./reports/use-aggreg
101
101
  period: '30d',
102
102
  nowMs: Date.now()
103
103
  }));
104
- const range = useMemo(()=>Aglyn.crmReportRange(view.period, view.nowMs), [
104
+ const range = useMemo(()=>crmReportRange(view.period, view.nowMs), [
105
105
  view
106
106
  ]);
107
107
  const routes = useMemo(()=>crmRoutes(basePath != null ? basePath : ''), [
@@ -197,9 +197,9 @@ import { invalidateAggregateReads, useAggregateRead } from "./reports/use-aggreg
197
197
  }
198
198
  },
199
199
  "aria-label": "Report period",
200
- children: Aglyn.CRM_REPORT_PERIODS.map((period)=>/*#__PURE__*/ _jsx(ToggleButton, {
200
+ children: CRM_REPORT_PERIODS.map((period)=>/*#__PURE__*/ _jsx(ToggleButton, {
201
201
  value: period,
202
- children: Aglyn.CRM_REPORT_PERIOD_LABELS[period]
202
+ children: CRM_REPORT_PERIOD_LABELS[period]
203
203
  }, period))
204
204
  }),
205
205
  /*#__PURE__*/ _jsx(Button, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/reports-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { mdiRefresh } from '@aglyn/shared-data-mdi'\nimport {\n MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { CardColumns } from '@aglyn/shared-ui-jsx/components/card-columns'\nimport {\n Button,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport { getCountFromServer, query } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { crmRoutes } from '../model/crm-routes'\nimport { ActivityCard } from './reports/activity-card'\nimport { ClosedDealsCard } from './reports/closed-deals-card'\nimport { ContactsMixCard } from './reports/contacts-mix-card'\nimport { ContactsTrendCard } from './reports/contacts-trend-card'\nimport { ForecastCard } from './reports/forecast-card'\nimport { LeadFunnelCard } from './reports/lead-funnel-card'\nimport { PipelineCard } from './reports/pipeline-card'\nimport { SourceConversionCard } from './reports/source-conversion-card'\nimport { WonLostByOwnerCard } from './reports/won-lost-by-owner-card'\nimport {\n type CrmReportScope,\n reportCacheKey,\n reportCachePrefix,\n scopedCollection,\n visibleToClause,\n} from './reports/report-scope'\nimport { TasksCard } from './reports/tasks-card'\nimport { invalidateAggregateReads, useAggregateRead } from './reports/use-aggregate-read'\n\n/**\n * `/crm/reports` — the CRM in aggregate (AGL-2604, AGL-2624).\n *\n * Nine cards, each reading its own collection: contacts over time, contacts\n * by source and stage, which sources convert, this site's lead funnel, the\n * open pipeline, what closed, who logged what, the forecast by close month\n * (AGL-2620, over the pipeline card's own window), and the task load. This\n * section resolves what they share and hands it down — see `CrmReportScope`\n * — so that every card counts the same reader's records over the same\n * clock. The lead funnel is the one card handed the site as well: leads live\n * under the host, not the org.\n *\n * ## The period is a control, and the reads follow it\n *\n * Nothing on this page reads a year on mount. The picker names the period,\n * the period sizes every read (one count per week of it, the deals closed\n * within it), and the default is thirty days. The three cards that are\n * stocks rather than flows — the open pipeline, its forecast, the open\n * tasks — ignore the period because \"what is open\" has no period.\n *\n * ## Scoped like the list\n *\n * Every query carries `visibleTo array-contains-any` over the group's\n * tokens, the predicate the rules evaluate, so a report can never count a\n * record its reader could not open — and the total here is the reader's\n * total, not the org's billable audience, which is the head-count the\n * contacts list keeps for the quota.\n *\n * ## Read once a minute, not once an arrival\n *\n * Every read on this page is keyed by the scope and the period and kept\n * for `AGGREGATE_READ_TTL_MS` — see `useAggregateRead` — so leaving to open\n * a deal the report named and coming straight back costs nothing. Refresh\n * forgets this reader's answers for this org and anchors the period again,\n * which is what makes every card ask afresh.\n */\nexport function ContactsReportsSection(props: ConsolePluginPageProps) {\n const { hostId, org, basePath } = props\n const firestore = useFirestore()\n // The org data root — null until the org lookup settles, and the page\n // reads nothing until it does — and the controller this page is viewed\n // as, resolved the way every CRM surface resolves them so the facet a\n // stage is read from here is the facet the contacts list edits it in.\n const { scope: dataScope, consentGroup, visibleTo } = useCrmScope({ hostId, org })\n // `null` at the organization level (AGL-2630): no clause, and every card\n // totals every site; the holder is then each contact's own.\n const tokens = useMemo(() => (visibleTo ? [...visibleTo] : null), [visibleTo])\n const groupId = consentGroup?.groupId ?? null\n const orgRecord = (org ?? null) as Record<string, unknown> | null\n /*\n * The period AND the moment it was chosen, as one value: the range is\n * anchored when the reader picks, not re-derived from a moving `Date.now()`\n * per render, which would rebuild every query on every pass.\n */\n const [view, setView] = useState<{\n period: Aglyn.CrmReportPeriod\n nowMs: number\n }>(() => ({ period: '30d', nowMs: Date.now() }))\n const range = useMemo(\n () => Aglyn.crmReportRange(view.period, view.nowMs),\n [view],\n )\n const routes = useMemo(() => crmRoutes(basePath ?? ''), [basePath])\n const refresh = useCallback(() => {\n if (dataScope) {\n invalidateAggregateReads(reportCachePrefix({ scope: dataScope, tokens, groupId }))\n }\n // A new anchor is what changes every card's dependencies, so each read\n // runs again and — its key forgotten — reaches the server.\n setView((current) => ({ period: current.period, nowMs: Date.now() }))\n }, [dataScope, tokens, groupId])\n\n const report = useMemo<CrmReportScope | null>(\n () =>\n dataScope\n ? {\n scope: dataScope,\n tokens,\n groupId,\n org: orgRecord,\n period: view.period,\n range,\n nowMs: view.nowMs,\n routes,\n }\n : null,\n [dataScope, tokens, groupId, orgRecord, view, range, routes],\n )\n\n /**\n * Every contact this reader may see, counted once for the two cards that\n * quote it — the tile, and the sample notice that compares the read to it.\n */\n const totalContacts = useAggregateRead<number>(\n () =>\n dataScope\n ? getCountFromServer(\n query(\n scopedCollection(firestore, dataScope, 'contacts'),\n ...visibleToClause(tokens),\n ),\n ).then((snapshot) => snapshot.data().count)\n : null,\n // The anchor is a dependency so Refresh re-asks; the key, which omits\n // it, is what answers a reopened section from memory.\n [firestore, dataScope, tokens, view.nowMs],\n report ? { cacheKey: reportCacheKey(report, 'contacts:total') } : {},\n )\n\n return (\n <Stack spacing={2}>\n <Stack\n direction=\"row\"\n spacing={2}\n sx={{\n alignItems: 'center',\n justifyContent: 'space-between',\n flexWrap: 'wrap',\n }}\n >\n <Typography variant=\"h6\" component=\"h2\">\n {'Reports'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 1 }}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={view.period}\n onChange={(_event, next) => {\n if (next) {\n setView({ period: next as Aglyn.CrmReportPeriod, nowMs: Date.now() })\n }\n }}\n aria-label=\"Report period\"\n >\n {Aglyn.CRM_REPORT_PERIODS.map((period) => (\n <ToggleButton key={period} value={period}>\n {Aglyn.CRM_REPORT_PERIOD_LABELS[period]}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Button\n size=\"small\"\n startIcon={<MdiIcon path={mdiRefresh.path} size={0.8} />}\n disabled={!report}\n onClick={refresh}\n >\n {'Refresh'}\n </Button>\n </Stack>\n </Stack>\n {report ? (\n <>\n {/*\n The same masonry the billing and staff pages use: columns as tall\n as their own contents, so a short card beside a tall one leaves no\n hole. Measured live before this changed — 271, 272 and 111 pixels\n of dead space down one report, and an odd card stranded with an\n empty column beside it.\n */}\n <CardColumns\n spacing={2}\n items={[\n {\n key: 'trend',\n children: (\n <ContactsTrendCard report={report} totalContacts={totalContacts} />\n ),\n },\n {\n key: 'mix',\n children: (\n <ContactsMixCard report={report} totalContacts={totalContacts} />\n ),\n },\n { key: 'source-conversion', children: <SourceConversionCard report={report} /> },\n { key: 'lead-funnel', children: <LeadFunnelCard report={report} hostId={hostId} /> },\n { key: 'pipeline', children: <PipelineCard report={report} /> },\n {\n key: 'closed',\n /*\n One column item carrying two cards, because multicol picks\n its own break: two consecutive items are only USUALLY\n adjacent, and a card that is a footnote to the one above it\n cannot be left to that. Won and lost by owner asks a second\n question of the very rows Won and lost already read — the\n same window under the same `closed:won` and `closed:lost`\n keys — so it is read as that card's breakdown, and\n `break-inside: avoid` keeps the pair whole in one column.\n */\n children: (\n <Stack spacing={2}>\n <ClosedDealsCard report={report} />\n <WonLostByOwnerCard report={report} />\n </Stack>\n ),\n },\n ]}\n />\n {/*\n Outside the columns: multicol cannot span, and these three read\n across the full width.\n */}\n <Stack spacing={2}>\n <ActivityCard report={report} />\n <ForecastCard report={report} />\n <TasksCard report={report} />\n </Stack>\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n )}\n </Stack>\n )\n}\nContactsReportsSection.displayName = 'ContactsReportsSection'\n\nexport default ContactsReportsSection\n"],"names":["Aglyn","mdiRefresh","MdiIcon","CardColumns","Button","Stack","ToggleButton","ToggleButtonGroup","Typography","getCountFromServer","query","useCallback","useMemo","useState","useFirestore","useCrmScope","crmRoutes","ActivityCard","ClosedDealsCard","ContactsMixCard","ContactsTrendCard","ForecastCard","LeadFunnelCard","PipelineCard","SourceConversionCard","WonLostByOwnerCard","reportCacheKey","reportCachePrefix","scopedCollection","visibleToClause","TasksCard","invalidateAggregateReads","useAggregateRead","ContactsReportsSection","props","hostId","org","basePath","firestore","scope","dataScope","consentGroup","visibleTo","tokens","groupId","orgRecord","view","setView","period","nowMs","Date","now","range","crmReportRange","routes","refresh","current","report","totalContacts","then","snapshot","data","count","cacheKey","spacing","direction","sx","alignItems","justifyContent","flexWrap","variant","component","rowGap","exclusive","size","color","value","onChange","_event","next","aria-label","CRM_REPORT_PERIODS","map","CRM_REPORT_PERIOD_LABELS","startIcon","path","disabled","onClick","items","key","children","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/reports-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { mdiRefresh } from '@aglyn/shared-data-mdi'\nimport {\n MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { CardColumns } from '@aglyn/shared-ui-jsx/components/card-columns'\nimport {\n Button,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport { getCountFromServer, query } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { crmRoutes } from '../model/crm-routes'\nimport { ActivityCard } from './reports/activity-card'\nimport { ClosedDealsCard } from './reports/closed-deals-card'\nimport { ContactsMixCard } from './reports/contacts-mix-card'\nimport { ContactsTrendCard } from './reports/contacts-trend-card'\nimport { ForecastCard } from './reports/forecast-card'\nimport { LeadFunnelCard } from './reports/lead-funnel-card'\nimport { PipelineCard } from './reports/pipeline-card'\nimport { SourceConversionCard } from './reports/source-conversion-card'\nimport { WonLostByOwnerCard } from './reports/won-lost-by-owner-card'\nimport {\n type CrmReportScope,\n reportCacheKey,\n reportCachePrefix,\n scopedCollection,\n visibleToClause,\n} from './reports/report-scope'\nimport { TasksCard } from './reports/tasks-card'\nimport { invalidateAggregateReads, useAggregateRead } from './reports/use-aggregate-read'\nimport {\n CRM_REPORT_PERIOD_LABELS,\n CRM_REPORT_PERIODS,\n type CrmReportPeriod,\n crmReportRange,\n} from '../model/crm-reports'\n\n/**\n * `/crm/reports` — the CRM in aggregate (AGL-2604, AGL-2624).\n *\n * Nine cards, each reading its own collection: contacts over time, contacts\n * by source and stage, which sources convert, this site's lead funnel, the\n * open pipeline, what closed, who logged what, the forecast by close month\n * (AGL-2620, over the pipeline card's own window), and the task load. This\n * section resolves what they share and hands it down — see `CrmReportScope`\n * — so that every card counts the same reader's records over the same\n * clock. The lead funnel is the one card handed the site as well: leads live\n * under the host, not the org.\n *\n * ## The period is a control, and the reads follow it\n *\n * Nothing on this page reads a year on mount. The picker names the period,\n * the period sizes every read (one count per week of it, the deals closed\n * within it), and the default is thirty days. The three cards that are\n * stocks rather than flows — the open pipeline, its forecast, the open\n * tasks — ignore the period because \"what is open\" has no period.\n *\n * ## Scoped like the list\n *\n * Every query carries `visibleTo array-contains-any` over the group's\n * tokens, the predicate the rules evaluate, so a report can never count a\n * record its reader could not open — and the total here is the reader's\n * total, not the org's billable audience, which is the head-count the\n * contacts list keeps for the quota.\n *\n * ## Read once a minute, not once an arrival\n *\n * Every read on this page is keyed by the scope and the period and kept\n * for `AGGREGATE_READ_TTL_MS` — see `useAggregateRead` — so leaving to open\n * a deal the report named and coming straight back costs nothing. Refresh\n * forgets this reader's answers for this org and anchors the period again,\n * which is what makes every card ask afresh.\n */\nexport function ContactsReportsSection(props: ConsolePluginPageProps) {\n const { hostId, org, basePath } = props\n const firestore = useFirestore()\n // The org data root — null until the org lookup settles, and the page\n // reads nothing until it does — and the controller this page is viewed\n // as, resolved the way every CRM surface resolves them so the facet a\n // stage is read from here is the facet the contacts list edits it in.\n const { scope: dataScope, consentGroup, visibleTo } = useCrmScope({ hostId, org })\n // `null` at the organization level (AGL-2630): no clause, and every card\n // totals every site; the holder is then each contact's own.\n const tokens = useMemo(() => (visibleTo ? [...visibleTo] : null), [visibleTo])\n const groupId = consentGroup?.groupId ?? null\n const orgRecord = (org ?? null) as Record<string, unknown> | null\n /*\n * The period AND the moment it was chosen, as one value: the range is\n * anchored when the reader picks, not re-derived from a moving `Date.now()`\n * per render, which would rebuild every query on every pass.\n */\n const [view, setView] = useState<{\n period: CrmReportPeriod\n nowMs: number\n }>(() => ({ period: '30d', nowMs: Date.now() }))\n const range = useMemo(\n () => crmReportRange(view.period, view.nowMs),\n [view],\n )\n const routes = useMemo(() => crmRoutes(basePath ?? ''), [basePath])\n const refresh = useCallback(() => {\n if (dataScope) {\n invalidateAggregateReads(reportCachePrefix({ scope: dataScope, tokens, groupId }))\n }\n // A new anchor is what changes every card's dependencies, so each read\n // runs again and — its key forgotten — reaches the server.\n setView((current) => ({ period: current.period, nowMs: Date.now() }))\n }, [dataScope, tokens, groupId])\n\n const report = useMemo<CrmReportScope | null>(\n () =>\n dataScope\n ? {\n scope: dataScope,\n tokens,\n groupId,\n org: orgRecord,\n period: view.period,\n range,\n nowMs: view.nowMs,\n routes,\n }\n : null,\n [dataScope, tokens, groupId, orgRecord, view, range, routes],\n )\n\n /**\n * Every contact this reader may see, counted once for the two cards that\n * quote it — the tile, and the sample notice that compares the read to it.\n */\n const totalContacts = useAggregateRead<number>(\n () =>\n dataScope\n ? getCountFromServer(\n query(\n scopedCollection(firestore, dataScope, 'contacts'),\n ...visibleToClause(tokens),\n ),\n ).then((snapshot) => snapshot.data().count)\n : null,\n // The anchor is a dependency so Refresh re-asks; the key, which omits\n // it, is what answers a reopened section from memory.\n [firestore, dataScope, tokens, view.nowMs],\n report ? { cacheKey: reportCacheKey(report, 'contacts:total') } : {},\n )\n\n return (\n <Stack spacing={2}>\n <Stack\n direction=\"row\"\n spacing={2}\n sx={{\n alignItems: 'center',\n justifyContent: 'space-between',\n flexWrap: 'wrap',\n }}\n >\n <Typography variant=\"h6\" component=\"h2\">\n {'Reports'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: 1 }}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={view.period}\n onChange={(_event, next) => {\n if (next) {\n setView({ period: next as CrmReportPeriod, nowMs: Date.now() })\n }\n }}\n aria-label=\"Report period\"\n >\n {CRM_REPORT_PERIODS.map((period) => (\n <ToggleButton key={period} value={period}>\n {CRM_REPORT_PERIOD_LABELS[period]}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Button\n size=\"small\"\n startIcon={<MdiIcon path={mdiRefresh.path} size={0.8} />}\n disabled={!report}\n onClick={refresh}\n >\n {'Refresh'}\n </Button>\n </Stack>\n </Stack>\n {report ? (\n <>\n {/*\n The same masonry the billing and staff pages use: columns as tall\n as their own contents, so a short card beside a tall one leaves no\n hole. Measured live before this changed — 271, 272 and 111 pixels\n of dead space down one report, and an odd card stranded with an\n empty column beside it.\n */}\n <CardColumns\n spacing={2}\n items={[\n {\n key: 'trend',\n children: (\n <ContactsTrendCard report={report} totalContacts={totalContacts} />\n ),\n },\n {\n key: 'mix',\n children: (\n <ContactsMixCard report={report} totalContacts={totalContacts} />\n ),\n },\n { key: 'source-conversion', children: <SourceConversionCard report={report} /> },\n { key: 'lead-funnel', children: <LeadFunnelCard report={report} hostId={hostId} /> },\n { key: 'pipeline', children: <PipelineCard report={report} /> },\n {\n key: 'closed',\n /*\n One column item carrying two cards, because multicol picks\n its own break: two consecutive items are only USUALLY\n adjacent, and a card that is a footnote to the one above it\n cannot be left to that. Won and lost by owner asks a second\n question of the very rows Won and lost already read — the\n same window under the same `closed:won` and `closed:lost`\n keys — so it is read as that card's breakdown, and\n `break-inside: avoid` keeps the pair whole in one column.\n */\n children: (\n <Stack spacing={2}>\n <ClosedDealsCard report={report} />\n <WonLostByOwnerCard report={report} />\n </Stack>\n ),\n },\n ]}\n />\n {/*\n Outside the columns: multicol cannot span, and these three read\n across the full width.\n */}\n <Stack spacing={2}>\n <ActivityCard report={report} />\n <ForecastCard report={report} />\n <TasksCard report={report} />\n </Stack>\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n )}\n </Stack>\n )\n}\nContactsReportsSection.displayName = 'ContactsReportsSection'\n\nexport default ContactsReportsSection\n"],"names":["mdiRefresh","MdiIcon","CardColumns","Button","Stack","ToggleButton","ToggleButtonGroup","Typography","getCountFromServer","query","useCallback","useMemo","useState","useFirestore","useCrmScope","crmRoutes","ActivityCard","ClosedDealsCard","ContactsMixCard","ContactsTrendCard","ForecastCard","LeadFunnelCard","PipelineCard","SourceConversionCard","WonLostByOwnerCard","reportCacheKey","reportCachePrefix","scopedCollection","visibleToClause","TasksCard","invalidateAggregateReads","useAggregateRead","CRM_REPORT_PERIOD_LABELS","CRM_REPORT_PERIODS","crmReportRange","ContactsReportsSection","props","hostId","org","basePath","firestore","scope","dataScope","consentGroup","visibleTo","tokens","groupId","orgRecord","view","setView","period","nowMs","Date","now","range","routes","refresh","current","report","totalContacts","then","snapshot","data","count","cacheKey","spacing","direction","sx","alignItems","justifyContent","flexWrap","variant","component","rowGap","exclusive","size","color","value","onChange","_event","next","aria-label","map","startIcon","path","disabled","onClick","items","key","children","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAGA,SAASA,UAAU,QAAQ,yBAAwB;AACnD,SACEC,OAAO,QAAQ,uBAAsB;AACvC,SAASC,WAAW,QAAQ,+CAA8C;AAC1E,SACEC,MAAM,EACNC,KAAK,EACLC,YAAY,EACZC,iBAAiB,EACjBC,UAAU,QACL,gBAAe;AACtB,SAASC,kBAAkB,EAAEC,KAAK,QAAQ,qBAAoB;AAC9D,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,WAAW,QAAQ,4BAAwB;AACpD,SAASC,SAAS,QAAQ,yBAAqB;AAC/C,SAASC,YAAY,QAAQ,6BAAyB;AACtD,SAASC,eAAe,QAAQ,iCAA6B;AAC7D,SAASC,eAAe,QAAQ,iCAA6B;AAC7D,SAASC,iBAAiB,QAAQ,mCAA+B;AACjE,SAASC,YAAY,QAAQ,6BAAyB;AACtD,SAASC,cAAc,QAAQ,gCAA4B;AAC3D,SAASC,YAAY,QAAQ,6BAAyB;AACtD,SAASC,oBAAoB,QAAQ,sCAAkC;AACvE,SAASC,kBAAkB,QAAQ,sCAAkC;AACrE,SAEEC,cAAc,EACdC,iBAAiB,EACjBC,gBAAgB,EAChBC,eAAe,QACV,4BAAwB;AAC/B,SAASC,SAAS,QAAQ,0BAAsB;AAChD,SAASC,wBAAwB,EAAEC,gBAAgB,QAAQ,kCAA8B;AACzF,SACEC,wBAAwB,EACxBC,kBAAkB,EAElBC,cAAc,QACT,0BAAsB;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,SAASC,uBAAuBC,KAA6B;;IAClE,MAAM,EAAEC,MAAM,EAAEC,GAAG,EAAEC,QAAQ,EAAE,GAAGH;IAClC,MAAMI,YAAY3B;IAClB,sEAAsE;IACtE,uEAAuE;IACvE,sEAAsE;IACtE,sEAAsE;IACtE,MAAM,EAAE4B,OAAOC,SAAS,EAAEC,YAAY,EAAEC,SAAS,EAAE,GAAG9B,YAAY;QAAEuB;QAAQC;IAAI;IAChF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMO,SAASlC,QAAQ,IAAOiC,YAAY;eAAIA;SAAU,GAAG,MAAO;QAACA;KAAU;IAC7E,MAAME,kBAAUH,gCAAAA,aAAcG,OAAO,mBAAI;IACzC,MAAMC,YAAaT,cAAAA,MAAO;IAC1B;;;;GAIC,GACD,MAAM,CAACU,MAAMC,QAAQ,GAAGrC,SAGrB,IAAO,CAAA;YAAEsC,QAAQ;YAAOC,OAAOC,KAAKC,GAAG;QAAG,CAAA;IAC7C,MAAMC,QAAQ3C,QACZ,IAAMuB,eAAec,KAAKE,MAAM,EAAEF,KAAKG,KAAK,GAC5C;QAACH;KAAK;IAER,MAAMO,SAAS5C,QAAQ,IAAMI,UAAUwB,mBAAAA,WAAY,KAAK;QAACA;KAAS;IAClE,MAAMiB,UAAU9C,YAAY;QAC1B,IAAIgC,WAAW;YACbZ,yBAAyBJ,kBAAkB;gBAAEe,OAAOC;gBAAWG;gBAAQC;YAAQ;QACjF;QACA,uEAAuE;QACvE,2DAA2D;QAC3DG,QAAQ,CAACQ,UAAa,CAAA;gBAAEP,QAAQO,QAAQP,MAAM;gBAAEC,OAAOC,KAAKC,GAAG;YAAG,CAAA;IACpE,GAAG;QAACX;QAAWG;QAAQC;KAAQ;IAE/B,MAAMY,SAAS/C,QACb,IACE+B,YACI;YACED,OAAOC;YACPG;YACAC;YACAR,KAAKS;YACLG,QAAQF,KAAKE,MAAM;YACnBI;YACAH,OAAOH,KAAKG,KAAK;YACjBI;QACF,IACA,MACN;QAACb;QAAWG;QAAQC;QAASC;QAAWC;QAAMM;QAAOC;KAAO;IAG9D;;;GAGC,GACD,MAAMI,gBAAgB5B,iBACpB,IACEW,YACIlC,mBACEC,MACEkB,iBAAiBa,WAAWE,WAAW,gBACpCd,gBAAgBiB,UAErBe,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK,IAC1C,MACN,sEAAsE;IACtE,sDAAsD;IACtD;QAACvB;QAAWE;QAAWG;QAAQG,KAAKG,KAAK;KAAC,EAC1CO,SAAS;QAAEM,UAAUvC,eAAeiC,QAAQ;IAAkB,IAAI,CAAC;IAGrE,qBACE,MAACtD;QAAM6D,SAAS;;0BACd,MAAC7D;gBACC8D,WAAU;gBACVD,SAAS;gBACTE,IAAI;oBACFC,YAAY;oBACZC,gBAAgB;oBAChBC,UAAU;gBACZ;;kCAEA,KAAC/D;wBAAWgE,SAAQ;wBAAKC,WAAU;kCAChC;;kCAEH,MAACpE;wBAAM8D,WAAU;wBAAMD,SAAS;wBAAGE,IAAI;4BAAEC,YAAY;4BAAUE,UAAU;4BAAQG,QAAQ;wBAAE;;0CACzF,KAACnE;gCACCoE,SAAS;gCACTC,MAAK;gCACLC,OAAM;gCACNC,OAAO7B,KAAKE,MAAM;gCAClB4B,UAAU,CAACC,QAAQC;oCACjB,IAAIA,MAAM;wCACR/B,QAAQ;4CAAEC,QAAQ8B;4CAAyB7B,OAAOC,KAAKC,GAAG;wCAAG;oCAC/D;gCACF;gCACA4B,cAAW;0CAEVhD,mBAAmBiD,GAAG,CAAC,CAAChC,uBACvB,KAAC7C;wCAA0BwE,OAAO3B;kDAC/BlB,wBAAwB,CAACkB,OAAO;uCADhBA;;0CAKvB,KAAC/C;gCACCwE,MAAK;gCACLQ,yBAAW,KAAClF;oCAAQmF,MAAMpF,WAAWoF,IAAI;oCAAET,MAAM;;gCACjDU,UAAU,CAAC3B;gCACX4B,SAAS9B;0CAER;;;;;;YAINE,uBACC;;kCAQE,KAACxD;wBACC+D,SAAS;wBACTsB,OAAO;4BACL;gCACEC,KAAK;gCACLC,wBACE,KAACtE;oCAAkBuC,QAAQA;oCAAQC,eAAeA;;4BAEtD;4BACA;gCACE6B,KAAK;gCACLC,wBACE,KAACvE;oCAAgBwC,QAAQA;oCAAQC,eAAeA;;4BAEpD;4BACA;gCAAE6B,KAAK;gCAAqBC,wBAAU,KAAClE;oCAAqBmC,QAAQA;;4BAAW;4BAC/E;gCAAE8B,KAAK;gCAAeC,wBAAU,KAACpE;oCAAeqC,QAAQA;oCAAQrB,QAAQA;;4BAAW;4BACnF;gCAAEmD,KAAK;gCAAYC,wBAAU,KAACnE;oCAAaoC,QAAQA;;4BAAW;4BAC9D;gCACE8B,KAAK;gCACL;;;;;;;;;gBASA,GACAC,wBACE,MAACrF;oCAAM6D,SAAS;;sDACd,KAAChD;4CAAgByC,QAAQA;;sDACzB,KAAClC;4CAAmBkC,QAAQA;;;;4BAGlC;yBACD;;kCAMH,MAACtD;wBAAM6D,SAAS;;0CACd,KAACjD;gCAAa0C,QAAQA;;0CACtB,KAACtC;gCAAasC,QAAQA;;0CACtB,KAAC7B;gCAAU6B,QAAQA;;;;;+BAIvB,KAACnD;gBAAWgE,SAAQ;gBAAQK,OAAM;0BAC/B;;;;AAKX;AACAzC,uBAAuBuD,WAAW,GAAG;AAErC,eAAevD,uBAAsB"}
@@ -12,3 +12,5 @@
12
12
  * (AGL-2614), so `contacts` now reads as an unknown id like any other.
13
13
  */
14
14
  export declare const BUNDLE_ID = "crm";
15
+ /** The records band's meter in the monthly usage sweep (`server/crm-records-meter.ts`). */
16
+ export declare const CRM_RECORDS_METER_ID = "records";
@@ -10,5 +10,6 @@
10
10
  * the old value was retired once that backfill reported nothing left
11
11
  * (AGL-2614), so `contacts` now reads as an unknown id like any other.
12
12
  */ export const BUNDLE_ID = 'crm';
13
+ /** The records band's meter in the monthly usage sweep (`server/crm-records-meter.ts`). */ export const CRM_RECORDS_METER_ID = 'records';
13
14
 
14
15
  //# sourceMappingURL=bundle-common.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/bundle-common.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n */\n\n/**\n * The persisted plugin id (AGL-2595). It was `contacts` while the surface was\n * one list; the stored lists were rewritten and the plugin manager's alias for\n * the old value was retired once that backfill reported nothing left\n * (AGL-2614), so `contacts` now reads as an unknown id like any other.\n */\nexport const BUNDLE_ID = 'crm'\n"],"names":["BUNDLE_ID"],"mappings":"AAAA;;;;;;CAMC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,YAAY,MAAK"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/bundle-common.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n */\n\n/**\n * The persisted plugin id (AGL-2595). It was `contacts` while the surface was\n * one list; the stored lists were rewritten and the plugin manager's alias for\n * the old value was retired once that backfill reported nothing left\n * (AGL-2614), so `contacts` now reads as an unknown id like any other.\n */\nexport const BUNDLE_ID = 'crm'\n\n/** The records band's meter in the monthly usage sweep (`server/crm-records-meter.ts`). */\nexport const CRM_RECORDS_METER_ID = 'records'\n"],"names":["BUNDLE_ID","CRM_RECORDS_METER_ID"],"mappings":"AAAA;;;;;;CAMC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,YAAY,MAAK;AAE9B,yFAAyF,GACzF,OAAO,MAAMC,uBAAuB,UAAS"}
@@ -28,6 +28,10 @@
28
28
  * records band and collection sizes join `GET /v1/usage`. The API is the
29
29
  * console's alone, so the tenant runtime does not register them.
30
30
  *
31
+ * It also clears a removed container off the CRM's records: a campaign is
32
+ * deleted from the console, and its owner asks every plugin's membership
33
+ * detacher first (`server/container-detach.ts`).
34
+ *
31
35
  * Light at boot: each resource's module is imported with its first request,
32
36
  * the descriptions when the document is first built, and the usage reader
33
37
  * with the first usage call.
@@ -16,6 +16,7 @@
16
16
  */ // The registry's own module, not the data layer's barrel: boot needs one
17
17
  // registry, not the whole server surface.
18
18
  import { registerApiV1Resource, registerApiV1UsageFigures } from "@aglyn/tenant-data-admin/server/api-v1-resources";
19
+ import { listPluginMembershipDetachers, registerPluginMembershipDetacher } from "@aglyn/aglyn/plugin-manager/plugin-membership-detach";
19
20
  import { BUNDLE_ID } from "./constants/bundle-common.js";
20
21
  /**
21
22
  * The plan feature every CRM resource of `/v1` needs, and the sentence its
@@ -80,6 +81,10 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
80
81
  * records band and collection sizes join `GET /v1/usage`. The API is the
81
82
  * console's alone, so the tenant runtime does not register them.
82
83
  *
84
+ * It also clears a removed container off the CRM's records: a campaign is
85
+ * deleted from the console, and its owner asks every plugin's membership
86
+ * detacher first (`server/container-detach.ts`).
87
+ *
83
88
  * Light at boot: each resource's module is imported with its first request,
84
89
  * the descriptions when the document is first built, and the usage reader
85
90
  * with the first usage call.
@@ -98,6 +103,13 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
98
103
  registerApiV1UsageFigures(async (ctx)=>(await import("./server/api-v1/usage.js")).crmUsageFigures(ctx), {
99
104
  pluginId: BUNDLE_ID
100
105
  });
106
+ // A removed container — a deleted campaign — comes off every lead and every
107
+ // contact facet naming it, before its owner removes it (AGL-3080).
108
+ if (!listPluginMembershipDetachers().includes(BUNDLE_ID)) {
109
+ registerPluginMembershipDetacher(async (request)=>(await import("./server/container-detach.js")).crmContainerDetacher(request), {
110
+ pluginId: BUNDLE_ID
111
+ });
112
+ }
101
113
  }
102
114
 
103
115
  //# sourceMappingURL=declarations.console-server.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.console-server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The registry's own module, not the data layer's barrel: boot needs one\n// registry, not the whole server surface.\nimport {\n type ApiV1ResourceHandler,\n registerApiV1Resource,\n registerApiV1UsageFigures,\n} from '@aglyn/tenant-data-admin/server/api-v1-resources'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The plan feature every CRM resource of `/v1` needs, and the sentence its\n * refusal says (AGL-2611, AGL-2851): the whole CRM is included from\n * Starter, so a Free workspace reads and changes none of it over the API. A\n * capture still records people on every plan; working them is the CRM.\n */\nconst CRM_SUITE = {\n feature: 'crm',\n message:\n 'The CRM — contacts, leads, companies, deals, tasks, pipelines, ' +\n 'activities and email templates — is not included in this organization’s plan',\n} as const\n\n/**\n * The CRM's resources on the customer REST API, each handler loaded with the\n * first request for it. In the order the API root lists them: the people\n * first, then the records a team keeps about them, then a site's leads\n * (`/v1/leads?siteId=`, a top-level path with the site as a parameter), then\n * the letters sent from a record (AGL-2658).\n */\nconst CRM_API_V1_RESOURCES: ReadonlyArray<readonly [string, ApiV1ResourceHandler]> = [\n ['contacts', async (...args) => (await import('./server/api-v1/contacts')).handleContacts(...args)],\n ['companies', async (...args) => (await import('./server/api-v1/crm-companies')).handleCompanies(...args)],\n ['pipelines', async (...args) => (await import('./server/api-v1/crm-pipelines')).handlePipelines(...args)],\n ['deals', async (...args) => (await import('./server/api-v1/crm-deals')).handleDeals(...args)],\n ['tasks', async (...args) => (await import('./server/api-v1/crm-tasks')).handleTasks(...args)],\n ['activities', async (...args) => (await import('./server/api-v1/crm-activities')).handleActivities(...args)],\n ['leads', async (...args) => (await import('./server/api-v1/crm-leads')).handleLeads(...args)],\n [\n 'email-templates',\n async (...args) => (await import('./server/api-v1/crm-email-templates')).handleEmailTemplates(...args),\n ],\n]\n\n/**\n * The CRM's CONSOLE-ONLY server declarations, named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * It serves the CRM's resources of `/v1` (AGL-2606): the console's router\n * owns the pipeline in front of them — the key, the plan's API access, the\n * quota, the rate limit, the error envelope — and hands every request under\n * `/v1/contacts`, `/v1/deals` and the rest to the CRM once the key is\n * authenticated and the organization's plan carries the CRM. Each resource\n * brings its description for the API's OpenAPI document, and the CRM's\n * records band and collection sizes join `GET /v1/usage`. The API is the\n * console's alone, so the tenant runtime does not register them.\n *\n * Light at boot: each resource's module is imported with its first request,\n * the descriptions when the document is first built, and the usage reader\n * with the first usage call.\n * Registering again replaces this plugin's own entries, so a second call (a\n * hot reload, a spec) is harmless.\n */\nexport function registerCrmConsoleServerDeclarations(): void {\n for (const [resource, handle] of CRM_API_V1_RESOURCES) {\n registerApiV1Resource(\n resource,\n {\n handle,\n entitlement: CRM_SUITE,\n describe: async () =>\n (await import('./server/api-v1/openapi')).CRM_API_V1_DESCRIPTIONS[resource],\n },\n { pluginId: BUNDLE_ID },\n )\n }\n registerApiV1UsageFigures(\n async (ctx) => (await import('./server/api-v1/usage')).crmUsageFigures(ctx),\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerApiV1Resource","registerApiV1UsageFigures","BUNDLE_ID","CRM_SUITE","feature","message","CRM_API_V1_RESOURCES","args","handleContacts","handleCompanies","handlePipelines","handleDeals","handleTasks","handleActivities","handleLeads","handleEmailTemplates","registerCrmConsoleServerDeclarations","resource","handle","entitlement","describe","CRM_API_V1_DESCRIPTIONS","pluginId","ctx","crmUsageFigures"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,0CAA0C;AAC1C,SAEEA,qBAAqB,EACrBC,yBAAyB,QACpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;CAKC,GACD,MAAMC,YAAY;IAChBC,SAAS;IACTC,SACE,oEACA;AACJ;AAEA;;;;;;CAMC,GACD,MAAMC,uBAA+E;IACnF;QAAC;QAAY,OAAO,GAAGC,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,8BAA0B,EAAGC,cAAc,IAAID;KAAM;IACnG;QAAC;QAAa,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGE,eAAe,IAAIF;KAAM;IAC1G;QAAC;QAAa,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGG,eAAe,IAAIH;KAAM;IAC1G;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGI,WAAW,IAAIJ;KAAM;IAC9F;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGK,WAAW,IAAIL;KAAM;IAC9F;QAAC;QAAc,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,oCAAgC,EAAGM,gBAAgB,IAAIN;KAAM;IAC7G;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGO,WAAW,IAAIP;KAAM;IAC9F;QACE;QACA,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGQ,oBAAoB,IAAIR;KAClG;CACF;AAED;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASS;IACd,KAAK,MAAM,CAACC,UAAUC,OAAO,IAAIZ,qBAAsB;QACrDN,sBACEiB,UACA;YACEC;YACAC,aAAahB;YACbiB,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGC,uBAAuB,CAACJ,SAAS;QAC/E,GACA;YAAEK,UAAUpB;QAAU;IAE1B;IACAD,0BACE,OAAOsB,MAAQ,AAAC,CAAA,MAAM,MAAM,CAAC,2BAAuB,EAAGC,eAAe,CAACD,MACvE;QAAED,UAAUpB;IAAU;AAE1B"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.console-server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The registry's own module, not the data layer's barrel: boot needs one\n// registry, not the whole server surface.\nimport {\n type ApiV1ResourceHandler,\n registerApiV1Resource,\n registerApiV1UsageFigures,\n} from '@aglyn/tenant-data-admin/server/api-v1-resources'\nimport {\n listPluginMembershipDetachers,\n registerPluginMembershipDetacher,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The plan feature every CRM resource of `/v1` needs, and the sentence its\n * refusal says (AGL-2611, AGL-2851): the whole CRM is included from\n * Starter, so a Free workspace reads and changes none of it over the API. A\n * capture still records people on every plan; working them is the CRM.\n */\nconst CRM_SUITE = {\n feature: 'crm',\n message:\n 'The CRM — contacts, leads, companies, deals, tasks, pipelines, ' +\n 'activities and email templates — is not included in this organization’s plan',\n} as const\n\n/**\n * The CRM's resources on the customer REST API, each handler loaded with the\n * first request for it. In the order the API root lists them: the people\n * first, then the records a team keeps about them, then a site's leads\n * (`/v1/leads?siteId=`, a top-level path with the site as a parameter), then\n * the letters sent from a record (AGL-2658).\n */\nconst CRM_API_V1_RESOURCES: ReadonlyArray<readonly [string, ApiV1ResourceHandler]> = [\n ['contacts', async (...args) => (await import('./server/api-v1/contacts')).handleContacts(...args)],\n ['companies', async (...args) => (await import('./server/api-v1/crm-companies')).handleCompanies(...args)],\n ['pipelines', async (...args) => (await import('./server/api-v1/crm-pipelines')).handlePipelines(...args)],\n ['deals', async (...args) => (await import('./server/api-v1/crm-deals')).handleDeals(...args)],\n ['tasks', async (...args) => (await import('./server/api-v1/crm-tasks')).handleTasks(...args)],\n ['activities', async (...args) => (await import('./server/api-v1/crm-activities')).handleActivities(...args)],\n ['leads', async (...args) => (await import('./server/api-v1/crm-leads')).handleLeads(...args)],\n [\n 'email-templates',\n async (...args) => (await import('./server/api-v1/crm-email-templates')).handleEmailTemplates(...args),\n ],\n]\n\n/**\n * The CRM's CONSOLE-ONLY server declarations, named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * It serves the CRM's resources of `/v1` (AGL-2606): the console's router\n * owns the pipeline in front of them — the key, the plan's API access, the\n * quota, the rate limit, the error envelope — and hands every request under\n * `/v1/contacts`, `/v1/deals` and the rest to the CRM once the key is\n * authenticated and the organization's plan carries the CRM. Each resource\n * brings its description for the API's OpenAPI document, and the CRM's\n * records band and collection sizes join `GET /v1/usage`. The API is the\n * console's alone, so the tenant runtime does not register them.\n *\n * It also clears a removed container off the CRM's records: a campaign is\n * deleted from the console, and its owner asks every plugin's membership\n * detacher first (`server/container-detach.ts`).\n *\n * Light at boot: each resource's module is imported with its first request,\n * the descriptions when the document is first built, and the usage reader\n * with the first usage call.\n * Registering again replaces this plugin's own entries, so a second call (a\n * hot reload, a spec) is harmless.\n */\nexport function registerCrmConsoleServerDeclarations(): void {\n for (const [resource, handle] of CRM_API_V1_RESOURCES) {\n registerApiV1Resource(\n resource,\n {\n handle,\n entitlement: CRM_SUITE,\n describe: async () =>\n (await import('./server/api-v1/openapi')).CRM_API_V1_DESCRIPTIONS[resource],\n },\n { pluginId: BUNDLE_ID },\n )\n }\n registerApiV1UsageFigures(\n async (ctx) => (await import('./server/api-v1/usage')).crmUsageFigures(ctx),\n { pluginId: BUNDLE_ID },\n )\n // A removed container — a deleted campaign — comes off every lead and every\n // contact facet naming it, before its owner removes it (AGL-3080).\n if (!listPluginMembershipDetachers().includes(BUNDLE_ID)) {\n registerPluginMembershipDetacher(\n async (request) => (await import('./server/container-detach')).crmContainerDetacher(request),\n { pluginId: BUNDLE_ID },\n )\n }\n}\n"],"names":["registerApiV1Resource","registerApiV1UsageFigures","listPluginMembershipDetachers","registerPluginMembershipDetacher","BUNDLE_ID","CRM_SUITE","feature","message","CRM_API_V1_RESOURCES","args","handleContacts","handleCompanies","handlePipelines","handleDeals","handleTasks","handleActivities","handleLeads","handleEmailTemplates","registerCrmConsoleServerDeclarations","resource","handle","entitlement","describe","CRM_API_V1_DESCRIPTIONS","pluginId","ctx","crmUsageFigures","includes","request","crmContainerDetacher"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,0CAA0C;AAC1C,SAEEA,qBAAqB,EACrBC,yBAAyB,QACpB,mDAAkD;AACzD,SACEC,6BAA6B,EAC7BC,gCAAgC,QAC3B,uDAAsD;AAC7D,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;CAKC,GACD,MAAMC,YAAY;IAChBC,SAAS;IACTC,SACE,oEACA;AACJ;AAEA;;;;;;CAMC,GACD,MAAMC,uBAA+E;IACnF;QAAC;QAAY,OAAO,GAAGC,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,8BAA0B,EAAGC,cAAc,IAAID;KAAM;IACnG;QAAC;QAAa,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGE,eAAe,IAAIF;KAAM;IAC1G;QAAC;QAAa,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGG,eAAe,IAAIH;KAAM;IAC1G;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGI,WAAW,IAAIJ;KAAM;IAC9F;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGK,WAAW,IAAIL;KAAM;IAC9F;QAAC;QAAc,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,oCAAgC,EAAGM,gBAAgB,IAAIN;KAAM;IAC7G;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGO,WAAW,IAAIP;KAAM;IAC9F;QACE;QACA,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGQ,oBAAoB,IAAIR;KAClG;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASS;IACd,KAAK,MAAM,CAACC,UAAUC,OAAO,IAAIZ,qBAAsB;QACrDR,sBACEmB,UACA;YACEC;YACAC,aAAahB;YACbiB,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGC,uBAAuB,CAACJ,SAAS;QAC/E,GACA;YAAEK,UAAUpB;QAAU;IAE1B;IACAH,0BACE,OAAOwB,MAAQ,AAAC,CAAA,MAAM,MAAM,CAAC,2BAAuB,EAAGC,eAAe,CAACD,MACvE;QAAED,UAAUpB;IAAU;IAExB,4EAA4E;IAC5E,mEAAmE;IACnE,IAAI,CAACF,gCAAgCyB,QAAQ,CAACvB,YAAY;QACxDD,iCACE,OAAOyB,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGC,oBAAoB,CAACD,UACpF;YAAEJ,UAAUpB;QAAU;IAE1B;AACF"}
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { type PluginContactCaptureWriter } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture';
18
18
  import { type PluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index';
19
+ import { type PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
19
20
  /**
20
21
  * The CRM as the plugin that keeps people.
21
22
  *
@@ -31,6 +32,11 @@ export declare const crmContactCaptureWriter: PluginContactCaptureWriter;
31
32
  * `server/pipeline-record-index.ts` for what it answers.
32
33
  */
33
34
  export declare const crmPipelineRecordIndex: PluginRecordIndex;
35
+ /**
36
+ * The CRM's writer on the record-timeline seam (AGL-2981), deferred: the
37
+ * writer and the Admin SDK it brings load with the first entry filed.
38
+ */
39
+ export declare const crmRecordTimelineWriter: PluginRecordTimelineWriter;
34
40
  /**
35
41
  * The plugin's SERVER declarations: what the server must know at boot, before
36
42
  * any door of the CRM has been called.
@@ -21,8 +21,10 @@ import { registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manage
21
21
  import { registerPluginPersonMatcher } from "@aglyn/aglyn/plugin-manager/plugin-person-matches";
22
22
  import { registerPluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
23
23
  import { registerPluginRecordEmailStateWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
24
+ import { registerPluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
24
25
  import { registerPluginRecordWrittenListener } from "@aglyn/aglyn/plugin-manager/plugin-record-written";
25
- import { BUNDLE_ID } from "./constants/bundle-common.js";
26
+ import { registerPluginUsageMeter } from "@aglyn/aglyn/plugin-manager/plugin-usage-meters";
27
+ import { BUNDLE_ID, CRM_RECORDS_METER_ID } from "./constants/bundle-common.js";
26
28
  import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
27
29
  /**
28
30
  * The CRM as the plugin that keeps people.
@@ -51,6 +53,23 @@ import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
51
53
  return pipelineRecordIndex.get(request);
52
54
  }
53
55
  };
56
+ /**
57
+ * The CRM's writer on the record-timeline seam (AGL-2981), deferred: the
58
+ * writer and the Admin SDK it brings load with the first entry filed.
59
+ */ export const crmRecordTimelineWriter = {
60
+ async logActivity (request) {
61
+ const { createCrmRecordTimelineWriter, defaultCrmRecordTimelineDeps } = await import("./server/record-timeline.js");
62
+ return createCrmRecordTimelineWriter(defaultCrmRecordTimelineDeps()).logActivity(request);
63
+ },
64
+ async createTask (request) {
65
+ const { createCrmRecordTimelineWriter, defaultCrmRecordTimelineDeps } = await import("./server/record-timeline.js");
66
+ return createCrmRecordTimelineWriter(defaultCrmRecordTimelineDeps()).createTask(request);
67
+ },
68
+ async recordEmailDelivery (request) {
69
+ const { createCrmRecordTimelineWriter, defaultCrmRecordTimelineDeps } = await import("./server/record-timeline.js");
70
+ return createCrmRecordTimelineWriter(defaultCrmRecordTimelineDeps()).recordEmailDelivery(request);
71
+ }
72
+ };
54
73
  /**
55
74
  * The plugin's SERVER declarations: what the server must know at boot, before
56
75
  * any door of the CRM has been called.
@@ -116,10 +135,29 @@ import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
116
135
  async stamp (request) {
117
136
  const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } = await import("./server/record-email-state.js");
118
137
  return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).stamp(request);
138
+ },
139
+ // A send's first opens and clicks, stamped on the contacts they name
140
+ // (AGL-2616) — the campaign webhook's courtesy, deferred like the rest.
141
+ async engaged (request) {
142
+ const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } = await import("./server/record-email-state.js");
143
+ return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).engaged(request);
144
+ },
145
+ // A delivered campaign's leads, moved to Nurturing (AGL-3446).
146
+ async reached (request) {
147
+ const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } = await import("./server/record-email-state.js");
148
+ return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).reached(request);
119
149
  }
120
150
  }, {
121
151
  pluginId: BUNDLE_ID
122
152
  });
153
+ // The record timeline writer (AGL-2981), here as well as in the API
154
+ // surfaces so a door in either app reaches it: a booking confirmed on the
155
+ // tenant, or by the payment webhook, files its meeting and follow-up
156
+ // through it (AGL-2660). Deferred like the rest; an API registration
157
+ // replaces this one with the same writer, loaded eagerly.
158
+ registerPluginRecordTimelineWriter(crmRecordTimelineWriter, {
159
+ pluginId: BUNDLE_ID
160
+ });
123
161
  // Sharing rules (AGL-3336), re-evaluated after every server write of a
124
162
  // lead, a contact, a company or a deal: the core tells its record-written
125
163
  // listeners from the list-field restamp every such writer ends with, in
@@ -151,6 +189,18 @@ import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
151
189
  }, {
152
190
  pluginId: BUNDLE_ID
153
191
  });
192
+ // The records band in the monthly usage sweep, a core cron that never
193
+ // loads a CRM door; declared in `usageAxes` so the sweep refuses to bill a
194
+ // month without it. Deferred like the rest: the counts load with the
195
+ // first sweep.
196
+ registerPluginUsageMeter({
197
+ pluginId: BUNDLE_ID,
198
+ id: CRM_RECORDS_METER_ID,
199
+ measure: async (context)=>{
200
+ const { measureCrmRecords } = await import("./server/crm-records-meter.js");
201
+ return measureCrmRecords(context);
202
+ }
203
+ });
154
204
  }
155
205
 
156
206
  //# sourceMappingURL=declarations.server.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport { registerPluginConsentGroupParticipant } from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport {\n registerPluginRecordIndex,\n type PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { registerPluginRecordEmailStateWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { registerPluginRecordWrittenListener } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { summarizeConsentGroupChange } from './model/consent-group-summary'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The `pipeline` record index, its reads loaded with the first one — see\n * `server/pipeline-record-index.ts` for what it answers.\n */\nexport const crmPipelineRecordIndex: PluginRecordIndex = {\n async list(request) {\n const { pipelineRecordIndex } = await import('./server/pipeline-record-index')\n return pipelineRecordIndex.list(request)\n },\n async get(request) {\n const { pipelineRecordIndex } = await import('./server/pipeline-record-index')\n return pipelineRecordIndex.get(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // The CRM's share of a consent group change (AGL-3320): a contact's\n // per-site refusal carried across a separation, and the records keyed by a\n // group re-homed after the flip. Registered whether or not a workspace has\n // the CRM switched on, because the contacts — and the refusals on them —\n // outlive the switch. Deferred like the rest; the summary is plain words\n // and answers synchronously.\n // The record email-state writer (AGL-3245), here as well as in the console\n // API so the tenant's doors reach it too: a form submission's capture is\n // checked for deliverability after its response (AGL-3328), and the \"Would\n // bounce\" that check finds is written through this seam. Deferred like the\n // rest; the console's own registration replaces this one with the same\n // writer, loaded eagerly.\n registerPluginRecordEmailStateWriter(\n {\n async stamp(request) {\n const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } =\n await import('./server/record-email-state')\n return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).stamp(request)\n },\n },\n { pluginId: BUNDLE_ID },\n )\n // Sharing rules (AGL-3336), re-evaluated after every server write of a\n // lead, a contact, a company or a deal: the core tells its record-written\n // listeners from the list-field restamp every such writer ends with, in\n // both apps — a form's capture on the tenant, an edit in the console.\n // Deferred like the rest: an org with no rules pays one cached read.\n registerPluginRecordWrittenListener(\n async (event) => {\n const { crmSharingRecordWritten } = await import('./server/crm-sharing')\n await crmSharingRecordWritten(event)\n },\n { pluginId: BUNDLE_ID, key: 'sharing' },\n )\n // The organization's pipelines and their stages, as another plugin reads\n // them (AGL-3080): an automation the AI drafts names the stage a deal\n // moves to. Deferred like the rest: the reads load with the first one.\n registerPluginRecordIndex('pipeline', crmPipelineRecordIndex, { pluginId: BUNDLE_ID })\n registerPluginConsentGroupParticipant(\n {\n async preview(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.preview(request)\n },\n async run(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.run(request)\n },\n summarize: summarizeConsentGroupChange,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\n"],"names":["registerPluginConsentGroupParticipant","registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","registerPluginRecordIndex","registerPluginRecordEmailStateWriter","registerPluginRecordWrittenListener","BUNDLE_ID","summarizeConsentGroupChange","crmContactCaptureWriter","capture","request","captureContactForCrm","crmPipelineRecordIndex","list","pipelineRecordIndex","get","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople","stamp","createCrmRecordEmailStateWriter","defaultCrmRecordEmailStateDeps","event","crmSharingRecordWritten","key","preview","consentGroupParticipant","run","summarize"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport { registerPluginConsentGroupParticipant } from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport {\n registerPluginRecordIndex,\n type PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { registerPluginRecordEmailStateWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n registerPluginRecordTimelineWriter,\n type PluginRecordTimelineWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { registerPluginRecordWrittenListener } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { registerPluginUsageMeter } from '@aglyn/aglyn/plugin-manager/plugin-usage-meters'\nimport { BUNDLE_ID, CRM_RECORDS_METER_ID } from './constants/bundle-common'\nimport { summarizeConsentGroupChange } from './model/consent-group-summary'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The `pipeline` record index, its reads loaded with the first one — see\n * `server/pipeline-record-index.ts` for what it answers.\n */\nexport const crmPipelineRecordIndex: PluginRecordIndex = {\n async list(request) {\n const { pipelineRecordIndex } = await import('./server/pipeline-record-index')\n return pipelineRecordIndex.list(request)\n },\n async get(request) {\n const { pipelineRecordIndex } = await import('./server/pipeline-record-index')\n return pipelineRecordIndex.get(request)\n },\n}\n\n/**\n * The CRM's writer on the record-timeline seam (AGL-2981), deferred: the\n * writer and the Admin SDK it brings load with the first entry filed.\n */\nexport const crmRecordTimelineWriter: PluginRecordTimelineWriter = {\n async logActivity(request) {\n const { createCrmRecordTimelineWriter, defaultCrmRecordTimelineDeps } =\n await import('./server/record-timeline')\n return createCrmRecordTimelineWriter(defaultCrmRecordTimelineDeps()).logActivity(request)\n },\n async createTask(request) {\n const { createCrmRecordTimelineWriter, defaultCrmRecordTimelineDeps } =\n await import('./server/record-timeline')\n return createCrmRecordTimelineWriter(defaultCrmRecordTimelineDeps()).createTask(request)\n },\n async recordEmailDelivery(request) {\n const { createCrmRecordTimelineWriter, defaultCrmRecordTimelineDeps } =\n await import('./server/record-timeline')\n return createCrmRecordTimelineWriter(defaultCrmRecordTimelineDeps()).recordEmailDelivery!(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // The CRM's share of a consent group change (AGL-3320): a contact's\n // per-site refusal carried across a separation, and the records keyed by a\n // group re-homed after the flip. Registered whether or not a workspace has\n // the CRM switched on, because the contacts — and the refusals on them —\n // outlive the switch. Deferred like the rest; the summary is plain words\n // and answers synchronously.\n // The record email-state writer (AGL-3245), here as well as in the console\n // API so the tenant's doors reach it too: a form submission's capture is\n // checked for deliverability after its response (AGL-3328), and the \"Would\n // bounce\" that check finds is written through this seam. Deferred like the\n // rest; the console's own registration replaces this one with the same\n // writer, loaded eagerly.\n registerPluginRecordEmailStateWriter(\n {\n async stamp(request) {\n const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } =\n await import('./server/record-email-state')\n return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).stamp(request)\n },\n // A send's first opens and clicks, stamped on the contacts they name\n // (AGL-2616) — the campaign webhook's courtesy, deferred like the rest.\n async engaged(request) {\n const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } =\n await import('./server/record-email-state')\n return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).engaged!(request)\n },\n // A delivered campaign's leads, moved to Nurturing (AGL-3446).\n async reached(request) {\n const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } =\n await import('./server/record-email-state')\n return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).reached!(request)\n },\n },\n { pluginId: BUNDLE_ID },\n )\n // The record timeline writer (AGL-2981), here as well as in the API\n // surfaces so a door in either app reaches it: a booking confirmed on the\n // tenant, or by the payment webhook, files its meeting and follow-up\n // through it (AGL-2660). Deferred like the rest; an API registration\n // replaces this one with the same writer, loaded eagerly.\n registerPluginRecordTimelineWriter(crmRecordTimelineWriter, { pluginId: BUNDLE_ID })\n // Sharing rules (AGL-3336), re-evaluated after every server write of a\n // lead, a contact, a company or a deal: the core tells its record-written\n // listeners from the list-field restamp every such writer ends with, in\n // both apps — a form's capture on the tenant, an edit in the console.\n // Deferred like the rest: an org with no rules pays one cached read.\n registerPluginRecordWrittenListener(\n async (event) => {\n const { crmSharingRecordWritten } = await import('./server/crm-sharing')\n await crmSharingRecordWritten(event)\n },\n { pluginId: BUNDLE_ID, key: 'sharing' },\n )\n // The organization's pipelines and their stages, as another plugin reads\n // them (AGL-3080): an automation the AI drafts names the stage a deal\n // moves to. Deferred like the rest: the reads load with the first one.\n registerPluginRecordIndex('pipeline', crmPipelineRecordIndex, { pluginId: BUNDLE_ID })\n registerPluginConsentGroupParticipant(\n {\n async preview(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.preview(request)\n },\n async run(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.run(request)\n },\n summarize: summarizeConsentGroupChange,\n },\n { pluginId: BUNDLE_ID },\n )\n // The records band in the monthly usage sweep, a core cron that never\n // loads a CRM door; declared in `usageAxes` so the sweep refuses to bill a\n // month without it. Deferred like the rest: the counts load with the\n // first sweep.\n registerPluginUsageMeter({\n pluginId: BUNDLE_ID,\n id: CRM_RECORDS_METER_ID,\n measure: async (context) => {\n const { measureCrmRecords } = await import('./server/crm-records-meter')\n return measureCrmRecords(context)\n },\n })\n}\n\n"],"names":["registerPluginConsentGroupParticipant","registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","registerPluginRecordIndex","registerPluginRecordEmailStateWriter","registerPluginRecordTimelineWriter","registerPluginRecordWrittenListener","registerPluginUsageMeter","BUNDLE_ID","CRM_RECORDS_METER_ID","summarizeConsentGroupChange","crmContactCaptureWriter","capture","request","captureContactForCrm","crmPipelineRecordIndex","list","pipelineRecordIndex","get","crmRecordTimelineWriter","logActivity","createCrmRecordTimelineWriter","defaultCrmRecordTimelineDeps","createTask","recordEmailDelivery","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople","stamp","createCrmRecordEmailStateWriter","defaultCrmRecordEmailStateDeps","engaged","reached","event","crmSharingRecordWritten","key","preview","consentGroupParticipant","run","summarize","id","measure","context","measureCrmRecords"],"mappings":"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"}