@aglyn/plugins-crm 1.0.0-beta.166 → 1.0.0-beta.168

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.
@@ -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 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, 'orgs', orgId, '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":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,cAAc,EAEdC,qBAAqB,EACrBC,wBAAwB,QACnB,eAAc;AACrB,SACEC,YAAY,EACZC,eAAe,EACfC,gBAAgB,EAChBC,eAAe,QACV,iCAAgC;AACvC,SAASC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACjD,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SAASC,QAAQ,QAAQ,QAAO;AAChC,SAASC,oBAAoB,QAAQ,sCAAkC;AACvE,SAASC,mBAAmB,QAAQ,qCAAiC;AACrE,SAAkCC,SAAS,QAAQ,yBAAqB;AACxE,SAASC,aAAa,EAAEC,eAAe,QAAQ,yBAAqB;AACpE,SAASC,qBAAqB,QAAQ,gCAA4B;AAClE,SAASC,oBAAoB,QAAQ,2BAAuB;AAC5D,SAASC,iBAAiB,EAAEC,iBAAiB,QAAQ,2BAAuB;AAC5E,SAASC,iBAAiB,QAAQ,2BAAuB;AACzD,SAASC,eAAe,QAAQ,yBAAqB;AACrD,SAASC,kBAAkB,QAAQ,4BAAwB;AAC3D,SAASC,mBAAmB,QAAQ,6BAAyB;AAC7D,SAASC,kBAAkB,QAAQ,4BAAwB;AAI3D;;;;;;;;;CASC,GACD,OAAO,SAASC,eAAeC,KAAyB;IACtD,MAAM,EAAEC,EAAE,EAAEC,MAAM,EAAEC,GAAG,EAAEC,QAAQ,EAAE,GAAGJ;IACtC,MAAMK,YAAY5B;IAClB,MAAM6B,SAASnB,UAAUiB;IACzB,MAAM,EACJG,MAAMC,IAAI,EACVC,MAAM,EACNC,SAAS,EACV,GAAGhC,gBACF,IAAMK,IAAIsB,WAAW,QAAQM,OAAO,SAASV,KAC7C;QAACI;QAAWH;QAAQD;KAAG;IAEzB,MAAM,EAAEU,KAAK,EAAE,GAAG/B,gBAAgB;QAAEsB;IAAO;IAC3C,MAAMU,SAAS1B,oBAAoByB;IACnC;;;;;GAKC,GACD,MAAME,YAAYlC,iBAAiBuB,QAAQ;QAAEY,SAAS;IAAK;IAC3D,uEAAuE;IACvE,mEAAmE;IACnE,MAAMC,YAAY9B,qBAAqB;QACrC0B;QACAT;QACAC,KAAKA;IACP;IACA,MAAM,CAACa,YAAYC,cAAc,GAAGjC,SAAS;IAC7C,MAAM,CAACkC,cAAcC,gBAAgB,GAAGnC,SAAS;IACjD,oEAAoE;IACpE,0DAA0D;IAC1D,MAAMoC,YAAYZ,OAAOjC,sBAAsBiC,IAAI,CAAC,QAAQ,IAAI;IAChE,MAAMa,QAAQ9B,qBAAqB;QACjCW;QACAS;QACAW,SAASd,QAAQY,YAAY;YAAEG,MAAM;YAAQtB;YAAIuB,OAAOJ;QAAU,IAAI;QACtEK,eAAejD,yBAAyBgC;IAC1C;IAEA,MAAMkB,QAAQlB,OAAOmB,OAAOnB,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,QAAQ,IAAIP,MAAM2B;IAEnE,IAAInB,WAAW,WAAYA,WAAW,aAAa,CAACD,MAAO;QACzD,qBACE,KAACnB;YACCkC,MAAK;YACLM,OAAOD;YACPE,MAAMxD,eAAe,YAAY;gBAAEyD,QAAQ;YAAgB;YAC3DC,UAAU1B,OAAO2B,OAAO,CAAC;YACzBC,WAAU;sBAEV,cAAA,KAACpD;gBAAWqD,SAAQ;gBAAQC,OAAM;0BAC/B3B,WAAW,UACR,iCACA;;;IAIZ;IACA,IAAI,CAACD,MAAM;QACT,qBACE,KAACnB;YACCkC,MAAK;YACLM,OAAOD;YACPE,MAAMxD,eAAe,YAAY;gBAAEyD,QAAQ;YAAgB;YAC3DC,UAAU1B,OAAO2B,OAAO,CAAC;YACzBC,WAAU;YACVG,OAAO;;IAGb;IAEA,qBACE;;0BAIE,MAACxD;gBAAMyD,SAAS;;kCACd,KAAC1C;wBACCM,QAAQA;wBACRS,OAAOA;wBACP4B,QAAQtC;wBACRO,MAAMA;wBACNgC,YAAY/B;wBACZC,WAAWA;wBACXN,UAAUA;wBACVQ,QAAQA;wBACR6B,WAAW,IAAMxB,cAAc;wBAC/ByB,aAAa,IAAMvB,gBAAgB;wBACnCwB,gBAAgBtB,MAAMuB,SAAS;wBAC/BC,QAAQxB,MAAMwB,MAAM;wBACpBC,gBAAgBzB,MAAM0B,cAAc,KAAK;wBACzC5C,KAAKA;wBACL,4DAA4D;wBAC5D,6DAA6D;wBAC7D,wDAAwD;wBACxD6C,YAAYvD,kBAAkBe,MAAMK,UAAUoC,OAAO,EAAEC,GAAG,CAAC,CAACC,qBAC1D,KAAC/D;gCAAyBsC,OAAM;gCAAW0B,OAAOD;+BAA9BA;;kCAIxB,KAAC7D;wBACCY,QAAQA;wBACRC,KAAKA;wBACLoB,MAAK;wBACL8B,UAAUpD;wBACVkD,IAAI,EAAEzB,gBAAAA,QAAS;;kCAEjB,KAAClC;wBACCU,QAAQA;wBACRqC,QAAQtC;wBACRO,MAAMA;wBACNgC,YAAY/B;wBACZC,WAAWA;wBACXuC,SAASpC,UAAUoC,OAAO;wBAC1BK,cAAczC,UAAU0C,KAAK;wBAC7BC,SAAS,CAAC,EAAEC,KAAK,EAAEC,OAAO,EAAE;4BAC1B,MAAMC,QAAQ,CAACC,MACbA,IAAIV,GAAG,CAAC,CAACW;;wCAEDhD;2CAFiB;wCACvBZ,IAAI4D;wCACJV,IAAI,WAAEtC,0BAAAA,UAAUoC,OAAO,CAACa,IAAI,CAAC,CAACC,SAAWA,OAAOX,KAAK,KAAKS,gCAApDhD,wBAAiEa,KAAK,mBAAI;oCAClF;;4BACF,KAAKX,UAAU;gCAAEwB,QAAQtC;4BAAG,GAAG;gCAAE+D,OAAOL,MAAMF;gCAAQC,SAASC,MAAMD;4BAAS;wBAChF;;kCAEF,KAAC/D;wBAAgBO,QAAQA;wBAAQqC,QAAQtC;wBAAIO,MAAMA;;kCACnD,KAACV;wBAAmBI,QAAQA;wBAAQC,KAAKA;wBAAKoC,QAAQtC;;;;0BAExD,KAACP;gBACCuE,MAAMjD;gBACNkD,SAAS,IAAMjD,cAAc;gBAC7Bf,QAAQA;gBACRS,OAAOA;gBACPR,KAAKA;gBACLoC,QAAQtC;gBACRO,MAAMA;gBACNJ,UAAUA;gBACVQ,QAAQA;;0BAEV,KAACf;gBACCoE,MAAM/C;gBACNgD,SAAS,IAAM/C,gBAAgB;gBAC/BjB,QAAQA;gBACRqC,QAAQtC;gBACRkE,SAAS,EAAEzC,gBAAAA,QAASzB;;YAErBoB,MAAM+C,MAAM;;;AAGnB;AACArE,eAAesE,WAAW,GAAG;AAE7B,eAAetE,eAAc"}
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 /*\n * Resolved BEFORE the read that needs it (AGL-3275). A lead is an org row\n * now, so the org has to be known to address one — and `orgId` is null\n * while the host-index lookup settles. Handing that null to `doc()` is a\n * thrown `TypeError`, which the page renders as a 500 rather than as a\n * record still loading.\n */\n const { orgId } = useOrgDataScope({ hostId })\n const {\n data: lead,\n status,\n fromCache,\n } = useFirestoreDoc<LeadDocument>(\n () => (orgId ? doc(firestore, 'orgs', orgId, 'leads', id) : null),\n [firestore, orgId, id],\n )\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","orgId","data","lead","status","fromCache","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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"}
@@ -97,7 +97,12 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
97
97
  const firestore = useFirestore();
98
98
  const { enqueueSnackbar } = useSnackbar();
99
99
  const routes = crmRoutes(basePath);
100
- const ref = doc(firestore, 'orgs', orgId, 'leads', leadId);
100
+ /*
101
+ * Built at USE, not at render (AGL-3275). A lead is an org row, so the org
102
+ * has to be known to address one — and `orgId` is null while the lookup
103
+ * settles. Composing a ref from that null throws during render, which the
104
+ * page shows as a 500 rather than as a card still loading.
105
+ */ const refFor = ()=>orgId ? doc(firestore, 'orgs', orgId, 'leads', leadId) : null;
101
106
  const status = Aglyn.crmLeadStatus(lead);
102
107
  const converted = Boolean(lead.convertedContactId);
103
108
  const open = Aglyn.isCrmLeadOpen(lead) && !converted;
@@ -168,6 +173,14 @@ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
168
173
  ]);
169
174
  const write = async (fields, done)=>{
170
175
  try {
176
+ const ref = refFor();
177
+ if (!ref) {
178
+ enqueueSnackbar('Still loading this workspace — try again in a moment.', {
179
+ variant: 'warning',
180
+ persist: false
181
+ });
182
+ return;
183
+ }
171
184
  await updateDoc(ref, _extends({}, fields, {
172
185
  updatedAt: serverTimestamp()
173
186
  }));
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-properties-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n AglynOrgBilling,\n CrmLeadFields,\n CrmLeadProfilePatch,\n CrmLeadStatus,\n} from '@aglyn/aglyn'\nimport { mdiAccountCancelOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n FormControl,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { deleteField, doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useId, useMemo, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport {\n crmCustomDraftChanges,\n type CrmCustomDraft,\n crmCustomDraftMissingRequired,\n crmCustomDraftValue,\n crmCustomDraftWrites,\n} from '../model/crm-custom-draft'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n addressDraftFrom,\n ContactAddressFields,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmCallButton, CrmPhoneLink } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { LeadStatusChip } from './lead-status-chip'\n\nconst NOTES_MAX = Aglyn.CRM_LEAD_NOTES_MAX\nconst TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX\n\n/** The profile as the form holds it: every field a string, the address a draft. */\ninterface ProfileDraft {\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n tags: string\n address: AddressDraft\n}\n\n/** The stored profile as a draft the fields can edit. */\nfunction profileDraftFrom(lead: Record<string, unknown> & CrmLeadFields): ProfileDraft {\n return {\n company: String(lead.company ?? ''),\n jobTitle: String(lead.jobTitle ?? ''),\n phone: String(lead.phone ?? lead['phone'] ?? ''),\n website: String(lead.website ?? ''),\n leadSource: String(lead.leadSource ?? ''),\n tags: (lead.tags ?? []).join(', '),\n address: addressDraftFrom(lead.address ?? null),\n }\n}\n\n/** The patch as the document takes it: a cleared field is deleted, not blanked. */\nfunction profileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? deleteField() : value\n }\n return write\n}\n\n/**\n * Why Convert is refused while an erasure waits on the person — the same\n * sentence shape the overflow's items carry, so the two read as one state.\n */\nexport const CONVERT_PENDING_ERASURE_REASON = 'An erasure is pending for this person'\n\n/** A label over a value — the record page's one row shape. */\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 LeadPropertiesCardProps {\n hostId: string\n /**\n * The org the site belongs to, as the page already resolved it — the\n * custom lead fields are ORG-wide (AGL-3272), and a second lookup here\n * would be one more read per record page for an answer already in hand.\n */\n orgId: string | null\n leadId: string\n lead: Record<string, unknown> & CrmLeadFields\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n basePath: string\n roster: OrgMemberOptions\n onConvert: () => void\n onUnqualify: () => void\n /**\n * Items the page adds to the overflow beside Unqualify — the privacy\n * erasure (AGL-2623) lives on the page, because it needs the workspace\n * role and the API, and this card owns the header it must appear in.\n */\n extraMenuItems?: RowActionsMenuItem[]\n /** What the page shows above the facts — the erasure-pending state. */\n banner?: React.ReactNode\n /**\n * Chips the page adds after the owner — the campaigns the lead is filed\n * under (AGL-3274), named from the containers the page reads once for\n * this header and the Campaigns card below it.\n */\n extraChips?: React.ReactNode\n /**\n * An erasure request is waiting on this person (AGL-2623). Convert stays\n * on the page but is refused with the reason, the way the overflow's items\n * are: a conversion filed now would reach the capture door only to be\n * refused there, and the lead itself goes when the request runs.\n */\n erasurePending?: boolean\n /**\n * The org the shell passed: the booking door reads whether the lead's site\n * runs Bookings and whether the plan is entitled to it (AGL-2660), and a\n * logged call reads the activity scope it belongs in (AGL-2661).\n */\n org?: Partial<AglynOrgBilling> | null\n}\n\n/**\n * What the team knows and decides about a lead: status, owner, notes, and\n * the identity and consent the capture recorded (AGL-2608).\n *\n * Status and owner are single-field client writes — the rules let a site\n * admin, editor or author update `hosts/{hostId}/leads`, and a one-field\n * `update` cannot roll anything else back. Notes are a text field seeded\n * from the document, so that save goes through `writeGuardedBySeed`: a draft\n * edited over a cached read would otherwise overwrite a newer note with an\n * older one plus a sentence.\n *\n * Converted leads are read-only here. Their status is the conversion, and\n * the actions become links to what the conversion made.\n */\nexport function LeadPropertiesCard(props: LeadPropertiesCardProps) {\n const {\n hostId,\n orgId,\n leadId,\n lead,\n leadStatus,\n fromCache,\n basePath,\n roster,\n onConvert,\n onUnqualify,\n extraMenuItems = [],\n banner,\n extraChips,\n erasurePending = false,\n org,\n } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const routes = crmRoutes(basePath)\n const ref = doc(firestore, 'orgs', orgId, 'leads', leadId)\n const status = Aglyn.crmLeadStatus(lead)\n const converted = Boolean(lead.convertedContactId)\n const open = Aglyn.isCrmLeadOpen(lead) && !converted\n // The last verdict on the address (AGL-3245), as the platform stamped it.\n const emailState = Aglyn.readEmailState(lead)\n /** What a capture that took a number left on the document (AGL-2661). */\n const leadPhone = String(lead['phone'] ?? '').trim()\n\n const [notes, setNotes] = useState(String(lead.notes ?? ''))\n // The label's id, so the status combobox is named \"Status\" rather than\n // after the status it shows — see `LeadOwnerSelect`.\n const statusLabelId = useId()\n const [notesDirty, setNotesDirty] = useState(false)\n const [savingNotes, setSavingNotes] = useState(false)\n /*\n * THE LEAD'S OWN PROFILE (AGL-3231) — company, title, phone, website,\n * address, tags, lead source — edited as one block with one Save, the\n * way the contact's Properties card saves. Seeded from the document and\n * guarded on save like the notes: a draft edited over a cached read must\n * not overwrite a newer profile with an older one.\n */\n const [profile, setProfile] = useState<ProfileDraft>(() => profileDraftFrom(lead))\n const [profileDirty, setProfileDirty] = useState(false)\n const [savingProfile, setSavingProfile] = useState(false)\n const [profileErrors, setProfileErrors] = useState<Record<string, string>>({})\n useEffect(() => {\n if (!profileDirty) setProfile(profileDraftFrom(lead))\n // The draft follows the document until it is edited; the fields are\n // read one by one so a change to any of them reseeds.\n }, [\n profileDirty,\n lead.company,\n lead.jobTitle,\n lead.phone,\n lead.website,\n lead.leadSource,\n lead.tags,\n lead.address,\n ])\n /*\n * THE ORG'S OWN LEAD FIELDS (AGL-3272), edited under the same Save as\n * the profile: one button over one card, so a person filling a lead in\n * does not have to find two.\n *\n * The draft holds only the keys the reader touched, and Save writes the\n * difference as dotted paths — a `custom` map written whole would take\n * out every key this card did not show, which is what a retired field's\n * values and an integration's writes sit under.\n */\n const fields = useContactFieldDefinitions(orgId, 'lead')\n const storedCustom = useMemo(() => lead.custom ?? {}, [lead.custom])\n const [custom, setCustom] = useState<CrmCustomDraft>({})\n\n const editProfile = <K extends keyof ProfileDraft>(key: K, value: ProfileDraft[K]) => {\n setProfile((current) => ({ ...current, [key]: value }))\n setProfileDirty(true)\n }\n // A newer note from the server replaces an UNEDITED draft; an edited one is\n // the reader's, and the guard on save decides whether it may land.\n useEffect(() => {\n if (!notesDirty) setNotes(String(lead.notes ?? ''))\n }, [lead.notes, notesDirty])\n\n const write = async (fields: Record<string, unknown>, done: string) => {\n try {\n await updateDoc(ref, { ...fields, updatedAt: serverTimestamp() })\n enqueueSnackbar(done, { variant: 'success', persist: false })\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n }\n }\n\n const saveNotes = async () => {\n setSavingNotes(true)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n () => write({ notes: notes.trim().slice(0, NOTES_MAX) }, 'Notes saved'),\n )\n setSavingNotes(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The notes could not be saved.', {\n variant: 'warning',\n })\n return\n }\n setNotesDirty(false)\n }\n\n /*\n * What Save is offered for: a profile field edited, or a custom value\n * that differs from the stored map. Touched-and-put-back does not count\n * — the draft keeps the key either way, and a Save that wrote nothing\n * would still bump `updatedAt`.\n */\n const dirty =\n profileDirty || crmCustomDraftChanges(storedCustom, custom).length > 0\n\n const saveProfile = async () => {\n const { patch, errors } = Aglyn.normalizeCrmLeadProfile({\n company: profile.company,\n jobTitle: profile.jobTitle,\n phone: profile.phone,\n website: profile.website,\n leadSource: profile.leadSource,\n tags: profile.tags,\n address: profile.address,\n })\n setProfileErrors(errors)\n if (Object.keys(errors).length) return\n /*\n * A required field the reader CLEARED is refused; one the lead has\n * always lacked is not this save's to demand, or a field added after\n * the lead was captured would block every later edit to its company.\n */\n const missing = crmCustomDraftMissingRequired(\n fields.active,\n storedCustom,\n custom,\n 'edit',\n )\n if (missing.length) {\n enqueueSnackbar(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`, {\n variant: 'warning',\n })\n return\n }\n setSavingProfile(true)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n () =>\n write(\n { ...profileWrite(patch), ...crmCustomDraftWrites(storedCustom, custom) },\n 'Lead saved',\n ),\n )\n setSavingProfile(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The lead could not be saved.', {\n variant: 'warning',\n })\n return\n }\n setProfileDirty(false)\n // The draft is spent: what it held is stored, and the controls read\n // the document again.\n setCustom({})\n }\n\n const consent = Aglyn.readMarketingBasis(lead, Aglyn.soloConsentGroup(hostId))\n const consentLine =\n consent.basis === 'granted'\n ? `Opted in to marketing${\n consent.basisAtMs ? ` on ${new Date(consent.basisAtMs).toLocaleDateString()}` : ''\n }`\n : consent.basis === 'declined'\n ? 'Declined marketing'\n : 'No marketing consent recorded — this lead cannot be emailed marketing'\n\n return (\n <CrmRecordHeader\n kind=\"Lead\"\n title={String(lead['name'] || lead['email'] || leadId)}\n // The name is the heading; the address is the one line under it,\n // unless the address IS the name, in which case there is no second fact.\n subtitle={lead['name'] ? String(lead['email'] ?? '') : undefined}\n help={Aglyn.pluginDocsHelp('crmLeads', { anchor: '#working-a-lead-from-the-row' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n // The booking door (AGL-2660), while the lead is still the record\n // being worked: once converted, the contact is where a meeting is\n // booked from, and the links below lead there.\n booking={converted ? undefined : { hostId, org, kind: 'lead', recordId: leadId }}\n actions={\n <>\n {converted ? null : erasurePending ? (\n <Tooltip title={CONVERT_PENDING_ERASURE_REASON}>\n {/* A disabled button receives no pointer events, so the\n tooltip anchors on the span around it. */}\n <span>\n <Button size=\"small\" variant=\"contained\" disabled>\n {'Convert'}\n </Button>\n </span>\n </Tooltip>\n ) : (\n <Button size=\"small\" variant=\"contained\" onClick={onConvert}>\n {'Convert'}\n </Button>\n )}\n {/* Dial the number the capture carried, and log the call (AGL-2661). */}\n <CrmCallButton\n hostId={hostId}\n org={org}\n link={{ leadId }}\n phone={leadPhone}\n />\n <CrmSendEmailButton\n hostId={hostId}\n leadId={leadId}\n email={String(lead['email'] ?? '')}\n name={String(lead['name'] ?? '')}\n emailState={emailState}\n />\n </>\n }\n menuItems={[\n ...(open\n ? [\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />,\n destructive: true,\n onClick: onUnqualify,\n } satisfies RowActionsMenuItem,\n ]\n : []),\n ...extraMenuItems,\n ]}\n chips={\n <>\n <LeadStatusChip lead={lead} />\n {/* The verdict on the address (AGL-3245), beside the status: a\n bounce does not move New or Working, but it is the first\n thing a person deciding whether to write must see. */}\n <CrmEmailStateChip state={emailState} />\n <CrmRecordChip\n label=\"Owner\"\n value={lead.ownerUid ? roster.labelFor(lead.ownerUid) : undefined}\n />\n {extraChips}\n </>\n }\n >\n <Stack spacing={3}>\n {banner}\n {/*\n Only when the capture carried one (AGL-2661): the sign-up and\n booking doors write no phone, so a row for every lead would be a\n permanent blank. A form that captures one fills this.\n */}\n {leadPhone ? (\n <Fact label=\"Phone\">\n <CrmPhoneLink phone={leadPhone} />\n </Fact>\n ) : null}\n <Fact label=\"Marketing consent\">{consentLine}</Fact>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n {converted ? (\n <Fact label=\"Status\">\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <LeadStatusChip lead={lead} />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {lead.convertedAtMs\n ? `Converted ${new Date(lead.convertedAtMs).toLocaleString()}`\n : 'Converted'}\n </Typography>\n </Stack>\n </Fact>\n ) : (\n <FormControl size=\"small\" sx={{ minWidth: 200 }}>\n <InputLabel id={statusLabelId}>{'Status'}</InputLabel>\n <Select\n labelId={statusLabelId}\n label=\"Status\"\n value={status === 'unqualified' ? 'unqualified' : status}\n onChange={(event) => {\n const next = String(event.target.value) as CrmLeadStatus\n if (next === 'unqualified') {\n onUnqualify()\n return\n }\n // Reopening drops the reason with the closed state: a lead\n // being worked again is not \"unqualified because …\".\n void write(\n {\n status: next,\n ...(status === 'unqualified' ? { unqualifiedReason: deleteField() } : {}),\n },\n 'Status updated',\n )\n }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{Aglyn.CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n <MenuItem value=\"unqualified\">\n {`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}\n </MenuItem>\n </Select>\n </FormControl>\n )}\n <LeadOwnerSelect\n value={lead.ownerUid}\n roster={roster}\n fullWidth={false}\n onChange={(uid) =>\n void write({ ownerUid: uid || deleteField() }, uid ? 'Owner assigned' : 'Owner cleared')\n }\n />\n </Stack>\n {status === 'unqualified' && lead.unqualifiedReason ? (\n <Alert severity=\"info\">{`Unqualified: ${lead.unqualifiedReason}`}</Alert>\n ) : null}\n {converted ? (\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap', rowGap: 1 }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.contact(String(lead.convertedContactId))}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open contact'}\n </Button>\n {lead.companyId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.company(lead.companyId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open company'}\n </Button>\n ) : null}\n {lead.dealId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.deal(lead.dealId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open deal'}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n {/*\n The profile (AGL-3231): what Salesforce keeps on a lead and hands\n to the contact and the account on convert. Read-only once\n converted — the contact is the record then, and the links above\n lead there.\n */}\n <Stack spacing={2}>\n <Typography variant=\"subtitle2\">{'Profile'}</Typography>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Company\"\n value={profile.company}\n onChange={(event) => editProfile('company', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={profile.jobTitle}\n onChange={(event) => editProfile('jobTitle', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={profile.phone}\n onChange={(event) => editProfile('phone', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['phone'])}\n helperText={profileErrors['phone'] || 'With the country code, like +1 512 555 0107'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={profile.website}\n onChange={(event) => editProfile('website', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['website'])}\n helperText={profileErrors['website'] || 'Like acme.com'}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Lead source\"\n value={profile.leadSource}\n onChange={(event) => editProfile('leadSource', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Tags\"\n value={profile.tags}\n onChange={(event) => editProfile('tags', event.target.value)}\n disabled={converted}\n helperText=\"Comma-separated\"\n fullWidth\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Address'}\n </Typography>\n <ContactAddressFields\n value={profile.address}\n onChange={(next) => editProfile('address', next)}\n disabled={converted}\n />\n {/*\n The org's own lead fields (AGL-3272), under the profile because\n they describe the same record and save with it. A converted\n lead's are read-only with the rest of the card.\n */}\n {fields.active.length ? (\n <>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Custom fields'}\n </Typography>\n {fields.active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={crmCustomDraftValue(storedCustom, custom, definition.key)}\n onChange={(value) =>\n setCustom((current) => ({ ...current, [definition.key]: value }))\n }\n disabled={converted || savingProfile}\n />\n ))}\n </>\n ) : null}\n {converted ? null : (\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void saveProfile()}\n disabled={!dirty || savingProfile}\n >\n {'Save'}\n </Button>\n </Stack>\n )}\n </Stack>\n <Stack spacing={1}>\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => {\n setNotes(event.target.value)\n setNotesDirty(true)\n }}\n multiline\n minRows={3}\n fullWidth\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n />\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void saveNotes()}\n disabled={!notesDirty || savingNotes}\n >\n {'Save notes'}\n </Button>\n </Stack>\n </Stack>\n </Stack>\n </CrmRecordHeader>\n )\n}\nLeadPropertiesCard.displayName = 'LeadPropertiesCard'\n\nexport default LeadPropertiesCard\n"],"names":["Aglyn","mdiAccountCancelOutline","AppLink","MdiIcon","useSnackbar","useFirestore","writeGuardedBySeed","Alert","Button","FormControl","InputLabel","MenuItem","Select","Stack","TextField","Tooltip","Typography","deleteField","doc","serverTimestamp","updateDoc","useEffect","useId","useMemo","useState","useContactFieldDefinitions","crmCustomDraftChanges","crmCustomDraftMissingRequired","crmCustomDraftValue","crmCustomDraftWrites","CrmCustomFieldControl","crmRoutes","addressDraftFrom","ContactAddressFields","CrmCallButton","CrmPhoneLink","CrmEmailStateChip","CrmRecordChip","CrmRecordHeader","CrmSendEmailButton","LeadOwnerSelect","LeadStatusChip","NOTES_MAX","CRM_LEAD_NOTES_MAX","TEXT_MAX","CRM_LEAD_TEXT_MAX","profileDraftFrom","lead","company","String","jobTitle","phone","website","leadSource","tags","join","address","profileWrite","patch","write","key","value","Object","entries","undefined","CONVERT_PENDING_ERASURE_REASON","Fact","props","spacing","variant","color","label","component","children","LeadPropertiesCard","hostId","orgId","leadId","leadStatus","fromCache","basePath","roster","onConvert","onUnqualify","extraMenuItems","banner","extraChips","erasurePending","org","firestore","enqueueSnackbar","routes","ref","status","crmLeadStatus","converted","Boolean","convertedContactId","open","isCrmLeadOpen","emailState","readEmailState","leadPhone","trim","notes","setNotes","statusLabelId","notesDirty","setNotesDirty","savingNotes","setSavingNotes","profile","setProfile","profileDirty","setProfileDirty","savingProfile","setSavingProfile","profileErrors","setProfileErrors","fields","storedCustom","custom","setCustom","editProfile","current","done","updatedAt","persist","error","Error","message","saveNotes","verdict","subject","unreadable","slice","ok","dirty","length","saveProfile","errors","normalizeCrmLeadProfile","keys","missing","active","consent","readMarketingBasis","soloConsentGroup","consentLine","basis","basisAtMs","Date","toLocaleDateString","kind","title","subtitle","help","pluginDocsHelp","anchor","backHref","section","backLabel","booking","recordId","actions","span","size","disabled","onClick","link","email","name","menuItems","icon","path","destructive","chips","state","ownerUid","labelFor","direction","xs","md","sx","alignItems","convertedAtMs","toLocaleString","minWidth","id","labelId","onChange","event","next","target","unqualifiedReason","CRM_LEAD_STATUS_LABELS","new","working","unqualified","fullWidth","uid","severity","flexWrap","rowGap","componentVariant","nativeButton","href","contact","companyId","dealId","deal","slotProps","htmlInput","maxLength","helperText","map","definition","$id","justifyContent","multiline","minRows","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,YAAYA,WAAW,eAAc;AAOrC,SAASC,uBAAuB,QAAQ,yBAAwB;AAChE,SAASC,OAAO,EAAEC,OAAO,QAAQ,uBAAsB;AAEvD,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAEEC,YAAY,EACZC,kBAAkB,QACb,iCAAgC;AACvC,SACEC,KAAK,EACLC,MAAM,EACNC,WAAW,EACXC,UAAU,EACVC,QAAQ,EACRC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,OAAO,EACPC,UAAU,QACL,gBAAe;AACtB,SAASC,WAAW,EAAEC,GAAG,EAAEC,eAAe,EAAEC,SAAS,QAAQ,qBAAoB;AACjF,SAASC,SAAS,EAAEC,KAAK,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AAC3D,SAASC,0BAA0B,QAAQ,4CAAwC;AACnF,SACEC,qBAAqB,EAErBC,6BAA6B,EAC7BC,mBAAmB,EACnBC,oBAAoB,QACf,+BAA2B;AAClC,SAASC,qBAAqB,QAAQ,gCAA4B;AAClE,SAASC,SAAS,QAAQ,yBAAqB;AAC/C,SACEC,gBAAgB,EAChBC,oBAAoB,QAEf,8BAA0B;AACjC,SAASC,aAAa,EAAEC,YAAY,QAAQ,wBAAoB;AAChE,SAASC,iBAAiB,QAAQ,4BAAwB;AAC1D,SAASC,aAAa,EAAEC,eAAe,QAAQ,yBAAqB;AACpE,SAASC,kBAAkB,QAAQ,6BAAyB;AAE5D,SAASC,eAAe,QAAQ,yBAAqB;AACrD,SAASC,cAAc,QAAQ,wBAAoB;AAEnD,MAAMC,YAAY1C,MAAM2C,kBAAkB;AAC1C,MAAMC,WAAW5C,MAAM6C,iBAAiB;AAaxC,uDAAuD,GACvD,SAASC,iBAAiBC,IAA6C;QAEnDA,eACCA,gBACHA,MAAAA,aACEA,eACGA,kBACZA,YACmBA;IAP5B,OAAO;QACLC,SAASC,QAAOF,gBAAAA,KAAKC,OAAO,YAAZD,gBAAgB;QAChCG,UAAUD,QAAOF,iBAAAA,KAAKG,QAAQ,YAAbH,iBAAiB;QAClCI,OAAOF,QAAOF,QAAAA,cAAAA,KAAKI,KAAK,YAAVJ,cAAcA,IAAI,CAAC,QAAQ,YAA3BA,OAA+B;QAC7CK,SAASH,QAAOF,gBAAAA,KAAKK,OAAO,YAAZL,gBAAgB;QAChCM,YAAYJ,QAAOF,mBAAAA,KAAKM,UAAU,YAAfN,mBAAmB;QACtCO,MAAM,EAACP,aAAAA,KAAKO,IAAI,YAATP,aAAa,EAAE,EAAEQ,IAAI,CAAC;QAC7BC,SAASxB,kBAAiBe,gBAAAA,KAAKS,OAAO,YAAZT,gBAAgB;IAC5C;AACF;AAEA,iFAAiF,GACjF,SAASU,aAAaC,KAA0B;IAC9C,MAAMC,QAAiC,CAAC;IACxC,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAACL,OAAQ;QAChD,IAAIG,UAAUG,WAAW;QACzBL,KAAK,CAACC,IAAI,GAAGC,UAAU,OAAO5C,gBAAgB4C;IAChD;IACA,OAAOF;AACT;AAEA;;;CAGC,GACD,OAAO,MAAMM,iCAAiC,wCAAuC;AAErF,4DAA4D,GAC5D,SAASC,KAAKC,KAAmD;IAC/D,qBACE,MAACtD;QAAMuD,SAAS;;0BACd,KAACpD;gBAAWqD,SAAQ;gBAAUC,OAAM;0BACjCH,MAAMI,KAAK;;0BAEd,KAACvD;gBAAWqD,SAAQ;gBAAQG,WAAU;0BACnCL,MAAMM,QAAQ;;;;AAIvB;AAgDA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,mBAAmBP,KAA8B;QA4BtCpB,aAEiBA,aA+JNA,aAmCZA,cACDA;IAhOvB,MAAM,EACJ4B,MAAM,EACNC,KAAK,EACLC,MAAM,EACN9B,IAAI,EACJ+B,UAAU,EACVC,SAAS,EACTC,QAAQ,EACRC,MAAM,EACNC,SAAS,EACTC,WAAW,EACXC,iBAAiB,EAAE,EACnBC,MAAM,EACNC,UAAU,EACVC,iBAAiB,KAAK,EACtBC,GAAG,EACJ,GAAGrB;IACJ,MAAMsB,YAAYpF;IAClB,MAAM,EAAEqF,eAAe,EAAE,GAAGtF;IAC5B,MAAMuF,SAAS5D,UAAUiD;IACzB,MAAMY,MAAM1E,IAAIuE,WAAW,QAAQb,OAAO,SAASC;IACnD,MAAMgB,SAAS7F,MAAM8F,aAAa,CAAC/C;IACnC,MAAMgD,YAAYC,QAAQjD,KAAKkD,kBAAkB;IACjD,MAAMC,OAAOlG,MAAMmG,aAAa,CAACpD,SAAS,CAACgD;IAC3C,0EAA0E;IAC1E,MAAMK,aAAapG,MAAMqG,cAAc,CAACtD;IACxC,uEAAuE,GACvE,MAAMuD,YAAYrD,QAAOF,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB,IAAIwD,IAAI;IAElD,MAAM,CAACC,OAAOC,SAAS,GAAGjF,SAASyB,QAAOF,cAAAA,KAAKyD,KAAK,YAAVzD,cAAc;IACxD,uEAAuE;IACvE,qDAAqD;IACrD,MAAM2D,gBAAgBpF;IACtB,MAAM,CAACqF,YAAYC,cAAc,GAAGpF,SAAS;IAC7C,MAAM,CAACqF,aAAaC,eAAe,GAAGtF,SAAS;IAC/C;;;;;;GAMC,GACD,MAAM,CAACuF,SAASC,WAAW,GAAGxF,SAAuB,IAAMsB,iBAAiBC;IAC5E,MAAM,CAACkE,cAAcC,gBAAgB,GAAG1F,SAAS;IACjD,MAAM,CAAC2F,eAAeC,iBAAiB,GAAG5F,SAAS;IACnD,MAAM,CAAC6F,eAAeC,iBAAiB,GAAG9F,SAAiC,CAAC;IAC5EH,UAAU;QACR,IAAI,CAAC4F,cAAcD,WAAWlE,iBAAiBC;IAC/C,oEAAoE;IACpE,sDAAsD;IACxD,GAAG;QACDkE;QACAlE,KAAKC,OAAO;QACZD,KAAKG,QAAQ;QACbH,KAAKI,KAAK;QACVJ,KAAKK,OAAO;QACZL,KAAKM,UAAU;QACfN,KAAKO,IAAI;QACTP,KAAKS,OAAO;KACb;IACD;;;;;;;;;GASC,GACD,MAAM+D,SAAS9F,2BAA2BmD,OAAO;IACjD,MAAM4C,eAAejG,QAAQ;YAAMwB;gBAAAA,eAAAA,KAAK0E,MAAM,YAAX1E,eAAe,CAAC;OAAG;QAACA,KAAK0E,MAAM;KAAC;IACnE,MAAM,CAACA,QAAQC,UAAU,GAAGlG,SAAyB,CAAC;IAEtD,MAAMmG,cAAc,CAA+B/D,KAAQC;QACzDmD,WAAW,CAACY,UAAa,aAAKA;gBAAS,CAAChE,IAAI,EAAEC;;QAC9CqD,gBAAgB;IAClB;IACA,4EAA4E;IAC5E,mEAAmE;IACnE7F,UAAU;YACyB0B;QAAjC,IAAI,CAAC4D,YAAYF,SAASxD,QAAOF,cAAAA,KAAKyD,KAAK,YAAVzD,cAAc;IACjD,GAAG;QAACA,KAAKyD,KAAK;QAAEG;KAAW;IAE3B,MAAMhD,QAAQ,OAAO4D,QAAiCM;QACpD,IAAI;YACF,MAAMzG,UAAUwE,KAAK,aAAK2B;gBAAQO,WAAW3G;;YAC7CuE,gBAAgBmC,MAAM;gBAAExD,SAAS;gBAAW0D,SAAS;YAAM;QAC7D,EAAE,OAAOC,OAAO;YACdtC,gBACEsC,iBAAiBC,QAAQD,MAAME,OAAO,GAAG,kCACzC;gBAAE7D,SAAS;YAAQ;QAEvB;IACF;IAEA,MAAM8D,YAAY;QAChBrB,eAAe;QACf,MAAMsB,UAAU,MAAM9H,mBACpB;YAAE+H,SAAS;YAAQtD;YAAWuD,YAAYxD,eAAe;QAAQ,GACjE,IAAMnB,MAAM;gBAAE6C,OAAOA,MAAMD,IAAI,GAAGgC,KAAK,CAAC,GAAG7F;YAAW,GAAG;QAE3DoE,eAAe;QACf,IAAI,CAACsB,QAAQI,EAAE,EAAE;gBACCJ;YAAhB1C,iBAAgB0C,mBAAAA,QAAQF,OAAO,YAAfE,mBAAmB,iCAAiC;gBAClE/D,SAAS;YACX;YACA;QACF;QACAuC,cAAc;IAChB;IAEA;;;;;GAKC,GACD,MAAM6B,QACJxB,gBAAgBvF,sBAAsB8F,cAAcC,QAAQiB,MAAM,GAAG;IAEvE,MAAMC,cAAc;QAClB,MAAM,EAAEjF,KAAK,EAAEkF,MAAM,EAAE,GAAG5I,MAAM6I,uBAAuB,CAAC;YACtD7F,SAAS+D,QAAQ/D,OAAO;YACxBE,UAAU6D,QAAQ7D,QAAQ;YAC1BC,OAAO4D,QAAQ5D,KAAK;YACpBC,SAAS2D,QAAQ3D,OAAO;YACxBC,YAAY0D,QAAQ1D,UAAU;YAC9BC,MAAMyD,QAAQzD,IAAI;YAClBE,SAASuD,QAAQvD,OAAO;QAC1B;QACA8D,iBAAiBsB;QACjB,IAAI9E,OAAOgF,IAAI,CAACF,QAAQF,MAAM,EAAE;QAChC;;;;KAIC,GACD,MAAMK,UAAUpH,8BACd4F,OAAOyB,MAAM,EACbxB,cACAC,QACA;QAEF,IAAIsB,QAAQL,MAAM,EAAE;YAClBhD,gBAAgB,GAAGqD,QAAQxF,IAAI,CAAC,MAAM,CAAC,EAAEwF,QAAQL,MAAM,GAAG,IAAI,QAAQ,KAAK,UAAU,CAAC,EAAE;gBACtFrE,SAAS;YACX;YACA;QACF;QACA+C,iBAAiB;QACjB,MAAMgB,UAAU,MAAM9H,mBACpB;YAAE+H,SAAS;YAAQtD;YAAWuD,YAAYxD,eAAe;QAAQ,GACjE,IACEnB,MACE,aAAKF,aAAaC,QAAW7B,qBAAqB2F,cAAcC,UAChE;QAGNL,iBAAiB;QACjB,IAAI,CAACgB,QAAQI,EAAE,EAAE;gBACCJ;YAAhB1C,iBAAgB0C,mBAAAA,QAAQF,OAAO,YAAfE,mBAAmB,gCAAgC;gBACjE/D,SAAS;YACX;YACA;QACF;QACA6C,gBAAgB;QAChB,oEAAoE;QACpE,sBAAsB;QACtBQ,UAAU,CAAC;IACb;IAEA,MAAMuB,UAAUjJ,MAAMkJ,kBAAkB,CAACnG,MAAM/C,MAAMmJ,gBAAgB,CAACxE;IACtE,MAAMyE,cACJH,QAAQI,KAAK,KAAK,YACd,CAAC,qBAAqB,EACpBJ,QAAQK,SAAS,GAAG,CAAC,IAAI,EAAE,IAAIC,KAAKN,QAAQK,SAAS,EAAEE,kBAAkB,IAAI,GAAG,IAChF,GACFP,QAAQI,KAAK,KAAK,aAChB,uBACA;IAER,qBACE,KAAC/G;QACCmH,MAAK;QACLC,OAAOzG,OAAOF,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,QAAQ,IAAI8B;QAC/C,iEAAiE;QACjE,yEAAyE;QACzE8E,UAAU5G,IAAI,CAAC,OAAO,GAAGE,QAAOF,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB,MAAMiB;QACvD4F,MAAM5J,MAAM6J,cAAc,CAAC,YAAY;YAAEC,QAAQ;QAA+B;QAChFC,UAAUpE,OAAOqE,OAAO,CAAC;QACzBC,WAAU;QACV,kEAAkE;QAClE,kEAAkE;QAClE,+CAA+C;QAC/CC,SAASnE,YAAY/B,YAAY;YAAEW;YAAQa;YAAKiE,MAAM;YAAQU,UAAUtF;QAAO;QAC/EuF,uBACE;;gBACGrE,YAAY,OAAOR,+BAClB,KAACxE;oBAAQ2I,OAAOzF;8BAGd,cAAA,KAACoG;kCACC,cAAA,KAAC7J;4BAAO8J,MAAK;4BAAQjG,SAAQ;4BAAYkG,QAAQ;sCAC9C;;;mCAKP,KAAC/J;oBAAO8J,MAAK;oBAAQjG,SAAQ;oBAAYmG,SAAStF;8BAC/C;;8BAIL,KAAChD;oBACCyC,QAAQA;oBACRa,KAAKA;oBACLiF,MAAM;wBAAE5F;oBAAO;oBACf1B,OAAOmD;;8BAET,KAAC/D;oBACCoC,QAAQA;oBACRE,QAAQA;oBACR6F,OAAOzH,QAAOF,eAAAA,IAAI,CAAC,QAAQ,YAAbA,eAAiB;oBAC/B4H,MAAM1H,QAAOF,aAAAA,IAAI,CAAC,OAAO,YAAZA,aAAgB;oBAC7BqD,YAAYA;;;;QAIlBwE,WAAW;eACL1E,OACA;gBACE;oBACEtC,KAAK;oBACLW,OAAO;oBACPsG,oBAAM,KAAC1K;wBAAQ2K,MAAM7K,wBAAwB6K,IAAI;wBAAER,MAAM;;oBACzDS,aAAa;oBACbP,SAASrF;gBACX;aACD,GACD,EAAE;eACHC;SACJ;QACD4F,qBACE;;8BACE,KAACvI;oBAAeM,MAAMA;;8BAItB,KAACX;oBAAkB6I,OAAO7E;;8BAC1B,KAAC/D;oBACCkC,OAAM;oBACNV,OAAOd,KAAKmI,QAAQ,GAAGjG,OAAOkG,QAAQ,CAACpI,KAAKmI,QAAQ,IAAIlH;;gBAEzDsB;;;kBAIL,cAAA,MAACzE;YAAMuD,SAAS;;gBACbiB;gBAMAiB,0BACC,KAACpC;oBAAKK,OAAM;8BACV,cAAA,KAACpC;wBAAagB,OAAOmD;;qBAErB;8BACJ,KAACpC;oBAAKK,OAAM;8BAAqB6E;;8BACjC,MAACvI;oBAAMuK,WAAW;wBAAEC,IAAI;wBAAUC,IAAI;oBAAM;oBAAGlH,SAAS;;wBACrD2B,0BACC,KAAC7B;4BAAKK,OAAM;sCACV,cAAA,MAAC1D;gCAAMuK,WAAU;gCAAMhH,SAAS;gCAAGmH,IAAI;oCAAEC,YAAY;gCAAS;;kDAC5D,KAAC/I;wCAAeM,MAAMA;;kDACtB,KAAC/B;wCAAWqD,SAAQ;wCAAQC,OAAM;kDAC/BvB,KAAK0I,aAAa,GACf,CAAC,UAAU,EAAE,IAAIlC,KAAKxG,KAAK0I,aAAa,EAAEC,cAAc,IAAI,GAC5D;;;;2CAKV,MAACjL;4BAAY6J,MAAK;4BAAQiB,IAAI;gCAAEI,UAAU;4BAAI;;8CAC5C,KAACjL;oCAAWkL,IAAIlF;8CAAgB;;8CAChC,MAAC9F;oCACCiL,SAASnF;oCACTnC,OAAM;oCACNV,OAAOgC,WAAW,gBAAgB,gBAAgBA;oCAClDiG,UAAU,CAACC;wCACT,MAAMC,OAAO/I,OAAO8I,MAAME,MAAM,CAACpI,KAAK;wCACtC,IAAImI,SAAS,eAAe;4CAC1B7G;4CACA;wCACF;wCACA,2DAA2D;wCAC3D,qDAAqD;wCACrD,KAAKxB,MACH;4CACEkC,QAAQmG;2CACJnG,WAAW,gBAAgB;4CAAEqG,mBAAmBjL;wCAAc,IAAI,CAAC,IAEzE;oCAEJ;;sDAEA,KAACN;4CAASkD,OAAM;sDAAO7D,MAAMmM,sBAAsB,CAACC,GAAG;;sDACvD,KAACzL;4CAASkD,OAAM;sDAAW7D,MAAMmM,sBAAsB,CAACE,OAAO;;sDAC/D,KAAC1L;4CAASkD,OAAM;sDACb,GAAG7D,MAAMmM,sBAAsB,CAACG,WAAW,CAAC,CAAC,CAAC;;;;;;sCAKvD,KAAC9J;4BACCqB,OAAOd,KAAKmI,QAAQ;4BACpBjG,QAAQA;4BACRsH,WAAW;4BACXT,UAAU,CAACU,MACT,KAAK7I,MAAM;oCAAEuH,UAAUsB,OAAOvL;gCAAc,GAAGuL,MAAM,mBAAmB;;;;gBAI7E3G,WAAW,iBAAiB9C,KAAKmJ,iBAAiB,iBACjD,KAAC3L;oBAAMkM,UAAS;8BAAQ,CAAC,aAAa,EAAE1J,KAAKmJ,iBAAiB,EAAE;qBAC9D;gBACHnG,0BACC,MAAClF;oBAAMuK,WAAU;oBAAMhH,SAAS;oBAAGmH,IAAI;wBAAEmB,UAAU;wBAAQC,QAAQ;oBAAE;;sCACnE,KAACnM;4BACCgE,WAAWtE;2BACN;4BAAE0M,kBAAkB;4BAASC,cAAc;wBAAM;4BACtDC,MAAMnH,OAAOoH,OAAO,CAAC9J,OAAOF,KAAKkD,kBAAkB;4BACnDqE,MAAK;4BACLjG,SAAQ;sCAEP;;wBAEFtB,KAAKiK,SAAS,iBACb,KAACxM;4BACCgE,WAAWtE;2BACN;4BAAE0M,kBAAkB;4BAASC,cAAc;wBAAM;4BACtDC,MAAMnH,OAAO3C,OAAO,CAACD,KAAKiK,SAAS;4BACnC1C,MAAK;4BACLjG,SAAQ;sCAEP;8BAED;wBACHtB,KAAKkK,MAAM,iBACV,KAACzM;4BACCgE,WAAWtE;2BACN;4BAAE0M,kBAAkB;4BAASC,cAAc;wBAAM;4BACtDC,MAAMnH,OAAOuH,IAAI,CAACnK,KAAKkK,MAAM;4BAC7B3C,MAAK;4BACLjG,SAAQ;sCAEP;8BAED;;qBAEJ;8BAOJ,MAACxD;oBAAMuD,SAAS;;sCACd,KAACpD;4BAAWqD,SAAQ;sCAAa;;sCACjC,MAACxD;4BAAMuK,WAAW;gCAAEC,IAAI;gCAAUC,IAAI;4BAAM;4BAAGlH,SAAS;;8CACtD,KAACtD;oCACCwJ,MAAK;oCACL/F,OAAM;oCACNV,OAAOkD,QAAQ/D,OAAO;oCACtB8I,UAAU,CAACC,QAAUpE,YAAY,WAAWoE,MAAME,MAAM,CAACpI,KAAK;oCAC9D0G,UAAUxE;oCACVoH,WAAW;wCAAEC,WAAW;4CAAEC,WAAWzK;wCAAS;oCAAE;oCAChD2J,SAAS;;8CAEX,KAACzL;oCACCwJ,MAAK;oCACL/F,OAAM;oCACNV,OAAOkD,QAAQ7D,QAAQ;oCACvB4I,UAAU,CAACC,QAAUpE,YAAY,YAAYoE,MAAME,MAAM,CAACpI,KAAK;oCAC/D0G,UAAUxE;oCACVoH,WAAW;wCAAEC,WAAW;4CAAEC,WAAWzK;wCAAS;oCAAE;oCAChD2J,SAAS;;;;sCAGb,MAAC1L;4BAAMuK,WAAW;gCAAEC,IAAI;gCAAUC,IAAI;4BAAM;4BAAGlH,SAAS;;8CACtD,KAACtD;oCACCwJ,MAAK;oCACL/F,OAAM;oCACNV,OAAOkD,QAAQ5D,KAAK;oCACpB2I,UAAU,CAACC,QAAUpE,YAAY,SAASoE,MAAME,MAAM,CAACpI,KAAK;oCAC5D0G,UAAUxE;oCACViC,OAAOhC,QAAQqB,aAAa,CAAC,QAAQ;oCACrCiG,YAAYjG,aAAa,CAAC,QAAQ,IAAI;oCACtCkF,SAAS;;8CAEX,KAACzL;oCACCwJ,MAAK;oCACL/F,OAAM;oCACNV,OAAOkD,QAAQ3D,OAAO;oCACtB0I,UAAU,CAACC,QAAUpE,YAAY,WAAWoE,MAAME,MAAM,CAACpI,KAAK;oCAC9D0G,UAAUxE;oCACViC,OAAOhC,QAAQqB,aAAa,CAAC,UAAU;oCACvCiG,YAAYjG,aAAa,CAAC,UAAU,IAAI;oCACxCkF,SAAS;;;;sCAGb,MAAC1L;4BAAMuK,WAAW;gCAAEC,IAAI;gCAAUC,IAAI;4BAAM;4BAAGlH,SAAS;;8CACtD,KAACtD;oCACCwJ,MAAK;oCACL/F,OAAM;oCACNV,OAAOkD,QAAQ1D,UAAU;oCACzByI,UAAU,CAACC,QAAUpE,YAAY,cAAcoE,MAAME,MAAM,CAACpI,KAAK;oCACjE0G,UAAUxE;oCACVoH,WAAW;wCAAEC,WAAW;4CAAEC,WAAWzK;wCAAS;oCAAE;oCAChD2J,SAAS;;8CAEX,KAACzL;oCACCwJ,MAAK;oCACL/F,OAAM;oCACNV,OAAOkD,QAAQzD,IAAI;oCACnBwI,UAAU,CAACC,QAAUpE,YAAY,QAAQoE,MAAME,MAAM,CAACpI,KAAK;oCAC3D0G,UAAUxE;oCACVuH,YAAW;oCACXf,SAAS;;;;sCAGb,KAACvL;4BAAWqD,SAAQ;4BAAUC,OAAM;sCACjC;;sCAEH,KAACrC;4BACC4B,OAAOkD,QAAQvD,OAAO;4BACtBsI,UAAU,CAACE,OAASrE,YAAY,WAAWqE;4BAC3CzB,UAAUxE;;wBAOXwB,OAAOyB,MAAM,CAACN,MAAM,iBACnB;;8CACE,KAAC1H;oCAAWqD,SAAQ;oCAAUC,OAAM;8CACjC;;gCAEFiD,OAAOyB,MAAM,CAACuE,GAAG,CAAC,CAACC,2BAClB,KAAC1L;wCAEC0L,YAAYA;wCACZ3J,OAAOjC,oBAAoB4F,cAAcC,QAAQ+F,WAAW5J,GAAG;wCAC/DkI,UAAU,CAACjI,QACT6D,UAAU,CAACE,UAAa,aAAKA;oDAAS,CAAC4F,WAAW5J,GAAG,CAAC,EAAEC;;wCAE1D0G,UAAUxE,aAAaoB;uCANlBqG,WAAWC,GAAG;;6BAUvB;wBACH1H,YAAY,qBACX,KAAClF;4BAAMuK,WAAU;4BAAMhH,SAAS;4BAAGmH,IAAI;gCAAEmC,gBAAgB;4BAAW;sCAClE,cAAA,KAAClN;gCACC8J,MAAK;gCACLjG,SAAQ;gCACRmG,SAAS,IAAM,KAAK7B;gCACpB4B,UAAU,CAAC9B,SAAStB;0CAEnB;;;;;8BAKT,MAACtG;oBAAMuD,SAAS;;sCACd,KAACtD;4BACCwJ,MAAK;4BACL/F,OAAM;4BACNV,OAAO2C;4BACPsF,UAAU,CAACC;gCACTtF,SAASsF,MAAME,MAAM,CAACpI,KAAK;gCAC3B+C,cAAc;4BAChB;4BACA+G,SAAS;4BACTC,SAAS;4BACTrB,SAAS;4BACTY,WAAW;gCAAEC,WAAW;oCAAEC,WAAW3K;gCAAU;4BAAE;;sCAEnD,KAAC7B;4BAAMuK,WAAU;4BAAMhH,SAAS;4BAAGmH,IAAI;gCAAEmC,gBAAgB;4BAAW;sCAClE,cAAA,KAAClN;gCACC8J,MAAK;gCACLjG,SAAQ;gCACRmG,SAAS,IAAM,KAAKrC;gCACpBoC,UAAU,CAAC5D,cAAcE;0CAExB;;;;;;;;AAOf;AACAnC,mBAAmBmJ,WAAW,GAAG;AAEjC,eAAenJ,mBAAkB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-properties-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n AglynOrgBilling,\n CrmLeadFields,\n CrmLeadProfilePatch,\n CrmLeadStatus,\n} from '@aglyn/aglyn'\nimport { mdiAccountCancelOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n FormControl,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { deleteField, doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useId, useMemo, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport {\n crmCustomDraftChanges,\n type CrmCustomDraft,\n crmCustomDraftMissingRequired,\n crmCustomDraftValue,\n crmCustomDraftWrites,\n} from '../model/crm-custom-draft'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n addressDraftFrom,\n ContactAddressFields,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmCallButton, CrmPhoneLink } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { LeadStatusChip } from './lead-status-chip'\n\nconst NOTES_MAX = Aglyn.CRM_LEAD_NOTES_MAX\nconst TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX\n\n/** The profile as the form holds it: every field a string, the address a draft. */\ninterface ProfileDraft {\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n tags: string\n address: AddressDraft\n}\n\n/** The stored profile as a draft the fields can edit. */\nfunction profileDraftFrom(lead: Record<string, unknown> & CrmLeadFields): ProfileDraft {\n return {\n company: String(lead.company ?? ''),\n jobTitle: String(lead.jobTitle ?? ''),\n phone: String(lead.phone ?? lead['phone'] ?? ''),\n website: String(lead.website ?? ''),\n leadSource: String(lead.leadSource ?? ''),\n tags: (lead.tags ?? []).join(', '),\n address: addressDraftFrom(lead.address ?? null),\n }\n}\n\n/** The patch as the document takes it: a cleared field is deleted, not blanked. */\nfunction profileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? deleteField() : value\n }\n return write\n}\n\n/**\n * Why Convert is refused while an erasure waits on the person — the same\n * sentence shape the overflow's items carry, so the two read as one state.\n */\nexport const CONVERT_PENDING_ERASURE_REASON = 'An erasure is pending for this person'\n\n/** A label over a value — the record page's one row shape. */\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 LeadPropertiesCardProps {\n hostId: string\n /**\n * The org the site belongs to, as the page already resolved it — the\n * custom lead fields are ORG-wide (AGL-3272), and a second lookup here\n * would be one more read per record page for an answer already in hand.\n */\n orgId: string | null\n leadId: string\n lead: Record<string, unknown> & CrmLeadFields\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n basePath: string\n roster: OrgMemberOptions\n onConvert: () => void\n onUnqualify: () => void\n /**\n * Items the page adds to the overflow beside Unqualify — the privacy\n * erasure (AGL-2623) lives on the page, because it needs the workspace\n * role and the API, and this card owns the header it must appear in.\n */\n extraMenuItems?: RowActionsMenuItem[]\n /** What the page shows above the facts — the erasure-pending state. */\n banner?: React.ReactNode\n /**\n * Chips the page adds after the owner — the campaigns the lead is filed\n * under (AGL-3274), named from the containers the page reads once for\n * this header and the Campaigns card below it.\n */\n extraChips?: React.ReactNode\n /**\n * An erasure request is waiting on this person (AGL-2623). Convert stays\n * on the page but is refused with the reason, the way the overflow's items\n * are: a conversion filed now would reach the capture door only to be\n * refused there, and the lead itself goes when the request runs.\n */\n erasurePending?: boolean\n /**\n * The org the shell passed: the booking door reads whether the lead's site\n * runs Bookings and whether the plan is entitled to it (AGL-2660), and a\n * logged call reads the activity scope it belongs in (AGL-2661).\n */\n org?: Partial<AglynOrgBilling> | null\n}\n\n/**\n * What the team knows and decides about a lead: status, owner, notes, and\n * the identity and consent the capture recorded (AGL-2608).\n *\n * Status and owner are single-field client writes — the rules let a site\n * admin, editor or author update `hosts/{hostId}/leads`, and a one-field\n * `update` cannot roll anything else back. Notes are a text field seeded\n * from the document, so that save goes through `writeGuardedBySeed`: a draft\n * edited over a cached read would otherwise overwrite a newer note with an\n * older one plus a sentence.\n *\n * Converted leads are read-only here. Their status is the conversion, and\n * the actions become links to what the conversion made.\n */\nexport function LeadPropertiesCard(props: LeadPropertiesCardProps) {\n const {\n hostId,\n orgId,\n leadId,\n lead,\n leadStatus,\n fromCache,\n basePath,\n roster,\n onConvert,\n onUnqualify,\n extraMenuItems = [],\n banner,\n extraChips,\n erasurePending = false,\n org,\n } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const routes = crmRoutes(basePath)\n /*\n * Built at USE, not at render (AGL-3275). A lead is an org row, so the org\n * has to be known to address one — and `orgId` is null while the lookup\n * settles. Composing a ref from that null throws during render, which the\n * page shows as a 500 rather than as a card still loading.\n */\n const refFor = () => (orgId ? doc(firestore, 'orgs', orgId, 'leads', leadId) : null)\n const status = Aglyn.crmLeadStatus(lead)\n const converted = Boolean(lead.convertedContactId)\n const open = Aglyn.isCrmLeadOpen(lead) && !converted\n // The last verdict on the address (AGL-3245), as the platform stamped it.\n const emailState = Aglyn.readEmailState(lead)\n /** What a capture that took a number left on the document (AGL-2661). */\n const leadPhone = String(lead['phone'] ?? '').trim()\n\n const [notes, setNotes] = useState(String(lead.notes ?? ''))\n // The label's id, so the status combobox is named \"Status\" rather than\n // after the status it shows — see `LeadOwnerSelect`.\n const statusLabelId = useId()\n const [notesDirty, setNotesDirty] = useState(false)\n const [savingNotes, setSavingNotes] = useState(false)\n /*\n * THE LEAD'S OWN PROFILE (AGL-3231) — company, title, phone, website,\n * address, tags, lead source — edited as one block with one Save, the\n * way the contact's Properties card saves. Seeded from the document and\n * guarded on save like the notes: a draft edited over a cached read must\n * not overwrite a newer profile with an older one.\n */\n const [profile, setProfile] = useState<ProfileDraft>(() => profileDraftFrom(lead))\n const [profileDirty, setProfileDirty] = useState(false)\n const [savingProfile, setSavingProfile] = useState(false)\n const [profileErrors, setProfileErrors] = useState<Record<string, string>>({})\n useEffect(() => {\n if (!profileDirty) setProfile(profileDraftFrom(lead))\n // The draft follows the document until it is edited; the fields are\n // read one by one so a change to any of them reseeds.\n }, [\n profileDirty,\n lead.company,\n lead.jobTitle,\n lead.phone,\n lead.website,\n lead.leadSource,\n lead.tags,\n lead.address,\n ])\n /*\n * THE ORG'S OWN LEAD FIELDS (AGL-3272), edited under the same Save as\n * the profile: one button over one card, so a person filling a lead in\n * does not have to find two.\n *\n * The draft holds only the keys the reader touched, and Save writes the\n * difference as dotted paths — a `custom` map written whole would take\n * out every key this card did not show, which is what a retired field's\n * values and an integration's writes sit under.\n */\n const fields = useContactFieldDefinitions(orgId, 'lead')\n const storedCustom = useMemo(() => lead.custom ?? {}, [lead.custom])\n const [custom, setCustom] = useState<CrmCustomDraft>({})\n\n const editProfile = <K extends keyof ProfileDraft>(key: K, value: ProfileDraft[K]) => {\n setProfile((current) => ({ ...current, [key]: value }))\n setProfileDirty(true)\n }\n // A newer note from the server replaces an UNEDITED draft; an edited one is\n // the reader's, and the guard on save decides whether it may land.\n useEffect(() => {\n if (!notesDirty) setNotes(String(lead.notes ?? ''))\n }, [lead.notes, notesDirty])\n\n const write = async (fields: Record<string, unknown>, done: string) => {\n try {\n const ref = refFor()\n if (!ref) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n await updateDoc(ref, { ...fields, updatedAt: serverTimestamp() })\n enqueueSnackbar(done, { variant: 'success', persist: false })\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n }\n }\n\n const saveNotes = async () => {\n setSavingNotes(true)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n () => write({ notes: notes.trim().slice(0, NOTES_MAX) }, 'Notes saved'),\n )\n setSavingNotes(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The notes could not be saved.', {\n variant: 'warning',\n })\n return\n }\n setNotesDirty(false)\n }\n\n /*\n * What Save is offered for: a profile field edited, or a custom value\n * that differs from the stored map. Touched-and-put-back does not count\n * — the draft keeps the key either way, and a Save that wrote nothing\n * would still bump `updatedAt`.\n */\n const dirty =\n profileDirty || crmCustomDraftChanges(storedCustom, custom).length > 0\n\n const saveProfile = async () => {\n const { patch, errors } = Aglyn.normalizeCrmLeadProfile({\n company: profile.company,\n jobTitle: profile.jobTitle,\n phone: profile.phone,\n website: profile.website,\n leadSource: profile.leadSource,\n tags: profile.tags,\n address: profile.address,\n })\n setProfileErrors(errors)\n if (Object.keys(errors).length) return\n /*\n * A required field the reader CLEARED is refused; one the lead has\n * always lacked is not this save's to demand, or a field added after\n * the lead was captured would block every later edit to its company.\n */\n const missing = crmCustomDraftMissingRequired(\n fields.active,\n storedCustom,\n custom,\n 'edit',\n )\n if (missing.length) {\n enqueueSnackbar(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`, {\n variant: 'warning',\n })\n return\n }\n setSavingProfile(true)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n () =>\n write(\n { ...profileWrite(patch), ...crmCustomDraftWrites(storedCustom, custom) },\n 'Lead saved',\n ),\n )\n setSavingProfile(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The lead could not be saved.', {\n variant: 'warning',\n })\n return\n }\n setProfileDirty(false)\n // The draft is spent: what it held is stored, and the controls read\n // the document again.\n setCustom({})\n }\n\n const consent = Aglyn.readMarketingBasis(lead, Aglyn.soloConsentGroup(hostId))\n const consentLine =\n consent.basis === 'granted'\n ? `Opted in to marketing${\n consent.basisAtMs ? ` on ${new Date(consent.basisAtMs).toLocaleDateString()}` : ''\n }`\n : consent.basis === 'declined'\n ? 'Declined marketing'\n : 'No marketing consent recorded — this lead cannot be emailed marketing'\n\n return (\n <CrmRecordHeader\n kind=\"Lead\"\n title={String(lead['name'] || lead['email'] || leadId)}\n // The name is the heading; the address is the one line under it,\n // unless the address IS the name, in which case there is no second fact.\n subtitle={lead['name'] ? String(lead['email'] ?? '') : undefined}\n help={Aglyn.pluginDocsHelp('crmLeads', { anchor: '#working-a-lead-from-the-row' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n // The booking door (AGL-2660), while the lead is still the record\n // being worked: once converted, the contact is where a meeting is\n // booked from, and the links below lead there.\n booking={converted ? undefined : { hostId, org, kind: 'lead', recordId: leadId }}\n actions={\n <>\n {converted ? null : erasurePending ? (\n <Tooltip title={CONVERT_PENDING_ERASURE_REASON}>\n {/* A disabled button receives no pointer events, so the\n tooltip anchors on the span around it. */}\n <span>\n <Button size=\"small\" variant=\"contained\" disabled>\n {'Convert'}\n </Button>\n </span>\n </Tooltip>\n ) : (\n <Button size=\"small\" variant=\"contained\" onClick={onConvert}>\n {'Convert'}\n </Button>\n )}\n {/* Dial the number the capture carried, and log the call (AGL-2661). */}\n <CrmCallButton\n hostId={hostId}\n org={org}\n link={{ leadId }}\n phone={leadPhone}\n />\n <CrmSendEmailButton\n hostId={hostId}\n leadId={leadId}\n email={String(lead['email'] ?? '')}\n name={String(lead['name'] ?? '')}\n emailState={emailState}\n />\n </>\n }\n menuItems={[\n ...(open\n ? [\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />,\n destructive: true,\n onClick: onUnqualify,\n } satisfies RowActionsMenuItem,\n ]\n : []),\n ...extraMenuItems,\n ]}\n chips={\n <>\n <LeadStatusChip lead={lead} />\n {/* The verdict on the address (AGL-3245), beside the status: a\n bounce does not move New or Working, but it is the first\n thing a person deciding whether to write must see. */}\n <CrmEmailStateChip state={emailState} />\n <CrmRecordChip\n label=\"Owner\"\n value={lead.ownerUid ? roster.labelFor(lead.ownerUid) : undefined}\n />\n {extraChips}\n </>\n }\n >\n <Stack spacing={3}>\n {banner}\n {/*\n Only when the capture carried one (AGL-2661): the sign-up and\n booking doors write no phone, so a row for every lead would be a\n permanent blank. A form that captures one fills this.\n */}\n {leadPhone ? (\n <Fact label=\"Phone\">\n <CrmPhoneLink phone={leadPhone} />\n </Fact>\n ) : null}\n <Fact label=\"Marketing consent\">{consentLine}</Fact>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n {converted ? (\n <Fact label=\"Status\">\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <LeadStatusChip lead={lead} />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {lead.convertedAtMs\n ? `Converted ${new Date(lead.convertedAtMs).toLocaleString()}`\n : 'Converted'}\n </Typography>\n </Stack>\n </Fact>\n ) : (\n <FormControl size=\"small\" sx={{ minWidth: 200 }}>\n <InputLabel id={statusLabelId}>{'Status'}</InputLabel>\n <Select\n labelId={statusLabelId}\n label=\"Status\"\n value={status === 'unqualified' ? 'unqualified' : status}\n onChange={(event) => {\n const next = String(event.target.value) as CrmLeadStatus\n if (next === 'unqualified') {\n onUnqualify()\n return\n }\n // Reopening drops the reason with the closed state: a lead\n // being worked again is not \"unqualified because …\".\n void write(\n {\n status: next,\n ...(status === 'unqualified' ? { unqualifiedReason: deleteField() } : {}),\n },\n 'Status updated',\n )\n }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{Aglyn.CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n <MenuItem value=\"unqualified\">\n {`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}\n </MenuItem>\n </Select>\n </FormControl>\n )}\n <LeadOwnerSelect\n value={lead.ownerUid}\n roster={roster}\n fullWidth={false}\n onChange={(uid) =>\n void write({ ownerUid: uid || deleteField() }, uid ? 'Owner assigned' : 'Owner cleared')\n }\n />\n </Stack>\n {status === 'unqualified' && lead.unqualifiedReason ? (\n <Alert severity=\"info\">{`Unqualified: ${lead.unqualifiedReason}`}</Alert>\n ) : null}\n {converted ? (\n <Stack direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap', rowGap: 1 }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.contact(String(lead.convertedContactId))}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open contact'}\n </Button>\n {lead.companyId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.company(lead.companyId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open company'}\n </Button>\n ) : null}\n {lead.dealId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.deal(lead.dealId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open deal'}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n {/*\n The profile (AGL-3231): what Salesforce keeps on a lead and hands\n to the contact and the account on convert. Read-only once\n converted — the contact is the record then, and the links above\n lead there.\n */}\n <Stack spacing={2}>\n <Typography variant=\"subtitle2\">{'Profile'}</Typography>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Company\"\n value={profile.company}\n onChange={(event) => editProfile('company', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={profile.jobTitle}\n onChange={(event) => editProfile('jobTitle', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={profile.phone}\n onChange={(event) => editProfile('phone', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['phone'])}\n helperText={profileErrors['phone'] || 'With the country code, like +1 512 555 0107'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={profile.website}\n onChange={(event) => editProfile('website', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['website'])}\n helperText={profileErrors['website'] || 'Like acme.com'}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Lead source\"\n value={profile.leadSource}\n onChange={(event) => editProfile('leadSource', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Tags\"\n value={profile.tags}\n onChange={(event) => editProfile('tags', event.target.value)}\n disabled={converted}\n helperText=\"Comma-separated\"\n fullWidth\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Address'}\n </Typography>\n <ContactAddressFields\n value={profile.address}\n onChange={(next) => editProfile('address', next)}\n disabled={converted}\n />\n {/*\n The org's own lead fields (AGL-3272), under the profile because\n they describe the same record and save with it. A converted\n lead's are read-only with the rest of the card.\n */}\n {fields.active.length ? (\n <>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Custom fields'}\n </Typography>\n {fields.active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={crmCustomDraftValue(storedCustom, custom, definition.key)}\n onChange={(value) =>\n setCustom((current) => ({ ...current, [definition.key]: value }))\n }\n disabled={converted || savingProfile}\n />\n ))}\n </>\n ) : null}\n {converted ? null : (\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void saveProfile()}\n disabled={!dirty || savingProfile}\n >\n {'Save'}\n </Button>\n </Stack>\n )}\n </Stack>\n <Stack spacing={1}>\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => {\n setNotes(event.target.value)\n setNotesDirty(true)\n }}\n multiline\n minRows={3}\n fullWidth\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n />\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void saveNotes()}\n disabled={!notesDirty || savingNotes}\n >\n {'Save notes'}\n </Button>\n </Stack>\n </Stack>\n </Stack>\n </CrmRecordHeader>\n )\n}\nLeadPropertiesCard.displayName = 'LeadPropertiesCard'\n\nexport default LeadPropertiesCard\n"],"names":["Aglyn","mdiAccountCancelOutline","AppLink","MdiIcon","useSnackbar","useFirestore","writeGuardedBySeed","Alert","Button","FormControl","InputLabel","MenuItem","Select","Stack","TextField","Tooltip","Typography","deleteField","doc","serverTimestamp","updateDoc","useEffect","useId","useMemo","useState","useContactFieldDefinitions","crmCustomDraftChanges","crmCustomDraftMissingRequired","crmCustomDraftValue","crmCustomDraftWrites","CrmCustomFieldControl","crmRoutes","addressDraftFrom","ContactAddressFields","CrmCallButton","CrmPhoneLink","CrmEmailStateChip","CrmRecordChip","CrmRecordHeader","CrmSendEmailButton","LeadOwnerSelect","LeadStatusChip","NOTES_MAX","CRM_LEAD_NOTES_MAX","TEXT_MAX","CRM_LEAD_TEXT_MAX","profileDraftFrom","lead","company","String","jobTitle","phone","website","leadSource","tags","join","address","profileWrite","patch","write","key","value","Object","entries","undefined","CONVERT_PENDING_ERASURE_REASON","Fact","props","spacing","variant","color","label","component","children","LeadPropertiesCard","hostId","orgId","leadId","leadStatus","fromCache","basePath","roster","onConvert","onUnqualify","extraMenuItems","banner","extraChips","erasurePending","org","firestore","enqueueSnackbar","routes","refFor","status","crmLeadStatus","converted","Boolean","convertedContactId","open","isCrmLeadOpen","emailState","readEmailState","leadPhone","trim","notes","setNotes","statusLabelId","notesDirty","setNotesDirty","savingNotes","setSavingNotes","profile","setProfile","profileDirty","setProfileDirty","savingProfile","setSavingProfile","profileErrors","setProfileErrors","fields","storedCustom","custom","setCustom","editProfile","current","done","ref","persist","updatedAt","error","Error","message","saveNotes","verdict","subject","unreadable","slice","ok","dirty","length","saveProfile","errors","normalizeCrmLeadProfile","keys","missing","active","consent","readMarketingBasis","soloConsentGroup","consentLine","basis","basisAtMs","Date","toLocaleDateString","kind","title","subtitle","help","pluginDocsHelp","anchor","backHref","section","backLabel","booking","recordId","actions","span","size","disabled","onClick","link","email","name","menuItems","icon","path","destructive","chips","state","ownerUid","labelFor","direction","xs","md","sx","alignItems","convertedAtMs","toLocaleString","minWidth","id","labelId","onChange","event","next","target","unqualifiedReason","CRM_LEAD_STATUS_LABELS","new","working","unqualified","fullWidth","uid","severity","flexWrap","rowGap","componentVariant","nativeButton","href","contact","companyId","dealId","deal","slotProps","htmlInput","maxLength","helperText","map","definition","$id","justifyContent","multiline","minRows","displayName"],"mappings":"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"}
@@ -51,6 +51,13 @@ const REASON_MAX = UNQUALIFY_REASON_MAX;
51
51
  const submit = async ()=>{
52
52
  const trimmed = reason.trim();
53
53
  if (!trimmed) return;
54
+ if (!orgId) {
55
+ enqueueSnackbar('Still loading this workspace — try again in a moment.', {
56
+ variant: 'warning',
57
+ persist: false
58
+ });
59
+ return;
60
+ }
54
61
  setBusy(true);
55
62
  try {
56
63
  const fields = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-unqualify-dialog.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { CrmLeadFields } from '@aglyn/aglyn'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogContentText,\n DialogTitle,\n TextField,\n} from '@mui/material'\nimport { doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\n/** The most a reason may say — shared with the bulk bar's one-reason-for-all (AGL-2662). */\nexport const UNQUALIFY_REASON_MAX = 500\nconst REASON_MAX = UNQUALIFY_REASON_MAX\n\nexport interface LeadUnqualifyDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n leadId: string\n /** How the lead reads in the title — its name, else its address. */\n leadLabel: string\n}\n\n/**\n * Close a lead without converting it, with the reason why (AGL-2608).\n *\n * The reason is REQUIRED. An unqualified lead with no reason is a row that\n * says only \"somebody gave up\", and the one thing a report on lost leads\n * wants to count is why. A client-direct write: `hosts/{hostId}/leads` is\n * not in the catch-all's update exclusions, so a site admin, editor or\n * author may update it, and nothing here needs the server.\n */\nexport function LeadUnqualifyDialog(props: LeadUnqualifyDialogProps) {\n const { open, onClose, hostId, leadId, leadLabel } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const [reason, setReason] = useState('')\n const [busy, setBusy] = useState(false)\n\n useEffect(() => {\n if (open) setReason('')\n }, [open])\n\n const submit = async () => {\n const trimmed = reason.trim()\n if (!trimmed) return\n setBusy(true)\n try {\n const fields: Required<Pick<CrmLeadFields, 'status' | 'unqualifiedReason'>> = {\n status: 'unqualified',\n unqualifiedReason: trimmed.slice(0, REASON_MAX),\n }\n await updateDoc(doc(firestore, 'orgs', orgId, 'leads', leadId), {\n ...fields,\n updatedAt: serverTimestamp(),\n })\n enqueueSnackbar('Lead marked unqualified', { variant: 'success', persist: false })\n onClose()\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"xs\" fullWidth>\n <DialogTitle>{`Unqualify ${leadLabel}?`}</DialogTitle>\n <DialogContent>\n <DialogContentText sx={{ mb: 2 }}>\n {'The lead stays on file and drops out of the open list. Say why, ' +\n 'so the reason can be counted later.'}\n </DialogContentText>\n <TextField\n label=\"Reason\"\n value={reason}\n onChange={(event) => setReason(event.target.value)}\n multiline\n minRows={2}\n fullWidth\n autoFocus\n slotProps={{ htmlInput: { maxLength: REASON_MAX } }}\n />\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button\n variant=\"contained\"\n color=\"warning\"\n onClick={() => void submit()}\n disabled={busy || !reason.trim()}\n >\n {'Unqualify'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nLeadUnqualifyDialog.displayName = 'LeadUnqualifyDialog'\n\nexport default LeadUnqualifyDialog\n"],"names":["useSnackbar","useFirestore","Button","Dialog","DialogActions","DialogContent","DialogContentText","DialogTitle","TextField","doc","serverTimestamp","updateDoc","useEffect","useState","useCrmScope","UNQUALIFY_REASON_MAX","REASON_MAX","LeadUnqualifyDialog","props","open","onClose","hostId","leadId","leadLabel","orgId","firestore","enqueueSnackbar","reason","setReason","busy","setBusy","submit","trimmed","trim","fields","status","unqualifiedReason","slice","updatedAt","variant","persist","error","Error","message","maxWidth","fullWidth","sx","mb","label","value","onChange","event","target","multiline","minRows","autoFocus","slotProps","htmlInput","maxLength","onClick","disabled","color","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAGA,SAASA,WAAW,QAAQ,8BAA6B;AACzD,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,MAAM,EACNC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,iBAAiB,EACjBC,WAAW,EACXC,SAAS,QACJ,gBAAe;AACtB,SAASC,GAAG,EAAEC,eAAe,EAAEC,SAAS,QAAQ,qBAAoB;AACpE,SAASC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAC3C,SAASC,WAAW,QAAQ,4BAAwB;AAEpD,0FAA0F,GAC1F,OAAO,MAAMC,uBAAuB,IAAG;AACvC,MAAMC,aAAaD;AAWnB;;;;;;;;CAQC,GACD,OAAO,SAASE,oBAAoBC,KAA+B;IACjE,MAAM,EAAEC,IAAI,EAAEC,OAAO,EAAEC,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAAGL;IACrD,0EAA0E;IAC1E,4DAA4D;IAC5D,MAAM,EAAEM,KAAK,EAAE,GAAGV,YAAY;QAAEO;IAAO;IACvC,MAAMI,YAAYxB;IAClB,MAAM,EAAEyB,eAAe,EAAE,GAAG1B;IAC5B,MAAM,CAAC2B,QAAQC,UAAU,GAAGf,SAAS;IACrC,MAAM,CAACgB,MAAMC,QAAQ,GAAGjB,SAAS;IAEjCD,UAAU;QACR,IAAIO,MAAMS,UAAU;IACtB,GAAG;QAACT;KAAK;IAET,MAAMY,SAAS;QACb,MAAMC,UAAUL,OAAOM,IAAI;QAC3B,IAAI,CAACD,SAAS;QACdF,QAAQ;QACR,IAAI;YACF,MAAMI,SAAwE;gBAC5EC,QAAQ;gBACRC,mBAAmBJ,QAAQK,KAAK,CAAC,GAAGrB;YACtC;YACA,MAAML,UAAUF,IAAIgB,WAAW,QAAQD,OAAO,SAASF,SAAS,aAC3DY;gBACHI,WAAW5B;;YAEbgB,gBAAgB,2BAA2B;gBAAEa,SAAS;gBAAWC,SAAS;YAAM;YAChFpB;QACF,EAAE,OAAOqB,OAAO;YACdf,gBACEe,iBAAiBC,QAAQD,MAAME,OAAO,GAAG,kCACzC;gBAAEJ,SAAS;YAAQ;QAEvB,SAAU;YACRT,QAAQ;QACV;IACF;IAEA,qBACE,MAAC3B;QAAOgB,MAAMA;QAAMC,SAASA;QAASwB,UAAS;QAAKC,SAAS;;0BAC3D,KAACtC;0BAAa,CAAC,UAAU,EAAEgB,UAAU,CAAC,CAAC;;0BACvC,MAAClB;;kCACC,KAACC;wBAAkBwC,IAAI;4BAAEC,IAAI;wBAAE;kCAC5B,qEACC;;kCAEJ,KAACvC;wBACCwC,OAAM;wBACNC,OAAOtB;wBACPuB,UAAU,CAACC,QAAUvB,UAAUuB,MAAMC,MAAM,CAACH,KAAK;wBACjDI,SAAS;wBACTC,SAAS;wBACTT,SAAS;wBACTU,SAAS;wBACTC,WAAW;4BAAEC,WAAW;gCAAEC,WAAW1C;4BAAW;wBAAE;;;;0BAGtD,MAACZ;;kCACC,KAACF;wBAAOyD,SAASvC;wBAASwC,UAAU/B;kCACjC;;kCAEH,KAAC3B;wBACCqC,SAAQ;wBACRsB,OAAM;wBACNF,SAAS,IAAM,KAAK5B;wBACpB6B,UAAU/B,QAAQ,CAACF,OAAOM,IAAI;kCAE7B;;;;;;AAKX;AACAhB,oBAAoB6C,WAAW,GAAG;AAElC,eAAe7C,oBAAmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-unqualify-dialog.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { CrmLeadFields } from '@aglyn/aglyn'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogContentText,\n DialogTitle,\n TextField,\n} from '@mui/material'\nimport { doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\n/** The most a reason may say — shared with the bulk bar's one-reason-for-all (AGL-2662). */\nexport const UNQUALIFY_REASON_MAX = 500\nconst REASON_MAX = UNQUALIFY_REASON_MAX\n\nexport interface LeadUnqualifyDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n leadId: string\n /** How the lead reads in the title — its name, else its address. */\n leadLabel: string\n}\n\n/**\n * Close a lead without converting it, with the reason why (AGL-2608).\n *\n * The reason is REQUIRED. An unqualified lead with no reason is a row that\n * says only \"somebody gave up\", and the one thing a report on lost leads\n * wants to count is why. A client-direct write: `hosts/{hostId}/leads` is\n * not in the catch-all's update exclusions, so a site admin, editor or\n * author may update it, and nothing here needs the server.\n */\nexport function LeadUnqualifyDialog(props: LeadUnqualifyDialogProps) {\n const { open, onClose, hostId, leadId, leadLabel } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const [reason, setReason] = useState('')\n const [busy, setBusy] = useState(false)\n\n useEffect(() => {\n if (open) setReason('')\n }, [open])\n\n const submit = async () => {\n const trimmed = reason.trim()\n if (!trimmed) return\n if (!orgId) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n setBusy(true)\n try {\n const fields: Required<Pick<CrmLeadFields, 'status' | 'unqualifiedReason'>> = {\n status: 'unqualified',\n unqualifiedReason: trimmed.slice(0, REASON_MAX),\n }\n await updateDoc(doc(firestore, 'orgs', orgId, 'leads', leadId), {\n ...fields,\n updatedAt: serverTimestamp(),\n })\n enqueueSnackbar('Lead marked unqualified', { variant: 'success', persist: false })\n onClose()\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"xs\" fullWidth>\n <DialogTitle>{`Unqualify ${leadLabel}?`}</DialogTitle>\n <DialogContent>\n <DialogContentText sx={{ mb: 2 }}>\n {'The lead stays on file and drops out of the open list. Say why, ' +\n 'so the reason can be counted later.'}\n </DialogContentText>\n <TextField\n label=\"Reason\"\n value={reason}\n onChange={(event) => setReason(event.target.value)}\n multiline\n minRows={2}\n fullWidth\n autoFocus\n slotProps={{ htmlInput: { maxLength: REASON_MAX } }}\n />\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button\n variant=\"contained\"\n color=\"warning\"\n onClick={() => void submit()}\n disabled={busy || !reason.trim()}\n >\n {'Unqualify'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nLeadUnqualifyDialog.displayName = 'LeadUnqualifyDialog'\n\nexport default LeadUnqualifyDialog\n"],"names":["useSnackbar","useFirestore","Button","Dialog","DialogActions","DialogContent","DialogContentText","DialogTitle","TextField","doc","serverTimestamp","updateDoc","useEffect","useState","useCrmScope","UNQUALIFY_REASON_MAX","REASON_MAX","LeadUnqualifyDialog","props","open","onClose","hostId","leadId","leadLabel","orgId","firestore","enqueueSnackbar","reason","setReason","busy","setBusy","submit","trimmed","trim","variant","persist","fields","status","unqualifiedReason","slice","updatedAt","error","Error","message","maxWidth","fullWidth","sx","mb","label","value","onChange","event","target","multiline","minRows","autoFocus","slotProps","htmlInput","maxLength","onClick","disabled","color","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAGA,SAASA,WAAW,QAAQ,8BAA6B;AACzD,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,MAAM,EACNC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,iBAAiB,EACjBC,WAAW,EACXC,SAAS,QACJ,gBAAe;AACtB,SAASC,GAAG,EAAEC,eAAe,EAAEC,SAAS,QAAQ,qBAAoB;AACpE,SAASC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAC3C,SAASC,WAAW,QAAQ,4BAAwB;AAEpD,0FAA0F,GAC1F,OAAO,MAAMC,uBAAuB,IAAG;AACvC,MAAMC,aAAaD;AAWnB;;;;;;;;CAQC,GACD,OAAO,SAASE,oBAAoBC,KAA+B;IACjE,MAAM,EAAEC,IAAI,EAAEC,OAAO,EAAEC,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAAGL;IACrD,0EAA0E;IAC1E,4DAA4D;IAC5D,MAAM,EAAEM,KAAK,EAAE,GAAGV,YAAY;QAAEO;IAAO;IACvC,MAAMI,YAAYxB;IAClB,MAAM,EAAEyB,eAAe,EAAE,GAAG1B;IAC5B,MAAM,CAAC2B,QAAQC,UAAU,GAAGf,SAAS;IACrC,MAAM,CAACgB,MAAMC,QAAQ,GAAGjB,SAAS;IAEjCD,UAAU;QACR,IAAIO,MAAMS,UAAU;IACtB,GAAG;QAACT;KAAK;IAET,MAAMY,SAAS;QACb,MAAMC,UAAUL,OAAOM,IAAI;QAC3B,IAAI,CAACD,SAAS;QACd,IAAI,CAACR,OAAO;YACVE,gBAAgB,yDAAyD;gBACvEQ,SAAS;gBACTC,SAAS;YACX;YACA;QACF;QACAL,QAAQ;QACR,IAAI;YACF,MAAMM,SAAwE;gBAC5EC,QAAQ;gBACRC,mBAAmBN,QAAQO,KAAK,CAAC,GAAGvB;YACtC;YACA,MAAML,UAAUF,IAAIgB,WAAW,QAAQD,OAAO,SAASF,SAAS,aAC3Dc;gBACHI,WAAW9B;;YAEbgB,gBAAgB,2BAA2B;gBAAEQ,SAAS;gBAAWC,SAAS;YAAM;YAChFf;QACF,EAAE,OAAOqB,OAAO;YACdf,gBACEe,iBAAiBC,QAAQD,MAAME,OAAO,GAAG,kCACzC;gBAAET,SAAS;YAAQ;QAEvB,SAAU;YACRJ,QAAQ;QACV;IACF;IAEA,qBACE,MAAC3B;QAAOgB,MAAMA;QAAMC,SAASA;QAASwB,UAAS;QAAKC,SAAS;;0BAC3D,KAACtC;0BAAa,CAAC,UAAU,EAAEgB,UAAU,CAAC,CAAC;;0BACvC,MAAClB;;kCACC,KAACC;wBAAkBwC,IAAI;4BAAEC,IAAI;wBAAE;kCAC5B,qEACC;;kCAEJ,KAACvC;wBACCwC,OAAM;wBACNC,OAAOtB;wBACPuB,UAAU,CAACC,QAAUvB,UAAUuB,MAAMC,MAAM,CAACH,KAAK;wBACjDI,SAAS;wBACTC,SAAS;wBACTT,SAAS;wBACTU,SAAS;wBACTC,WAAW;4BAAEC,WAAW;gCAAEC,WAAW1C;4BAAW;wBAAE;;;;0BAGtD,MAACZ;;kCACC,KAACF;wBAAOyD,SAASvC;wBAASwC,UAAU/B;kCACjC;;kCAEH,KAAC3B;wBACCgC,SAAQ;wBACR2B,OAAM;wBACNF,SAAS,IAAM,KAAK5B;wBACpB6B,UAAU/B,QAAQ,CAACF,OAAOM,IAAI;kCAE7B;;;;;;AAKX;AACAhB,oBAAoB6C,WAAW,GAAG;AAElC,eAAe7C,oBAAmB"}
@@ -5,16 +5,16 @@ import type { ConsolePluginPageProps } from '@aglyn/aglyn';
5
5
  * A section of its own, the way Salesforce keeps Leads apart from Contacts:
6
6
  * a lead is a capture — a form, a booking, a sign-up — that somebody has
7
7
  * still to work, and it converts into a contact, a company and a deal when
8
- * it is real. Reads `hosts/{hostId}/leads`, host-scoped by path, so there is
9
- * no `visibleTo` filter; the Firestore rules admit any member of the site to
10
- * read it and an admin, editor or author to update it, which is what makes
11
- * the inline status and owner changes client-direct writes.
8
+ * it is real. Reads `orgs/{orgId}/leads` narrowed by `visibleTo` to the sites
9
+ * this viewer may see (AGL-3275) the same collection and the same clause at
10
+ * both levels, which is what lets ONE listener serve a section that used to
11
+ * open one per site.
12
12
  *
13
- * At the ORGANIZATION level (AGL-2630) there is no one site to read: the
14
- * section opens the same query under every site the org has (`useOrgLeads`)
15
- * and lists the merged window with a Site column, every row naming the site
16
- * its writes and its link go to. The per-site notes — which of a site's
17
- * forms file a lead — belong to a site's own hub and are not drawn here.
13
+ * Under a site the clause names that site; at the ORGANIZATION level an
14
+ * org-wide member reads without one, since the rules short-circuit on
15
+ * `isOrgWideMember()` and a clause would only narrow what they may already
16
+ * read. The per-site notes — which of a site's forms file a lead — belong to
17
+ * a site's own hub and are not drawn here.
18
18
  */
19
19
  export declare function CrmLeadsSection(props: ConsolePluginPageProps): import("react").JSX.Element;
20
20
  export declare namespace CrmLeadsSection {
@@ -25,6 +25,7 @@ import { useOrgMemberOptions } from "../hooks/use-org-member-options.js";
25
25
  import { useCrmOrgMount } from "../hooks/use-crm-org-mount.js";
26
26
  import { useCrmSavedView } from "../hooks/use-crm-saved-view.js";
27
27
  import { useCrmScope } from "../hooks/use-crm-scope.js";
28
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
28
29
  import { useContactFieldDefinitions } from "../hooks/use-contact-field-definitions.js";
29
30
  import { customFieldColumns } from "./contact-custom-columns.js";
30
31
  import { useCrmViewGrid } from "../hooks/use-crm-view-grid.js";
@@ -37,7 +38,7 @@ import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.com
37
38
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
38
39
  import { useFirestore, useFirestoreCollection, useHostCampaigns } from "@aglyn/tenant-feature-instance";
39
40
  import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, InputAdornment, InputLabel, MenuItem, Select, Stack, TextField, Typography } from "@mui/material";
40
- import { collection, deleteField, doc, limit, orderBy, query, serverTimestamp, updateDoc } from "firebase/firestore";
41
+ import { collection, deleteField, doc, limit, orderBy, query, serverTimestamp, updateDoc, where } from "firebase/firestore";
41
42
  import { useRouter } from "next/navigation";
42
43
  import { useCallback, useEffect, useId, useMemo, useState } from "react";
43
44
  import { downloadTextFile } from "../model/contacts-csv.js";
@@ -85,16 +86,16 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
85
86
  * A section of its own, the way Salesforce keeps Leads apart from Contacts:
86
87
  * a lead is a capture — a form, a booking, a sign-up — that somebody has
87
88
  * still to work, and it converts into a contact, a company and a deal when
88
- * it is real. Reads `hosts/{hostId}/leads`, host-scoped by path, so there is
89
- * no `visibleTo` filter; the Firestore rules admit any member of the site to
90
- * read it and an admin, editor or author to update it, which is what makes
91
- * the inline status and owner changes client-direct writes.
89
+ * it is real. Reads `orgs/{orgId}/leads` narrowed by `visibleTo` to the sites
90
+ * this viewer may see (AGL-3275) the same collection and the same clause at
91
+ * both levels, which is what lets ONE listener serve a section that used to
92
+ * open one per site.
92
93
  *
93
- * At the ORGANIZATION level (AGL-2630) there is no one site to read: the
94
- * section opens the same query under every site the org has (`useOrgLeads`)
95
- * and lists the merged window with a Site column, every row naming the site
96
- * its writes and its link go to. The per-site notes — which of a site's
97
- * forms file a lead — belong to a site's own hub and are not drawn here.
94
+ * Under a site the clause names that site; at the ORGANIZATION level an
95
+ * org-wide member reads without one, since the rules short-circuit on
96
+ * `isOrgWideMember()` and a clause would only narrow what they may already
97
+ * read. The per-site notes — which of a site's forms file a lead — belong to
98
+ * a site's own hub and are not drawn here.
98
99
  */ export function CrmLeadsSection(props) {
99
100
  var _ref, _converting_capturedByHostIds_, _ref1;
100
101
  const { hostId, org, basePath } = props;
@@ -110,10 +111,16 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
110
111
  // The org's lead fields, for the optional columns below (AGL-3272).
111
112
  const leadFields = useContactFieldDefinitions(orgId, 'lead');
112
113
  const routes = crmRoutes(basePath != null ? basePath : '');
113
- // Under a site: the site's own window, rows keyed by document id.
114
- const site = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'leads'), orderBy('lastSeenAtMs', 'desc'), limit(LEADS_WINDOW + 1)) : null, [
114
+ /*
115
+ * Under a site: the ORG collection, narrowed to what this site may see
116
+ * (AGL-3275). It read `hosts/{hostId}/leads` until the silo moved, and
117
+ * leaving it there would have shown a site its pre-migration rows and
118
+ * nothing captured since — then nothing at all, once AGL-3276 emptied the
119
+ * path it was reading.
120
+ */ const site = useFirestoreCollection(()=>hostId && orgId ? query(collection(firestore, 'orgs', orgId, 'leads'), where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)), orderBy('lastSeenAtMs', 'desc'), limit(LEADS_WINDOW + 1)) : null, [
115
121
  firestore,
116
- hostId
122
+ hostId,
123
+ orgId
117
124
  ], {
118
125
  idField: '$id'
119
126
  });
@@ -378,6 +385,13 @@ import OrgLeadSurfacesNote from "./org-lead-surfaces-note.js";
378
385
  enqueueSnackbar
379
386
  ]);
380
387
  const writeLead = useCallback(async (lead, fields, done)=>{
388
+ if (!orgId) {
389
+ enqueueSnackbar('Still loading this workspace — try again in a moment.', {
390
+ variant: 'warning',
391
+ persist: false
392
+ });
393
+ return;
394
+ }
381
395
  try {
382
396
  await updateDoc(doc(firestore, 'orgs', orgId, 'leads', lead.leadId), _extends({}, fields, {
383
397
  updatedAt: serverTimestamp()