@aglyn/plugins-crm 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +14 -14
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/components/companies-bulk-bar.js +12 -4
- package/src/lib/components/companies-bulk-bar.js.map +1 -1
- package/src/lib/components/company-detail-page.js +11 -0
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +12 -2
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/company-picker.js +10 -3
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +4 -3
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.d.ts +4 -4
- package/src/lib/components/contact-detail-page.js +50 -53
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -0
- package/src/lib/components/contact-list-columns.js +21 -5
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-merge-dialog.js +2 -1
- package/src/lib/components/contact-merge-dialog.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +4 -3
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
- package/src/lib/components/contacts-bulk-bar.js +24 -51
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +9 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-booking-zone.d.ts +8 -2
- package/src/lib/components/crm-booking-zone.js.map +1 -1
- package/src/lib/components/crm-export-all-button.d.ts +1 -1
- package/src/lib/components/crm-export-all-button.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +2 -1
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/crm-send-email-dialog.js +9 -6
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +2 -2
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +18 -2
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +10 -0
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deal-products-card.js +27 -16
- package/src/lib/components/deal-products-card.js.map +1 -1
- package/src/lib/components/deals-bulk-bar.js +13 -5
- package/src/lib/components/deals-bulk-bar.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +4 -4
- package/src/lib/components/lead-campaigns-card.js +15 -8
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +25 -5
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +10 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-status-chip.js +3 -1
- package/src/lib/components/lead-status-chip.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.js +4 -5
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +6 -0
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +34 -11
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +41 -8
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +5 -4
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/org-lead-surfaces-note.js +6 -2
- package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
- package/src/lib/components/recipes-card.js +9 -4
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/record-sharing-card.d.ts +57 -0
- package/src/lib/components/record-sharing-card.js +244 -0
- package/src/lib/components/record-sharing-card.js.map +1 -0
- package/src/lib/components/reports/activity-card.js +4 -3
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/components/reports/closed-deals-card.js +4 -3
- package/src/lib/components/reports/closed-deals-card.js.map +1 -1
- package/src/lib/components/reports/contacts-mix-card.js +3 -2
- package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
- package/src/lib/components/reports/contacts-trend-card.js +2 -1
- package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
- package/src/lib/components/reports/forecast-card.js +3 -2
- package/src/lib/components/reports/forecast-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +8 -6
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +4 -3
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/components/reports/report-format.d.ts +1 -1
- package/src/lib/components/reports/report-format.js.map +1 -1
- package/src/lib/components/reports/report-scope.d.ts +1 -1
- package/src/lib/components/reports/report-scope.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +2 -1
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/reports/tasks-card.js +2 -1
- package/src/lib/components/reports/tasks-card.js.map +1 -1
- package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
- package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
- package/src/lib/components/reports-section.js +4 -4
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/components/settings-section.js +7 -0
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/components/share-records-dialog.d.ts +28 -0
- package/src/lib/components/share-records-dialog.js +184 -0
- package/src/lib/components/share-records-dialog.js.map +1 -0
- package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
- package/src/lib/components/sharing-rule-drawer.js +374 -0
- package/src/lib/components/sharing-rule-drawer.js.map +1 -0
- package/src/lib/components/sharing-rules-card.d.ts +24 -0
- package/src/lib/components/sharing-rules-card.js +287 -0
- package/src/lib/components/sharing-rules-card.js.map +1 -0
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.js.map +1 -1
- package/src/lib/components/use-site-route-context.d.ts +25 -0
- package/src/lib/components/use-site-route-context.js +58 -0
- package/src/lib/components/use-site-route-context.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +31 -0
- package/src/lib/constants/api-routes.js +27 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +2 -0
- package/src/lib/constants/bundle-common.js +1 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +41 -0
- package/src/lib/declarations.console-server.js +115 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +12 -0
- package/src/lib/declarations.server.js +84 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-contact-remove.d.ts +35 -0
- package/src/lib/hooks/use-contact-remove.js +77 -0
- package/src/lib/hooks/use-contact-remove.js.map +1 -0
- package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
- package/src/lib/hooks/use-crm-campaigns.js +3 -3
- package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
- package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
- package/src/lib/hooks/use-crm-sharing.js +109 -0
- package/src/lib/hooks/use-crm-sharing.js.map +1 -0
- package/src/lib/model/campaign-filing-activity.d.ts +1 -1
- package/src/lib/model/campaign-filing-activity.js +1 -1
- package/src/lib/model/campaign-filing-activity.js.map +1 -1
- package/src/lib/model/companies-csv.d.ts +1 -1
- package/src/lib/model/companies-csv.js +2 -2
- package/src/lib/model/companies-csv.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +68 -0
- package/src/lib/model/contact-holder.js +129 -0
- package/src/lib/model/contact-holder.js.map +1 -0
- package/src/lib/model/contact-interaction-links.d.ts +46 -0
- package/src/lib/model/contact-interaction-links.js +65 -0
- package/src/lib/model/contact-interaction-links.js.map +1 -0
- package/src/lib/model/contact-merge.d.ts +136 -0
- package/src/lib/model/contact-merge.js +412 -0
- package/src/lib/model/contact-merge.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +7 -1
- package/src/lib/model/contact-record.js +7 -6
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-remove.d.ts +48 -0
- package/src/lib/model/contact-remove.js +22 -0
- package/src/lib/model/contact-remove.js.map +1 -0
- package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
- package/src/lib/model/contacts-bulk-writes.js +2 -82
- package/src/lib/model/contacts-bulk-writes.js.map +1 -1
- package/src/lib/model/contacts-csv.d.ts +2 -2
- package/src/lib/model/contacts-csv.js +2 -2
- package/src/lib/model/contacts-csv.js.map +1 -1
- package/src/lib/model/crm-csv.d.ts +239 -0
- package/src/lib/model/crm-csv.js +467 -0
- package/src/lib/model/crm-csv.js.map +1 -0
- package/src/lib/model/crm-digest.d.ts +227 -0
- package/src/lib/model/crm-digest.js +336 -0
- package/src/lib/model/crm-digest.js.map +1 -0
- package/src/lib/model/crm-import.d.ts +7 -1
- package/src/lib/model/crm-import.js +2 -1
- package/src/lib/model/crm-import.js.map +1 -1
- package/src/lib/model/crm-inbound.d.ts +162 -0
- package/src/lib/model/crm-inbound.js +208 -0
- package/src/lib/model/crm-inbound.js.map +1 -0
- package/src/lib/model/crm-lead-import.d.ts +1 -1
- package/src/lib/model/crm-lead-import.js +4 -4
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/crm-record-routes.d.ts +5 -0
- package/src/lib/model/crm-record-routes.js +7 -0
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/crm-reports.d.ts +467 -0
- package/src/lib/model/crm-reports.js +667 -0
- package/src/lib/model/crm-reports.js.map +1 -0
- package/src/lib/model/crm-routes.d.ts +6 -5
- package/src/lib/model/crm-routes.js +7 -7
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/model/crm-search-sources.d.ts +15 -0
- package/src/lib/model/crm-search-sources.js +162 -0
- package/src/lib/model/crm-search-sources.js.map +1 -0
- package/src/lib/model/crm-sharing-api.d.ts +32 -0
- package/src/lib/model/crm-sharing-api.js +59 -0
- package/src/lib/model/crm-sharing-api.js.map +1 -0
- package/src/lib/model/crm-sharing.d.ts +333 -0
- package/src/lib/model/crm-sharing.js +603 -0
- package/src/lib/model/crm-sharing.js.map +1 -0
- package/src/lib/model/deals-csv.d.ts +1 -1
- package/src/lib/model/deals-csv.js +4 -5
- package/src/lib/model/deals-csv.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +5 -4
- package/src/lib/model/lead-filters.js +7 -5
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/leads-csv.d.ts +1 -1
- package/src/lib/model/leads-csv.js +1 -1
- package/src/lib/model/leads-csv.js.map +1 -1
- package/src/lib/model/tasks-csv.d.ts +1 -1
- package/src/lib/model/tasks-csv.js +1 -1
- package/src/lib/model/tasks-csv.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +75 -0
- package/src/lib/plan-entitlements.js +56 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +39 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
- package/src/lib/server/api-v1/contacts-merge.js +120 -0
- package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
- package/src/lib/server/api-v1/contacts.d.ts +18 -0
- package/src/lib/server/api-v1/contacts.js +1076 -0
- package/src/lib/server/api-v1/contacts.js.map +1 -0
- package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
- package/src/lib/server/api-v1/crm-activities.js +269 -0
- package/src/lib/server/api-v1/crm-activities.js.map +1 -0
- package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
- package/src/lib/server/api-v1/crm-companies.js +391 -0
- package/src/lib/server/api-v1/crm-companies.js.map +1 -0
- package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
- package/src/lib/server/api-v1/crm-deals.js +631 -0
- package/src/lib/server/api-v1/crm-deals.js.map +1 -0
- package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
- package/src/lib/server/api-v1/crm-email-templates.js +336 -0
- package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
- package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
- package/src/lib/server/api-v1/crm-leads.js +833 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -0
- package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
- package/src/lib/server/api-v1/crm-pipelines.js +146 -0
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
- package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
- package/src/lib/server/api-v1/crm-shared.js +436 -0
- package/src/lib/server/api-v1/crm-shared.js.map +1 -0
- package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
- package/src/lib/server/api-v1/crm-tasks.js +398 -0
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +29 -0
- package/src/lib/server/api-v1/openapi.js +985 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/usage.d.ts +26 -0
- package/src/lib/server/api-v1/usage.js +73 -0
- package/src/lib/server/api-v1/usage.js.map +1 -0
- package/src/lib/server/campaign-filing-activity.d.ts +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -11
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/capture-contact.js +15 -4
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/consent-group-participant.js +1 -1
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +33 -0
- package/src/lib/server/contact-email-engagement.js +146 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +91 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-remove.d.ts +75 -0
- package/src/lib/server/contact-remove.js +244 -0
- package/src/lib/server/contact-remove.js.map +1 -0
- package/src/lib/server/contact-update.js +4 -3
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.d.ts +47 -11
- package/src/lib/server/contacts-import.js +171 -71
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/contacts-merge.d.ts +1 -1
- package/src/lib/server/contacts-merge.js +3 -2
- package/src/lib/server/contacts-merge.js.map +1 -1
- package/src/lib/server/container-detach.d.ts +31 -0
- package/src/lib/server/container-detach.js +115 -0
- package/src/lib/server/container-detach.js.map +1 -0
- package/src/lib/server/convert-host-lead.d.ts +189 -0
- package/src/lib/server/convert-host-lead.js +518 -0
- package/src/lib/server/convert-host-lead.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +162 -0
- package/src/lib/server/crm-inbound-email.js +259 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-records-meter.d.ts +50 -0
- package/src/lib/server/crm-records-meter.js +126 -0
- package/src/lib/server/crm-records-meter.js.map +1 -0
- package/src/lib/server/crm-sharing.d.ts +108 -0
- package/src/lib/server/crm-sharing.js +790 -0
- package/src/lib/server/crm-sharing.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +101 -0
- package/src/lib/server/daily-digest-route.js +578 -0
- package/src/lib/server/daily-digest-route.js.map +1 -0
- package/src/lib/server/export-route.d.ts +60 -0
- package/src/lib/server/export-route.js +447 -0
- package/src/lib/server/export-route.js.map +1 -0
- package/src/lib/server/inbound-address.js +5 -3
- package/src/lib/server/inbound-address.js.map +1 -1
- package/src/lib/server/inbound-route.d.ts +74 -0
- package/src/lib/server/inbound-route.js +260 -0
- package/src/lib/server/inbound-route.js.map +1 -0
- package/src/lib/server/lead-campaign-carry.js +3 -3
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-convert.d.ts +1 -1
- package/src/lib/server/lead-convert.js +2 -2
- package/src/lib/server/lead-convert.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +1 -1
- package/src/lib/server/lead-create.js +10 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-nurturing.d.ts +23 -0
- package/src/lib/server/lead-nurturing.js +82 -0
- package/src/lib/server/lead-nurturing.js.map +1 -0
- package/src/lib/server/leads-import.js +7 -7
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/merge-contacts.d.ts +55 -0
- package/src/lib/server/merge-contacts.js +274 -0
- package/src/lib/server/merge-contacts.js.map +1 -0
- package/src/lib/server/pipeline-record-index.d.ts +36 -0
- package/src/lib/server/pipeline-record-index.js +87 -0
- package/src/lib/server/pipeline-record-index.js.map +1 -0
- package/src/lib/server/record-email-state.js +23 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-facts.js +2 -1
- package/src/lib/server/record-facts.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +12 -0
- package/src/lib/server/record-timeline.js +127 -15
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +82 -0
- package/src/lib/server/task-reminders-route.js +499 -0
- package/src/lib/server/task-reminders-route.js.map +1 -0
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +22 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server-deal-stage.js +2 -1
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.d.ts +5 -2
- package/src/lib/server.js +47 -2
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
- package/src/lib/testing/stand-in-site-record-routes.js +55 -0
- package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
- package/src/lib/usage-axes.d.ts +35 -0
- package/src/lib/usage-axes.js +75 -0
- package/src/lib/usage-axes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -24
- package/src/lib/components/use-emails-hub-path.js +0 -59
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-mix-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { contactPrimaryGroup } from '../../model/contact-record'\nimport { ReportBreakdown } from './report-breakdown'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { type AggregateRead, useWindowRead } from './use-aggregate-read'\n\n/**\n * How many contacts the source mix and the funnel are read from.\n *\n * These two charts need a FIELD off each contact — which facet sources it\n * carries, which lifecycle stage — and Firestore aggregates cannot group by\n * a map key, so the only way to draw them is to read documents. A thousand\n * is the same bound the contacts list carries, and past it the card SAYS it\n * is a sample rather than quietly drawing a thousand as if it were the org.\n */\nconst CONTACT_SAMPLE_CEILING = 1000\n\nexport interface ContactsMixCardProps {\n report: CrmReportScope\n /** The section's count of every contact this reader may see — what decides whether the read is a sample. */\n totalContacts: AggregateRead<number>\n}\n\n/**\n * Contacts by source, and the lifecycle funnel, from the newest thousand\n * contacts (AGL-2604).\n *\n * Both are read through THIS group's facet, the way every other field on\n * the contacts page is: a source is which capture surface this holder saw\n * the person through, and a stage is where this business's sales team put\n * them. Reading either off the top of the document would chart another\n * holder's records — the disclosure the facet shape exists to end.\n *\n * Newest by `createdAt` rather than by `updatedAt`, so that the sample is\n * \"the last thousand people captured\" — a population a reader can reason\n * about — and not \"the thousand most recently edited\", which is whichever\n * ones a bulk tag touched last.\n */\nexport function ContactsMixCard(props: ContactsMixCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, groupId, org, nowMs } = report\n const firestore = useFirestore()\n\n const sample = useWindowRead<Record<string, unknown>>(\n () =>\n query(\n scopedCollection(firestore, scope, 'contacts'),\n ...visibleToClause(tokens),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_SAMPLE_CEILING + 1),\n ),\n CONTACT_SAMPLE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'contacts:sample') },\n )\n const status = sample.status\n\n const mix = useMemo(() => {\n // Through the viewing group's facet under a site; at the organization\n // level (AGL-2630) through each person's own primary holder, so the mix\n // is of the profiles the capturing sites keep.\n const facets = sample.rows.map((row) =>\n Aglyn.readContactFacet(\n row,\n groupId ?? contactPrimaryGroup(row, org).groupId,\n ),\n )\n // A person captured two ways counts under both sources: the chart asks\n // \"how many people came through each door\", and a person who came\n // through two did.\n const sources = Aglyn.tally(\n facets.flatMap((facet) =>\n (Object.keys(facet.sources) as Aglyn.ContactSource[]).filter(\n (source) => facet.sources[source],\n ),\n ),\n (source) => source,\n )\n const stageCounts: Partial<Record<Aglyn.ContactLifecycleStage, number>> = {}\n let unstaged = 0\n for (const facet of facets) {\n const stage = facet.lifecycleStage\n if (Aglyn.isContactLifecycleStage(stage)) {\n stageCounts[stage] = (stageCounts[stage] ?? 0) + 1\n } else {\n unstaged += 1\n }\n }\n return { sources, funnel: Aglyn.funnelFromStages(stageCounts), unstaged }\n }, [sample, groupId, org])\n\n // A sample only once the read has settled: while the window is empty\n // every org \"exceeds\" zero rows.\n const sampled =\n status === 'success' &&\n (sample.truncated ||\n (totalContacts.value !== null && totalContacts.value > sample.rows.length))\n\n return (\n <CardDisplay\n header={'Sources and lifecycle'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#sources-and-lifecycle',\n excerpt:\n 'Which capture surfaces your contacts came through, and how many ' +\n 'have reached each lifecycle stage. Read from the newest thousand ' +\n 'contacts, through this site’s own view of each person.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {sampled ? (\n <Alert severity=\"info\">\n {`Read from the newest ${sample.rows.length.toLocaleString()} contacts${\n totalContacts.value !== null\n ? ` of ${totalContacts.value.toLocaleString()}`\n : ''\n } — a sample, not the whole list.`}\n </Alert>\n ) : null}\n <Section title={'By source'}>\n <ReportBreakdown\n rows={mix.sources.map((row) => ({\n key: row.key,\n label:\n Aglyn.CONTACT_SOURCE_LABELS[row.key as Aglyn.ContactSource] ??\n row.key,\n value: row.count,\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to count yet.'\n }\n />\n {mix.sources.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A person captured two ways counts under both.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Lifecycle funnel'}>\n <ReportBreakdown\n rows={mix.funnel.steps.map((step) => ({\n key: step.stage,\n label: step.label,\n value: step.reached,\n note:\n `${plural(step.count, 'person', 'people')} here now` +\n (step.conversion === null\n ? ''\n : ` · ${percent(step.conversion)} of the step before`),\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to place yet.'\n }\n />\n {sample.rows.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Each step counts everyone who reached it or went further. ${plural(\n mix.unstaged,\n 'contact',\n )} with no stage yet · ${mix.funnel.other.toLocaleString()} marked other.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsMixCard.displayName = 'ContactsMixCard'\n\nexport default ContactsMixCard\n"],"names":["Aglyn","CardDisplay","percent","Section","Alert","Stack","Typography","limit","orderBy","query","useMemo","useFirestore","contactPrimaryGroup","ReportBreakdown","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","CONTACT_SAMPLE_CEILING","ContactsMixCard","props","report","totalContacts","scope","tokens","groupId","org","nowMs","firestore","sample","cacheKey","status","mix","facets","rows","map","row","readContactFacet","sources","tally","flatMap","facet","Object","keys","filter","source","stageCounts","unstaged","stage","lifecycleStage","isContactLifecycleStage","funnel","funnelFromStages","sampled","truncated","value","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","toLocaleString","title","key","label","CONTACT_SOURCE_LABELS","count","emptyText","variant","color","steps","step","reached","note","conversion","other","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-mix-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { limit, orderBy, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { contactPrimaryGroup } from '../../model/contact-record'\nimport { ReportBreakdown } from './report-breakdown'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { type AggregateRead, useWindowRead } from './use-aggregate-read'\nimport { funnelFromStages, tally } from '../../model/crm-reports'\n\n/**\n * How many contacts the source mix and the funnel are read from.\n *\n * These two charts need a FIELD off each contact — which facet sources it\n * carries, which lifecycle stage — and Firestore aggregates cannot group by\n * a map key, so the only way to draw them is to read documents. A thousand\n * is the same bound the contacts list carries, and past it the card SAYS it\n * is a sample rather than quietly drawing a thousand as if it were the org.\n */\nconst CONTACT_SAMPLE_CEILING = 1000\n\nexport interface ContactsMixCardProps {\n report: CrmReportScope\n /** The section's count of every contact this reader may see — what decides whether the read is a sample. */\n totalContacts: AggregateRead<number>\n}\n\n/**\n * Contacts by source, and the lifecycle funnel, from the newest thousand\n * contacts (AGL-2604).\n *\n * Both are read through THIS group's facet, the way every other field on\n * the contacts page is: a source is which capture surface this holder saw\n * the person through, and a stage is where this business's sales team put\n * them. Reading either off the top of the document would chart another\n * holder's records — the disclosure the facet shape exists to end.\n *\n * Newest by `createdAt` rather than by `updatedAt`, so that the sample is\n * \"the last thousand people captured\" — a population a reader can reason\n * about — and not \"the thousand most recently edited\", which is whichever\n * ones a bulk tag touched last.\n */\nexport function ContactsMixCard(props: ContactsMixCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, groupId, org, nowMs } = report\n const firestore = useFirestore()\n\n const sample = useWindowRead<Record<string, unknown>>(\n () =>\n query(\n scopedCollection(firestore, scope, 'contacts'),\n ...visibleToClause(tokens),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_SAMPLE_CEILING + 1),\n ),\n CONTACT_SAMPLE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'contacts:sample') },\n )\n const status = sample.status\n\n const mix = useMemo(() => {\n // Through the viewing group's facet under a site; at the organization\n // level (AGL-2630) through each person's own primary holder, so the mix\n // is of the profiles the capturing sites keep.\n const facets = sample.rows.map((row) =>\n Aglyn.readContactFacet(\n row,\n groupId ?? contactPrimaryGroup(row, org).groupId,\n ),\n )\n // A person captured two ways counts under both sources: the chart asks\n // \"how many people came through each door\", and a person who came\n // through two did.\n const sources = tally(\n facets.flatMap((facet) =>\n (Object.keys(facet.sources) as Aglyn.ContactSource[]).filter(\n (source) => facet.sources[source],\n ),\n ),\n (source) => source,\n )\n const stageCounts: Partial<Record<Aglyn.ContactLifecycleStage, number>> = {}\n let unstaged = 0\n for (const facet of facets) {\n const stage = facet.lifecycleStage\n if (Aglyn.isContactLifecycleStage(stage)) {\n stageCounts[stage] = (stageCounts[stage] ?? 0) + 1\n } else {\n unstaged += 1\n }\n }\n return { sources, funnel: funnelFromStages(stageCounts), unstaged }\n }, [sample, groupId, org])\n\n // A sample only once the read has settled: while the window is empty\n // every org \"exceeds\" zero rows.\n const sampled =\n status === 'success' &&\n (sample.truncated ||\n (totalContacts.value !== null && totalContacts.value > sample.rows.length))\n\n return (\n <CardDisplay\n header={'Sources and lifecycle'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#sources-and-lifecycle',\n excerpt:\n 'Which capture surfaces your contacts came through, and how many ' +\n 'have reached each lifecycle stage. Read from the newest thousand ' +\n 'contacts, through this site’s own view of each person.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {sampled ? (\n <Alert severity=\"info\">\n {`Read from the newest ${sample.rows.length.toLocaleString()} contacts${\n totalContacts.value !== null\n ? ` of ${totalContacts.value.toLocaleString()}`\n : ''\n } — a sample, not the whole list.`}\n </Alert>\n ) : null}\n <Section title={'By source'}>\n <ReportBreakdown\n rows={mix.sources.map((row) => ({\n key: row.key,\n label:\n Aglyn.CONTACT_SOURCE_LABELS[row.key as Aglyn.ContactSource] ??\n row.key,\n value: row.count,\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to count yet.'\n }\n />\n {mix.sources.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A person captured two ways counts under both.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Lifecycle funnel'}>\n <ReportBreakdown\n rows={mix.funnel.steps.map((step) => ({\n key: step.stage,\n label: step.label,\n value: step.reached,\n note:\n `${plural(step.count, 'person', 'people')} here now` +\n (step.conversion === null\n ? ''\n : ` · ${percent(step.conversion)} of the step before`),\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No contacts to place yet.'\n }\n />\n {sample.rows.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Each step counts everyone who reached it or went further. ${plural(\n mix.unstaged,\n 'contact',\n )} with no stage yet · ${mix.funnel.other.toLocaleString()} marked other.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsMixCard.displayName = 'ContactsMixCard'\n\nexport default ContactsMixCard\n"],"names":["Aglyn","CardDisplay","percent","Section","Alert","Stack","Typography","limit","orderBy","query","useMemo","useFirestore","contactPrimaryGroup","ReportBreakdown","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","funnelFromStages","tally","CONTACT_SAMPLE_CEILING","ContactsMixCard","props","report","totalContacts","scope","tokens","groupId","org","nowMs","firestore","sample","cacheKey","status","mix","facets","rows","map","row","readContactFacet","sources","flatMap","facet","Object","keys","filter","source","stageCounts","unstaged","stage","lifecycleStage","isContactLifecycleStage","funnel","sampled","truncated","value","length","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","severity","toLocaleString","title","key","label","CONTACT_SOURCE_LABELS","count","emptyText","variant","color","steps","step","reached","note","conversion","other","displayName"],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
19
|
-
import {
|
|
19
|
+
import { pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
20
|
import { CardDisplay } from "@aglyn/shared-ui-jsx";
|
|
21
21
|
import { Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
|
|
22
22
|
import { Alert, Stack, Typography } from "@mui/material";
|
|
@@ -27,6 +27,7 @@ import { weekLabel } from "./report-format.js";
|
|
|
27
27
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
28
28
|
import { ReportStatTile } from "./report-stat-tile.js";
|
|
29
29
|
import { useAggregateRead } from "./use-aggregate-read.js";
|
|
30
|
+
import { deltaPercent, weekBuckets } from "../../model/crm-reports.js";
|
|
30
31
|
/**
|
|
31
32
|
* New contacts over the period, against the period before, week by week
|
|
32
33
|
* (AGL-2604).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-trend-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { deltaPercent, pluginDocsHelp, weekBuckets } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { getCountFromServer, query, Timestamp, where } from 'firebase/firestore'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { type AggregateRead, useAggregateRead } from './use-aggregate-read'\n\nexport interface ContactsTrendCardProps {\n report: CrmReportScope\n /** The section's one count of every contact this reader may see. */\n totalContacts: AggregateRead<number>\n}\n\ninterface ContactsTrend {\n /** New contacts in the period — the sum of the weeks. */\n current: number\n /** New contacts in the period before it. */\n previous: number\n weekly: Array<{ start: number; end: number; count: number }>\n}\n\n/**\n * New contacts over the period, against the period before, week by week\n * (AGL-2604).\n *\n * ## Every number here is a server count\n *\n * One `getCountFromServer` per week of the period and one for the previous\n * period — fourteen reads for ninety days, billed as index entries rather\n * than documents — and NO document is downloaded to be counted. The period\n * figure is the sum of its weeks rather than a fifteenth read, so the tile\n * and the bars cannot disagree. The contacts list's head-count learned the\n * same lesson (AGL-1706): a count taken from a capped listener saturates at\n * the cap, and the org this page is for is the one with more contacts than\n * any listener should carry.\n *\n * The range is on `createdAt`, the timestamp every contact writer stamps —\n * the server upsert with `serverTimestamp()`, the console's add-by-hand with\n * a `Date` — so \"new\" means first captured, not last touched.\n */\nexport function ContactsTrendCard(props: ContactsTrendCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const trend = useAggregateRead<ContactsTrend>(() => {\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const createdBetween = (from: number, to: number) =>\n getCountFromServer(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(from)),\n where('createdAt', '<', Timestamp.fromMillis(to)),\n ),\n ).then((snapshot) => snapshot.data().count)\n const buckets = weekBuckets(range.from, range.to)\n return Promise.all([\n createdBetween(range.previousFrom, range.previousTo),\n ...buckets.map((bucket) => createdBetween(bucket.start, bucket.end)),\n ]).then(([previous, ...weekly]) => ({\n previous,\n current: weekly.reduce((sum, count) => sum + count, 0),\n weekly: buckets.map((bucket, index) => ({\n ...bucket,\n count: weekly[index] ?? 0,\n })),\n }))\n }, [firestore, scope, tokens, range], { cacheKey: reportCacheKey(report, 'contacts:trend') })\n\n const figures = trend.value\n return (\n <CardDisplay\n header={'Contacts'}\n help={pluginDocsHelp('crmReports', {\n anchor: '#contacts',\n excerpt:\n 'New contacts in the period against the period before it, week ' +\n 'by week, and how many contacts this site can see in all. Counted ' +\n 'on the server by when each contact was first captured.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'New contacts'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? deltaPercent(figures.current, figures.previous) : null}\n note={\n figures\n ? figures.previous\n ? `${figures.previous.toLocaleString()} in the previous period`\n : 'none in the previous period'\n : undefined\n }\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Total contacts'}\n value={\n totalContacts.value === null\n ? null\n : totalContacts.value.toLocaleString()\n }\n note={'visible to this site'}\n href={routes.section('contacts')}\n />\n </Stack>\n {trend.status === 'error' || totalContacts.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The contact counts could not be read. The rest of the page is unaffected.'}\n </Alert>\n ) : null}\n <Section title={'New contacts per week'}>\n <ReportBarChart\n labels={(figures?.weekly ?? []).map((week) => weekLabel(week.start))}\n series={[\n {\n name: 'New contacts',\n color: 'primary.main',\n values: (figures?.weekly ?? []).map((week) => week.count),\n },\n ]}\n emptyText={\n trend.status === 'loading' ? 'Counting…' : 'Nothing in this period.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Each bar is seven days from the start of the period; the last ' +\n 'may be shorter.'}\n </Typography>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsTrendCard.displayName = 'ContactsTrendCard'\n\nexport default ContactsTrendCard\n"],"names":["deltaPercent","pluginDocsHelp","weekBuckets","CardDisplay","Section","Alert","Stack","Typography","getCountFromServer","query","Timestamp","where","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","ContactsTrendCard","props","report","totalContacts","scope","tokens","range","routes","firestore","trend","contacts","createdBetween","from","to","fromMillis","then","snapshot","data","count","buckets","Promise","all","previousFrom","previousTo","map","bucket","start","end","previous","weekly","current","reduce","sum","index","cacheKey","figures","value","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","deltaPct","note","undefined","href","section","status","severity","title","labels","week","series","name","color","values","emptyText","variant","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/contacts-trend-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport { getCountFromServer, query, Timestamp, where } from 'firebase/firestore'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ReportBarChart } from './report-bar-chart'\nimport { weekLabel } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { type AggregateRead, useAggregateRead } from './use-aggregate-read'\nimport { deltaPercent, weekBuckets } from '../../model/crm-reports'\n\nexport interface ContactsTrendCardProps {\n report: CrmReportScope\n /** The section's one count of every contact this reader may see. */\n totalContacts: AggregateRead<number>\n}\n\ninterface ContactsTrend {\n /** New contacts in the period — the sum of the weeks. */\n current: number\n /** New contacts in the period before it. */\n previous: number\n weekly: Array<{ start: number; end: number; count: number }>\n}\n\n/**\n * New contacts over the period, against the period before, week by week\n * (AGL-2604).\n *\n * ## Every number here is a server count\n *\n * One `getCountFromServer` per week of the period and one for the previous\n * period — fourteen reads for ninety days, billed as index entries rather\n * than documents — and NO document is downloaded to be counted. The period\n * figure is the sum of its weeks rather than a fifteenth read, so the tile\n * and the bars cannot disagree. The contacts list's head-count learned the\n * same lesson (AGL-1706): a count taken from a capped listener saturates at\n * the cap, and the org this page is for is the one with more contacts than\n * any listener should carry.\n *\n * The range is on `createdAt`, the timestamp every contact writer stamps —\n * the server upsert with `serverTimestamp()`, the console's add-by-hand with\n * a `Date` — so \"new\" means first captured, not last touched.\n */\nexport function ContactsTrendCard(props: ContactsTrendCardProps) {\n const { report, totalContacts } = props\n const { scope, tokens, range, routes } = report\n const firestore = useFirestore()\n\n const trend = useAggregateRead<ContactsTrend>(() => {\n const contacts = scopedCollection(firestore, scope, 'contacts')\n const createdBetween = (from: number, to: number) =>\n getCountFromServer(\n query(\n contacts,\n ...visibleToClause(tokens),\n where('createdAt', '>=', Timestamp.fromMillis(from)),\n where('createdAt', '<', Timestamp.fromMillis(to)),\n ),\n ).then((snapshot) => snapshot.data().count)\n const buckets = weekBuckets(range.from, range.to)\n return Promise.all([\n createdBetween(range.previousFrom, range.previousTo),\n ...buckets.map((bucket) => createdBetween(bucket.start, bucket.end)),\n ]).then(([previous, ...weekly]) => ({\n previous,\n current: weekly.reduce((sum, count) => sum + count, 0),\n weekly: buckets.map((bucket, index) => ({\n ...bucket,\n count: weekly[index] ?? 0,\n })),\n }))\n }, [firestore, scope, tokens, range], { cacheKey: reportCacheKey(report, 'contacts:trend') })\n\n const figures = trend.value\n return (\n <CardDisplay\n header={'Contacts'}\n help={pluginDocsHelp('crmReports', {\n anchor: '#contacts',\n excerpt:\n 'New contacts in the period against the period before it, week ' +\n 'by week, and how many contacts this site can see in all. Counted ' +\n 'on the server by when each contact was first captured.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'New contacts'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? deltaPercent(figures.current, figures.previous) : null}\n note={\n figures\n ? figures.previous\n ? `${figures.previous.toLocaleString()} in the previous period`\n : 'none in the previous period'\n : undefined\n }\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Total contacts'}\n value={\n totalContacts.value === null\n ? null\n : totalContacts.value.toLocaleString()\n }\n note={'visible to this site'}\n href={routes.section('contacts')}\n />\n </Stack>\n {trend.status === 'error' || totalContacts.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The contact counts could not be read. The rest of the page is unaffected.'}\n </Alert>\n ) : null}\n <Section title={'New contacts per week'}>\n <ReportBarChart\n labels={(figures?.weekly ?? []).map((week) => weekLabel(week.start))}\n series={[\n {\n name: 'New contacts',\n color: 'primary.main',\n values: (figures?.weekly ?? []).map((week) => week.count),\n },\n ]}\n emptyText={\n trend.status === 'loading' ? 'Counting…' : 'Nothing in this period.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Each bar is seven days from the start of the period; the last ' +\n 'may be shorter.'}\n </Typography>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nContactsTrendCard.displayName = 'ContactsTrendCard'\n\nexport default ContactsTrendCard\n"],"names":["pluginDocsHelp","CardDisplay","Section","Alert","Stack","Typography","getCountFromServer","query","Timestamp","where","useFirestore","ReportBarChart","weekLabel","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","deltaPercent","weekBuckets","ContactsTrendCard","props","report","totalContacts","scope","tokens","range","routes","firestore","trend","contacts","createdBetween","from","to","fromMillis","then","snapshot","data","count","buckets","Promise","all","previousFrom","previousTo","map","bucket","start","end","previous","weekly","current","reduce","sum","index","cacheKey","figures","value","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","label","toLocaleString","deltaPct","note","undefined","href","section","status","severity","title","labels","week","series","name","color","values","emptyText","variant","displayName"],"mappings":"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"}
|
|
@@ -27,6 +27,7 @@ import { collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-col
|
|
|
27
27
|
import { plural } from "./report-format.js";
|
|
28
28
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
29
29
|
import { useWindowRead } from "./use-aggregate-read.js";
|
|
30
|
+
import { currencyOfDeals, forecastByCloseMonth } from "../../model/crm-reports.js";
|
|
30
31
|
/**
|
|
31
32
|
* The same window the pipeline card reads, under the SAME cache key and
|
|
32
33
|
* the same dependencies, so the two cards cost one read between them —
|
|
@@ -73,12 +74,12 @@ const PIPELINE_CEILING = 20;
|
|
|
73
74
|
], {
|
|
74
75
|
cacheKey: reportCacheKey(report, 'pipeline:pipelines')
|
|
75
76
|
});
|
|
76
|
-
const forecast = useMemo(()=>
|
|
77
|
+
const forecast = useMemo(()=>forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs), [
|
|
77
78
|
dealWindow.rows,
|
|
78
79
|
pipelineWindow.rows,
|
|
79
80
|
nowMs
|
|
80
81
|
]);
|
|
81
|
-
const currency = useMemo(()=>
|
|
82
|
+
const currency = useMemo(()=>currencyOfDeals(dealWindow.rows), [
|
|
82
83
|
dealWindow.rows
|
|
83
84
|
]);
|
|
84
85
|
const read = dealWindow.status === 'success' && pipelineWindow.status === 'success';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/forecast-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Box,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\n\n/**\n * The same window the pipeline card reads, under the SAME cache key and\n * the same dependencies, so the two cards cost one read between them —\n * `useAggregateRead` joins a read already in the air for the key. The\n * numbers must match the pipeline card's query exactly or the key would\n * name two different questions.\n */\nconst OPEN_DEAL_CEILING = 1000\nconst PIPELINE_CEILING = 20\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface ForecastCardProps {\n report: CrmReportScope\n}\n\n/** `Sep 2026` — the month a column is. */\nfunction monthLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })\n}\n\n/**\n * Forecast by close month (AGL-2620): the open pipeline laid out by the\n * month each deal is expected to close, for the next six, per pipeline,\n * at face value and weighted by the stage.\n *\n * Not period-scoped, like the pipeline card and for the same reason: the\n * forecast is what is open NOW, looked at forward. Deals with no expected\n * close are their own row rather than a footnote — that row is the size\n * of the pipeline nobody has scheduled, which is the number the picker\n * of a close date most needs to see — and deals dated before this month\n * (overdue) or past the sixth (later) are rows too, so the column adds up\n * to the pipeline.\n */\nexport function ForecastCard(props: ForecastCardProps) {\n const { report } = props\n const { scope, tokens, nowMs } = report\n const firestore = useFirestore()\n\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(scopedCollection(firestore, scope, 'pipelines'), ...visibleToClause(tokens)),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n\n const forecast = useMemo(\n () => Aglyn.forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs),\n [dealWindow.rows, pipelineWindow.rows, nowMs],\n )\n const currency = useMemo(() => Aglyn.currencyOfDeals(dealWindow.rows), [dealWindow.rows])\n const read = dealWindow.status === 'success' && pipelineWindow.status === 'success'\n const failed = dealWindow.status === 'error' || pipelineWindow.status === 'error'\n\n // A pipeline with nothing open is still a column; an unknown pipeline id\n // is a column named for what it is.\n const columns = forecast.pipelines\n const manyColumns = columns.length > 1\n\n const rows: Array<{ key: string; label: string; cellOf: (row: Aglyn.PipelineForecast) => Aglyn.ForecastCell; total: Aglyn.ForecastCell; muted?: boolean }> = [\n {\n key: 'overdue',\n label: 'Before this month',\n cellOf: (row) => row.overdue,\n total: forecast.overdue,\n muted: true,\n },\n ...forecast.buckets.map((bucket, index) => ({\n key: bucket.key,\n label: monthLabel(bucket.start),\n cellOf: (row: Aglyn.PipelineForecast) => row.months[index],\n total: forecast.months[index],\n })),\n {\n key: 'later',\n label: 'Later',\n cellOf: (row) => row.later,\n total: forecast.later,\n muted: true,\n },\n {\n key: 'undated',\n label: 'No expected close',\n cellOf: (row) => row.undated,\n total: forecast.undated,\n muted: true,\n },\n ].filter((row) => row.key !== 'overdue' && row.key !== 'later' ? true : row.total.count > 0)\n\n const cell = (value: Aglyn.ForecastCell, code: string) =>\n value.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.disabled\">\n {'—'}\n </Typography>\n ) : (\n <Stack sx={{ lineHeight: 1.2 }}>\n <Typography variant=\"body2\">{money(value.amountCents, code)}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(value.weightedCents, code)} weighted · ${plural(value.count, 'deal')}`}\n </Typography>\n </Stack>\n )\n\n return (\n <CardDisplay\n header={'Forecast by close month'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#forecast-by-close-month',\n excerpt:\n 'Every open deal by the month it is expected to close, for the ' +\n 'next six months and per pipeline, at face value and weighted by ' +\n 'its stage. Deals with no expected close are their own row.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Typography variant=\"body2\" color=\"warning.main\">\n {'The forecast could not be read.'}\n </Typography>\n ) : !read ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ) : forecast.total.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : (\n <Box>\n <ScrollTable size=\"small\" aria-label=\"Forecast by close month\">\n <TableHead>\n <TableRow>\n <TableCell>{'Expected close'}</TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {column.name || 'Pipeline not visible'}\n </TableCell>\n ))}\n {manyColumns ? <TableCell align=\"right\">{'All pipelines'}</TableCell> : null}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key} data-forecast-row={row.key}>\n <TableCell>\n <Typography variant=\"body2\" color={row.muted ? 'text.secondary' : undefined}>\n {row.label}\n </Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(row.cellOf(column), currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(row.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n ))}\n <TableRow>\n <TableCell>\n <Typography variant=\"subtitle2\">{'Open pipeline'}</Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(column.total, currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(forecast.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n </TableBody>\n </ScrollTable>\n </Box>\n )}\n {read && currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the figures add them as numbers and show ${currency.currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {read && dealWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`From the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`}\n </Typography>\n ) : null}\n {read && pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are named.`}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nForecastCard.displayName = 'ForecastCard'\n\nexport default ForecastCard\n"],"names":["Aglyn","money","CardDisplay","ScrollTable","Box","Stack","TableBody","TableCell","TableHead","TableRow","Typography","limit","orderBy","query","where","useMemo","useFirestore","collectionCeiling","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","OPEN_DEAL_CEILING","PIPELINE_CEILING","monthLabel","startMs","Date","toLocaleDateString","undefined","month","year","ForecastCard","props","report","scope","tokens","nowMs","firestore","dealWindow","cacheKey","pipelineWindow","forecast","forecastByCloseMonth","rows","currency","currencyOfDeals","read","status","failed","columns","pipelines","manyColumns","length","key","label","cellOf","row","overdue","total","muted","buckets","map","bucket","index","start","months","later","undated","filter","count","cell","value","code","variant","color","sx","lineHeight","amountCents","weightedCents","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","size","aria-label","column","align","name","pipelineId","data-forecast-row","mixed","toUpperCase","truncated","toLocaleString","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/forecast-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { money } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Box,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collectionCeiling } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { plural } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { useWindowRead } from './use-aggregate-read'\nimport {\n currencyOfDeals,\n forecastByCloseMonth,\n type ForecastCell,\n type PipelineForecast,\n} from '../../model/crm-reports'\n\n/**\n * The same window the pipeline card reads, under the SAME cache key and\n * the same dependencies, so the two cards cost one read between them —\n * `useAggregateRead` joins a read already in the air for the key. The\n * numbers must match the pipeline card's query exactly or the key would\n * name two different questions.\n */\nconst OPEN_DEAL_CEILING = 1000\nconst PIPELINE_CEILING = 20\n\ntype DealRow = Aglyn.CrmDeal & { $id: string }\ntype PipelineRow = Aglyn.CrmPipeline & { $id: string }\n\nexport interface ForecastCardProps {\n report: CrmReportScope\n}\n\n/** `Sep 2026` — the month a column is. */\nfunction monthLabel(startMs: number): string {\n return new Date(startMs).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })\n}\n\n/**\n * Forecast by close month (AGL-2620): the open pipeline laid out by the\n * month each deal is expected to close, for the next six, per pipeline,\n * at face value and weighted by the stage.\n *\n * Not period-scoped, like the pipeline card and for the same reason: the\n * forecast is what is open NOW, looked at forward. Deals with no expected\n * close are their own row rather than a footnote — that row is the size\n * of the pipeline nobody has scheduled, which is the number the picker\n * of a close date most needs to see — and deals dated before this month\n * (overdue) or past the sixth (later) are rows too, so the column adds up\n * to the pipeline.\n */\nexport function ForecastCard(props: ForecastCardProps) {\n const { report } = props\n const { scope, tokens, nowMs } = report\n const firestore = useFirestore()\n\n const dealWindow = useWindowRead<DealRow>(\n () =>\n query(\n scopedCollection(firestore, scope, 'deals'),\n ...visibleToClause(tokens),\n where('status', '==', 'open'),\n orderBy('updatedAt', 'desc'),\n limit(OPEN_DEAL_CEILING + 1),\n ),\n OPEN_DEAL_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:deals') },\n )\n const pipelineWindow = useWindowRead<PipelineRow>(\n () =>\n collectionCeiling(\n query(scopedCollection(firestore, scope, 'pipelines'), ...visibleToClause(tokens)),\n PIPELINE_CEILING,\n ),\n PIPELINE_CEILING,\n [firestore, scope, tokens, nowMs],\n { cacheKey: reportCacheKey(report, 'pipeline:pipelines') },\n )\n\n const forecast = useMemo(\n () => forecastByCloseMonth(dealWindow.rows, pipelineWindow.rows, nowMs),\n [dealWindow.rows, pipelineWindow.rows, nowMs],\n )\n const currency = useMemo(() => currencyOfDeals(dealWindow.rows), [dealWindow.rows])\n const read = dealWindow.status === 'success' && pipelineWindow.status === 'success'\n const failed = dealWindow.status === 'error' || pipelineWindow.status === 'error'\n\n // A pipeline with nothing open is still a column; an unknown pipeline id\n // is a column named for what it is.\n const columns = forecast.pipelines\n const manyColumns = columns.length > 1\n\n const rows: Array<{ key: string; label: string; cellOf: (row: PipelineForecast) => ForecastCell; total: ForecastCell; muted?: boolean }> = [\n {\n key: 'overdue',\n label: 'Before this month',\n cellOf: (row) => row.overdue,\n total: forecast.overdue,\n muted: true,\n },\n ...forecast.buckets.map((bucket, index) => ({\n key: bucket.key,\n label: monthLabel(bucket.start),\n cellOf: (row: PipelineForecast) => row.months[index],\n total: forecast.months[index],\n })),\n {\n key: 'later',\n label: 'Later',\n cellOf: (row) => row.later,\n total: forecast.later,\n muted: true,\n },\n {\n key: 'undated',\n label: 'No expected close',\n cellOf: (row) => row.undated,\n total: forecast.undated,\n muted: true,\n },\n ].filter((row) => row.key !== 'overdue' && row.key !== 'later' ? true : row.total.count > 0)\n\n const cell = (value: ForecastCell, code: string) =>\n value.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.disabled\">\n {'—'}\n </Typography>\n ) : (\n <Stack sx={{ lineHeight: 1.2 }}>\n <Typography variant=\"body2\">{money(value.amountCents, code)}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(value.weightedCents, code)} weighted · ${plural(value.count, 'deal')}`}\n </Typography>\n </Stack>\n )\n\n return (\n <CardDisplay\n header={'Forecast by close month'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#forecast-by-close-month',\n excerpt:\n 'Every open deal by the month it is expected to close, for the ' +\n 'next six months and per pipeline, at face value and weighted by ' +\n 'its stage. Deals with no expected close are their own row.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n {failed ? (\n <Typography variant=\"body2\" color=\"warning.main\">\n {'The forecast could not be read.'}\n </Typography>\n ) : !read ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ) : forecast.total.count === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No open deals yet.'}\n </Typography>\n ) : (\n <Box>\n <ScrollTable size=\"small\" aria-label=\"Forecast by close month\">\n <TableHead>\n <TableRow>\n <TableCell>{'Expected close'}</TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {column.name || 'Pipeline not visible'}\n </TableCell>\n ))}\n {manyColumns ? <TableCell align=\"right\">{'All pipelines'}</TableCell> : null}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key} data-forecast-row={row.key}>\n <TableCell>\n <Typography variant=\"body2\" color={row.muted ? 'text.secondary' : undefined}>\n {row.label}\n </Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(row.cellOf(column), currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(row.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n ))}\n <TableRow>\n <TableCell>\n <Typography variant=\"subtitle2\">{'Open pipeline'}</Typography>\n </TableCell>\n {columns.map((column) => (\n <TableCell key={column.pipelineId} align=\"right\">\n {cell(column.total, currency.currency)}\n </TableCell>\n ))}\n {manyColumns ? (\n <TableCell align=\"right\">{cell(forecast.total, currency.currency)}</TableCell>\n ) : null}\n </TableRow>\n </TableBody>\n </ScrollTable>\n </Box>\n )}\n {read && currency.mixed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Deals are in more than one currency; the figures add them as numbers and show ${currency.currency.toUpperCase()}, the most common.`}\n </Typography>\n ) : null}\n {read && dealWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`From the ${OPEN_DEAL_CEILING.toLocaleString()} most recently updated open deals.`}\n </Typography>\n ) : null}\n {read && pipelineWindow.truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Only the first ${PIPELINE_CEILING} pipelines are named.`}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nForecastCard.displayName = 'ForecastCard'\n\nexport default ForecastCard\n"],"names":["Aglyn","money","CardDisplay","ScrollTable","Box","Stack","TableBody","TableCell","TableHead","TableRow","Typography","limit","orderBy","query","where","useMemo","useFirestore","collectionCeiling","plural","reportCacheKey","scopedCollection","visibleToClause","useWindowRead","currencyOfDeals","forecastByCloseMonth","OPEN_DEAL_CEILING","PIPELINE_CEILING","monthLabel","startMs","Date","toLocaleDateString","undefined","month","year","ForecastCard","props","report","scope","tokens","nowMs","firestore","dealWindow","cacheKey","pipelineWindow","forecast","rows","currency","read","status","failed","columns","pipelines","manyColumns","length","key","label","cellOf","row","overdue","total","muted","buckets","map","bucket","index","start","months","later","undated","filter","count","cell","value","code","variant","color","sx","lineHeight","amountCents","weightedCents","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","spacing","size","aria-label","column","align","name","pipelineId","data-forecast-row","mixed","toUpperCase","truncated","toLocaleString","displayName"],"mappings":"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"}
|
|
@@ -31,6 +31,7 @@ import { plural, reportFilename } from "./report-format.js";
|
|
|
31
31
|
import { reportCacheKey } from "./report-scope.js";
|
|
32
32
|
import { ReportStatTile } from "./report-stat-tile.js";
|
|
33
33
|
import { useAggregateRead, useWindowRead } from "./use-aggregate-read.js";
|
|
34
|
+
import { deltaPercent, leadFunnel } from "../../model/crm-reports.js";
|
|
34
35
|
/**
|
|
35
36
|
* How many of the period's leads the funnel is placed from — the Leads
|
|
36
37
|
* section's own window, over the period rather than over everything.
|
|
@@ -58,7 +59,9 @@ const LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {
|
|
|
58
59
|
* `visibleTo` is the site's tokens under a site, and `null` at the
|
|
59
60
|
* organization level where an org-wide member reads without a clause. The
|
|
60
61
|
* range stays a single-field one; with the scope clause it composes with the
|
|
61
|
-
* `visibleTo` + `firstSeenAtMs`
|
|
62
|
+
* `visibleTo` + `firstSeenAtMs` entries in `cloud/firebase-firestore.indexes.json`
|
|
63
|
+
* — the descending one for the window, the ascending one for the counts,
|
|
64
|
+
* which name no order (AGL-3443).
|
|
62
65
|
*/ function leadsBetween(firestore, orgId, visibleTo, from, to) {
|
|
63
66
|
return query(collection(firestore, 'orgs', orgId, 'leads'), ...visibleTo ? [
|
|
64
67
|
where('visibleTo', 'array-contains-any', [
|
|
@@ -162,7 +165,7 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
|
|
|
162
165
|
* out a quiet one has nothing left to balance. One aggregate and one
|
|
163
166
|
* bounded read, unscoped, as an org-wide member's reads are.
|
|
164
167
|
*/ function OrgLeadFunnelCard(props) {
|
|
165
|
-
var _ref
|
|
168
|
+
var _ref;
|
|
166
169
|
const { report } = props;
|
|
167
170
|
const { range } = report;
|
|
168
171
|
const firestore = useFirestore();
|
|
@@ -170,7 +173,6 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
|
|
|
170
173
|
const hostIds = useMemo(()=>(mount == null ? void 0 : mount.hostsReady) ? mount.hosts.map((host)=>host.id) : null, [
|
|
171
174
|
mount
|
|
172
175
|
]);
|
|
173
|
-
const key = (_ref = hostIds == null ? void 0 : hostIds.join('\n')) != null ? _ref : '';
|
|
174
176
|
/*
|
|
175
177
|
* ONE READ, not one per site (AGL-3275). The sites share a collection now,
|
|
176
178
|
* so a lead two brands in a consent group both hold would be counted once
|
|
@@ -209,7 +211,7 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
|
|
|
209
211
|
}, [
|
|
210
212
|
merged
|
|
211
213
|
]);
|
|
212
|
-
const sites = (
|
|
214
|
+
const sites = (_ref = hostIds == null ? void 0 : hostIds.length) != null ? _ref : 0;
|
|
213
215
|
const figures = captured.value;
|
|
214
216
|
const sampled = window.status === 'success' && (window.truncated || figures !== null && figures.current > window.rows.length);
|
|
215
217
|
return /*#__PURE__*/ _jsx(LeadFunnelBody, {
|
|
@@ -229,7 +231,7 @@ OrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard';
|
|
|
229
231
|
const { report, subheader, captured, window, caption, errorText } = props;
|
|
230
232
|
const { period, routes } = report;
|
|
231
233
|
const status = window.status;
|
|
232
|
-
const funnel = useMemo(()=>
|
|
234
|
+
const funnel = useMemo(()=>leadFunnel(window.rows), [
|
|
233
235
|
window
|
|
234
236
|
]);
|
|
235
237
|
const read = status === 'success';
|
|
@@ -300,7 +302,7 @@ OrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard';
|
|
|
300
302
|
/*#__PURE__*/ _jsx(ReportStatTile, {
|
|
301
303
|
label: 'Leads captured',
|
|
302
304
|
value: figures ? figures.current.toLocaleString() : null,
|
|
303
|
-
deltaPct: figures ?
|
|
305
|
+
deltaPct: figures ? deltaPercent(figures.current, figures.previous) : null,
|
|
304
306
|
note: 'first seen in the period',
|
|
305
307
|
href: routes.section('leads')
|
|
306
308
|
}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-funnel-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 {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\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 { useCrmOrgMount } from '../../hooks/use-crm-org-mount'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename } from './report-format'\nimport { type CrmReportScope, reportCacheKey } from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport {\n type AggregateRead,\n useAggregateRead,\n useWindowRead,\n type WindowRead,\n} from './use-aggregate-read'\n\n/**\n * How many of the period's leads the funnel is placed from — the Leads\n * section's own window, over the period rather than over everything.\n *\n * The captured count is a server aggregate and never meets this bound; the\n * funnel needs each lead's status and reason, so it reads the period's\n * newest two hundred and says when there were more. At the organization\n * level the bound is PER SITE (AGL-2634): each site's window is its own,\n * and a site with a thousand leads must not crowd out a site with ten.\n */\nexport const LEAD_CEILING = 200\n/** How many reasons the bar list draws; the rest are counted beneath it. */\nconst REASONS_SHOWN = 8\n\n/** The CSV's columns: one file for both the statuses and the reasons. */\nconst COLUMNS = ['Kind', 'Label', 'Count', 'Share'] as const\n\ntype LeadRow = Record<string, unknown> & Aglyn.CrmLeadFields & { $id: string }\n\n/** The period's captured count, and the previous period's for the delta. */\ninterface CapturedFigures {\n current: number\n previous: number\n}\n\nexport interface LeadFunnelCardProps {\n report: CrmReportScope\n /**\n * The site whose leads are placed. A lead lives under its host, not the\n * org — `hosts/{hostId}/leads`, private by path, no `visibleTo` — so the\n * report's org scope cannot reach it and the card is told the site. At\n * the organization level `null` (AGL-2630): the card then reads every\n * site the mount lists, one window each (AGL-2634).\n */\n hostId: string | null\n}\n\nconst LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#lead-funnel',\n excerpt:\n 'Of the leads this site captured in the period, how many are ' +\n 'still new, being worked, qualified or unqualified — and the ' +\n 'reasons the unqualified ones were closed. Placed by when each ' +\n 'lead was first seen.',\n})\n\n/**\n * The leads a scope may see, first seen in a range (AGL-3275).\n *\n * `visibleTo` is the site's tokens under a site, and `null` at the\n * organization level where an org-wide member reads without a clause. The\n * range stays a single-field one; with the scope clause it composes with the\n * `visibleTo` + `firstSeenAtMs` entry in `cloud/firebase-firestore.indexes.json`.\n */\nfunction leadsBetween(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | null,\n from: number,\n to: number,\n) {\n return query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...(visibleTo ? [where('visibleTo', 'array-contains-any', [...visibleTo])] : []),\n where('firstSeenAtMs', '>=', from),\n where('firstSeenAtMs', '<', to),\n )\n}\n\nconst countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * The lead funnel, at whichever level the report is mounted (AGL-2624,\n * AGL-2630, AGL-2634).\n *\n * Under a site: that site. At the organization level: every site the\n * mount lists, one window each, totaled — the org hub's reports total\n * every site, and the leads are no exception now that the fan-out is\n * bounded by the org's site list. With no sites to read the card says so\n * instead of guessing.\n */\nexport function LeadFunnelCard(props: LeadFunnelCardProps) {\n const { report, hostId } = props\n const mount = useCrmOrgMount()\n if (hostId) return <SiteLeadFunnelCard report={report} hostId={hostId} />\n if (mount && (!mount.hostsReady || mount.hosts.length > 0)) {\n return <OrgLeadFunnelCard report={report} />\n }\n return (\n <CardDisplay header={'Lead funnel'} help={LEAD_FUNNEL_HELP} contentGutterX contentGutterY>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {mount\n ? 'Leads live under a site, and this organization has no sites yet.'\n : 'Leads live under a site, and this report is mounted under none.'}\n </Typography>\n </CardDisplay>\n )\n}\n\n/**\n * The lead funnel (AGL-2624): of the leads this site captured in the\n * period, how many are still new, being worked, qualified or unqualified —\n * and why the unqualified ones were closed.\n *\n * A cohort by first sighting, followed to where each lead stands now,\n * because that is the question a period asks of leads — \"what became of\n * the ones we got\" — and because a lead's status has no history on the\n * document: only its current value. The window is a range on\n * `firstSeenAtMs`, which the capture door stamps once on creation and\n * never again, so a returning visitor's second form is not a second lead\n * in a second period. A single-field range needs no composite index.\n *\n * Read directly off `hosts/{hostId}/leads` under the site's own rules —\n * any member of the site may read it — the way the Leads section reads\n * it, so the funnel and the list can never disagree about who may see a\n * lead.\n */\nfunction SiteLeadFunnelCard(props: { report: CrmReportScope; hostId: string }) {\n const { report, hostId } = props\n const { range } = report\n const firestore = useFirestore()\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], report.tokens, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:counts:${hostId}`) },\n )\n const window = useWindowRead<LeadRow>(\n () =>\n query(\n leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n LEAD_CEILING,\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:window:${hostId}`) },\n )\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n (figures !== null ? ` of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'This site’s leads could not be read.'}\n />\n )\n}\nSiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard'\n\n/**\n * THE ORGANIZATION'S FUNNEL (AGL-2634, AGL-3275): every lead the org holds.\n *\n * This totaled one aggregate and one window PER SITE, because the sites'\n * lead collections were disjoint. They share one collection now, so a sum\n * would count a lead that two brands in a consent group both hold once for\n * each of them — and the merge that existed so a busy site could not crowd\n * out a quiet one has nothing left to balance. One aggregate and one\n * bounded read, unscoped, as an org-wide member's reads are.\n */\nfunction OrgLeadFunnelCard(props: { report: CrmReportScope }) {\n const { report } = props\n const { range } = report\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const hostIds = useMemo(\n () => (mount?.hostsReady ? mount.hosts.map((host) => host.id) : null),\n [mount],\n )\n const key = hostIds?.join('\\n') ?? ''\n\n /*\n * ONE READ, not one per site (AGL-3275). The sites share a collection now,\n * so a lead two brands in a consent group both hold would be counted once\n * for each of them — and the org's funnel would report more leads than the\n * org has. `null` tokens is the org-wide read that carries no clause.\n */\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], null, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], null, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:counts:org') },\n )\n const merged = useAggregateRead<{ rows: LeadRow[]; truncated: boolean }>(\n () =>\n getDocs(\n query(\n leadsBetween(firestore, report.scope[1], null, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n ).then((snapshot) =>\n ceilingedWindow(\n snapshot.docs.map(\n (document) => ({ ...document.data(), $id: document.id }) as unknown as LeadRow,\n ),\n LEAD_CEILING,\n ),\n ),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:window:org') },\n )\n const window = useMemo<WindowRead<LeadRow>>(\n () => ({\n rows: merged.value?.rows ?? [],\n truncated: merged.value?.truncated ?? false,\n status: merged.status,\n }),\n [merged],\n )\n const sites = hostIds?.length ?? 0\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n subheader={`Every site (${sites.toLocaleString()})`}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n ` — at most ${LEAD_CEILING.toLocaleString()}` +\n (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'The organization’s leads could not be read.'}\n />\n )\n}\nOrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard'\n\n/**\n * The funnel as drawn, whichever level fed it: four tiles, the statuses,\n * the reasons, the export. Pure over the two reads.\n */\nfunction LeadFunnelBody(props: {\n report: CrmReportScope\n subheader?: string\n captured: AggregateRead<CapturedFigures>\n window: WindowRead<LeadRow>\n caption?: string\n errorText: string\n}) {\n const { report, subheader, captured, window, caption, errorText } = props\n const { period, routes } = report\n const status = window.status\n\n const funnel = useMemo(() => Aglyn.leadFunnel(window.rows), [window])\n const read = status === 'success'\n const figures = captured.value\n const share = (count: number, of: number): string | null =>\n of > 0 ? percent(count / of) : null\n const reasons = funnel.reasons.slice(0, REASONS_SHOWN)\n const moreReasons = funnel.reasons.length - reasons.length\n\n return (\n <CardDisplay\n header={'Lead funnel'}\n subheader={subheader}\n help={LEAD_FUNNEL_HELP}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <ReportExport\n filename={reportFilename('lead-funnel', period)}\n columns={COLUMNS}\n rows={() => [\n ...Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => [\n 'Status',\n Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n funnel.byStatus[leadStatus],\n share(funnel.byStatus[leadStatus], funnel.total) ?? '',\n ]),\n ...funnel.reasons.map((reason) => [\n 'Unqualified reason',\n reason.label,\n reason.count,\n share(reason.count, funnel.byStatus.unqualified) ?? '',\n ]),\n ]}\n disabled={!read || !funnel.total}\n />\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('leads')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open leads'}\n </Button>\n </Stack>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Leads captured'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? Aglyn.deltaPercent(figures.current, figures.previous) : null}\n note={'first seen in the period'}\n href={routes.section('leads')}\n />\n <ReportStatTile\n label={'Qualified'}\n value={read ? funnel.byStatus.qualified.toLocaleString() : null}\n note={\n funnel.qualifiedRate === null\n ? read\n ? 'nobody captured in the period'\n : undefined\n : `${percent(funnel.qualifiedRate)} of those captured`\n }\n />\n <ReportStatTile\n label={'Unqualified'}\n value={read ? funnel.byStatus.unqualified.toLocaleString() : null}\n note={\n funnel.unqualifiedRate === null\n ? undefined\n : `${percent(funnel.unqualifiedRate)} of those captured`\n }\n riseIsGood={false}\n />\n <ReportStatTile\n label={'Still open'}\n value={read ? funnel.open.toLocaleString() : null}\n note={'new or being worked'}\n href={routes.section('leads')}\n />\n </Stack>\n {captured.status === 'error' || status === 'error' ? (\n <Alert severity=\"warning\">{errorText}</Alert>\n ) : null}\n <Section title={'Where they stand'}>\n <ReportBreakdown\n rows={Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => ({\n key: leadStatus,\n label: Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n value: funnel.byStatus[leadStatus],\n note: share(funnel.byStatus[leadStatus], funnel.total) ?? undefined,\n color:\n leadStatus === 'qualified'\n ? 'success.main'\n : leadStatus === 'unqualified'\n ? 'warning.main'\n : undefined,\n }))}\n emptyText={''}\n />\n {read && !funnel.total ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No leads captured in this period.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Why leads were unqualified'}>\n <ReportBreakdown\n rows={reasons.map((reason) => ({\n key: reason.key,\n label: reason.label,\n value: reason.count,\n note: share(reason.count, funnel.byStatus.unqualified) ?? undefined,\n color: 'warning.main',\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No lead was unqualified in this period.'\n }\n />\n {moreReasons > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`And ${plural(moreReasons, 'other reason')}, each given once or twice.`}\n </Typography>\n ) : null}\n </Section>\n {/* What the funnel, and so the export, is placed from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {caption}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nLeadFunnelBody.displayName = 'LeadFunnelBody'\nLeadFunnelCard.displayName = 'LeadFunnelCard'\n\nexport default LeadFunnelCard\n"],"names":["Aglyn","AppLink","CardDisplay","percent","Section","ceilingedWindow","Alert","Button","Stack","Typography","collection","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","useCrmOrgMount","ReportBreakdown","ReportExport","plural","reportFilename","reportCacheKey","ReportStatTile","useAggregateRead","useWindowRead","LEAD_CEILING","REASONS_SHOWN","COLUMNS","LEAD_FUNNEL_HELP","pluginDocsHelp","anchor","excerpt","leadsBetween","firestore","orgId","visibleTo","from","to","countOf","target","then","snapshot","data","count","LeadFunnelCard","props","report","hostId","mount","SiteLeadFunnelCard","hostsReady","hosts","length","OrgLeadFunnelCard","header","help","contentGutterX","contentGutterY","variant","color","range","captured","Promise","all","scope","tokens","previousFrom","previousTo","current","previous","cacheKey","window","figures","value","sampled","status","truncated","rows","LeadFunnelBody","caption","toLocaleString","undefined","errorText","displayName","hostIds","map","host","id","key","join","merged","docs","document","$id","sites","subheader","period","routes","funnel","leadFunnel","read","share","of","reasons","slice","moreReasons","HeaderProps","action","direction","spacing","filename","columns","CRM_LEAD_STATUSES","leadStatus","CRM_LEAD_STATUS_LABELS","byStatus","total","reason","label","unqualified","disabled","component","componentVariant","nativeButton","href","section","size","sx","flexWrap","deltaPct","deltaPercent","note","qualified","qualifiedRate","unqualifiedRate","riseIsGood","open","severity","title","emptyText"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-funnel-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 {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\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 { useCrmOrgMount } from '../../hooks/use-crm-org-mount'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename } from './report-format'\nimport { type CrmReportScope, reportCacheKey } from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport {\n type AggregateRead,\n useAggregateRead,\n useWindowRead,\n type WindowRead,\n} from './use-aggregate-read'\nimport { deltaPercent, leadFunnel } from '../../model/crm-reports'\n\n/**\n * How many of the period's leads the funnel is placed from — the Leads\n * section's own window, over the period rather than over everything.\n *\n * The captured count is a server aggregate and never meets this bound; the\n * funnel needs each lead's status and reason, so it reads the period's\n * newest two hundred and says when there were more. At the organization\n * level the bound is PER SITE (AGL-2634): each site's window is its own,\n * and a site with a thousand leads must not crowd out a site with ten.\n */\nexport const LEAD_CEILING = 200\n/** How many reasons the bar list draws; the rest are counted beneath it. */\nconst REASONS_SHOWN = 8\n\n/** The CSV's columns: one file for both the statuses and the reasons. */\nconst COLUMNS = ['Kind', 'Label', 'Count', 'Share'] as const\n\ntype LeadRow = Record<string, unknown> & Aglyn.CrmLeadFields & { $id: string }\n\n/** The period's captured count, and the previous period's for the delta. */\ninterface CapturedFigures {\n current: number\n previous: number\n}\n\nexport interface LeadFunnelCardProps {\n report: CrmReportScope\n /**\n * The site whose leads are placed. A lead lives under its host, not the\n * org — `hosts/{hostId}/leads`, private by path, no `visibleTo` — so the\n * report's org scope cannot reach it and the card is told the site. At\n * the organization level `null` (AGL-2630): the card then reads every\n * site the mount lists, one window each (AGL-2634).\n */\n hostId: string | null\n}\n\nconst LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#lead-funnel',\n excerpt:\n 'Of the leads this site captured in the period, how many are ' +\n 'still new, being worked, qualified or unqualified — and the ' +\n 'reasons the unqualified ones were closed. Placed by when each ' +\n 'lead was first seen.',\n})\n\n/**\n * The leads a scope may see, first seen in a range (AGL-3275).\n *\n * `visibleTo` is the site's tokens under a site, and `null` at the\n * organization level where an org-wide member reads without a clause. The\n * range stays a single-field one; with the scope clause it composes with the\n * `visibleTo` + `firstSeenAtMs` entries in `cloud/firebase-firestore.indexes.json`\n * — the descending one for the window, the ascending one for the counts,\n * which name no order (AGL-3443).\n */\nfunction leadsBetween(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | null,\n from: number,\n to: number,\n) {\n return query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...(visibleTo ? [where('visibleTo', 'array-contains-any', [...visibleTo])] : []),\n where('firstSeenAtMs', '>=', from),\n where('firstSeenAtMs', '<', to),\n )\n}\n\nconst countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * The lead funnel, at whichever level the report is mounted (AGL-2624,\n * AGL-2630, AGL-2634).\n *\n * Under a site: that site. At the organization level: every site the\n * mount lists, one window each, totaled — the org hub's reports total\n * every site, and the leads are no exception now that the fan-out is\n * bounded by the org's site list. With no sites to read the card says so\n * instead of guessing.\n */\nexport function LeadFunnelCard(props: LeadFunnelCardProps) {\n const { report, hostId } = props\n const mount = useCrmOrgMount()\n if (hostId) return <SiteLeadFunnelCard report={report} hostId={hostId} />\n if (mount && (!mount.hostsReady || mount.hosts.length > 0)) {\n return <OrgLeadFunnelCard report={report} />\n }\n return (\n <CardDisplay header={'Lead funnel'} help={LEAD_FUNNEL_HELP} contentGutterX contentGutterY>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {mount\n ? 'Leads live under a site, and this organization has no sites yet.'\n : 'Leads live under a site, and this report is mounted under none.'}\n </Typography>\n </CardDisplay>\n )\n}\n\n/**\n * The lead funnel (AGL-2624): of the leads this site captured in the\n * period, how many are still new, being worked, qualified or unqualified —\n * and why the unqualified ones were closed.\n *\n * A cohort by first sighting, followed to where each lead stands now,\n * because that is the question a period asks of leads — \"what became of\n * the ones we got\" — and because a lead's status has no history on the\n * document: only its current value. The window is a range on\n * `firstSeenAtMs`, which the capture door stamps once on creation and\n * never again, so a returning visitor's second form is not a second lead\n * in a second period. A single-field range needs no composite index.\n *\n * Read directly off `hosts/{hostId}/leads` under the site's own rules —\n * any member of the site may read it — the way the Leads section reads\n * it, so the funnel and the list can never disagree about who may see a\n * lead.\n */\nfunction SiteLeadFunnelCard(props: { report: CrmReportScope; hostId: string }) {\n const { report, hostId } = props\n const { range } = report\n const firestore = useFirestore()\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], report.tokens, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:counts:${hostId}`) },\n )\n const window = useWindowRead<LeadRow>(\n () =>\n query(\n leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n LEAD_CEILING,\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:window:${hostId}`) },\n )\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n (figures !== null ? ` of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'This site’s leads could not be read.'}\n />\n )\n}\nSiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard'\n\n/**\n * THE ORGANIZATION'S FUNNEL (AGL-2634, AGL-3275): every lead the org holds.\n *\n * This totaled one aggregate and one window PER SITE, because the sites'\n * lead collections were disjoint. They share one collection now, so a sum\n * would count a lead that two brands in a consent group both hold once for\n * each of them — and the merge that existed so a busy site could not crowd\n * out a quiet one has nothing left to balance. One aggregate and one\n * bounded read, unscoped, as an org-wide member's reads are.\n */\nfunction OrgLeadFunnelCard(props: { report: CrmReportScope }) {\n const { report } = props\n const { range } = report\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const hostIds = useMemo(\n () => (mount?.hostsReady ? mount.hosts.map((host) => host.id) : null),\n [mount],\n )\n\n /*\n * ONE READ, not one per site (AGL-3275). The sites share a collection now,\n * so a lead two brands in a consent group both hold would be counted once\n * for each of them — and the org's funnel would report more leads than the\n * org has. `null` tokens is the org-wide read that carries no clause.\n */\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], null, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], null, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:counts:org') },\n )\n const merged = useAggregateRead<{ rows: LeadRow[]; truncated: boolean }>(\n () =>\n getDocs(\n query(\n leadsBetween(firestore, report.scope[1], null, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n ).then((snapshot) =>\n ceilingedWindow(\n snapshot.docs.map(\n (document) => ({ ...document.data(), $id: document.id }) as unknown as LeadRow,\n ),\n LEAD_CEILING,\n ),\n ),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:window:org') },\n )\n const window = useMemo<WindowRead<LeadRow>>(\n () => ({\n rows: merged.value?.rows ?? [],\n truncated: merged.value?.truncated ?? false,\n status: merged.status,\n }),\n [merged],\n )\n const sites = hostIds?.length ?? 0\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n subheader={`Every site (${sites.toLocaleString()})`}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n ` — at most ${LEAD_CEILING.toLocaleString()}` +\n (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'The organization’s leads could not be read.'}\n />\n )\n}\nOrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard'\n\n/**\n * The funnel as drawn, whichever level fed it: four tiles, the statuses,\n * the reasons, the export. Pure over the two reads.\n */\nfunction LeadFunnelBody(props: {\n report: CrmReportScope\n subheader?: string\n captured: AggregateRead<CapturedFigures>\n window: WindowRead<LeadRow>\n caption?: string\n errorText: string\n}) {\n const { report, subheader, captured, window, caption, errorText } = props\n const { period, routes } = report\n const status = window.status\n\n const funnel = useMemo(() => leadFunnel(window.rows), [window])\n const read = status === 'success'\n const figures = captured.value\n const share = (count: number, of: number): string | null =>\n of > 0 ? percent(count / of) : null\n const reasons = funnel.reasons.slice(0, REASONS_SHOWN)\n const moreReasons = funnel.reasons.length - reasons.length\n\n return (\n <CardDisplay\n header={'Lead funnel'}\n subheader={subheader}\n help={LEAD_FUNNEL_HELP}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <ReportExport\n filename={reportFilename('lead-funnel', period)}\n columns={COLUMNS}\n rows={() => [\n ...Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => [\n 'Status',\n Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n funnel.byStatus[leadStatus],\n share(funnel.byStatus[leadStatus], funnel.total) ?? '',\n ]),\n ...funnel.reasons.map((reason) => [\n 'Unqualified reason',\n reason.label,\n reason.count,\n share(reason.count, funnel.byStatus.unqualified) ?? '',\n ]),\n ]}\n disabled={!read || !funnel.total}\n />\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('leads')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open leads'}\n </Button>\n </Stack>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Leads captured'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? deltaPercent(figures.current, figures.previous) : null}\n note={'first seen in the period'}\n href={routes.section('leads')}\n />\n <ReportStatTile\n label={'Qualified'}\n value={read ? funnel.byStatus.qualified.toLocaleString() : null}\n note={\n funnel.qualifiedRate === null\n ? read\n ? 'nobody captured in the period'\n : undefined\n : `${percent(funnel.qualifiedRate)} of those captured`\n }\n />\n <ReportStatTile\n label={'Unqualified'}\n value={read ? funnel.byStatus.unqualified.toLocaleString() : null}\n note={\n funnel.unqualifiedRate === null\n ? undefined\n : `${percent(funnel.unqualifiedRate)} of those captured`\n }\n riseIsGood={false}\n />\n <ReportStatTile\n label={'Still open'}\n value={read ? funnel.open.toLocaleString() : null}\n note={'new or being worked'}\n href={routes.section('leads')}\n />\n </Stack>\n {captured.status === 'error' || status === 'error' ? (\n <Alert severity=\"warning\">{errorText}</Alert>\n ) : null}\n <Section title={'Where they stand'}>\n <ReportBreakdown\n rows={Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => ({\n key: leadStatus,\n label: Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n value: funnel.byStatus[leadStatus],\n note: share(funnel.byStatus[leadStatus], funnel.total) ?? undefined,\n color:\n leadStatus === 'qualified'\n ? 'success.main'\n : leadStatus === 'unqualified'\n ? 'warning.main'\n : undefined,\n }))}\n emptyText={''}\n />\n {read && !funnel.total ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No leads captured in this period.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Why leads were unqualified'}>\n <ReportBreakdown\n rows={reasons.map((reason) => ({\n key: reason.key,\n label: reason.label,\n value: reason.count,\n note: share(reason.count, funnel.byStatus.unqualified) ?? undefined,\n color: 'warning.main',\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No lead was unqualified in this period.'\n }\n />\n {moreReasons > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`And ${plural(moreReasons, 'other reason')}, each given once or twice.`}\n </Typography>\n ) : null}\n </Section>\n {/* What the funnel, and so the export, is placed from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {caption}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nLeadFunnelBody.displayName = 'LeadFunnelBody'\nLeadFunnelCard.displayName = 'LeadFunnelCard'\n\nexport default LeadFunnelCard\n"],"names":["Aglyn","AppLink","CardDisplay","percent","Section","ceilingedWindow","Alert","Button","Stack","Typography","collection","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","useCrmOrgMount","ReportBreakdown","ReportExport","plural","reportFilename","reportCacheKey","ReportStatTile","useAggregateRead","useWindowRead","deltaPercent","leadFunnel","LEAD_CEILING","REASONS_SHOWN","COLUMNS","LEAD_FUNNEL_HELP","pluginDocsHelp","anchor","excerpt","leadsBetween","firestore","orgId","visibleTo","from","to","countOf","target","then","snapshot","data","count","LeadFunnelCard","props","report","hostId","mount","SiteLeadFunnelCard","hostsReady","hosts","length","OrgLeadFunnelCard","header","help","contentGutterX","contentGutterY","variant","color","range","captured","Promise","all","scope","tokens","previousFrom","previousTo","current","previous","cacheKey","window","figures","value","sampled","status","truncated","rows","LeadFunnelBody","caption","toLocaleString","undefined","errorText","displayName","hostIds","map","host","id","merged","docs","document","$id","sites","subheader","period","routes","funnel","read","share","of","reasons","slice","moreReasons","HeaderProps","action","direction","spacing","filename","columns","CRM_LEAD_STATUSES","leadStatus","CRM_LEAD_STATUS_LABELS","byStatus","total","reason","label","unqualified","disabled","component","componentVariant","nativeButton","href","section","size","sx","flexWrap","deltaPct","note","qualified","qualifiedRate","unqualifiedRate","riseIsGood","open","severity","title","key","emptyText"],"mappings":"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"}
|
|
@@ -31,6 +31,7 @@ import { plural, reportFilename, shortDate } from "./report-format.js";
|
|
|
31
31
|
import { reportCacheKey, scopedCollection, visibleToClause } from "./report-scope.js";
|
|
32
32
|
import { ReportStatTile } from "./report-stat-tile.js";
|
|
33
33
|
import { useAggregateRead, useWindowRead } from "./use-aggregate-read.js";
|
|
34
|
+
import { currencyOfDeals, pipelineTotals } from "../../model/crm-reports.js";
|
|
34
35
|
/**
|
|
35
36
|
* How many open deals the by-stage chart, the forecast and the top-deals
|
|
36
37
|
* table are read from.
|
|
@@ -116,7 +117,7 @@ const TOP_DEALS = 10;
|
|
|
116
117
|
var _byPipeline_get;
|
|
117
118
|
return {
|
|
118
119
|
pipeline,
|
|
119
|
-
totals:
|
|
120
|
+
totals: pipelineTotals((_byPipeline_get = byPipeline.get(pipeline.$id)) != null ? _byPipeline_get : [], pipeline)
|
|
120
121
|
};
|
|
121
122
|
});
|
|
122
123
|
const known = new Set(pipelineWindow.rows.map((pipeline)=>pipeline.$id));
|
|
@@ -137,11 +138,11 @@ const TOP_DEALS = 10;
|
|
|
137
138
|
}).slice(0, TOP_DEALS);
|
|
138
139
|
return {
|
|
139
140
|
pipelines,
|
|
140
|
-
orphanTotals:
|
|
141
|
+
orphanTotals: pipelineTotals(orphans, null),
|
|
141
142
|
weightedCents: pipelines.reduce((total, entry)=>total + entry.totals.weightedCents, 0),
|
|
142
143
|
top,
|
|
143
144
|
stageName,
|
|
144
|
-
currency:
|
|
145
|
+
currency: currencyOfDeals(dealWindow.rows)
|
|
145
146
|
};
|
|
146
147
|
}, [
|
|
147
148
|
dealWindow,
|