@aglyn/plugins-crm 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/activity-link-fields.d.ts +34 -0
- package/src/lib/components/activity-link-fields.js +141 -0
- package/src/lib/components/activity-link-fields.js.map +1 -0
- package/src/lib/components/activity-list.d.ts +8 -0
- package/src/lib/components/activity-list.js +4 -2
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/activity-queries.d.ts +7 -2
- package/src/lib/components/activity-queries.js +7 -3
- package/src/lib/components/activity-queries.js.map +1 -1
- package/src/lib/components/company-contact-roles-card.d.ts +42 -0
- package/src/lib/components/company-contact-roles-card.js +135 -0
- package/src/lib/components/company-contact-roles-card.js.map +1 -0
- package/src/lib/components/company-detail-page.js +12 -1
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +9 -6
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +4 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +15 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +13 -14
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +5 -4
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-properties-card.js +41 -20
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/contact-roles-editor.d.ts +54 -0
- package/src/lib/components/contact-roles-editor.js +270 -0
- package/src/lib/components/contact-roles-editor.js.map +1 -0
- package/src/lib/components/contacts-section.js +6 -4
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-console-sections.js +1 -1
- package/src/lib/components/crm-console-sections.js.map +1 -1
- package/src/lib/components/crm-record-picker.d.ts +19 -0
- package/src/lib/components/crm-record-picker.js +40 -11
- package/src/lib/components/crm-record-picker.js.map +1 -1
- package/src/lib/components/crm-related-to-picker.d.ts +33 -0
- package/src/lib/components/crm-related-to-picker.js +135 -0
- package/src/lib/components/crm-related-to-picker.js.map +1 -0
- package/src/lib/components/crm-site-picker.d.ts +13 -1
- package/src/lib/components/crm-site-picker.js +15 -6
- package/src/lib/components/crm-site-picker.js.map +1 -1
- package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
- package/src/lib/components/crm-transfer-buttons.js +9 -6
- package/src/lib/components/crm-transfer-buttons.js.map +1 -1
- package/src/lib/components/deal-contact-roles-card.js +21 -239
- package/src/lib/components/deal-contact-roles-card.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +2 -1
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +11 -7
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.js +5 -4
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/log-activity-dialog.d.ts +27 -9
- package/src/lib/components/log-activity-dialog.js +96 -32
- package/src/lib/components/log-activity-dialog.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +7 -3
- package/src/lib/components/new-contact-drawer.js +27 -15
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/components/pipeline-stages-dialog.js +5 -1
- package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
- package/src/lib/components/record-activity-card.d.ts +11 -2
- package/src/lib/components/record-activity-card.js +90 -5
- package/src/lib/components/record-activity-card.js.map +1 -1
- package/src/lib/components/settings-section.d.ts +4 -2
- package/src/lib/components/settings-section.js +7 -5
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
- package/src/lib/hooks/crm-activity-client-reads.js +50 -0
- package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
- package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
- package/src/lib/hooks/use-campaign-filing-log.js +10 -3
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
- package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
- package/src/lib/hooks/use-crm-org-mount.js +2 -1
- package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +17 -5
- package/src/lib/hooks/use-crm-scope.js +12 -5
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +12 -0
- package/src/lib/model/contact-holder.js +20 -1
- package/src/lib/model/contact-holder.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +5 -0
- package/src/lib/model/contact-record.js +7 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-activity-link.d.ts +117 -0
- package/src/lib/model/crm-activity-link.js +113 -0
- package/src/lib/model/crm-activity-link.js.map +1 -0
- package/src/lib/model/crm-api.d.ts +4 -1
- package/src/lib/model/crm-api.js +9 -5
- package/src/lib/model/crm-api.js.map +1 -1
- package/src/lib/model/deal-board-model.js +5 -3
- package/src/lib/model/deal-board-model.js.map +1 -1
- package/src/lib/model/deal-contact-roles.d.ts +15 -1
- package/src/lib/model/deal-contact-roles.js +17 -3
- package/src/lib/model/deal-contact-roles.js.map +1 -1
- package/src/lib/model/settle-stage-names.d.ts +25 -0
- package/src/lib/model/settle-stage-names.js +29 -0
- package/src/lib/model/settle-stage-names.js.map +1 -0
- package/src/lib/native-nav.js +1 -1
- package/src/lib/native-nav.js.map +1 -1
- package/src/lib/server/add-contact-by-hand.d.ts +84 -0
- package/src/lib/server/add-contact-by-hand.js +212 -0
- package/src/lib/server/add-contact-by-hand.js.map +1 -0
- package/src/lib/server/api-v1/contacts.js +61 -23
- package/src/lib/server/api-v1/contacts.js.map +1 -1
- package/src/lib/server/api-v1/crm-activities.js +13 -3
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
- package/src/lib/server/api-v1/crm-pipelines.js +8 -2
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
- package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
- package/src/lib/server/api-v1/crm-shared.js +23 -11
- package/src/lib/server/api-v1/crm-shared.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +3 -3
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/automation-steps.js +11 -9
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -2
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/contact-update.d.ts +4 -0
- package/src/lib/server/contact-update.js +71 -10
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/convert-host-lead.js +13 -2
- package/src/lib/server/convert-host-lead.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +8 -1
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/crm-activity-company.d.ts +25 -0
- package/src/lib/server/crm-activity-company.js +34 -0
- package/src/lib/server/crm-activity-company.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +21 -15
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-sharing.js +9 -2
- package/src/lib/server/crm-sharing.js.map +1 -1
- package/src/lib/server/email-send.js +10 -8
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +6 -2
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/org-caller.d.ts +8 -0
- package/src/lib/server/org-caller.js +10 -0
- package/src/lib/server/org-caller.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +3 -1
- package/src/lib/server/record-timeline.js +11 -3
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +152 -43
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/contacts.js +83 -48
- package/src/lib/transfer/contacts.js.map +1 -1
- package/src/lib/transfer/fields.js +1 -2
- package/src/lib/transfer/fields.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-crm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.
|
|
31
|
-
"@aglyn/shared-ui-jsx": "1.
|
|
32
|
-
"@aglyn/shared-ui-next": "1.
|
|
33
|
-
"@aglyn/shared-ui-snackstack": "1.
|
|
34
|
-
"@aglyn/shared-util-email": "1.
|
|
35
|
-
"@aglyn/shared-util-http": "1.
|
|
36
|
-
"@aglyn/shared-util-timestamp": "1.
|
|
37
|
-
"@aglyn/shared-util-tools": "1.
|
|
38
|
-
"@aglyn/tenant-data-admin": "1.
|
|
39
|
-
"@aglyn/tenant-feature-instance": "1.
|
|
40
|
-
"@aglyn/tenant-runtime": "1.
|
|
28
|
+
"@aglyn/aglyn": "1.1.0",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.1.0",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.1.0",
|
|
31
|
+
"@aglyn/shared-ui-jsx": "1.1.0",
|
|
32
|
+
"@aglyn/shared-ui-next": "1.1.0",
|
|
33
|
+
"@aglyn/shared-ui-snackstack": "1.1.0",
|
|
34
|
+
"@aglyn/shared-util-email": "1.1.0",
|
|
35
|
+
"@aglyn/shared-util-http": "1.1.0",
|
|
36
|
+
"@aglyn/shared-util-timestamp": "1.1.0",
|
|
37
|
+
"@aglyn/shared-util-tools": "1.1.0",
|
|
38
|
+
"@aglyn/tenant-data-admin": "1.1.0",
|
|
39
|
+
"@aglyn/tenant-feature-instance": "1.1.0",
|
|
40
|
+
"@aglyn/tenant-runtime": "1.1.0",
|
|
41
41
|
"@dnd-kit/core": "^6.3.1",
|
|
42
42
|
"@dnd-kit/utilities": "^3.2.2",
|
|
43
43
|
"@swc/helpers": "0.5.23",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CrmActivityPicks } from '../model/crm-activity-link';
|
|
2
|
+
import type { ActivityRecordLink, ActivityScope } from './activity-queries';
|
|
3
|
+
export interface ActivityLinkFieldsProps {
|
|
4
|
+
/** Where the fields read, from `useActivityScope`. */
|
|
5
|
+
scope: ActivityScope;
|
|
6
|
+
picks: CrmActivityPicks;
|
|
7
|
+
onChange: (picks: CrmActivityPicks) => void;
|
|
8
|
+
/** The record the dialog was opened on. */
|
|
9
|
+
link: ActivityRecordLink;
|
|
10
|
+
/** The deal page's own document, whose contact roles lead **Name**. */
|
|
11
|
+
deal?: {
|
|
12
|
+
contactId?: unknown;
|
|
13
|
+
contactRoles?: unknown;
|
|
14
|
+
} | null;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* **Name** and **Related To** on the log dialog (AGL-4123): who the entry is
|
|
19
|
+
* with, and the company or deal it is about. Salesforce's Who and What.
|
|
20
|
+
*
|
|
21
|
+
* A component of its own so that its reads are the open dialog's: MUI mounts
|
|
22
|
+
* a closed dialog's content only once it opens, and the dialog itself stays
|
|
23
|
+
* mounted on every record page.
|
|
24
|
+
*
|
|
25
|
+
* On a deal's page every contact role leads **Name**, whichever part they
|
|
26
|
+
* play and however long since they were updated; the list below still offers
|
|
27
|
+
* anybody else this reader may see, so a call with a colleague at the same
|
|
28
|
+
* company can be logged without first adding them to the deal.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ActivityLinkFields(props: ActivityLinkFieldsProps): import("react").JSX.Element;
|
|
31
|
+
export declare namespace ActivityLinkFields {
|
|
32
|
+
var displayName: string;
|
|
33
|
+
}
|
|
34
|
+
export default ActivityLinkFields;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
19
|
+
import { dealContactRolesOf } from "@aglyn/aglyn";
|
|
20
|
+
import { Box, FormHelperText, Stack } from "@mui/material";
|
|
21
|
+
import { useMemo } from "react";
|
|
22
|
+
import { useCrmRecordNames } from "../hooks/use-crm-record-names.js";
|
|
23
|
+
import { CrmRecordPicker } from "./crm-record-picker.js";
|
|
24
|
+
import { CrmRelatedToPicker } from "./crm-related-to-picker.js";
|
|
25
|
+
/**
|
|
26
|
+
* **Name** and **Related To** on the log dialog (AGL-4123): who the entry is
|
|
27
|
+
* with, and the company or deal it is about. Salesforce's Who and What.
|
|
28
|
+
*
|
|
29
|
+
* A component of its own so that its reads are the open dialog's: MUI mounts
|
|
30
|
+
* a closed dialog's content only once it opens, and the dialog itself stays
|
|
31
|
+
* mounted on every record page.
|
|
32
|
+
*
|
|
33
|
+
* On a deal's page every contact role leads **Name**, whichever part they
|
|
34
|
+
* play and however long since they were updated; the list below still offers
|
|
35
|
+
* anybody else this reader may see, so a call with a colleague at the same
|
|
36
|
+
* company can be logged without first adding them to the deal.
|
|
37
|
+
*/ export function ActivityLinkFields(props) {
|
|
38
|
+
var _ref, _names, _link_companyId;
|
|
39
|
+
const { scope, picks, onChange, link, deal, disabled } = props;
|
|
40
|
+
const { dataScope, orgId, readTokens, consentGroup, org } = scope;
|
|
41
|
+
const groupId = (_ref = consentGroup == null ? void 0 : consentGroup.groupId) != null ? _ref : null;
|
|
42
|
+
const { contactId, related } = picks;
|
|
43
|
+
const roleIds = useMemo(()=>deal ? dealContactRolesOf(deal).map((row)=>row.contactId) : [], [
|
|
44
|
+
deal
|
|
45
|
+
]);
|
|
46
|
+
const names = useCrmRecordNames({
|
|
47
|
+
orgId,
|
|
48
|
+
groupId,
|
|
49
|
+
org,
|
|
50
|
+
records: [
|
|
51
|
+
...roleIds.map((id)=>({
|
|
52
|
+
kind: 'contact',
|
|
53
|
+
id
|
|
54
|
+
})),
|
|
55
|
+
...contactId ? [
|
|
56
|
+
{
|
|
57
|
+
kind: 'contact',
|
|
58
|
+
id: contactId
|
|
59
|
+
}
|
|
60
|
+
] : [],
|
|
61
|
+
...related ? [
|
|
62
|
+
related
|
|
63
|
+
] : []
|
|
64
|
+
]
|
|
65
|
+
});
|
|
66
|
+
const pinned = roleIds.flatMap((id)=>{
|
|
67
|
+
const label = names('contact', id);
|
|
68
|
+
return label ? [
|
|
69
|
+
{
|
|
70
|
+
id,
|
|
71
|
+
label
|
|
72
|
+
}
|
|
73
|
+
] : [];
|
|
74
|
+
});
|
|
75
|
+
return /*#__PURE__*/ _jsxs(Stack, {
|
|
76
|
+
spacing: 0.5,
|
|
77
|
+
children: [
|
|
78
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
79
|
+
direction: {
|
|
80
|
+
xs: 'column',
|
|
81
|
+
sm: 'row'
|
|
82
|
+
},
|
|
83
|
+
spacing: 1,
|
|
84
|
+
children: [
|
|
85
|
+
/*#__PURE__*/ _jsx(Box, {
|
|
86
|
+
sx: {
|
|
87
|
+
flex: 1,
|
|
88
|
+
minWidth: 0
|
|
89
|
+
},
|
|
90
|
+
children: /*#__PURE__*/ _jsx(CrmRecordPicker, {
|
|
91
|
+
kind: "contact",
|
|
92
|
+
label: "Name",
|
|
93
|
+
scope: dataScope,
|
|
94
|
+
readTokens: readTokens,
|
|
95
|
+
groupId: groupId,
|
|
96
|
+
org: org,
|
|
97
|
+
value: contactId,
|
|
98
|
+
// The kind until the name lands, and for one nobody can name: an id
|
|
99
|
+
// means nothing to anybody.
|
|
100
|
+
valueLabel: contactId ? (_names = names('contact', contactId)) != null ? _names : 'Contact' : undefined,
|
|
101
|
+
onChange: (id)=>onChange(_extends({}, picks, {
|
|
102
|
+
contactId: id
|
|
103
|
+
})),
|
|
104
|
+
pinned: pinned,
|
|
105
|
+
pinnedLabel: "On this deal",
|
|
106
|
+
disabled: disabled,
|
|
107
|
+
lazy: true
|
|
108
|
+
})
|
|
109
|
+
}),
|
|
110
|
+
/*#__PURE__*/ _jsx(Box, {
|
|
111
|
+
sx: {
|
|
112
|
+
flex: 1,
|
|
113
|
+
minWidth: 0
|
|
114
|
+
},
|
|
115
|
+
children: /*#__PURE__*/ _jsx(CrmRelatedToPicker, {
|
|
116
|
+
scope: dataScope,
|
|
117
|
+
orgId: orgId,
|
|
118
|
+
readTokens: readTokens,
|
|
119
|
+
contactId: contactId,
|
|
120
|
+
companyId: (related == null ? void 0 : related.kind) === 'company' ? related.id : (_link_companyId = link.companyId) != null ? _link_companyId : null,
|
|
121
|
+
value: related,
|
|
122
|
+
valueLabel: related ? names(related.kind, related.id) : undefined,
|
|
123
|
+
onChange: (next)=>onChange(_extends({}, picks, {
|
|
124
|
+
related: next
|
|
125
|
+
})),
|
|
126
|
+
disabled: disabled
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
]
|
|
130
|
+
}),
|
|
131
|
+
contactId || related ? null : /*#__PURE__*/ _jsx(FormHelperText, {
|
|
132
|
+
error: true,
|
|
133
|
+
children: 'Name a contact, or what this is related to.'
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
ActivityLinkFields.displayName = 'ActivityLinkFields';
|
|
139
|
+
export default ActivityLinkFields;
|
|
140
|
+
|
|
141
|
+
//# sourceMappingURL=activity-link-fields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-link-fields.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 { dealContactRolesOf } from '@aglyn/aglyn'\nimport { Box, FormHelperText, Stack } from '@mui/material'\nimport { useMemo } from 'react'\nimport { useCrmRecordNames } from '../hooks/use-crm-record-names'\nimport type { CrmActivityPicks } from '../model/crm-activity-link'\nimport type { ActivityRecordLink, ActivityScope } from './activity-queries'\nimport { CrmRecordPicker } from './crm-record-picker'\nimport { CrmRelatedToPicker } from './crm-related-to-picker'\n\nexport interface ActivityLinkFieldsProps {\n /** Where the fields read, from `useActivityScope`. */\n scope: ActivityScope\n picks: CrmActivityPicks\n onChange: (picks: CrmActivityPicks) => void\n /** The record the dialog was opened on. */\n link: ActivityRecordLink\n /** The deal page's own document, whose contact roles lead **Name**. */\n deal?: { contactId?: unknown; contactRoles?: unknown } | null\n disabled?: boolean\n}\n\n/**\n * **Name** and **Related To** on the log dialog (AGL-4123): who the entry is\n * with, and the company or deal it is about. Salesforce's Who and What.\n *\n * A component of its own so that its reads are the open dialog's: MUI mounts\n * a closed dialog's content only once it opens, and the dialog itself stays\n * mounted on every record page.\n *\n * On a deal's page every contact role leads **Name**, whichever part they\n * play and however long since they were updated; the list below still offers\n * anybody else this reader may see, so a call with a colleague at the same\n * company can be logged without first adding them to the deal.\n */\nexport function ActivityLinkFields(props: ActivityLinkFieldsProps) {\n const { scope, picks, onChange, link, deal, disabled } = props\n const { dataScope, orgId, readTokens, consentGroup, org } = scope\n const groupId = consentGroup?.groupId ?? null\n const { contactId, related } = picks\n const roleIds = useMemo(\n () => (deal ? dealContactRolesOf(deal).map((row) => row.contactId) : []),\n [deal],\n )\n const names = useCrmRecordNames({\n orgId,\n groupId,\n org,\n records: [\n ...roleIds.map((id) => ({ kind: 'contact' as const, id })),\n ...(contactId ? [{ kind: 'contact' as const, id: contactId }] : []),\n ...(related ? [related] : []),\n ],\n })\n const pinned = roleIds.flatMap((id) => {\n const label = names('contact', id)\n return label ? [{ id, label }] : []\n })\n return (\n <Stack spacing={0.5}>\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>\n <Box sx={{ flex: 1, minWidth: 0 }}>\n <CrmRecordPicker\n kind=\"contact\"\n label=\"Name\"\n scope={dataScope}\n readTokens={readTokens}\n groupId={groupId}\n org={org}\n value={contactId}\n // The kind until the name lands, and for one nobody can name: an id\n // means nothing to anybody.\n valueLabel={contactId ? (names('contact', contactId) ?? 'Contact') : undefined}\n onChange={(id) => onChange({ ...picks, contactId: id })}\n pinned={pinned}\n pinnedLabel=\"On this deal\"\n disabled={disabled}\n lazy\n />\n </Box>\n <Box sx={{ flex: 1, minWidth: 0 }}>\n <CrmRelatedToPicker\n scope={dataScope}\n orgId={orgId}\n readTokens={readTokens}\n contactId={contactId}\n companyId={related?.kind === 'company' ? related.id : (link.companyId ?? null)}\n value={related}\n valueLabel={related ? names(related.kind, related.id) : undefined}\n onChange={(next) => onChange({ ...picks, related: next })}\n disabled={disabled}\n />\n </Box>\n </Stack>\n {contactId || related ? null : (\n <FormHelperText error>{'Name a contact, or what this is related to.'}</FormHelperText>\n )}\n </Stack>\n )\n}\nActivityLinkFields.displayName = 'ActivityLinkFields'\n\nexport default ActivityLinkFields\n"],"names":["dealContactRolesOf","Box","FormHelperText","Stack","useMemo","useCrmRecordNames","CrmRecordPicker","CrmRelatedToPicker","ActivityLinkFields","props","names","link","scope","picks","onChange","deal","disabled","dataScope","orgId","readTokens","consentGroup","org","groupId","contactId","related","roleIds","map","row","records","id","kind","pinned","flatMap","label","spacing","direction","xs","sm","sx","flex","minWidth","value","valueLabel","undefined","pinnedLabel","lazy","companyId","next","error","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,kBAAkB,QAAQ,eAAc;AACjD,SAASC,GAAG,EAAEC,cAAc,EAAEC,KAAK,QAAQ,gBAAe;AAC1D,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,iBAAiB,QAAQ,mCAA+B;AAGjE,SAASC,eAAe,QAAQ,yBAAqB;AACrD,SAASC,kBAAkB,QAAQ,6BAAyB;AAc5D;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,mBAAmBC,KAA8B;cAqC5BC,QAc8BC;IAlDjE,MAAM,EAAEC,KAAK,EAAEC,KAAK,EAAEC,QAAQ,EAAEH,IAAI,EAAEI,IAAI,EAAEC,QAAQ,EAAE,GAAGP;IACzD,MAAM,EAAEQ,SAAS,EAAEC,KAAK,EAAEC,UAAU,EAAEC,YAAY,EAAEC,GAAG,EAAE,GAAGT;IAC5D,MAAMU,kBAAUF,gCAAAA,aAAcE,OAAO,mBAAI;IACzC,MAAM,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGX;IAC/B,MAAMY,UAAUrB,QACd,IAAOW,OAAOf,mBAAmBe,MAAMW,GAAG,CAAC,CAACC,MAAQA,IAAIJ,SAAS,IAAI,EAAE,EACvE;QAACR;KAAK;IAER,MAAML,QAAQL,kBAAkB;QAC9Ba;QACAI;QACAD;QACAO,SAAS;eACJH,QAAQC,GAAG,CAAC,CAACG,KAAQ,CAAA;oBAAEC,MAAM;oBAAoBD;gBAAG,CAAA;eACnDN,YAAY;gBAAC;oBAAEO,MAAM;oBAAoBD,IAAIN;gBAAU;aAAE,GAAG,EAAE;eAC9DC,UAAU;gBAACA;aAAQ,GAAG,EAAE;SAC7B;IACH;IACA,MAAMO,SAASN,QAAQO,OAAO,CAAC,CAACH;QAC9B,MAAMI,QAAQvB,MAAM,WAAWmB;QAC/B,OAAOI,QAAQ;YAAC;gBAAEJ;gBAAII;YAAM;SAAE,GAAG,EAAE;IACrC;IACA,qBACE,MAAC9B;QAAM+B,SAAS;;0BACd,MAAC/B;gBAAMgC,WAAW;oBAAEC,IAAI;oBAAUC,IAAI;gBAAM;gBAAGH,SAAS;;kCACtD,KAACjC;wBAAIqC,IAAI;4BAAEC,MAAM;4BAAGC,UAAU;wBAAE;kCAC9B,cAAA,KAAClC;4BACCwB,MAAK;4BACLG,OAAM;4BACNrB,OAAOK;4BACPE,YAAYA;4BACZG,SAASA;4BACTD,KAAKA;4BACLoB,OAAOlB;4BACP,oEAAoE;4BACpE,4BAA4B;4BAC5BmB,YAAYnB,aAAab,SAAAA,MAAM,WAAWa,sBAAjBb,SAA+B,YAAaiC;4BACrE7B,UAAU,CAACe,KAAOf,SAAS,aAAKD;oCAAOU,WAAWM;;4BAClDE,QAAQA;4BACRa,aAAY;4BACZ5B,UAAUA;4BACV6B,IAAI;;;kCAGR,KAAC5C;wBAAIqC,IAAI;4BAAEC,MAAM;4BAAGC,UAAU;wBAAE;kCAC9B,cAAA,KAACjC;4BACCK,OAAOK;4BACPC,OAAOA;4BACPC,YAAYA;4BACZI,WAAWA;4BACXuB,WAAWtB,CAAAA,2BAAAA,QAASM,IAAI,MAAK,YAAYN,QAAQK,EAAE,IAAIlB,kBAAAA,KAAKmC,SAAS,YAAdnC,kBAAkB;4BACzE8B,OAAOjB;4BACPkB,YAAYlB,UAAUd,MAAMc,QAAQM,IAAI,EAAEN,QAAQK,EAAE,IAAIc;4BACxD7B,UAAU,CAACiC,OAASjC,SAAS,aAAKD;oCAAOW,SAASuB;;4BAClD/B,UAAUA;;;;;YAIfO,aAAaC,UAAU,qBACtB,KAACtB;gBAAe8C,KAAK;0BAAE;;;;AAI/B;AACAxC,mBAAmByC,WAAW,GAAG;AAEjC,eAAezC,mBAAkB"}
|
|
@@ -43,6 +43,12 @@ export interface ActivityRowProps {
|
|
|
43
43
|
* activity is about here, a record page needs nothing.
|
|
44
44
|
*/
|
|
45
45
|
subject?: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Who or what this entry is about, on a timeline it rolled up to. Unlike
|
|
48
|
+
* {@link subject} it stands on every row: a rolled-up email is as much in
|
|
49
|
+
* need of saying whose it is as a rolled-up call.
|
|
50
|
+
*/
|
|
51
|
+
about?: ReactNode;
|
|
46
52
|
/** The clock the relative time is read against; `Date.now()` when absent. */
|
|
47
53
|
nowMs?: number;
|
|
48
54
|
/**
|
|
@@ -95,6 +101,8 @@ export interface ActivityListProps {
|
|
|
95
101
|
loading?: boolean;
|
|
96
102
|
/** The record each row is about, for a list that spans records. */
|
|
97
103
|
subjectFor?: (activity: CrmActivityRow) => ReactNode;
|
|
104
|
+
/** Who or what each row is about, for a timeline rows roll up to. */
|
|
105
|
+
aboutFor?: (activity: CrmActivityRow) => ReactNode;
|
|
98
106
|
/**
|
|
99
107
|
* No controls on any row, whoever is reading — and no member read to
|
|
100
108
|
* decide them. A feed that spans records is a place to see what happened,
|
|
@@ -124,7 +124,7 @@ ActivityKindIcon.displayName = 'ActivityKindIcon';
|
|
|
124
124
|
* not?", and the honest answer would be "you could, through the API".
|
|
125
125
|
*/ export function ActivityRow(props) {
|
|
126
126
|
var _props_expanded, _props_onToggle;
|
|
127
|
-
const { activity, scope, onEdit, subject, nowMs, editable } = props;
|
|
127
|
+
const { activity, scope, onEdit, subject, about, nowMs, editable } = props;
|
|
128
128
|
const [ownExpanded, setOwnExpanded] = useState(false);
|
|
129
129
|
const expanded = (_props_expanded = props.expanded) != null ? _props_expanded : ownExpanded;
|
|
130
130
|
const onToggle = (_props_onToggle = props.onToggle) != null ? _props_onToggle : ()=>setOwnExpanded((open)=>!open);
|
|
@@ -234,6 +234,7 @@ ActivityKindIcon.displayName = 'ActivityKindIcon';
|
|
|
234
234
|
})
|
|
235
235
|
}) : null,
|
|
236
236
|
captured ? null : subject,
|
|
237
|
+
about,
|
|
237
238
|
detail.length ? /*#__PURE__*/ _jsx(Typography, {
|
|
238
239
|
variant: "caption",
|
|
239
240
|
color: "text.secondary",
|
|
@@ -309,7 +310,7 @@ ActivityRow.displayName = 'ActivityRow';
|
|
|
309
310
|
* for it a single time however many rows it draws.
|
|
310
311
|
*/ export function ActivityList(props) {
|
|
311
312
|
var _props_expansion;
|
|
312
|
-
const { rows, scope, onEdit, emptyText = 'Nothing logged yet.', emptyAction, hasMore, onShowMore, loading, subjectFor, readOnly } = props;
|
|
313
|
+
const { rows, scope, onEdit, emptyText = 'Nothing logged yet.', emptyAction, hasMore, onShowMore, loading, subjectFor, aboutFor, readOnly } = props;
|
|
313
314
|
const canEdit = useCanEditActivity(scope.orgId, !readOnly);
|
|
314
315
|
const ownExpansion = useTimelineExpansion();
|
|
315
316
|
const expansion = (_props_expansion = props.expansion) != null ? _props_expansion : ownExpansion;
|
|
@@ -340,6 +341,7 @@ ActivityRow.displayName = 'ActivityRow';
|
|
|
340
341
|
scope: scope,
|
|
341
342
|
onEdit: onEdit,
|
|
342
343
|
subject: subjectFor == null ? void 0 : subjectFor(activity),
|
|
344
|
+
about: aboutFor == null ? void 0 : aboutFor(activity),
|
|
343
345
|
nowMs: nowMs,
|
|
344
346
|
editable: canEdit(activity),
|
|
345
347
|
expanded: expansion.isExpanded(activity.$id),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-list.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 { CrmActivityKind, CrmActivityRow } from '@aglyn/aglyn'\nimport {\n mdiAccountGroupOutline,\n mdiDeleteOutline,\n mdiDotsHorizontalCircleOutline,\n mdiEmailOutline,\n mdiEmailReceiveOutline,\n mdiFacebookMessenger,\n mdiInstagram,\n mdiLinkedin,\n mdiMessageTextOutline,\n mdiNoteTextOutline,\n mdiPencilOutline,\n mdiPhoneOutline,\n mdiWhatsapp,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport { Chip, IconButton, Stack, Tooltip, Typography } from '@mui/material'\nimport { deleteDoc, doc } from 'firebase/firestore'\nimport { type ReactNode, useCallback, useState } from 'react'\nimport { type ActivityScope, useCanEditActivity } from './activity-queries'\nimport {\n TimelineEntry,\n type TimelineExpansion,\n useTimelineExpansion,\n useTimelinePages,\n} from './activity-timeline'\n\n/**\n * One glyph per kind. Typed against the union so a kind added to the list\n * cannot render with no icon — `MdiIcon` would fall back to its default\n * glyph silently, and a log where every note looks like a placeholder is\n * worse than a build error.\n */\nconst KIND_ICONS: Record<CrmActivityKind, { path: string }> = {\n call: mdiPhoneOutline,\n email: mdiEmailOutline,\n text: mdiMessageTextOutline,\n whatsapp: mdiWhatsapp,\n messenger: mdiFacebookMessenger,\n instagram: mdiInstagram,\n linkedin: mdiLinkedin,\n meeting: mdiAccountGroupOutline,\n note: mdiNoteTextOutline,\n other: mdiDotsHorizontalCircleOutline,\n}\n\n/** The icon for a kind — a stored kind outside the union draws as `other`. */\nexport function ActivityKindIcon(props: { kind: string; size?: number }) {\n const { kind, size = 1 } = props\n const icon = Aglyn.isCrmActivityKind(kind) ? KIND_ICONS[kind] : KIND_ICONS.other\n return <MdiIcon path={icon.path} size={size} />\n}\nActivityKindIcon.displayName = 'ActivityKindIcon'\n\n/**\n * The author's name for a row (AGL-2600).\n *\n * The denormalized `byName` leads: it is the only name a scoped colleague\n * can read, since a member document is readable by its subject and by\n * org-wide members alone. The signed-in user's OWN rows fall back to their\n * live name — the one case where a lookup is possible — and a row that\n * carries neither (written by a door that did not stamp one) reads as a\n * team member rather than as a uid.\n */\nexport function useActivityAuthorName(): (\n activity: Pick<CrmActivityRow, 'byUid' | 'byName'>,\n) => string {\n const { data: user } = useUser()\n const ownName = useUserName()\n const uid = user?.uid\n return useCallback(\n (activity) => {\n if (activity.byName) return activity.byName\n if (uid && activity.byUid === uid && ownName) return ownName\n return 'A team member'\n },\n [uid, ownName],\n )\n}\n\n/**\n * How long a note's own words may run on a collapsed row before it opens to\n * the rest; a single line under this reads whole, with nothing to open.\n */\nconst ACTIVITY_HEADING_MAX = 80\n\n/**\n * What a collapsed activity reads as, and whether opening it shows more.\n *\n * A subject is the heading and the body is what the row opens to. With no\n * subject the body's first line is the heading, and a body that runs past\n * that line, or past what one line holds, opens to the whole text.\n */\nexport function activityHeading(activity: Pick<CrmActivityRow, 'subject' | 'body'>): {\n heading: string\n opens: boolean\n} {\n const body = typeof activity.body === 'string' ? activity.body.trim() : ''\n const subject = typeof activity.subject === 'string' ? activity.subject.trim() : ''\n if (subject) return { heading: subject, opens: body !== '' }\n const firstLine = body.split('\\n').find((line) => line.trim() !== '')?.trim() ?? ''\n return {\n heading: firstLine,\n opens: body !== firstLine || firstLine.length > ACTIVITY_HEADING_MAX,\n }\n}\n\n/** \"45 min\" or \"2 h 05 min\" — a duration a manager reads at a glance. */\nfunction durationLabel(minutes: number): string {\n if (minutes < 60) return `${minutes} min`\n const hours = Math.floor(minutes / 60)\n const rest = minutes % 60\n return rest ? `${hours} h ${String(rest).padStart(2, '0')} min` : `${hours} h`\n}\n\nexport interface ActivityRowProps {\n activity: CrmActivityRow\n /** Where the delete is written, from `useActivityScope`. */\n scope: ActivityScope\n /** Opens the dialog on this activity. Absent, the row offers no edit. */\n onEdit?: (activity: CrmActivityRow) => void\n /**\n * What sits after the kind, before the body — a feed puts the record the\n * activity is about here, a record page needs nothing.\n */\n subject?: ReactNode\n /** The clock the relative time is read against; `Date.now()` when absent. */\n nowMs?: number\n /**\n * Whether THIS reader may edit and delete the row — the author, or an\n * org-wide member. Decided by whoever draws the rows, through\n * `useCanEditActivity`, once for the whole list: a row that asked for\n * itself would read the member document once per row.\n */\n editable?: boolean\n /**\n * Whether the row is open to its body. The list holds it so the card's\n * \"Expand all\" reaches every row; a row drawn alone holds its own.\n */\n expanded?: boolean\n onToggle?: () => void\n}\n\n/**\n * One logged activity: the kind's icon and label, what was said, how it\n * went, who logged it and how long ago — with edit and delete for whoever\n * may (AGL-2600).\n *\n * Collapsed, the row is its chips, its heading and its caption: the kind,\n * the direction and delivery state, how a call went, the subject or the\n * note's first line, and who, to or from whom, and when. Opened, it shows\n * the body — an email's whole text, a note's every line.\n *\n * Edit and delete appear only when `editable` says this reader may — the\n * author or an org-wide member. That is the console's verdict and not the\n * rules' — see `useCanEditActivity` for why the rules admit more — so the\n * controls are hidden rather than disabled: a disabled button asks \"why\n * not?\", and the honest answer would be \"you could, through the API\".\n */\nexport function ActivityRow(props: ActivityRowProps) {\n const { activity, scope, onEdit, subject, nowMs, editable } = props\n const [ownExpanded, setOwnExpanded] = useState(false)\n const expanded = props.expanded ?? ownExpanded\n const onToggle = props.onToggle ?? (() => setOwnExpanded((open) => !open))\n const { firestore, dataScope } = scope\n const authorName = useActivityAuthorName()\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n\n const handleDelete = useCallback(async () => {\n if (!dataScope) return\n const confirmed = await confirm({\n title: 'Delete this activity?',\n description:\n `This ${Aglyn.CRM_ACTIVITY_KIND_LABELS[\n Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other'\n ].toLowerCase()} is removed from the record it was logged against. ` +\n 'Nothing the platform captured — a form, an order, a booking — is ' +\n 'affected.',\n confirmationText: 'Delete activity',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n await deleteDoc(\n doc(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n activity.$id,\n ),\n )\n enqueueSnackbar('Activity deleted', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [dataScope, confirm, activity, firestore, enqueueSnackbar])\n\n const label =\n Aglyn.CRM_ACTIVITY_KIND_LABELS[\n Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other'\n ]\n /*\n * A message the platform SENT (AGL-2615) carries a subject, an address\n * and a delivery state the webhook advances; the state is the chip beside\n * the kind, red once the message did not land. What was sent is a record\n * of a fact and offers no edit — a rewritten body would misstate what\n * left — though whoever may delete a row may still delete this one.\n */\n const captured = typeof activity.messageId === 'string' && activity.messageId !== ''\n const deliveryState = Aglyn.isCrmEmailDeliveryState(activity.deliveryState)\n ? activity.deliveryState\n : null\n // A hand-logged email or call carries a direction too (AGL-3517); only a\n // message the platform carried or captured is \"sent\" or \"received\" here.\n const sent = activity.direction === 'outbound' && (deliveryState !== null || captured)\n /*\n * A message CAPTURED from a mailbox (AGL-2657) carries the provider's\n * Message-ID and, when a correspondent wrote it, the `inbound` direction.\n * It is drawn as its own thing — \"Received\" beside the kind, the sender\n * where a sent row shows the recipient, no author because nobody on the\n * team logged it — and a member's copied send reads \"Sent\" without a\n * delivery chip, since the platform did not carry it. Neither takes the\n * caller's \"Logged\" chip: nobody logged them.\n */\n const received = activity.direction === 'inbound' && captured\n /*\n * A hand-logged call or email names its direction in the kind's own chip\n * — \"Inbound call\", \"Outbound email\" (AGL-3517); a sent or captured\n * message says it with the chips below instead.\n */\n const kindChip =\n sent || received || captured\n ? label\n : Aglyn.crmActivityKindTitle(\n Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other',\n activity.direction,\n )\n const when = new Date(activity.atMs)\n const detail = [\n activity.outcome ? activity.outcome : null,\n typeof activity.durationMinutes === 'number' && activity.durationMinutes > 0\n ? durationLabel(activity.durationMinutes)\n : null,\n ].filter(Boolean)\n\n const { heading, opens } = activityHeading(activity)\n const hasSubject = typeof activity.subject === 'string' && activity.subject.trim() !== ''\n\n return (\n <TimelineEntry\n icon={\n received ? (\n <MdiIcon path={mdiEmailReceiveOutline.path} />\n ) : (\n <ActivityKindIcon kind={activity.kind} />\n )\n }\n label={heading || label.toLowerCase()}\n expanded={expanded}\n onToggle={onToggle}\n chips={\n <>\n <Chip label={kindChip} size=\"small\" />\n {received ? (\n <Chip\n label=\"Received\"\n size=\"small\"\n variant=\"outlined\"\n color=\"info\"\n data-testid=\"activity-direction\"\n />\n ) : captured ? (\n <Chip\n label=\"Sent\"\n size=\"small\"\n variant=\"outlined\"\n data-testid=\"activity-direction\"\n />\n ) : null}\n {deliveryState ? (\n <Tooltip\n title={\n typeof activity.deliveryAtMs === 'number'\n ? new Date(activity.deliveryAtMs).toLocaleString()\n : ''\n }\n >\n <Chip\n label={Aglyn.CRM_EMAIL_DELIVERY_STATE_LABELS[deliveryState]}\n size=\"small\"\n variant=\"outlined\"\n color={Aglyn.isCrmEmailDeliveryFailure(deliveryState) ? 'error' : 'default'}\n data-testid=\"activity-delivery-state\"\n />\n </Tooltip>\n ) : null}\n {captured ? null : subject}\n {detail.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {detail.join(' · ')}\n </Typography>\n ) : null}\n </>\n }\n // With no subject the heading IS the body's first line, so an open\n // row shows the body alone rather than its first line twice.\n title={hasSubject || !opens || !expanded ? heading : null}\n titleVariant={hasSubject ? 'subtitle2' : 'body2'}\n meta={\n <Tooltip title={when.toLocaleString()}>\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ alignSelf: 'flex-start' }}\n >\n {[\n received ? null : authorName(activity),\n received && activity.from ? `from ${activity.from}` : null,\n sent && activity.to ? `to ${activity.to}` : null,\n Aglyn.activityTimeLabel(activity.atMs, nowMs ?? Date.now()),\n ]\n .filter(Boolean)\n .join(' · ')}\n </Typography>\n </Tooltip>\n }\n body={\n opens ? (\n <Typography variant=\"body2\" sx={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>\n {activity.body}\n </Typography>\n ) : undefined\n }\n actions={\n editable ? (\n <>\n {onEdit && !sent && !received ? (\n <IconButton\n size=\"small\"\n aria-label=\"Edit activity\"\n onClick={() => onEdit(activity)}\n >\n <MdiIcon path={mdiPencilOutline.path} size={0.8} />\n </IconButton>\n ) : null}\n <IconButton\n size=\"small\"\n aria-label=\"Delete activity\"\n onClick={handleDelete}\n >\n <MdiIcon path={mdiDeleteOutline.path} size={0.8} />\n </IconButton>\n </>\n ) : null\n }\n />\n )\n}\nActivityRow.displayName = 'ActivityRow'\n\nexport interface ActivityListProps {\n /** Newest-first, as the listener hands them back. */\n rows: readonly CrmActivityRow[]\n scope: ActivityScope\n onEdit?: (activity: CrmActivityRow) => void\n /** What the list says when there is nothing in it. */\n emptyText?: string\n /** The way out of an empty list — the record's \"Log activity\" button. */\n emptyAction?: ReactNode\n /** The listener holds less than exists; `onShowMore` widens it. */\n hasMore?: boolean\n onShowMore?: () => void\n /** The listener is reading, so an empty window is not yet an empty log. */\n loading?: boolean\n /** The record each row is about, for a list that spans records. */\n subjectFor?: (activity: CrmActivityRow) => ReactNode\n /**\n * No controls on any row, whoever is reading — and no member read to\n * decide them. A feed that spans records is a place to see what happened,\n * not to rewrite it; the record's own page is where an activity is\n * corrected, beside everything else about it.\n */\n readOnly?: boolean\n /**\n * Which rows are open, held by the card so its header's \"Expand all\"\n * reaches them. A list with no header control holds its own.\n */\n expansion?: TimelineExpansion\n}\n\n/**\n * A newest-first list of logged activities, collapsed to one entry each and\n * paged by the console's shared footer (AGL-2600).\n *\n * The rows arrive ordered — the query is `orderBy('atMs', 'desc')` — and the\n * list does not sort them again; a second sort here is a second place for\n * the order to be defined. The footer pages the window the listener holds,\n * and a page turned past it widens the listener while the probe row says\n * more exists (`useTimelinePages`).\n *\n * Who may edit is decided here, once, and handed to every row: the verdict\n * reads the member document, and the list is the one place that can ask\n * for it a single time however many rows it draws.\n */\nexport function ActivityList(props: ActivityListProps) {\n const {\n rows,\n scope,\n onEdit,\n emptyText = 'Nothing logged yet.',\n emptyAction,\n hasMore,\n onShowMore,\n loading,\n subjectFor,\n readOnly,\n } = props\n const canEdit = useCanEditActivity(scope.orgId, !readOnly)\n const ownExpansion = useTimelineExpansion()\n const expansion = props.expansion ?? ownExpansion\n const pages = useTimelinePages(rows.length, { hasMore, showMore: onShowMore, loading })\n // One clock for every row of one paint.\n const nowMs = Date.now()\n if (!rows.length) {\n return loading ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : (\n <EmptyStateComponent\n compact\n label={'Nothing logged yet'}\n description={emptyText}\n action={emptyAction}\n />\n )\n }\n return (\n <Stack spacing={2}>\n {pages.slice(rows).map((activity) => (\n <ActivityRow\n key={activity.$id}\n activity={activity}\n scope={scope}\n onEdit={onEdit}\n subject={subjectFor?.(activity)}\n nowMs={nowMs}\n editable={canEdit(activity)}\n expanded={expansion.isExpanded(activity.$id)}\n onToggle={() => expansion.toggle(activity.$id)}\n />\n ))}\n <ListPagination {...pages.footer} />\n </Stack>\n )\n}\nActivityList.displayName = 'ActivityList'\n\nexport default ActivityList\n"],"names":["Aglyn","mdiAccountGroupOutline","mdiDeleteOutline","mdiDotsHorizontalCircleOutline","mdiEmailOutline","mdiEmailReceiveOutline","mdiFacebookMessenger","mdiInstagram","mdiLinkedin","mdiMessageTextOutline","mdiNoteTextOutline","mdiPencilOutline","mdiPhoneOutline","mdiWhatsapp","MdiIcon","useConfirmationContext","EmptyStateComponent","ListPagination","useSnackbar","useUser","useUserName","Chip","IconButton","Stack","Tooltip","Typography","deleteDoc","doc","useCallback","useState","useCanEditActivity","TimelineEntry","useTimelineExpansion","useTimelinePages","KIND_ICONS","call","email","text","whatsapp","messenger","instagram","linkedin","meeting","note","other","ActivityKindIcon","props","kind","size","icon","isCrmActivityKind","path","displayName","useActivityAuthorName","data","user","ownName","uid","activity","byName","byUid","ACTIVITY_HEADING_MAX","activityHeading","body","trim","subject","heading","opens","firstLine","split","find","line","length","durationLabel","minutes","hours","Math","floor","rest","String","padStart","ActivityRow","scope","onEdit","nowMs","editable","ownExpanded","setOwnExpanded","expanded","onToggle","open","firestore","dataScope","authorName","confirm","enqueueSnackbar","handleDelete","confirmed","title","description","CRM_ACTIVITY_KIND_LABELS","toLowerCase","confirmationText","confirmationButtonProps","color","then","catch","CRM_COLLECTIONS","activities","$id","variant","persist","error","console","allowDuplicate","label","captured","messageId","deliveryState","isCrmEmailDeliveryState","sent","direction","received","kindChip","crmActivityKindTitle","when","Date","atMs","detail","outcome","durationMinutes","filter","Boolean","hasSubject","chips","data-testid","deliveryAtMs","toLocaleString","CRM_EMAIL_DELIVERY_STATE_LABELS","isCrmEmailDeliveryFailure","join","titleVariant","meta","sx","alignSelf","from","to","activityTimeLabel","now","whiteSpace","overflowWrap","undefined","actions","aria-label","onClick","ActivityList","rows","emptyText","emptyAction","hasMore","onShowMore","loading","subjectFor","readOnly","canEdit","orgId","ownExpansion","expansion","pages","showMore","compact","action","spacing","slice","map","isExpanded","toggle","footer"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-list.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 { CrmActivityKind, CrmActivityRow } from '@aglyn/aglyn'\nimport {\n mdiAccountGroupOutline,\n mdiDeleteOutline,\n mdiDotsHorizontalCircleOutline,\n mdiEmailOutline,\n mdiEmailReceiveOutline,\n mdiFacebookMessenger,\n mdiInstagram,\n mdiLinkedin,\n mdiMessageTextOutline,\n mdiNoteTextOutline,\n mdiPencilOutline,\n mdiPhoneOutline,\n mdiWhatsapp,\n} from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport { Chip, IconButton, Stack, Tooltip, Typography } from '@mui/material'\nimport { deleteDoc, doc } from 'firebase/firestore'\nimport { type ReactNode, useCallback, useState } from 'react'\nimport { type ActivityScope, useCanEditActivity } from './activity-queries'\nimport {\n TimelineEntry,\n type TimelineExpansion,\n useTimelineExpansion,\n useTimelinePages,\n} from './activity-timeline'\n\n/**\n * One glyph per kind. Typed against the union so a kind added to the list\n * cannot render with no icon — `MdiIcon` would fall back to its default\n * glyph silently, and a log where every note looks like a placeholder is\n * worse than a build error.\n */\nconst KIND_ICONS: Record<CrmActivityKind, { path: string }> = {\n call: mdiPhoneOutline,\n email: mdiEmailOutline,\n text: mdiMessageTextOutline,\n whatsapp: mdiWhatsapp,\n messenger: mdiFacebookMessenger,\n instagram: mdiInstagram,\n linkedin: mdiLinkedin,\n meeting: mdiAccountGroupOutline,\n note: mdiNoteTextOutline,\n other: mdiDotsHorizontalCircleOutline,\n}\n\n/** The icon for a kind — a stored kind outside the union draws as `other`. */\nexport function ActivityKindIcon(props: { kind: string; size?: number }) {\n const { kind, size = 1 } = props\n const icon = Aglyn.isCrmActivityKind(kind) ? KIND_ICONS[kind] : KIND_ICONS.other\n return <MdiIcon path={icon.path} size={size} />\n}\nActivityKindIcon.displayName = 'ActivityKindIcon'\n\n/**\n * The author's name for a row (AGL-2600).\n *\n * The denormalized `byName` leads: it is the only name a scoped colleague\n * can read, since a member document is readable by its subject and by\n * org-wide members alone. The signed-in user's OWN rows fall back to their\n * live name — the one case where a lookup is possible — and a row that\n * carries neither (written by a door that did not stamp one) reads as a\n * team member rather than as a uid.\n */\nexport function useActivityAuthorName(): (\n activity: Pick<CrmActivityRow, 'byUid' | 'byName'>,\n) => string {\n const { data: user } = useUser()\n const ownName = useUserName()\n const uid = user?.uid\n return useCallback(\n (activity) => {\n if (activity.byName) return activity.byName\n if (uid && activity.byUid === uid && ownName) return ownName\n return 'A team member'\n },\n [uid, ownName],\n )\n}\n\n/**\n * How long a note's own words may run on a collapsed row before it opens to\n * the rest; a single line under this reads whole, with nothing to open.\n */\nconst ACTIVITY_HEADING_MAX = 80\n\n/**\n * What a collapsed activity reads as, and whether opening it shows more.\n *\n * A subject is the heading and the body is what the row opens to. With no\n * subject the body's first line is the heading, and a body that runs past\n * that line, or past what one line holds, opens to the whole text.\n */\nexport function activityHeading(activity: Pick<CrmActivityRow, 'subject' | 'body'>): {\n heading: string\n opens: boolean\n} {\n const body = typeof activity.body === 'string' ? activity.body.trim() : ''\n const subject = typeof activity.subject === 'string' ? activity.subject.trim() : ''\n if (subject) return { heading: subject, opens: body !== '' }\n const firstLine = body.split('\\n').find((line) => line.trim() !== '')?.trim() ?? ''\n return {\n heading: firstLine,\n opens: body !== firstLine || firstLine.length > ACTIVITY_HEADING_MAX,\n }\n}\n\n/** \"45 min\" or \"2 h 05 min\" — a duration a manager reads at a glance. */\nfunction durationLabel(minutes: number): string {\n if (minutes < 60) return `${minutes} min`\n const hours = Math.floor(minutes / 60)\n const rest = minutes % 60\n return rest ? `${hours} h ${String(rest).padStart(2, '0')} min` : `${hours} h`\n}\n\nexport interface ActivityRowProps {\n activity: CrmActivityRow\n /** Where the delete is written, from `useActivityScope`. */\n scope: ActivityScope\n /** Opens the dialog on this activity. Absent, the row offers no edit. */\n onEdit?: (activity: CrmActivityRow) => void\n /**\n * What sits after the kind, before the body — a feed puts the record the\n * activity is about here, a record page needs nothing.\n */\n subject?: ReactNode\n /**\n * Who or what this entry is about, on a timeline it rolled up to. Unlike\n * {@link subject} it stands on every row: a rolled-up email is as much in\n * need of saying whose it is as a rolled-up call.\n */\n about?: ReactNode\n /** The clock the relative time is read against; `Date.now()` when absent. */\n nowMs?: number\n /**\n * Whether THIS reader may edit and delete the row — the author, or an\n * org-wide member. Decided by whoever draws the rows, through\n * `useCanEditActivity`, once for the whole list: a row that asked for\n * itself would read the member document once per row.\n */\n editable?: boolean\n /**\n * Whether the row is open to its body. The list holds it so the card's\n * \"Expand all\" reaches every row; a row drawn alone holds its own.\n */\n expanded?: boolean\n onToggle?: () => void\n}\n\n/**\n * One logged activity: the kind's icon and label, what was said, how it\n * went, who logged it and how long ago — with edit and delete for whoever\n * may (AGL-2600).\n *\n * Collapsed, the row is its chips, its heading and its caption: the kind,\n * the direction and delivery state, how a call went, the subject or the\n * note's first line, and who, to or from whom, and when. Opened, it shows\n * the body — an email's whole text, a note's every line.\n *\n * Edit and delete appear only when `editable` says this reader may — the\n * author or an org-wide member. That is the console's verdict and not the\n * rules' — see `useCanEditActivity` for why the rules admit more — so the\n * controls are hidden rather than disabled: a disabled button asks \"why\n * not?\", and the honest answer would be \"you could, through the API\".\n */\nexport function ActivityRow(props: ActivityRowProps) {\n const { activity, scope, onEdit, subject, about, nowMs, editable } = props\n const [ownExpanded, setOwnExpanded] = useState(false)\n const expanded = props.expanded ?? ownExpanded\n const onToggle = props.onToggle ?? (() => setOwnExpanded((open) => !open))\n const { firestore, dataScope } = scope\n const authorName = useActivityAuthorName()\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n\n const handleDelete = useCallback(async () => {\n if (!dataScope) return\n const confirmed = await confirm({\n title: 'Delete this activity?',\n description:\n `This ${Aglyn.CRM_ACTIVITY_KIND_LABELS[\n Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other'\n ].toLowerCase()} is removed from the record it was logged against. ` +\n 'Nothing the platform captured — a form, an order, a booking — is ' +\n 'affected.',\n confirmationText: 'Delete activity',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n await deleteDoc(\n doc(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n activity.$id,\n ),\n )\n enqueueSnackbar('Activity deleted', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [dataScope, confirm, activity, firestore, enqueueSnackbar])\n\n const label =\n Aglyn.CRM_ACTIVITY_KIND_LABELS[\n Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other'\n ]\n /*\n * A message the platform SENT (AGL-2615) carries a subject, an address\n * and a delivery state the webhook advances; the state is the chip beside\n * the kind, red once the message did not land. What was sent is a record\n * of a fact and offers no edit — a rewritten body would misstate what\n * left — though whoever may delete a row may still delete this one.\n */\n const captured = typeof activity.messageId === 'string' && activity.messageId !== ''\n const deliveryState = Aglyn.isCrmEmailDeliveryState(activity.deliveryState)\n ? activity.deliveryState\n : null\n // A hand-logged email or call carries a direction too (AGL-3517); only a\n // message the platform carried or captured is \"sent\" or \"received\" here.\n const sent = activity.direction === 'outbound' && (deliveryState !== null || captured)\n /*\n * A message CAPTURED from a mailbox (AGL-2657) carries the provider's\n * Message-ID and, when a correspondent wrote it, the `inbound` direction.\n * It is drawn as its own thing — \"Received\" beside the kind, the sender\n * where a sent row shows the recipient, no author because nobody on the\n * team logged it — and a member's copied send reads \"Sent\" without a\n * delivery chip, since the platform did not carry it. Neither takes the\n * caller's \"Logged\" chip: nobody logged them.\n */\n const received = activity.direction === 'inbound' && captured\n /*\n * A hand-logged call or email names its direction in the kind's own chip\n * — \"Inbound call\", \"Outbound email\" (AGL-3517); a sent or captured\n * message says it with the chips below instead.\n */\n const kindChip =\n sent || received || captured\n ? label\n : Aglyn.crmActivityKindTitle(\n Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other',\n activity.direction,\n )\n const when = new Date(activity.atMs)\n const detail = [\n activity.outcome ? activity.outcome : null,\n typeof activity.durationMinutes === 'number' && activity.durationMinutes > 0\n ? durationLabel(activity.durationMinutes)\n : null,\n ].filter(Boolean)\n\n const { heading, opens } = activityHeading(activity)\n const hasSubject = typeof activity.subject === 'string' && activity.subject.trim() !== ''\n\n return (\n <TimelineEntry\n icon={\n received ? (\n <MdiIcon path={mdiEmailReceiveOutline.path} />\n ) : (\n <ActivityKindIcon kind={activity.kind} />\n )\n }\n label={heading || label.toLowerCase()}\n expanded={expanded}\n onToggle={onToggle}\n chips={\n <>\n <Chip label={kindChip} size=\"small\" />\n {received ? (\n <Chip\n label=\"Received\"\n size=\"small\"\n variant=\"outlined\"\n color=\"info\"\n data-testid=\"activity-direction\"\n />\n ) : captured ? (\n <Chip\n label=\"Sent\"\n size=\"small\"\n variant=\"outlined\"\n data-testid=\"activity-direction\"\n />\n ) : null}\n {deliveryState ? (\n <Tooltip\n title={\n typeof activity.deliveryAtMs === 'number'\n ? new Date(activity.deliveryAtMs).toLocaleString()\n : ''\n }\n >\n <Chip\n label={Aglyn.CRM_EMAIL_DELIVERY_STATE_LABELS[deliveryState]}\n size=\"small\"\n variant=\"outlined\"\n color={Aglyn.isCrmEmailDeliveryFailure(deliveryState) ? 'error' : 'default'}\n data-testid=\"activity-delivery-state\"\n />\n </Tooltip>\n ) : null}\n {captured ? null : subject}\n {about}\n {detail.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {detail.join(' · ')}\n </Typography>\n ) : null}\n </>\n }\n // With no subject the heading IS the body's first line, so an open\n // row shows the body alone rather than its first line twice.\n title={hasSubject || !opens || !expanded ? heading : null}\n titleVariant={hasSubject ? 'subtitle2' : 'body2'}\n meta={\n <Tooltip title={when.toLocaleString()}>\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ alignSelf: 'flex-start' }}\n >\n {[\n received ? null : authorName(activity),\n received && activity.from ? `from ${activity.from}` : null,\n sent && activity.to ? `to ${activity.to}` : null,\n Aglyn.activityTimeLabel(activity.atMs, nowMs ?? Date.now()),\n ]\n .filter(Boolean)\n .join(' · ')}\n </Typography>\n </Tooltip>\n }\n body={\n opens ? (\n <Typography variant=\"body2\" sx={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>\n {activity.body}\n </Typography>\n ) : undefined\n }\n actions={\n editable ? (\n <>\n {onEdit && !sent && !received ? (\n <IconButton\n size=\"small\"\n aria-label=\"Edit activity\"\n onClick={() => onEdit(activity)}\n >\n <MdiIcon path={mdiPencilOutline.path} size={0.8} />\n </IconButton>\n ) : null}\n <IconButton\n size=\"small\"\n aria-label=\"Delete activity\"\n onClick={handleDelete}\n >\n <MdiIcon path={mdiDeleteOutline.path} size={0.8} />\n </IconButton>\n </>\n ) : null\n }\n />\n )\n}\nActivityRow.displayName = 'ActivityRow'\n\nexport interface ActivityListProps {\n /** Newest-first, as the listener hands them back. */\n rows: readonly CrmActivityRow[]\n scope: ActivityScope\n onEdit?: (activity: CrmActivityRow) => void\n /** What the list says when there is nothing in it. */\n emptyText?: string\n /** The way out of an empty list — the record's \"Log activity\" button. */\n emptyAction?: ReactNode\n /** The listener holds less than exists; `onShowMore` widens it. */\n hasMore?: boolean\n onShowMore?: () => void\n /** The listener is reading, so an empty window is not yet an empty log. */\n loading?: boolean\n /** The record each row is about, for a list that spans records. */\n subjectFor?: (activity: CrmActivityRow) => ReactNode\n /** Who or what each row is about, for a timeline rows roll up to. */\n aboutFor?: (activity: CrmActivityRow) => ReactNode\n /**\n * No controls on any row, whoever is reading — and no member read to\n * decide them. A feed that spans records is a place to see what happened,\n * not to rewrite it; the record's own page is where an activity is\n * corrected, beside everything else about it.\n */\n readOnly?: boolean\n /**\n * Which rows are open, held by the card so its header's \"Expand all\"\n * reaches them. A list with no header control holds its own.\n */\n expansion?: TimelineExpansion\n}\n\n/**\n * A newest-first list of logged activities, collapsed to one entry each and\n * paged by the console's shared footer (AGL-2600).\n *\n * The rows arrive ordered — the query is `orderBy('atMs', 'desc')` — and the\n * list does not sort them again; a second sort here is a second place for\n * the order to be defined. The footer pages the window the listener holds,\n * and a page turned past it widens the listener while the probe row says\n * more exists (`useTimelinePages`).\n *\n * Who may edit is decided here, once, and handed to every row: the verdict\n * reads the member document, and the list is the one place that can ask\n * for it a single time however many rows it draws.\n */\nexport function ActivityList(props: ActivityListProps) {\n const {\n rows,\n scope,\n onEdit,\n emptyText = 'Nothing logged yet.',\n emptyAction,\n hasMore,\n onShowMore,\n loading,\n subjectFor,\n aboutFor,\n readOnly,\n } = props\n const canEdit = useCanEditActivity(scope.orgId, !readOnly)\n const ownExpansion = useTimelineExpansion()\n const expansion = props.expansion ?? ownExpansion\n const pages = useTimelinePages(rows.length, { hasMore, showMore: onShowMore, loading })\n // One clock for every row of one paint.\n const nowMs = Date.now()\n if (!rows.length) {\n return loading ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : (\n <EmptyStateComponent\n compact\n label={'Nothing logged yet'}\n description={emptyText}\n action={emptyAction}\n />\n )\n }\n return (\n <Stack spacing={2}>\n {pages.slice(rows).map((activity) => (\n <ActivityRow\n key={activity.$id}\n activity={activity}\n scope={scope}\n onEdit={onEdit}\n subject={subjectFor?.(activity)}\n about={aboutFor?.(activity)}\n nowMs={nowMs}\n editable={canEdit(activity)}\n expanded={expansion.isExpanded(activity.$id)}\n onToggle={() => expansion.toggle(activity.$id)}\n />\n ))}\n <ListPagination {...pages.footer} />\n </Stack>\n )\n}\nActivityList.displayName = 'ActivityList'\n\nexport default ActivityList\n"],"names":["Aglyn","mdiAccountGroupOutline","mdiDeleteOutline","mdiDotsHorizontalCircleOutline","mdiEmailOutline","mdiEmailReceiveOutline","mdiFacebookMessenger","mdiInstagram","mdiLinkedin","mdiMessageTextOutline","mdiNoteTextOutline","mdiPencilOutline","mdiPhoneOutline","mdiWhatsapp","MdiIcon","useConfirmationContext","EmptyStateComponent","ListPagination","useSnackbar","useUser","useUserName","Chip","IconButton","Stack","Tooltip","Typography","deleteDoc","doc","useCallback","useState","useCanEditActivity","TimelineEntry","useTimelineExpansion","useTimelinePages","KIND_ICONS","call","email","text","whatsapp","messenger","instagram","linkedin","meeting","note","other","ActivityKindIcon","props","kind","size","icon","isCrmActivityKind","path","displayName","useActivityAuthorName","data","user","ownName","uid","activity","byName","byUid","ACTIVITY_HEADING_MAX","activityHeading","body","trim","subject","heading","opens","firstLine","split","find","line","length","durationLabel","minutes","hours","Math","floor","rest","String","padStart","ActivityRow","scope","onEdit","about","nowMs","editable","ownExpanded","setOwnExpanded","expanded","onToggle","open","firestore","dataScope","authorName","confirm","enqueueSnackbar","handleDelete","confirmed","title","description","CRM_ACTIVITY_KIND_LABELS","toLowerCase","confirmationText","confirmationButtonProps","color","then","catch","CRM_COLLECTIONS","activities","$id","variant","persist","error","console","allowDuplicate","label","captured","messageId","deliveryState","isCrmEmailDeliveryState","sent","direction","received","kindChip","crmActivityKindTitle","when","Date","atMs","detail","outcome","durationMinutes","filter","Boolean","hasSubject","chips","data-testid","deliveryAtMs","toLocaleString","CRM_EMAIL_DELIVERY_STATE_LABELS","isCrmEmailDeliveryFailure","join","titleVariant","meta","sx","alignSelf","from","to","activityTimeLabel","now","whiteSpace","overflowWrap","undefined","actions","aria-label","onClick","ActivityList","rows","emptyText","emptyAction","hasMore","onShowMore","loading","subjectFor","aboutFor","readOnly","canEdit","orgId","ownExpansion","expansion","pages","showMore","compact","action","spacing","slice","map","isExpanded","toggle","footer"],"mappings":"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"}
|
|
@@ -3,8 +3,9 @@ import type { AglynOrgBilling, CrmActivityRow } from '@aglyn/aglyn';
|
|
|
3
3
|
/** The org document as the shell passes it: partial, and possibly absent. */
|
|
4
4
|
export type CrmOrg = Partial<AglynOrgBilling> | null | undefined;
|
|
5
5
|
/**
|
|
6
|
-
* Which record an activity surface is about. Exactly one
|
|
7
|
-
*
|
|
6
|
+
* Which record an activity surface is about. Exactly one is set by a record
|
|
7
|
+
* page; a feed across the CRM sets none. An entry can carry several links
|
|
8
|
+
* (`model/crm-activity-link.ts`), and each timeline still lists by its own.
|
|
8
9
|
*/
|
|
9
10
|
export interface ActivityRecordLink {
|
|
10
11
|
contactId?: string;
|
|
@@ -43,6 +44,10 @@ export declare function useActivityScope(hostId: string | null, org: CrmOrg): {
|
|
|
43
44
|
consentGroup: Aglyn.ConsentGroup;
|
|
44
45
|
readTokens: readonly Aglyn.ScopeToken[];
|
|
45
46
|
writeTokens: readonly Aglyn.ScopeToken[];
|
|
47
|
+
/** Whether a new activity must name a site — see `useCrmScope`'s `createNeedsSite`. */
|
|
48
|
+
needsSite: boolean;
|
|
49
|
+
/** The org document, for the holder a contact is read through at the org level. */
|
|
50
|
+
org: Record<string, unknown> | null;
|
|
46
51
|
};
|
|
47
52
|
export type ActivityScope = ReturnType<typeof useActivityScope>;
|
|
48
53
|
/**
|
|
@@ -46,7 +46,7 @@ import { crmVisibleToClause, useCrmScope } from "../hooks/use-crm-scope.js";
|
|
|
46
46
|
* site: `consentGroup` and `readTokens` are `null` — the listener carries
|
|
47
47
|
* no scope clause — and a new activity is stamped with the picked site's
|
|
48
48
|
* tokens and provenance.
|
|
49
|
-
*/ const { scope: dataScope, orgId, consentGroup, visibleTo: readTokens, createHostId, createTokens: writeTokens } = useCrmScope({
|
|
49
|
+
*/ const { scope: dataScope, orgId, consentGroup, visibleTo: readTokens, createHostId, createTokens: writeTokens, createNeedsSite } = useCrmScope({
|
|
50
50
|
hostId,
|
|
51
51
|
org
|
|
52
52
|
});
|
|
@@ -61,7 +61,9 @@ import { crmVisibleToClause, useCrmScope } from "../hooks/use-crm-scope.js";
|
|
|
61
61
|
*/ mountedHostId: hostId,
|
|
62
62
|
consentGroup,
|
|
63
63
|
readTokens,
|
|
64
|
-
writeTokens
|
|
64
|
+
writeTokens,
|
|
65
|
+
/** Whether a new activity must name a site — see `useCrmScope`'s `createNeedsSite`. */ needsSite: createNeedsSite,
|
|
66
|
+
/** The org document, for the holder a contact is read through at the org level. */ org: org != null ? org : null
|
|
65
67
|
}), [
|
|
66
68
|
firestore,
|
|
67
69
|
dataScope,
|
|
@@ -70,7 +72,9 @@ import { crmVisibleToClause, useCrmScope } from "../hooks/use-crm-scope.js";
|
|
|
70
72
|
hostId,
|
|
71
73
|
consentGroup,
|
|
72
74
|
readTokens,
|
|
73
|
-
writeTokens
|
|
75
|
+
writeTokens,
|
|
76
|
+
createNeedsSite,
|
|
77
|
+
org
|
|
74
78
|
]);
|
|
75
79
|
}
|
|
76
80
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-queries.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { AglynOrgBilling, CrmActivityRow } from '@aglyn/aglyn'\nimport {\n useFirestore,\n usePagedCollection,\n useScopeTokens,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n orderBy,\n query,\n where,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport { crmVisibleToClause, useCrmScope } from '../hooks/use-crm-scope'\n\n/** The org document as the shell passes it: partial, and possibly absent. */\nexport type CrmOrg = Partial<AglynOrgBilling> | null | undefined\n\n/**\n * Which record an activity surface is about. Exactly one of the three is set\n * by a record page; a feed across the CRM sets none.\n */\nexport interface ActivityRecordLink {\n contactId?: string\n companyId?: string\n dealId?: string\n /** A lead's own log (AGL-2615) — see `CrmActivityLink.leadId`. */\n leadId?: string\n}\n\n/**\n * Everything an activity surface needs to know about WHERE it is reading\n * and writing (AGL-2600), resolved once from the two props the shell hands\n * every CRM page.\n *\n * Four surfaces read activities — a record card, a contact's timeline, the\n * recent feed and the dialog that writes them — and each of them would\n * otherwise resolve the same org, the same consent group and the same token\n * set from the same two inputs. Resolved here so that a surface cannot read\n * with one group and write with another, which is the shape of leak the\n * contacts list closed (a listener with no `where` at all).\n *\n * Pure of Firestore reads: the org document is the one the shell already\n * loaded, so the group and the tokens cost nothing, and the data scope is\n * the memoized `['orgs', orgId]` tuple every host-scoped card builds.\n */\nexport function useActivityScope(hostId: string | null, org: CrmOrg) {\n const firestore = useFirestore()\n /*\n * The controller this surface is showing — the sites declared to be one\n * sender, or this site alone — read from the org document the shell\n * passed, by the one hook every CRM surface resolves its scope through\n * (AGL-2614). The contacts list resolves its group the same way, so an\n * activity logged from a contact's page lands in the scope that contact\n * is read under. At the organization level (AGL-2630) there is no viewing\n * site: `consentGroup` and `readTokens` are `null` — the listener carries\n * no scope clause — and a new activity is stamped with the picked site's\n * tokens and provenance.\n */\n const {\n scope: dataScope,\n orgId,\n consentGroup,\n visibleTo: readTokens,\n createHostId,\n createTokens: writeTokens,\n } = useCrmScope({ hostId, org })\n return useMemo(\n () => ({\n firestore,\n dataScope,\n orgId,\n /** The site a NEW activity names as provenance — the mounted site, or the picked one. */\n hostId: createHostId,\n /**\n * The site the surface is MOUNTED under, or `null` at the organization\n * level — what a dialog reads to decide whether to ask which site.\n */\n mountedHostId: hostId,\n consentGroup,\n readTokens,\n writeTokens,\n }),\n [\n firestore,\n dataScope,\n orgId,\n createHostId,\n hostId,\n consentGroup,\n readTokens,\n writeTokens,\n ],\n )\n}\n\nexport type ActivityScope = ReturnType<typeof useActivityScope>\n\n/**\n * Whether the signed-in user may EDIT or DELETE a given activity (AGL-2600).\n *\n * The author may, and so may an org-wide member — the person who administers\n * the org can correct or remove anything logged in it. Nobody else: a scoped\n * colleague may read a call log they did not write, and may not rewrite it.\n *\n * ⚠️ This is a UI verdict, not an enforced one. The Firestore rules on\n * `crmActivities` admit any member who may write scoped org data, and they\n * do not distinguish the author from anybody else — there is no `byUid`\n * clause in them. The controls are hidden here so that the console does not\n * offer a colleague's log for editing, and a caller who bypasses the console\n * is a caller the rules already admit. Tightening the rules to the author is\n * a decision about what the API permits, not something a card takes on its\n * own.\n *\n * `useScopeTokens` answers org-wide while it loads, so the verdict for a\n * NON-author waits for `loaded` — a scoped colleague must not see edit\n * controls flash on for one paint and then vanish.\n *\n * Called ONCE by the list that draws the rows, never by a row. The verdict\n * costs a read of the member document, and a hundred rows each asking would\n * be a hundred reads of one document on every mount — the shape of cost the\n * console pays nowhere else. `enabled` is for a surface that offers no\n * controls at all, the feed: `useScopeTokens` opens nothing for an undefined\n * org, so a disabled verdict reads nothing and answers no.\n */\nexport function useCanEditActivity(\n orgId: string | undefined,\n enabled = true,\n) {\n const { data: user } = useUser()\n const uid = user?.uid\n const membership = useScopeTokens(enabled ? orgId : undefined)\n return useCallback(\n (activity: Pick<CrmActivityRow, 'byUid'>): boolean => {\n if (!enabled || !uid) return false\n if (activity.byUid === uid) return true\n return membership.loaded && membership.orgWide\n },\n [enabled, uid, membership.loaded, membership.orgWide],\n )\n}\n\n/** How many rows a \"show more\" step adds, and how many the first paint reads. */\nexport const ACTIVITY_PAGE_SIZE = 100\n\n/**\n * The activities filed against one record, or across the CRM, newest-first\n * and bounded (AGL-2600).\n *\n * The window is the query. `usePagedCollection` widens the listener by one\n * page each time the reader asks for more, so a record with three hundred\n * calls costs a hundred documents until somebody wants the next hundred —\n * and `hasMore` is a fact from the probe row, not a guess from the length.\n * The rows handed back are the WHOLE window so far, not the last page alone,\n * because a log reads as one list that grows at the bottom rather than as\n * numbered pages.\n *\n * Filtered by `array-contains-any` over the reader's tokens, which is the\n * predicate the rules evaluate with `hasAny`: a filtered query is provable\n * per document, and an unfiltered one is refused outright rather than\n * quietly returning everything. The record filter is one equality on top of\n * it — the four indexes `(visibleTo, contactId|companyId|dealId|leadId, atMs\n * DESC)` exist for exactly these queries, and `(visibleTo, atMs DESC)` for the\n * feed that names no record.\n *\n * Each record filter is indexed twice. An organization-level reader has no\n * tokens, so `crmVisibleToClause` contributes no constraint and the query\n * arrives as `(<record>, atMs DESC)` with the array field gone — a different\n * index, not a prefix of the one above it. Adding a record kind here means\n * adding BOTH halves; one of them alone leaves the other audience unserved.\n *\n * These are all DESCENDING because this window asks for `desc` outright. A\n * read over the same rows that names no order is not served by them: Firestore\n * orders such a query by its range field ascending, and an index serves one\n * direction or its exact reverse. That is why the report counts carry their\n * own ascending indexes rather than borrowing these.\n */\nexport function useActivityWindow(\n scope: ActivityScope,\n link: ActivityRecordLink,\n pageSize: number = ACTIVITY_PAGE_SIZE,\n) {\n const { firestore, dataScope, readTokens } = scope\n const { contactId, companyId, dealId, leadId } = link\n const paged = usePagedCollection<CrmActivityRow>(\n (pageLimit) => {\n if (!dataScope) return null\n const record: QueryConstraint[] = contactId\n ? [where('contactId', '==', contactId)]\n : companyId\n ? [where('companyId', '==', companyId)]\n : dealId\n ? [where('dealId', '==', dealId)]\n : leadId\n ? [where('leadId', '==', leadId)]\n : []\n return query(\n collection(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n ),\n ...crmVisibleToClause(readTokens),\n ...record,\n orderBy('atMs', 'desc'),\n limit(pageLimit),\n )\n },\n [firestore, dataScope, readTokens, contactId, companyId, dealId, leadId],\n { idField: '$id', pageSize },\n )\n const windowSize = paged.pageSize * (paged.page + 1)\n // The probe row is the one past the window; it says \"more exists\" and is\n // never shown.\n const rows = useMemo(\n () => (paged.data ?? []).slice(0, windowSize),\n [paged.data, windowSize],\n )\n const showMore = useCallback(\n () => paged.setPage(paged.page + 1),\n [paged],\n )\n return {\n rows,\n hasMore: paged.hasMore,\n showMore,\n status: paged.status,\n /** Whether the listener has a scope to read at all. */\n ready: Boolean(dataScope),\n }\n}\n"],"names":["Aglyn","useFirestore","usePagedCollection","useScopeTokens","useUser","collection","limit","orderBy","query","where","useCallback","useMemo","crmVisibleToClause","useCrmScope","useActivityScope","hostId","org","firestore","scope","dataScope","orgId","consentGroup","visibleTo","readTokens","createHostId","createTokens","writeTokens","mountedHostId","useCanEditActivity","enabled","data","user","uid","membership","undefined","activity","byUid","loaded","orgWide","ACTIVITY_PAGE_SIZE","useActivityWindow","link","pageSize","contactId","companyId","dealId","leadId","paged","pageLimit","record","CRM_COLLECTIONS","activities","idField","windowSize","page","rows","slice","showMore","setPage","hasMore","status","ready","Boolean"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/activity-queries.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { AglynOrgBilling, CrmActivityRow } from '@aglyn/aglyn'\nimport {\n useFirestore,\n usePagedCollection,\n useScopeTokens,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n orderBy,\n query,\n where,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport { crmVisibleToClause, useCrmScope } from '../hooks/use-crm-scope'\n\n/** The org document as the shell passes it: partial, and possibly absent. */\nexport type CrmOrg = Partial<AglynOrgBilling> | null | undefined\n\n/**\n * Which record an activity surface is about. Exactly one is set by a record\n * page; a feed across the CRM sets none. An entry can carry several links\n * (`model/crm-activity-link.ts`), and each timeline still lists by its own.\n */\nexport interface ActivityRecordLink {\n contactId?: string\n companyId?: string\n dealId?: string\n /** A lead's own log (AGL-2615) — see `CrmActivityLink.leadId`. */\n leadId?: string\n}\n\n/**\n * Everything an activity surface needs to know about WHERE it is reading\n * and writing (AGL-2600), resolved once from the two props the shell hands\n * every CRM page.\n *\n * Four surfaces read activities — a record card, a contact's timeline, the\n * recent feed and the dialog that writes them — and each of them would\n * otherwise resolve the same org, the same consent group and the same token\n * set from the same two inputs. Resolved here so that a surface cannot read\n * with one group and write with another, which is the shape of leak the\n * contacts list closed (a listener with no `where` at all).\n *\n * Pure of Firestore reads: the org document is the one the shell already\n * loaded, so the group and the tokens cost nothing, and the data scope is\n * the memoized `['orgs', orgId]` tuple every host-scoped card builds.\n */\nexport function useActivityScope(hostId: string | null, org: CrmOrg) {\n const firestore = useFirestore()\n /*\n * The controller this surface is showing — the sites declared to be one\n * sender, or this site alone — read from the org document the shell\n * passed, by the one hook every CRM surface resolves its scope through\n * (AGL-2614). The contacts list resolves its group the same way, so an\n * activity logged from a contact's page lands in the scope that contact\n * is read under. At the organization level (AGL-2630) there is no viewing\n * site: `consentGroup` and `readTokens` are `null` — the listener carries\n * no scope clause — and a new activity is stamped with the picked site's\n * tokens and provenance.\n */\n const {\n scope: dataScope,\n orgId,\n consentGroup,\n visibleTo: readTokens,\n createHostId,\n createTokens: writeTokens,\n createNeedsSite,\n } = useCrmScope({ hostId, org })\n return useMemo(\n () => ({\n firestore,\n dataScope,\n orgId,\n /** The site a NEW activity names as provenance — the mounted site, or the picked one. */\n hostId: createHostId,\n /**\n * The site the surface is MOUNTED under, or `null` at the organization\n * level — what a dialog reads to decide whether to ask which site.\n */\n mountedHostId: hostId,\n consentGroup,\n readTokens,\n writeTokens,\n /** Whether a new activity must name a site — see `useCrmScope`'s `createNeedsSite`. */\n needsSite: createNeedsSite,\n /** The org document, for the holder a contact is read through at the org level. */\n org: (org ?? null) as Record<string, unknown> | null,\n }),\n [\n firestore,\n dataScope,\n orgId,\n createHostId,\n hostId,\n consentGroup,\n readTokens,\n writeTokens,\n createNeedsSite,\n org,\n ],\n )\n}\n\nexport type ActivityScope = ReturnType<typeof useActivityScope>\n\n/**\n * Whether the signed-in user may EDIT or DELETE a given activity (AGL-2600).\n *\n * The author may, and so may an org-wide member — the person who administers\n * the org can correct or remove anything logged in it. Nobody else: a scoped\n * colleague may read a call log they did not write, and may not rewrite it.\n *\n * ⚠️ This is a UI verdict, not an enforced one. The Firestore rules on\n * `crmActivities` admit any member who may write scoped org data, and they\n * do not distinguish the author from anybody else — there is no `byUid`\n * clause in them. The controls are hidden here so that the console does not\n * offer a colleague's log for editing, and a caller who bypasses the console\n * is a caller the rules already admit. Tightening the rules to the author is\n * a decision about what the API permits, not something a card takes on its\n * own.\n *\n * `useScopeTokens` answers org-wide while it loads, so the verdict for a\n * NON-author waits for `loaded` — a scoped colleague must not see edit\n * controls flash on for one paint and then vanish.\n *\n * Called ONCE by the list that draws the rows, never by a row. The verdict\n * costs a read of the member document, and a hundred rows each asking would\n * be a hundred reads of one document on every mount — the shape of cost the\n * console pays nowhere else. `enabled` is for a surface that offers no\n * controls at all, the feed: `useScopeTokens` opens nothing for an undefined\n * org, so a disabled verdict reads nothing and answers no.\n */\nexport function useCanEditActivity(\n orgId: string | undefined,\n enabled = true,\n) {\n const { data: user } = useUser()\n const uid = user?.uid\n const membership = useScopeTokens(enabled ? orgId : undefined)\n return useCallback(\n (activity: Pick<CrmActivityRow, 'byUid'>): boolean => {\n if (!enabled || !uid) return false\n if (activity.byUid === uid) return true\n return membership.loaded && membership.orgWide\n },\n [enabled, uid, membership.loaded, membership.orgWide],\n )\n}\n\n/** How many rows a \"show more\" step adds, and how many the first paint reads. */\nexport const ACTIVITY_PAGE_SIZE = 100\n\n/**\n * The activities filed against one record, or across the CRM, newest-first\n * and bounded (AGL-2600).\n *\n * The window is the query. `usePagedCollection` widens the listener by one\n * page each time the reader asks for more, so a record with three hundred\n * calls costs a hundred documents until somebody wants the next hundred —\n * and `hasMore` is a fact from the probe row, not a guess from the length.\n * The rows handed back are the WHOLE window so far, not the last page alone,\n * because a log reads as one list that grows at the bottom rather than as\n * numbered pages.\n *\n * Filtered by `array-contains-any` over the reader's tokens, which is the\n * predicate the rules evaluate with `hasAny`: a filtered query is provable\n * per document, and an unfiltered one is refused outright rather than\n * quietly returning everything. The record filter is one equality on top of\n * it — the four indexes `(visibleTo, contactId|companyId|dealId|leadId, atMs\n * DESC)` exist for exactly these queries, and `(visibleTo, atMs DESC)` for the\n * feed that names no record.\n *\n * Each record filter is indexed twice. An organization-level reader has no\n * tokens, so `crmVisibleToClause` contributes no constraint and the query\n * arrives as `(<record>, atMs DESC)` with the array field gone — a different\n * index, not a prefix of the one above it. Adding a record kind here means\n * adding BOTH halves; one of them alone leaves the other audience unserved.\n *\n * These are all DESCENDING because this window asks for `desc` outright. A\n * read over the same rows that names no order is not served by them: Firestore\n * orders such a query by its range field ascending, and an index serves one\n * direction or its exact reverse. That is why the report counts carry their\n * own ascending indexes rather than borrowing these.\n */\nexport function useActivityWindow(\n scope: ActivityScope,\n link: ActivityRecordLink,\n pageSize: number = ACTIVITY_PAGE_SIZE,\n) {\n const { firestore, dataScope, readTokens } = scope\n const { contactId, companyId, dealId, leadId } = link\n const paged = usePagedCollection<CrmActivityRow>(\n (pageLimit) => {\n if (!dataScope) return null\n const record: QueryConstraint[] = contactId\n ? [where('contactId', '==', contactId)]\n : companyId\n ? [where('companyId', '==', companyId)]\n : dealId\n ? [where('dealId', '==', dealId)]\n : leadId\n ? [where('leadId', '==', leadId)]\n : []\n return query(\n collection(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n ),\n ...crmVisibleToClause(readTokens),\n ...record,\n orderBy('atMs', 'desc'),\n limit(pageLimit),\n )\n },\n [firestore, dataScope, readTokens, contactId, companyId, dealId, leadId],\n { idField: '$id', pageSize },\n )\n const windowSize = paged.pageSize * (paged.page + 1)\n // The probe row is the one past the window; it says \"more exists\" and is\n // never shown.\n const rows = useMemo(\n () => (paged.data ?? []).slice(0, windowSize),\n [paged.data, windowSize],\n )\n const showMore = useCallback(\n () => paged.setPage(paged.page + 1),\n [paged],\n )\n return {\n rows,\n hasMore: paged.hasMore,\n showMore,\n status: paged.status,\n /** Whether the listener has a scope to read at all. */\n ready: Boolean(dataScope),\n }\n}\n"],"names":["Aglyn","useFirestore","usePagedCollection","useScopeTokens","useUser","collection","limit","orderBy","query","where","useCallback","useMemo","crmVisibleToClause","useCrmScope","useActivityScope","hostId","org","firestore","scope","dataScope","orgId","consentGroup","visibleTo","readTokens","createHostId","createTokens","writeTokens","createNeedsSite","mountedHostId","needsSite","useCanEditActivity","enabled","data","user","uid","membership","undefined","activity","byUid","loaded","orgWide","ACTIVITY_PAGE_SIZE","useActivityWindow","link","pageSize","contactId","companyId","dealId","leadId","paged","pageLimit","record","CRM_COLLECTIONS","activities","idField","windowSize","page","rows","slice","showMore","setPage","hasMore","status","ready","Boolean"],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type CrmOrgDoc } from '../hooks/use-crm-scope';
|
|
18
|
+
import type { CrmRoutes } from '../model/crm-routes';
|
|
19
|
+
export interface CompanyContactRolesCardProps {
|
|
20
|
+
companyId: string;
|
|
21
|
+
company: {
|
|
22
|
+
contactRoles?: unknown;
|
|
23
|
+
};
|
|
24
|
+
/** The site the page is read under, or `null` at the organization level. */
|
|
25
|
+
hostId: string | null;
|
|
26
|
+
org: CrmOrgDoc;
|
|
27
|
+
routes: CrmRoutes;
|
|
28
|
+
/** The listener's verdict on the company, for the stale-seed guard. */
|
|
29
|
+
fromCache: boolean;
|
|
30
|
+
unreadable: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Salesforce's Account Contact Roles (AGL-4122): who at a company plays which
|
|
34
|
+
* part, and who the Primary is. Separate from the roles on a deal, as there: a
|
|
35
|
+
* person's part in the company is a fact about the account, and their part in
|
|
36
|
+
* one deal can differ. The same list of roles serves both.
|
|
37
|
+
*/
|
|
38
|
+
export declare function CompanyContactRolesCard(props: CompanyContactRolesCardProps): import("react").JSX.Element;
|
|
39
|
+
export declare namespace CompanyContactRolesCard {
|
|
40
|
+
var displayName: string;
|
|
41
|
+
}
|
|
42
|
+
export default CompanyContactRolesCard;
|