@echothink-ui/domain-widgets 0.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/README.md +5 -0
- package/dist/index.cjs +3069 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3956 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +281 -0
- package/dist/index.js +3029 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/index.test.tsx +123 -0
- package/src/index.tsx +3925 -0
- package/src/styles.css +4660 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,3925 @@
|
|
|
1
|
+
import "./styles.css";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { DownloadIcon, PlusIcon, TableIcon } from "@echothink-ui/icons";
|
|
5
|
+
import {
|
|
6
|
+
Badge,
|
|
7
|
+
Button,
|
|
8
|
+
Checkbox,
|
|
9
|
+
LinkButton,
|
|
10
|
+
Select,
|
|
11
|
+
Surface,
|
|
12
|
+
StatusDot,
|
|
13
|
+
TextInput,
|
|
14
|
+
createSurfaceComponent,
|
|
15
|
+
statusLabel,
|
|
16
|
+
type EthAction,
|
|
17
|
+
type EthListItem,
|
|
18
|
+
type EthOperationalStatus,
|
|
19
|
+
type SurfaceComponentProps
|
|
20
|
+
} from "@echothink-ui/core";
|
|
21
|
+
|
|
22
|
+
export type ApprovalWorkflowMode = "sequential" | "parallel" | "conditional";
|
|
23
|
+
|
|
24
|
+
export interface CRMContactPanelProps extends SurfaceComponentProps {
|
|
25
|
+
contacts?: EthListItem[];
|
|
26
|
+
relationshipHealthLabel?: React.ReactNode;
|
|
27
|
+
engagementLabel?: React.ReactNode;
|
|
28
|
+
nextStep?: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SupportTicketQueueProps extends SurfaceComponentProps {
|
|
32
|
+
tickets?: EthListItem[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textFromNode(node: React.ReactNode): string {
|
|
36
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
37
|
+
if (Array.isArray(node)) return node.map(textFromNode).join(" ");
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getContactInitials(label: React.ReactNode) {
|
|
42
|
+
const words = textFromNode(label).trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
if (!words.length) return "CT";
|
|
44
|
+
return words
|
|
45
|
+
.slice(0, 2)
|
|
46
|
+
.map((word) => word.charAt(0).toUpperCase())
|
|
47
|
+
.join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeDomId(value: string) {
|
|
51
|
+
return value.replace(/[^A-Za-z0-9_-]+/g, "-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function splitTicketPriority(label: React.ReactNode) {
|
|
55
|
+
if (typeof label !== "string" && typeof label !== "number") {
|
|
56
|
+
return { priority: undefined, label };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const text = String(label);
|
|
60
|
+
const match = text.match(/^\[(P[0-4])\]\s*(.+)$/i);
|
|
61
|
+
if (!match) return { priority: undefined, label };
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
priority: match[1].toUpperCase(),
|
|
65
|
+
label: match[2]
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function healthTone(status?: EthOperationalStatus) {
|
|
70
|
+
if (status === "blocked" || status === "failed" || status === "stale") return "risk";
|
|
71
|
+
if (status === "warning" || status === "pending-approval" || status === "approval-required") {
|
|
72
|
+
return "watch";
|
|
73
|
+
}
|
|
74
|
+
if (status === "queued" || status === "not-started") return "new";
|
|
75
|
+
return "healthy";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function healthLabel(status?: EthOperationalStatus) {
|
|
79
|
+
if (status === "blocked" || status === "failed" || status === "stale") return "Needs attention";
|
|
80
|
+
if (status === "warning" || status === "pending-approval" || status === "approval-required") {
|
|
81
|
+
return "Monitor closely";
|
|
82
|
+
}
|
|
83
|
+
if (status === "queued" || status === "not-started") return "New relationship";
|
|
84
|
+
return "Healthy";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function CRMContactActions({ actions }: { actions?: EthAction[] }) {
|
|
88
|
+
if (!actions?.length) return null;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="eth-domain-crm-contact-panel__contact-actions">
|
|
92
|
+
{actions.slice(0, 2).map((action) =>
|
|
93
|
+
action.href ? (
|
|
94
|
+
<LinkButton
|
|
95
|
+
key={action.id}
|
|
96
|
+
href={action.disabled ? undefined : action.href}
|
|
97
|
+
intent={action.intent ?? "ghost"}
|
|
98
|
+
density="compact"
|
|
99
|
+
aria-disabled={action.disabled ? true : undefined}
|
|
100
|
+
tabIndex={action.disabled ? -1 : undefined}
|
|
101
|
+
>
|
|
102
|
+
{action.label}
|
|
103
|
+
</LinkButton>
|
|
104
|
+
) : (
|
|
105
|
+
<Button
|
|
106
|
+
key={action.id}
|
|
107
|
+
type="button"
|
|
108
|
+
intent={action.intent ?? "ghost"}
|
|
109
|
+
density="compact"
|
|
110
|
+
disabled={action.disabled}
|
|
111
|
+
onClick={action.onSelect}
|
|
112
|
+
>
|
|
113
|
+
{action.label}
|
|
114
|
+
</Button>
|
|
115
|
+
)
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function SupportTicketActions({ actions }: { actions?: EthAction[] }) {
|
|
122
|
+
if (!actions?.length) return null;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="eth-domain-support-ticket-queue__actions" aria-label="Ticket actions">
|
|
126
|
+
{actions.slice(0, 2).map((action) =>
|
|
127
|
+
action.href ? (
|
|
128
|
+
<LinkButton
|
|
129
|
+
key={action.id}
|
|
130
|
+
href={action.disabled ? undefined : action.href}
|
|
131
|
+
intent={action.intent ?? "ghost"}
|
|
132
|
+
density="compact"
|
|
133
|
+
aria-disabled={action.disabled ? true : undefined}
|
|
134
|
+
tabIndex={action.disabled ? -1 : undefined}
|
|
135
|
+
>
|
|
136
|
+
{action.label}
|
|
137
|
+
</LinkButton>
|
|
138
|
+
) : (
|
|
139
|
+
<Button
|
|
140
|
+
key={action.id}
|
|
141
|
+
type="button"
|
|
142
|
+
intent={action.intent ?? "ghost"}
|
|
143
|
+
density="compact"
|
|
144
|
+
disabled={action.disabled}
|
|
145
|
+
onClick={action.onSelect}
|
|
146
|
+
>
|
|
147
|
+
{action.label}
|
|
148
|
+
</Button>
|
|
149
|
+
)
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function CRMContactPanel({
|
|
156
|
+
title = "Account relationship",
|
|
157
|
+
description,
|
|
158
|
+
status = "active",
|
|
159
|
+
metadata,
|
|
160
|
+
items,
|
|
161
|
+
contacts,
|
|
162
|
+
actions,
|
|
163
|
+
footer,
|
|
164
|
+
children,
|
|
165
|
+
className,
|
|
166
|
+
relationshipHealthLabel,
|
|
167
|
+
engagementLabel,
|
|
168
|
+
nextStep,
|
|
169
|
+
...props
|
|
170
|
+
}: CRMContactPanelProps) {
|
|
171
|
+
const contactItems = contacts ?? items ?? [];
|
|
172
|
+
const contactsHeadingId = React.useId();
|
|
173
|
+
const signalsHeadingId = React.useId();
|
|
174
|
+
const tone = healthTone(status);
|
|
175
|
+
const health = relationshipHealthLabel ?? healthLabel(status);
|
|
176
|
+
const engagement =
|
|
177
|
+
engagementLabel ??
|
|
178
|
+
`${contactItems.length} contact${contactItems.length === 1 ? "" : "s"} mapped`;
|
|
179
|
+
const nextActivity = nextStep ?? "Review next activity";
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Surface
|
|
183
|
+
{...props}
|
|
184
|
+
data-eth-component="CRMContactPanel"
|
|
185
|
+
title={title}
|
|
186
|
+
description={description}
|
|
187
|
+
status={status}
|
|
188
|
+
metadata={metadata}
|
|
189
|
+
actions={actions}
|
|
190
|
+
footer={footer}
|
|
191
|
+
className={["eth-domain-crm-contact-panel", className].filter(Boolean).join(" ")}
|
|
192
|
+
>
|
|
193
|
+
<div className="eth-domain-crm-contact-panel__body">
|
|
194
|
+
<section
|
|
195
|
+
className="eth-domain-crm-contact-panel__contacts"
|
|
196
|
+
aria-labelledby={contactsHeadingId}
|
|
197
|
+
>
|
|
198
|
+
<div className="eth-domain-crm-contact-panel__section-header">
|
|
199
|
+
<div>
|
|
200
|
+
<span>Relationship map</span>
|
|
201
|
+
<h3 id={contactsHeadingId}>Key contacts</h3>
|
|
202
|
+
</div>
|
|
203
|
+
<strong>{engagement}</strong>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{contactItems.length ? (
|
|
207
|
+
<div className="eth-domain-crm-contact-panel__contact-list" role="list">
|
|
208
|
+
{contactItems.map((item) => (
|
|
209
|
+
<article
|
|
210
|
+
key={item.id}
|
|
211
|
+
className="eth-domain-crm-contact-panel__contact"
|
|
212
|
+
role="listitem"
|
|
213
|
+
>
|
|
214
|
+
<span className="eth-domain-crm-contact-panel__avatar" aria-hidden="true">
|
|
215
|
+
{getContactInitials(item.label)}
|
|
216
|
+
</span>
|
|
217
|
+
<div className="eth-domain-crm-contact-panel__contact-main">
|
|
218
|
+
<strong>{item.href ? <a href={item.href}>{item.label}</a> : item.label}</strong>
|
|
219
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
220
|
+
{item.meta ? (
|
|
221
|
+
<span className="eth-domain-crm-contact-panel__contact-meta">
|
|
222
|
+
{item.meta}
|
|
223
|
+
</span>
|
|
224
|
+
) : null}
|
|
225
|
+
</div>
|
|
226
|
+
{item.status || item.actions?.length ? (
|
|
227
|
+
<div className="eth-domain-crm-contact-panel__contact-aside">
|
|
228
|
+
{item.status ? (
|
|
229
|
+
<StatusDot status={item.status} label={statusLabel(item.status)} />
|
|
230
|
+
) : null}
|
|
231
|
+
<CRMContactActions actions={item.actions} />
|
|
232
|
+
</div>
|
|
233
|
+
) : null}
|
|
234
|
+
</article>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
) : (
|
|
238
|
+
<p className="eth-domain-crm-contact-panel__empty">No contacts linked.</p>
|
|
239
|
+
)}
|
|
240
|
+
</section>
|
|
241
|
+
|
|
242
|
+
<aside className="eth-domain-crm-contact-panel__signals" aria-labelledby={signalsHeadingId}>
|
|
243
|
+
<div className="eth-domain-crm-contact-panel__section-header">
|
|
244
|
+
<div>
|
|
245
|
+
<span>Account signals</span>
|
|
246
|
+
<h3 id={signalsHeadingId}>Relationship health</h3>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="eth-domain-crm-contact-panel__signal-list">
|
|
250
|
+
<div className="eth-domain-crm-contact-panel__signal" data-tone={tone}>
|
|
251
|
+
<span>Health</span>
|
|
252
|
+
<strong>{health}</strong>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="eth-domain-crm-contact-panel__signal">
|
|
255
|
+
<span>Coverage</span>
|
|
256
|
+
<strong>{engagement}</strong>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="eth-domain-crm-contact-panel__signal">
|
|
259
|
+
<span>Next step</span>
|
|
260
|
+
<strong>{nextActivity}</strong>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</aside>
|
|
264
|
+
</div>
|
|
265
|
+
{children}
|
|
266
|
+
</Surface>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function SupportTicketQueue({
|
|
271
|
+
title = "Support queue",
|
|
272
|
+
description,
|
|
273
|
+
status,
|
|
274
|
+
metadata,
|
|
275
|
+
items,
|
|
276
|
+
tickets,
|
|
277
|
+
actions,
|
|
278
|
+
footer,
|
|
279
|
+
children,
|
|
280
|
+
className,
|
|
281
|
+
...props
|
|
282
|
+
}: SupportTicketQueueProps) {
|
|
283
|
+
const ticketItems = tickets ?? items ?? [];
|
|
284
|
+
const queueId = React.useId().replace(/:/g, "");
|
|
285
|
+
const blockedCount = ticketItems.filter((item) =>
|
|
286
|
+
["blocked", "failed", "approval-required"].includes(item.status ?? "")
|
|
287
|
+
).length;
|
|
288
|
+
const runningCount = ticketItems.filter((item) =>
|
|
289
|
+
["running", "in-progress"].includes(item.status ?? "")
|
|
290
|
+
).length;
|
|
291
|
+
const nextSla =
|
|
292
|
+
textFromNode(
|
|
293
|
+
ticketItems.find((item) => item.status === "blocked")?.description ??
|
|
294
|
+
ticketItems[0]?.description
|
|
295
|
+
) || "No active SLA";
|
|
296
|
+
|
|
297
|
+
const summary =
|
|
298
|
+
metadata ??
|
|
299
|
+
[
|
|
300
|
+
{ label: "Open tickets", value: ticketItems.length.toLocaleString() },
|
|
301
|
+
{ label: "Blocked", value: blockedCount.toLocaleString() },
|
|
302
|
+
{ label: "In progress", value: runningCount.toLocaleString() },
|
|
303
|
+
{ label: "Next SLA", value: nextSla }
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<Surface
|
|
308
|
+
{...props}
|
|
309
|
+
data-eth-component="SupportTicketQueue"
|
|
310
|
+
title={title}
|
|
311
|
+
description={description}
|
|
312
|
+
status={status}
|
|
313
|
+
metadata={summary}
|
|
314
|
+
actions={actions}
|
|
315
|
+
footer={footer}
|
|
316
|
+
className={["eth-domain-support-ticket-queue", className].filter(Boolean).join(" ")}
|
|
317
|
+
>
|
|
318
|
+
{ticketItems.length ? (
|
|
319
|
+
<div
|
|
320
|
+
className="eth-domain-support-ticket-queue__list"
|
|
321
|
+
role="list"
|
|
322
|
+
aria-label="Support tickets"
|
|
323
|
+
>
|
|
324
|
+
{ticketItems.map((item) => {
|
|
325
|
+
const { priority, label } = splitTicketPriority(item.label);
|
|
326
|
+
const ticketId = `${queueId}-${safeDomId(item.id)}`;
|
|
327
|
+
const labelId = `${ticketId}-label`;
|
|
328
|
+
const slaId = item.description ? `${ticketId}-sla` : undefined;
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<article
|
|
332
|
+
key={item.id}
|
|
333
|
+
className="eth-domain-support-ticket-queue__row"
|
|
334
|
+
data-status={item.status}
|
|
335
|
+
role="listitem"
|
|
336
|
+
aria-labelledby={labelId}
|
|
337
|
+
aria-describedby={slaId}
|
|
338
|
+
>
|
|
339
|
+
<div className="eth-domain-support-ticket-queue__ticket">
|
|
340
|
+
{priority ? (
|
|
341
|
+
<span
|
|
342
|
+
className="eth-domain-support-ticket-queue__priority"
|
|
343
|
+
data-priority={priority}
|
|
344
|
+
>
|
|
345
|
+
{priority}
|
|
346
|
+
</span>
|
|
347
|
+
) : null}
|
|
348
|
+
<strong id={labelId}>
|
|
349
|
+
{item.href ? <a href={item.href}>{label}</a> : label}
|
|
350
|
+
</strong>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{item.description ? (
|
|
354
|
+
<div
|
|
355
|
+
className={[
|
|
356
|
+
"eth-domain-support-ticket-queue__field",
|
|
357
|
+
"eth-domain-support-ticket-queue__field--sla"
|
|
358
|
+
].join(" ")}
|
|
359
|
+
id={slaId}
|
|
360
|
+
>
|
|
361
|
+
<span>SLA</span>
|
|
362
|
+
<strong>{item.description}</strong>
|
|
363
|
+
</div>
|
|
364
|
+
) : null}
|
|
365
|
+
|
|
366
|
+
{item.meta ? (
|
|
367
|
+
<div
|
|
368
|
+
className={[
|
|
369
|
+
"eth-domain-support-ticket-queue__field",
|
|
370
|
+
"eth-domain-support-ticket-queue__field--owner"
|
|
371
|
+
].join(" ")}
|
|
372
|
+
>
|
|
373
|
+
<span>Owner</span>
|
|
374
|
+
<strong>{item.meta}</strong>
|
|
375
|
+
</div>
|
|
376
|
+
) : null}
|
|
377
|
+
|
|
378
|
+
<div className="eth-domain-support-ticket-queue__status">
|
|
379
|
+
{item.status ? (
|
|
380
|
+
<StatusDot status={item.status} label={statusLabel(item.status)} />
|
|
381
|
+
) : null}
|
|
382
|
+
<SupportTicketActions actions={item.actions} />
|
|
383
|
+
</div>
|
|
384
|
+
</article>
|
|
385
|
+
);
|
|
386
|
+
})}
|
|
387
|
+
</div>
|
|
388
|
+
) : (
|
|
389
|
+
<p className="eth-domain-support-ticket-queue__empty">No active support tickets.</p>
|
|
390
|
+
)}
|
|
391
|
+
{children}
|
|
392
|
+
</Surface>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface ApprovalWorkflowStep extends Record<string, unknown> {
|
|
397
|
+
id: string;
|
|
398
|
+
label: string;
|
|
399
|
+
approver?: string;
|
|
400
|
+
condition?: string;
|
|
401
|
+
required?: boolean;
|
|
402
|
+
status?: EthOperationalStatus;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface ComplianceChecklistItem extends EthListItem {
|
|
406
|
+
controlId?: React.ReactNode;
|
|
407
|
+
evidence?: React.ReactNode;
|
|
408
|
+
evidenceHref?: string;
|
|
409
|
+
evidenceLabel?: React.ReactNode;
|
|
410
|
+
owner?: React.ReactNode;
|
|
411
|
+
dueDate?: React.ReactNode;
|
|
412
|
+
updatedAt?: React.ReactNode;
|
|
413
|
+
required?: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface ComplianceChecklistProps extends Omit<SurfaceComponentProps, "items"> {
|
|
417
|
+
items?: ComplianceChecklistItem[];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export type RiskMatrixImpact = "low" | "medium" | "high" | "critical";
|
|
421
|
+
export type RiskMatrixProbability = "low" | "medium" | "high";
|
|
422
|
+
export type RiskMatrixSeverity = "low" | "medium" | "high" | "critical";
|
|
423
|
+
export type RiskMatrixView = "matrix" | "register";
|
|
424
|
+
|
|
425
|
+
export interface RiskMatrixItem extends EthListItem {
|
|
426
|
+
impact?: RiskMatrixImpact;
|
|
427
|
+
probability?: RiskMatrixProbability;
|
|
428
|
+
severity?: RiskMatrixSeverity;
|
|
429
|
+
owner?: React.ReactNode;
|
|
430
|
+
mitigation?: React.ReactNode;
|
|
431
|
+
dueDate?: React.ReactNode;
|
|
432
|
+
score?: React.ReactNode;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface RiskMatrixProps extends Omit<SurfaceComponentProps, "items"> {
|
|
436
|
+
items?: RiskMatrixItem[];
|
|
437
|
+
selectedItemId?: string;
|
|
438
|
+
view?: RiskMatrixView;
|
|
439
|
+
reviewCadence?: React.ReactNode;
|
|
440
|
+
onViewChange?: (view: RiskMatrixView) => void;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface InvoiceLineItem extends EthListItem {
|
|
444
|
+
quantity?: React.ReactNode;
|
|
445
|
+
rate?: React.ReactNode;
|
|
446
|
+
amount?: React.ReactNode;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface InvoicePaymentMilestone extends EthListItem {
|
|
450
|
+
dueDate?: React.ReactNode;
|
|
451
|
+
amount?: React.ReactNode;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export interface InvoiceViewerProps extends Omit<SurfaceComponentProps, "items"> {
|
|
455
|
+
items?: InvoiceLineItem[];
|
|
456
|
+
customer?: React.ReactNode;
|
|
457
|
+
dueDate?: React.ReactNode;
|
|
458
|
+
terms?: React.ReactNode;
|
|
459
|
+
owner?: React.ReactNode;
|
|
460
|
+
subtotal?: React.ReactNode;
|
|
461
|
+
tax?: React.ReactNode;
|
|
462
|
+
total?: React.ReactNode;
|
|
463
|
+
amountDue?: React.ReactNode;
|
|
464
|
+
payments?: InvoicePaymentMilestone[];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface OrderFulfillmentItem extends EthListItem {
|
|
468
|
+
owner?: React.ReactNode;
|
|
469
|
+
timestamp?: React.ReactNode;
|
|
470
|
+
location?: React.ReactNode;
|
|
471
|
+
quantity?: React.ReactNode;
|
|
472
|
+
exception?: React.ReactNode;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface OrderManagementPanelProps extends Omit<SurfaceComponentProps, "items"> {
|
|
476
|
+
items?: OrderFulfillmentItem[];
|
|
477
|
+
customer?: React.ReactNode;
|
|
478
|
+
orderTotal?: React.ReactNode;
|
|
479
|
+
paymentTerms?: React.ReactNode;
|
|
480
|
+
shipTo?: React.ReactNode;
|
|
481
|
+
carrier?: React.ReactNode;
|
|
482
|
+
eta?: React.ReactNode;
|
|
483
|
+
fulfillmentOwner?: React.ReactNode;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface KnowledgeBaseArticleSection {
|
|
487
|
+
id: string;
|
|
488
|
+
title: React.ReactNode;
|
|
489
|
+
body: React.ReactNode;
|
|
490
|
+
status?: EthOperationalStatus;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface KnowledgeBaseRelatedArticle extends EthListItem {
|
|
494
|
+
type?: React.ReactNode;
|
|
495
|
+
updatedAt?: React.ReactNode;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export interface KnowledgeBaseCitation {
|
|
499
|
+
id: string;
|
|
500
|
+
label: React.ReactNode;
|
|
501
|
+
source?: React.ReactNode;
|
|
502
|
+
excerpt?: React.ReactNode;
|
|
503
|
+
href?: string;
|
|
504
|
+
status?: EthOperationalStatus;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export interface KnowledgeBaseArticleViewerProps extends Omit<SurfaceComponentProps, "items"> {
|
|
508
|
+
body?: React.ReactNode;
|
|
509
|
+
sections?: KnowledgeBaseArticleSection[];
|
|
510
|
+
relatedArticles?: KnowledgeBaseRelatedArticle[];
|
|
511
|
+
citations?: KnowledgeBaseCitation[];
|
|
512
|
+
items?: KnowledgeBaseRelatedArticle[];
|
|
513
|
+
articleStatusLabel?: React.ReactNode;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface DataImportWizardStep extends EthListItem {
|
|
517
|
+
owner?: React.ReactNode;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface DataImportFieldMapping {
|
|
521
|
+
id: string;
|
|
522
|
+
sourceField: React.ReactNode;
|
|
523
|
+
targetField: React.ReactNode;
|
|
524
|
+
sample?: React.ReactNode;
|
|
525
|
+
status?: EthOperationalStatus;
|
|
526
|
+
required?: boolean;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface DataImportValidationIssue {
|
|
530
|
+
id: string;
|
|
531
|
+
field: React.ReactNode;
|
|
532
|
+
message: React.ReactNode;
|
|
533
|
+
count?: React.ReactNode;
|
|
534
|
+
status?: EthOperationalStatus;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export interface DataImportWizardProps extends Omit<SurfaceComponentProps, "items"> {
|
|
538
|
+
items?: DataImportWizardStep[];
|
|
539
|
+
fileName?: React.ReactNode;
|
|
540
|
+
sourceLabel?: React.ReactNode;
|
|
541
|
+
rowCount?: React.ReactNode;
|
|
542
|
+
columnCount?: React.ReactNode;
|
|
543
|
+
mappingCount?: React.ReactNode;
|
|
544
|
+
validationSummary?: React.ReactNode;
|
|
545
|
+
mappings?: DataImportFieldMapping[];
|
|
546
|
+
validationIssues?: DataImportValidationIssue[];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export interface MappingConfigurationItem extends EthListItem {
|
|
550
|
+
sourceField?: React.ReactNode;
|
|
551
|
+
targetField?: React.ReactNode;
|
|
552
|
+
sourceObject?: React.ReactNode;
|
|
553
|
+
targetObject?: React.ReactNode;
|
|
554
|
+
sourceType?: React.ReactNode;
|
|
555
|
+
targetType?: React.ReactNode;
|
|
556
|
+
sample?: React.ReactNode;
|
|
557
|
+
transform?: React.ReactNode;
|
|
558
|
+
required?: boolean;
|
|
559
|
+
confidence?: React.ReactNode;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export interface MappingConfigurationIssue {
|
|
563
|
+
id: string;
|
|
564
|
+
field: React.ReactNode;
|
|
565
|
+
message: React.ReactNode;
|
|
566
|
+
status?: EthOperationalStatus;
|
|
567
|
+
count?: React.ReactNode;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export interface MappingConfigurationWizardProps extends Omit<SurfaceComponentProps, "items"> {
|
|
571
|
+
items?: MappingConfigurationItem[];
|
|
572
|
+
sourceObject?: React.ReactNode;
|
|
573
|
+
targetObject?: React.ReactNode;
|
|
574
|
+
mappingCount?: React.ReactNode;
|
|
575
|
+
validationSummary?: React.ReactNode;
|
|
576
|
+
targetOptions?: Array<{ value: string; label: string; disabled?: boolean }>;
|
|
577
|
+
validationIssues?: MappingConfigurationIssue[];
|
|
578
|
+
onMappingChange?: (id: string, targetField: string) => void;
|
|
579
|
+
onAutoMap?: () => void;
|
|
580
|
+
onValidate?: () => void;
|
|
581
|
+
onSubmit?: () => void;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface ReportBuilderSection extends EthListItem {
|
|
585
|
+
owner?: React.ReactNode;
|
|
586
|
+
source?: React.ReactNode;
|
|
587
|
+
updatedAt?: React.ReactNode;
|
|
588
|
+
required?: boolean;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export interface ReportBuilderProps extends Omit<SurfaceComponentProps, "items"> {
|
|
592
|
+
items?: ReportBuilderSection[];
|
|
593
|
+
formatLabel?: React.ReactNode;
|
|
594
|
+
owner?: React.ReactNode;
|
|
595
|
+
updatedAt?: React.ReactNode;
|
|
596
|
+
onAddSection?: () => void;
|
|
597
|
+
onExportPdf?: () => void;
|
|
598
|
+
onExportCsv?: () => void;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export interface InventoryTableItem extends EthListItem {
|
|
602
|
+
sku?: React.ReactNode;
|
|
603
|
+
location?: React.ReactNode;
|
|
604
|
+
onHand?: React.ReactNode;
|
|
605
|
+
available?: React.ReactNode;
|
|
606
|
+
allocated?: React.ReactNode;
|
|
607
|
+
reorderPoint?: React.ReactNode;
|
|
608
|
+
incoming?: React.ReactNode;
|
|
609
|
+
updatedAt?: React.ReactNode;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export interface InventoryTableProps extends Omit<SurfaceComponentProps, "items"> {
|
|
613
|
+
items?: InventoryTableItem[];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface ApprovalWorkflowEditorProps extends Omit<
|
|
617
|
+
SurfaceComponentProps,
|
|
618
|
+
"children" | "items" | "metadata" | "onChange"
|
|
619
|
+
> {
|
|
620
|
+
steps?: ApprovalWorkflowStep[];
|
|
621
|
+
approverOptions?: Array<{ value: string; label: string; disabled?: boolean }>;
|
|
622
|
+
mode?: ApprovalWorkflowMode;
|
|
623
|
+
onChange?: (steps: ApprovalWorkflowStep[]) => void;
|
|
624
|
+
onModeChange?: (mode: ApprovalWorkflowMode) => void;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export type ProcessDesignerNodeKind = "start" | "task" | "decision" | "end";
|
|
628
|
+
|
|
629
|
+
export interface ProcessDesignerItem extends EthListItem {
|
|
630
|
+
kind?: ProcessDesignerNodeKind;
|
|
631
|
+
owner?: React.ReactNode;
|
|
632
|
+
lane?: React.ReactNode;
|
|
633
|
+
duration?: React.ReactNode;
|
|
634
|
+
x?: number;
|
|
635
|
+
y?: number;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export interface ProcessDesignerConnection {
|
|
639
|
+
from: string;
|
|
640
|
+
to: string;
|
|
641
|
+
label?: React.ReactNode;
|
|
642
|
+
status?: EthOperationalStatus;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export interface ProcessDesignerValidationIssue {
|
|
646
|
+
id: string;
|
|
647
|
+
target?: React.ReactNode;
|
|
648
|
+
message: React.ReactNode;
|
|
649
|
+
status?: EthOperationalStatus;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export interface ProcessDesignerProps extends Omit<SurfaceComponentProps, "items"> {
|
|
653
|
+
items?: ProcessDesignerItem[];
|
|
654
|
+
connections?: ProcessDesignerConnection[];
|
|
655
|
+
validationIssues?: ProcessDesignerValidationIssue[];
|
|
656
|
+
selectedItemId?: string;
|
|
657
|
+
onAddStep?: () => void;
|
|
658
|
+
onValidate?: () => void;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const defaultApproverOptions = [
|
|
662
|
+
{ value: "", label: "Select approver", disabled: true },
|
|
663
|
+
{ value: "team-lead", label: "Team lead" },
|
|
664
|
+
{ value: "finance-ops", label: "Finance operations" },
|
|
665
|
+
{ value: "legal-review", label: "Legal review" },
|
|
666
|
+
{ value: "security-office", label: "Security office" },
|
|
667
|
+
{ value: "executive-sponsor", label: "Executive sponsor" }
|
|
668
|
+
];
|
|
669
|
+
|
|
670
|
+
const defaultApprovalSteps: ApprovalWorkflowStep[] = [
|
|
671
|
+
{
|
|
672
|
+
id: "manager-review",
|
|
673
|
+
label: "Manager review",
|
|
674
|
+
approver: "team-lead",
|
|
675
|
+
condition: "Spend is above requester limit",
|
|
676
|
+
required: true,
|
|
677
|
+
status: "completed"
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
id: "finance-check",
|
|
681
|
+
label: "Finance control check",
|
|
682
|
+
approver: "finance-ops",
|
|
683
|
+
condition: "Amount is greater than $10,000",
|
|
684
|
+
required: true,
|
|
685
|
+
status: "pending-approval"
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
id: "legal-signoff",
|
|
689
|
+
label: "Legal sign-off",
|
|
690
|
+
approver: "legal-review",
|
|
691
|
+
condition: "Vendor terms changed",
|
|
692
|
+
required: false,
|
|
693
|
+
status: "queued"
|
|
694
|
+
}
|
|
695
|
+
];
|
|
696
|
+
|
|
697
|
+
const modeOptions: Array<{ value: ApprovalWorkflowMode; label: string }> = [
|
|
698
|
+
{ value: "sequential", label: "Sequential route" },
|
|
699
|
+
{ value: "parallel", label: "Parallel quorum" },
|
|
700
|
+
{ value: "conditional", label: "Conditional branch" }
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
const defaultProcessItems: ProcessDesignerItem[] = [
|
|
704
|
+
{
|
|
705
|
+
id: "start",
|
|
706
|
+
label: "HRIS trigger",
|
|
707
|
+
description: "New employee record received from Workday.",
|
|
708
|
+
status: "completed",
|
|
709
|
+
kind: "start",
|
|
710
|
+
owner: "People ops",
|
|
711
|
+
lane: "Intake",
|
|
712
|
+
duration: "Instant",
|
|
713
|
+
x: 96,
|
|
714
|
+
y: 170
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
id: "profile",
|
|
718
|
+
label: "Collect profile",
|
|
719
|
+
description: "Manager confirms role, location, and start date.",
|
|
720
|
+
status: "completed",
|
|
721
|
+
kind: "task",
|
|
722
|
+
owner: "Hiring manager",
|
|
723
|
+
lane: "Intake",
|
|
724
|
+
duration: "4h SLA",
|
|
725
|
+
x: 278,
|
|
726
|
+
y: 170
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
id: "approval",
|
|
730
|
+
label: "Approval required?",
|
|
731
|
+
description: "Route privileged roles through security approval.",
|
|
732
|
+
status: "warning",
|
|
733
|
+
kind: "decision",
|
|
734
|
+
owner: "People systems",
|
|
735
|
+
lane: "Policy",
|
|
736
|
+
duration: "Conditional",
|
|
737
|
+
x: 460,
|
|
738
|
+
y: 170
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
id: "security",
|
|
742
|
+
label: "Security review",
|
|
743
|
+
description: "Validate device, access groups, and background check.",
|
|
744
|
+
status: "pending-approval",
|
|
745
|
+
kind: "task",
|
|
746
|
+
owner: "Security office",
|
|
747
|
+
lane: "Controls",
|
|
748
|
+
duration: "24h SLA",
|
|
749
|
+
x: 642,
|
|
750
|
+
y: 92
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
id: "provision",
|
|
754
|
+
label: "Provision access",
|
|
755
|
+
description: "Create accounts and assign application entitlements.",
|
|
756
|
+
status: "in-progress",
|
|
757
|
+
kind: "task",
|
|
758
|
+
owner: "IT operations",
|
|
759
|
+
lane: "Fulfillment",
|
|
760
|
+
duration: "2h SLA",
|
|
761
|
+
x: 642,
|
|
762
|
+
y: 248
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: "complete",
|
|
766
|
+
label: "Day-one ready",
|
|
767
|
+
description: "Welcome package, access, and training tasks are complete.",
|
|
768
|
+
status: "queued",
|
|
769
|
+
kind: "end",
|
|
770
|
+
owner: "People ops",
|
|
771
|
+
lane: "Fulfillment",
|
|
772
|
+
duration: "Start date",
|
|
773
|
+
x: 824,
|
|
774
|
+
y: 248
|
|
775
|
+
}
|
|
776
|
+
];
|
|
777
|
+
|
|
778
|
+
const defaultProcessConnections: ProcessDesignerConnection[] = [
|
|
779
|
+
{ from: "start", to: "profile" },
|
|
780
|
+
{ from: "profile", to: "approval" },
|
|
781
|
+
{ from: "approval", to: "security", label: "Needs review", status: "warning" },
|
|
782
|
+
{ from: "approval", to: "provision", label: "Auto approve" },
|
|
783
|
+
{ from: "security", to: "provision", label: "Approved" },
|
|
784
|
+
{ from: "provision", to: "complete" }
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
const defaultProcessValidationIssues: ProcessDesignerValidationIssue[] = [
|
|
788
|
+
{
|
|
789
|
+
id: "backup-owner",
|
|
790
|
+
target: "Approval required?",
|
|
791
|
+
message: "Privileged access branch needs a backup owner.",
|
|
792
|
+
status: "warning"
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
id: "sla-policy",
|
|
796
|
+
target: "Security review",
|
|
797
|
+
message: "Security review SLA is waiting on policy confirmation.",
|
|
798
|
+
status: "pending-approval"
|
|
799
|
+
}
|
|
800
|
+
];
|
|
801
|
+
|
|
802
|
+
function nextApprovalStepId(steps: ApprovalWorkflowStep[]) {
|
|
803
|
+
let index = steps.length + 1;
|
|
804
|
+
let id = `approval-step-${index}`;
|
|
805
|
+
while (steps.some((step) => step.id === id)) {
|
|
806
|
+
index += 1;
|
|
807
|
+
id = `approval-step-${index}`;
|
|
808
|
+
}
|
|
809
|
+
return id;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function formatApprovalStatus(status: EthOperationalStatus | undefined, required?: boolean) {
|
|
813
|
+
if (status === "completed") return "Completed";
|
|
814
|
+
if (status === "pending-approval") return "Pending";
|
|
815
|
+
if (status === "blocked" || status === "failed") return "Blocked";
|
|
816
|
+
if (status === "queued") return "Queued";
|
|
817
|
+
return required ? "Required" : "Optional";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function isComplianceComplete(status: EthOperationalStatus | undefined) {
|
|
821
|
+
return status === "completed" || status === "succeeded" || status === "synced";
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function needsComplianceAttention(status: EthOperationalStatus | undefined) {
|
|
825
|
+
return (
|
|
826
|
+
status === "approval-required" ||
|
|
827
|
+
status === "blocked" ||
|
|
828
|
+
status === "failed" ||
|
|
829
|
+
status === "pending-approval" ||
|
|
830
|
+
status === "stale" ||
|
|
831
|
+
status === "warning"
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function deriveComplianceStatus(
|
|
836
|
+
items: ComplianceChecklistItem[]
|
|
837
|
+
): EthOperationalStatus | undefined {
|
|
838
|
+
if (!items.length) return undefined;
|
|
839
|
+
if (items.some((item) => item.status === "blocked" || item.status === "failed")) {
|
|
840
|
+
return "blocked";
|
|
841
|
+
}
|
|
842
|
+
if (items.some((item) => item.status === "approval-required")) {
|
|
843
|
+
return "approval-required";
|
|
844
|
+
}
|
|
845
|
+
if (items.every((item) => isComplianceComplete(item.status))) {
|
|
846
|
+
return "completed";
|
|
847
|
+
}
|
|
848
|
+
return "in-progress";
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function hasComplianceEvidence(item: ComplianceChecklistItem) {
|
|
852
|
+
return Boolean(item.evidence ?? item.evidenceLabel ?? item.evidenceHref ?? item.meta);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function hasDisplayValue(value: React.ReactNode) {
|
|
856
|
+
return value !== undefined && value !== null && value !== "";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function isReportSectionIncluded(status: EthOperationalStatus | undefined) {
|
|
860
|
+
return (
|
|
861
|
+
status !== "inactive" &&
|
|
862
|
+
status !== "not-started" &&
|
|
863
|
+
status !== "blocked" &&
|
|
864
|
+
status !== "failed"
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function needsReportSectionAttention(status: EthOperationalStatus | undefined) {
|
|
869
|
+
return (
|
|
870
|
+
status === "approval-required" ||
|
|
871
|
+
status === "blocked" ||
|
|
872
|
+
status === "failed" ||
|
|
873
|
+
status === "pending-approval" ||
|
|
874
|
+
status === "stale" ||
|
|
875
|
+
status === "warning"
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function deriveReportBuilderStatus(items: ReportBuilderSection[]): EthOperationalStatus {
|
|
880
|
+
if (!items.length) return "not-started";
|
|
881
|
+
if (items.some((item) => item.status === "blocked" || item.status === "failed")) {
|
|
882
|
+
return "blocked";
|
|
883
|
+
}
|
|
884
|
+
if (items.some((item) => needsReportSectionAttention(item.status))) return "warning";
|
|
885
|
+
if (items.some((item) => item.status === "in-progress" || item.status === "running")) {
|
|
886
|
+
return "in-progress";
|
|
887
|
+
}
|
|
888
|
+
return "active";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function reportBuilderReadinessLabel(status: EthOperationalStatus, attentionCount: number) {
|
|
892
|
+
if (attentionCount) {
|
|
893
|
+
return `${attentionCount} ${attentionCount === 1 ? "issue" : "issues"} to resolve`;
|
|
894
|
+
}
|
|
895
|
+
if (status === "not-started") return "Not started";
|
|
896
|
+
if (status === "in-progress" || status === "running") return "Draft in progress";
|
|
897
|
+
if (status === "blocked" || status === "failed") return "Blocked";
|
|
898
|
+
return "Ready";
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function reportSectionMeta(item: ReportBuilderSection, index: number) {
|
|
902
|
+
return item.meta ?? item.updatedAt ?? item.source ?? `Section ${index + 1}`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const riskImpactLevels: RiskMatrixImpact[] = ["critical", "high", "medium", "low"];
|
|
906
|
+
const riskProbabilityLevels: RiskMatrixProbability[] = ["low", "medium", "high"];
|
|
907
|
+
const riskImpactRank: Record<RiskMatrixImpact, number> = {
|
|
908
|
+
low: 1,
|
|
909
|
+
medium: 2,
|
|
910
|
+
high: 3,
|
|
911
|
+
critical: 4
|
|
912
|
+
};
|
|
913
|
+
const riskProbabilityRank: Record<RiskMatrixProbability, number> = {
|
|
914
|
+
low: 1,
|
|
915
|
+
medium: 2,
|
|
916
|
+
high: 3
|
|
917
|
+
};
|
|
918
|
+
const riskSeverityRank: Record<RiskMatrixSeverity, number> = {
|
|
919
|
+
low: 1,
|
|
920
|
+
medium: 2,
|
|
921
|
+
high: 3,
|
|
922
|
+
critical: 4
|
|
923
|
+
};
|
|
924
|
+
const riskImpactLabels: Record<RiskMatrixImpact, string> = {
|
|
925
|
+
low: "Low",
|
|
926
|
+
medium: "Medium",
|
|
927
|
+
high: "High",
|
|
928
|
+
critical: "Critical"
|
|
929
|
+
};
|
|
930
|
+
const riskProbabilityLabels: Record<RiskMatrixProbability, string> = {
|
|
931
|
+
low: "Low",
|
|
932
|
+
medium: "Medium",
|
|
933
|
+
high: "High"
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
interface ResolvedRiskMatrixItem extends RiskMatrixItem {
|
|
937
|
+
resolvedImpact: RiskMatrixImpact;
|
|
938
|
+
resolvedProbability: RiskMatrixProbability;
|
|
939
|
+
resolvedSeverity: RiskMatrixSeverity;
|
|
940
|
+
resolvedScore: number;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function isRiskImpact(value: string): value is RiskMatrixImpact {
|
|
944
|
+
return value === "low" || value === "medium" || value === "high" || value === "critical";
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function isRiskProbability(value: string): value is RiskMatrixProbability {
|
|
948
|
+
return value === "low" || value === "medium" || value === "high";
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function normalizeRiskImpact(value: unknown): RiskMatrixImpact | undefined {
|
|
952
|
+
if (typeof value !== "string") return undefined;
|
|
953
|
+
const normalized = value.trim().toLowerCase();
|
|
954
|
+
return isRiskImpact(normalized) ? normalized : undefined;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function normalizeRiskProbability(value: unknown): RiskMatrixProbability | undefined {
|
|
958
|
+
if (typeof value !== "string") return undefined;
|
|
959
|
+
const normalized = value.trim().toLowerCase();
|
|
960
|
+
return isRiskProbability(normalized) ? normalized : undefined;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function riskFieldFromDescription(item: RiskMatrixItem, field: "impact" | "probability") {
|
|
964
|
+
const text = textFromNode(item.description);
|
|
965
|
+
const match = text.match(new RegExp(`${field}\\s*[:=]\\s*(critical|high|medium|low)`, "i"));
|
|
966
|
+
return match?.[1];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function fallbackImpactForStatus(status: EthOperationalStatus | undefined): RiskMatrixImpact {
|
|
970
|
+
if (status === "blocked" || status === "failed" || status === "approval-required") {
|
|
971
|
+
return "critical";
|
|
972
|
+
}
|
|
973
|
+
if (status === "warning" || status === "stale" || status === "pending-approval") {
|
|
974
|
+
return "high";
|
|
975
|
+
}
|
|
976
|
+
if (status === "completed" || status === "succeeded" || status === "synced") return "low";
|
|
977
|
+
return "medium";
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function riskSeverityFromScore(
|
|
981
|
+
impact: RiskMatrixImpact,
|
|
982
|
+
probability: RiskMatrixProbability
|
|
983
|
+
): RiskMatrixSeverity {
|
|
984
|
+
const score = riskImpactRank[impact] * riskProbabilityRank[probability];
|
|
985
|
+
if (score >= 9) return "critical";
|
|
986
|
+
if (score >= 6) return "high";
|
|
987
|
+
if (score >= 3) return "medium";
|
|
988
|
+
return "low";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function resolveRiskMatrixItem(item: RiskMatrixItem): ResolvedRiskMatrixItem {
|
|
992
|
+
const resolvedImpact =
|
|
993
|
+
normalizeRiskImpact(item.impact) ??
|
|
994
|
+
normalizeRiskImpact(riskFieldFromDescription(item, "impact")) ??
|
|
995
|
+
fallbackImpactForStatus(item.status);
|
|
996
|
+
const resolvedProbability =
|
|
997
|
+
normalizeRiskProbability(item.probability) ??
|
|
998
|
+
normalizeRiskProbability(riskFieldFromDescription(item, "probability")) ??
|
|
999
|
+
"medium";
|
|
1000
|
+
const resolvedSeverity =
|
|
1001
|
+
item.severity ??
|
|
1002
|
+
(item.status === "blocked" || item.status === "failed"
|
|
1003
|
+
? "critical"
|
|
1004
|
+
: riskSeverityFromScore(resolvedImpact, resolvedProbability));
|
|
1005
|
+
|
|
1006
|
+
return {
|
|
1007
|
+
...item,
|
|
1008
|
+
resolvedImpact,
|
|
1009
|
+
resolvedProbability,
|
|
1010
|
+
resolvedSeverity,
|
|
1011
|
+
resolvedScore: riskImpactRank[resolvedImpact] * riskProbabilityRank[resolvedProbability]
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function compareRiskPriority(a: ResolvedRiskMatrixItem, b: ResolvedRiskMatrixItem) {
|
|
1016
|
+
return (
|
|
1017
|
+
riskSeverityRank[b.resolvedSeverity] - riskSeverityRank[a.resolvedSeverity] ||
|
|
1018
|
+
b.resolvedScore - a.resolvedScore ||
|
|
1019
|
+
riskImpactRank[b.resolvedImpact] - riskImpactRank[a.resolvedImpact] ||
|
|
1020
|
+
riskProbabilityRank[b.resolvedProbability] - riskProbabilityRank[a.resolvedProbability]
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function deriveRiskMatrixStatus(items: ResolvedRiskMatrixItem[]): EthOperationalStatus | undefined {
|
|
1025
|
+
if (!items.length) return undefined;
|
|
1026
|
+
if (items.some((item) => item.resolvedSeverity === "critical")) return "blocked";
|
|
1027
|
+
if (items.some((item) => item.resolvedSeverity === "high")) return "warning";
|
|
1028
|
+
if (items.some((item) => item.resolvedSeverity === "medium")) return "in-progress";
|
|
1029
|
+
return "active";
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function deriveRiskMatrixSurfaceSeverity(items: ResolvedRiskMatrixItem[]) {
|
|
1033
|
+
if (items.some((item) => item.resolvedSeverity === "critical")) return "danger" as const;
|
|
1034
|
+
if (items.some((item) => item.resolvedSeverity === "high")) return "warning" as const;
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function riskCountLabel(count: number) {
|
|
1039
|
+
return `${count} risk${count === 1 ? "" : "s"}`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function isCompletedProcessStep(status: EthOperationalStatus | undefined) {
|
|
1043
|
+
return status === "completed" || status === "succeeded" || status === "synced";
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function processStepKind(
|
|
1047
|
+
item: ProcessDesignerItem,
|
|
1048
|
+
index: number,
|
|
1049
|
+
total: number
|
|
1050
|
+
): ProcessDesignerNodeKind {
|
|
1051
|
+
if (item.kind) return item.kind;
|
|
1052
|
+
if (index === 0) return "start";
|
|
1053
|
+
if (index === total - 1) return "end";
|
|
1054
|
+
return "task";
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function deriveProcessStatus(
|
|
1058
|
+
items: ProcessDesignerItem[],
|
|
1059
|
+
validationIssues: ProcessDesignerValidationIssue[]
|
|
1060
|
+
): EthOperationalStatus | undefined {
|
|
1061
|
+
if (validationIssues.some((issue) => issue.status === "blocked" || issue.status === "failed")) {
|
|
1062
|
+
return "blocked";
|
|
1063
|
+
}
|
|
1064
|
+
if (
|
|
1065
|
+
validationIssues.some(
|
|
1066
|
+
(issue) =>
|
|
1067
|
+
issue.status === "warning" ||
|
|
1068
|
+
issue.status === "pending-approval" ||
|
|
1069
|
+
issue.status === "approval-required"
|
|
1070
|
+
)
|
|
1071
|
+
) {
|
|
1072
|
+
return "warning";
|
|
1073
|
+
}
|
|
1074
|
+
if (!items.length) return undefined;
|
|
1075
|
+
if (items.some((item) => item.status === "blocked" || item.status === "failed")) {
|
|
1076
|
+
return "blocked";
|
|
1077
|
+
}
|
|
1078
|
+
if (items.every((item) => isCompletedProcessStep(item.status))) {
|
|
1079
|
+
return "completed";
|
|
1080
|
+
}
|
|
1081
|
+
if (
|
|
1082
|
+
items.some(
|
|
1083
|
+
(item) =>
|
|
1084
|
+
item.status === "active" || item.status === "in-progress" || item.status === "running"
|
|
1085
|
+
)
|
|
1086
|
+
) {
|
|
1087
|
+
return "in-progress";
|
|
1088
|
+
}
|
|
1089
|
+
return "queued";
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function sequentialProcessConnections(items: ProcessDesignerItem[]): ProcessDesignerConnection[] {
|
|
1093
|
+
return items.slice(1).map((item, index) => ({ from: items[index].id, to: item.id }));
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function processNodePoint(item: ProcessDesignerItem, index: number) {
|
|
1097
|
+
return {
|
|
1098
|
+
x: item.x ?? 110 + (index % 4) * 210,
|
|
1099
|
+
y: item.y ?? 140 + Math.floor(index / 4) * 140
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function graphLabelLines(value: React.ReactNode, fallback: string) {
|
|
1104
|
+
const label = textFromNode(value).trim() || fallback;
|
|
1105
|
+
if (label.length <= 18) return [label];
|
|
1106
|
+
|
|
1107
|
+
const lines: string[] = [];
|
|
1108
|
+
let current = "";
|
|
1109
|
+
for (const word of label.split(/\s+/)) {
|
|
1110
|
+
const next = current ? `${current} ${word}` : word;
|
|
1111
|
+
if (next.length > 18 && current) {
|
|
1112
|
+
lines.push(current);
|
|
1113
|
+
current = word;
|
|
1114
|
+
} else {
|
|
1115
|
+
current = next;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (current) lines.push(current);
|
|
1119
|
+
|
|
1120
|
+
if (lines.length <= 2) return lines;
|
|
1121
|
+
return [lines[0], `${lines.slice(1).join(" ").slice(0, 16)}...`];
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const defaultOrderFulfillmentItems: OrderFulfillmentItem[] = [
|
|
1125
|
+
{
|
|
1126
|
+
id: "received",
|
|
1127
|
+
label: "Order received",
|
|
1128
|
+
description: "Purchase order matched against contract pricing and customer terms.",
|
|
1129
|
+
owner: "Sales operations",
|
|
1130
|
+
timestamp: "May 29, 08:12",
|
|
1131
|
+
status: "completed"
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
id: "payment",
|
|
1135
|
+
label: "Payment terms cleared",
|
|
1136
|
+
description: "Net 30 credit check passed; release hold removed by finance.",
|
|
1137
|
+
owner: "Finance operations",
|
|
1138
|
+
timestamp: "May 29, 09:05",
|
|
1139
|
+
status: "completed"
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
id: "pick-pack",
|
|
1143
|
+
label: "Pick and pack",
|
|
1144
|
+
description: "Warehouse B is staging the final cartons for carrier handoff.",
|
|
1145
|
+
owner: "Fulfillment team",
|
|
1146
|
+
location: "Warehouse B",
|
|
1147
|
+
quantity: "18 / 20 cartons",
|
|
1148
|
+
timestamp: "May 29, 14:40",
|
|
1149
|
+
status: "in-progress"
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
id: "carrier",
|
|
1153
|
+
label: "Carrier handoff",
|
|
1154
|
+
description: "FedEx pickup booked; ASN will post when scan is received.",
|
|
1155
|
+
owner: "FedEx Ground",
|
|
1156
|
+
location: "Dock 4",
|
|
1157
|
+
timestamp: "May 29, 17:00",
|
|
1158
|
+
status: "queued"
|
|
1159
|
+
}
|
|
1160
|
+
];
|
|
1161
|
+
|
|
1162
|
+
function isOrderStepComplete(status: EthOperationalStatus | undefined) {
|
|
1163
|
+
return status === "completed" || status === "succeeded" || status === "synced";
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function isOrderStepCurrent(status: EthOperationalStatus | undefined) {
|
|
1167
|
+
return status === "active" || status === "in-progress" || status === "running";
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function needsOrderAttention(item: OrderFulfillmentItem) {
|
|
1171
|
+
return (
|
|
1172
|
+
hasDisplayValue(item.exception) ||
|
|
1173
|
+
item.status === "approval-required" ||
|
|
1174
|
+
item.status === "blocked" ||
|
|
1175
|
+
item.status === "failed" ||
|
|
1176
|
+
item.status === "pending-approval" ||
|
|
1177
|
+
item.status === "stale" ||
|
|
1178
|
+
item.status === "warning"
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function deriveOrderStatus(items: OrderFulfillmentItem[]): EthOperationalStatus | undefined {
|
|
1183
|
+
if (!items.length) return undefined;
|
|
1184
|
+
if (items.some((item) => item.status === "blocked" || item.status === "failed")) {
|
|
1185
|
+
return "blocked";
|
|
1186
|
+
}
|
|
1187
|
+
if (items.some((item) => item.status === "approval-required")) {
|
|
1188
|
+
return "approval-required";
|
|
1189
|
+
}
|
|
1190
|
+
if (items.every((item) => isOrderStepComplete(item.status))) return "completed";
|
|
1191
|
+
if (items.some((item) => isOrderStepCurrent(item.status))) return "in-progress";
|
|
1192
|
+
if (items.some((item) => item.status === "pending-approval" || item.status === "warning")) {
|
|
1193
|
+
return "pending-approval";
|
|
1194
|
+
}
|
|
1195
|
+
return "queued";
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function currentOrderStepIndex(items: OrderFulfillmentItem[]) {
|
|
1199
|
+
const activeIndex = items.findIndex((item) => isOrderStepCurrent(item.status));
|
|
1200
|
+
if (activeIndex >= 0) return activeIndex;
|
|
1201
|
+
|
|
1202
|
+
const nextIndex = items.findIndex((item) => !isOrderStepComplete(item.status));
|
|
1203
|
+
return nextIndex >= 0 ? nextIndex : Math.max(items.length - 1, 0);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function OrderFulfillmentActions({ actions }: { actions?: EthAction[] }) {
|
|
1207
|
+
if (!actions?.length) return null;
|
|
1208
|
+
|
|
1209
|
+
return (
|
|
1210
|
+
<div className="eth-domain-order-management__step-actions">
|
|
1211
|
+
{actions.slice(0, 2).map((action) =>
|
|
1212
|
+
action.href ? (
|
|
1213
|
+
<LinkButton
|
|
1214
|
+
key={action.id}
|
|
1215
|
+
href={action.disabled ? undefined : action.href}
|
|
1216
|
+
intent={action.intent ?? "ghost"}
|
|
1217
|
+
density="compact"
|
|
1218
|
+
aria-disabled={action.disabled ? true : undefined}
|
|
1219
|
+
tabIndex={action.disabled ? -1 : undefined}
|
|
1220
|
+
>
|
|
1221
|
+
{action.label}
|
|
1222
|
+
</LinkButton>
|
|
1223
|
+
) : (
|
|
1224
|
+
<Button
|
|
1225
|
+
key={action.id}
|
|
1226
|
+
type="button"
|
|
1227
|
+
intent={action.intent ?? "ghost"}
|
|
1228
|
+
density="compact"
|
|
1229
|
+
disabled={action.disabled}
|
|
1230
|
+
onClick={action.onSelect}
|
|
1231
|
+
>
|
|
1232
|
+
{action.label}
|
|
1233
|
+
</Button>
|
|
1234
|
+
)
|
|
1235
|
+
)}
|
|
1236
|
+
</div>
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function parseInventoryNumber(value: React.ReactNode): number | null {
|
|
1241
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
1242
|
+
if (typeof value !== "string") return null;
|
|
1243
|
+
|
|
1244
|
+
const normalized = value.replace(/,/g, "").trim();
|
|
1245
|
+
if (!/^-?\d+(\.\d+)?$/.test(normalized)) return null;
|
|
1246
|
+
|
|
1247
|
+
const parsed = Number(normalized);
|
|
1248
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function formatInventoryValue(value: React.ReactNode) {
|
|
1252
|
+
if (!hasDisplayValue(value)) return "--";
|
|
1253
|
+
if (typeof value === "number") return new Intl.NumberFormat("en-US").format(value);
|
|
1254
|
+
return value;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function parseInventoryStockDescription(description: React.ReactNode) {
|
|
1258
|
+
const text = textFromNode(description);
|
|
1259
|
+
const match = text.match(
|
|
1260
|
+
/\bOn hand:\s*([0-9,]+(?:\.\d+)?)\s*(?:[^A-Za-z0-9]+\s*)?Reorder:\s*([0-9,]+(?:\.\d+)?)/i
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
if (!match) return null;
|
|
1264
|
+
return {
|
|
1265
|
+
onHand: Number(match[1].replace(/,/g, "")),
|
|
1266
|
+
reorderPoint: Number(match[2].replace(/,/g, ""))
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function inventoryStatusFromLevels(
|
|
1271
|
+
item: InventoryTableItem,
|
|
1272
|
+
onHandNumber: number | null,
|
|
1273
|
+
reorderNumber: number | null
|
|
1274
|
+
): EthOperationalStatus {
|
|
1275
|
+
if (item.status) return item.status;
|
|
1276
|
+
if (onHandNumber !== null && onHandNumber <= 0) return "failed";
|
|
1277
|
+
if (onHandNumber !== null && reorderNumber !== null && onHandNumber <= reorderNumber) {
|
|
1278
|
+
return "warning";
|
|
1279
|
+
}
|
|
1280
|
+
return "active";
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function inventoryStatusLabel(status: EthOperationalStatus) {
|
|
1284
|
+
if (status === "failed" || status === "blocked") return "Stockout";
|
|
1285
|
+
if (status === "warning" || status === "stale") return "Reorder";
|
|
1286
|
+
if (status === "in-progress" || status === "running") return "Replenishing";
|
|
1287
|
+
if (status === "queued" || status === "not-started") return "Pending count";
|
|
1288
|
+
if (
|
|
1289
|
+
status === "active" ||
|
|
1290
|
+
status === "completed" ||
|
|
1291
|
+
status === "succeeded" ||
|
|
1292
|
+
status === "synced"
|
|
1293
|
+
) {
|
|
1294
|
+
return "In stock";
|
|
1295
|
+
}
|
|
1296
|
+
return statusLabel(status);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
interface InventoryTableRow {
|
|
1300
|
+
item: InventoryTableItem;
|
|
1301
|
+
parsedStockDescription: boolean;
|
|
1302
|
+
onHand: React.ReactNode;
|
|
1303
|
+
onHandNumber: number | null;
|
|
1304
|
+
reorderPoint: React.ReactNode;
|
|
1305
|
+
reorderNumber: number | null;
|
|
1306
|
+
available: React.ReactNode;
|
|
1307
|
+
status: EthOperationalStatus;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function normalizeInventoryRow(item: InventoryTableItem): InventoryTableRow {
|
|
1311
|
+
const parsedStockDescription = parseInventoryStockDescription(item.description);
|
|
1312
|
+
const onHand = item.onHand ?? parsedStockDescription?.onHand;
|
|
1313
|
+
const reorderPoint = item.reorderPoint ?? parsedStockDescription?.reorderPoint;
|
|
1314
|
+
const onHandNumber = parseInventoryNumber(onHand);
|
|
1315
|
+
const reorderNumber = parseInventoryNumber(reorderPoint);
|
|
1316
|
+
const allocatedNumber = parseInventoryNumber(item.allocated);
|
|
1317
|
+
const available =
|
|
1318
|
+
item.available ??
|
|
1319
|
+
(onHandNumber !== null && allocatedNumber !== null
|
|
1320
|
+
? onHandNumber - allocatedNumber
|
|
1321
|
+
: undefined);
|
|
1322
|
+
const status = inventoryStatusFromLevels(item, onHandNumber, reorderNumber);
|
|
1323
|
+
|
|
1324
|
+
return {
|
|
1325
|
+
item,
|
|
1326
|
+
parsedStockDescription: Boolean(parsedStockDescription),
|
|
1327
|
+
onHand,
|
|
1328
|
+
onHandNumber,
|
|
1329
|
+
reorderPoint,
|
|
1330
|
+
reorderNumber,
|
|
1331
|
+
available,
|
|
1332
|
+
status
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function inventoryLevelPercent(row: InventoryTableRow) {
|
|
1337
|
+
if (row.onHandNumber === null || row.reorderNumber === null) return 0;
|
|
1338
|
+
|
|
1339
|
+
const capacity = Math.max(row.reorderNumber * 2, row.onHandNumber, 1);
|
|
1340
|
+
return Math.max(0, Math.min(100, Math.round((row.onHandNumber / capacity) * 100)));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function isInventoryReorderRow(row: InventoryTableRow) {
|
|
1344
|
+
if (row.onHandNumber !== null && row.reorderNumber !== null) {
|
|
1345
|
+
return row.onHandNumber <= row.reorderNumber;
|
|
1346
|
+
}
|
|
1347
|
+
return row.status === "warning" || row.status === "failed" || row.status === "blocked";
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function isInventoryStockoutRow(row: InventoryTableRow) {
|
|
1351
|
+
if (row.onHandNumber !== null) return row.onHandNumber <= 0;
|
|
1352
|
+
return row.status === "failed" || row.status === "blocked";
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export function InventoryTable({
|
|
1356
|
+
title = "Inventory",
|
|
1357
|
+
description = "SKU availability, reorder thresholds, and fulfillment readiness.",
|
|
1358
|
+
items = [],
|
|
1359
|
+
metadata,
|
|
1360
|
+
actions,
|
|
1361
|
+
footer,
|
|
1362
|
+
children,
|
|
1363
|
+
className,
|
|
1364
|
+
...props
|
|
1365
|
+
}: InventoryTableProps) {
|
|
1366
|
+
const rows = items.map(normalizeInventoryRow);
|
|
1367
|
+
const totalOnHand = rows.reduce(
|
|
1368
|
+
(sum, row) => (row.onHandNumber === null ? sum : sum + row.onHandNumber),
|
|
1369
|
+
0
|
|
1370
|
+
);
|
|
1371
|
+
const hasOnHandTotal = rows.some((row) => row.onHandNumber !== null);
|
|
1372
|
+
const reorderCount = rows.filter(isInventoryReorderRow).length;
|
|
1373
|
+
const stockoutCount = rows.filter(isInventoryStockoutRow).length;
|
|
1374
|
+
const computedMetadata =
|
|
1375
|
+
metadata ??
|
|
1376
|
+
(rows.length
|
|
1377
|
+
? [
|
|
1378
|
+
{ label: "SKUs", value: rows.length },
|
|
1379
|
+
{ label: "Below reorder", value: reorderCount },
|
|
1380
|
+
{ label: "Stockouts", value: stockoutCount },
|
|
1381
|
+
...(hasOnHandTotal
|
|
1382
|
+
? [{ label: "On hand", value: new Intl.NumberFormat("en-US").format(totalOnHand) }]
|
|
1383
|
+
: [])
|
|
1384
|
+
]
|
|
1385
|
+
: undefined);
|
|
1386
|
+
|
|
1387
|
+
return (
|
|
1388
|
+
<Surface
|
|
1389
|
+
{...props}
|
|
1390
|
+
data-eth-component="InventoryTable"
|
|
1391
|
+
title={title}
|
|
1392
|
+
description={description}
|
|
1393
|
+
metadata={computedMetadata}
|
|
1394
|
+
actions={actions}
|
|
1395
|
+
footer={footer}
|
|
1396
|
+
className={["eth-domain-inventory-table", className].filter(Boolean).join(" ")}
|
|
1397
|
+
>
|
|
1398
|
+
{rows.length ? (
|
|
1399
|
+
<div className="eth-domain-inventory-table__table-wrap">
|
|
1400
|
+
<table className="eth-domain-inventory-table__table">
|
|
1401
|
+
<caption className="eth-domain-inventory-table__caption">
|
|
1402
|
+
Inventory stock by SKU and reorder threshold
|
|
1403
|
+
</caption>
|
|
1404
|
+
<colgroup>
|
|
1405
|
+
<col className="eth-domain-inventory-table__sku-col" />
|
|
1406
|
+
<col className="eth-domain-inventory-table__location-col" />
|
|
1407
|
+
<col className="eth-domain-inventory-table__quantity-col" />
|
|
1408
|
+
<col className="eth-domain-inventory-table__quantity-col" />
|
|
1409
|
+
<col className="eth-domain-inventory-table__quantity-col" />
|
|
1410
|
+
<col className="eth-domain-inventory-table__status-col" />
|
|
1411
|
+
</colgroup>
|
|
1412
|
+
<thead>
|
|
1413
|
+
<tr>
|
|
1414
|
+
<th scope="col">SKU</th>
|
|
1415
|
+
<th scope="col">Location</th>
|
|
1416
|
+
<th scope="col">On hand</th>
|
|
1417
|
+
<th scope="col">Available</th>
|
|
1418
|
+
<th scope="col">Reorder point</th>
|
|
1419
|
+
<th scope="col">Stock status</th>
|
|
1420
|
+
</tr>
|
|
1421
|
+
</thead>
|
|
1422
|
+
<tbody>
|
|
1423
|
+
{rows.map((row) => {
|
|
1424
|
+
const { item } = row;
|
|
1425
|
+
const labelContent = item.href ? <a href={item.href}>{item.label}</a> : item.label;
|
|
1426
|
+
const showDescription =
|
|
1427
|
+
hasDisplayValue(item.description) && !row.parsedStockDescription;
|
|
1428
|
+
const location = item.location ?? item.meta;
|
|
1429
|
+
const statusLabelText = inventoryStatusLabel(row.status);
|
|
1430
|
+
|
|
1431
|
+
return (
|
|
1432
|
+
<tr key={item.id} data-status={row.status}>
|
|
1433
|
+
<td>
|
|
1434
|
+
<div className="eth-domain-inventory-table__sku">
|
|
1435
|
+
<span>{item.sku ?? item.id}</span>
|
|
1436
|
+
<strong>{labelContent}</strong>
|
|
1437
|
+
{showDescription ? <p>{item.description}</p> : null}
|
|
1438
|
+
</div>
|
|
1439
|
+
</td>
|
|
1440
|
+
<td>
|
|
1441
|
+
<div className="eth-domain-inventory-table__cell-stack">
|
|
1442
|
+
<strong>{formatInventoryValue(location)}</strong>
|
|
1443
|
+
{hasDisplayValue(item.updatedAt) ? (
|
|
1444
|
+
<span>{item.updatedAt}</span>
|
|
1445
|
+
) : (
|
|
1446
|
+
<span>Cycle count current</span>
|
|
1447
|
+
)}
|
|
1448
|
+
</div>
|
|
1449
|
+
</td>
|
|
1450
|
+
<td>
|
|
1451
|
+
<div className="eth-domain-inventory-table__quantity">
|
|
1452
|
+
<strong>{formatInventoryValue(row.onHand)}</strong>
|
|
1453
|
+
<span className="eth-domain-inventory-table__level" aria-hidden="true">
|
|
1454
|
+
<span
|
|
1455
|
+
className="eth-domain-inventory-table__level-fill"
|
|
1456
|
+
style={{ inlineSize: `${inventoryLevelPercent(row)}%` }}
|
|
1457
|
+
/>
|
|
1458
|
+
</span>
|
|
1459
|
+
</div>
|
|
1460
|
+
</td>
|
|
1461
|
+
<td className="eth-domain-inventory-table__numeric">
|
|
1462
|
+
{formatInventoryValue(row.available)}
|
|
1463
|
+
</td>
|
|
1464
|
+
<td className="eth-domain-inventory-table__numeric">
|
|
1465
|
+
{formatInventoryValue(row.reorderPoint)}
|
|
1466
|
+
</td>
|
|
1467
|
+
<td>
|
|
1468
|
+
<div className="eth-domain-inventory-table__state">
|
|
1469
|
+
<StatusDot status={row.status} label={statusLabelText} />
|
|
1470
|
+
{hasDisplayValue(item.incoming) ? (
|
|
1471
|
+
<span>Incoming {formatInventoryValue(item.incoming)}</span>
|
|
1472
|
+
) : null}
|
|
1473
|
+
</div>
|
|
1474
|
+
</td>
|
|
1475
|
+
</tr>
|
|
1476
|
+
);
|
|
1477
|
+
})}
|
|
1478
|
+
</tbody>
|
|
1479
|
+
</table>
|
|
1480
|
+
</div>
|
|
1481
|
+
) : (
|
|
1482
|
+
<div className="eth-domain-inventory-table__empty">No inventory items configured.</div>
|
|
1483
|
+
)}
|
|
1484
|
+
{children}
|
|
1485
|
+
</Surface>
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
export function OrderManagementPanel({
|
|
1490
|
+
title = "Order",
|
|
1491
|
+
description,
|
|
1492
|
+
status,
|
|
1493
|
+
metadata,
|
|
1494
|
+
actions,
|
|
1495
|
+
footer,
|
|
1496
|
+
children,
|
|
1497
|
+
className,
|
|
1498
|
+
items = defaultOrderFulfillmentItems,
|
|
1499
|
+
customer,
|
|
1500
|
+
orderTotal,
|
|
1501
|
+
paymentTerms,
|
|
1502
|
+
shipTo,
|
|
1503
|
+
carrier,
|
|
1504
|
+
eta,
|
|
1505
|
+
fulfillmentOwner = "Fulfillment operations",
|
|
1506
|
+
...props
|
|
1507
|
+
}: OrderManagementPanelProps) {
|
|
1508
|
+
const orderStatus = status ?? deriveOrderStatus(items) ?? "not-started";
|
|
1509
|
+
const currentIndex = currentOrderStepIndex(items);
|
|
1510
|
+
const currentStep = items[currentIndex];
|
|
1511
|
+
const completedCount = items.filter((item) => isOrderStepComplete(item.status)).length;
|
|
1512
|
+
const attentionCount = items.filter(needsOrderAttention).length;
|
|
1513
|
+
const lifecycleHeadingId = React.useId();
|
|
1514
|
+
const executionHeadingId = React.useId();
|
|
1515
|
+
const computedMetadata =
|
|
1516
|
+
metadata ??
|
|
1517
|
+
[
|
|
1518
|
+
{
|
|
1519
|
+
label: "Fulfillment",
|
|
1520
|
+
value: items.length ? `${completedCount}/${items.length} checkpoints` : undefined
|
|
1521
|
+
},
|
|
1522
|
+
{ label: "Carrier", value: carrier },
|
|
1523
|
+
{ label: "ETA", value: eta },
|
|
1524
|
+
{
|
|
1525
|
+
label: "Exceptions",
|
|
1526
|
+
value: attentionCount ? `${attentionCount} open` : "None"
|
|
1527
|
+
}
|
|
1528
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
1529
|
+
const executionSummary = [
|
|
1530
|
+
{ label: "Customer", value: customer },
|
|
1531
|
+
{ label: "Current checkpoint", value: currentStep?.label },
|
|
1532
|
+
{ label: "Owner", value: currentStep?.owner ?? fulfillmentOwner },
|
|
1533
|
+
{ label: "Ship to", value: shipTo },
|
|
1534
|
+
{ label: "Payment terms", value: paymentTerms },
|
|
1535
|
+
{ label: "Order total", value: orderTotal }
|
|
1536
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
1537
|
+
const activeException = items.find(needsOrderAttention);
|
|
1538
|
+
|
|
1539
|
+
return (
|
|
1540
|
+
<Surface
|
|
1541
|
+
{...props}
|
|
1542
|
+
data-eth-component="OrderManagementPanel"
|
|
1543
|
+
title={title}
|
|
1544
|
+
description={description}
|
|
1545
|
+
status={orderStatus}
|
|
1546
|
+
metadata={computedMetadata.length ? computedMetadata : undefined}
|
|
1547
|
+
actions={actions}
|
|
1548
|
+
footer={footer}
|
|
1549
|
+
className={["eth-domain-order-management", className].filter(Boolean).join(" ")}
|
|
1550
|
+
>
|
|
1551
|
+
<div className="eth-domain-order-management__body">
|
|
1552
|
+
<section
|
|
1553
|
+
className="eth-domain-order-management__timeline"
|
|
1554
|
+
aria-labelledby={lifecycleHeadingId}
|
|
1555
|
+
>
|
|
1556
|
+
<div className="eth-domain-order-management__section-header">
|
|
1557
|
+
<div>
|
|
1558
|
+
<span>Fulfillment</span>
|
|
1559
|
+
<h3 id={lifecycleHeadingId}>Lifecycle</h3>
|
|
1560
|
+
</div>
|
|
1561
|
+
<strong>
|
|
1562
|
+
{items.length ? `${completedCount} of ${items.length} complete` : "No checkpoints"}
|
|
1563
|
+
</strong>
|
|
1564
|
+
</div>
|
|
1565
|
+
|
|
1566
|
+
{items.length ? (
|
|
1567
|
+
<ol className="eth-domain-order-management__steps">
|
|
1568
|
+
{items.map((item, index) => {
|
|
1569
|
+
const itemStatus = item.status ?? "not-started";
|
|
1570
|
+
const labelContent = item.href ? (
|
|
1571
|
+
<a href={item.href}>{item.label}</a>
|
|
1572
|
+
) : (
|
|
1573
|
+
item.label
|
|
1574
|
+
);
|
|
1575
|
+
const detailRows = [
|
|
1576
|
+
{ label: "Owner", value: item.owner },
|
|
1577
|
+
{ label: "Location", value: item.location },
|
|
1578
|
+
{ label: "Quantity", value: item.quantity },
|
|
1579
|
+
{ label: "Time", value: item.timestamp ?? item.meta }
|
|
1580
|
+
].filter((detail) => hasDisplayValue(detail.value));
|
|
1581
|
+
|
|
1582
|
+
return (
|
|
1583
|
+
<li
|
|
1584
|
+
key={item.id}
|
|
1585
|
+
aria-current={index === currentIndex ? "step" : undefined}
|
|
1586
|
+
data-current={index === currentIndex ? "true" : undefined}
|
|
1587
|
+
data-status={itemStatus}
|
|
1588
|
+
>
|
|
1589
|
+
<span className="eth-domain-order-management__step-marker" aria-hidden="true">
|
|
1590
|
+
{index + 1}
|
|
1591
|
+
</span>
|
|
1592
|
+
<div className="eth-domain-order-management__step-main">
|
|
1593
|
+
<div className="eth-domain-order-management__step-heading">
|
|
1594
|
+
<strong>{labelContent}</strong>
|
|
1595
|
+
<StatusDot status={itemStatus} label={statusLabel(itemStatus)} />
|
|
1596
|
+
</div>
|
|
1597
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
1598
|
+
{detailRows.length ? (
|
|
1599
|
+
<dl className="eth-domain-order-management__step-meta">
|
|
1600
|
+
{detailRows.map((detail, detailIndex) => (
|
|
1601
|
+
<div key={detailIndex}>
|
|
1602
|
+
<dt>{detail.label}</dt>
|
|
1603
|
+
<dd>{detail.value}</dd>
|
|
1604
|
+
</div>
|
|
1605
|
+
))}
|
|
1606
|
+
</dl>
|
|
1607
|
+
) : null}
|
|
1608
|
+
{item.exception ? (
|
|
1609
|
+
<div className="eth-domain-order-management__step-exception">
|
|
1610
|
+
{item.exception}
|
|
1611
|
+
</div>
|
|
1612
|
+
) : null}
|
|
1613
|
+
<OrderFulfillmentActions actions={item.actions} />
|
|
1614
|
+
</div>
|
|
1615
|
+
</li>
|
|
1616
|
+
);
|
|
1617
|
+
})}
|
|
1618
|
+
</ol>
|
|
1619
|
+
) : (
|
|
1620
|
+
<div className="eth-domain-order-management__empty">
|
|
1621
|
+
No fulfillment checkpoints configured.
|
|
1622
|
+
</div>
|
|
1623
|
+
)}
|
|
1624
|
+
</section>
|
|
1625
|
+
|
|
1626
|
+
<aside
|
|
1627
|
+
className="eth-domain-order-management__execution"
|
|
1628
|
+
aria-labelledby={executionHeadingId}
|
|
1629
|
+
>
|
|
1630
|
+
<div className="eth-domain-order-management__section-header">
|
|
1631
|
+
<div>
|
|
1632
|
+
<span>Execution</span>
|
|
1633
|
+
<h3 id={executionHeadingId}>Order controls</h3>
|
|
1634
|
+
</div>
|
|
1635
|
+
<StatusDot status={orderStatus} label={statusLabel(orderStatus)} />
|
|
1636
|
+
</div>
|
|
1637
|
+
|
|
1638
|
+
{executionSummary.length ? (
|
|
1639
|
+
<dl className="eth-domain-order-management__summary">
|
|
1640
|
+
{executionSummary.map((item, index) => (
|
|
1641
|
+
<div key={index}>
|
|
1642
|
+
<dt>{item.label}</dt>
|
|
1643
|
+
<dd>{item.value}</dd>
|
|
1644
|
+
</div>
|
|
1645
|
+
))}
|
|
1646
|
+
</dl>
|
|
1647
|
+
) : null}
|
|
1648
|
+
|
|
1649
|
+
<div
|
|
1650
|
+
className="eth-domain-order-management__attention"
|
|
1651
|
+
data-status={activeException ? activeException.status ?? "warning" : "completed"}
|
|
1652
|
+
>
|
|
1653
|
+
<span>Exception state</span>
|
|
1654
|
+
<strong>
|
|
1655
|
+
{activeException ? activeException.label : "No active fulfillment exceptions"}
|
|
1656
|
+
</strong>
|
|
1657
|
+
{activeException?.exception ? <p>{activeException.exception}</p> : null}
|
|
1658
|
+
</div>
|
|
1659
|
+
</aside>
|
|
1660
|
+
</div>
|
|
1661
|
+
{children}
|
|
1662
|
+
</Surface>
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const defaultKnowledgeBaseSections: KnowledgeBaseArticleSection[] = [
|
|
1667
|
+
{
|
|
1668
|
+
id: "routing",
|
|
1669
|
+
title: "Approval routing",
|
|
1670
|
+
body: "Requests with external recipients enter legal review before delivery. The route records each approver, decision, exception reason, and timestamp for audit export.",
|
|
1671
|
+
status: "active"
|
|
1672
|
+
},
|
|
1673
|
+
{
|
|
1674
|
+
id: "exceptions",
|
|
1675
|
+
title: "Exception handling",
|
|
1676
|
+
body: "If legal requests a change, the article owner receives a revision task and external send remains disabled until the legal approval returns.",
|
|
1677
|
+
status: "pending-approval"
|
|
1678
|
+
}
|
|
1679
|
+
];
|
|
1680
|
+
|
|
1681
|
+
const defaultKnowledgeBaseRelatedArticles: KnowledgeBaseRelatedArticle[] = [
|
|
1682
|
+
{
|
|
1683
|
+
id: "legal-review-sla",
|
|
1684
|
+
label: "Legal review SLA",
|
|
1685
|
+
description: "Response windows, backup approvers, and overdue escalation paths.",
|
|
1686
|
+
meta: "Policy",
|
|
1687
|
+
type: "Policy",
|
|
1688
|
+
updatedAt: "Updated May 20",
|
|
1689
|
+
status: "active"
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
id: "external-send-controls",
|
|
1693
|
+
label: "External send controls",
|
|
1694
|
+
description: "Controls that keep outbound delivery locked while approval is open.",
|
|
1695
|
+
meta: "Runbook",
|
|
1696
|
+
type: "Runbook",
|
|
1697
|
+
updatedAt: "Updated May 18",
|
|
1698
|
+
status: "in-progress"
|
|
1699
|
+
}
|
|
1700
|
+
];
|
|
1701
|
+
|
|
1702
|
+
const defaultKnowledgeBaseCitations: KnowledgeBaseCitation[] = [
|
|
1703
|
+
{
|
|
1704
|
+
id: "approval-policy",
|
|
1705
|
+
label: "External send approval policy",
|
|
1706
|
+
source: "Policy KB-214",
|
|
1707
|
+
excerpt: "Outbound delivery requires recorded legal approval.",
|
|
1708
|
+
status: "completed"
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
id: "audit-control",
|
|
1712
|
+
label: "Audit evidence control",
|
|
1713
|
+
source: "Control AE-09",
|
|
1714
|
+
excerpt: "Approval decisions must include owner, timestamp, and reason.",
|
|
1715
|
+
status: "active"
|
|
1716
|
+
}
|
|
1717
|
+
];
|
|
1718
|
+
|
|
1719
|
+
function knowledgeBaseStatusLabel(status: EthOperationalStatus | undefined) {
|
|
1720
|
+
return status ? statusLabel(status) : "Published";
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function hasKnowledgeBaseNode(node: React.ReactNode) {
|
|
1724
|
+
return node !== undefined && node !== null && node !== false && node !== "";
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function renderKnowledgeBaseNode(node: React.ReactNode) {
|
|
1728
|
+
if (!hasKnowledgeBaseNode(node)) return null;
|
|
1729
|
+
if (typeof node === "string" || typeof node === "number") return <p>{node}</p>;
|
|
1730
|
+
return node;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
export function ComplianceChecklist({
|
|
1734
|
+
title = "Compliance checklist",
|
|
1735
|
+
description = "Evidence readiness, control ownership, and audit state for the current review.",
|
|
1736
|
+
items = [],
|
|
1737
|
+
metadata,
|
|
1738
|
+
status,
|
|
1739
|
+
footer,
|
|
1740
|
+
children,
|
|
1741
|
+
className,
|
|
1742
|
+
...props
|
|
1743
|
+
}: ComplianceChecklistProps) {
|
|
1744
|
+
const completeCount = items.filter((item) => isComplianceComplete(item.status)).length;
|
|
1745
|
+
const attentionCount = items.filter((item) => needsComplianceAttention(item.status)).length;
|
|
1746
|
+
const evidenceCount = items.filter(hasComplianceEvidence).length;
|
|
1747
|
+
const computedMetadata =
|
|
1748
|
+
metadata ??
|
|
1749
|
+
(items.length
|
|
1750
|
+
? [
|
|
1751
|
+
{ label: "Complete", value: `${completeCount}/${items.length}` },
|
|
1752
|
+
{ label: "Needs attention", value: attentionCount },
|
|
1753
|
+
{ label: "Evidence linked", value: `${evidenceCount}/${items.length}` }
|
|
1754
|
+
]
|
|
1755
|
+
: undefined);
|
|
1756
|
+
|
|
1757
|
+
return (
|
|
1758
|
+
<Surface
|
|
1759
|
+
{...props}
|
|
1760
|
+
data-eth-component="ComplianceChecklist"
|
|
1761
|
+
title={title}
|
|
1762
|
+
description={description}
|
|
1763
|
+
status={status ?? deriveComplianceStatus(items)}
|
|
1764
|
+
metadata={computedMetadata}
|
|
1765
|
+
footer={footer}
|
|
1766
|
+
className={["eth-domain-compliance-checklist", className].filter(Boolean).join(" ")}
|
|
1767
|
+
>
|
|
1768
|
+
{items.length ? (
|
|
1769
|
+
<div className="eth-domain-compliance-checklist__table-wrap">
|
|
1770
|
+
<table className="eth-domain-compliance-checklist__table">
|
|
1771
|
+
<caption className="eth-domain-compliance-checklist__caption">
|
|
1772
|
+
Compliance evidence checklist
|
|
1773
|
+
</caption>
|
|
1774
|
+
<colgroup>
|
|
1775
|
+
<col className="eth-domain-compliance-checklist__control-col" />
|
|
1776
|
+
<col className="eth-domain-compliance-checklist__owner-col" />
|
|
1777
|
+
<col className="eth-domain-compliance-checklist__evidence-col" />
|
|
1778
|
+
<col className="eth-domain-compliance-checklist__state-col" />
|
|
1779
|
+
</colgroup>
|
|
1780
|
+
<thead>
|
|
1781
|
+
<tr>
|
|
1782
|
+
<th scope="col">Control</th>
|
|
1783
|
+
<th scope="col">Owner</th>
|
|
1784
|
+
<th scope="col">Evidence</th>
|
|
1785
|
+
<th scope="col">Audit state</th>
|
|
1786
|
+
</tr>
|
|
1787
|
+
</thead>
|
|
1788
|
+
<tbody>
|
|
1789
|
+
{items.map((item) => {
|
|
1790
|
+
const itemStatus = item.status ?? "not-started";
|
|
1791
|
+
const labelContent = item.href ? <a href={item.href}>{item.label}</a> : item.label;
|
|
1792
|
+
const evidenceContent = item.evidence ?? item.evidenceLabel ?? item.meta;
|
|
1793
|
+
|
|
1794
|
+
return (
|
|
1795
|
+
<tr key={item.id} data-status={itemStatus}>
|
|
1796
|
+
<td>
|
|
1797
|
+
<div className="eth-domain-compliance-checklist__control">
|
|
1798
|
+
{item.controlId ? (
|
|
1799
|
+
<span className="eth-domain-compliance-checklist__control-id">
|
|
1800
|
+
{item.controlId}
|
|
1801
|
+
</span>
|
|
1802
|
+
) : null}
|
|
1803
|
+
<strong>{labelContent}</strong>
|
|
1804
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
1805
|
+
</div>
|
|
1806
|
+
</td>
|
|
1807
|
+
<td>
|
|
1808
|
+
<div className="eth-domain-compliance-checklist__cell-stack">
|
|
1809
|
+
<strong>{item.owner ?? "Unassigned"}</strong>
|
|
1810
|
+
{item.dueDate ? (
|
|
1811
|
+
<span className="eth-domain-compliance-checklist__secondary">
|
|
1812
|
+
Due {item.dueDate}
|
|
1813
|
+
</span>
|
|
1814
|
+
) : null}
|
|
1815
|
+
</div>
|
|
1816
|
+
</td>
|
|
1817
|
+
<td>
|
|
1818
|
+
<div className="eth-domain-compliance-checklist__cell-stack">
|
|
1819
|
+
{evidenceContent ? (
|
|
1820
|
+
item.evidenceHref ? (
|
|
1821
|
+
<a
|
|
1822
|
+
className="eth-domain-compliance-checklist__evidence-link"
|
|
1823
|
+
href={item.evidenceHref}
|
|
1824
|
+
>
|
|
1825
|
+
{evidenceContent}
|
|
1826
|
+
</a>
|
|
1827
|
+
) : (
|
|
1828
|
+
<strong>{evidenceContent}</strong>
|
|
1829
|
+
)
|
|
1830
|
+
) : (
|
|
1831
|
+
<span className="eth-domain-compliance-checklist__secondary">
|
|
1832
|
+
Evidence not linked
|
|
1833
|
+
</span>
|
|
1834
|
+
)}
|
|
1835
|
+
{item.updatedAt ? (
|
|
1836
|
+
<span className="eth-domain-compliance-checklist__secondary">
|
|
1837
|
+
{item.updatedAt}
|
|
1838
|
+
</span>
|
|
1839
|
+
) : null}
|
|
1840
|
+
</div>
|
|
1841
|
+
</td>
|
|
1842
|
+
<td>
|
|
1843
|
+
<div className="eth-domain-compliance-checklist__state">
|
|
1844
|
+
<StatusDot status={itemStatus} label={statusLabel(itemStatus)} />
|
|
1845
|
+
{item.required ? (
|
|
1846
|
+
<span className="eth-domain-compliance-checklist__requirement">
|
|
1847
|
+
Required
|
|
1848
|
+
</span>
|
|
1849
|
+
) : null}
|
|
1850
|
+
</div>
|
|
1851
|
+
</td>
|
|
1852
|
+
</tr>
|
|
1853
|
+
);
|
|
1854
|
+
})}
|
|
1855
|
+
</tbody>
|
|
1856
|
+
</table>
|
|
1857
|
+
</div>
|
|
1858
|
+
) : (
|
|
1859
|
+
<div className="eth-domain-compliance-checklist__empty">
|
|
1860
|
+
No compliance controls configured.
|
|
1861
|
+
</div>
|
|
1862
|
+
)}
|
|
1863
|
+
{children}
|
|
1864
|
+
</Surface>
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function RiskMatrixActions({ actions }: { actions?: EthAction[] }) {
|
|
1869
|
+
if (!actions?.length) return null;
|
|
1870
|
+
|
|
1871
|
+
return (
|
|
1872
|
+
<div className="eth-domain-risk-matrix__item-actions">
|
|
1873
|
+
{actions.map((action) =>
|
|
1874
|
+
action.href ? (
|
|
1875
|
+
<LinkButton
|
|
1876
|
+
key={action.id}
|
|
1877
|
+
href={action.disabled ? undefined : action.href}
|
|
1878
|
+
intent={action.intent ?? "ghost"}
|
|
1879
|
+
density="compact"
|
|
1880
|
+
aria-disabled={action.disabled ? true : undefined}
|
|
1881
|
+
tabIndex={action.disabled ? -1 : undefined}
|
|
1882
|
+
>
|
|
1883
|
+
{action.label}
|
|
1884
|
+
</LinkButton>
|
|
1885
|
+
) : (
|
|
1886
|
+
<Button
|
|
1887
|
+
key={action.id}
|
|
1888
|
+
type="button"
|
|
1889
|
+
intent={action.intent ?? "ghost"}
|
|
1890
|
+
density="compact"
|
|
1891
|
+
disabled={action.disabled}
|
|
1892
|
+
onClick={action.onSelect}
|
|
1893
|
+
>
|
|
1894
|
+
{action.label}
|
|
1895
|
+
</Button>
|
|
1896
|
+
)
|
|
1897
|
+
)}
|
|
1898
|
+
</div>
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function RiskMatrixFact({
|
|
1903
|
+
label,
|
|
1904
|
+
value
|
|
1905
|
+
}: {
|
|
1906
|
+
label: React.ReactNode;
|
|
1907
|
+
value: React.ReactNode;
|
|
1908
|
+
}) {
|
|
1909
|
+
if (!hasDisplayValue(value)) return null;
|
|
1910
|
+
|
|
1911
|
+
return (
|
|
1912
|
+
<div>
|
|
1913
|
+
<dt>{label}</dt>
|
|
1914
|
+
<dd>{value}</dd>
|
|
1915
|
+
</div>
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
export function RiskMatrix({
|
|
1920
|
+
title = "Risk register",
|
|
1921
|
+
description = "Impact and probability register with mitigation ownership.",
|
|
1922
|
+
status,
|
|
1923
|
+
severity,
|
|
1924
|
+
metadata,
|
|
1925
|
+
actions,
|
|
1926
|
+
footer,
|
|
1927
|
+
children,
|
|
1928
|
+
className,
|
|
1929
|
+
items = [],
|
|
1930
|
+
selectedItemId,
|
|
1931
|
+
view,
|
|
1932
|
+
reviewCadence,
|
|
1933
|
+
onViewChange,
|
|
1934
|
+
...props
|
|
1935
|
+
}: RiskMatrixProps) {
|
|
1936
|
+
const matrixHeadingId = React.useId();
|
|
1937
|
+
const registerHeadingId = React.useId();
|
|
1938
|
+
const [internalView, setInternalView] = React.useState<RiskMatrixView>("matrix");
|
|
1939
|
+
const currentView = view ?? internalView;
|
|
1940
|
+
const risks = items.map(resolveRiskMatrixItem).sort(compareRiskPriority);
|
|
1941
|
+
const priorityRisk = selectedItemId
|
|
1942
|
+
? risks.find((item) => item.id === selectedItemId) ?? risks[0]
|
|
1943
|
+
: risks[0];
|
|
1944
|
+
const criticalCount = risks.filter((item) => item.resolvedSeverity === "critical").length;
|
|
1945
|
+
const highCount = risks.filter((item) => item.resolvedSeverity === "high").length;
|
|
1946
|
+
const mediumCount = risks.filter((item) => item.resolvedSeverity === "medium").length;
|
|
1947
|
+
const priorityStatus = priorityRisk
|
|
1948
|
+
? priorityRisk.status ?? deriveRiskMatrixStatus([priorityRisk]) ?? "active"
|
|
1949
|
+
: "active";
|
|
1950
|
+
const computedMetadata =
|
|
1951
|
+
metadata ??
|
|
1952
|
+
[
|
|
1953
|
+
{ label: "Open risks", value: risks.length },
|
|
1954
|
+
{ label: "Critical", value: criticalCount },
|
|
1955
|
+
{ label: "High", value: highCount },
|
|
1956
|
+
{ label: "Medium", value: mediumCount }
|
|
1957
|
+
];
|
|
1958
|
+
const changeView = (nextView: RiskMatrixView) => {
|
|
1959
|
+
if (view === undefined) setInternalView(nextView);
|
|
1960
|
+
onViewChange?.(nextView);
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
return (
|
|
1964
|
+
<Surface
|
|
1965
|
+
{...props}
|
|
1966
|
+
data-eth-component="RiskMatrix"
|
|
1967
|
+
title={title}
|
|
1968
|
+
description={description}
|
|
1969
|
+
status={status ?? deriveRiskMatrixStatus(risks)}
|
|
1970
|
+
severity={severity ?? deriveRiskMatrixSurfaceSeverity(risks)}
|
|
1971
|
+
metadata={computedMetadata}
|
|
1972
|
+
actions={actions}
|
|
1973
|
+
footer={footer}
|
|
1974
|
+
className={["eth-domain-risk-matrix", className].filter(Boolean).join(" ")}
|
|
1975
|
+
>
|
|
1976
|
+
<div
|
|
1977
|
+
className="eth-domain-risk-matrix__toolbar"
|
|
1978
|
+
role="group"
|
|
1979
|
+
aria-label="Risk matrix controls"
|
|
1980
|
+
>
|
|
1981
|
+
<div className="eth-domain-risk-matrix__view-switch" role="group" aria-label="Risk view">
|
|
1982
|
+
<Button
|
|
1983
|
+
type="button"
|
|
1984
|
+
intent={currentView === "matrix" ? "secondary" : "ghost"}
|
|
1985
|
+
density="compact"
|
|
1986
|
+
aria-label="Matrix view"
|
|
1987
|
+
aria-pressed={currentView === "matrix"}
|
|
1988
|
+
onClick={() => changeView("matrix")}
|
|
1989
|
+
>
|
|
1990
|
+
Matrix
|
|
1991
|
+
</Button>
|
|
1992
|
+
<Button
|
|
1993
|
+
type="button"
|
|
1994
|
+
intent={currentView === "register" ? "secondary" : "ghost"}
|
|
1995
|
+
density="compact"
|
|
1996
|
+
aria-label="Register view"
|
|
1997
|
+
aria-pressed={currentView === "register"}
|
|
1998
|
+
onClick={() => changeView("register")}
|
|
1999
|
+
>
|
|
2000
|
+
Register
|
|
2001
|
+
</Button>
|
|
2002
|
+
</div>
|
|
2003
|
+
{reviewCadence ? (
|
|
2004
|
+
<span className="eth-domain-risk-matrix__cadence">
|
|
2005
|
+
Review <strong>{reviewCadence}</strong>
|
|
2006
|
+
</span>
|
|
2007
|
+
) : null}
|
|
2008
|
+
{criticalCount || highCount ? (
|
|
2009
|
+
<span className="eth-domain-risk-matrix__attention">
|
|
2010
|
+
{criticalCount} critical / {highCount} high
|
|
2011
|
+
</span>
|
|
2012
|
+
) : null}
|
|
2013
|
+
</div>
|
|
2014
|
+
|
|
2015
|
+
{risks.length ? (
|
|
2016
|
+
<div className="eth-domain-risk-matrix__workspace" data-view={currentView}>
|
|
2017
|
+
{currentView === "matrix" ? (
|
|
2018
|
+
<section
|
|
2019
|
+
className="eth-domain-risk-matrix__matrix-panel"
|
|
2020
|
+
aria-labelledby={matrixHeadingId}
|
|
2021
|
+
>
|
|
2022
|
+
<div className="eth-domain-risk-matrix__panel-header">
|
|
2023
|
+
<div>
|
|
2024
|
+
<span>Matrix</span>
|
|
2025
|
+
<h3 id={matrixHeadingId}>Impact x probability</h3>
|
|
2026
|
+
</div>
|
|
2027
|
+
<div
|
|
2028
|
+
className="eth-domain-risk-matrix__legend"
|
|
2029
|
+
role="list"
|
|
2030
|
+
aria-label="Risk severity legend"
|
|
2031
|
+
>
|
|
2032
|
+
{(["low", "medium", "high", "critical"] as RiskMatrixSeverity[]).map(
|
|
2033
|
+
(riskSeverity) => (
|
|
2034
|
+
<span key={riskSeverity} data-severity={riskSeverity} role="listitem">
|
|
2035
|
+
{riskImpactLabels[riskSeverity]}
|
|
2036
|
+
</span>
|
|
2037
|
+
)
|
|
2038
|
+
)}
|
|
2039
|
+
</div>
|
|
2040
|
+
</div>
|
|
2041
|
+
|
|
2042
|
+
<div
|
|
2043
|
+
className="eth-domain-risk-matrix__grid"
|
|
2044
|
+
role="grid"
|
|
2045
|
+
aria-label="Impact probability risk matrix"
|
|
2046
|
+
>
|
|
2047
|
+
<div
|
|
2048
|
+
className="eth-domain-risk-matrix__grid-row eth-domain-risk-matrix__grid-row--header"
|
|
2049
|
+
role="row"
|
|
2050
|
+
>
|
|
2051
|
+
<div className="eth-domain-risk-matrix__corner" role="columnheader">
|
|
2052
|
+
Impact
|
|
2053
|
+
</div>
|
|
2054
|
+
{riskProbabilityLevels.map((probability) => (
|
|
2055
|
+
<div
|
|
2056
|
+
key={probability}
|
|
2057
|
+
className="eth-domain-risk-matrix__axis"
|
|
2058
|
+
role="columnheader"
|
|
2059
|
+
>
|
|
2060
|
+
<span>Probability</span>
|
|
2061
|
+
<strong>{riskProbabilityLabels[probability]}</strong>
|
|
2062
|
+
</div>
|
|
2063
|
+
))}
|
|
2064
|
+
</div>
|
|
2065
|
+
|
|
2066
|
+
{riskImpactLevels.map((impact) => (
|
|
2067
|
+
<div key={impact} className="eth-domain-risk-matrix__grid-row" role="row">
|
|
2068
|
+
<div
|
|
2069
|
+
className="eth-domain-risk-matrix__axis eth-domain-risk-matrix__row-axis"
|
|
2070
|
+
role="rowheader"
|
|
2071
|
+
>
|
|
2072
|
+
<span>Impact</span>
|
|
2073
|
+
<strong>{riskImpactLabels[impact]}</strong>
|
|
2074
|
+
</div>
|
|
2075
|
+
{riskProbabilityLevels.map((probability) => {
|
|
2076
|
+
const cellRisks = risks.filter(
|
|
2077
|
+
(item) =>
|
|
2078
|
+
item.resolvedImpact === impact && item.resolvedProbability === probability
|
|
2079
|
+
);
|
|
2080
|
+
const cellSeverity =
|
|
2081
|
+
cellRisks[0]?.resolvedSeverity ??
|
|
2082
|
+
riskSeverityFromScore(impact, probability);
|
|
2083
|
+
|
|
2084
|
+
return (
|
|
2085
|
+
<div
|
|
2086
|
+
key={`${impact}-${probability}`}
|
|
2087
|
+
className="eth-domain-risk-matrix__cell"
|
|
2088
|
+
data-severity={cellSeverity}
|
|
2089
|
+
role="gridcell"
|
|
2090
|
+
aria-label={`${riskImpactLabels[impact]} impact, ${riskProbabilityLabels[probability]} probability: ${riskCountLabel(cellRisks.length)}`}
|
|
2091
|
+
>
|
|
2092
|
+
{cellRisks.length ? (
|
|
2093
|
+
<div className="eth-domain-risk-matrix__cell-risks" role="list">
|
|
2094
|
+
{cellRisks.map((risk) => {
|
|
2095
|
+
const labelContent = risk.href ? (
|
|
2096
|
+
<a href={risk.href}>{risk.label}</a>
|
|
2097
|
+
) : (
|
|
2098
|
+
risk.label
|
|
2099
|
+
);
|
|
2100
|
+
|
|
2101
|
+
return (
|
|
2102
|
+
<article
|
|
2103
|
+
key={risk.id}
|
|
2104
|
+
className="eth-domain-risk-matrix__risk-card"
|
|
2105
|
+
data-severity={risk.resolvedSeverity}
|
|
2106
|
+
role="listitem"
|
|
2107
|
+
>
|
|
2108
|
+
<span className="eth-domain-risk-matrix__risk-score">
|
|
2109
|
+
{risk.score ?? risk.resolvedScore}
|
|
2110
|
+
</span>
|
|
2111
|
+
<div className="eth-domain-risk-matrix__risk-card-main">
|
|
2112
|
+
<strong>{labelContent}</strong>
|
|
2113
|
+
<div className="eth-domain-risk-matrix__risk-card-meta">
|
|
2114
|
+
{risk.owner ? <span>{risk.owner}</span> : null}
|
|
2115
|
+
{risk.dueDate ? <span>{risk.dueDate}</span> : null}
|
|
2116
|
+
</div>
|
|
2117
|
+
</div>
|
|
2118
|
+
</article>
|
|
2119
|
+
);
|
|
2120
|
+
})}
|
|
2121
|
+
</div>
|
|
2122
|
+
) : (
|
|
2123
|
+
<span className="eth-domain-risk-matrix__empty-cell">No risks</span>
|
|
2124
|
+
)}
|
|
2125
|
+
</div>
|
|
2126
|
+
);
|
|
2127
|
+
})}
|
|
2128
|
+
</div>
|
|
2129
|
+
))}
|
|
2130
|
+
</div>
|
|
2131
|
+
</section>
|
|
2132
|
+
) : null}
|
|
2133
|
+
|
|
2134
|
+
<aside className="eth-domain-risk-matrix__detail" aria-label="Highest priority risk">
|
|
2135
|
+
{priorityRisk ? (
|
|
2136
|
+
<>
|
|
2137
|
+
<div className="eth-domain-risk-matrix__panel-header">
|
|
2138
|
+
<div>
|
|
2139
|
+
<span>Highest priority</span>
|
|
2140
|
+
<h3>{priorityRisk.label}</h3>
|
|
2141
|
+
</div>
|
|
2142
|
+
<StatusDot status={priorityStatus} label={statusLabel(priorityStatus)} />
|
|
2143
|
+
</div>
|
|
2144
|
+
{priorityRisk.description ? <p>{priorityRisk.description}</p> : null}
|
|
2145
|
+
<dl className="eth-domain-risk-matrix__facts">
|
|
2146
|
+
<RiskMatrixFact
|
|
2147
|
+
label="Impact"
|
|
2148
|
+
value={riskImpactLabels[priorityRisk.resolvedImpact]}
|
|
2149
|
+
/>
|
|
2150
|
+
<RiskMatrixFact
|
|
2151
|
+
label="Probability"
|
|
2152
|
+
value={riskProbabilityLabels[priorityRisk.resolvedProbability]}
|
|
2153
|
+
/>
|
|
2154
|
+
<RiskMatrixFact label="Owner" value={priorityRisk.owner} />
|
|
2155
|
+
<RiskMatrixFact label="Due" value={priorityRisk.dueDate} />
|
|
2156
|
+
<RiskMatrixFact
|
|
2157
|
+
label="Score"
|
|
2158
|
+
value={priorityRisk.score ?? priorityRisk.resolvedScore}
|
|
2159
|
+
/>
|
|
2160
|
+
<RiskMatrixFact label="Review" value={reviewCadence} />
|
|
2161
|
+
</dl>
|
|
2162
|
+
{priorityRisk.mitigation ? (
|
|
2163
|
+
<div className="eth-domain-risk-matrix__mitigation">
|
|
2164
|
+
<span>Mitigation</span>
|
|
2165
|
+
<strong>{priorityRisk.mitigation}</strong>
|
|
2166
|
+
</div>
|
|
2167
|
+
) : null}
|
|
2168
|
+
<RiskMatrixActions actions={priorityRisk.actions} />
|
|
2169
|
+
</>
|
|
2170
|
+
) : (
|
|
2171
|
+
<p className="eth-domain-risk-matrix__empty">No priority risk selected.</p>
|
|
2172
|
+
)}
|
|
2173
|
+
|
|
2174
|
+
<section
|
|
2175
|
+
className="eth-domain-risk-matrix__register"
|
|
2176
|
+
aria-labelledby={registerHeadingId}
|
|
2177
|
+
>
|
|
2178
|
+
<div className="eth-domain-risk-matrix__panel-header">
|
|
2179
|
+
<div>
|
|
2180
|
+
<span>Register</span>
|
|
2181
|
+
<h3 id={registerHeadingId}>Mapped risks</h3>
|
|
2182
|
+
</div>
|
|
2183
|
+
<strong>{riskCountLabel(risks.length)}</strong>
|
|
2184
|
+
</div>
|
|
2185
|
+
<div className="eth-domain-risk-matrix__register-list" role="list">
|
|
2186
|
+
{risks.map((risk) => {
|
|
2187
|
+
const itemStatus = risk.status ?? deriveRiskMatrixStatus([risk]) ?? "active";
|
|
2188
|
+
return (
|
|
2189
|
+
<article
|
|
2190
|
+
key={risk.id}
|
|
2191
|
+
className="eth-domain-risk-matrix__register-item"
|
|
2192
|
+
data-severity={risk.resolvedSeverity}
|
|
2193
|
+
role="listitem"
|
|
2194
|
+
>
|
|
2195
|
+
<div>
|
|
2196
|
+
<strong>{risk.label}</strong>
|
|
2197
|
+
<span>
|
|
2198
|
+
{riskImpactLabels[risk.resolvedImpact]} /{" "}
|
|
2199
|
+
{riskProbabilityLabels[risk.resolvedProbability]}
|
|
2200
|
+
</span>
|
|
2201
|
+
</div>
|
|
2202
|
+
<StatusDot status={itemStatus} label={statusLabel(itemStatus)} />
|
|
2203
|
+
</article>
|
|
2204
|
+
);
|
|
2205
|
+
})}
|
|
2206
|
+
</div>
|
|
2207
|
+
</section>
|
|
2208
|
+
</aside>
|
|
2209
|
+
</div>
|
|
2210
|
+
) : (
|
|
2211
|
+
<div className="eth-domain-risk-matrix__empty">No risks mapped.</div>
|
|
2212
|
+
)}
|
|
2213
|
+
|
|
2214
|
+
{children}
|
|
2215
|
+
</Surface>
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
export function InvoiceViewer({
|
|
2220
|
+
title = "Invoice",
|
|
2221
|
+
description,
|
|
2222
|
+
status = "pending-approval",
|
|
2223
|
+
items = [],
|
|
2224
|
+
metadata,
|
|
2225
|
+
actions,
|
|
2226
|
+
footer,
|
|
2227
|
+
children,
|
|
2228
|
+
className,
|
|
2229
|
+
customer,
|
|
2230
|
+
dueDate,
|
|
2231
|
+
terms,
|
|
2232
|
+
owner,
|
|
2233
|
+
subtotal,
|
|
2234
|
+
tax,
|
|
2235
|
+
total,
|
|
2236
|
+
amountDue,
|
|
2237
|
+
payments = [],
|
|
2238
|
+
...props
|
|
2239
|
+
}: InvoiceViewerProps) {
|
|
2240
|
+
const computedMetadata =
|
|
2241
|
+
metadata ??
|
|
2242
|
+
[
|
|
2243
|
+
{ label: "Amount due", value: amountDue ?? total },
|
|
2244
|
+
{ label: "Due date", value: dueDate },
|
|
2245
|
+
{ label: "Terms", value: terms },
|
|
2246
|
+
{ label: "Owner", value: owner ?? customer }
|
|
2247
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
2248
|
+
const summaryRows: Array<{
|
|
2249
|
+
label: React.ReactNode;
|
|
2250
|
+
value: React.ReactNode;
|
|
2251
|
+
emphasis?: boolean;
|
|
2252
|
+
}> = [
|
|
2253
|
+
{ label: "Subtotal", value: subtotal },
|
|
2254
|
+
{ label: "Tax", value: tax },
|
|
2255
|
+
{ label: "Total", value: total },
|
|
2256
|
+
{ label: "Amount due", value: amountDue ?? total, emphasis: true }
|
|
2257
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
2258
|
+
|
|
2259
|
+
return (
|
|
2260
|
+
<Surface
|
|
2261
|
+
{...props}
|
|
2262
|
+
data-eth-component="InvoiceViewer"
|
|
2263
|
+
title={title}
|
|
2264
|
+
description={description}
|
|
2265
|
+
status={status}
|
|
2266
|
+
metadata={computedMetadata.length ? computedMetadata : undefined}
|
|
2267
|
+
actions={actions}
|
|
2268
|
+
footer={footer}
|
|
2269
|
+
className={["eth-domain-invoice-viewer", className].filter(Boolean).join(" ")}
|
|
2270
|
+
>
|
|
2271
|
+
<div className="eth-domain-invoice-viewer__body">
|
|
2272
|
+
<section className="eth-domain-invoice-viewer__section" aria-label="Invoice line items">
|
|
2273
|
+
<div className="eth-domain-invoice-viewer__section-header">
|
|
2274
|
+
<div>
|
|
2275
|
+
<span>Charges</span>
|
|
2276
|
+
<h3>Line items</h3>
|
|
2277
|
+
</div>
|
|
2278
|
+
<strong>
|
|
2279
|
+
{items.length} item{items.length === 1 ? "" : "s"}
|
|
2280
|
+
</strong>
|
|
2281
|
+
</div>
|
|
2282
|
+
|
|
2283
|
+
{items.length ? (
|
|
2284
|
+
<div className="eth-domain-invoice-viewer__table-wrap">
|
|
2285
|
+
<table className="eth-domain-invoice-viewer__table">
|
|
2286
|
+
<caption className="eth-domain-invoice-viewer__caption">Invoice line items</caption>
|
|
2287
|
+
<thead>
|
|
2288
|
+
<tr>
|
|
2289
|
+
<th scope="col">Item</th>
|
|
2290
|
+
<th scope="col">Qty</th>
|
|
2291
|
+
<th scope="col">Rate</th>
|
|
2292
|
+
<th scope="col">Amount</th>
|
|
2293
|
+
</tr>
|
|
2294
|
+
</thead>
|
|
2295
|
+
<tbody>
|
|
2296
|
+
{items.map((item) => {
|
|
2297
|
+
const itemStatus = item.status ?? "not-started";
|
|
2298
|
+
const labelContent = item.href ? (
|
|
2299
|
+
<a href={item.href}>{item.label}</a>
|
|
2300
|
+
) : (
|
|
2301
|
+
item.label
|
|
2302
|
+
);
|
|
2303
|
+
|
|
2304
|
+
return (
|
|
2305
|
+
<tr key={item.id} data-status={itemStatus}>
|
|
2306
|
+
<td>
|
|
2307
|
+
<div className="eth-domain-invoice-viewer__line-item">
|
|
2308
|
+
<strong>{labelContent}</strong>
|
|
2309
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
2310
|
+
{item.status ? (
|
|
2311
|
+
<StatusDot status={item.status} label={statusLabel(item.status)} />
|
|
2312
|
+
) : null}
|
|
2313
|
+
</div>
|
|
2314
|
+
</td>
|
|
2315
|
+
<td>{item.quantity ?? "1"}</td>
|
|
2316
|
+
<td>{item.rate ?? "-"}</td>
|
|
2317
|
+
<td>{item.amount ?? item.meta ?? "-"}</td>
|
|
2318
|
+
</tr>
|
|
2319
|
+
);
|
|
2320
|
+
})}
|
|
2321
|
+
</tbody>
|
|
2322
|
+
</table>
|
|
2323
|
+
</div>
|
|
2324
|
+
) : (
|
|
2325
|
+
<p className="eth-domain-invoice-viewer__empty">No invoice line items available.</p>
|
|
2326
|
+
)}
|
|
2327
|
+
</section>
|
|
2328
|
+
|
|
2329
|
+
<aside className="eth-domain-invoice-viewer__section" aria-label="Payment summary">
|
|
2330
|
+
<div className="eth-domain-invoice-viewer__section-header">
|
|
2331
|
+
<div>
|
|
2332
|
+
<span>Payment</span>
|
|
2333
|
+
<h3>Summary</h3>
|
|
2334
|
+
</div>
|
|
2335
|
+
<StatusDot status={status} label={statusLabel(status)} />
|
|
2336
|
+
</div>
|
|
2337
|
+
|
|
2338
|
+
{summaryRows.length ? (
|
|
2339
|
+
<dl className="eth-domain-invoice-viewer__totals">
|
|
2340
|
+
{summaryRows.map((row, index) => (
|
|
2341
|
+
<div
|
|
2342
|
+
key={index}
|
|
2343
|
+
className={row.emphasis ? "eth-domain-invoice-viewer__total" : undefined}
|
|
2344
|
+
>
|
|
2345
|
+
<dt>{row.label}</dt>
|
|
2346
|
+
<dd>{row.value}</dd>
|
|
2347
|
+
</div>
|
|
2348
|
+
))}
|
|
2349
|
+
</dl>
|
|
2350
|
+
) : null}
|
|
2351
|
+
|
|
2352
|
+
{payments.length ? (
|
|
2353
|
+
<ol className="eth-domain-invoice-viewer__payments" aria-label="Payment milestones">
|
|
2354
|
+
{payments.map((payment) => {
|
|
2355
|
+
const paymentStatus = payment.status ?? "queued";
|
|
2356
|
+
|
|
2357
|
+
return (
|
|
2358
|
+
<li key={payment.id} data-status={paymentStatus}>
|
|
2359
|
+
<div className="eth-domain-invoice-viewer__payment-main">
|
|
2360
|
+
<strong>{payment.label}</strong>
|
|
2361
|
+
{payment.dueDate || payment.description ? (
|
|
2362
|
+
<span>{payment.dueDate ?? payment.description}</span>
|
|
2363
|
+
) : null}
|
|
2364
|
+
</div>
|
|
2365
|
+
{hasDisplayValue(payment.amount ?? payment.meta) ? (
|
|
2366
|
+
<span className="eth-domain-invoice-viewer__payment-amount">
|
|
2367
|
+
{payment.amount ?? payment.meta}
|
|
2368
|
+
</span>
|
|
2369
|
+
) : null}
|
|
2370
|
+
</li>
|
|
2371
|
+
);
|
|
2372
|
+
})}
|
|
2373
|
+
</ol>
|
|
2374
|
+
) : null}
|
|
2375
|
+
</aside>
|
|
2376
|
+
</div>
|
|
2377
|
+
{children}
|
|
2378
|
+
</Surface>
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
export function KnowledgeBaseArticleViewer({
|
|
2383
|
+
title = "Knowledge base article",
|
|
2384
|
+
description,
|
|
2385
|
+
status = "active",
|
|
2386
|
+
metadata,
|
|
2387
|
+
actions,
|
|
2388
|
+
footer,
|
|
2389
|
+
children,
|
|
2390
|
+
className,
|
|
2391
|
+
body,
|
|
2392
|
+
sections,
|
|
2393
|
+
relatedArticles,
|
|
2394
|
+
citations = defaultKnowledgeBaseCitations,
|
|
2395
|
+
items,
|
|
2396
|
+
articleStatusLabel,
|
|
2397
|
+
...props
|
|
2398
|
+
}: KnowledgeBaseArticleViewerProps) {
|
|
2399
|
+
const hasBody = hasKnowledgeBaseNode(body) || React.Children.count(children) > 0;
|
|
2400
|
+
const articleSections = sections ?? (hasBody ? [] : defaultKnowledgeBaseSections);
|
|
2401
|
+
const related = relatedArticles ?? items ?? defaultKnowledgeBaseRelatedArticles;
|
|
2402
|
+
const articleHeadingId = React.useId();
|
|
2403
|
+
const relatedHeadingId = React.useId();
|
|
2404
|
+
const citationsHeadingId = React.useId();
|
|
2405
|
+
|
|
2406
|
+
return (
|
|
2407
|
+
<Surface
|
|
2408
|
+
{...props}
|
|
2409
|
+
data-eth-component="KnowledgeBaseArticleViewer"
|
|
2410
|
+
title={title}
|
|
2411
|
+
description={description}
|
|
2412
|
+
status={status}
|
|
2413
|
+
metadata={metadata}
|
|
2414
|
+
actions={actions}
|
|
2415
|
+
footer={footer}
|
|
2416
|
+
className={["eth-domain-knowledge-base-viewer", className].filter(Boolean).join(" ")}
|
|
2417
|
+
>
|
|
2418
|
+
<div className="eth-domain-knowledge-base-viewer__workspace">
|
|
2419
|
+
<article
|
|
2420
|
+
className="eth-domain-knowledge-base-viewer__article"
|
|
2421
|
+
aria-labelledby={articleHeadingId}
|
|
2422
|
+
>
|
|
2423
|
+
<div className="eth-domain-knowledge-base-viewer__article-header">
|
|
2424
|
+
<div>
|
|
2425
|
+
<span>Article body</span>
|
|
2426
|
+
<h3 id={articleHeadingId}>Guidance</h3>
|
|
2427
|
+
</div>
|
|
2428
|
+
<StatusDot
|
|
2429
|
+
status={status}
|
|
2430
|
+
label={articleStatusLabel ?? knowledgeBaseStatusLabel(status)}
|
|
2431
|
+
/>
|
|
2432
|
+
</div>
|
|
2433
|
+
|
|
2434
|
+
{hasBody ? (
|
|
2435
|
+
<div className="eth-domain-knowledge-base-viewer__body">
|
|
2436
|
+
{renderKnowledgeBaseNode(body)}
|
|
2437
|
+
{children}
|
|
2438
|
+
</div>
|
|
2439
|
+
) : null}
|
|
2440
|
+
|
|
2441
|
+
{articleSections.length ? (
|
|
2442
|
+
<div className="eth-domain-knowledge-base-viewer__sections">
|
|
2443
|
+
{articleSections.map((section) => (
|
|
2444
|
+
<section
|
|
2445
|
+
key={section.id}
|
|
2446
|
+
className="eth-domain-knowledge-base-viewer__section"
|
|
2447
|
+
data-status={section.status}
|
|
2448
|
+
>
|
|
2449
|
+
<div className="eth-domain-knowledge-base-viewer__section-heading">
|
|
2450
|
+
<h4>{section.title}</h4>
|
|
2451
|
+
{section.status ? (
|
|
2452
|
+
<StatusDot status={section.status} label={statusLabel(section.status)} />
|
|
2453
|
+
) : null}
|
|
2454
|
+
</div>
|
|
2455
|
+
{renderKnowledgeBaseNode(section.body)}
|
|
2456
|
+
</section>
|
|
2457
|
+
))}
|
|
2458
|
+
</div>
|
|
2459
|
+
) : null}
|
|
2460
|
+
</article>
|
|
2461
|
+
|
|
2462
|
+
<aside className="eth-domain-knowledge-base-viewer__rail">
|
|
2463
|
+
<section
|
|
2464
|
+
className="eth-domain-knowledge-base-viewer__panel"
|
|
2465
|
+
aria-labelledby={relatedHeadingId}
|
|
2466
|
+
>
|
|
2467
|
+
<div className="eth-domain-knowledge-base-viewer__panel-header">
|
|
2468
|
+
<span>Knowledge graph</span>
|
|
2469
|
+
<h3 id={relatedHeadingId}>Related content</h3>
|
|
2470
|
+
</div>
|
|
2471
|
+
{related.length ? (
|
|
2472
|
+
<div className="eth-domain-knowledge-base-viewer__related-list" role="list">
|
|
2473
|
+
{related.map((item) => {
|
|
2474
|
+
const labelContent = item.href ? (
|
|
2475
|
+
<a href={item.href}>{item.label}</a>
|
|
2476
|
+
) : (
|
|
2477
|
+
item.label
|
|
2478
|
+
);
|
|
2479
|
+
return (
|
|
2480
|
+
<article
|
|
2481
|
+
key={item.id}
|
|
2482
|
+
className="eth-domain-knowledge-base-viewer__related-item"
|
|
2483
|
+
role="listitem"
|
|
2484
|
+
>
|
|
2485
|
+
<div>
|
|
2486
|
+
<strong>{labelContent}</strong>
|
|
2487
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
2488
|
+
</div>
|
|
2489
|
+
<div className="eth-domain-knowledge-base-viewer__item-meta">
|
|
2490
|
+
{(item.type ?? item.meta) ? <span>{item.type ?? item.meta}</span> : null}
|
|
2491
|
+
{item.updatedAt ? <span>{item.updatedAt}</span> : null}
|
|
2492
|
+
{item.status ? (
|
|
2493
|
+
<StatusDot status={item.status} label={statusLabel(item.status)} />
|
|
2494
|
+
) : null}
|
|
2495
|
+
</div>
|
|
2496
|
+
</article>
|
|
2497
|
+
);
|
|
2498
|
+
})}
|
|
2499
|
+
</div>
|
|
2500
|
+
) : (
|
|
2501
|
+
<p className="eth-domain-knowledge-base-viewer__empty">No related content linked.</p>
|
|
2502
|
+
)}
|
|
2503
|
+
</section>
|
|
2504
|
+
|
|
2505
|
+
<section
|
|
2506
|
+
className="eth-domain-knowledge-base-viewer__panel"
|
|
2507
|
+
aria-labelledby={citationsHeadingId}
|
|
2508
|
+
>
|
|
2509
|
+
<div className="eth-domain-knowledge-base-viewer__panel-header">
|
|
2510
|
+
<span>Evidence</span>
|
|
2511
|
+
<h3 id={citationsHeadingId}>Citations</h3>
|
|
2512
|
+
</div>
|
|
2513
|
+
{citations.length ? (
|
|
2514
|
+
<ol className="eth-domain-knowledge-base-viewer__citations">
|
|
2515
|
+
{citations.map((citation) => {
|
|
2516
|
+
const labelContent = citation.href ? (
|
|
2517
|
+
<a href={citation.href}>{citation.label}</a>
|
|
2518
|
+
) : (
|
|
2519
|
+
citation.label
|
|
2520
|
+
);
|
|
2521
|
+
return (
|
|
2522
|
+
<li key={citation.id}>
|
|
2523
|
+
<div className="eth-domain-knowledge-base-viewer__citation-main">
|
|
2524
|
+
<strong>{labelContent}</strong>
|
|
2525
|
+
{citation.source ? <span>{citation.source}</span> : null}
|
|
2526
|
+
{renderKnowledgeBaseNode(citation.excerpt)}
|
|
2527
|
+
</div>
|
|
2528
|
+
{citation.status ? (
|
|
2529
|
+
<StatusDot status={citation.status} label={statusLabel(citation.status)} />
|
|
2530
|
+
) : null}
|
|
2531
|
+
</li>
|
|
2532
|
+
);
|
|
2533
|
+
})}
|
|
2534
|
+
</ol>
|
|
2535
|
+
) : (
|
|
2536
|
+
<p className="eth-domain-knowledge-base-viewer__empty">No citations attached.</p>
|
|
2537
|
+
)}
|
|
2538
|
+
</section>
|
|
2539
|
+
</aside>
|
|
2540
|
+
</div>
|
|
2541
|
+
</Surface>
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const defaultDataImportSteps: DataImportWizardStep[] = [
|
|
2546
|
+
{
|
|
2547
|
+
id: "upload",
|
|
2548
|
+
label: "Upload CSV",
|
|
2549
|
+
description: "File accepted and parsed into a preview set.",
|
|
2550
|
+
status: "completed"
|
|
2551
|
+
},
|
|
2552
|
+
{
|
|
2553
|
+
id: "map",
|
|
2554
|
+
label: "Map fields",
|
|
2555
|
+
description: "Match source columns to the destination object.",
|
|
2556
|
+
status: "in-progress"
|
|
2557
|
+
},
|
|
2558
|
+
{
|
|
2559
|
+
id: "validate",
|
|
2560
|
+
label: "Validate",
|
|
2561
|
+
description: "Run required field and duplicate checks.",
|
|
2562
|
+
status: "queued"
|
|
2563
|
+
},
|
|
2564
|
+
{
|
|
2565
|
+
id: "submit",
|
|
2566
|
+
label: "Submit",
|
|
2567
|
+
description: "Create records after validation passes.",
|
|
2568
|
+
status: "not-started"
|
|
2569
|
+
}
|
|
2570
|
+
];
|
|
2571
|
+
|
|
2572
|
+
function dataImportStatusFromSteps(
|
|
2573
|
+
steps: DataImportWizardStep[]
|
|
2574
|
+
): EthOperationalStatus | undefined {
|
|
2575
|
+
if (!steps.length) return undefined;
|
|
2576
|
+
if (steps.some((step) => step.status === "blocked" || step.status === "failed")) {
|
|
2577
|
+
return "blocked";
|
|
2578
|
+
}
|
|
2579
|
+
if (steps.every((step) => step.status === "completed" || step.status === "succeeded")) {
|
|
2580
|
+
return "completed";
|
|
2581
|
+
}
|
|
2582
|
+
if (
|
|
2583
|
+
steps.some(
|
|
2584
|
+
(step) =>
|
|
2585
|
+
step.status === "in-progress" || step.status === "running" || step.status === "active"
|
|
2586
|
+
)
|
|
2587
|
+
) {
|
|
2588
|
+
return "in-progress";
|
|
2589
|
+
}
|
|
2590
|
+
if (steps.some((step) => step.status === "queued" || step.status === "pending-approval")) {
|
|
2591
|
+
return "queued";
|
|
2592
|
+
}
|
|
2593
|
+
return "not-started";
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
function isCompletedImportStep(status: EthOperationalStatus | undefined) {
|
|
2597
|
+
return status === "completed" || status === "succeeded" || status === "synced";
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function isCurrentImportStep(status: EthOperationalStatus | undefined) {
|
|
2601
|
+
return status === "in-progress" || status === "running" || status === "active";
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
function currentImportStepIndex(steps: DataImportWizardStep[]) {
|
|
2605
|
+
const activeIndex = steps.findIndex((step) => isCurrentImportStep(step.status));
|
|
2606
|
+
if (activeIndex >= 0) return activeIndex;
|
|
2607
|
+
|
|
2608
|
+
const nextIndex = steps.findIndex((step) => !isCompletedImportStep(step.status));
|
|
2609
|
+
return nextIndex >= 0 ? nextIndex : Math.max(steps.length - 1, 0);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
export function DataImportWizard({
|
|
2613
|
+
title = "Data import",
|
|
2614
|
+
description = "Import, map, validate, and submit records.",
|
|
2615
|
+
items = defaultDataImportSteps,
|
|
2616
|
+
metadata,
|
|
2617
|
+
status,
|
|
2618
|
+
actions,
|
|
2619
|
+
footer,
|
|
2620
|
+
children,
|
|
2621
|
+
className,
|
|
2622
|
+
fileName,
|
|
2623
|
+
sourceLabel,
|
|
2624
|
+
rowCount,
|
|
2625
|
+
columnCount,
|
|
2626
|
+
mappingCount,
|
|
2627
|
+
validationSummary,
|
|
2628
|
+
mappings = [],
|
|
2629
|
+
validationIssues = [],
|
|
2630
|
+
...props
|
|
2631
|
+
}: DataImportWizardProps) {
|
|
2632
|
+
const currentIndex = currentImportStepIndex(items);
|
|
2633
|
+
const completeCount = items.filter((item) => isCompletedImportStep(item.status)).length;
|
|
2634
|
+
const computedMetadata =
|
|
2635
|
+
metadata ??
|
|
2636
|
+
[
|
|
2637
|
+
{ label: "File", value: fileName },
|
|
2638
|
+
{ label: "Rows", value: rowCount },
|
|
2639
|
+
{ label: "Columns", value: columnCount },
|
|
2640
|
+
{ label: "Mapped", value: mappingCount },
|
|
2641
|
+
{ label: "Validation", value: validationSummary }
|
|
2642
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
2643
|
+
|
|
2644
|
+
return (
|
|
2645
|
+
<Surface
|
|
2646
|
+
{...props}
|
|
2647
|
+
data-eth-component="DataImportWizard"
|
|
2648
|
+
title={title}
|
|
2649
|
+
description={description}
|
|
2650
|
+
status={status ?? dataImportStatusFromSteps(items)}
|
|
2651
|
+
metadata={computedMetadata.length ? computedMetadata : undefined}
|
|
2652
|
+
actions={actions}
|
|
2653
|
+
footer={footer}
|
|
2654
|
+
className={["eth-domain-data-import-wizard", className].filter(Boolean).join(" ")}
|
|
2655
|
+
>
|
|
2656
|
+
<ol className="eth-domain-data-import-wizard__steps" aria-label="Data import progress">
|
|
2657
|
+
{items.map((step, index) => {
|
|
2658
|
+
const stepStatus = step.status ?? "not-started";
|
|
2659
|
+
|
|
2660
|
+
return (
|
|
2661
|
+
<li
|
|
2662
|
+
key={step.id}
|
|
2663
|
+
aria-current={index === currentIndex ? "step" : undefined}
|
|
2664
|
+
className="eth-domain-data-import-wizard__step"
|
|
2665
|
+
data-current={index === currentIndex ? "true" : undefined}
|
|
2666
|
+
data-status={stepStatus}
|
|
2667
|
+
>
|
|
2668
|
+
<span className="eth-domain-data-import-wizard__step-marker" aria-hidden="true">
|
|
2669
|
+
{index + 1}
|
|
2670
|
+
</span>
|
|
2671
|
+
<span className="eth-domain-data-import-wizard__step-copy">
|
|
2672
|
+
<strong>{step.label}</strong>
|
|
2673
|
+
{step.description ? <span>{step.description}</span> : null}
|
|
2674
|
+
</span>
|
|
2675
|
+
<StatusDot status={stepStatus} label={statusLabel(stepStatus)} />
|
|
2676
|
+
</li>
|
|
2677
|
+
);
|
|
2678
|
+
})}
|
|
2679
|
+
</ol>
|
|
2680
|
+
|
|
2681
|
+
<div className="eth-domain-data-import-wizard__workspace">
|
|
2682
|
+
<section className="eth-domain-data-import-wizard__panel" aria-label="Upload summary">
|
|
2683
|
+
<div className="eth-domain-data-import-wizard__panel-header">
|
|
2684
|
+
<span>Source</span>
|
|
2685
|
+
<strong>{fileName ?? "No file selected"}</strong>
|
|
2686
|
+
</div>
|
|
2687
|
+
<dl className="eth-domain-data-import-wizard__facts">
|
|
2688
|
+
{hasDisplayValue(sourceLabel) ? (
|
|
2689
|
+
<div>
|
|
2690
|
+
<dt>Import type</dt>
|
|
2691
|
+
<dd>{sourceLabel}</dd>
|
|
2692
|
+
</div>
|
|
2693
|
+
) : null}
|
|
2694
|
+
{hasDisplayValue(rowCount) ? (
|
|
2695
|
+
<div>
|
|
2696
|
+
<dt>Rows detected</dt>
|
|
2697
|
+
<dd>{rowCount}</dd>
|
|
2698
|
+
</div>
|
|
2699
|
+
) : null}
|
|
2700
|
+
{hasDisplayValue(columnCount) ? (
|
|
2701
|
+
<div>
|
|
2702
|
+
<dt>Columns</dt>
|
|
2703
|
+
<dd>{columnCount}</dd>
|
|
2704
|
+
</div>
|
|
2705
|
+
) : null}
|
|
2706
|
+
<div>
|
|
2707
|
+
<dt>Progress</dt>
|
|
2708
|
+
<dd>
|
|
2709
|
+
{items.length
|
|
2710
|
+
? `${completeCount}/${items.length} steps complete`
|
|
2711
|
+
: "No steps configured"}
|
|
2712
|
+
</dd>
|
|
2713
|
+
</div>
|
|
2714
|
+
</dl>
|
|
2715
|
+
</section>
|
|
2716
|
+
|
|
2717
|
+
{mappings.length ? (
|
|
2718
|
+
<section className="eth-domain-data-import-wizard__panel eth-domain-data-import-wizard__panel--wide">
|
|
2719
|
+
<div className="eth-domain-data-import-wizard__panel-header">
|
|
2720
|
+
<span>Field mapping</span>
|
|
2721
|
+
<strong>{mappingCount ?? `${mappings.length} fields`}</strong>
|
|
2722
|
+
</div>
|
|
2723
|
+
<div className="eth-domain-data-import-wizard__table-wrap">
|
|
2724
|
+
<table className="eth-domain-data-import-wizard__table">
|
|
2725
|
+
<caption>CSV fields mapped to destination properties</caption>
|
|
2726
|
+
<thead>
|
|
2727
|
+
<tr>
|
|
2728
|
+
<th scope="col">Source field</th>
|
|
2729
|
+
<th scope="col">Destination</th>
|
|
2730
|
+
<th scope="col">Sample</th>
|
|
2731
|
+
<th scope="col">State</th>
|
|
2732
|
+
</tr>
|
|
2733
|
+
</thead>
|
|
2734
|
+
<tbody>
|
|
2735
|
+
{mappings.map((mapping) => {
|
|
2736
|
+
const mappingStatus = mapping.status ?? "not-started";
|
|
2737
|
+
|
|
2738
|
+
return (
|
|
2739
|
+
<tr key={mapping.id} data-status={mappingStatus}>
|
|
2740
|
+
<td>
|
|
2741
|
+
<strong>{mapping.sourceField}</strong>
|
|
2742
|
+
{mapping.required ? <span>Required</span> : null}
|
|
2743
|
+
</td>
|
|
2744
|
+
<td>{mapping.targetField}</td>
|
|
2745
|
+
<td>{mapping.sample ?? "No sample"}</td>
|
|
2746
|
+
<td>
|
|
2747
|
+
<StatusDot status={mappingStatus} label={statusLabel(mappingStatus)} />
|
|
2748
|
+
</td>
|
|
2749
|
+
</tr>
|
|
2750
|
+
);
|
|
2751
|
+
})}
|
|
2752
|
+
</tbody>
|
|
2753
|
+
</table>
|
|
2754
|
+
</div>
|
|
2755
|
+
</section>
|
|
2756
|
+
) : null}
|
|
2757
|
+
|
|
2758
|
+
{validationIssues.length || hasDisplayValue(validationSummary) ? (
|
|
2759
|
+
<section className="eth-domain-data-import-wizard__panel">
|
|
2760
|
+
<div className="eth-domain-data-import-wizard__panel-header">
|
|
2761
|
+
<span>Validation</span>
|
|
2762
|
+
<strong>{validationSummary ?? "Ready to run"}</strong>
|
|
2763
|
+
</div>
|
|
2764
|
+
{validationIssues.length ? (
|
|
2765
|
+
<ul className="eth-domain-data-import-wizard__issues" aria-label="Validation issues">
|
|
2766
|
+
{validationIssues.map((issue) => {
|
|
2767
|
+
const issueStatus = issue.status ?? "warning";
|
|
2768
|
+
|
|
2769
|
+
return (
|
|
2770
|
+
<li key={issue.id} data-status={issueStatus}>
|
|
2771
|
+
<div>
|
|
2772
|
+
<strong>{issue.field}</strong>
|
|
2773
|
+
<span>{issue.message}</span>
|
|
2774
|
+
</div>
|
|
2775
|
+
{hasDisplayValue(issue.count) ? <span>{issue.count}</span> : null}
|
|
2776
|
+
</li>
|
|
2777
|
+
);
|
|
2778
|
+
})}
|
|
2779
|
+
</ul>
|
|
2780
|
+
) : (
|
|
2781
|
+
<p className="eth-domain-data-import-wizard__empty-note">
|
|
2782
|
+
No validation issues have been detected.
|
|
2783
|
+
</p>
|
|
2784
|
+
)}
|
|
2785
|
+
</section>
|
|
2786
|
+
) : null}
|
|
2787
|
+
</div>
|
|
2788
|
+
{children}
|
|
2789
|
+
</Surface>
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
const defaultMappingTargetOptions = [
|
|
2794
|
+
{ value: "", label: "Select target field", disabled: true },
|
|
2795
|
+
{ value: "contact.firstName", label: "Contact / First name" },
|
|
2796
|
+
{ value: "contact.lastName", label: "Contact / Last name" },
|
|
2797
|
+
{ value: "account.name", label: "Account / Name" },
|
|
2798
|
+
{ value: "account.domain", label: "Account / Domain" },
|
|
2799
|
+
{ value: "opportunity.amount", label: "Opportunity / Amount" },
|
|
2800
|
+
{ value: "opportunity.closeDate", label: "Opportunity / Close date" }
|
|
2801
|
+
];
|
|
2802
|
+
|
|
2803
|
+
function parseMappingLabel(label: React.ReactNode) {
|
|
2804
|
+
const text = textFromNode(label).trim();
|
|
2805
|
+
const match = text.match(/^(.*?)\s*(?:→|->)\s*(.*?)$/);
|
|
2806
|
+
if (!match) return undefined;
|
|
2807
|
+
|
|
2808
|
+
return {
|
|
2809
|
+
source: match[1].trim(),
|
|
2810
|
+
target: match[2].trim()
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function mappingSource(item: MappingConfigurationItem) {
|
|
2815
|
+
return item.sourceField ?? parseMappingLabel(item.label)?.source ?? item.label;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
function mappingTarget(item: MappingConfigurationItem) {
|
|
2819
|
+
return item.targetField ?? parseMappingLabel(item.label)?.target;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
function selectableValue(value: React.ReactNode) {
|
|
2823
|
+
if (typeof value === "string" || typeof value === "number") return String(value);
|
|
2824
|
+
return undefined;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function mappingItemStatus(item: MappingConfigurationItem) {
|
|
2828
|
+
return item.status ?? (hasDisplayValue(mappingTarget(item)) ? "active" : "warning");
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function isMappingUnresolved(item: MappingConfigurationItem) {
|
|
2832
|
+
const status = mappingItemStatus(item);
|
|
2833
|
+
return (
|
|
2834
|
+
!hasDisplayValue(mappingTarget(item)) ||
|
|
2835
|
+
status === "warning" ||
|
|
2836
|
+
status === "blocked" ||
|
|
2837
|
+
status === "failed" ||
|
|
2838
|
+
status === "approval-required"
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
function mappingWizardStatus(
|
|
2843
|
+
items: MappingConfigurationItem[],
|
|
2844
|
+
issues: MappingConfigurationIssue[]
|
|
2845
|
+
): EthOperationalStatus {
|
|
2846
|
+
if (!items.length) return "not-started";
|
|
2847
|
+
if (
|
|
2848
|
+
items.some((item) =>
|
|
2849
|
+
["blocked", "failed", "approval-required"].includes(mappingItemStatus(item))
|
|
2850
|
+
) ||
|
|
2851
|
+
issues.some((issue) =>
|
|
2852
|
+
["blocked", "failed", "approval-required"].includes(issue.status ?? "warning")
|
|
2853
|
+
)
|
|
2854
|
+
) {
|
|
2855
|
+
return "blocked";
|
|
2856
|
+
}
|
|
2857
|
+
if (items.some(isMappingUnresolved) || issues.length) return "warning";
|
|
2858
|
+
return "active";
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
function optionsForMapping(
|
|
2862
|
+
options: Array<{ value: string; label: string; disabled?: boolean }>,
|
|
2863
|
+
value: string | undefined
|
|
2864
|
+
) {
|
|
2865
|
+
if (!value || options.some((option) => option.value === value)) return options;
|
|
2866
|
+
return [...options, { value, label: value }];
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
export function MappingConfigurationWizard({
|
|
2870
|
+
title = "Mapping configuration",
|
|
2871
|
+
description = "Configure how source fields map into destination objects.",
|
|
2872
|
+
items = [],
|
|
2873
|
+
sourceObject,
|
|
2874
|
+
targetObject,
|
|
2875
|
+
mappingCount,
|
|
2876
|
+
validationSummary,
|
|
2877
|
+
targetOptions = defaultMappingTargetOptions,
|
|
2878
|
+
validationIssues = [],
|
|
2879
|
+
metadata,
|
|
2880
|
+
status,
|
|
2881
|
+
actions,
|
|
2882
|
+
footer,
|
|
2883
|
+
children,
|
|
2884
|
+
className,
|
|
2885
|
+
onMappingChange,
|
|
2886
|
+
onAutoMap,
|
|
2887
|
+
onValidate,
|
|
2888
|
+
onSubmit,
|
|
2889
|
+
...props
|
|
2890
|
+
}: MappingConfigurationWizardProps) {
|
|
2891
|
+
const [targetOverrides, setTargetOverrides] = React.useState<Record<string, string>>({});
|
|
2892
|
+
|
|
2893
|
+
React.useEffect(() => {
|
|
2894
|
+
setTargetOverrides({});
|
|
2895
|
+
}, [items]);
|
|
2896
|
+
|
|
2897
|
+
const resolvedMappingTarget = React.useCallback(
|
|
2898
|
+
(item: MappingConfigurationItem) => targetOverrides[item.id] ?? mappingTarget(item),
|
|
2899
|
+
[targetOverrides]
|
|
2900
|
+
);
|
|
2901
|
+
const resolvedMappingStatus = React.useCallback(
|
|
2902
|
+
(item: MappingConfigurationItem) =>
|
|
2903
|
+
item.status ?? (hasDisplayValue(resolvedMappingTarget(item)) ? "active" : "warning"),
|
|
2904
|
+
[resolvedMappingTarget]
|
|
2905
|
+
);
|
|
2906
|
+
const isResolvedMappingUnresolved = React.useCallback(
|
|
2907
|
+
(item: MappingConfigurationItem) => {
|
|
2908
|
+
const itemStatus = resolvedMappingStatus(item);
|
|
2909
|
+
|
|
2910
|
+
return (
|
|
2911
|
+
!hasDisplayValue(resolvedMappingTarget(item)) ||
|
|
2912
|
+
itemStatus === "warning" ||
|
|
2913
|
+
itemStatus === "blocked" ||
|
|
2914
|
+
itemStatus === "failed" ||
|
|
2915
|
+
itemStatus === "approval-required"
|
|
2916
|
+
);
|
|
2917
|
+
},
|
|
2918
|
+
[resolvedMappingStatus, resolvedMappingTarget]
|
|
2919
|
+
);
|
|
2920
|
+
const mappedCount = items.filter((item) => hasDisplayValue(resolvedMappingTarget(item))).length;
|
|
2921
|
+
const unresolvedCount =
|
|
2922
|
+
items.filter(isResolvedMappingUnresolved).length + validationIssues.length;
|
|
2923
|
+
const resolvedItems = items.map((item) => ({
|
|
2924
|
+
...item,
|
|
2925
|
+
targetField: resolvedMappingTarget(item),
|
|
2926
|
+
status: resolvedMappingStatus(item)
|
|
2927
|
+
}));
|
|
2928
|
+
const computedStatus = status ?? mappingWizardStatus(resolvedItems, validationIssues);
|
|
2929
|
+
const computedValidationSummary =
|
|
2930
|
+
validationSummary ??
|
|
2931
|
+
(unresolvedCount
|
|
2932
|
+
? `${unresolvedCount} ${unresolvedCount === 1 ? "issue" : "issues"} to resolve`
|
|
2933
|
+
: "Ready to apply");
|
|
2934
|
+
const mappingProgressLabel =
|
|
2935
|
+
mappingCount ?? (items.length ? `${mappedCount}/${items.length}` : "No fields");
|
|
2936
|
+
const computedMetadata =
|
|
2937
|
+
metadata ??
|
|
2938
|
+
[
|
|
2939
|
+
{ label: "Source object", value: sourceObject },
|
|
2940
|
+
{ label: "Target object", value: targetObject },
|
|
2941
|
+
{ label: "Mapped", value: mappingProgressLabel },
|
|
2942
|
+
{ label: "Validation", value: computedValidationSummary }
|
|
2943
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
2944
|
+
const wizardSteps = [
|
|
2945
|
+
{
|
|
2946
|
+
id: "source",
|
|
2947
|
+
label: "Source",
|
|
2948
|
+
description: items.length ? `${items.length} fields detected` : "No fields loaded",
|
|
2949
|
+
status: items.length ? "completed" : "not-started"
|
|
2950
|
+
},
|
|
2951
|
+
{
|
|
2952
|
+
id: "mapping",
|
|
2953
|
+
label: "Map",
|
|
2954
|
+
description: items.length ? `${mappedCount}/${items.length} mapped` : "Awaiting source",
|
|
2955
|
+
status: !items.length
|
|
2956
|
+
? "not-started"
|
|
2957
|
+
: mappedCount === items.length
|
|
2958
|
+
? "completed"
|
|
2959
|
+
: "in-progress"
|
|
2960
|
+
},
|
|
2961
|
+
{
|
|
2962
|
+
id: "validation",
|
|
2963
|
+
label: "Validate",
|
|
2964
|
+
description: computedValidationSummary,
|
|
2965
|
+
status: computedStatus === "blocked" ? "blocked" : unresolvedCount ? "warning" : "completed"
|
|
2966
|
+
},
|
|
2967
|
+
{
|
|
2968
|
+
id: "review",
|
|
2969
|
+
label: "Review",
|
|
2970
|
+
description: unresolvedCount ? "Resolve issues first" : "Ready for sync",
|
|
2971
|
+
status: unresolvedCount ? "not-started" : "queued"
|
|
2972
|
+
}
|
|
2973
|
+
] satisfies Array<{
|
|
2974
|
+
id: string;
|
|
2975
|
+
label: string;
|
|
2976
|
+
description: React.ReactNode;
|
|
2977
|
+
status: EthOperationalStatus;
|
|
2978
|
+
}>;
|
|
2979
|
+
const currentStepIndex = wizardSteps.findIndex((step) => !isCompletedImportStep(step.status));
|
|
2980
|
+
const currentIndex = currentStepIndex >= 0 ? currentStepIndex : wizardSteps.length - 1;
|
|
2981
|
+
|
|
2982
|
+
return (
|
|
2983
|
+
<Surface
|
|
2984
|
+
{...props}
|
|
2985
|
+
data-eth-component="MappingConfigurationWizard"
|
|
2986
|
+
title={title}
|
|
2987
|
+
description={description}
|
|
2988
|
+
status={computedStatus}
|
|
2989
|
+
metadata={computedMetadata.length ? computedMetadata : undefined}
|
|
2990
|
+
actions={actions}
|
|
2991
|
+
className={["eth-domain-mapping-config-wizard", className].filter(Boolean).join(" ")}
|
|
2992
|
+
>
|
|
2993
|
+
<ol className="eth-domain-mapping-config-wizard__steps" aria-label="Mapping progress">
|
|
2994
|
+
{wizardSteps.map((step, index) => (
|
|
2995
|
+
<li
|
|
2996
|
+
key={step.id}
|
|
2997
|
+
aria-current={index === currentIndex ? "step" : undefined}
|
|
2998
|
+
className="eth-domain-mapping-config-wizard__step"
|
|
2999
|
+
data-current={index === currentIndex ? "true" : undefined}
|
|
3000
|
+
data-status={step.status}
|
|
3001
|
+
>
|
|
3002
|
+
<span className="eth-domain-mapping-config-wizard__step-marker" aria-hidden="true">
|
|
3003
|
+
{index + 1}
|
|
3004
|
+
</span>
|
|
3005
|
+
<span className="eth-domain-mapping-config-wizard__step-copy">
|
|
3006
|
+
<strong>{step.label}</strong>
|
|
3007
|
+
<span>{step.description}</span>
|
|
3008
|
+
</span>
|
|
3009
|
+
<StatusDot status={step.status} label={statusLabel(step.status)} />
|
|
3010
|
+
</li>
|
|
3011
|
+
))}
|
|
3012
|
+
</ol>
|
|
3013
|
+
|
|
3014
|
+
<div className="eth-domain-mapping-config-wizard__workspace">
|
|
3015
|
+
<aside className="eth-domain-mapping-config-wizard__panel" aria-label="Object scope">
|
|
3016
|
+
<div className="eth-domain-mapping-config-wizard__panel-header">
|
|
3017
|
+
<span>Objects</span>
|
|
3018
|
+
<strong>
|
|
3019
|
+
{sourceObject ?? "Source object"}
|
|
3020
|
+
<span aria-hidden="true"> -> </span>
|
|
3021
|
+
{targetObject ?? "Target object"}
|
|
3022
|
+
</strong>
|
|
3023
|
+
</div>
|
|
3024
|
+
<dl className="eth-domain-mapping-config-wizard__facts">
|
|
3025
|
+
<div>
|
|
3026
|
+
<dt>Source object</dt>
|
|
3027
|
+
<dd>{sourceObject ?? "Not selected"}</dd>
|
|
3028
|
+
</div>
|
|
3029
|
+
<div>
|
|
3030
|
+
<dt>Target object</dt>
|
|
3031
|
+
<dd>{targetObject ?? "Not selected"}</dd>
|
|
3032
|
+
</div>
|
|
3033
|
+
<div>
|
|
3034
|
+
<dt>Mapped fields</dt>
|
|
3035
|
+
<dd>{mappingProgressLabel}</dd>
|
|
3036
|
+
</div>
|
|
3037
|
+
<div>
|
|
3038
|
+
<dt>Validation</dt>
|
|
3039
|
+
<dd>{computedValidationSummary}</dd>
|
|
3040
|
+
</div>
|
|
3041
|
+
</dl>
|
|
3042
|
+
</aside>
|
|
3043
|
+
|
|
3044
|
+
<section
|
|
3045
|
+
className="eth-domain-mapping-config-wizard__panel eth-domain-mapping-config-wizard__panel--mapping"
|
|
3046
|
+
aria-label="Field mappings"
|
|
3047
|
+
>
|
|
3048
|
+
<div className="eth-domain-mapping-config-wizard__panel-header eth-domain-mapping-config-wizard__panel-header--inline">
|
|
3049
|
+
<div>
|
|
3050
|
+
<span>Field mapping</span>
|
|
3051
|
+
<strong>
|
|
3052
|
+
{mappingCount ??
|
|
3053
|
+
(items.length ? `${mappedCount}/${items.length} mapped` : "No fields")}
|
|
3054
|
+
</strong>
|
|
3055
|
+
</div>
|
|
3056
|
+
<Button type="button" intent="tertiary" density="compact" onClick={onAutoMap}>
|
|
3057
|
+
Auto-map
|
|
3058
|
+
</Button>
|
|
3059
|
+
</div>
|
|
3060
|
+
|
|
3061
|
+
{items.length ? (
|
|
3062
|
+
<div className="eth-domain-mapping-config-wizard__table-wrap">
|
|
3063
|
+
<table className="eth-domain-mapping-config-wizard__table">
|
|
3064
|
+
<caption>Source fields mapped to destination fields</caption>
|
|
3065
|
+
<thead>
|
|
3066
|
+
<tr>
|
|
3067
|
+
<th scope="col">Source field</th>
|
|
3068
|
+
<th scope="col">Destination field</th>
|
|
3069
|
+
<th scope="col">Transform</th>
|
|
3070
|
+
<th scope="col">State</th>
|
|
3071
|
+
</tr>
|
|
3072
|
+
</thead>
|
|
3073
|
+
<tbody>
|
|
3074
|
+
{items.map((item) => {
|
|
3075
|
+
const source = mappingSource(item);
|
|
3076
|
+
const target = resolvedMappingTarget(item);
|
|
3077
|
+
const targetValue = selectableValue(target);
|
|
3078
|
+
const itemStatus = resolvedMappingStatus(item);
|
|
3079
|
+
const sourceLabel = textFromNode(source) || `mapping-${item.id}`;
|
|
3080
|
+
|
|
3081
|
+
return (
|
|
3082
|
+
<tr key={item.id} data-status={itemStatus}>
|
|
3083
|
+
<td>
|
|
3084
|
+
<div className="eth-domain-mapping-config-wizard__field-stack">
|
|
3085
|
+
<strong>{source}</strong>
|
|
3086
|
+
{hasDisplayValue(item.sourceType) ? (
|
|
3087
|
+
<span>{item.sourceType}</span>
|
|
3088
|
+
) : null}
|
|
3089
|
+
{hasDisplayValue(item.sample) ? <p>Sample: {item.sample}</p> : null}
|
|
3090
|
+
</div>
|
|
3091
|
+
</td>
|
|
3092
|
+
<td>
|
|
3093
|
+
<div className="eth-domain-mapping-config-wizard__target-field">
|
|
3094
|
+
<Select
|
|
3095
|
+
labelText={`Destination for ${sourceLabel}`}
|
|
3096
|
+
hideLabel
|
|
3097
|
+
density="compact"
|
|
3098
|
+
value={targetValue ?? ""}
|
|
3099
|
+
options={optionsForMapping(targetOptions, targetValue)}
|
|
3100
|
+
invalid={!hasDisplayValue(target)}
|
|
3101
|
+
invalidText="Select a destination field."
|
|
3102
|
+
onChange={(event) => {
|
|
3103
|
+
const nextTarget = event.currentTarget.value;
|
|
3104
|
+
setTargetOverrides((current) => ({
|
|
3105
|
+
...current,
|
|
3106
|
+
[item.id]: nextTarget
|
|
3107
|
+
}));
|
|
3108
|
+
onMappingChange?.(item.id, nextTarget);
|
|
3109
|
+
}}
|
|
3110
|
+
/>
|
|
3111
|
+
{hasDisplayValue(item.targetType) ? (
|
|
3112
|
+
<span>{item.targetType}</span>
|
|
3113
|
+
) : null}
|
|
3114
|
+
</div>
|
|
3115
|
+
</td>
|
|
3116
|
+
<td>
|
|
3117
|
+
<div className="eth-domain-mapping-config-wizard__field-stack">
|
|
3118
|
+
<strong>{item.transform ?? "Direct copy"}</strong>
|
|
3119
|
+
{hasDisplayValue(item.description) ? <p>{item.description}</p> : null}
|
|
3120
|
+
</div>
|
|
3121
|
+
</td>
|
|
3122
|
+
<td>
|
|
3123
|
+
<div className="eth-domain-mapping-config-wizard__state">
|
|
3124
|
+
<StatusDot status={itemStatus} label={statusLabel(itemStatus)} />
|
|
3125
|
+
<div className="eth-domain-mapping-config-wizard__badges">
|
|
3126
|
+
{item.required ? <Badge severity="info">Required</Badge> : null}
|
|
3127
|
+
{hasDisplayValue(item.confidence) ? (
|
|
3128
|
+
<Badge severity={itemStatus === "warning" ? "warning" : "neutral"}>
|
|
3129
|
+
{item.confidence}
|
|
3130
|
+
</Badge>
|
|
3131
|
+
) : null}
|
|
3132
|
+
</div>
|
|
3133
|
+
</div>
|
|
3134
|
+
</td>
|
|
3135
|
+
</tr>
|
|
3136
|
+
);
|
|
3137
|
+
})}
|
|
3138
|
+
</tbody>
|
|
3139
|
+
</table>
|
|
3140
|
+
</div>
|
|
3141
|
+
) : (
|
|
3142
|
+
<p className="eth-domain-mapping-config-wizard__empty">No fields are ready to map.</p>
|
|
3143
|
+
)}
|
|
3144
|
+
</section>
|
|
3145
|
+
|
|
3146
|
+
<aside className="eth-domain-mapping-config-wizard__panel" aria-label="Validation issues">
|
|
3147
|
+
<div className="eth-domain-mapping-config-wizard__panel-header">
|
|
3148
|
+
<span>Validation</span>
|
|
3149
|
+
<strong>{computedValidationSummary}</strong>
|
|
3150
|
+
</div>
|
|
3151
|
+
{validationIssues.length ? (
|
|
3152
|
+
<ul
|
|
3153
|
+
className="eth-domain-mapping-config-wizard__issues"
|
|
3154
|
+
aria-label="Mapping validation issues"
|
|
3155
|
+
>
|
|
3156
|
+
{validationIssues.map((issue) => {
|
|
3157
|
+
const issueStatus = issue.status ?? "warning";
|
|
3158
|
+
|
|
3159
|
+
return (
|
|
3160
|
+
<li key={issue.id} data-status={issueStatus}>
|
|
3161
|
+
<div>
|
|
3162
|
+
<strong>{issue.field}</strong>
|
|
3163
|
+
<span>{issue.message}</span>
|
|
3164
|
+
</div>
|
|
3165
|
+
{hasDisplayValue(issue.count) ? <span>{issue.count}</span> : null}
|
|
3166
|
+
</li>
|
|
3167
|
+
);
|
|
3168
|
+
})}
|
|
3169
|
+
</ul>
|
|
3170
|
+
) : (
|
|
3171
|
+
<p className="eth-domain-mapping-config-wizard__empty-note">
|
|
3172
|
+
No mapping validation issues.
|
|
3173
|
+
</p>
|
|
3174
|
+
)}
|
|
3175
|
+
</aside>
|
|
3176
|
+
</div>
|
|
3177
|
+
|
|
3178
|
+
{children}
|
|
3179
|
+
|
|
3180
|
+
<footer className="eth-domain-mapping-config-wizard__footer">
|
|
3181
|
+
{footer ?? (
|
|
3182
|
+
<>
|
|
3183
|
+
<span>{computedValidationSummary}</span>
|
|
3184
|
+
<div className="eth-domain-mapping-config-wizard__footer-actions">
|
|
3185
|
+
<Button type="button" intent="ghost" density="compact">
|
|
3186
|
+
Back
|
|
3187
|
+
</Button>
|
|
3188
|
+
<Button type="button" intent="secondary" density="compact" onClick={onValidate}>
|
|
3189
|
+
Validate
|
|
3190
|
+
</Button>
|
|
3191
|
+
<Button
|
|
3192
|
+
type="button"
|
|
3193
|
+
intent="primary"
|
|
3194
|
+
density="compact"
|
|
3195
|
+
disabled={Boolean(unresolvedCount)}
|
|
3196
|
+
onClick={onSubmit}
|
|
3197
|
+
>
|
|
3198
|
+
Apply mapping
|
|
3199
|
+
</Button>
|
|
3200
|
+
</div>
|
|
3201
|
+
</>
|
|
3202
|
+
)}
|
|
3203
|
+
</footer>
|
|
3204
|
+
</Surface>
|
|
3205
|
+
);
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
export function ReportBuilder({
|
|
3209
|
+
title = "Report builder",
|
|
3210
|
+
description,
|
|
3211
|
+
status,
|
|
3212
|
+
metadata,
|
|
3213
|
+
actions,
|
|
3214
|
+
footer,
|
|
3215
|
+
children,
|
|
3216
|
+
className,
|
|
3217
|
+
items = [],
|
|
3218
|
+
formatLabel = "PDF / CSV",
|
|
3219
|
+
owner,
|
|
3220
|
+
updatedAt,
|
|
3221
|
+
onAddSection,
|
|
3222
|
+
onExportPdf,
|
|
3223
|
+
onExportCsv,
|
|
3224
|
+
...props
|
|
3225
|
+
}: ReportBuilderProps) {
|
|
3226
|
+
const sectionsHeadingId = React.useId();
|
|
3227
|
+
const previewHeadingId = React.useId();
|
|
3228
|
+
const includedSections = items.filter((item) => isReportSectionIncluded(item.status));
|
|
3229
|
+
const attentionCount = items.filter((item) => needsReportSectionAttention(item.status)).length;
|
|
3230
|
+
const computedStatus = status ?? deriveReportBuilderStatus(items);
|
|
3231
|
+
const readinessLabel = reportBuilderReadinessLabel(computedStatus, attentionCount);
|
|
3232
|
+
const computedMetadata =
|
|
3233
|
+
metadata ??
|
|
3234
|
+
[
|
|
3235
|
+
{
|
|
3236
|
+
label: "Sections",
|
|
3237
|
+
value: items.length
|
|
3238
|
+
? `${items.length} ${items.length === 1 ? "section" : "sections"}`
|
|
3239
|
+
: "No sections"
|
|
3240
|
+
},
|
|
3241
|
+
{
|
|
3242
|
+
label: "Included",
|
|
3243
|
+
value: items.length ? `${includedSections.length}/${items.length}` : "0/0"
|
|
3244
|
+
},
|
|
3245
|
+
{ label: "Readiness", value: readinessLabel },
|
|
3246
|
+
{ label: "Export", value: formatLabel }
|
|
3247
|
+
].filter((item) => hasDisplayValue(item.value));
|
|
3248
|
+
const currentSectionIndex = items.findIndex(
|
|
3249
|
+
(item) => item.status === "in-progress" || item.status === "running"
|
|
3250
|
+
);
|
|
3251
|
+
const currentIndex = currentSectionIndex >= 0 ? currentSectionIndex : 0;
|
|
3252
|
+
const exportSummary = items.length
|
|
3253
|
+
? `${includedSections.length}/${items.length} sections included`
|
|
3254
|
+
: "No sections selected";
|
|
3255
|
+
|
|
3256
|
+
return (
|
|
3257
|
+
<Surface
|
|
3258
|
+
{...props}
|
|
3259
|
+
data-eth-component="ReportBuilder"
|
|
3260
|
+
title={title}
|
|
3261
|
+
description={description}
|
|
3262
|
+
status={computedStatus}
|
|
3263
|
+
metadata={computedMetadata.length ? computedMetadata : undefined}
|
|
3264
|
+
actions={actions}
|
|
3265
|
+
footer={footer}
|
|
3266
|
+
className={["eth-domain-report-builder", className].filter(Boolean).join(" ")}
|
|
3267
|
+
>
|
|
3268
|
+
<div className="eth-domain-report-builder__workspace">
|
|
3269
|
+
<section
|
|
3270
|
+
className="eth-domain-report-builder__sections"
|
|
3271
|
+
aria-labelledby={sectionsHeadingId}
|
|
3272
|
+
>
|
|
3273
|
+
<div className="eth-domain-report-builder__panel-header eth-domain-report-builder__panel-header--inline">
|
|
3274
|
+
<div>
|
|
3275
|
+
<span>Composition</span>
|
|
3276
|
+
<h3 id={sectionsHeadingId}>Report sections</h3>
|
|
3277
|
+
</div>
|
|
3278
|
+
<Button
|
|
3279
|
+
type="button"
|
|
3280
|
+
intent="tertiary"
|
|
3281
|
+
density="compact"
|
|
3282
|
+
icon={<PlusIcon size={16} />}
|
|
3283
|
+
onClick={onAddSection}
|
|
3284
|
+
>
|
|
3285
|
+
Add section
|
|
3286
|
+
</Button>
|
|
3287
|
+
</div>
|
|
3288
|
+
|
|
3289
|
+
{items.length ? (
|
|
3290
|
+
<ol className="eth-domain-report-builder__section-list" aria-label="Report sections">
|
|
3291
|
+
{items.map((item, index) => {
|
|
3292
|
+
const itemStatus = item.status ?? "not-started";
|
|
3293
|
+
const labelContent = item.href ? <a href={item.href}>{item.label}</a> : item.label;
|
|
3294
|
+
const meta = reportSectionMeta(item, index);
|
|
3295
|
+
|
|
3296
|
+
return (
|
|
3297
|
+
<li
|
|
3298
|
+
key={item.id}
|
|
3299
|
+
aria-current={index === currentIndex ? "step" : undefined}
|
|
3300
|
+
className="eth-domain-report-builder__section-item"
|
|
3301
|
+
data-current={index === currentIndex ? "true" : undefined}
|
|
3302
|
+
data-status={itemStatus}
|
|
3303
|
+
>
|
|
3304
|
+
<span className="eth-domain-report-builder__section-index" aria-hidden="true">
|
|
3305
|
+
{index + 1}
|
|
3306
|
+
</span>
|
|
3307
|
+
<div className="eth-domain-report-builder__section-main">
|
|
3308
|
+
<strong>{labelContent}</strong>
|
|
3309
|
+
{item.description ? <p>{item.description}</p> : null}
|
|
3310
|
+
{hasDisplayValue(meta) ? (
|
|
3311
|
+
<span className="eth-domain-report-builder__section-meta">{meta}</span>
|
|
3312
|
+
) : null}
|
|
3313
|
+
</div>
|
|
3314
|
+
<div className="eth-domain-report-builder__section-state">
|
|
3315
|
+
<StatusDot status={itemStatus} label={statusLabel(itemStatus)} />
|
|
3316
|
+
{item.required ? <Badge severity="info">Required</Badge> : null}
|
|
3317
|
+
</div>
|
|
3318
|
+
</li>
|
|
3319
|
+
);
|
|
3320
|
+
})}
|
|
3321
|
+
</ol>
|
|
3322
|
+
) : (
|
|
3323
|
+
<p className="eth-domain-report-builder__empty">No report sections configured.</p>
|
|
3324
|
+
)}
|
|
3325
|
+
</section>
|
|
3326
|
+
|
|
3327
|
+
<section className="eth-domain-report-builder__preview" aria-label="Report preview">
|
|
3328
|
+
<div className="eth-domain-report-builder__panel-header eth-domain-report-builder__panel-header--inline">
|
|
3329
|
+
<div>
|
|
3330
|
+
<span>Preview</span>
|
|
3331
|
+
<h3 id={previewHeadingId}>{title}</h3>
|
|
3332
|
+
</div>
|
|
3333
|
+
<StatusDot status={computedStatus} label={statusLabel(computedStatus)} />
|
|
3334
|
+
</div>
|
|
3335
|
+
|
|
3336
|
+
<div
|
|
3337
|
+
className="eth-domain-report-builder__preview-page"
|
|
3338
|
+
aria-labelledby={previewHeadingId}
|
|
3339
|
+
>
|
|
3340
|
+
<span className="eth-domain-report-builder__preview-kicker">Draft report</span>
|
|
3341
|
+
<strong className="eth-domain-report-builder__preview-title">{title}</strong>
|
|
3342
|
+
<div className="eth-domain-report-builder__preview-meta">
|
|
3343
|
+
{hasDisplayValue(owner) ? <span>Owner: {owner}</span> : null}
|
|
3344
|
+
{hasDisplayValue(updatedAt) ? <span>Updated: {updatedAt}</span> : null}
|
|
3345
|
+
<span>{formatLabel}</span>
|
|
3346
|
+
</div>
|
|
3347
|
+
{includedSections.length ? (
|
|
3348
|
+
<div className="eth-domain-report-builder__preview-lines">
|
|
3349
|
+
{includedSections.slice(0, 4).map((item, index) => (
|
|
3350
|
+
<div key={item.id} className="eth-domain-report-builder__preview-line">
|
|
3351
|
+
<span>{item.label}</span>
|
|
3352
|
+
<span>{reportSectionMeta(item, index)}</span>
|
|
3353
|
+
</div>
|
|
3354
|
+
))}
|
|
3355
|
+
</div>
|
|
3356
|
+
) : (
|
|
3357
|
+
<p className="eth-domain-report-builder__empty-note">
|
|
3358
|
+
Select at least one section to generate a preview.
|
|
3359
|
+
</p>
|
|
3360
|
+
)}
|
|
3361
|
+
</div>
|
|
3362
|
+
|
|
3363
|
+
<div className="eth-domain-report-builder__export-bar" aria-label="Export actions">
|
|
3364
|
+
<span>{exportSummary}</span>
|
|
3365
|
+
<div className="eth-domain-report-builder__export-actions">
|
|
3366
|
+
<Button
|
|
3367
|
+
type="button"
|
|
3368
|
+
intent="primary"
|
|
3369
|
+
density="compact"
|
|
3370
|
+
icon={<DownloadIcon size={16} />}
|
|
3371
|
+
disabled={!includedSections.length}
|
|
3372
|
+
onClick={onExportPdf}
|
|
3373
|
+
>
|
|
3374
|
+
Export PDF
|
|
3375
|
+
</Button>
|
|
3376
|
+
<Button
|
|
3377
|
+
type="button"
|
|
3378
|
+
intent="secondary"
|
|
3379
|
+
density="compact"
|
|
3380
|
+
icon={<TableIcon size={16} />}
|
|
3381
|
+
disabled={!includedSections.length}
|
|
3382
|
+
onClick={onExportCsv}
|
|
3383
|
+
>
|
|
3384
|
+
Export CSV
|
|
3385
|
+
</Button>
|
|
3386
|
+
</div>
|
|
3387
|
+
</div>
|
|
3388
|
+
</section>
|
|
3389
|
+
</div>
|
|
3390
|
+
|
|
3391
|
+
{children}
|
|
3392
|
+
</Surface>
|
|
3393
|
+
);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
export function ApprovalWorkflowEditor({
|
|
3397
|
+
title = "Approval workflow",
|
|
3398
|
+
description = "Configure approval routing, ownership, and conditional gates.",
|
|
3399
|
+
steps,
|
|
3400
|
+
approverOptions = defaultApproverOptions,
|
|
3401
|
+
mode,
|
|
3402
|
+
onChange,
|
|
3403
|
+
onModeChange,
|
|
3404
|
+
className,
|
|
3405
|
+
status,
|
|
3406
|
+
...props
|
|
3407
|
+
}: ApprovalWorkflowEditorProps) {
|
|
3408
|
+
const [internalSteps, setInternalSteps] = React.useState<ApprovalWorkflowStep[]>(() =>
|
|
3409
|
+
defaultApprovalSteps.map((step) => ({ ...step }))
|
|
3410
|
+
);
|
|
3411
|
+
const [internalMode, setInternalMode] = React.useState<ApprovalWorkflowMode>("sequential");
|
|
3412
|
+
const currentSteps = steps ?? internalSteps;
|
|
3413
|
+
const currentMode = mode ?? internalMode;
|
|
3414
|
+
const missingApproverCount = currentSteps.filter((step) => !step.approver).length;
|
|
3415
|
+
const requiredCount = currentSteps.filter((step) => step.required).length;
|
|
3416
|
+
const readyCount = currentSteps.length - missingApproverCount;
|
|
3417
|
+
const validationMessage = missingApproverCount
|
|
3418
|
+
? `${missingApproverCount} approval ${missingApproverCount === 1 ? "step needs" : "steps need"} an approver.`
|
|
3419
|
+
: "All approval steps have owners and can be simulated.";
|
|
3420
|
+
|
|
3421
|
+
const commitSteps = (nextSteps: ApprovalWorkflowStep[]) => {
|
|
3422
|
+
if (!steps) setInternalSteps(nextSteps);
|
|
3423
|
+
onChange?.(nextSteps);
|
|
3424
|
+
};
|
|
3425
|
+
|
|
3426
|
+
const updateStep = (index: number, next: ApprovalWorkflowStep) => {
|
|
3427
|
+
const nextSteps = [...currentSteps];
|
|
3428
|
+
nextSteps[index] = next;
|
|
3429
|
+
commitSteps(nextSteps);
|
|
3430
|
+
};
|
|
3431
|
+
|
|
3432
|
+
const addStep = () => {
|
|
3433
|
+
commitSteps([
|
|
3434
|
+
...currentSteps,
|
|
3435
|
+
{
|
|
3436
|
+
id: nextApprovalStepId(currentSteps),
|
|
3437
|
+
label: "Approval step",
|
|
3438
|
+
approver: "",
|
|
3439
|
+
condition: "Define routing condition",
|
|
3440
|
+
required: true,
|
|
3441
|
+
status: "queued"
|
|
3442
|
+
}
|
|
3443
|
+
]);
|
|
3444
|
+
};
|
|
3445
|
+
|
|
3446
|
+
const removeStep = (id: string) => {
|
|
3447
|
+
commitSteps(currentSteps.filter((step) => step.id !== id));
|
|
3448
|
+
};
|
|
3449
|
+
|
|
3450
|
+
const changeMode = (nextMode: ApprovalWorkflowMode) => {
|
|
3451
|
+
if (!mode) setInternalMode(nextMode);
|
|
3452
|
+
onModeChange?.(nextMode);
|
|
3453
|
+
};
|
|
3454
|
+
|
|
3455
|
+
return (
|
|
3456
|
+
<Surface
|
|
3457
|
+
{...props}
|
|
3458
|
+
data-eth-component="ApprovalWorkflowEditor"
|
|
3459
|
+
title={title}
|
|
3460
|
+
description={description}
|
|
3461
|
+
status={status ?? (missingApproverCount ? "pending-approval" : "active")}
|
|
3462
|
+
metadata={[
|
|
3463
|
+
{ label: "Steps", value: currentSteps.length },
|
|
3464
|
+
{ label: "Required gates", value: requiredCount },
|
|
3465
|
+
{ label: "Ready", value: `${readyCount}/${currentSteps.length || 0}` }
|
|
3466
|
+
]}
|
|
3467
|
+
className={`eth-domain-approval-workflow-editor ${className ?? ""}`}
|
|
3468
|
+
>
|
|
3469
|
+
<div className="eth-domain-approval-workflow-editor__settings">
|
|
3470
|
+
<Select
|
|
3471
|
+
labelText="Routing mode"
|
|
3472
|
+
density="compact"
|
|
3473
|
+
value={currentMode}
|
|
3474
|
+
options={modeOptions}
|
|
3475
|
+
onChange={(event) => changeMode(event.currentTarget.value as ApprovalWorkflowMode)}
|
|
3476
|
+
/>
|
|
3477
|
+
<div className="eth-domain-approval-workflow-editor__validation" role="status">
|
|
3478
|
+
<span>Validation</span>
|
|
3479
|
+
<strong>{validationMessage}</strong>
|
|
3480
|
+
</div>
|
|
3481
|
+
</div>
|
|
3482
|
+
|
|
3483
|
+
{currentSteps.length ? (
|
|
3484
|
+
<>
|
|
3485
|
+
<div className="eth-domain-approval-workflow-editor__route" aria-label="Approval route">
|
|
3486
|
+
{currentSteps.map((step, index) => (
|
|
3487
|
+
<React.Fragment key={step.id}>
|
|
3488
|
+
<span className="eth-domain-approval-workflow-editor__route-node">
|
|
3489
|
+
<strong>{index + 1}</strong>
|
|
3490
|
+
<span>{step.label}</span>
|
|
3491
|
+
</span>
|
|
3492
|
+
{index < currentSteps.length - 1 ? (
|
|
3493
|
+
<span
|
|
3494
|
+
className="eth-domain-approval-workflow-editor__route-arrow"
|
|
3495
|
+
aria-hidden="true"
|
|
3496
|
+
>
|
|
3497
|
+
->
|
|
3498
|
+
</span>
|
|
3499
|
+
) : null}
|
|
3500
|
+
</React.Fragment>
|
|
3501
|
+
))}
|
|
3502
|
+
</div>
|
|
3503
|
+
|
|
3504
|
+
<ol className="eth-domain-approval-workflow-editor__steps">
|
|
3505
|
+
{currentSteps.map((step, index) => (
|
|
3506
|
+
<li key={step.id} className="eth-domain-approval-workflow-editor__step">
|
|
3507
|
+
<div className="eth-domain-approval-workflow-editor__step-index" aria-hidden="true">
|
|
3508
|
+
{index + 1}
|
|
3509
|
+
</div>
|
|
3510
|
+
<div className="eth-domain-approval-workflow-editor__step-main">
|
|
3511
|
+
<div className="eth-domain-approval-workflow-editor__step-header">
|
|
3512
|
+
<div>
|
|
3513
|
+
<span>Gate {index + 1}</span>
|
|
3514
|
+
<strong>{formatApprovalStatus(step.status, step.required)}</strong>
|
|
3515
|
+
</div>
|
|
3516
|
+
<Button
|
|
3517
|
+
type="button"
|
|
3518
|
+
intent="ghost"
|
|
3519
|
+
density="compact"
|
|
3520
|
+
disabled={currentSteps.length <= 1}
|
|
3521
|
+
onClick={() => removeStep(step.id)}
|
|
3522
|
+
>
|
|
3523
|
+
Remove
|
|
3524
|
+
</Button>
|
|
3525
|
+
</div>
|
|
3526
|
+
<div className="eth-domain-approval-workflow-editor__fields">
|
|
3527
|
+
<TextInput
|
|
3528
|
+
labelText="Step name"
|
|
3529
|
+
density="compact"
|
|
3530
|
+
value={step.label}
|
|
3531
|
+
onChange={(event) =>
|
|
3532
|
+
updateStep(index, { ...step, label: event.currentTarget.value })
|
|
3533
|
+
}
|
|
3534
|
+
/>
|
|
3535
|
+
<Select
|
|
3536
|
+
labelText="Approver"
|
|
3537
|
+
density="compact"
|
|
3538
|
+
value={step.approver ?? ""}
|
|
3539
|
+
options={approverOptions}
|
|
3540
|
+
invalid={!step.approver}
|
|
3541
|
+
invalidText="Choose an approver."
|
|
3542
|
+
onChange={(event) =>
|
|
3543
|
+
updateStep(index, { ...step, approver: event.currentTarget.value })
|
|
3544
|
+
}
|
|
3545
|
+
/>
|
|
3546
|
+
<TextInput
|
|
3547
|
+
labelText="Condition"
|
|
3548
|
+
density="compact"
|
|
3549
|
+
value={step.condition ?? ""}
|
|
3550
|
+
onChange={(event) =>
|
|
3551
|
+
updateStep(index, { ...step, condition: event.currentTarget.value })
|
|
3552
|
+
}
|
|
3553
|
+
/>
|
|
3554
|
+
</div>
|
|
3555
|
+
<div className="eth-domain-approval-workflow-editor__step-footer">
|
|
3556
|
+
<Checkbox
|
|
3557
|
+
label="Required gate"
|
|
3558
|
+
checked={Boolean(step.required)}
|
|
3559
|
+
onChange={(event) =>
|
|
3560
|
+
updateStep(index, { ...step, required: event.currentTarget.checked })
|
|
3561
|
+
}
|
|
3562
|
+
/>
|
|
3563
|
+
<span>Escalates to workflow owner when overdue by 24h.</span>
|
|
3564
|
+
</div>
|
|
3565
|
+
</div>
|
|
3566
|
+
</li>
|
|
3567
|
+
))}
|
|
3568
|
+
</ol>
|
|
3569
|
+
</>
|
|
3570
|
+
) : (
|
|
3571
|
+
<div className="eth-domain-approval-workflow-editor__empty">
|
|
3572
|
+
No approval steps configured.
|
|
3573
|
+
</div>
|
|
3574
|
+
)}
|
|
3575
|
+
|
|
3576
|
+
<div className="eth-domain-approval-workflow-editor__footer">
|
|
3577
|
+
<Button type="button" intent="secondary" density="compact" onClick={addStep}>
|
|
3578
|
+
Add approval
|
|
3579
|
+
</Button>
|
|
3580
|
+
<Button
|
|
3581
|
+
type="button"
|
|
3582
|
+
intent="tertiary"
|
|
3583
|
+
density="compact"
|
|
3584
|
+
disabled={Boolean(missingApproverCount)}
|
|
3585
|
+
>
|
|
3586
|
+
Simulate route
|
|
3587
|
+
</Button>
|
|
3588
|
+
</div>
|
|
3589
|
+
</Surface>
|
|
3590
|
+
);
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
export function ProcessDesigner({
|
|
3594
|
+
title = "Business process",
|
|
3595
|
+
description = "Design, validate, and inspect a multi-step process.",
|
|
3596
|
+
items,
|
|
3597
|
+
connections,
|
|
3598
|
+
validationIssues: validationIssuesProp,
|
|
3599
|
+
selectedItemId,
|
|
3600
|
+
metadata,
|
|
3601
|
+
actions,
|
|
3602
|
+
footer,
|
|
3603
|
+
children,
|
|
3604
|
+
className,
|
|
3605
|
+
status,
|
|
3606
|
+
onAddStep,
|
|
3607
|
+
onValidate,
|
|
3608
|
+
...props
|
|
3609
|
+
}: ProcessDesignerProps) {
|
|
3610
|
+
const canvasTitleId = React.useId();
|
|
3611
|
+
const graphTitleId = React.useId();
|
|
3612
|
+
const graphDescriptionId = React.useId();
|
|
3613
|
+
const markerId = React.useId().replace(/:/g, "");
|
|
3614
|
+
const panelTitleId = React.useId();
|
|
3615
|
+
const processItems = items ?? defaultProcessItems;
|
|
3616
|
+
const validationIssues =
|
|
3617
|
+
validationIssuesProp ?? (items ? [] : defaultProcessValidationIssues);
|
|
3618
|
+
const processConnections =
|
|
3619
|
+
connections ?? (items ? sequentialProcessConnections(processItems) : defaultProcessConnections);
|
|
3620
|
+
const titleText = textFromNode(title).trim() || "Business process";
|
|
3621
|
+
const [internalSelectedId, setInternalSelectedId] = React.useState<string | undefined>(
|
|
3622
|
+
selectedItemId ?? processItems[0]?.id
|
|
3623
|
+
);
|
|
3624
|
+
|
|
3625
|
+
React.useEffect(() => {
|
|
3626
|
+
if (selectedItemId) {
|
|
3627
|
+
setInternalSelectedId(selectedItemId);
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
setInternalSelectedId((current) =>
|
|
3632
|
+
processItems.some((item) => item.id === current) ? current : processItems[0]?.id
|
|
3633
|
+
);
|
|
3634
|
+
}, [processItems, selectedItemId]);
|
|
3635
|
+
|
|
3636
|
+
const activeItem =
|
|
3637
|
+
processItems.find((item) => item.id === (selectedItemId ?? internalSelectedId)) ??
|
|
3638
|
+
processItems[0];
|
|
3639
|
+
const completedCount = processItems.filter((item) => isCompletedProcessStep(item.status)).length;
|
|
3640
|
+
const decisionCount = processItems.filter(
|
|
3641
|
+
(item, index) => processStepKind(item, index, processItems.length) === "decision"
|
|
3642
|
+
).length;
|
|
3643
|
+
const processStatus = status ?? deriveProcessStatus(processItems, validationIssues);
|
|
3644
|
+
const validationLabel = validationIssues.length
|
|
3645
|
+
? `${validationIssues.length} open ${validationIssues.length === 1 ? "issue" : "issues"}`
|
|
3646
|
+
: "No open issues";
|
|
3647
|
+
|
|
3648
|
+
const selectNode = (id: string) => {
|
|
3649
|
+
if (!selectedItemId) setInternalSelectedId(id);
|
|
3650
|
+
};
|
|
3651
|
+
|
|
3652
|
+
return (
|
|
3653
|
+
<Surface
|
|
3654
|
+
{...props}
|
|
3655
|
+
data-eth-component="ProcessDesigner"
|
|
3656
|
+
title={title}
|
|
3657
|
+
description={description}
|
|
3658
|
+
status={processStatus}
|
|
3659
|
+
metadata={
|
|
3660
|
+
metadata ?? [
|
|
3661
|
+
{ label: "Steps", value: processItems.length },
|
|
3662
|
+
{ label: "Completed", value: `${completedCount}/${processItems.length || 0}` },
|
|
3663
|
+
{ label: "Branches", value: decisionCount },
|
|
3664
|
+
{ label: "Validation", value: validationLabel }
|
|
3665
|
+
]
|
|
3666
|
+
}
|
|
3667
|
+
actions={actions}
|
|
3668
|
+
footer={
|
|
3669
|
+
footer ?? (
|
|
3670
|
+
<div className="eth-domain-process-designer__footer">
|
|
3671
|
+
<span>Draft process: {titleText}</span>
|
|
3672
|
+
<span>Last validated 12 minutes ago</span>
|
|
3673
|
+
</div>
|
|
3674
|
+
)
|
|
3675
|
+
}
|
|
3676
|
+
className={`eth-domain-process-designer ${className ?? ""}`}
|
|
3677
|
+
>
|
|
3678
|
+
<div className="eth-domain-process-designer__workspace">
|
|
3679
|
+
<section
|
|
3680
|
+
className="eth-domain-process-designer__canvas-shell"
|
|
3681
|
+
aria-labelledby={canvasTitleId}
|
|
3682
|
+
>
|
|
3683
|
+
<div className="eth-domain-process-designer__toolbar">
|
|
3684
|
+
<div>
|
|
3685
|
+
<span>Canvas</span>
|
|
3686
|
+
<strong id={canvasTitleId}>
|
|
3687
|
+
{processItems.length ? titleText : "No workflow steps"}
|
|
3688
|
+
</strong>
|
|
3689
|
+
</div>
|
|
3690
|
+
<div className="eth-domain-process-designer__toolbar-actions">
|
|
3691
|
+
{processStatus ? <StatusDot status={processStatus} label={validationLabel} /> : null}
|
|
3692
|
+
<Button type="button" intent="secondary" density="compact" onClick={onAddStep}>
|
|
3693
|
+
Add step
|
|
3694
|
+
</Button>
|
|
3695
|
+
<Button type="button" intent="tertiary" density="compact" onClick={onValidate}>
|
|
3696
|
+
Validate
|
|
3697
|
+
</Button>
|
|
3698
|
+
</div>
|
|
3699
|
+
</div>
|
|
3700
|
+
|
|
3701
|
+
{processItems.length ? (
|
|
3702
|
+
<div className="eth-domain-process-designer__canvas">
|
|
3703
|
+
<svg
|
|
3704
|
+
viewBox="0 0 920 340"
|
|
3705
|
+
role="group"
|
|
3706
|
+
aria-labelledby={graphTitleId}
|
|
3707
|
+
aria-describedby={graphDescriptionId}
|
|
3708
|
+
>
|
|
3709
|
+
<title id={graphTitleId}>Business process graph</title>
|
|
3710
|
+
<desc id={graphDescriptionId}>
|
|
3711
|
+
Process graph with {processItems.length} steps and {processConnections.length}{" "}
|
|
3712
|
+
connections.
|
|
3713
|
+
</desc>
|
|
3714
|
+
<defs>
|
|
3715
|
+
<marker
|
|
3716
|
+
id={markerId}
|
|
3717
|
+
viewBox="0 0 10 10"
|
|
3718
|
+
refX="8"
|
|
3719
|
+
refY="5"
|
|
3720
|
+
markerWidth="6"
|
|
3721
|
+
markerHeight="6"
|
|
3722
|
+
orient="auto-start-reverse"
|
|
3723
|
+
>
|
|
3724
|
+
<path d="M 0 0 L 10 5 L 0 10 z" />
|
|
3725
|
+
</marker>
|
|
3726
|
+
</defs>
|
|
3727
|
+
|
|
3728
|
+
{processConnections.map((connection, index) => {
|
|
3729
|
+
const fromIndex = processItems.findIndex((item) => item.id === connection.from);
|
|
3730
|
+
const toIndex = processItems.findIndex((item) => item.id === connection.to);
|
|
3731
|
+
const from = processItems[fromIndex];
|
|
3732
|
+
const to = processItems[toIndex];
|
|
3733
|
+
if (!from || !to) return null;
|
|
3734
|
+
|
|
3735
|
+
const fromPoint = processNodePoint(from, fromIndex);
|
|
3736
|
+
const toPoint = processNodePoint(to, toIndex);
|
|
3737
|
+
const midX = (fromPoint.x + toPoint.x) / 2;
|
|
3738
|
+
const midY = (fromPoint.y + toPoint.y) / 2;
|
|
3739
|
+
const label = textFromNode(connection.label).trim();
|
|
3740
|
+
|
|
3741
|
+
return (
|
|
3742
|
+
<g
|
|
3743
|
+
key={`${connection.from}-${connection.to}-${index}`}
|
|
3744
|
+
className="eth-domain-process-designer__edge"
|
|
3745
|
+
data-status={connection.status}
|
|
3746
|
+
>
|
|
3747
|
+
<line
|
|
3748
|
+
x1={fromPoint.x}
|
|
3749
|
+
y1={fromPoint.y}
|
|
3750
|
+
x2={toPoint.x}
|
|
3751
|
+
y2={toPoint.y}
|
|
3752
|
+
markerEnd={`url(#${markerId})`}
|
|
3753
|
+
/>
|
|
3754
|
+
{label ? (
|
|
3755
|
+
<text x={midX} y={midY - 10} textAnchor="middle">
|
|
3756
|
+
{label}
|
|
3757
|
+
</text>
|
|
3758
|
+
) : null}
|
|
3759
|
+
</g>
|
|
3760
|
+
);
|
|
3761
|
+
})}
|
|
3762
|
+
|
|
3763
|
+
{processItems.map((item, index) => {
|
|
3764
|
+
const point = processNodePoint(item, index);
|
|
3765
|
+
const kind = processStepKind(item, index, processItems.length);
|
|
3766
|
+
const selected = activeItem?.id === item.id;
|
|
3767
|
+
const lines = graphLabelLines(item.label, item.id);
|
|
3768
|
+
|
|
3769
|
+
return (
|
|
3770
|
+
<g
|
|
3771
|
+
key={item.id}
|
|
3772
|
+
className="eth-domain-process-designer__node"
|
|
3773
|
+
data-kind={kind}
|
|
3774
|
+
data-status={item.status}
|
|
3775
|
+
data-selected={selected ? "true" : undefined}
|
|
3776
|
+
transform={`translate(${point.x}, ${point.y})`}
|
|
3777
|
+
role="button"
|
|
3778
|
+
tabIndex={0}
|
|
3779
|
+
aria-label={`${textFromNode(item.label) || item.id}, ${statusLabel(
|
|
3780
|
+
item.status ?? "queued"
|
|
3781
|
+
)}`}
|
|
3782
|
+
onClick={() => selectNode(item.id)}
|
|
3783
|
+
onKeyDown={(event) => {
|
|
3784
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
3785
|
+
event.preventDefault();
|
|
3786
|
+
selectNode(item.id);
|
|
3787
|
+
}
|
|
3788
|
+
}}
|
|
3789
|
+
>
|
|
3790
|
+
{kind === "decision" ? (
|
|
3791
|
+
<polygon points="0,-44 78,0 0,44 -78,0" />
|
|
3792
|
+
) : (
|
|
3793
|
+
<rect x="-72" y="-36" width="144" height="72" rx="0" />
|
|
3794
|
+
)}
|
|
3795
|
+
<circle
|
|
3796
|
+
className="eth-domain-process-designer__node-status"
|
|
3797
|
+
cx={kind === "decision" ? -44 : -52}
|
|
3798
|
+
cy={kind === "decision" ? -18 : -18}
|
|
3799
|
+
r="5"
|
|
3800
|
+
/>
|
|
3801
|
+
<text textAnchor="middle">
|
|
3802
|
+
{lines.map((line, lineIndex) => (
|
|
3803
|
+
<tspan
|
|
3804
|
+
key={`${line}-${lineIndex}`}
|
|
3805
|
+
x="0"
|
|
3806
|
+
dy={
|
|
3807
|
+
lineIndex === 0
|
|
3808
|
+
? lines.length > 1
|
|
3809
|
+
? "-0.35em"
|
|
3810
|
+
: "0.35em"
|
|
3811
|
+
: "1.2em"
|
|
3812
|
+
}
|
|
3813
|
+
>
|
|
3814
|
+
{line}
|
|
3815
|
+
</tspan>
|
|
3816
|
+
))}
|
|
3817
|
+
</text>
|
|
3818
|
+
</g>
|
|
3819
|
+
);
|
|
3820
|
+
})}
|
|
3821
|
+
</svg>
|
|
3822
|
+
</div>
|
|
3823
|
+
) : (
|
|
3824
|
+
<div className="eth-domain-process-designer__empty">No process steps configured.</div>
|
|
3825
|
+
)}
|
|
3826
|
+
</section>
|
|
3827
|
+
|
|
3828
|
+
<aside className="eth-domain-process-designer__inspector" aria-labelledby={panelTitleId}>
|
|
3829
|
+
<div className="eth-domain-process-designer__panel-header">
|
|
3830
|
+
<span>Step properties</span>
|
|
3831
|
+
<h3 id={panelTitleId}>{activeItem ? activeItem.label : "No step selected"}</h3>
|
|
3832
|
+
</div>
|
|
3833
|
+
{activeItem ? (
|
|
3834
|
+
<>
|
|
3835
|
+
<StatusDot
|
|
3836
|
+
status={activeItem.status ?? "queued"}
|
|
3837
|
+
label={statusLabel(activeItem.status ?? "queued")}
|
|
3838
|
+
/>
|
|
3839
|
+
{activeItem.description ? (
|
|
3840
|
+
<p className="eth-domain-process-designer__description">
|
|
3841
|
+
{activeItem.description}
|
|
3842
|
+
</p>
|
|
3843
|
+
) : null}
|
|
3844
|
+
<dl className="eth-domain-process-designer__facts">
|
|
3845
|
+
<div>
|
|
3846
|
+
<dt>Owner</dt>
|
|
3847
|
+
<dd>{activeItem.owner ?? "Unassigned"}</dd>
|
|
3848
|
+
</div>
|
|
3849
|
+
<div>
|
|
3850
|
+
<dt>Lane</dt>
|
|
3851
|
+
<dd>{activeItem.lane ?? "Default lane"}</dd>
|
|
3852
|
+
</div>
|
|
3853
|
+
<div>
|
|
3854
|
+
<dt>SLA</dt>
|
|
3855
|
+
<dd>{activeItem.duration ?? activeItem.meta ?? "Not set"}</dd>
|
|
3856
|
+
</div>
|
|
3857
|
+
</dl>
|
|
3858
|
+
</>
|
|
3859
|
+
) : (
|
|
3860
|
+
<p className="eth-domain-process-designer__description">
|
|
3861
|
+
No selected step.
|
|
3862
|
+
</p>
|
|
3863
|
+
)}
|
|
3864
|
+
|
|
3865
|
+
<section className="eth-domain-process-designer__validation" aria-label="Validation">
|
|
3866
|
+
<div className="eth-domain-process-designer__panel-header">
|
|
3867
|
+
<span>Validation</span>
|
|
3868
|
+
<strong>{validationLabel}</strong>
|
|
3869
|
+
</div>
|
|
3870
|
+
{validationIssues.length ? (
|
|
3871
|
+
<ul>
|
|
3872
|
+
{validationIssues.map((issue) => (
|
|
3873
|
+
<li key={issue.id} data-status={issue.status ?? "warning"}>
|
|
3874
|
+
<StatusDot
|
|
3875
|
+
status={issue.status ?? "warning"}
|
|
3876
|
+
label={statusLabel(issue.status ?? "warning")}
|
|
3877
|
+
/>
|
|
3878
|
+
<div>
|
|
3879
|
+
{issue.target ? <strong>{issue.target}</strong> : null}
|
|
3880
|
+
<span>{issue.message}</span>
|
|
3881
|
+
</div>
|
|
3882
|
+
</li>
|
|
3883
|
+
))}
|
|
3884
|
+
</ul>
|
|
3885
|
+
) : (
|
|
3886
|
+
<p className="eth-domain-process-designer__empty-note">
|
|
3887
|
+
No validation issues detected.
|
|
3888
|
+
</p>
|
|
3889
|
+
)}
|
|
3890
|
+
</section>
|
|
3891
|
+
</aside>
|
|
3892
|
+
</div>
|
|
3893
|
+
|
|
3894
|
+
{children}
|
|
3895
|
+
</Surface>
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
export const SalesPipelineBoard = createSurfaceComponent("SalesPipelineBoard");
|
|
3900
|
+
export const ContractReviewPanel = createSurfaceComponent("ContractReviewPanel");
|
|
3901
|
+
export const RuleSimulationPanel = createSurfaceComponent("RuleSimulationPanel");
|
|
3902
|
+
|
|
3903
|
+
export type SalesPipelineBoardProps = SurfaceComponentProps;
|
|
3904
|
+
export type ContractReviewPanelProps = SurfaceComponentProps;
|
|
3905
|
+
export type RuleSimulationPanelProps = SurfaceComponentProps;
|
|
3906
|
+
|
|
3907
|
+
export const DomainWidgetsComponentNames = [
|
|
3908
|
+
"CRMContactPanel",
|
|
3909
|
+
"SalesPipelineBoard",
|
|
3910
|
+
"SupportTicketQueue",
|
|
3911
|
+
"KnowledgeBaseArticleViewer",
|
|
3912
|
+
"InventoryTable",
|
|
3913
|
+
"OrderManagementPanel",
|
|
3914
|
+
"InvoiceViewer",
|
|
3915
|
+
"ContractReviewPanel",
|
|
3916
|
+
"ComplianceChecklist",
|
|
3917
|
+
"RiskMatrix",
|
|
3918
|
+
"ApprovalWorkflowEditor",
|
|
3919
|
+
"DataImportWizard",
|
|
3920
|
+
"MappingConfigurationWizard",
|
|
3921
|
+
"ReportBuilder",
|
|
3922
|
+
"ProcessDesigner",
|
|
3923
|
+
"RuleSimulationPanel"
|
|
3924
|
+
] as const;
|
|
3925
|
+
export type DomainWidgetsComponentName = (typeof DomainWidgetsComponentNames)[number];
|