@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 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 ?? 'nedjamez',
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
- const FACET_PREDICATES = {
231
- 'f:my-deals': (d) => d.owner === 'nedjamez' || d.assigned_to === 'nedjamez',
232
- 'f:closing-soon': (d) => d.next_action_mode === 'approve',
233
- 'f:agent-run': (d) => d.owner === 'sales-agent' || d.assigned_to === 'sales-agent',
234
- ...Object.fromEntries(STAGES.map((s) => [`f:stage:${s}`, (d) => d.stage === s])),
235
- // 'f:open-pipeline' intentionally ABSENT SALES_RESET_FACET returns every deal.
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 === 'nedjamez' || d.assigned_to === 'nedjamez');
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({ queryKey: QK.wonDeals, queryFn: fetchWonDeals, enabled: activeView === 2 });
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, FACET_PREDICATES, SALES_RESET_FACET),
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "com.ikenga.sales",
3
3
  "name": "Sales",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "ikenga_api": "1",
6
6
  "kind": "embedded",
7
7
  "author": { "name": "Royalti", "key": "royalti" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikenga/pkg-sales",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sales — Pipeline, Forecast, Won over the production sales schema. Multi-file iframe pkg; deterministic CSS vendoring build.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {