@ikenga/pkg-sales 0.3.0 → 0.4.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/dist/app.js +13 -1
- package/dist/features/sales/sales-view.js +29 -17
- package/dist/lib/operator.js +39 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -126,6 +126,12 @@ function App() {
|
|
|
126
126
|
const [bridgeReady, setBridgeReady] = useState(false);
|
|
127
127
|
const [bridgeError, setBridgeError] = useState(null);
|
|
128
128
|
const [activeFeature, setActiveFeature] = useState(null);
|
|
129
|
+
// Operator identity (hostContext.operator — see @ikenga/contract's
|
|
130
|
+
// host-context.ts). OPTIONAL: absent means unknown operator, standalone
|
|
131
|
+
// dev never sets it. Threaded down as a plain id/label pair, same shape
|
|
132
|
+
// as activeFeature, so the view fails safe instead of assuming a default.
|
|
133
|
+
const [operatorId, setOperatorId] = useState(null);
|
|
134
|
+
const [operatorLabel, setOperatorLabel] = useState(null);
|
|
129
135
|
|
|
130
136
|
useEffect(() => {
|
|
131
137
|
if (isStandalone()) {
|
|
@@ -138,11 +144,17 @@ function App() {
|
|
|
138
144
|
onContextChange: (ctx) => {
|
|
139
145
|
const af = ctx?.royaltiSuite?.activeFeature;
|
|
140
146
|
if (typeof af === 'string') setActiveFeature(af);
|
|
147
|
+
const op = ctx?.operator;
|
|
148
|
+
setOperatorId(typeof op?.id === 'string' ? op.id : null);
|
|
149
|
+
setOperatorLabel(typeof op?.displayName === 'string' ? op.displayName : (typeof op?.id === 'string' ? op.id : null));
|
|
141
150
|
},
|
|
142
151
|
})
|
|
143
152
|
.then((ctx) => {
|
|
144
153
|
const af = ctx?.royaltiSuite?.activeFeature;
|
|
145
154
|
if (typeof af === 'string') setActiveFeature(af);
|
|
155
|
+
const op = ctx?.operator;
|
|
156
|
+
setOperatorId(typeof op?.id === 'string' ? op.id : null);
|
|
157
|
+
setOperatorLabel(typeof op?.displayName === 'string' ? op.displayName : (typeof op?.id === 'string' ? op.id : null));
|
|
146
158
|
setBridgeReady(true);
|
|
147
159
|
})
|
|
148
160
|
.catch((e) => setBridgeError(e.message ?? String(e)));
|
|
@@ -155,7 +167,7 @@ function App() {
|
|
|
155
167
|
return html`<div style=${{ padding: '2rem', color: 'var(--fg-muted)' }}>Connecting…</div>`;
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
return html`<${QueryClientProvider} client=${queryClient}><${SalesView} activeFeature=${activeFeature} /></${QueryClientProvider}>`;
|
|
170
|
+
return html`<${QueryClientProvider} client=${queryClient}><${SalesView} activeFeature=${activeFeature} operatorId=${operatorId} operatorLabel=${operatorLabel} /></${QueryClientProvider}>`;
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
createRoot(document.getElementById('root')).render(html`<${App} />`);
|
|
@@ -39,6 +39,9 @@ import { buildCreateBrief, dispatchCreate } from '../../lib/create-dispatch.js';
|
|
|
39
39
|
import { dispatchItemAction } from '../../lib/dispatch.js';
|
|
40
40
|
// facet-wire recipe: sidebar filter facets (f:*) narrow the pipeline list/kanban.
|
|
41
41
|
import { applyFacet } from '../../lib/facet-filter.js';
|
|
42
|
+
// operator-identity recipe: hostContext.operator threaded down from app.js —
|
|
43
|
+
// "mine" predicates/fallbacks fail safe (empty/unclaimed) when unknown.
|
|
44
|
+
import { isMine } from '../../lib/operator.js';
|
|
42
45
|
|
|
43
46
|
// ─── Stage enum ───────────────────────────────────────────────────────────────
|
|
44
47
|
// Per the R-04 Pipeline-stages convention (06-skill-action-contract.md §Pipeline-stages).
|
|
@@ -139,7 +142,7 @@ async function fetchOpenDeals() {
|
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
async function fetchWonDeals() {
|
|
145
|
+
async function fetchWonDeals(operatorId) {
|
|
143
146
|
if (isStandalone()) return WON_DEALS_FIXTURE;
|
|
144
147
|
try {
|
|
145
148
|
const rows = await hostDbQuery(
|
|
@@ -153,7 +156,7 @@ async function fetchWonDeals() {
|
|
|
153
156
|
return rows.map((r) => ({
|
|
154
157
|
...r,
|
|
155
158
|
title: r.title ?? r.company,
|
|
156
|
-
owner: r.owner ?? r.assigned_to ??
|
|
159
|
+
owner: r.owner ?? r.assigned_to ?? operatorId ?? null,
|
|
157
160
|
closed: r.last_contact ? r.last_contact.substring(0, 10) : '—',
|
|
158
161
|
value: typeof r.value === 'string' ? parseFloat(r.value) || 0 : (r.value ?? 0),
|
|
159
162
|
}));
|
|
@@ -227,13 +230,17 @@ function handleAction(deal) {
|
|
|
227
230
|
|
|
228
231
|
const SALES_RESET_FACET = 'f:open-pipeline';
|
|
229
232
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
/** operatorId-parameterized so 'f:my-deals' fails safe (matches nothing) when
|
|
234
|
+
* the operator is unknown — see lib/operator.js. */
|
|
235
|
+
function salesFacetPredicates(operatorId) {
|
|
236
|
+
return {
|
|
237
|
+
'f:my-deals': (d) => isMine(d.owner, operatorId) || isMine(d.assigned_to, operatorId),
|
|
238
|
+
'f:closing-soon': (d) => d.next_action_mode === 'approve',
|
|
239
|
+
'f:agent-run': (d) => d.owner === 'sales-agent' || d.assigned_to === 'sales-agent',
|
|
240
|
+
...Object.fromEntries(STAGES.map((s) => [`f:stage:${s}`, (d) => d.stage === s])),
|
|
241
|
+
// 'f:open-pipeline' intentionally ABSENT → SALES_RESET_FACET returns every deal.
|
|
242
|
+
};
|
|
243
|
+
}
|
|
237
244
|
|
|
238
245
|
// ─── Menu builder ─────────────────────────────────────────────────────────────
|
|
239
246
|
|
|
@@ -242,10 +249,11 @@ const FACET_PREDICATES = {
|
|
|
242
249
|
* activeView: 0 = Pipeline | 1 = Forecast | 2 = Won
|
|
243
250
|
* pipeMode: 'list' | 'kanban' (only relevant when activeView === 0)
|
|
244
251
|
* deals: the open deals array (for counts)
|
|
252
|
+
* operatorId: current known operator id (null when unknown — see lib/operator.js)
|
|
245
253
|
*/
|
|
246
|
-
function buildSalesMenu(activeView, pipeMode, deals, activeFacet) {
|
|
254
|
+
function buildSalesMenu(activeView, pipeMode, deals, activeFacet, operatorId) {
|
|
247
255
|
const openCount = deals.length;
|
|
248
|
-
const myDeals = deals.filter((d) => d.owner
|
|
256
|
+
const myDeals = deals.filter((d) => isMine(d.owner, operatorId) || isMine(d.assigned_to, operatorId));
|
|
249
257
|
const closingSoon = deals.filter((d) => d.next_action_mode === 'approve');
|
|
250
258
|
const agentRun = deals.filter((d) => (d.owner === 'sales-agent' || d.assigned_to === 'sales-agent'));
|
|
251
259
|
// A facet only highlights on the Pipeline view (facets dim/inert off it).
|
|
@@ -787,7 +795,7 @@ function ErrorState({ error, onRetry }) {
|
|
|
787
795
|
|
|
788
796
|
// ─── SalesView root ───────────────────────────────────────────────────────────
|
|
789
797
|
|
|
790
|
-
export function SalesView({ activeFeature }) {
|
|
798
|
+
export function SalesView({ activeFeature, operatorId }) {
|
|
791
799
|
// View state: 0=Pipeline | 1=Forecast | 2=Won
|
|
792
800
|
const [activeView, setActiveView] = useState(() => {
|
|
793
801
|
const p = new URLSearchParams(window.location.search).get('view');
|
|
@@ -803,7 +811,11 @@ export function SalesView({ activeFeature }) {
|
|
|
803
811
|
|
|
804
812
|
// ── Data queries ────────────────────────────────────────────────────────────
|
|
805
813
|
const openDealsQ = useQuery({ queryKey: QK.openDeals, queryFn: fetchOpenDeals });
|
|
806
|
-
const wonDealsQ = useQuery({
|
|
814
|
+
const wonDealsQ = useQuery({
|
|
815
|
+
queryKey: QK.wonDeals,
|
|
816
|
+
queryFn: () => fetchWonDeals(operatorId),
|
|
817
|
+
enabled: activeView === 2,
|
|
818
|
+
});
|
|
807
819
|
|
|
808
820
|
const activitiesQ = useQuery({
|
|
809
821
|
queryKey: QK.activities(selectedDeal?.id ?? null),
|
|
@@ -818,8 +830,8 @@ export function SalesView({ activeFeature }) {
|
|
|
818
830
|
// nothing shows the empty pane, not a stale full list). Badges stay live
|
|
819
831
|
// because buildSalesMenu counts the FULL `deals`, not this slice.
|
|
820
832
|
const visibleDeals = useMemo(
|
|
821
|
-
() => applyFacet(deals, activeFacet,
|
|
822
|
-
[deals, activeFacet],
|
|
833
|
+
() => applyFacet(deals, activeFacet, salesFacetPredicates(operatorId), SALES_RESET_FACET),
|
|
834
|
+
[deals, activeFacet, operatorId],
|
|
823
835
|
);
|
|
824
836
|
|
|
825
837
|
// ── db-updated refresh ──────────────────────────────────────────────────────
|
|
@@ -859,9 +871,9 @@ export function SalesView({ activeFeature }) {
|
|
|
859
871
|
// ── setMenu publish ─────────────────────────────────────────────────────────
|
|
860
872
|
useEffect(() => {
|
|
861
873
|
if (isStandalone()) return;
|
|
862
|
-
const items = buildSalesMenu(activeView, pipeMode, deals, activeFacet);
|
|
874
|
+
const items = buildSalesMenu(activeView, pipeMode, deals, activeFacet, operatorId);
|
|
863
875
|
setMenu(items).catch(() => {/* ignore */});
|
|
864
|
-
}, [activeView, pipeMode, deals, activeFacet]);
|
|
876
|
+
}, [activeView, pipeMode, deals, activeFacet, operatorId]);
|
|
865
877
|
|
|
866
878
|
// ── Stage change mutation (kanban drag) ─────────────────────────────────────
|
|
867
879
|
const stageChange = useMutation({
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// operator.js — operator-identity helpers shared by every no-build domain app
|
|
2
|
+
// pkg (sales/research/content/strategy). SOURCE OF TRUTH — vendored
|
|
3
|
+
// byte-identically into each pkg's dist/lib/operator.js via vendorRuntime()
|
|
4
|
+
// (add 'operator' to that pkg's build.mjs `files` list). Never hand-edit a
|
|
5
|
+
// vendored copy; edit here and re-run the pkg's `scripts/build.mjs`.
|
|
6
|
+
//
|
|
7
|
+
// hostContext.operator is OPTIONAL (see @ikenga/contract's host-context.ts —
|
|
8
|
+
// IkengaHostContextExtensions.operator: OperatorIdentity | undefined): absent
|
|
9
|
+
// means an UNKNOWN operator, not a default one. Every helper below fails safe
|
|
10
|
+
// on a null/undefined operatorId — "mine" matches nothing, "other owner"
|
|
11
|
+
// defaults to true (never mislabels an unclaimed row as the human), and
|
|
12
|
+
// initials degrade to a neutral glyph rather than guessing an identity.
|
|
13
|
+
//
|
|
14
|
+
// This module is host/bridge-agnostic on purpose (pure functions only, no
|
|
15
|
+
// import from bridge.js): callers thread the plain `operatorId` string (or
|
|
16
|
+
// null) down from app.js's `connectBridge()` ctx.operator.id, the same way
|
|
17
|
+
// `activeFeature` is already threaded from ctx.royaltiSuite.activeFeature.
|
|
18
|
+
|
|
19
|
+
/** True only when `value` names the CURRENT known operator. An unknown
|
|
20
|
+
* operator (`operatorId` null/undefined) never matches — a "mine" filter or
|
|
21
|
+
* badge count degrades to empty, never to "everyone" or "no one knows". */
|
|
22
|
+
export function isMine(value, operatorId) {
|
|
23
|
+
return operatorId != null && value === operatorId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when `value` names an owner other than the current operator — the
|
|
27
|
+
* complement used for "agent-tracked" grouping and is-agent avatar styling.
|
|
28
|
+
* An unknown operator makes every named owner read as "other" (we can never
|
|
29
|
+
* be sure an unclaimed value is the human), so this defaults to true rather
|
|
30
|
+
* than false when `operatorId` is null. */
|
|
31
|
+
export function isOtherOwner(value, operatorId) {
|
|
32
|
+
return value != null && value !== operatorId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Single-glyph label for an avatar/initial badge. Falls back to '?' rather
|
|
36
|
+
* than guessing when the label is missing or empty. */
|
|
37
|
+
export function initialOf(label) {
|
|
38
|
+
return typeof label === 'string' && label.length > 0 ? label[0].toUpperCase() : '?';
|
|
39
|
+
}
|
package/manifest.json
CHANGED
package/package.json
CHANGED