@aglyn/plugins-crm 1.0.0-beta.164 → 1.0.0-beta.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +14 -4
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/contact-field-drawer.js +19 -2
  5. package/src/lib/components/contact-field-drawer.js.map +1 -1
  6. package/src/lib/components/fields-section.d.ts +2 -1
  7. package/src/lib/components/fields-section.js +6 -3
  8. package/src/lib/components/fields-section.js.map +1 -1
  9. package/src/lib/components/lead-campaigns-card.d.ts +59 -0
  10. package/src/lib/components/lead-campaigns-card.js +160 -0
  11. package/src/lib/components/lead-campaigns-card.js.map +1 -0
  12. package/src/lib/components/lead-detail-page.js +53 -3
  13. package/src/lib/components/lead-detail-page.js.map +1 -1
  14. package/src/lib/components/lead-history-card.js +5 -0
  15. package/src/lib/components/lead-history-card.js.map +1 -1
  16. package/src/lib/components/lead-properties-card.d.ts +12 -0
  17. package/src/lib/components/lead-properties-card.js +63 -5
  18. package/src/lib/components/lead-properties-card.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.d.ts +6 -0
  20. package/src/lib/components/leads-bulk-bar.js +34 -3
  21. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  22. package/src/lib/components/leads-section.js +16 -2
  23. package/src/lib/components/leads-section.js.map +1 -1
  24. package/src/lib/components/new-lead-drawer.d.ts +14 -1
  25. package/src/lib/components/new-lead-drawer.js +41 -3
  26. package/src/lib/components/new-lead-drawer.js.map +1 -1
  27. package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
  28. package/src/lib/hooks/use-campaign-filing-log.js +91 -0
  29. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
  30. package/src/lib/model/campaign-filing-activity.d.ts +82 -0
  31. package/src/lib/model/campaign-filing-activity.js +88 -0
  32. package/src/lib/model/campaign-filing-activity.js.map +1 -0
  33. package/src/lib/server/campaign-filing-activity.d.ts +79 -0
  34. package/src/lib/server/campaign-filing-activity.js +93 -0
  35. package/src/lib/server/campaign-filing-activity.js.map +1 -0
  36. package/src/lib/server/lead-campaign-carry.js +25 -0
  37. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  38. package/src/lib/server/lead-create.d.ts +15 -4
  39. package/src/lib/server/lead-create.js +68 -10
  40. package/src/lib/server/lead-create.js.map +1 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
+ import { campaignMembershipUnchanged, campaignMembershipValue, pluginDocsHelp, readCampaignIds } from "@aglyn/aglyn";
19
+ import CampaignPicker from "@aglyn/shared-ui-email-campaigns/components/campaign-picker.component";
20
+ import { CardDisplay } from "@aglyn/shared-ui-jsx";
21
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
22
+ import { useFirestore, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
23
+ import { Button, Stack } from "@mui/material";
24
+ import { doc, serverTimestamp, updateDoc } from "firebase/firestore";
25
+ import { useEffect, useState } from "react";
26
+ /** The helper line under the picker — the contact card's, word for word. */ export const LEAD_CAMPAIGNS_HELPER_TEXT = 'Your own filing. It never adds anyone to a send — a campaign mails its lists.';
27
+ /**
28
+ * The campaigns a lead is filed under, as NAMES for the record header.
29
+ *
30
+ * Resolved from the site's containers; an id with no live container — a
31
+ * campaign the console deleted, or one still loading — is left out rather
32
+ * than shown, because a document id on a chip tells a reader nothing and
33
+ * looks like a fault. The picker below keeps such an id visible as itself,
34
+ * so the filing is never silently lost; the header only ever names.
35
+ */ export function leadCampaignNames(lead, options) {
36
+ const labels = new Map(options.map((option)=>[
37
+ option.value,
38
+ option.label
39
+ ]));
40
+ return readCampaignIds(lead).map((id)=>labels.get(id)).filter((name)=>Boolean(name));
41
+ }
42
+ /**
43
+ * WHICH CAMPAIGNS THIS LEAD IS FILED UNDER (AGL-3274).
44
+ *
45
+ * The contact's Relationship card has carried this picker since AGL-2596;
46
+ * the lead's page had none, so a lead filed under a campaign by the New
47
+ * lead drawer, the bulk bar or a sequence's enroll showed nothing of it —
48
+ * the one field on the record the reader had no way to see or change.
49
+ *
50
+ * The same control, the same value helper, the same helper line. The save
51
+ * is the lead's own client door — a one-field `update` on
52
+ * `hosts/{hostId}/leads`, as the properties card writes status and notes —
53
+ * guarded by the seed like every draft edited over a cached read, and
54
+ * `campaignMembershipUnchanged` keeps Save quiet while the picker only
55
+ * reorders what is stored.
56
+ *
57
+ * It is FILING, not audience: the helper says so, in the sentence the
58
+ * contact card uses, because a reader who finds a campaign picker on a
59
+ * person's page assumes the opposite.
60
+ */ export function LeadCampaignsCard(props) {
61
+ const { hostId, leadId, lead, leadStatus, fromCache, options, optionsReady, onFiled } = props;
62
+ const firestore = useFirestore();
63
+ const { enqueueSnackbar } = useSnackbar();
64
+ const stored = readCampaignIds(lead);
65
+ const [selected, setSelected] = useState(stored);
66
+ const [dirty, setDirty] = useState(false);
67
+ const [saving, setSaving] = useState(false);
68
+ // A newer membership from the server replaces an UNEDITED selection — a
69
+ // sequence's enroll or a bulk add lands while the page is open — and an
70
+ // edited one is the reader's, decided by the guard on save.
71
+ const storedKey = stored.join(',');
72
+ useEffect(()=>{
73
+ if (!dirty) setSelected(stored);
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
75
+ }, [
76
+ storedKey,
77
+ dirty
78
+ ]);
79
+ const unchanged = campaignMembershipUnchanged(stored, selected);
80
+ const empty = optionsReady && !options.length;
81
+ const save = async ()=>{
82
+ if (unchanged) return;
83
+ setSaving(true);
84
+ const next = campaignMembershipValue(selected);
85
+ const verdict = await writeGuardedBySeed({
86
+ subject: 'lead',
87
+ fromCache,
88
+ unreadable: leadStatus === 'error'
89
+ }, async ()=>{
90
+ await updateDoc(doc(firestore, 'hosts', hostId, 'leads', leadId), {
91
+ campaignIds: next,
92
+ updatedAt: serverTimestamp()
93
+ });
94
+ });
95
+ setSaving(false);
96
+ if (!verdict.ok) {
97
+ var _verdict_message;
98
+ enqueueSnackbar((_verdict_message = verdict.message) != null ? _verdict_message : 'The filing could not be saved.', {
99
+ variant: 'warning'
100
+ });
101
+ return;
102
+ }
103
+ setDirty(false);
104
+ enqueueSnackbar('Filing saved', {
105
+ variant: 'success',
106
+ persist: false
107
+ });
108
+ onFiled == null ? void 0 : onFiled({
109
+ added: next.filter((id)=>!stored.includes(id)),
110
+ removed: stored.filter((id)=>!next.includes(id))
111
+ });
112
+ };
113
+ return /*#__PURE__*/ _jsx(CardDisplay, {
114
+ header: 'Campaigns',
115
+ help: pluginDocsHelp('crmLeads', {
116
+ anchor: '#a-leads-page'
117
+ }),
118
+ contentGutterX: true,
119
+ contentGutterY: true,
120
+ children: /*#__PURE__*/ _jsxs(Stack, {
121
+ spacing: 2,
122
+ children: [
123
+ /*#__PURE__*/ _jsx(CampaignPicker, {
124
+ options: options,
125
+ value: selected,
126
+ onChange: (next)=>{
127
+ setSelected(next);
128
+ setDirty(true);
129
+ },
130
+ label: "Filed under campaigns",
131
+ helperText: LEAD_CAMPAIGNS_HELPER_TEXT,
132
+ disabled: saving,
133
+ empty: empty,
134
+ emptyText: "This site has no campaigns yet. Create one from Marketing to file leads under it."
135
+ }),
136
+ empty ? null : /*#__PURE__*/ _jsx(Stack, {
137
+ direction: "row",
138
+ children: /*#__PURE__*/ _jsx(Button, {
139
+ size: "small",
140
+ variant: "outlined",
141
+ disabled: saving || unchanged,
142
+ onClick: ()=>void save().catch((error)=>{
143
+ console.error(error);
144
+ enqueueSnackbar(error instanceof Error && error.message ? error.message : 'An error has occurred', {
145
+ variant: 'error',
146
+ allowDuplicate: true
147
+ });
148
+ setSaving(false);
149
+ }),
150
+ children: saving ? 'Saving…' : 'Save filing'
151
+ })
152
+ })
153
+ ]
154
+ })
155
+ });
156
+ }
157
+ LeadCampaignsCard.displayName = 'LeadCampaignsCard';
158
+ export default LeadCampaignsCard;
159
+
160
+ //# sourceMappingURL=lead-campaigns-card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-campaigns-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 {\n campaignMembershipUnchanged,\n campaignMembershipValue,\n pluginDocsHelp,\n readCampaignIds,\n} from '@aglyn/aglyn'\nimport CampaignPicker, {\n type CampaignPickerOption,\n} from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Stack } from '@mui/material'\nimport { doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\n\n/** The helper line under the picker — the contact card's, word for word. */\nexport const LEAD_CAMPAIGNS_HELPER_TEXT =\n 'Your own filing. It never adds anyone to a send — a campaign mails its lists.'\n\n/**\n * The campaigns a lead is filed under, as NAMES for the record header.\n *\n * Resolved from the site's containers; an id with no live container — a\n * campaign the console deleted, or one still loading — is left out rather\n * than shown, because a document id on a chip tells a reader nothing and\n * looks like a fault. The picker below keeps such an id visible as itself,\n * so the filing is never silently lost; the header only ever names.\n */\nexport function leadCampaignNames(\n lead: Record<string, unknown> | null | undefined,\n options: readonly CampaignPickerOption[],\n): string[] {\n const labels = new Map(options.map((option) => [option.value, option.label]))\n return readCampaignIds(lead)\n .map((id) => labels.get(id))\n .filter((name): name is string => Boolean(name))\n}\n\nexport interface LeadCampaignsCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n /** The site's campaigns, read once by the page for this card and the header. */\n options: readonly CampaignPickerOption[]\n /** The campaign read has answered — false while it settles. */\n optionsReady: boolean\n /**\n * What a saved filing changed, once it has landed: the ids added to and\n * removed from the lead's membership. The page files the activity\n * entries (AGL-3274); the card only knows the document.\n */\n onFiled?: (change: { added: string[]; removed: string[] }) => void\n}\n\n/**\n * WHICH CAMPAIGNS THIS LEAD IS FILED UNDER (AGL-3274).\n *\n * The contact's Relationship card has carried this picker since AGL-2596;\n * the lead's page had none, so a lead filed under a campaign by the New\n * lead drawer, the bulk bar or a sequence's enroll showed nothing of it —\n * the one field on the record the reader had no way to see or change.\n *\n * The same control, the same value helper, the same helper line. The save\n * is the lead's own client door — a one-field `update` on\n * `hosts/{hostId}/leads`, as the properties card writes status and notes —\n * guarded by the seed like every draft edited over a cached read, and\n * `campaignMembershipUnchanged` keeps Save quiet while the picker only\n * reorders what is stored.\n *\n * It is FILING, not audience: the helper says so, in the sentence the\n * contact card uses, because a reader who finds a campaign picker on a\n * person's page assumes the opposite.\n */\nexport function LeadCampaignsCard(props: LeadCampaignsCardProps) {\n const { hostId, leadId, lead, leadStatus, fromCache, options, optionsReady, onFiled } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const stored = readCampaignIds(lead)\n const [selected, setSelected] = useState<string[]>(stored)\n const [dirty, setDirty] = useState(false)\n const [saving, setSaving] = useState(false)\n // A newer membership from the server replaces an UNEDITED selection — a\n // sequence's enroll or a bulk add lands while the page is open — and an\n // edited one is the reader's, decided by the guard on save.\n const storedKey = stored.join(',')\n useEffect(() => {\n if (!dirty) setSelected(stored)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [storedKey, dirty])\n\n const unchanged = campaignMembershipUnchanged(stored, selected)\n const empty = optionsReady && !options.length\n\n const save = async () => {\n if (unchanged) return\n setSaving(true)\n const next = campaignMembershipValue(selected)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n async () => {\n await updateDoc(doc(firestore, 'hosts', hostId, 'leads', leadId), {\n campaignIds: next,\n updatedAt: serverTimestamp(),\n })\n },\n )\n setSaving(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The filing could not be saved.', { variant: 'warning' })\n return\n }\n setDirty(false)\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n onFiled?.({\n added: next.filter((id) => !stored.includes(id)),\n removed: stored.filter((id) => !next.includes(id)),\n })\n }\n\n return (\n <CardDisplay\n header={'Campaigns'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <CampaignPicker\n options={options}\n value={selected}\n onChange={(next) => {\n setSelected(next)\n setDirty(true)\n }}\n label=\"Filed under campaigns\"\n helperText={LEAD_CAMPAIGNS_HELPER_TEXT}\n disabled={saving}\n empty={empty}\n emptyText=\"This site has no campaigns yet. Create one from Marketing to file leads under it.\"\n />\n {empty ? null : (\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || unchanged}\n onClick={() => void save().catch((error: unknown) => {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n setSaving(false)\n })}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nLeadCampaignsCard.displayName = 'LeadCampaignsCard'\n\nexport default LeadCampaignsCard\n"],"names":["campaignMembershipUnchanged","campaignMembershipValue","pluginDocsHelp","readCampaignIds","CampaignPicker","CardDisplay","useSnackbar","useFirestore","writeGuardedBySeed","Button","Stack","doc","serverTimestamp","updateDoc","useEffect","useState","LEAD_CAMPAIGNS_HELPER_TEXT","leadCampaignNames","lead","options","labels","Map","map","option","value","label","id","get","filter","name","Boolean","LeadCampaignsCard","props","hostId","leadId","leadStatus","fromCache","optionsReady","onFiled","firestore","enqueueSnackbar","stored","selected","setSelected","dirty","setDirty","saving","setSaving","storedKey","join","unchanged","empty","length","save","next","verdict","subject","unreadable","campaignIds","updatedAt","ok","message","variant","persist","added","includes","removed","header","help","anchor","contentGutterX","contentGutterY","spacing","onChange","helperText","disabled","emptyText","direction","size","onClick","catch","error","console","Error","allowDuplicate","displayName"],"mappings":"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"}
@@ -16,15 +16,17 @@
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
18
  import { pluginDocsHelp, normalizeContactEmail, readErasureRequestedAtMs } from "@aglyn/aglyn";
19
- import { useFirestore, useFirestoreDoc, useOrgDataScope } from "@aglyn/tenant-feature-instance";
19
+ import { useFirestore, useFirestoreDoc, useHostCampaigns, useOrgDataScope } from "@aglyn/tenant-feature-instance";
20
20
  import { Stack, Typography } from "@mui/material";
21
21
  import { doc } from "firebase/firestore";
22
22
  import { useState } from "react";
23
+ import { useCampaignFilingLog } from "../hooks/use-campaign-filing-log.js";
23
24
  import { useOrgMemberOptions } from "../hooks/use-org-member-options.js";
24
25
  import { crmRoutes } from "../model/crm-routes.js";
25
- import { CrmRecordHeader } from "./crm-record-header.js";
26
+ import { CrmRecordChip, CrmRecordHeader } from "./crm-record-header.js";
26
27
  import { CrmRecordInsightsZone } from "./crm-record-insights-zone.js";
27
28
  import { useErasePersonAction } from "./erase-person-action.js";
29
+ import { LeadCampaignsCard, leadCampaignNames } from "./lead-campaigns-card.js";
28
30
  import { LeadConvertDialog } from "./lead-convert-dialog.js";
29
31
  import { LeadHistoryCard } from "./lead-history-card.js";
30
32
  import { LeadPropertiesCard } from "./lead-properties-card.js";
@@ -52,6 +54,21 @@ import { RecordActivityCard } from "./record-activity-card.js";
52
54
  hostId
53
55
  });
54
56
  const roster = useOrgMemberOptions(orgId);
57
+ /*
58
+ * The site's campaigns, read once for the page (AGL-3274): the header
59
+ * names the ones the lead is filed under and the Campaigns card offers
60
+ * them. One listener, not one per surface — a lead belongs to one site,
61
+ * so the containers are that site's.
62
+ */ const campaigns = useHostCampaigns(hostId, {
63
+ enabled: true
64
+ });
65
+ // A saved filing is written on the lead's Activity too (AGL-3274), one
66
+ // entry per campaign added or removed, by the member who saved it.
67
+ const logFiling = useCampaignFilingLog({
68
+ orgId,
69
+ hostId,
70
+ org: org
71
+ });
55
72
  const [converting, setConverting] = useState(false);
56
73
  const [unqualifying, setUnqualifying] = useState(false);
57
74
  // The privacy erasure (AGL-2623), offered from the lead as from the
@@ -103,6 +120,7 @@ import { RecordActivityCard } from "./record-activity-card.js";
103
120
  children: [
104
121
  /*#__PURE__*/ _jsx(LeadPropertiesCard, {
105
122
  hostId: hostId,
123
+ orgId: orgId,
106
124
  leadId: id,
107
125
  lead: lead,
108
126
  leadStatus: status,
@@ -114,7 +132,14 @@ import { RecordActivityCard } from "./record-activity-card.js";
114
132
  extraMenuItems: erase.menuItems,
115
133
  banner: erase.banner,
116
134
  erasurePending: erase.pendingSinceMs !== null,
117
- org: org
135
+ org: org,
136
+ // The campaigns the lead is filed under (AGL-3274), by name
137
+ // beside the status. An id no container answers for draws no
138
+ // chip: the card below keeps it, the header only names.
139
+ extraChips: leadCampaignNames(lead, campaigns.options).map((name)=>/*#__PURE__*/ _jsx(CrmRecordChip, {
140
+ label: "Campaign",
141
+ value: name
142
+ }, name))
118
143
  }),
119
144
  /*#__PURE__*/ _jsx(CrmRecordInsightsZone, {
120
145
  hostId: hostId,
@@ -123,6 +148,31 @@ import { RecordActivityCard } from "./record-activity-card.js";
123
148
  recordId: id,
124
149
  name: label != null ? label : ''
125
150
  }),
151
+ /*#__PURE__*/ _jsx(LeadCampaignsCard, {
152
+ hostId: hostId,
153
+ leadId: id,
154
+ lead: lead,
155
+ leadStatus: status,
156
+ fromCache: fromCache,
157
+ options: campaigns.options,
158
+ optionsReady: campaigns.ready,
159
+ onFiled: ({ added, removed })=>{
160
+ const named = (ids)=>ids.map((campaignId)=>{
161
+ var _ref;
162
+ var _campaigns_options_find;
163
+ return {
164
+ id: campaignId,
165
+ name: (_ref = (_campaigns_options_find = campaigns.options.find((option)=>option.value === campaignId)) == null ? void 0 : _campaigns_options_find.label) != null ? _ref : ''
166
+ };
167
+ });
168
+ void logFiling({
169
+ leadId: id
170
+ }, {
171
+ filed: named(added),
172
+ removed: named(removed)
173
+ });
174
+ }
175
+ }),
126
176
  /*#__PURE__*/ _jsx(LeadHistoryCard, {
127
177
  hostId: hostId,
128
178
  leadId: id,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-detail-page.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n pluginDocsHelp,\n type CrmLeadFields,\n normalizeContactEmail,\n readErasureRequestedAtMs,\n} from '@aglyn/aglyn'\nimport {\n useFirestore,\n useFirestoreDoc,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport { Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useState } from 'react'\nimport { useOrgMemberOptions } from '../hooks/use-org-member-options'\nimport { type CrmDetailPageProps, crmRoutes } from '../model/crm-routes'\nimport { CrmRecordHeader } from './crm-record-header'\nimport { CrmRecordInsightsZone } from './crm-record-insights-zone'\nimport { useErasePersonAction } from './erase-person-action'\nimport { LeadConvertDialog } from './lead-convert-dialog'\nimport { LeadHistoryCard } from './lead-history-card'\nimport { LeadPropertiesCard } from './lead-properties-card'\nimport { LeadUnqualifyDialog } from './lead-unqualify-dialog'\nimport { RecordActivityCard } from './record-activity-card'\n\ntype LeadDocument = Record<string, unknown> & CrmLeadFields\n\n/**\n * `/crm/leads/{leadId}` — one lead (AGL-2608).\n *\n * The record behind a row of the Leads section: what the person did on the\n * site, who is working them, and the conversion that turns them into a\n * contact, a company and a deal. One document listen — `hosts/{hostId}/leads`\n * is host-scoped by path, so there is no `visibleTo` to filter — and one\n * roster request for the owner picker; the convert dialog's reads open only\n * when it does.\n */\nexport function LeadDetailPage(props: CrmDetailPageProps) {\n const { id, hostId, org, basePath } = props\n const firestore = useFirestore()\n const routes = crmRoutes(basePath)\n const {\n data: lead,\n status,\n fromCache,\n } = useFirestoreDoc<LeadDocument>(\n () => doc(firestore, 'hosts', hostId, 'leads', id),\n [firestore, hostId, id],\n )\n const { orgId } = useOrgDataScope({ hostId })\n const roster = useOrgMemberOptions(orgId)\n const [converting, setConverting] = useState(false)\n const [unqualifying, setUnqualifying] = useState(false)\n // The privacy erasure (AGL-2623), offered from the lead as from the\n // contact: the same request, filed by the lead's address.\n const leadEmail = lead ? normalizeContactEmail(lead['email']) : null\n const erase = useErasePersonAction({\n hostId,\n orgId,\n subject: lead && leadEmail ? { kind: 'lead', id, email: leadEmail } : null,\n requestedAtMs: readErasureRequestedAtMs(lead),\n })\n\n const label = lead ? String(lead['name'] || lead['email'] || id) : undefined\n\n if (status === 'error' || (status === 'success' && !lead)) {\n return (\n <CrmRecordHeader\n kind=\"Lead\"\n title={undefined}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {status === 'error'\n ? 'This lead could not be read.'\n : 'This lead no longer exists — it may have been removed from the Inbox.'}\n </Typography>\n </CrmRecordHeader>\n )\n }\n if (!lead) {\n return (\n <CrmRecordHeader\n kind=\"Lead\"\n title={undefined}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n loading\n />\n )\n }\n\n return (\n <>\n {/* The properties card is the record's lead card: it publishes the\n page heading and the trail, so the history card under it says what\n it holds rather than repeating the name. */}\n <Stack spacing={3}>\n <LeadPropertiesCard\n hostId={hostId}\n leadId={id}\n lead={lead}\n leadStatus={status}\n fromCache={fromCache}\n basePath={basePath}\n roster={roster}\n onConvert={() => setConverting(true)}\n onUnqualify={() => setUnqualifying(true)}\n extraMenuItems={erase.menuItems}\n banner={erase.banner}\n erasurePending={erase.pendingSinceMs !== null}\n org={org}\n />\n {/* What an assistant says about where the lead stands (AGL-2917): read on its own site. */}\n <CrmRecordInsightsZone\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n kind=\"lead\"\n recordId={id}\n name={label ?? ''}\n />\n <LeadHistoryCard hostId={hostId} leadId={id} lead={lead} />\n <RecordActivityCard hostId={hostId} org={org} leadId={id} />\n </Stack>\n <LeadConvertDialog\n open={converting}\n onClose={() => setConverting(false)}\n hostId={hostId}\n orgId={orgId}\n org={org as Record<string, unknown> | undefined}\n leadId={id}\n lead={lead}\n basePath={basePath}\n roster={roster}\n />\n <LeadUnqualifyDialog\n open={unqualifying}\n onClose={() => setUnqualifying(false)}\n hostId={hostId}\n leadId={id}\n leadLabel={label ?? id}\n />\n {erase.dialog}\n </>\n )\n}\nLeadDetailPage.displayName = 'LeadDetailPage'\n\nexport default LeadDetailPage\n"],"names":["pluginDocsHelp","normalizeContactEmail","readErasureRequestedAtMs","useFirestore","useFirestoreDoc","useOrgDataScope","Stack","Typography","doc","useState","useOrgMemberOptions","crmRoutes","CrmRecordHeader","CrmRecordInsightsZone","useErasePersonAction","LeadConvertDialog","LeadHistoryCard","LeadPropertiesCard","LeadUnqualifyDialog","RecordActivityCard","LeadDetailPage","props","id","hostId","org","basePath","firestore","routes","data","lead","status","fromCache","orgId","roster","converting","setConverting","unqualifying","setUnqualifying","leadEmail","erase","subject","kind","email","requestedAtMs","label","String","undefined","title","help","anchor","backHref","section","backLabel","variant","color","loading","spacing","leadId","leadStatus","onConvert","onUnqualify","extraMenuItems","menuItems","banner","erasurePending","pendingSinceMs","recordId","name","open","onClose","leadLabel","dialog","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-detail-page.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n pluginDocsHelp,\n type CrmLeadFields,\n normalizeContactEmail,\n readErasureRequestedAtMs,\n} from '@aglyn/aglyn'\nimport {\n useFirestore,\n useFirestoreDoc,\n useHostCampaigns,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport { Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useState } from 'react'\nimport { useCampaignFilingLog } from '../hooks/use-campaign-filing-log'\nimport { useOrgMemberOptions } from '../hooks/use-org-member-options'\nimport { type CrmDetailPageProps, crmRoutes } from '../model/crm-routes'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmRecordInsightsZone } from './crm-record-insights-zone'\nimport { useErasePersonAction } from './erase-person-action'\nimport { LeadCampaignsCard, leadCampaignNames } from './lead-campaigns-card'\nimport { LeadConvertDialog } from './lead-convert-dialog'\nimport { LeadHistoryCard } from './lead-history-card'\nimport { LeadPropertiesCard } from './lead-properties-card'\nimport { LeadUnqualifyDialog } from './lead-unqualify-dialog'\nimport { RecordActivityCard } from './record-activity-card'\n\ntype LeadDocument = Record<string, unknown> & CrmLeadFields\n\n/**\n * `/crm/leads/{leadId}` — one lead (AGL-2608).\n *\n * The record behind a row of the Leads section: what the person did on the\n * site, who is working them, and the conversion that turns them into a\n * contact, a company and a deal. One document listen — `hosts/{hostId}/leads`\n * is host-scoped by path, so there is no `visibleTo` to filter — and one\n * roster request for the owner picker; the convert dialog's reads open only\n * when it does.\n */\nexport function LeadDetailPage(props: CrmDetailPageProps) {\n const { id, hostId, org, basePath } = props\n const firestore = useFirestore()\n const routes = crmRoutes(basePath)\n const {\n data: lead,\n status,\n fromCache,\n } = useFirestoreDoc<LeadDocument>(\n () => doc(firestore, 'hosts', hostId, 'leads', id),\n [firestore, hostId, id],\n )\n const { orgId } = useOrgDataScope({ hostId })\n const roster = useOrgMemberOptions(orgId)\n /*\n * The site's campaigns, read once for the page (AGL-3274): the header\n * names the ones the lead is filed under and the Campaigns card offers\n * them. One listener, not one per surface — a lead belongs to one site,\n * so the containers are that site's.\n */\n const campaigns = useHostCampaigns(hostId, { enabled: true })\n // A saved filing is written on the lead's Activity too (AGL-3274), one\n // entry per campaign added or removed, by the member who saved it.\n const logFiling = useCampaignFilingLog({\n orgId,\n hostId,\n org: org as Record<string, unknown> | undefined,\n })\n const [converting, setConverting] = useState(false)\n const [unqualifying, setUnqualifying] = useState(false)\n // The privacy erasure (AGL-2623), offered from the lead as from the\n // contact: the same request, filed by the lead's address.\n const leadEmail = lead ? normalizeContactEmail(lead['email']) : null\n const erase = useErasePersonAction({\n hostId,\n orgId,\n subject: lead && leadEmail ? { kind: 'lead', id, email: leadEmail } : null,\n requestedAtMs: readErasureRequestedAtMs(lead),\n })\n\n const label = lead ? String(lead['name'] || lead['email'] || id) : undefined\n\n if (status === 'error' || (status === 'success' && !lead)) {\n return (\n <CrmRecordHeader\n kind=\"Lead\"\n title={undefined}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {status === 'error'\n ? 'This lead could not be read.'\n : 'This lead no longer exists — it may have been removed from the Inbox.'}\n </Typography>\n </CrmRecordHeader>\n )\n }\n if (!lead) {\n return (\n <CrmRecordHeader\n kind=\"Lead\"\n title={undefined}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n loading\n />\n )\n }\n\n return (\n <>\n {/* The properties card is the record's lead card: it publishes the\n page heading and the trail, so the history card under it says what\n it holds rather than repeating the name. */}\n <Stack spacing={3}>\n <LeadPropertiesCard\n hostId={hostId}\n orgId={orgId}\n leadId={id}\n lead={lead}\n leadStatus={status}\n fromCache={fromCache}\n basePath={basePath}\n roster={roster}\n onConvert={() => setConverting(true)}\n onUnqualify={() => setUnqualifying(true)}\n extraMenuItems={erase.menuItems}\n banner={erase.banner}\n erasurePending={erase.pendingSinceMs !== null}\n org={org}\n // The campaigns the lead is filed under (AGL-3274), by name\n // beside the status. An id no container answers for draws no\n // chip: the card below keeps it, the header only names.\n extraChips={leadCampaignNames(lead, campaigns.options).map((name) => (\n <CrmRecordChip key={name} label=\"Campaign\" value={name} />\n ))}\n />\n {/* What an assistant says about where the lead stands (AGL-2917): read on its own site. */}\n <CrmRecordInsightsZone\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n kind=\"lead\"\n recordId={id}\n name={label ?? ''}\n />\n <LeadCampaignsCard\n hostId={hostId}\n leadId={id}\n lead={lead}\n leadStatus={status}\n fromCache={fromCache}\n options={campaigns.options}\n optionsReady={campaigns.ready}\n onFiled={({ added, removed }) => {\n const named = (ids: string[]) =>\n ids.map((campaignId) => ({\n id: campaignId,\n name: campaigns.options.find((option) => option.value === campaignId)?.label ?? '',\n }))\n void logFiling({ leadId: id }, { filed: named(added), removed: named(removed) })\n }}\n />\n <LeadHistoryCard hostId={hostId} leadId={id} lead={lead} />\n <RecordActivityCard hostId={hostId} org={org} leadId={id} />\n </Stack>\n <LeadConvertDialog\n open={converting}\n onClose={() => setConverting(false)}\n hostId={hostId}\n orgId={orgId}\n org={org as Record<string, unknown> | undefined}\n leadId={id}\n lead={lead}\n basePath={basePath}\n roster={roster}\n />\n <LeadUnqualifyDialog\n open={unqualifying}\n onClose={() => setUnqualifying(false)}\n hostId={hostId}\n leadId={id}\n leadLabel={label ?? id}\n />\n {erase.dialog}\n </>\n )\n}\nLeadDetailPage.displayName = 'LeadDetailPage'\n\nexport default LeadDetailPage\n"],"names":["pluginDocsHelp","normalizeContactEmail","readErasureRequestedAtMs","useFirestore","useFirestoreDoc","useHostCampaigns","useOrgDataScope","Stack","Typography","doc","useState","useCampaignFilingLog","useOrgMemberOptions","crmRoutes","CrmRecordChip","CrmRecordHeader","CrmRecordInsightsZone","useErasePersonAction","LeadCampaignsCard","leadCampaignNames","LeadConvertDialog","LeadHistoryCard","LeadPropertiesCard","LeadUnqualifyDialog","RecordActivityCard","LeadDetailPage","props","id","hostId","org","basePath","firestore","routes","data","lead","status","fromCache","orgId","roster","campaigns","enabled","logFiling","converting","setConverting","unqualifying","setUnqualifying","leadEmail","erase","subject","kind","email","requestedAtMs","label","String","undefined","title","help","anchor","backHref","section","backLabel","variant","color","loading","spacing","leadId","leadStatus","onConvert","onUnqualify","extraMenuItems","menuItems","banner","erasurePending","pendingSinceMs","extraChips","options","map","name","value","recordId","optionsReady","ready","onFiled","added","removed","named","ids","campaignId","find","option","filed","open","onClose","leadLabel","dialog","displayName"],"mappings":"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"}
@@ -155,6 +155,11 @@ function Fact(props) {
155
155
  hostId: hostId,
156
156
  recordKind: "lead",
157
157
  recordId: leadId
158
+ }),
159
+ /*#__PURE__*/ _jsx(Typography, {
160
+ variant: "caption",
161
+ color: "text.secondary",
162
+ children: 'Filing is separate — see Filed under campaigns.'
158
163
  })
159
164
  ]
160
165
  }) : null
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-history-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 { CAPTURED_BY_HOST_FIELD, CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\n// The component path and NOT the marketing barrel, for the reason the Inbox\n// and the contacts list give: the barrel is the tenant loader's entry point\n// for the plugin's SITE half, and a console card named there ships to every\n// published page.\nimport {\n CrmRecordAttributionZone,\n useHasCrmRecordAttribution,\n} from './crm-attribution-zone'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\n\n/**\n * The surfaces `addHostLead` names — `signup`, `booking`, `form:{formId}`,\n * `import` — as they read on screen. A form's id is not a name, so it is\n * shown as the kind with the id beside it rather than as an opaque token.\n * The bare kind `form` is the lifecycle backfill's spelling for a person\n * whose timeline kept no form id (AGL-2631), and reads as the kind, the\n * way the contact's own source chip does.\n */\nexport function leadSourceLabel(source: string): string {\n if (source === 'signup') return 'Sign-up'\n if (source === 'booking') return 'Booking'\n if (source === 'import') return CONTACT_SOURCE_LABELS.import\n if (source === 'form') return CONTACT_SOURCE_LABELS.form\n // A lead entered by hand or over the REST API (AGL-3231).\n if (source === 'manual') return 'Added by hand'\n if (source === 'api') return 'API'\n if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`\n return source\n}\n\n/** Every surface that produced a capture — the array, or the older single field. */\nexport function leadSources(lead: Record<string, unknown>): string[] {\n const sources = lead['sources']\n if (Array.isArray(sources) && sources.length) {\n return sources.map((source) => String(source))\n }\n return typeof lead['source'] === 'string' && lead['source'] ? [lead['source']] : []\n}\n\n/** Epoch millis or a Firestore timestamp, as a local date-time, or a dash. */\nexport function leadTimeLabel(value: unknown): string {\n if (typeof value === 'number' && Number.isFinite(value)) {\n return new Date(value).toLocaleString()\n }\n const asDate = (value as { toDate?: () => Date } | null | undefined)?.toDate?.()\n return asDate ? asDate.toLocaleString() : '—'\n}\n\nfunction Fact(props: { label: string; children: React.ReactNode }) {\n return (\n <Stack spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.label}\n </Typography>\n <Typography variant=\"body2\" component=\"div\">\n {props.children}\n </Typography>\n </Stack>\n )\n}\n\nexport interface LeadHistoryCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n}\n\n/**\n * What the capture door recorded about this person (AGL-2608): every\n * surface that produced a capture, how many times, and when the first and\n * the latest happened — the fields `addHostLead` keeps on the one document\n * per person — plus the campaign the first capture is credited to.\n *\n * Read-only by construction. Nothing here is the team's to edit: it is what\n * the visitor did, and the CRM's working state lives on the card above it.\n */\nexport function LeadHistoryCard(props: LeadHistoryCardProps) {\n const { hostId, leadId, lead } = props\n // The caption introduces what another plugin draws; with none loaded it\n // would introduce nothing.\n const hasAttribution = useHasCrmRecordAttribution()\n const sources = leadSources(lead)\n const capturedBy = lead[CAPTURED_BY_HOST_FIELD]\n const count = Number(lead['submissionCount'] ?? 0) || (sources.length ? 1 : 0)\n return (\n <CardDisplay\n header={'Captured history'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={3}>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={3}>\n <Fact label=\"First seen\">\n {leadTimeLabel(lead['firstSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Last seen\">\n {leadTimeLabel(lead['lastSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Captures\">{count ? String(count) : '—'}</Fact>\n <Fact label=\"Captured on\">\n {Array.isArray(capturedBy) && capturedBy.length\n ? capturedBy.map(String).join(', ')\n : hostId}\n </Fact>\n </Stack>\n <Fact label=\"Sources\">\n {sources.length ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip key={source} size=\"small\" variant=\"outlined\" label={leadSourceLabel(source)} />\n ))}\n </Stack>\n ) : (\n '—'\n )}\n </Fact>\n {hasAttribution ? (\n <Stack spacing={1}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Where this lead came from'}\n </Typography>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"lead\"\n recordId={leadId}\n />\n </Stack>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nLeadHistoryCard.displayName = 'LeadHistoryCard'\n\nexport default LeadHistoryCard\n"],"names":["CAPTURED_BY_HOST_FIELD","CONTACT_SOURCE_LABELS","pluginDocsHelp","CrmRecordAttributionZone","useHasCrmRecordAttribution","CardDisplay","Chip","Stack","Typography","leadSourceLabel","source","import","form","startsWith","slice","length","leadSources","lead","sources","Array","isArray","map","String","leadTimeLabel","value","Number","isFinite","Date","toLocaleString","asDate","toDate","Fact","props","spacing","variant","color","label","component","children","LeadHistoryCard","hostId","leadId","hasAttribution","capturedBy","count","header","help","anchor","contentGutterX","contentGutterY","direction","xs","md","join","sx","flexWrap","rowGap","size","recordKind","recordId","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-history-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 { CAPTURED_BY_HOST_FIELD, CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\n// The component path and NOT the marketing barrel, for the reason the Inbox\n// and the contacts list give: the barrel is the tenant loader's entry point\n// for the plugin's SITE half, and a console card named there ships to every\n// published page.\nimport {\n CrmRecordAttributionZone,\n useHasCrmRecordAttribution,\n} from './crm-attribution-zone'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\n\n/**\n * The surfaces `addHostLead` names — `signup`, `booking`, `form:{formId}`,\n * `import` — as they read on screen. A form's id is not a name, so it is\n * shown as the kind with the id beside it rather than as an opaque token.\n * The bare kind `form` is the lifecycle backfill's spelling for a person\n * whose timeline kept no form id (AGL-2631), and reads as the kind, the\n * way the contact's own source chip does.\n */\nexport function leadSourceLabel(source: string): string {\n if (source === 'signup') return 'Sign-up'\n if (source === 'booking') return 'Booking'\n if (source === 'import') return CONTACT_SOURCE_LABELS.import\n if (source === 'form') return CONTACT_SOURCE_LABELS.form\n // A lead entered by hand or over the REST API (AGL-3231).\n if (source === 'manual') return 'Added by hand'\n if (source === 'api') return 'API'\n if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`\n return source\n}\n\n/** Every surface that produced a capture — the array, or the older single field. */\nexport function leadSources(lead: Record<string, unknown>): string[] {\n const sources = lead['sources']\n if (Array.isArray(sources) && sources.length) {\n return sources.map((source) => String(source))\n }\n return typeof lead['source'] === 'string' && lead['source'] ? [lead['source']] : []\n}\n\n/** Epoch millis or a Firestore timestamp, as a local date-time, or a dash. */\nexport function leadTimeLabel(value: unknown): string {\n if (typeof value === 'number' && Number.isFinite(value)) {\n return new Date(value).toLocaleString()\n }\n const asDate = (value as { toDate?: () => Date } | null | undefined)?.toDate?.()\n return asDate ? asDate.toLocaleString() : '—'\n}\n\nfunction Fact(props: { label: string; children: React.ReactNode }) {\n return (\n <Stack spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.label}\n </Typography>\n <Typography variant=\"body2\" component=\"div\">\n {props.children}\n </Typography>\n </Stack>\n )\n}\n\nexport interface LeadHistoryCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n}\n\n/**\n * What the capture door recorded about this person (AGL-2608): every\n * surface that produced a capture, how many times, and when the first and\n * the latest happened — the fields `addHostLead` keeps on the one document\n * per person — plus the campaign the first capture is credited to.\n *\n * Read-only by construction. Nothing here is the team's to edit: it is what\n * the visitor did, and the CRM's working state lives on the card above it.\n */\nexport function LeadHistoryCard(props: LeadHistoryCardProps) {\n const { hostId, leadId, lead } = props\n // The caption introduces what another plugin draws; with none loaded it\n // would introduce nothing.\n const hasAttribution = useHasCrmRecordAttribution()\n const sources = leadSources(lead)\n const capturedBy = lead[CAPTURED_BY_HOST_FIELD]\n const count = Number(lead['submissionCount'] ?? 0) || (sources.length ? 1 : 0)\n return (\n <CardDisplay\n header={'Captured history'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={3}>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={3}>\n <Fact label=\"First seen\">\n {leadTimeLabel(lead['firstSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Last seen\">\n {leadTimeLabel(lead['lastSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Captures\">{count ? String(count) : '—'}</Fact>\n <Fact label=\"Captured on\">\n {Array.isArray(capturedBy) && capturedBy.length\n ? capturedBy.map(String).join(', ')\n : hostId}\n </Fact>\n </Stack>\n <Fact label=\"Sources\">\n {sources.length ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip key={source} size=\"small\" variant=\"outlined\" label={leadSourceLabel(source)} />\n ))}\n </Stack>\n ) : (\n '—'\n )}\n </Fact>\n {hasAttribution ? (\n <Stack spacing={1}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Where this lead came from'}\n </Typography>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"lead\"\n recordId={leadId}\n />\n {/* Attribution is which campaign brought the person; the\n filing on the Campaigns card is the team's own (AGL-3274),\n and a reader who sees one campaign here and another there\n is owed the difference. */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filing is separate — see Filed under campaigns.'}\n </Typography>\n </Stack>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nLeadHistoryCard.displayName = 'LeadHistoryCard'\n\nexport default LeadHistoryCard\n"],"names":["CAPTURED_BY_HOST_FIELD","CONTACT_SOURCE_LABELS","pluginDocsHelp","CrmRecordAttributionZone","useHasCrmRecordAttribution","CardDisplay","Chip","Stack","Typography","leadSourceLabel","source","import","form","startsWith","slice","length","leadSources","lead","sources","Array","isArray","map","String","leadTimeLabel","value","Number","isFinite","Date","toLocaleString","asDate","toDate","Fact","props","spacing","variant","color","label","component","children","LeadHistoryCard","hostId","leadId","hasAttribution","capturedBy","count","header","help","anchor","contentGutterX","contentGutterY","direction","xs","md","join","sx","flexWrap","rowGap","size","recordKind","recordId","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,sBAAsB,EAAEC,qBAAqB,EAAEC,cAAc,QAAQ,eAAc;AAC5F,4EAA4E;AAC5E,4EAA4E;AAC5E,4EAA4E;AAC5E,kBAAkB;AAClB,SACEC,wBAAwB,EACxBC,0BAA0B,QACrB,4BAAwB;AAC/B,SAASC,WAAW,QAAQ,uBAAsB;AAClD,SAASC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAEvD;;;;;;;CAOC,GACD,OAAO,SAASC,gBAAgBC,MAAc;IAC5C,IAAIA,WAAW,UAAU,OAAO;IAChC,IAAIA,WAAW,WAAW,OAAO;IACjC,IAAIA,WAAW,UAAU,OAAOT,sBAAsBU,MAAM;IAC5D,IAAID,WAAW,QAAQ,OAAOT,sBAAsBW,IAAI;IACxD,0DAA0D;IAC1D,IAAIF,WAAW,UAAU,OAAO;IAChC,IAAIA,WAAW,OAAO,OAAO;IAC7B,IAAIA,OAAOG,UAAU,CAAC,UAAU,OAAO,CAAC,KAAK,EAAEH,OAAOI,KAAK,CAAC,QAAQC,MAAM,GAAG;IAC7E,OAAOL;AACT;AAEA,kFAAkF,GAClF,OAAO,SAASM,YAAYC,IAA6B;IACvD,MAAMC,UAAUD,IAAI,CAAC,UAAU;IAC/B,IAAIE,MAAMC,OAAO,CAACF,YAAYA,QAAQH,MAAM,EAAE;QAC5C,OAAOG,QAAQG,GAAG,CAAC,CAACX,SAAWY,OAAOZ;IACxC;IACA,OAAO,OAAOO,IAAI,CAAC,SAAS,KAAK,YAAYA,IAAI,CAAC,SAAS,GAAG;QAACA,IAAI,CAAC,SAAS;KAAC,GAAG,EAAE;AACrF;AAEA,4EAA4E,GAC5E,OAAO,SAASM,cAAcC,KAAc;QAI3B;IAHf,IAAI,OAAOA,UAAU,YAAYC,OAAOC,QAAQ,CAACF,QAAQ;QACvD,OAAO,IAAIG,KAAKH,OAAOI,cAAc;IACvC;IACA,MAAMC,SAAUL,0BAAD,gBAAA,AAACA,MAAsDM,MAAM,qBAA7D,mBAACN;IAChB,OAAOK,SAASA,OAAOD,cAAc,KAAK;AAC5C;AAEA,SAASG,KAAKC,KAAmD;IAC/D,qBACE,MAACzB;QAAM0B,SAAS;;0BACd,KAACzB;gBAAW0B,SAAQ;gBAAUC,OAAM;0BACjCH,MAAMI,KAAK;;0BAEd,KAAC5B;gBAAW0B,SAAQ;gBAAQG,WAAU;0BACnCL,MAAMM,QAAQ;;;;AAIvB;AAQA;;;;;;;;CAQC,GACD,OAAO,SAASC,gBAAgBP,KAA2B;QAOpCf,uBAWIA,qBAGAA;IApBzB,MAAM,EAAEuB,MAAM,EAAEC,MAAM,EAAExB,IAAI,EAAE,GAAGe;IACjC,wEAAwE;IACxE,2BAA2B;IAC3B,MAAMU,iBAAiBtC;IACvB,MAAMc,UAAUF,YAAYC;IAC5B,MAAM0B,aAAa1B,IAAI,CAACjB,uBAAuB;IAC/C,MAAM4C,QAAQnB,QAAOR,wBAAAA,IAAI,CAAC,kBAAkB,YAAvBA,wBAA2B,MAAOC,CAAAA,QAAQH,MAAM,GAAG,IAAI,CAAA;IAC5E,qBACE,KAACV;QACCwC,QAAQ;QACRC,MAAM5C,eAAe,YAAY;YAAE6C,QAAQ;QAAgB;QAC3DC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAC1C;YAAM0B,SAAS;;8BACd,MAAC1B;oBAAM2C,WAAW;wBAAEC,IAAI;wBAAUC,IAAI;oBAAM;oBAAGnB,SAAS;;sCACtD,KAACF;4BAAKK,OAAM;sCACTb,eAAcN,sBAAAA,IAAI,CAAC,gBAAgB,YAArBA,sBAAyBA,IAAI,CAAC,YAAY;;sCAE3D,KAACc;4BAAKK,OAAM;sCACTb,eAAcN,qBAAAA,IAAI,CAAC,eAAe,YAApBA,qBAAwBA,IAAI,CAAC,YAAY;;sCAE1D,KAACc;4BAAKK,OAAM;sCAAYQ,QAAQtB,OAAOsB,SAAS;;sCAChD,KAACb;4BAAKK,OAAM;sCACTjB,MAAMC,OAAO,CAACuB,eAAeA,WAAW5B,MAAM,GAC3C4B,WAAWtB,GAAG,CAACC,QAAQ+B,IAAI,CAAC,QAC5Bb;;;;8BAGR,KAACT;oBAAKK,OAAM;8BACTlB,QAAQH,MAAM,iBACb,KAACR;wBAAM2C,WAAU;wBAAMjB,SAAS;wBAAKqB,IAAI;4BAAEC,UAAU;4BAAQC,QAAQ;wBAAI;kCACtEtC,QAAQG,GAAG,CAAC,CAACX,uBACZ,KAACJ;gCAAkBmD,MAAK;gCAAQvB,SAAQ;gCAAWE,OAAO3B,gBAAgBC;+BAA/DA;yBAIf;;gBAGHgC,+BACC,MAACnC;oBAAM0B,SAAS;;sCACd,KAACzB;4BAAW0B,SAAQ;4BAAUC,OAAM;sCACjC;;sCAEH,KAAChC;4BACCqC,QAAQA;4BACRkB,YAAW;4BACXC,UAAUlB;;sCAMZ,KAACjC;4BAAW0B,SAAQ;4BAAUC,OAAM;sCACjC;;;qBAGH;;;;AAIZ;AACAI,gBAAgBqB,WAAW,GAAG;AAE9B,eAAerB,gBAAe"}
@@ -9,6 +9,12 @@ import type { OrgMemberOptions } from '../hooks/use-org-member-options';
9
9
  export declare const CONVERT_PENDING_ERASURE_REASON = "An erasure is pending for this person";
10
10
  export interface LeadPropertiesCardProps {
11
11
  hostId: string;
12
+ /**
13
+ * The org the site belongs to, as the page already resolved it — the
14
+ * custom lead fields are ORG-wide (AGL-3272), and a second lookup here
15
+ * would be one more read per record page for an answer already in hand.
16
+ */
17
+ orgId: string | null;
12
18
  leadId: string;
13
19
  lead: Record<string, unknown> & CrmLeadFields;
14
20
  leadStatus: FirestoreDocStatus;
@@ -26,6 +32,12 @@ export interface LeadPropertiesCardProps {
26
32
  extraMenuItems?: RowActionsMenuItem[];
27
33
  /** What the page shows above the facts — the erasure-pending state. */
28
34
  banner?: React.ReactNode;
35
+ /**
36
+ * Chips the page adds after the owner — the campaigns the lead is filed
37
+ * under (AGL-3274), named from the containers the page reads once for
38
+ * this header and the Campaigns card below it.
39
+ */
40
+ extraChips?: React.ReactNode;
29
41
  /**
30
42
  * An erasure request is waiting on this person (AGL-2623). Convert stays
31
43
  * on the page but is refused with the reason, the way the overflow's items
@@ -23,7 +23,10 @@ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
23
23
  import { useFirestore, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
24
24
  import { Alert, Button, FormControl, InputLabel, MenuItem, Select, Stack, TextField, Tooltip, Typography } from "@mui/material";
25
25
  import { deleteField, doc, serverTimestamp, updateDoc } from "firebase/firestore";
26
- import { useEffect, useId, useState } from "react";
26
+ import { useEffect, useId, useMemo, useState } from "react";
27
+ import { useContactFieldDefinitions } from "../hooks/use-contact-field-definitions.js";
28
+ import { crmCustomDraftChanges, crmCustomDraftMissingRequired, crmCustomDraftValue, crmCustomDraftWrites } from "../model/crm-custom-draft.js";
29
+ import { CrmCustomFieldControl } from "./crm-custom-field-control.js";
27
30
  import { crmRoutes } from "../model/crm-routes.js";
28
31
  import { addressDraftFrom, ContactAddressFields } from "./contact-address-fields.js";
29
32
  import { CrmCallButton, CrmPhoneLink } from "./crm-call-actions.js";
@@ -90,7 +93,7 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
90
93
  * the actions become links to what the conversion made.
91
94
  */ export function LeadPropertiesCard(props) {
92
95
  var _lead_phone, _lead_notes, _lead_email, _lead_email1, _lead_name;
93
- const { hostId, leadId, lead, leadStatus, fromCache, basePath, roster, onConvert, onUnqualify, extraMenuItems = [], banner, erasurePending = false, org } = props;
96
+ const { hostId, orgId, leadId, lead, leadStatus, fromCache, basePath, roster, onConvert, onUnqualify, extraMenuItems = [], banner, extraChips, erasurePending = false, org } = props;
94
97
  const firestore = useFirestore();
95
98
  const { enqueueSnackbar } = useSnackbar();
96
99
  const routes = crmRoutes(basePath);
@@ -131,6 +134,23 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
131
134
  lead.tags,
132
135
  lead.address
133
136
  ]);
137
+ /*
138
+ * THE ORG'S OWN LEAD FIELDS (AGL-3272), edited under the same Save as
139
+ * the profile: one button over one card, so a person filling a lead in
140
+ * does not have to find two.
141
+ *
142
+ * The draft holds only the keys the reader touched, and Save writes the
143
+ * difference as dotted paths — a `custom` map written whole would take
144
+ * out every key this card did not show, which is what a retired field's
145
+ * values and an integration's writes sit under.
146
+ */ const fields = useContactFieldDefinitions(orgId, 'lead');
147
+ const storedCustom = useMemo(()=>{
148
+ var _lead_custom;
149
+ return (_lead_custom = lead.custom) != null ? _lead_custom : {};
150
+ }, [
151
+ lead.custom
152
+ ]);
153
+ const [custom, setCustom] = useState({});
134
154
  const editProfile = (key, value)=>{
135
155
  setProfile((current)=>_extends({}, current, {
136
156
  [key]: value
@@ -180,6 +200,12 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
180
200
  }
181
201
  setNotesDirty(false);
182
202
  };
203
+ /*
204
+ * What Save is offered for: a profile field edited, or a custom value
205
+ * that differs from the stored map. Touched-and-put-back does not count
206
+ * — the draft keeps the key either way, and a Save that wrote nothing
207
+ * would still bump `updatedAt`.
208
+ */ const dirty = profileDirty || crmCustomDraftChanges(storedCustom, custom).length > 0;
183
209
  const saveProfile = async ()=>{
184
210
  const { patch, errors } = Aglyn.normalizeCrmLeadProfile({
185
211
  company: profile.company,
@@ -192,12 +218,23 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
192
218
  });
193
219
  setProfileErrors(errors);
194
220
  if (Object.keys(errors).length) return;
221
+ /*
222
+ * A required field the reader CLEARED is refused; one the lead has
223
+ * always lacked is not this save's to demand, or a field added after
224
+ * the lead was captured would block every later edit to its company.
225
+ */ const missing = crmCustomDraftMissingRequired(fields.active, storedCustom, custom, 'edit');
226
+ if (missing.length) {
227
+ enqueueSnackbar(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`, {
228
+ variant: 'warning'
229
+ });
230
+ return;
231
+ }
195
232
  setSavingProfile(true);
196
233
  const verdict = await writeGuardedBySeed({
197
234
  subject: 'lead',
198
235
  fromCache,
199
236
  unreadable: leadStatus === 'error'
200
- }, ()=>write(profileWrite(patch), 'Lead saved'));
237
+ }, ()=>write(_extends({}, profileWrite(patch), crmCustomDraftWrites(storedCustom, custom)), 'Lead saved'));
201
238
  setSavingProfile(false);
202
239
  if (!verdict.ok) {
203
240
  var _verdict_message;
@@ -207,6 +244,9 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
207
244
  return;
208
245
  }
209
246
  setProfileDirty(false);
247
+ // The draft is spent: what it held is stored, and the controls read
248
+ // the document again.
249
+ setCustom({});
210
250
  };
211
251
  const consent = Aglyn.readMarketingBasis(lead, Aglyn.soloConsentGroup(hostId));
212
252
  const consentLine = consent.basis === 'granted' ? `Opted in to marketing${consent.basisAtMs ? ` on ${new Date(consent.basisAtMs).toLocaleDateString()}` : ''}` : consent.basis === 'declined' ? 'Declined marketing' : 'No marketing consent recorded — this lead cannot be emailed marketing';
@@ -291,7 +331,8 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
291
331
  /*#__PURE__*/ _jsx(CrmRecordChip, {
292
332
  label: "Owner",
293
333
  value: lead.ownerUid ? roster.labelFor(lead.ownerUid) : undefined
294
- })
334
+ }),
335
+ extraChips
295
336
  ]
296
337
  }),
297
338
  children: /*#__PURE__*/ _jsxs(Stack, {
@@ -548,6 +589,23 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
548
589
  onChange: (next)=>editProfile('address', next),
549
590
  disabled: converted
550
591
  }),
592
+ fields.active.length ? /*#__PURE__*/ _jsxs(_Fragment, {
593
+ children: [
594
+ /*#__PURE__*/ _jsx(Typography, {
595
+ variant: "caption",
596
+ color: "text.secondary",
597
+ children: 'Custom fields'
598
+ }),
599
+ fields.active.map((definition)=>/*#__PURE__*/ _jsx(CrmCustomFieldControl, {
600
+ definition: definition,
601
+ value: crmCustomDraftValue(storedCustom, custom, definition.key),
602
+ onChange: (value)=>setCustom((current)=>_extends({}, current, {
603
+ [definition.key]: value
604
+ })),
605
+ disabled: converted || savingProfile
606
+ }, definition.$id))
607
+ ]
608
+ }) : null,
551
609
  converted ? null : /*#__PURE__*/ _jsx(Stack, {
552
610
  direction: "row",
553
611
  spacing: 1,
@@ -558,7 +616,7 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
558
616
  size: "small",
559
617
  variant: "contained",
560
618
  onClick: ()=>void saveProfile(),
561
- disabled: !profileDirty || savingProfile,
619
+ disabled: !dirty || savingProfile,
562
620
  children: 'Save'
563
621
  })
564
622
  })