nucleus-core-ts 0.10.115 → 0.10.117

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/.build-ok CHANGED
@@ -1 +1 @@
1
- 0.10.115
1
+ 0.10.117
@@ -9,6 +9,9 @@ interface PendingTabProps {
9
9
  onDecisionMade?: (entityName: string, entityId: string) => void;
10
10
  /** Spread over the English defaults; omit it and the tab stays English. */
11
11
  labels?: Partial<VerificationFlowLabels>;
12
+ /** Names the record a decision is about. See `VerificationFlowPageProps`. */
13
+ describeEntity?: (entityName: string, entityId: string) => string | undefined;
14
+ entityHref?: (entityName: string, entityId: string) => string | undefined;
12
15
  }
13
- export declare function PendingTab({ pendingAction, decideAction, uploadSignatureAction, onDecisionMade, labels, }: PendingTabProps): ReactElement;
16
+ export declare function PendingTab({ pendingAction, decideAction, uploadSignatureAction, onDecisionMade, labels, describeEntity, entityHref, }: PendingTabProps): ReactElement;
14
17
  export {};
@@ -5,11 +5,26 @@ import { DEFAULT_VERIFICATION_FLOW_LABELS } from '../labels';
5
5
  import { useVerificationFlowStore } from '../store';
6
6
  import { getActiveTheme } from '../theme/store';
7
7
  import { planDecision, signatureFileType } from './decisionPlan';
8
- export function PendingTab({ pendingAction, decideAction, uploadSignatureAction, onDecisionMade, labels }) {
8
+ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction, onDecisionMade, labels, describeEntity, entityHref }) {
9
9
  // Local, not in the store: only one card is in deciding mode at a time, and
10
10
  // a chosen file must not survive cancelling out of that card.
11
11
  const [signatureFile, setSignatureFile] = useState(null);
12
- const [signatureNote, setSignatureNote] = useState(null);
12
+ /*
13
+ * Renamed from `signatureNote`, and that is the whole bug.
14
+ *
15
+ * It carried every engine refusal — wrong role, already signed an earlier
16
+ * step, account switched off — but its ONE render site sat inside the
17
+ * `require_signature` branch. On a step that needs no signature (the
18
+ * ordinary case) pressing Approve wrote the refusal into state that nothing
19
+ * rendered: the card stayed open, nothing was recorded, and the reviewer was
20
+ * told nothing at all. It is now its own always-rendered block.
21
+ */ const [decisionNote, setDecisionNote] = useState(null);
22
+ /*
23
+ * A queue that could not be READ is not an empty queue. Both failure paths
24
+ * used to call `setPendingItems([])`, so a 500, an expired session and a
25
+ * genuinely clear queue all rendered the same reassuring tick and
26
+ * "No pending verifications".
27
+ */ const [loadError, setLoadError] = useState(null);
13
28
  const theme = getActiveTheme();
14
29
  const say = {
15
30
  ...DEFAULT_VERIFICATION_FLOW_LABELS,
@@ -18,6 +33,7 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
18
33
  const store = useVerificationFlowStore();
19
34
  const loadPending = useEffectEvent(()=>{
20
35
  store.setLoadingPending(true);
36
+ setLoadError(null);
21
37
  pendingAction.start({
22
38
  payload: undefined,
23
39
  onAfterHandle: (res)=>{
@@ -25,6 +41,7 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
25
41
  if (response.success === false) {
26
42
  console.error('[PendingTab] API returned success=false:', res);
27
43
  store.setPendingItems([]);
44
+ setLoadError(response.message || say.pendingLoadFailedBody);
28
45
  store.setLoadingPending(false);
29
46
  return;
30
47
  }
@@ -36,6 +53,7 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
36
53
  const msg = errObj?.message || errObj?.error || JSON.stringify(err);
37
54
  console.error(`[PendingTab] Pending error (HTTP ${code ?? '?'}):`, msg);
38
55
  store.setPendingItems([]);
56
+ setLoadError(typeof msg === 'string' ? msg : say.pendingLoadFailedBody);
39
57
  store.setLoadingPending(false);
40
58
  }
41
59
  });
@@ -72,19 +90,19 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
72
90
  * already says exactly what is wrong, and a reviewer can act on
73
91
  * "cannot decide it" in a way they cannot act on "something failed".
74
92
  */ if (res && res.success === false) {
75
- setSignatureNote(res.message || say.decisionRefused);
93
+ setDecisionNote(res.message || say.decisionRefused);
76
94
  return;
77
95
  }
78
96
  store.setDecidingId(null);
79
97
  store.setDecisionReason('');
80
98
  setSignatureFile(null);
81
- setSignatureNote(null);
99
+ setDecisionNote(null);
82
100
  onDecisionMade?.(item.entity_name, item.entity_id);
83
101
  loadPending();
84
102
  },
85
103
  onErrorHandle: (err)=>{
86
104
  const msg = err?.message;
87
- setSignatureNote(msg || say.decisionRefused);
105
+ setDecisionNote(msg || say.decisionRefused);
88
106
  }
89
107
  });
90
108
  });
@@ -99,7 +117,7 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
99
117
  * uploaded as the signed-in user and only its id is sent. A rejection needs
100
118
  * no signature: you do not sign what you are refusing.
101
119
  */ const handleDecide = useEffectEvent((item, decision)=>{
102
- setSignatureNote(null);
120
+ setDecisionNote(null);
103
121
  const plan = planDecision({
104
122
  decision,
105
123
  requireSignature: item.require_signature,
@@ -111,7 +129,7 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
111
129
  return;
112
130
  }
113
131
  if (plan.kind === 'blocked') {
114
- setSignatureNote(plan.reason === 'no-upload-action' ? say.signatureUnavailable : say.signatureMissing);
132
+ setDecisionNote(plan.reason === 'no-upload-action' ? say.signatureUnavailable : say.signatureMissing);
115
133
  return;
116
134
  }
117
135
  if (!uploadSignatureAction || !signatureFile) return;
@@ -126,7 +144,7 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
126
144
  extension: signatureFile.name.split('.').pop() || '',
127
145
  type: signatureFileType(signatureFile.type)
128
146
  }));
129
- setSignatureNote(say.signatureUploading);
147
+ setDecisionNote(say.signatureUploading);
130
148
  uploadSignatureAction.start({
131
149
  payload: form,
132
150
  onAfterHandle: (res)=>{
@@ -134,12 +152,12 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
134
152
  if (!id) {
135
153
  // Reported, never swallowed: a decision that silently did not happen
136
154
  // is worse than one that failed loudly.
137
- setSignatureNote(say.signatureUploadFailed);
155
+ setDecisionNote(say.signatureUploadFailed);
138
156
  return;
139
157
  }
140
158
  sendDecision(item, decision, id);
141
159
  },
142
- onErrorHandle: ()=>setSignatureNote(say.signatureUploadFailed)
160
+ onErrorHandle: ()=>setDecisionNote(say.signatureUploadFailed)
143
161
  });
144
162
  });
145
163
  if (store.isLoadingPending) {
@@ -150,6 +168,36 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
150
168
  })
151
169
  });
152
170
  }
171
+ /*
172
+ * The failure state comes FIRST, and it is a different screen from the
173
+ * empty one. "You have nothing to approve" is a fact a reviewer acts on by
174
+ * going away; "we could not ask" is one they act on by trying again.
175
+ */ if (loadError) {
176
+ return /*#__PURE__*/ _jsxs("div", {
177
+ className: theme.pending.failure.container,
178
+ role: "alert",
179
+ children: [
180
+ /*#__PURE__*/ _jsx("p", {
181
+ className: theme.pending.failure.title,
182
+ children: say.pendingLoadFailedTitle
183
+ }),
184
+ /*#__PURE__*/ _jsx("p", {
185
+ className: theme.pending.failure.text,
186
+ children: say.pendingLoadFailedBody
187
+ }),
188
+ /*#__PURE__*/ _jsx("p", {
189
+ className: theme.pending.failure.text,
190
+ children: loadError
191
+ }),
192
+ /*#__PURE__*/ _jsx("button", {
193
+ type: "button",
194
+ onClick: ()=>loadPending(),
195
+ className: theme.pending.failure.button,
196
+ children: say.retry
197
+ })
198
+ ]
199
+ });
200
+ }
153
201
  if (store.pendingItems.length === 0) {
154
202
  return /*#__PURE__*/ _jsxs("div", {
155
203
  className: theme.pending.empty.container,
@@ -185,89 +233,109 @@ export function PendingTab({ pendingAction, decideAction, uploadSignatureAction,
185
233
  className: theme.pending.title,
186
234
  children: say.pendingApprovals(store.pendingItems.length)
187
235
  }),
188
- store.pendingItems.map((item)=>/*#__PURE__*/ _jsx("div", {
236
+ store.pendingItems.map((item)=>/*#__PURE__*/ _jsxs("div", {
189
237
  className: theme.pending.card.container,
190
- children: /*#__PURE__*/ _jsxs("div", {
191
- className: "flex items-center justify-between",
192
- children: [
193
- /*#__PURE__*/ _jsxs("div", {
194
- children: [
195
- /*#__PURE__*/ _jsx("p", {
196
- className: theme.pending.card.title,
197
- children: say.pendingCardTitle(item.flow_name, item.step_order)
198
- }),
199
- /*#__PURE__*/ _jsxs("p", {
200
- className: theme.pending.card.subtitle,
201
- children: [
202
- item.entity_name,
203
- "/",
204
- item.entity_id
205
- ]
206
- })
207
- ]
208
- }),
209
- store.decidingId === item.instance_id ? /*#__PURE__*/ _jsxs("div", {
210
- className: theme.pending.card.actions,
211
- children: [
212
- item.require_signature ? /*#__PURE__*/ _jsxs("label", {
213
- className: "flex flex-col gap-1 text-xs",
214
- children: [
215
- /*#__PURE__*/ _jsx("span", {
216
- children: say.signaturePrompt
217
- }),
218
- /*#__PURE__*/ _jsx("input", {
219
- type: "file",
220
- onChange: (e)=>{
221
- setSignatureFile(e.target.files?.[0] ?? null);
222
- setSignatureNote(null);
223
- },
224
- className: theme.pending.input
225
- }),
226
- signatureNote ? /*#__PURE__*/ _jsx("span", {
227
- className: "text-red-600",
228
- children: signatureNote
229
- }) : null
230
- ]
231
- }) : null,
232
- /*#__PURE__*/ _jsx("input", {
233
- type: "text",
234
- placeholder: say.reasonPlaceholder,
235
- value: store.decisionReason,
236
- onChange: (e)=>store.setDecisionReason(e.target.value),
237
- className: theme.pending.input
238
- }),
239
- /*#__PURE__*/ _jsx("button", {
240
- type: "button",
241
- onClick: ()=>handleDecide(item, 'approved'),
242
- className: theme.pending.approveButton,
243
- children: say.approve
244
- }),
245
- /*#__PURE__*/ _jsx("button", {
246
- type: "button",
247
- onClick: ()=>handleDecide(item, 'rejected'),
248
- className: theme.pending.rejectButton,
249
- children: say.reject
250
- }),
251
- /*#__PURE__*/ _jsx("button", {
252
- type: "button",
253
- onClick: ()=>{
254
- store.setDecidingId(null);
255
- store.setDecisionReason('');
256
- setSignatureFile(null);
257
- setSignatureNote(null);
258
- },
259
- className: theme.pending.cancelButton,
260
- children: say.cancel
261
- })
262
- ]
263
- }) : /*#__PURE__*/ _jsx("button", {
264
- type: "button",
265
- onClick: ()=>store.setDecidingId(item.instance_id),
266
- className: theme.pending.reviewButton,
267
- children: say.review
268
- })
269
- ]
270
- })
238
+ children: [
239
+ /*#__PURE__*/ _jsxs("div", {
240
+ className: "flex flex-wrap items-start justify-between gap-3",
241
+ children: [
242
+ /*#__PURE__*/ _jsxs("div", {
243
+ className: "min-w-0",
244
+ children: [
245
+ /*#__PURE__*/ _jsx("p", {
246
+ className: theme.pending.card.title,
247
+ children: say.pendingCardTitle(item.flow_name, item.step_order)
248
+ }),
249
+ item.step_name ? /*#__PURE__*/ _jsx("p", {
250
+ className: theme.pending.card.subtitle,
251
+ children: item.step_name
252
+ }) : null,
253
+ /*#__PURE__*/ _jsxs("span", {
254
+ className: theme.pending.card.recordRow,
255
+ children: [
256
+ /*#__PURE__*/ _jsx("span", {
257
+ className: theme.pending.card.recordLabel,
258
+ children: say.pendingRecordLabel
259
+ }),
260
+ /*#__PURE__*/ _jsx("span", {
261
+ className: theme.pending.card.recordValue,
262
+ children: describeEntity?.(item.entity_name, item.entity_id) ?? `${item.entity_name}/${item.entity_id}`
263
+ }),
264
+ entityHref?.(item.entity_name, item.entity_id) ? /*#__PURE__*/ _jsx("a", {
265
+ href: entityHref(item.entity_name, item.entity_id),
266
+ target: "_blank",
267
+ rel: "noopener noreferrer",
268
+ className: theme.pending.card.recordLink,
269
+ children: say.openRecord
270
+ }) : null
271
+ ]
272
+ })
273
+ ]
274
+ }),
275
+ store.decidingId === item.instance_id ? /*#__PURE__*/ _jsxs("div", {
276
+ className: theme.pending.card.actions,
277
+ children: [
278
+ item.require_signature ? /*#__PURE__*/ _jsxs("label", {
279
+ className: "flex flex-col gap-1 text-xs",
280
+ children: [
281
+ /*#__PURE__*/ _jsx("span", {
282
+ children: say.signaturePrompt
283
+ }),
284
+ /*#__PURE__*/ _jsx("input", {
285
+ type: "file",
286
+ onChange: (e)=>{
287
+ setSignatureFile(e.target.files?.[0] ?? null);
288
+ setDecisionNote(null);
289
+ },
290
+ className: theme.pending.input
291
+ })
292
+ ]
293
+ }) : null,
294
+ /*#__PURE__*/ _jsx("input", {
295
+ type: "text",
296
+ placeholder: say.reasonPlaceholder,
297
+ value: store.decisionReason,
298
+ onChange: (e)=>store.setDecisionReason(e.target.value),
299
+ className: theme.pending.input
300
+ }),
301
+ /*#__PURE__*/ _jsx("button", {
302
+ type: "button",
303
+ onClick: ()=>handleDecide(item, 'approved'),
304
+ className: theme.pending.approveButton,
305
+ children: say.approve
306
+ }),
307
+ /*#__PURE__*/ _jsx("button", {
308
+ type: "button",
309
+ onClick: ()=>handleDecide(item, 'rejected'),
310
+ className: theme.pending.rejectButton,
311
+ children: say.reject
312
+ }),
313
+ /*#__PURE__*/ _jsx("button", {
314
+ type: "button",
315
+ onClick: ()=>{
316
+ store.setDecidingId(null);
317
+ store.setDecisionReason('');
318
+ setSignatureFile(null);
319
+ setDecisionNote(null);
320
+ },
321
+ className: theme.pending.cancelButton,
322
+ children: say.cancel
323
+ })
324
+ ]
325
+ }) : /*#__PURE__*/ _jsx("button", {
326
+ type: "button",
327
+ onClick: ()=>store.setDecidingId(item.instance_id),
328
+ className: theme.pending.reviewButton,
329
+ children: say.review
330
+ })
331
+ ]
332
+ }),
333
+ store.decidingId === item.instance_id && decisionNote ? /*#__PURE__*/ _jsx("p", {
334
+ className: theme.pending.notice,
335
+ role: "alert",
336
+ children: decisionNote
337
+ }) : null
338
+ ]
271
339
  }, `${item.instance_id}-${item.step_order}`))
272
340
  ]
273
341
  });
@@ -34,6 +34,8 @@ interface PropertiesPanelProps {
34
34
  panelTheme: VerificationFlowPageTheme['propertiesPanel'];
35
35
  /** Spread over the English defaults; omit it and the panel stays English. */
36
36
  labels?: Partial<VerificationFlowLabels>;
37
+ /** The channels this install can deliver on. Omit = all five, as before. */
38
+ availableChannels?: NotificationChannel[];
37
39
  }
38
- export declare function PropertiesPanel({ nodeId, nodeData, onUpdateNode, onDeleteNode, onClose, listUsersAction, listRolesAction, panelTheme, labels, }: PropertiesPanelProps): ReactElement;
40
+ export declare function PropertiesPanel({ nodeId, nodeData, onUpdateNode, onDeleteNode, onClose, listUsersAction, listRolesAction, panelTheme, labels, availableChannels, }: PropertiesPanelProps): ReactElement;
39
41
  export {};
@@ -28,10 +28,11 @@ const ALL_RECIPIENT_TYPES = [
28
28
  'user'
29
29
  ];
30
30
  // ─── Channel Multi-Select ─────────────────────────────────────────
31
- function ChannelMultiSelect({ selected, onChange, msTheme, say }) {
31
+ function ChannelMultiSelect({ selected, onChange, msTheme, say, available }) {
32
32
  const theme = msTheme;
33
33
  const channelLabels = notificationChannelLabels(say);
34
34
  const [isOpen, setIsOpen] = useState(false);
35
+ const canDeliver = (ch)=>!available || available.includes(ch);
35
36
  const toggle = (channel)=>{
36
37
  if (selected.includes(channel)) {
37
38
  onChange(selected.filter((c)=>c !== channel));
@@ -91,15 +92,28 @@ function ChannelMultiSelect({ selected, onChange, msTheme, say }) {
91
92
  }),
92
93
  isOpen && /*#__PURE__*/ _jsx("div", {
93
94
  className: theme.dropdown,
94
- children: ALL_CHANNELS.map((ch)=>/*#__PURE__*/ _jsxs("button", {
95
+ children: ALL_CHANNELS.map((ch)=>{
96
+ /*
97
+ * A channel this install cannot deliver on is shown and refused,
98
+ * not hidden: the author should see that SMS exists as a product
99
+ * feature and that THIS deployment does not have it, which is a
100
+ * different thing from it not existing at all.
101
+ */ const deliverable = canDeliver(ch);
102
+ return /*#__PURE__*/ _jsxs("button", {
95
103
  type: "button",
96
- className: selected.includes(ch) ? theme.optionSelected : theme.option,
97
- onClick: ()=>toggle(ch),
104
+ disabled: !deliverable,
105
+ title: deliverable ? undefined : say.channelUnavailable,
106
+ className: `${selected.includes(ch) ? theme.optionSelected : theme.option}${deliverable ? '' : ' opacity-40 cursor-not-allowed'}`,
107
+ onClick: ()=>{
108
+ if (deliverable) toggle(ch);
109
+ },
98
110
  children: [
99
111
  selected.includes(ch) ? '✓ ' : '',
100
- channelLabels[ch] ?? ch
112
+ channelLabels[ch] ?? ch,
113
+ deliverable ? '' : ` — ${say.channelUnavailable}`
101
114
  ]
102
- }, ch))
115
+ }, ch);
116
+ })
103
117
  })
104
118
  ]
105
119
  });
@@ -420,7 +434,7 @@ function RoleSearchList({ listRolesAction, selectedRole, onSelect, ursTheme, say
420
434
  });
421
435
  }
422
436
  // ─── Main Properties Panel ────────────────────────────────────────
423
- export function PropertiesPanel({ nodeId, nodeData, onUpdateNode, onDeleteNode, onClose, listUsersAction, listRolesAction, panelTheme, labels }) {
437
+ export function PropertiesPanel({ nodeId, nodeData, onUpdateNode, onDeleteNode, onClose, listUsersAction, listRolesAction, panelTheme, labels, availableChannels }) {
424
438
  const theme = panelTheme;
425
439
  const say = {
426
440
  ...DEFAULT_VERIFICATION_FLOW_LABELS,
@@ -477,6 +491,27 @@ export function PropertiesPanel({ nodeId, nodeData, onUpdateNode, onDeleteNode,
477
491
  /*#__PURE__*/ _jsxs("div", {
478
492
  className: theme.body,
479
493
  children: [
494
+ /*#__PURE__*/ _jsxs("p", {
495
+ className: theme.help,
496
+ children: [
497
+ nodeType === 'step' && say.stepHelp,
498
+ nodeType === 'verifier' && say.verifierHelp,
499
+ nodeType === 'notification' && say.notificationHelp
500
+ ]
501
+ }),
502
+ nodeType === 'step' && /* The one fact a step carries at runtime, and the operator cannot
503
+ * type it: it is derived from the arrows when the flow is saved. */ /*#__PURE__*/ _jsxs("p", {
504
+ className: theme.readOnlyRow,
505
+ children: [
506
+ /*#__PURE__*/ _jsx("span", {
507
+ children: say.stepOrderLabel
508
+ }),
509
+ /*#__PURE__*/ _jsx("span", {
510
+ className: theme.readOnlyValue,
511
+ children: Number(data.stepOrder) > 0 ? data.stepOrder : say.stepOrderUnset
512
+ })
513
+ ]
514
+ }),
480
515
  /*#__PURE__*/ _jsxs("div", {
481
516
  className: theme.section,
482
517
  children: [
@@ -677,6 +712,7 @@ export function PropertiesPanel({ nodeId, nodeData, onUpdateNode, onDeleteNode,
677
712
  onChange: (channels)=>update({
678
713
  channels
679
714
  }),
715
+ available: availableChannels,
680
716
  msTheme: theme.multiSelect,
681
717
  say: say
682
718
  })
@@ -1,4 +1,4 @@
1
1
  import '@xyflow/react/dist/style.css';
2
2
  import type { ReactElement } from 'react';
3
3
  import type { VerificationFlowPageProps } from '../types';
4
- export declare function VerificationFlowPage({ entityName, title, subtitle, flowListAction, flowGetAction, flowSaveAction, flowPublishAction, flowUnpublishAction, flowDeleteAction, pendingAction, decideAction, uploadSignatureAction, listUsersAction, listRolesAction, entityStatusesAction, onFlowSelected, onDecisionMade, onEntityClick, onBack, showPending, showEntityStatuses, className, themeMode, labels, }: VerificationFlowPageProps): ReactElement;
4
+ export declare function VerificationFlowPage({ entityName, title, subtitle, flowListAction, flowGetAction, flowSaveAction, flowPublishAction, flowUnpublishAction, flowDeleteAction, pendingAction, decideAction, uploadSignatureAction, listUsersAction, listRolesAction, entityStatusesAction, onFlowSelected, onDecisionMade, onEntityClick, onBack, showPending, showEntityStatuses, className, themeMode, labels, describeEntity, entityHref, availableChannels, initialTab, }: VerificationFlowPageProps): ReactElement;
@@ -149,7 +149,19 @@ function createNewNode(nodeType, position, say) {
149
149
  base.data = {
150
150
  ...base.data,
151
151
  trigger: 'on_step_reached',
152
- channels: []
152
+ channels: [
153
+ 'portal'
154
+ ],
155
+ /*
156
+ * The recipient the panel ALREADY shows.
157
+ *
158
+ * The properties panel renders `data.recipientType || 'all_verifiers'`,
159
+ * so a node nobody opened read "All Verifiers" while the field was
160
+ * undefined — and `buildSavePayload` only writes a recipient row when
161
+ * the field is set. The result was a notification node that drew
162
+ * correctly, saved, published green and reached nobody, with no error
163
+ * anywhere. Seeding it makes the screen's claim true.
164
+ */ recipientType: 'all_verifiers'
153
165
  };
154
166
  }
155
167
  return base;
@@ -252,14 +264,19 @@ function buildSavePayload(flowId, entityName, flowName, flowDescription, trigger
252
264
  channel: ch
253
265
  });
254
266
  }
255
- if (d.recipientType) {
256
- notification_recipients.push({
257
- rule_id: n.id,
258
- recipient_type: d.recipientType,
259
- recipient_user_id: d.recipientUserId,
260
- recipient_role: d.recipientRole
261
- });
262
- }
267
+ /*
268
+ * ALWAYS a recipient row, defaulting to what the panel displays.
269
+ *
270
+ * `if (d.recipientType)` skipped the row for every node the operator
271
+ * never opened, which is every node they dropped and wired without
272
+ * thinking about it — the commonest way to build a flow. A rule with no
273
+ * recipient resolves to an empty list and the send loop never runs.
274
+ */ notification_recipients.push({
275
+ rule_id: n.id,
276
+ recipient_type: d.recipientType || 'all_verifiers',
277
+ recipient_user_id: d.recipientUserId,
278
+ recipient_role: d.recipientRole
279
+ });
263
280
  }
264
281
  return {
265
282
  flow_id: flowId,
@@ -280,7 +297,7 @@ function buildSavePayload(flowId, entityName, flowName, flowDescription, trigger
280
297
  };
281
298
  }
282
299
  // ─── Main Component ───────────────────────────────────────────────
283
- export function VerificationFlowPage({ entityName, title, subtitle, flowListAction, flowGetAction, flowSaveAction, flowPublishAction, flowUnpublishAction, flowDeleteAction, pendingAction, decideAction, uploadSignatureAction, listUsersAction, listRolesAction, entityStatusesAction, onFlowSelected, onDecisionMade, onEntityClick, onBack, showPending = true, showEntityStatuses = false, className, themeMode = 'dark', labels }) {
300
+ export function VerificationFlowPage({ entityName, title, subtitle, flowListAction, flowGetAction, flowSaveAction, flowPublishAction, flowUnpublishAction, flowDeleteAction, pendingAction, decideAction, uploadSignatureAction, listUsersAction, listRolesAction, entityStatusesAction, onFlowSelected, onDecisionMade, onEntityClick, onBack, showPending = true, showEntityStatuses = false, className, themeMode = 'dark', labels, describeEntity, entityHref, availableChannels, initialTab }) {
284
301
  const theme = getVerificationFlowTheme(themeMode);
285
302
  setActiveTheme(theme);
286
303
  const say = {
@@ -626,6 +643,15 @@ export function VerificationFlowPage({ entityName, title, subtitle, flowListActi
626
643
  useEffect(()=>{
627
644
  loadFlows();
628
645
  }, []);
646
+ /*
647
+ * Open where the host said, once, on mount. The store is module-level and
648
+ * survives navigation, so this is also what stops a reviewer who came from
649
+ * a notification landing on whatever tab they happened to leave open.
650
+ */ useEffect(()=>{
651
+ if (initialTab) store.setActiveTab(initialTab);
652
+ }, [
653
+ initialTab
654
+ ]);
629
655
  useEffect(()=>{
630
656
  if (store.selectedFlowId) {
631
657
  loadFlowDetail(store.selectedFlowId);
@@ -887,7 +913,7 @@ export function VerificationFlowPage({ entityName, title, subtitle, flowListActi
887
913
  children: viewMode === 'designer' && store.activeTab === 'flow' && /*#__PURE__*/ _jsxs(_Fragment, {
888
914
  children: [
889
915
  /*#__PURE__*/ _jsx("span", {
890
- className: "text-[11px] text-white/50 font-medium",
916
+ className: cn('text-[11px] font-medium', themeMode === 'light' ? 'text-gray-600' : 'text-white/50'),
891
917
  children: flowName || say.untitledFlow
892
918
  }),
893
919
  selectedFlow?.is_draft !== undefined && /*#__PURE__*/ _jsx("span", {
@@ -1127,6 +1153,19 @@ export function VerificationFlowPage({ entityName, title, subtitle, flowListActi
1127
1153
  isDraggingOver && /*#__PURE__*/ _jsx("div", {
1128
1154
  className: theme.canvas.dropIndicator
1129
1155
  }),
1156
+ !store.isLoadingGraph && nodes.length === 0 && /*#__PURE__*/ _jsxs("div", {
1157
+ className: theme.canvas.emptyHint,
1158
+ children: [
1159
+ /*#__PURE__*/ _jsx("p", {
1160
+ className: theme.canvas.emptyHintTitle,
1161
+ children: say.canvasEmptyTitle
1162
+ }),
1163
+ /*#__PURE__*/ _jsx("p", {
1164
+ className: theme.canvas.emptyHintBody,
1165
+ children: say.canvasEmptyBody
1166
+ })
1167
+ ]
1168
+ }),
1130
1169
  /*#__PURE__*/ _jsxs(ReactFlow, {
1131
1170
  nodes: nodes,
1132
1171
  edges: edges,
@@ -1259,7 +1298,8 @@ export function VerificationFlowPage({ entityName, title, subtitle, flowListActi
1259
1298
  listUsersAction: listUsersAction,
1260
1299
  listRolesAction: listRolesAction,
1261
1300
  panelTheme: theme.propertiesPanel,
1262
- labels: say
1301
+ labels: say,
1302
+ availableChannels: availableChannels
1263
1303
  })
1264
1304
  })
1265
1305
  ]
@@ -1271,7 +1311,9 @@ export function VerificationFlowPage({ entityName, title, subtitle, flowListActi
1271
1311
  decideAction: decideAction,
1272
1312
  uploadSignatureAction: uploadSignatureAction,
1273
1313
  onDecisionMade: onDecisionMade,
1274
- labels: say
1314
+ labels: say,
1315
+ describeEntity: describeEntity,
1316
+ entityHref: entityHref
1275
1317
  })
1276
1318
  }),
1277
1319
  store.activeTab === 'statuses' && showEntityStatuses && entityStatusesAction && /*#__PURE__*/ _jsx("div", {
@@ -122,6 +122,22 @@ export type VerificationFlowLabels = {
122
122
  stepProperties: string;
123
123
  verifierProperties: string;
124
124
  notificationProperties: string;
125
+ /**
126
+ * What each kind of node IS, said where the operator is standing.
127
+ *
128
+ * The owner of this product asked, about his own screen, "what does the
129
+ * step node even do?" — and nothing on it could answer him. One paragraph
130
+ * at the top of each properties panel, plus the derived order a step
131
+ * cannot be given by hand.
132
+ */
133
+ stepHelp: string;
134
+ verifierHelp: string;
135
+ notificationHelp: string;
136
+ stepOrderLabel: string;
137
+ stepOrderUnset: string;
138
+ /** The blank canvas, which said nothing at all. */
139
+ canvasEmptyTitle: string;
140
+ canvasEmptyBody: string;
125
141
  sectionGeneral: string;
126
142
  sectionAssignment: string;
127
143
  sectionOptions: string;
@@ -161,6 +177,21 @@ export type VerificationFlowLabels = {
161
177
  approve: string;
162
178
  reject: string;
163
179
  review: string;
180
+ /**
181
+ * The queue could not be READ. Until this existed the tab rendered the
182
+ * empty state for a failed request, so "you have nothing to approve" and
183
+ * "we could not ask" looked identical — and the second one is the state a
184
+ * reviewer must act on.
185
+ */
186
+ pendingLoadFailedTitle: string;
187
+ pendingLoadFailedBody: string;
188
+ retry: string;
189
+ /** Heading above the record a decision is about. */
190
+ pendingRecordLabel: string;
191
+ /** Opens the record itself, so a reviewer can read what they are signing. */
192
+ openRecord: string;
193
+ /** Shown beside a channel this deployment cannot deliver on. */
194
+ channelUnavailable: string;
164
195
  verificationStatusTitle: string;
165
196
  /** "12 records for orders" — count and entity, both inside the label. */
166
197
  recordCount: (count: number, entityName: string) => string;