@aglyn/plugins-crm 1.0.0-beta.162 → 1.0.0-beta.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-crm",
3
- "version": "1.0.0-beta.162",
3
+ "version": "1.0.0-beta.163",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.162",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.162",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.162",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.162",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.162",
33
- "@aglyn/shared-ui-next": "1.0.0-beta.162",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.162",
35
- "@aglyn/shared-util-email": "1.0.0-beta.162",
36
- "@aglyn/shared-util-http": "1.0.0-beta.162",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.162",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.162",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.162",
28
+ "@aglyn/aglyn": "1.0.0-beta.163",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.163",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.163",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.163",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.163",
33
+ "@aglyn/shared-ui-next": "1.0.0-beta.163",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.163",
35
+ "@aglyn/shared-util-email": "1.0.0-beta.163",
36
+ "@aglyn/shared-util-http": "1.0.0-beta.163",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.163",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.163",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.163",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/utilities": "^3.2.2",
42
42
  "@swc/helpers": "0.5.23",
@@ -41,9 +41,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
41
41
  * twice. A lead already at the asked status is skipped rather than rewritten
42
42
  * so "Nothing to change" means what it says.
43
43
  */ import { CRM_LEAD_STATUS_LABELS, crmLeadStatus, isCrmLeadOpen } from "@aglyn/aglyn";
44
- import { useFirestore } from "@aglyn/tenant-feature-instance";
44
+ import CampaignPicker from "@aglyn/shared-ui-email-campaigns/components/campaign-picker.component";
45
+ import { useFirestore, useHostCampaigns } from "@aglyn/tenant-feature-instance";
45
46
  import { Button, MenuItem, TextField } from "@mui/material";
46
- import { deleteField, doc, serverTimestamp } from "firebase/firestore";
47
+ import { arrayUnion, deleteField, doc, serverTimestamp } from "firebase/firestore";
47
48
  import { useCallback, useMemo, useState } from "react";
48
49
  import { useCrmBulkApply } from "../hooks/use-crm-bulk-apply.js";
49
50
  import { downloadTextFile } from "../model/contacts-csv.js";
@@ -60,7 +61,8 @@ const NOUN = {
60
61
  const ACTION_TITLES = {
61
62
  owner: 'Set the owner',
62
63
  status: 'Set the status',
63
- unqualify: 'Unqualify'
64
+ unqualify: 'Unqualify',
65
+ campaign: 'Add to campaign'
64
66
  };
65
67
  /** The statuses the bar can set — the open ones; closing goes through Unqualify. */ const SETTABLE_STATUSES = [
66
68
  'new',
@@ -90,6 +92,15 @@ function LeadsBulkBarBody(props) {
90
92
  ]);
91
93
  const [pending, setPending] = useState(null);
92
94
  const [value, setValue] = useState('');
95
+ /*
96
+ * The campaigns to add the selection to (AGL-3254), and the site's
97
+ * containers the picker offers — read only while that dialog is open.
98
+ * Under a site alone: a campaign belongs to one site, and at the
99
+ * organization level a selection spans them.
100
+ */ const [campaignIds, setCampaignIds] = useState([]);
101
+ const campaigns = useHostCampaigns(hostId != null ? hostId : undefined, {
102
+ enabled: pending === 'campaign' && Boolean(hostId)
103
+ });
93
104
  // The reference a write names is the row's own site and document.
94
105
  const writers = useMemo(()=>{
95
106
  const byId = new Map(rows.map((row)=>[
@@ -107,6 +118,7 @@ function LeadsBulkBarBody(props) {
107
118
  ]);
108
119
  const openAction = (action)=>{
109
120
  setValue('');
121
+ setCampaignIds([]);
110
122
  setPending(action);
111
123
  };
112
124
  const runPlan = useCallback((plan, done)=>apply({
@@ -142,6 +154,26 @@ function LeadsBulkBarBody(props) {
142
154
  }, (count)=>value ? `Owner set on ${countNoun(count, NOUN)}` : `Owner cleared on ${countNoun(count, NOUN)}`);
143
155
  return;
144
156
  }
157
+ if (action === 'campaign') {
158
+ // Added to what each lead carries — `arrayUnion`, the way a sequence's
159
+ // enroll and the import add one — never in place of it.
160
+ for (const lead of selectedRows){
161
+ writes.push({
162
+ id: lead.$id,
163
+ label: labelOf(lead),
164
+ kind: 'update',
165
+ data: {
166
+ campaignIds: arrayUnion(...campaignIds),
167
+ updatedAt: serverTimestamp()
168
+ }
169
+ });
170
+ }
171
+ await runPlan({
172
+ writes,
173
+ skipped
174
+ }, (count)=>`Added ${countNoun(count, NOUN)} to ${campaignIds.length === 1 ? 'the campaign' : `${campaignIds.length} campaigns`}`);
175
+ return;
176
+ }
145
177
  if (action === 'status') {
146
178
  const status = value;
147
179
  for (const lead of selectedRows){
@@ -209,6 +241,7 @@ function LeadsBulkBarBody(props) {
209
241
  }, [
210
242
  pending,
211
243
  value,
244
+ campaignIds,
212
245
  selectedRows,
213
246
  runPlan
214
247
  ]);
@@ -218,7 +251,7 @@ function LeadsBulkBarBody(props) {
218
251
  selectedRows,
219
252
  csv
220
253
  ]);
221
- const canApply = pending === 'owner' || (pending === 'status' ? Boolean(value) : Boolean(value.trim()));
254
+ const canApply = pending === 'owner' || (pending === 'campaign' ? campaignIds.length > 0 : pending === 'status' ? Boolean(value) : Boolean(value.trim()));
222
255
  return /*#__PURE__*/ _jsxs(CrmBulkBarFrame, {
223
256
  count: selected.length,
224
257
  noun: NOUN,
@@ -233,7 +266,7 @@ function LeadsBulkBarBody(props) {
233
266
  noun: NOUN,
234
267
  busy: busy,
235
268
  canApply: canApply,
236
- applyLabel: pending === 'unqualify' ? 'Unqualify' : undefined,
269
+ applyLabel: pending === 'unqualify' ? 'Unqualify' : pending === 'campaign' ? 'Add' : undefined,
237
270
  onClose: ()=>setPending(null),
238
271
  onApply: ()=>void handleApply(),
239
272
  children: pending === 'owner' ? /*#__PURE__*/ _jsx(LeadOwnerSelect, {
@@ -265,6 +298,13 @@ function LeadsBulkBarBody(props) {
265
298
  maxLength: UNQUALIFY_REASON_MAX
266
299
  }
267
300
  }
301
+ }) : pending === 'campaign' ? /*#__PURE__*/ _jsx(CampaignPicker, {
302
+ options: campaigns.options,
303
+ value: campaignIds,
304
+ onChange: setCampaignIds,
305
+ helperText: "Added to the campaigns each selected lead is already in. It does not decide who a campaign mails.",
306
+ empty: campaigns.ready && !campaigns.options.length,
307
+ emptyText: "This site has no campaigns yet. Create one from Marketing to file leads under it."
268
308
  }) : null
269
309
  }),
270
310
  children: [
@@ -286,6 +326,12 @@ function LeadsBulkBarBody(props) {
286
326
  onClick: ()=>openAction('unqualify'),
287
327
  children: 'Unqualify'
288
328
  }),
329
+ hostId ? /*#__PURE__*/ _jsx(Button, {
330
+ size: "small",
331
+ disabled: busy,
332
+ onClick: ()=>openAction('campaign'),
333
+ children: 'Add to campaign'
334
+ }) : null,
289
335
  /*#__PURE__*/ _jsx(Button, {
290
336
  size: "small",
291
337
  disabled: busy,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/leads-bulk-bar.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\n/**\n * The bar over the Leads table, for whatever rows are ticked (AGL-2662).\n *\n * Hand them to an owner, set their status, unqualify them with one reason,\n * or take them into a spreadsheet. Every act here is a document write and\n * nothing more — a lead's status and owner are the team's own notes on a\n * capture, with no event and no notification behind them, which is why the\n * row's inline select writes them client-direct — so the whole bar goes\n * through the shared batched runner under the same rules.\n *\n * ## A row names its own site\n *\n * A lead lives at `hosts/{hostId}/leads/{leadId}`, and at the organization\n * level the selection spans sites, so the reference for a write is built\n * from the ROW rather than from a scope the bar was handed: the row knows\n * its site, the bar need not. Under a site every row names the same one.\n *\n * ## What is declined, by name\n *\n * A converted lead's status IS its conversion, stamped by the route, and\n * the bar leaves it alone under Set status and Unqualify the way the row\n * menu and the inline select do. A lead already closed is not unqualified\n * twice. A lead already at the asked status is skipped rather than rewritten\n * so \"Nothing to change\" means what it says.\n */\n\nimport {\n CRM_LEAD_STATUS_LABELS,\n type CrmLeadFields,\n type CrmLeadStatus,\n crmLeadStatus,\n isCrmLeadOpen,\n} from '@aglyn/aglyn'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { Button, MenuItem, TextField } from '@mui/material'\nimport { deleteField, doc, serverTimestamp } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useCrmBulkApply } from '../hooks/use-crm-bulk-apply'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { downloadTextFile } from '../model/contacts-csv'\nimport {\n type CrmBulkPlan,\n type CrmBulkSkip,\n type CrmBulkWrite,\n crmBulkWriters,\n runCrmBulkWrites,\n} from '../model/crm-bulk-writes'\nimport { type LeadCsvOptions, leadsCsv } from '../model/leads-csv'\nimport {\n type CrmBulkNoun,\n CrmBulkBarFrame,\n CrmBulkValueDialog,\n countNoun,\n} from './crm-bulk-bar-frame'\nimport CrmExportAllButton from './crm-export-all-button'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { UNQUALIFY_REASON_MAX } from './lead-unqualify-dialog'\n\n/**\n * One row of the Leads list as the bar needs it: the document's fields,\n * the grid's key, and which document under which site a write names.\n */\nexport type LeadBulkRow = Record<string, unknown> &\n CrmLeadFields & { $id: string; leadId: string; hostId: string }\n\nexport interface LeadsBulkBarProps {\n rows: readonly LeadBulkRow[]\n selected: readonly string[]\n onSelectedChange: (ids: string[]) => void\n /** The section's roster — already read for the Owner column. */\n roster: OrgMemberOptions\n /** How the export names the owner and, at the org level, the site — the list's own. */\n csv?: LeadCsvOptions\n /** The organization these leads belong to; null while it is unresolved. */\n orgId?: string | null\n /**\n * The site the list is read under, or `null` at the organization level\n * where the complete export spans every site (AGL-2662).\n */\n hostId?: string | null\n}\n\nconst NOUN: CrmBulkNoun = { singular: 'lead', plural: 'leads' }\n\ntype PendingAction = 'owner' | 'status' | 'unqualify'\n\nconst ACTION_TITLES: Record<PendingAction, string> = {\n owner: 'Set the owner',\n status: 'Set the status',\n unqualify: 'Unqualify',\n}\n\n/** The statuses the bar can set — the open ones; closing goes through Unqualify. */\nconst SETTABLE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\n/** The label a report lists a lead under — its name, else its address. */\nconst labelOf = (lead: LeadBulkRow): string =>\n String(lead['name'] || lead['email'] || lead.leadId)\n\nexport function LeadsBulkBar(props: LeadsBulkBarProps) {\n if (!props.selected.length) return null\n return <LeadsBulkBarBody {...props} />\n}\nLeadsBulkBar.displayName = 'LeadsBulkBar'\n\nfunction LeadsBulkBarBody(props: LeadsBulkBarProps) {\n const { rows, selected, onSelectedChange, roster, csv } = props\n const orgId = props.orgId ?? null\n const hostId = props.hostId ?? null\n const firestore = useFirestore()\n const { busy, report, apply, dismissReport } = useCrmBulkApply({ recordKind: 'lead' })\n\n const selectedRows = useMemo(() => {\n const chosen = new Set(selected)\n return rows.filter((row) => chosen.has(row.$id))\n }, [rows, selected])\n\n const [pending, setPending] = useState<PendingAction | null>(null)\n const [value, setValue] = useState('')\n\n // The reference a write names is the row's own site and document.\n const writers = useMemo(() => {\n const byId = new Map(rows.map((row) => [row.$id, row]))\n return crmBulkWriters(firestore, (id) => {\n const row = byId.get(id)\n return doc(firestore, 'hosts', row?.hostId ?? '', 'leads', row?.leadId ?? id)\n })\n }, [firestore, rows])\n\n const openAction = (action: PendingAction) => {\n setValue('')\n setPending(action)\n }\n\n const runPlan = useCallback(\n (plan: CrmBulkPlan, done: (count: number) => string) =>\n apply({\n attempted: plan.writes.length,\n skipped: plan.skipped,\n job: () => runCrmBulkWrites(writers, plan.writes, (write) => write.label),\n done,\n }),\n [apply, writers],\n )\n\n const handleApply = useCallback(async () => {\n if (!pending) return\n const action = pending\n setPending(null)\n const writes: CrmBulkWrite[] = []\n const skipped: CrmBulkSkip[] = []\n if (action === 'owner') {\n for (const lead of selectedRows) {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: { ownerUid: value ? value : deleteField(), updatedAt: serverTimestamp() },\n })\n }\n await runPlan(\n { writes, skipped },\n (count) => (value ? `Owner set on ${countNoun(count, NOUN)}` : `Owner cleared on ${countNoun(count, NOUN)}`),\n )\n return\n }\n if (action === 'status') {\n const status = value as CrmLeadStatus\n for (const lead of selectedRows) {\n const current = crmLeadStatus(lead)\n if (lead.convertedContactId) {\n skipped.push({ label: labelOf(lead), reason: 'was converted' })\n } else if (current === status) {\n skipped.push({\n label: labelOf(lead),\n reason: `already ${CRM_LEAD_STATUS_LABELS[status]}`,\n })\n } else {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: {\n status,\n // Reopening an unqualified lead clears its reason, as the row does.\n ...(current === 'unqualified' ? { unqualifiedReason: deleteField() } : {}),\n updatedAt: serverTimestamp(),\n },\n })\n }\n }\n await runPlan(\n { writes, skipped },\n (count) => `Status set on ${countNoun(count, NOUN)}`,\n )\n return\n }\n const reason = value.trim().slice(0, UNQUALIFY_REASON_MAX)\n for (const lead of selectedRows) {\n if (lead.convertedContactId) {\n skipped.push({ label: labelOf(lead), reason: 'was converted' })\n } else if (!isCrmLeadOpen(lead)) {\n skipped.push({ label: labelOf(lead), reason: 'is already closed' })\n } else {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: {\n status: 'unqualified' satisfies CrmLeadStatus,\n unqualifiedReason: reason,\n updatedAt: serverTimestamp(),\n },\n })\n }\n }\n await runPlan(\n { writes, skipped },\n (count) => `Marked ${countNoun(count, NOUN)} unqualified`,\n )\n }, [pending, value, selectedRows, runPlan])\n\n const handleExport = useCallback(() => {\n downloadTextFile('leads-selected.csv', 'text/csv', leadsCsv(selectedRows, csv))\n }, [selectedRows, csv])\n\n const canApply =\n pending === 'owner' || (pending === 'status' ? Boolean(value) : Boolean(value.trim()))\n\n return (\n <CrmBulkBarFrame\n count={selected.length}\n noun={NOUN}\n busy={busy}\n onClear={() => onSelectedChange([])}\n report={report}\n onDismissReport={dismissReport}\n extras={\n <CrmBulkValueDialog\n open={pending !== null}\n title={pending ? ACTION_TITLES[pending] : ''}\n count={selected.length}\n noun={NOUN}\n busy={busy}\n canApply={canApply}\n applyLabel={pending === 'unqualify' ? 'Unqualify' : undefined}\n onClose={() => setPending(null)}\n onApply={() => void handleApply()}\n >\n {pending === 'owner' ? (\n <LeadOwnerSelect value={value} onChange={setValue} roster={roster} size=\"medium\" />\n ) : pending === 'status' ? (\n <TextField\n select\n size=\"small\"\n label=\"Status\"\n value={value}\n onChange={(event) => setValue(event.target.value)}\n helperText=\"Closing a lead goes through Unqualify, which asks why.\"\n >\n {SETTABLE_STATUSES.map((status) => (\n <MenuItem key={status} value={status}>\n {CRM_LEAD_STATUS_LABELS[status]}\n </MenuItem>\n ))}\n </TextField>\n ) : pending === 'unqualify' ? (\n <TextField\n label=\"Reason\"\n value={value}\n onChange={(event) => setValue(event.target.value)}\n multiline\n minRows={2}\n autoFocus\n helperText=\"One reason for every selected lead, kept on each so it can be counted later.\"\n slotProps={{ htmlInput: { maxLength: UNQUALIFY_REASON_MAX } }}\n />\n ) : null}\n </CrmBulkValueDialog>\n }\n >\n <Button size=\"small\" disabled={busy} onClick={() => openAction('owner')}>\n {'Set owner'}\n </Button>\n <Button size=\"small\" disabled={busy} onClick={() => openAction('status')}>\n {'Set status'}\n </Button>\n <Button size=\"small\" disabled={busy} onClick={() => openAction('unqualify')}>\n {'Unqualify'}\n </Button>\n <Button size=\"small\" disabled={busy} onClick={handleExport}>\n {'Export CSV'}\n </Button>\n {/*\n The selection's file is the rows on screen; this one is the whole\n collection, streamed by the server (AGL-2662).\n */}\n <CrmExportAllButton\n resource=\"leads\"\n orgId={orgId}\n hostId={hostId}\n disabled={busy}\n />\n </CrmBulkBarFrame>\n )\n}\nLeadsBulkBarBody.displayName = 'LeadsBulkBarBody'\n\nexport default LeadsBulkBar\n"],"names":["CRM_LEAD_STATUS_LABELS","crmLeadStatus","isCrmLeadOpen","useFirestore","Button","MenuItem","TextField","deleteField","doc","serverTimestamp","useCallback","useMemo","useState","useCrmBulkApply","downloadTextFile","crmBulkWriters","runCrmBulkWrites","leadsCsv","CrmBulkBarFrame","CrmBulkValueDialog","countNoun","CrmExportAllButton","LeadOwnerSelect","UNQUALIFY_REASON_MAX","NOUN","singular","plural","ACTION_TITLES","owner","status","unqualify","SETTABLE_STATUSES","labelOf","lead","String","leadId","LeadsBulkBar","props","selected","length","LeadsBulkBarBody","displayName","rows","onSelectedChange","roster","csv","orgId","hostId","firestore","busy","report","apply","dismissReport","recordKind","selectedRows","chosen","Set","filter","row","has","$id","pending","setPending","value","setValue","writers","byId","Map","map","id","get","openAction","action","runPlan","plan","done","attempted","writes","skipped","job","write","label","handleApply","push","kind","data","ownerUid","updatedAt","count","current","convertedContactId","reason","unqualifiedReason","trim","slice","handleExport","canApply","Boolean","noun","onClear","onDismissReport","extras","open","title","applyLabel","undefined","onClose","onApply","onChange","size","select","event","target","helperText","multiline","minRows","autoFocus","slotProps","htmlInput","maxLength","disabled","onClick","resource"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/leads-bulk-bar.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\n/**\n * The bar over the Leads table, for whatever rows are ticked (AGL-2662).\n *\n * Hand them to an owner, set their status, unqualify them with one reason,\n * or take them into a spreadsheet. Every act here is a document write and\n * nothing more — a lead's status and owner are the team's own notes on a\n * capture, with no event and no notification behind them, which is why the\n * row's inline select writes them client-direct — so the whole bar goes\n * through the shared batched runner under the same rules.\n *\n * ## A row names its own site\n *\n * A lead lives at `hosts/{hostId}/leads/{leadId}`, and at the organization\n * level the selection spans sites, so the reference for a write is built\n * from the ROW rather than from a scope the bar was handed: the row knows\n * its site, the bar need not. Under a site every row names the same one.\n *\n * ## What is declined, by name\n *\n * A converted lead's status IS its conversion, stamped by the route, and\n * the bar leaves it alone under Set status and Unqualify the way the row\n * menu and the inline select do. A lead already closed is not unqualified\n * twice. A lead already at the asked status is skipped rather than rewritten\n * so \"Nothing to change\" means what it says.\n */\n\nimport {\n CRM_LEAD_STATUS_LABELS,\n type CrmLeadFields,\n type CrmLeadStatus,\n crmLeadStatus,\n isCrmLeadOpen,\n} from '@aglyn/aglyn'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useFirestore, useHostCampaigns } from '@aglyn/tenant-feature-instance'\nimport { Button, MenuItem, TextField } from '@mui/material'\nimport { arrayUnion, deleteField, doc, serverTimestamp } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useCrmBulkApply } from '../hooks/use-crm-bulk-apply'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { downloadTextFile } from '../model/contacts-csv'\nimport {\n type CrmBulkPlan,\n type CrmBulkSkip,\n type CrmBulkWrite,\n crmBulkWriters,\n runCrmBulkWrites,\n} from '../model/crm-bulk-writes'\nimport { type LeadCsvOptions, leadsCsv } from '../model/leads-csv'\nimport {\n type CrmBulkNoun,\n CrmBulkBarFrame,\n CrmBulkValueDialog,\n countNoun,\n} from './crm-bulk-bar-frame'\nimport CrmExportAllButton from './crm-export-all-button'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { UNQUALIFY_REASON_MAX } from './lead-unqualify-dialog'\n\n/**\n * One row of the Leads list as the bar needs it: the document's fields,\n * the grid's key, and which document under which site a write names.\n */\nexport type LeadBulkRow = Record<string, unknown> &\n CrmLeadFields & { $id: string; leadId: string; hostId: string }\n\nexport interface LeadsBulkBarProps {\n rows: readonly LeadBulkRow[]\n selected: readonly string[]\n onSelectedChange: (ids: string[]) => void\n /** The section's roster — already read for the Owner column. */\n roster: OrgMemberOptions\n /** How the export names the owner and, at the org level, the site — the list's own. */\n csv?: LeadCsvOptions\n /** The organization these leads belong to; null while it is unresolved. */\n orgId?: string | null\n /**\n * The site the list is read under, or `null` at the organization level\n * where the complete export spans every site (AGL-2662).\n */\n hostId?: string | null\n}\n\nconst NOUN: CrmBulkNoun = { singular: 'lead', plural: 'leads' }\n\ntype PendingAction = 'owner' | 'status' | 'unqualify' | 'campaign'\n\nconst ACTION_TITLES: Record<PendingAction, string> = {\n owner: 'Set the owner',\n status: 'Set the status',\n unqualify: 'Unqualify',\n campaign: 'Add to campaign',\n}\n\n/** The statuses the bar can set — the open ones; closing goes through Unqualify. */\nconst SETTABLE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\n/** The label a report lists a lead under — its name, else its address. */\nconst labelOf = (lead: LeadBulkRow): string =>\n String(lead['name'] || lead['email'] || lead.leadId)\n\nexport function LeadsBulkBar(props: LeadsBulkBarProps) {\n if (!props.selected.length) return null\n return <LeadsBulkBarBody {...props} />\n}\nLeadsBulkBar.displayName = 'LeadsBulkBar'\n\nfunction LeadsBulkBarBody(props: LeadsBulkBarProps) {\n const { rows, selected, onSelectedChange, roster, csv } = props\n const orgId = props.orgId ?? null\n const hostId = props.hostId ?? null\n const firestore = useFirestore()\n const { busy, report, apply, dismissReport } = useCrmBulkApply({ recordKind: 'lead' })\n\n const selectedRows = useMemo(() => {\n const chosen = new Set(selected)\n return rows.filter((row) => chosen.has(row.$id))\n }, [rows, selected])\n\n const [pending, setPending] = useState<PendingAction | null>(null)\n const [value, setValue] = useState('')\n /*\n * The campaigns to add the selection to (AGL-3254), and the site's\n * containers the picker offers — read only while that dialog is open.\n * Under a site alone: a campaign belongs to one site, and at the\n * organization level a selection spans them.\n */\n const [campaignIds, setCampaignIds] = useState<string[]>([])\n const campaigns = useHostCampaigns(hostId ?? undefined, {\n enabled: pending === 'campaign' && Boolean(hostId),\n })\n\n // The reference a write names is the row's own site and document.\n const writers = useMemo(() => {\n const byId = new Map(rows.map((row) => [row.$id, row]))\n return crmBulkWriters(firestore, (id) => {\n const row = byId.get(id)\n return doc(firestore, 'hosts', row?.hostId ?? '', 'leads', row?.leadId ?? id)\n })\n }, [firestore, rows])\n\n const openAction = (action: PendingAction) => {\n setValue('')\n setCampaignIds([])\n setPending(action)\n }\n\n const runPlan = useCallback(\n (plan: CrmBulkPlan, done: (count: number) => string) =>\n apply({\n attempted: plan.writes.length,\n skipped: plan.skipped,\n job: () => runCrmBulkWrites(writers, plan.writes, (write) => write.label),\n done,\n }),\n [apply, writers],\n )\n\n const handleApply = useCallback(async () => {\n if (!pending) return\n const action = pending\n setPending(null)\n const writes: CrmBulkWrite[] = []\n const skipped: CrmBulkSkip[] = []\n if (action === 'owner') {\n for (const lead of selectedRows) {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: { ownerUid: value ? value : deleteField(), updatedAt: serverTimestamp() },\n })\n }\n await runPlan(\n { writes, skipped },\n (count) => (value ? `Owner set on ${countNoun(count, NOUN)}` : `Owner cleared on ${countNoun(count, NOUN)}`),\n )\n return\n }\n if (action === 'campaign') {\n // Added to what each lead carries — `arrayUnion`, the way a sequence's\n // enroll and the import add one — never in place of it.\n for (const lead of selectedRows) {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: { campaignIds: arrayUnion(...campaignIds), updatedAt: serverTimestamp() },\n })\n }\n await runPlan(\n { writes, skipped },\n (count) =>\n `Added ${countNoun(count, NOUN)} to ${campaignIds.length === 1 ? 'the campaign' : `${campaignIds.length} campaigns`}`,\n )\n return\n }\n if (action === 'status') {\n const status = value as CrmLeadStatus\n for (const lead of selectedRows) {\n const current = crmLeadStatus(lead)\n if (lead.convertedContactId) {\n skipped.push({ label: labelOf(lead), reason: 'was converted' })\n } else if (current === status) {\n skipped.push({\n label: labelOf(lead),\n reason: `already ${CRM_LEAD_STATUS_LABELS[status]}`,\n })\n } else {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: {\n status,\n // Reopening an unqualified lead clears its reason, as the row does.\n ...(current === 'unqualified' ? { unqualifiedReason: deleteField() } : {}),\n updatedAt: serverTimestamp(),\n },\n })\n }\n }\n await runPlan(\n { writes, skipped },\n (count) => `Status set on ${countNoun(count, NOUN)}`,\n )\n return\n }\n const reason = value.trim().slice(0, UNQUALIFY_REASON_MAX)\n for (const lead of selectedRows) {\n if (lead.convertedContactId) {\n skipped.push({ label: labelOf(lead), reason: 'was converted' })\n } else if (!isCrmLeadOpen(lead)) {\n skipped.push({ label: labelOf(lead), reason: 'is already closed' })\n } else {\n writes.push({\n id: lead.$id,\n label: labelOf(lead),\n kind: 'update',\n data: {\n status: 'unqualified' satisfies CrmLeadStatus,\n unqualifiedReason: reason,\n updatedAt: serverTimestamp(),\n },\n })\n }\n }\n await runPlan(\n { writes, skipped },\n (count) => `Marked ${countNoun(count, NOUN)} unqualified`,\n )\n }, [pending, value, campaignIds, selectedRows, runPlan])\n\n const handleExport = useCallback(() => {\n downloadTextFile('leads-selected.csv', 'text/csv', leadsCsv(selectedRows, csv))\n }, [selectedRows, csv])\n\n const canApply =\n pending === 'owner' ||\n (pending === 'campaign'\n ? campaignIds.length > 0\n : pending === 'status'\n ? Boolean(value)\n : Boolean(value.trim()))\n\n return (\n <CrmBulkBarFrame\n count={selected.length}\n noun={NOUN}\n busy={busy}\n onClear={() => onSelectedChange([])}\n report={report}\n onDismissReport={dismissReport}\n extras={\n <CrmBulkValueDialog\n open={pending !== null}\n title={pending ? ACTION_TITLES[pending] : ''}\n count={selected.length}\n noun={NOUN}\n busy={busy}\n canApply={canApply}\n applyLabel={pending === 'unqualify' ? 'Unqualify' : pending === 'campaign' ? 'Add' : undefined}\n onClose={() => setPending(null)}\n onApply={() => void handleApply()}\n >\n {pending === 'owner' ? (\n <LeadOwnerSelect value={value} onChange={setValue} roster={roster} size=\"medium\" />\n ) : pending === 'status' ? (\n <TextField\n select\n size=\"small\"\n label=\"Status\"\n value={value}\n onChange={(event) => setValue(event.target.value)}\n helperText=\"Closing a lead goes through Unqualify, which asks why.\"\n >\n {SETTABLE_STATUSES.map((status) => (\n <MenuItem key={status} value={status}>\n {CRM_LEAD_STATUS_LABELS[status]}\n </MenuItem>\n ))}\n </TextField>\n ) : pending === 'unqualify' ? (\n <TextField\n label=\"Reason\"\n value={value}\n onChange={(event) => setValue(event.target.value)}\n multiline\n minRows={2}\n autoFocus\n helperText=\"One reason for every selected lead, kept on each so it can be counted later.\"\n slotProps={{ htmlInput: { maxLength: UNQUALIFY_REASON_MAX } }}\n />\n ) : pending === 'campaign' ? (\n <CampaignPicker\n options={campaigns.options}\n value={campaignIds}\n onChange={setCampaignIds}\n helperText=\"Added to the campaigns each selected lead is already in. It does not decide who a campaign mails.\"\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"This site has no campaigns yet. Create one from Marketing to file leads under it.\"\n />\n ) : null}\n </CrmBulkValueDialog>\n }\n >\n <Button size=\"small\" disabled={busy} onClick={() => openAction('owner')}>\n {'Set owner'}\n </Button>\n <Button size=\"small\" disabled={busy} onClick={() => openAction('status')}>\n {'Set status'}\n </Button>\n <Button size=\"small\" disabled={busy} onClick={() => openAction('unqualify')}>\n {'Unqualify'}\n </Button>\n {hostId ? (\n <Button size=\"small\" disabled={busy} onClick={() => openAction('campaign')}>\n {'Add to campaign'}\n </Button>\n ) : null}\n <Button size=\"small\" disabled={busy} onClick={handleExport}>\n {'Export CSV'}\n </Button>\n {/*\n The selection's file is the rows on screen; this one is the whole\n collection, streamed by the server (AGL-2662).\n */}\n <CrmExportAllButton\n resource=\"leads\"\n orgId={orgId}\n hostId={hostId}\n disabled={busy}\n />\n </CrmBulkBarFrame>\n )\n}\nLeadsBulkBarBody.displayName = 'LeadsBulkBarBody'\n\nexport default LeadsBulkBar\n"],"names":["CRM_LEAD_STATUS_LABELS","crmLeadStatus","isCrmLeadOpen","CampaignPicker","useFirestore","useHostCampaigns","Button","MenuItem","TextField","arrayUnion","deleteField","doc","serverTimestamp","useCallback","useMemo","useState","useCrmBulkApply","downloadTextFile","crmBulkWriters","runCrmBulkWrites","leadsCsv","CrmBulkBarFrame","CrmBulkValueDialog","countNoun","CrmExportAllButton","LeadOwnerSelect","UNQUALIFY_REASON_MAX","NOUN","singular","plural","ACTION_TITLES","owner","status","unqualify","campaign","SETTABLE_STATUSES","labelOf","lead","String","leadId","LeadsBulkBar","props","selected","length","LeadsBulkBarBody","displayName","rows","onSelectedChange","roster","csv","orgId","hostId","firestore","busy","report","apply","dismissReport","recordKind","selectedRows","chosen","Set","filter","row","has","$id","pending","setPending","value","setValue","campaignIds","setCampaignIds","campaigns","undefined","enabled","Boolean","writers","byId","Map","map","id","get","openAction","action","runPlan","plan","done","attempted","writes","skipped","job","write","label","handleApply","push","kind","data","ownerUid","updatedAt","count","current","convertedContactId","reason","unqualifiedReason","trim","slice","handleExport","canApply","noun","onClear","onDismissReport","extras","open","title","applyLabel","onClose","onApply","onChange","size","select","event","target","helperText","multiline","minRows","autoFocus","slotProps","htmlInput","maxLength","options","empty","ready","emptyText","disabled","onClick","resource"],"mappings":"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"}
@@ -33,14 +33,14 @@ import RowActionsMenu from "@aglyn/shared-ui-jsx/components/row-actions-menu.com
33
33
  import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
34
34
  import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.component";
35
35
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
36
- import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
36
+ import { useFirestore, useFirestoreCollection, useHostCampaigns } from "@aglyn/tenant-feature-instance";
37
37
  import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, InputAdornment, InputLabel, MenuItem, Select, Stack, TextField, Typography } from "@mui/material";
38
38
  import { collection, deleteField, doc, limit, orderBy, query, serverTimestamp, updateDoc } from "firebase/firestore";
39
39
  import { useRouter } from "next/navigation";
40
40
  import { useCallback, useEffect, useId, useMemo, useState } from "react";
41
41
  import { downloadTextFile } from "../model/contacts-csv.js";
42
42
  import { crmRoutes } from "../model/crm-routes.js";
43
- import { LEAD_FILTER_LABELS, LEAD_EMAIL_FILTER_LABELS, LEAD_EMAIL_FILTERS, LEAD_FILTERS, leadMatchesEmailFilter, leadMatchesFilter, leadMatchesSearch } from "../model/lead-filters.js";
43
+ import { LEAD_FILTER_LABELS, LEAD_EMAIL_FILTER_LABELS, LEAD_EMAIL_FILTERS, LEAD_FILTERS, leadMatchesCampaignFilter, leadMatchesEmailFilter, leadMatchesFilter, leadMatchesSearch } from "../model/lead-filters.js";
44
44
  import { leadsCsv } from "../model/leads-csv.js";
45
45
  import { LeadConvertDialog } from "./lead-convert-dialog.js";
46
46
  import { leadSourceLabel, leadSources, leadTimeLabel } from "./lead-history-card.js";
@@ -193,10 +193,47 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
193
193
  views.setFilters,
194
194
  views.state.filters
195
195
  ]);
196
+ /*
197
+ * The `Campaign` filter (AGL-3254) is the view's too, as a `campaignIds`
198
+ * clause: the id of one of the site's campaign containers, resolved to
199
+ * its name from the containers themselves — ids only in storage, so a
200
+ * renamed campaign keeps its leads. Under a site alone: a campaign
201
+ * belongs to one site, and the organization-level list spans them all.
202
+ */ const campaignFilter = useMemo(()=>{
203
+ var _ref;
204
+ var _views_state_filters_find;
205
+ return (_ref = (_views_state_filters_find = views.state.filters.find((clause)=>clause.field === 'campaignIds')) == null ? void 0 : _views_state_filters_find.value) != null ? _ref : '';
206
+ }, [
207
+ views.state.filters
208
+ ]);
209
+ const setCampaignFilter = useCallback((next)=>views.setFilters([
210
+ ...views.state.filters.filter((clause)=>clause.field !== 'campaignIds'),
211
+ ...next ? [
212
+ {
213
+ field: 'campaignIds',
214
+ op: 'contains',
215
+ value: next
216
+ }
217
+ ] : []
218
+ ]), [
219
+ views.setFilters,
220
+ views.state.filters
221
+ ]);
222
+ const campaigns = useHostCampaigns(hostId, {
223
+ enabled: Boolean(hostId)
224
+ });
225
+ const campaignName = useCallback((id)=>{
226
+ var _ref;
227
+ var _campaigns_options_find;
228
+ return (_ref = (_campaigns_options_find = campaigns.options.find((option)=>option.value === id)) == null ? void 0 : _campaigns_options_find.label) != null ? _ref : id;
229
+ }, [
230
+ campaigns.options
231
+ ]);
196
232
  // The label's id, so the filter's combobox is named "Show" rather than
197
233
  // after the option it shows — see `LeadOwnerSelect`.
198
234
  const filterLabelId = useId();
199
235
  const emailFilterLabelId = useId();
236
+ const campaignFilterLabelId = useId();
200
237
  /*
201
238
  * The search box is the SECTION'S, not the grid's (AGL-3246). The grid's
202
239
  * quick filter runs over the rows the grid holds, and the grid holds one
@@ -206,10 +243,11 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
206
243
  * before the footer's count and the page slice, over the fields a person
207
244
  * types to find a lead: name, email, company, title and tags.
208
245
  */ const [search, setSearch] = useState('');
209
- const rows = useMemo(()=>window.filter((lead)=>leadMatchesFilter(lead, filter) && leadMatchesEmailFilter(lead, emailFilter) && leadMatchesSearch(lead, search)), [
246
+ const rows = useMemo(()=>window.filter((lead)=>leadMatchesFilter(lead, filter) && leadMatchesEmailFilter(lead, emailFilter) && leadMatchesCampaignFilter(lead, campaignFilter) && leadMatchesSearch(lead, search)), [
210
247
  window,
211
248
  filter,
212
249
  emailFilter,
250
+ campaignFilter,
213
251
  search
214
252
  ]);
215
253
  const [page, setPage] = useState(0);
@@ -222,6 +260,7 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
222
260
  }, [
223
261
  filter,
224
262
  emailFilter,
263
+ campaignFilter,
225
264
  search
226
265
  ]);
227
266
  const pageRows = useMemo(()=>rows.slice(page * pageSize, (page + 1) * pageSize), [
@@ -238,6 +277,7 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
238
277
  useEffect(()=>setSelectedIds([]), [
239
278
  filter,
240
279
  emailFilter,
280
+ campaignFilter,
241
281
  search
242
282
  ]);
243
283
  // How the file names the owner and, at the org level, the site.
@@ -295,6 +335,8 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
295
335
  address: values.address
296
336
  } : {}, values.tags.length ? {
297
337
  tags: values.tags
338
+ } : {}, values.campaignIds.length ? {
339
+ campaignIds: values.campaignIds
298
340
  } : {}, values.ownerUid ? {
299
341
  ownerUid: values.ownerUid
300
342
  } : {}, values.notes ? {
@@ -479,6 +521,17 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
479
521
  return ((_row_tags = row.tags) != null ? _row_tags : []).join(', ');
480
522
  }
481
523
  },
524
+ // The campaigns the lead is filed under (AGL-3254), by name — the
525
+ // ids are the storage. Under a site only, like the filter.
526
+ ...hostId ? [
527
+ {
528
+ field: 'campaignIds',
529
+ headerName: 'Campaign',
530
+ flex: 1,
531
+ minWidth: 150,
532
+ valueGetter: (_value, row)=>Aglyn.readCampaignIds(row).map(campaignName).join(', ')
533
+ }
534
+ ] : [],
482
535
  {
483
536
  field: 'lastSeenAtMs',
484
537
  headerName: 'Last seen',
@@ -566,7 +619,8 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
566
619
  routes,
567
620
  writeLead,
568
621
  hostId,
569
- mount
622
+ mount,
623
+ campaignName
570
624
  ]);
571
625
  /* The column and sort models are the view's (AGL-2617). */ const grid = useCrmViewGrid(views, columns);
572
626
  return /*#__PURE__*/ _jsxs(_Fragment, {
@@ -631,6 +685,41 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
631
685
  })
632
686
  ]
633
687
  }),
688
+ hostId ? /*#__PURE__*/ _jsxs(FormControl, {
689
+ size: "small",
690
+ sx: {
691
+ minWidth: 180
692
+ },
693
+ children: [
694
+ /*#__PURE__*/ _jsx(InputLabel, {
695
+ id: campaignFilterLabelId,
696
+ shrink: true,
697
+ children: 'Campaign'
698
+ }),
699
+ /*#__PURE__*/ _jsxs(Select, {
700
+ labelId: campaignFilterLabelId,
701
+ label: "Campaign",
702
+ notched: true,
703
+ value: campaignFilter,
704
+ onChange: (event)=>setCampaignFilter(String(event.target.value)),
705
+ displayEmpty: true,
706
+ children: [
707
+ /*#__PURE__*/ _jsx(MenuItem, {
708
+ value: "",
709
+ children: 'Any campaign'
710
+ }),
711
+ campaigns.options.map((option)=>/*#__PURE__*/ _jsx(MenuItem, {
712
+ value: option.value,
713
+ children: option.label
714
+ }, option.value)),
715
+ campaignFilter && !campaigns.options.some((option)=>option.value === campaignFilter) ? /*#__PURE__*/ _jsx(MenuItem, {
716
+ value: campaignFilter,
717
+ children: campaignFilter
718
+ }) : null
719
+ ]
720
+ })
721
+ ]
722
+ }) : null,
634
723
  /*#__PURE__*/ _jsx(TextField, {
635
724
  size: "small",
636
725
  value: search,