@elabs-ai/components-process 4.1.0 → 5.0.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 +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- package/src/violation-list/violation-list.tsx +259 -0
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process explorer template — the canonical full-screen `process-explorer` archetype: a
|
|
3
|
+
* map-first process-mining workspace over one order-to-cash event log.
|
|
4
|
+
*
|
|
5
|
+
* - a slim header with the scope, and the six KPIs as ONE ribbon (`layout="inline"`), so
|
|
6
|
+
* the height of the screen belongs to the process map;
|
|
7
|
+
* - a toolbar that owns every control — metric layer, deviations, detail sliders, the
|
|
8
|
+
* active filters, layout direction, table twin — so nothing floats over the map;
|
|
9
|
+
* - the map on the left, top-down so the whole process fits a pane of any width, and
|
|
10
|
+
* beside it a panel of statistical insights (throughput distribution against the SLA,
|
|
11
|
+
* the slowest hand-overs, the weekly trend, box and violin plots by region and
|
|
12
|
+
* channel), the variants as colour strips, the selected element, and deviations;
|
|
13
|
+
* - a dock for the wide, time-based views — dotted chart, performance spectrum, workload
|
|
14
|
+
* heatmap, case table — that stays a tab strip until one of them is asked for.
|
|
15
|
+
*
|
|
16
|
+
* This story is the single source of truth: `pnpm gen` derives the consumer template source
|
|
17
|
+
* (`docs/playbooks/templates/process-explorer.tsx`) from it, and the copy-own registry block
|
|
18
|
+
* (`registry/blocks/process-explorer-page/`) mirrors it. One `useProcessExplorer` instance
|
|
19
|
+
* drives every panel, so the map, the chips, the KPIs, the variants and every chart always
|
|
20
|
+
* describe the same set of cases (Invariant F: filtering re-inks, it never removes).
|
|
21
|
+
* Remember `import "@xyflow/react/dist/style.css"` is wired in Storybook preview.
|
|
22
|
+
* Verify across every theme with globals=theme:<slug>.
|
|
23
|
+
*/
|
|
24
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
25
|
+
import "@xyflow/react/dist/style.css";
|
|
26
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
27
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
28
|
+
import {
|
|
29
|
+
ArrowDown,
|
|
30
|
+
ArrowLeft,
|
|
31
|
+
ArrowRight,
|
|
32
|
+
ChevronsUp,
|
|
33
|
+
PanelBottom,
|
|
34
|
+
PanelRight,
|
|
35
|
+
Play,
|
|
36
|
+
ShieldAlert,
|
|
37
|
+
SlidersHorizontal,
|
|
38
|
+
Table2,
|
|
39
|
+
Waypoints,
|
|
40
|
+
} from "lucide-react";
|
|
41
|
+
import {
|
|
42
|
+
Badge,
|
|
43
|
+
Button,
|
|
44
|
+
Descriptions,
|
|
45
|
+
DescriptionsItem,
|
|
46
|
+
Dialog,
|
|
47
|
+
DialogContent,
|
|
48
|
+
DialogDescription,
|
|
49
|
+
DialogHeader,
|
|
50
|
+
DialogTitle,
|
|
51
|
+
Popover,
|
|
52
|
+
PopoverContent,
|
|
53
|
+
PopoverTrigger,
|
|
54
|
+
Select,
|
|
55
|
+
SelectContent,
|
|
56
|
+
SelectItem,
|
|
57
|
+
SelectTrigger,
|
|
58
|
+
SelectValue,
|
|
59
|
+
Separator,
|
|
60
|
+
Sheet,
|
|
61
|
+
SheetContent,
|
|
62
|
+
SheetDescription,
|
|
63
|
+
SheetHeader,
|
|
64
|
+
SheetTitle,
|
|
65
|
+
StatePanel,
|
|
66
|
+
Tabs,
|
|
67
|
+
TabsContent,
|
|
68
|
+
TabsList,
|
|
69
|
+
TabsTrigger,
|
|
70
|
+
Toggle,
|
|
71
|
+
ToggleGroup,
|
|
72
|
+
ToggleGroupItem,
|
|
73
|
+
Tooltip,
|
|
74
|
+
TooltipContent,
|
|
75
|
+
TooltipProvider,
|
|
76
|
+
TooltipTrigger,
|
|
77
|
+
useLocale,
|
|
78
|
+
} from "@elabs-ai/components-ui";
|
|
79
|
+
import {
|
|
80
|
+
Bar,
|
|
81
|
+
BarChart,
|
|
82
|
+
BarYAxis,
|
|
83
|
+
ChartConfigProvider,
|
|
84
|
+
ChartTooltip,
|
|
85
|
+
DistributionChart,
|
|
86
|
+
Grid,
|
|
87
|
+
HeatmapChart,
|
|
88
|
+
Line,
|
|
89
|
+
LineChart,
|
|
90
|
+
XAxis,
|
|
91
|
+
YAxis,
|
|
92
|
+
} from "@elabs-ai/components-charts";
|
|
93
|
+
import {
|
|
94
|
+
AbstractionControls,
|
|
95
|
+
activityColorScale,
|
|
96
|
+
CaseTable,
|
|
97
|
+
CaseTimeline,
|
|
98
|
+
casesFromLog,
|
|
99
|
+
createCaseTableColumns,
|
|
100
|
+
DottedChart,
|
|
101
|
+
formatDurationMs,
|
|
102
|
+
MetricLayerSwitch,
|
|
103
|
+
PerformanceSpectrum,
|
|
104
|
+
processEdgeId,
|
|
105
|
+
ProcessFilterBar,
|
|
106
|
+
ProcessKpiStrip,
|
|
107
|
+
ProcessMap,
|
|
108
|
+
ProcessReplay,
|
|
109
|
+
useProcessExplorer,
|
|
110
|
+
VariantExplorer,
|
|
111
|
+
ViolationList,
|
|
112
|
+
type CaseRow,
|
|
113
|
+
type MetricLayer,
|
|
114
|
+
type ProcessMapProps,
|
|
115
|
+
} from "./index";
|
|
116
|
+
import {
|
|
117
|
+
conformanceRateSeries,
|
|
118
|
+
liftHappyPath,
|
|
119
|
+
tokenReplay,
|
|
120
|
+
type EventLog,
|
|
121
|
+
type EventRow,
|
|
122
|
+
} from "./core";
|
|
123
|
+
|
|
124
|
+
/** Read off the components that own them, so this screen imports no package for a type alone. */
|
|
125
|
+
type CaseColumn = ReturnType<typeof createCaseTableColumns>[number];
|
|
126
|
+
type MapDirection = NonNullable<ProcessMapProps["direction"]>;
|
|
127
|
+
|
|
128
|
+
// ── The event log ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
const HOUR = 3_600_000;
|
|
131
|
+
const DAY = 24 * HOUR;
|
|
132
|
+
/** Monday 6 July 2026, 07:00 UTC — the first order of the quarter. */
|
|
133
|
+
const LOG_START = Date.UTC(2026, 6, 6, 7);
|
|
134
|
+
/** The log was exported ten weeks and two days later; anything after this has not happened. */
|
|
135
|
+
const LOG_END = LOG_START + 72 * DAY;
|
|
136
|
+
const CASE_COUNT = 420;
|
|
137
|
+
/** Order to cash is promised in 21 days. */
|
|
138
|
+
const SLA_DAYS = 21;
|
|
139
|
+
|
|
140
|
+
const REGIONS = ["North", "South", "East", "West"] as const;
|
|
141
|
+
const SEGMENTS = ["Enterprise", "Mid-market", "SMB"] as const;
|
|
142
|
+
const CHANNELS = ["Web shop", "EDI", "Sales rep"] as const;
|
|
143
|
+
|
|
144
|
+
/** A deterministic 32-bit PRNG (mulberry32): the same log on the server and in the browser. */
|
|
145
|
+
function seeded(seed: number): () => number {
|
|
146
|
+
let a = seed >>> 0;
|
|
147
|
+
return () => {
|
|
148
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
149
|
+
let t = a;
|
|
150
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
151
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
152
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** People work 07:00–19:00 on weekdays; anything that lands outside waits for the morning. */
|
|
157
|
+
function toWorkingHours(ms: number, random: () => number): number {
|
|
158
|
+
const date = new Date(ms);
|
|
159
|
+
const hour = date.getUTCHours();
|
|
160
|
+
if (hour >= 19) date.setUTCDate(date.getUTCDate() + 1);
|
|
161
|
+
if (hour >= 19 || hour < 7) date.setUTCHours(7, Math.floor(random() * 170), 0, 0);
|
|
162
|
+
const weekday = date.getUTCDay();
|
|
163
|
+
if (weekday === 6) date.setUTCDate(date.getUTCDate() + 2);
|
|
164
|
+
if (weekday === 0) date.setUTCDate(date.getUTCDate() + 1);
|
|
165
|
+
return date.getTime();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A quarter of order-to-cash: a happy path, a credit re-check loop that hits enterprise
|
|
170
|
+
* orders hardest, a backorder detour that is mostly a West problem, a three-week credit
|
|
171
|
+
* backlog in the South, invoices sent before the goods, payment reminders, rejections,
|
|
172
|
+
* cancellations — and the orders that were still open when the log was exported.
|
|
173
|
+
*/
|
|
174
|
+
function buildOrderToCashLog(): EventLog {
|
|
175
|
+
const random = seeded(20260706);
|
|
176
|
+
const between = (min: number, max: number) => min + random() * (max - min);
|
|
177
|
+
const pick = <T,>(items: readonly T[], weights: readonly number[]): T => {
|
|
178
|
+
let roll = random() * weights.reduce((sum, weight) => sum + weight, 0);
|
|
179
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
180
|
+
roll -= weights[index]!;
|
|
181
|
+
if (roll <= 0) return items[index]!;
|
|
182
|
+
}
|
|
183
|
+
return items[items.length - 1]!;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const events: EventRow[] = [];
|
|
187
|
+
const caseAttributes: Record<string, Record<string, unknown>> = {};
|
|
188
|
+
|
|
189
|
+
for (let index = 0; index < CASE_COUNT; index += 1) {
|
|
190
|
+
const caseId = `SO-${String(48200 + index * 3)}`;
|
|
191
|
+
const region = pick(REGIONS, [3, 2.4, 2.2, 1.8]);
|
|
192
|
+
const segment = pick(SEGMENTS, [1.6, 2.6, 3]);
|
|
193
|
+
const channel = pick(CHANNELS, segment === "Enterprise" ? [1, 3, 3] : [4, 1.5, 1.5]);
|
|
194
|
+
const week = Math.floor((index / CASE_COUNT) * 10);
|
|
195
|
+
const weekdayOffset = Math.floor(random() * 5);
|
|
196
|
+
caseAttributes[caseId] = {
|
|
197
|
+
region,
|
|
198
|
+
segment,
|
|
199
|
+
channel,
|
|
200
|
+
orderValue: Math.round(between(1, segment === "Enterprise" ? 90 : 24)) * 500,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
let clock = LOG_START + week * 7 * DAY + weekdayOffset * DAY + between(0, 10) * HOUR;
|
|
204
|
+
const push = (activity: string, waitHours: number, resource: string, office = true) => {
|
|
205
|
+
clock += waitHours * HOUR;
|
|
206
|
+
if (office) clock = toWorkingHours(clock, random);
|
|
207
|
+
events.push({ caseId, activity, timestamp: clock, resource });
|
|
208
|
+
};
|
|
209
|
+
const clerk = pick(["A. Novak", "B. Ferreira", "C. Okafor"], [3, 2, 2]);
|
|
210
|
+
const analyst = pick(["D. Lindqvist", "E. Haddad"], [1, 1]);
|
|
211
|
+
|
|
212
|
+
push("Create Order", 0, channel === "EDI" ? "EDI gateway" : clerk);
|
|
213
|
+
|
|
214
|
+
// The South's credit desk ran three weeks behind in weeks 4–6.
|
|
215
|
+
const backlog = region === "South" && week >= 3 && week <= 5;
|
|
216
|
+
push(
|
|
217
|
+
"Check Credit",
|
|
218
|
+
backlog ? between(30, 76) : between(0.5, 5),
|
|
219
|
+
segment === "SMB" && !backlog ? "Credit service" : analyst,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
let loops = 0;
|
|
223
|
+
const amendOdds = segment === "Enterprise" ? 0.3 : segment === "Mid-market" ? 0.14 : 0.06;
|
|
224
|
+
while (loops < 2 && random() < (loops === 0 ? amendOdds : 0.2)) {
|
|
225
|
+
push("Amend Order", between(3, 22), clerk);
|
|
226
|
+
push("Check Credit", between(2, 9), analyst);
|
|
227
|
+
loops += 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (random() < 0.05) {
|
|
231
|
+
push("Reject Order", between(1, 6), analyst);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
push("Approve Order", segment === "Enterprise" ? between(6, 30) : between(1, 8), analyst);
|
|
235
|
+
push("Reserve Stock", between(0.5, 5), "Warehouse system");
|
|
236
|
+
|
|
237
|
+
if (random() < 0.03) {
|
|
238
|
+
push("Cancel Order", between(4, 40), clerk);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (random() < (region === "West" ? 0.3 : 0.08)) {
|
|
242
|
+
push("Backorder", between(2, 6), "Warehouse system");
|
|
243
|
+
push("Reserve Stock", between(48, 140), "Warehouse system");
|
|
244
|
+
}
|
|
245
|
+
push(
|
|
246
|
+
"Pick Items",
|
|
247
|
+
between(4, 26),
|
|
248
|
+
pick(["F. Marchetti", "G. Sato", "Picking robot"], [2, 2, 3]),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (random() < 0.08) {
|
|
252
|
+
push("Send Invoice", between(2, 10), "Billing run");
|
|
253
|
+
push("Ship Order", between(8, 30), "Carrier hand-over");
|
|
254
|
+
} else {
|
|
255
|
+
push("Ship Order", between(6, 30), "Carrier hand-over");
|
|
256
|
+
push("Send Invoice", between(1, 12), "Billing run");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (random() < 0.16) {
|
|
260
|
+
push("Payment Reminder", between(14, 21) * 24, "Dunning run");
|
|
261
|
+
push("Receive Payment", between(2, 9) * 24, "Bank feed", false);
|
|
262
|
+
} else {
|
|
263
|
+
push(
|
|
264
|
+
"Receive Payment",
|
|
265
|
+
(channel === "EDI" ? between(3, 10) : between(5, 18)) * 24,
|
|
266
|
+
"Bank feed",
|
|
267
|
+
false,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Whatever is dated after the export has not happened yet: those orders are still open.
|
|
273
|
+
const happened = events.filter((event) => (event.timestamp as number) <= LOG_END);
|
|
274
|
+
return { events: happened, caseAttributes };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const ORDER_TO_CASH = buildOrderToCashLog();
|
|
278
|
+
|
|
279
|
+
/** The process as designed — what conformance is measured against. */
|
|
280
|
+
const REFERENCE_MODEL = liftHappyPath({
|
|
281
|
+
id: "order-to-cash",
|
|
282
|
+
label: "Order to cash",
|
|
283
|
+
steps: [
|
|
284
|
+
{ activity: "Create Order" },
|
|
285
|
+
{ activity: "Check Credit" },
|
|
286
|
+
{ activity: "Approve Order" },
|
|
287
|
+
{ activity: "Reserve Stock" },
|
|
288
|
+
{ activity: "Pick Items" },
|
|
289
|
+
{ activity: "Ship Order" },
|
|
290
|
+
{ activity: "Send Invoice" },
|
|
291
|
+
{ activity: "Receive Payment" },
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
/** The log narrowed to one region; case attributes travel with the cases that survive. */
|
|
296
|
+
function scopeLog(log: EventLog, region: string): EventLog {
|
|
297
|
+
if (region === "all") return log;
|
|
298
|
+
const keep = new Set(
|
|
299
|
+
Object.entries(log.caseAttributes ?? {})
|
|
300
|
+
.filter(([, attributes]) => attributes.region === region)
|
|
301
|
+
.map(([caseId]) => caseId),
|
|
302
|
+
);
|
|
303
|
+
const caseAttributes: Record<string, Record<string, unknown>> = {};
|
|
304
|
+
for (const caseId of keep) caseAttributes[caseId] = log.caseAttributes![caseId]!;
|
|
305
|
+
return { events: log.events.filter((event) => keep.has(event.caseId)), caseAttributes };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Statistics read off the filtered log ─────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
type TraceEvent = { activity: string; ms: number; resource?: string };
|
|
311
|
+
type Trace = { caseId: string; events: TraceEvent[]; attributes: Record<string, unknown> };
|
|
312
|
+
type ThroughputRow = { days: number; region: string; channel: string };
|
|
313
|
+
type WeekRow = { date: Date; median: number; p90: number };
|
|
314
|
+
type HandoverRow = { name: string; id: string; median: number; p90: number; count: number };
|
|
315
|
+
type LoadRow = { day: string; hour: string; events: number };
|
|
316
|
+
type ResourceRow = { name: string; events: number };
|
|
317
|
+
type WaitRow = { hours: number };
|
|
318
|
+
|
|
319
|
+
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
|
|
320
|
+
const OFFICE_HOURS = Array.from({ length: 12 }, (_, index) => String(index + 7).padStart(2, "0"));
|
|
321
|
+
|
|
322
|
+
function quantileOf(sorted: readonly number[], q: number): number {
|
|
323
|
+
if (sorted.length === 0) return 0;
|
|
324
|
+
const position = (sorted.length - 1) * q;
|
|
325
|
+
const low = Math.floor(position);
|
|
326
|
+
const high = Math.ceil(position);
|
|
327
|
+
return sorted[low]! + (sorted[high]! - sorted[low]!) * (position - low);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function tracesOf(log: EventLog): Trace[] {
|
|
331
|
+
const byCase = new Map<string, Trace>();
|
|
332
|
+
for (const event of log.events) {
|
|
333
|
+
let trace = byCase.get(event.caseId);
|
|
334
|
+
if (!trace) {
|
|
335
|
+
trace = {
|
|
336
|
+
caseId: event.caseId,
|
|
337
|
+
events: [],
|
|
338
|
+
attributes: log.caseAttributes?.[event.caseId] ?? {},
|
|
339
|
+
};
|
|
340
|
+
byCase.set(event.caseId, trace);
|
|
341
|
+
}
|
|
342
|
+
trace.events.push({
|
|
343
|
+
activity: event.activity,
|
|
344
|
+
ms: new Date(event.timestamp).getTime(),
|
|
345
|
+
resource: event.resource,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const traces = [...byCase.values()];
|
|
349
|
+
for (const trace of traces) trace.events.sort((a, b) => a.ms - b.ms);
|
|
350
|
+
return traces;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Everything the analysis dock plots, derived once per filtered log. */
|
|
354
|
+
function analyse(traces: readonly Trace[]) {
|
|
355
|
+
const throughput: ThroughputRow[] = [];
|
|
356
|
+
const closedByWeek = new Map<number, number[]>();
|
|
357
|
+
const load = new Map<string, number>();
|
|
358
|
+
const resources = new Map<string, number>();
|
|
359
|
+
let open = 0;
|
|
360
|
+
|
|
361
|
+
for (const trace of traces) {
|
|
362
|
+
const first = trace.events[0]!;
|
|
363
|
+
const last = trace.events[trace.events.length - 1]!;
|
|
364
|
+
if (last.activity === "Receive Payment") {
|
|
365
|
+
const days = (last.ms - first.ms) / DAY;
|
|
366
|
+
throughput.push({
|
|
367
|
+
days,
|
|
368
|
+
region: String(trace.attributes.region ?? "Unknown"),
|
|
369
|
+
channel: String(trace.attributes.channel ?? "Unknown"),
|
|
370
|
+
});
|
|
371
|
+
const week = Math.floor((last.ms - LOG_START) / (7 * DAY));
|
|
372
|
+
closedByWeek.set(week, [...(closedByWeek.get(week) ?? []), days]);
|
|
373
|
+
} else if (last.activity !== "Reject Order" && last.activity !== "Cancel Order") {
|
|
374
|
+
open += 1;
|
|
375
|
+
}
|
|
376
|
+
for (const event of trace.events) {
|
|
377
|
+
const date = new Date(event.ms);
|
|
378
|
+
const day = WEEKDAYS[date.getUTCDay() - 1];
|
|
379
|
+
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
380
|
+
if (day && OFFICE_HOURS.includes(hour)) {
|
|
381
|
+
load.set(`${day}|${hour}`, (load.get(`${day}|${hour}`) ?? 0) + 1);
|
|
382
|
+
}
|
|
383
|
+
if (event.resource) resources.set(event.resource, (resources.get(event.resource) ?? 0) + 1);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const weeks: WeekRow[] = [...closedByWeek.entries()]
|
|
388
|
+
.filter(([, days]) => days.length >= 3)
|
|
389
|
+
.sort(([a], [b]) => a - b)
|
|
390
|
+
.map(([week, days]) => {
|
|
391
|
+
const sorted = [...days].sort((a, b) => a - b);
|
|
392
|
+
return {
|
|
393
|
+
date: new Date(LOG_START + week * 7 * DAY),
|
|
394
|
+
median: Number(quantileOf(sorted, 0.5).toFixed(1)),
|
|
395
|
+
p90: Number(quantileOf(sorted, 0.9).toFixed(1)),
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const workload: LoadRow[] = WEEKDAYS.flatMap((day) =>
|
|
400
|
+
OFFICE_HOURS.map((hour) => ({ day, hour, events: load.get(`${day}|${hour}`) ?? 0 })),
|
|
401
|
+
);
|
|
402
|
+
const resourceLoad: ResourceRow[] = [...resources.entries()]
|
|
403
|
+
.map(([name, events]) => ({ name, events }))
|
|
404
|
+
.sort((a, b) => b.events - a.events)
|
|
405
|
+
.slice(0, 6);
|
|
406
|
+
|
|
407
|
+
const sortedDays = throughput.map((row) => row.days).sort((a, b) => a - b);
|
|
408
|
+
const late = sortedDays.filter((days) => days > SLA_DAYS).length;
|
|
409
|
+
return {
|
|
410
|
+
throughput,
|
|
411
|
+
weeks,
|
|
412
|
+
workload,
|
|
413
|
+
resourceLoad,
|
|
414
|
+
open,
|
|
415
|
+
closed: throughput.length,
|
|
416
|
+
medianDays: quantileOf(sortedDays, 0.5),
|
|
417
|
+
p90Days: quantileOf(sortedDays, 0.9),
|
|
418
|
+
lateShare: throughput.length > 0 ? late / throughput.length : 0,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Hours between two activities wherever one directly follows the other; or into one activity. */
|
|
423
|
+
function waitsFor(
|
|
424
|
+
traces: readonly Trace[],
|
|
425
|
+
selection: { kind: "activity" | "transition"; id: string },
|
|
426
|
+
edge: { source: string; target: string } | undefined,
|
|
427
|
+
): WaitRow[] {
|
|
428
|
+
const rows: WaitRow[] = [];
|
|
429
|
+
for (const trace of traces) {
|
|
430
|
+
for (let index = 1; index < trace.events.length; index += 1) {
|
|
431
|
+
const from = trace.events[index - 1]!;
|
|
432
|
+
const to = trace.events[index]!;
|
|
433
|
+
const match =
|
|
434
|
+
selection.kind === "activity"
|
|
435
|
+
? to.activity === selection.id
|
|
436
|
+
: edge !== undefined && from.activity === edge.source && to.activity === edge.target;
|
|
437
|
+
if (match) rows.push({ hours: (to.ms - from.ms) / HOUR });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return rows;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
|
|
444
|
+
const count = new Intl.NumberFormat("en-US");
|
|
445
|
+
const dayLabel = new Intl.DateTimeFormat("en-US", {
|
|
446
|
+
month: "short",
|
|
447
|
+
day: "numeric",
|
|
448
|
+
timeZone: "UTC",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ── Small pieces ─────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/** One statistical view in the dock: the finding as its title, how to read it underneath. */
|
|
454
|
+
function Figure({
|
|
455
|
+
title,
|
|
456
|
+
note,
|
|
457
|
+
className,
|
|
458
|
+
children,
|
|
459
|
+
}: {
|
|
460
|
+
title: string;
|
|
461
|
+
note: string;
|
|
462
|
+
className?: string;
|
|
463
|
+
children: ReactNode;
|
|
464
|
+
}) {
|
|
465
|
+
return (
|
|
466
|
+
<figure className={`flex min-w-0 flex-col gap-2 ${className ?? ""}`}>
|
|
467
|
+
<figcaption className="flex min-w-0 flex-col">
|
|
468
|
+
<span className="truncate text-body font-medium">{title}</span>
|
|
469
|
+
<span className="truncate text-meta text-muted-foreground">{note}</span>
|
|
470
|
+
</figcaption>
|
|
471
|
+
{children}
|
|
472
|
+
</figure>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** An icon button with its name on hover — the toolbar has no room for labels. */
|
|
477
|
+
function ToolbarToggle({
|
|
478
|
+
label,
|
|
479
|
+
pressed,
|
|
480
|
+
onPressedChange,
|
|
481
|
+
children,
|
|
482
|
+
}: {
|
|
483
|
+
label: string;
|
|
484
|
+
pressed: boolean;
|
|
485
|
+
onPressedChange: (pressed: boolean) => void;
|
|
486
|
+
children: ReactNode;
|
|
487
|
+
}) {
|
|
488
|
+
return (
|
|
489
|
+
// Self-provided, like `IconButton` — a Radix `Tooltip` throws without a
|
|
490
|
+
// provider above it, and this toolbar is dropped into any shell.
|
|
491
|
+
<TooltipProvider>
|
|
492
|
+
<Tooltip>
|
|
493
|
+
<TooltipTrigger asChild>
|
|
494
|
+
<Toggle aria-label={label} pressed={pressed} onPressedChange={onPressedChange} size="sm">
|
|
495
|
+
{children}
|
|
496
|
+
</Toggle>
|
|
497
|
+
</TooltipTrigger>
|
|
498
|
+
<TooltipContent>{label}</TooltipContent>
|
|
499
|
+
</Tooltip>
|
|
500
|
+
</TooltipProvider>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The rail's reading of the current selection — numbers from the SAME graph the canvas
|
|
506
|
+
* draws, and the distribution of the waits behind its median.
|
|
507
|
+
*/
|
|
508
|
+
function SelectionDetail({
|
|
509
|
+
graph,
|
|
510
|
+
selection,
|
|
511
|
+
traces,
|
|
512
|
+
}: {
|
|
513
|
+
graph: ReturnType<typeof useProcessExplorer>["graph"];
|
|
514
|
+
selection: { kind: "activity" | "transition"; id: string } | null;
|
|
515
|
+
traces: readonly Trace[];
|
|
516
|
+
}) {
|
|
517
|
+
const transition =
|
|
518
|
+
selection?.kind === "transition"
|
|
519
|
+
? graph.transitions.find(
|
|
520
|
+
(candidate) => processEdgeId(candidate.source, candidate.target) === selection.id,
|
|
521
|
+
)
|
|
522
|
+
: undefined;
|
|
523
|
+
const waits = useMemo(
|
|
524
|
+
() => (selection ? waitsFor(traces, selection, transition) : []),
|
|
525
|
+
[traces, selection, transition],
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
if (!selection) {
|
|
529
|
+
return (
|
|
530
|
+
<StatePanel
|
|
531
|
+
kind="empty"
|
|
532
|
+
size="sm"
|
|
533
|
+
title="Nothing selected"
|
|
534
|
+
description="Select an activity or a transition on the map to read its numbers and the distribution behind them."
|
|
535
|
+
/>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const activity =
|
|
540
|
+
selection.kind === "activity"
|
|
541
|
+
? graph.activities.find((candidate) => candidate.id === selection.id)
|
|
542
|
+
: undefined;
|
|
543
|
+
if (!activity && !transition) return null;
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<div className="flex flex-col gap-4">
|
|
547
|
+
<div className="flex flex-col gap-1">
|
|
548
|
+
<span className="text-meta text-muted-foreground">
|
|
549
|
+
{activity ? "Activity" : "Transition"}
|
|
550
|
+
</span>
|
|
551
|
+
<span className="text-subtitle font-semibold text-balance">
|
|
552
|
+
{activity ? activity.id : `${transition!.source} → ${transition!.target}`}
|
|
553
|
+
</span>
|
|
554
|
+
</div>
|
|
555
|
+
{activity ? (
|
|
556
|
+
<Descriptions>
|
|
557
|
+
<DescriptionsItem label="Cases" numeric>
|
|
558
|
+
{count.format(activity.cases)}
|
|
559
|
+
</DescriptionsItem>
|
|
560
|
+
<DescriptionsItem label="Executions" numeric>
|
|
561
|
+
{count.format(activity.instances)}
|
|
562
|
+
</DescriptionsItem>
|
|
563
|
+
<DescriptionsItem label="Role">
|
|
564
|
+
{activity.isStart ? "Start" : activity.isEnd ? "End" : "Intermediate"}
|
|
565
|
+
</DescriptionsItem>
|
|
566
|
+
</Descriptions>
|
|
567
|
+
) : (
|
|
568
|
+
<Descriptions>
|
|
569
|
+
<DescriptionsItem label="Hand-overs" numeric>
|
|
570
|
+
{count.format(transition!.count)}
|
|
571
|
+
</DescriptionsItem>
|
|
572
|
+
<DescriptionsItem label="Median wait" numeric>
|
|
573
|
+
{formatDurationMs(transition!.duration.median)}
|
|
574
|
+
</DescriptionsItem>
|
|
575
|
+
<DescriptionsItem label="90th percentile" numeric>
|
|
576
|
+
{formatDurationMs(transition!.duration.p90)}
|
|
577
|
+
</DescriptionsItem>
|
|
578
|
+
<DescriptionsItem label="Total waiting" numeric>
|
|
579
|
+
{formatDurationMs(transition!.duration.sum)}
|
|
580
|
+
</DescriptionsItem>
|
|
581
|
+
</Descriptions>
|
|
582
|
+
)}
|
|
583
|
+
{waits.length >= 8 ? (
|
|
584
|
+
<Figure
|
|
585
|
+
title={activity ? "Wait before this activity" : "Wait on this hand-over"}
|
|
586
|
+
note={`Hours, ${count.format(waits.length)} occurrences; the flag is the median.`}
|
|
587
|
+
>
|
|
588
|
+
<div className="h-40">
|
|
589
|
+
<DistributionChart
|
|
590
|
+
accessibleLabel="Distribution of waiting time in hours"
|
|
591
|
+
bins={16}
|
|
592
|
+
data={waits}
|
|
593
|
+
kind="histogram"
|
|
594
|
+
valueKey="hours"
|
|
595
|
+
/>
|
|
596
|
+
</div>
|
|
597
|
+
</Figure>
|
|
598
|
+
) : null}
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── The screen ───────────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
type DockTab = "timeline" | "spectrum" | "workload" | "cases";
|
|
606
|
+
type RailTab = "insights" | "variants" | "selection" | "deviations";
|
|
607
|
+
|
|
608
|
+
/** The screen. Everything below is composition — no new primitive is authored here. */
|
|
609
|
+
export function ProcessExplorerTemplate() {
|
|
610
|
+
const [region, setRegion] = useState("all");
|
|
611
|
+
const log = useMemo(() => scopeLog(ORDER_TO_CASH, region), [region]);
|
|
612
|
+
const explorer = useProcessExplorer(log, { abstraction: { activities: 1, paths: 0.8 } });
|
|
613
|
+
|
|
614
|
+
const [direction, setDirection] = useState<MapDirection>("TB");
|
|
615
|
+
const [tableView, setTableView] = useState(false);
|
|
616
|
+
const [showDeviations, setShowDeviations] = useState(false);
|
|
617
|
+
const [railOpen, setRailOpen] = useState(true);
|
|
618
|
+
const [railTab, setRailTab] = useState<RailTab>("insights");
|
|
619
|
+
const [dockOpen, setDockOpen] = useState(false);
|
|
620
|
+
const [dockTab, setDockTab] = useState<DockTab>("timeline");
|
|
621
|
+
const [timelineAxis, setTimelineAxis] = useState<"absolute" | "relative">("relative");
|
|
622
|
+
const [dockTall, setDockTall] = useState(false);
|
|
623
|
+
const [replayOpen, setReplayOpen] = useState(false);
|
|
624
|
+
const [openCaseId, setOpenCaseId] = useState<string | null>(null);
|
|
625
|
+
|
|
626
|
+
const totalCases = useMemo(() => new Set(log.events.map((event) => event.caseId)).size, [log]);
|
|
627
|
+
// Built ONCE per graph and shared by the map, the variant rail and the dotted chart, so
|
|
628
|
+
// "Check Credit" is the same swatch in all three.
|
|
629
|
+
const colorScale = useMemo(() => activityColorScale(explorer.graph), [explorer.graph]);
|
|
630
|
+
const traces = useMemo(() => tracesOf(explorer.filteredLog), [explorer.filteredLog]);
|
|
631
|
+
const stats = useMemo(() => analyse(traces), [traces]);
|
|
632
|
+
const conformance = useMemo(
|
|
633
|
+
() => tokenReplay(explorer.filteredLog, REFERENCE_MODEL),
|
|
634
|
+
[explorer.filteredLog],
|
|
635
|
+
);
|
|
636
|
+
// The case table reads the same replay the map and the KPI ribbon do: a case conforms when
|
|
637
|
+
// it replays on the reference model without a single deviation.
|
|
638
|
+
const cases = useMemo(() => {
|
|
639
|
+
const fits = new Map(conformance.traces.map((trace) => [trace.caseId, trace.fitness >= 1]));
|
|
640
|
+
return casesFromLog(explorer.filteredLog).map(
|
|
641
|
+
(row): CaseRow => ({
|
|
642
|
+
...row,
|
|
643
|
+
conformance: fits.get(row.caseId) ? "conforming" : "nonConforming",
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
}, [explorer.filteredLog, conformance]);
|
|
647
|
+
const { t, formatDate } = useLocale();
|
|
648
|
+
const caseColumns = useMemo(() => {
|
|
649
|
+
const base = createCaseTableColumns({ t, formatDate });
|
|
650
|
+
const column = (key: string) =>
|
|
651
|
+
base.find((candidate) => "accessorKey" in candidate && candidate.accessorKey === key)!;
|
|
652
|
+
const rank = new Map(explorer.variants.map((variant, index) => [variant.id, index + 1]));
|
|
653
|
+
const attribute = (key: string, header: string): CaseColumn => ({
|
|
654
|
+
id: key,
|
|
655
|
+
header,
|
|
656
|
+
accessorFn: (row) => String(row.attributes?.[key] ?? "—"),
|
|
657
|
+
});
|
|
658
|
+
return [
|
|
659
|
+
{ ...column("caseId"), header: "Order" },
|
|
660
|
+
attribute("region", "Region"),
|
|
661
|
+
attribute("channel", "Channel"),
|
|
662
|
+
column("start"),
|
|
663
|
+
column("end"),
|
|
664
|
+
{ ...column("durationMs"), header: "Throughput" },
|
|
665
|
+
column("eventCount"),
|
|
666
|
+
{
|
|
667
|
+
id: "variant",
|
|
668
|
+
header: "Variant",
|
|
669
|
+
meta: { numeric: true },
|
|
670
|
+
accessorFn: (row) => rank.get(row.variantId) ?? 0,
|
|
671
|
+
cell: ({ getValue }) => `#${getValue<number>()}`,
|
|
672
|
+
},
|
|
673
|
+
column("conformance"),
|
|
674
|
+
] satisfies CaseColumn[];
|
|
675
|
+
}, [t, formatDate, explorer.variants]);
|
|
676
|
+
const conformanceSeries = useMemo(
|
|
677
|
+
() => conformanceRateSeries(explorer.filteredLog, REFERENCE_MODEL, "week"),
|
|
678
|
+
[explorer.filteredLog],
|
|
679
|
+
);
|
|
680
|
+
const trends = useMemo(() => {
|
|
681
|
+
const weeks = Array.from({ length: 10 }, () => ({ cases: 0, events: 0, rework: 0 }));
|
|
682
|
+
for (const trace of traces) {
|
|
683
|
+
const bucket = weeks[Math.floor((trace.events[0]!.ms - LOG_START) / (7 * DAY))];
|
|
684
|
+
if (!bucket) continue;
|
|
685
|
+
bucket.cases += 1;
|
|
686
|
+
bucket.events += trace.events.length;
|
|
687
|
+
const seen = new Set(trace.events.map((event) => event.activity));
|
|
688
|
+
if (seen.size < trace.events.length) bucket.rework += 1;
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
cases: weeks.map((week) => week.cases),
|
|
692
|
+
events: weeks.map((week) => week.events),
|
|
693
|
+
reworkRate: weeks.map((week) => (week.cases > 0 ? week.rework / week.cases : 0)),
|
|
694
|
+
medianThroughput: stats.weeks.map((week) => week.median * DAY),
|
|
695
|
+
};
|
|
696
|
+
}, [traces, stats.weeks]);
|
|
697
|
+
|
|
698
|
+
const handovers: HandoverRow[] = useMemo(
|
|
699
|
+
() =>
|
|
700
|
+
explorer.graph.transitions
|
|
701
|
+
.filter((transition) => transition.count >= 5)
|
|
702
|
+
.map((transition) => ({
|
|
703
|
+
name: `${transition.source} → ${transition.target}`,
|
|
704
|
+
id: processEdgeId(transition.source, transition.target),
|
|
705
|
+
median: Number((transition.duration.median / HOUR).toFixed(1)),
|
|
706
|
+
p90: Number((transition.duration.p90 / HOUR).toFixed(1)),
|
|
707
|
+
count: transition.count,
|
|
708
|
+
}))
|
|
709
|
+
.sort((a, b) => b.median - a.median)
|
|
710
|
+
.slice(0, 6),
|
|
711
|
+
[explorer.graph],
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const openCaseEvents = useMemo(
|
|
715
|
+
() =>
|
|
716
|
+
openCaseId ? explorer.filteredLog.events.filter((event) => event.caseId === openCaseId) : [],
|
|
717
|
+
[explorer.filteredLog, openCaseId],
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
const hasProcess = explorer.graph.activities.length > 0;
|
|
721
|
+
const slowest = handovers[0];
|
|
722
|
+
|
|
723
|
+
function select(next: Parameters<typeof explorer.onSelect>[0]) {
|
|
724
|
+
explorer.onSelect(next);
|
|
725
|
+
if (next) {
|
|
726
|
+
setRailOpen(true);
|
|
727
|
+
setRailTab("selection");
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// The toolbar's layer switch and the full `MetricLayerSwitch` in the popover write the same
|
|
732
|
+
// state; the metric coercion mirrors the component's own (a performance layer cannot paint
|
|
733
|
+
// a frequency metric, and the reverse).
|
|
734
|
+
function changeLayer(next: string) {
|
|
735
|
+
if (!next) return;
|
|
736
|
+
explorer.setLayer(next as MetricLayer);
|
|
737
|
+
if (next === "performance") explorer.setMetric({ node: "median", edge: "median" });
|
|
738
|
+
if (next === "frequency") explorer.setMetric({ node: "absolute", edge: "absolute" });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// The variant rail emits ids and how to apply them — it never filters itself. Keeping at
|
|
742
|
+
// most ONE "variant" intent in the chain, updated in place, keeps "last interaction wins".
|
|
743
|
+
function applyVariantSelection(ids: string[], mode: "replace" | "toggle") {
|
|
744
|
+
const activeIndex = explorer.intents.findIndex((intent) => intent.kind === "variant");
|
|
745
|
+
const active = activeIndex >= 0 ? explorer.intents[activeIndex] : undefined;
|
|
746
|
+
const previousIds = active && active.kind === "variant" ? active.ids : [];
|
|
747
|
+
const toggled = ids[0]!;
|
|
748
|
+
const nextIds =
|
|
749
|
+
mode === "replace"
|
|
750
|
+
? ids
|
|
751
|
+
: previousIds.includes(toggled)
|
|
752
|
+
? previousIds.filter((id) => id !== toggled)
|
|
753
|
+
: [...previousIds, toggled];
|
|
754
|
+
if (activeIndex >= 0) explorer.clearIntent(activeIndex);
|
|
755
|
+
if (nextIds.length > 0) explorer.applyIntent({ kind: "variant", ids: nextIds });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function clearFilters() {
|
|
759
|
+
for (let index = explorer.intents.length - 1; index >= 0; index -= 1) {
|
|
760
|
+
explorer.clearIntent(index);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return (
|
|
765
|
+
// `min-h-0` on every link of the chain and `overflow-hidden` at the root: the map is the
|
|
766
|
+
// one region that absorbs the leftover height, so nothing above or below it may refuse
|
|
767
|
+
// to shrink. `@container`: the rail and the header details follow THIS box, not the window.
|
|
768
|
+
// Every chart here sits in a narrow column by design; keep their axes and labels instead of
|
|
769
|
+
// letting the narrow breakpoint thin them to a phone's furniture.
|
|
770
|
+
<ChartConfigProvider value={{ density: { base: "md", narrow: "md" } }}>
|
|
771
|
+
<div className="@container flex h-full min-h-176 flex-col overflow-hidden bg-background text-foreground">
|
|
772
|
+
<header className="flex h-header shrink-0 items-center gap-3 border-b border-border px-4">
|
|
773
|
+
<Waypoints aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|
774
|
+
<h1 className="truncate text-body font-semibold">Order to cash</h1>
|
|
775
|
+
<Badge variant="outline">Q3 2026</Badge>
|
|
776
|
+
<span className="hidden truncate text-meta text-muted-foreground @4xl:inline">
|
|
777
|
+
{`${dayLabel.format(LOG_START)} – ${dayLabel.format(LOG_END)} · ${count.format(stats.closed)} closed · ${count.format(stats.open)} still open · SLA ${SLA_DAYS} days`}
|
|
778
|
+
</span>
|
|
779
|
+
<div className="ms-auto flex shrink-0 items-center gap-2">
|
|
780
|
+
<Select value={region} onValueChange={setRegion}>
|
|
781
|
+
<SelectTrigger aria-label="Region" className="min-w-36" size="sm">
|
|
782
|
+
<SelectValue />
|
|
783
|
+
</SelectTrigger>
|
|
784
|
+
<SelectContent>
|
|
785
|
+
<SelectItem value="all">All regions</SelectItem>
|
|
786
|
+
{REGIONS.map((name) => (
|
|
787
|
+
<SelectItem key={name} value={name}>
|
|
788
|
+
{name}
|
|
789
|
+
</SelectItem>
|
|
790
|
+
))}
|
|
791
|
+
</SelectContent>
|
|
792
|
+
</Select>
|
|
793
|
+
<Button variant="outline" size="sm" onClick={() => setReplayOpen(true)}>
|
|
794
|
+
<Play aria-hidden="true" />
|
|
795
|
+
Replay
|
|
796
|
+
</Button>
|
|
797
|
+
</div>
|
|
798
|
+
</header>
|
|
799
|
+
|
|
800
|
+
<ProcessKpiStrip
|
|
801
|
+
className="shrink-0 border-b border-border"
|
|
802
|
+
layout="inline"
|
|
803
|
+
kpis={explorer.kpis}
|
|
804
|
+
conformance={conformance}
|
|
805
|
+
conformanceSeries={conformanceSeries}
|
|
806
|
+
trends={trends}
|
|
807
|
+
loading={explorer.loading}
|
|
808
|
+
/>
|
|
809
|
+
|
|
810
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
|
811
|
+
<ToggleGroup
|
|
812
|
+
type="single"
|
|
813
|
+
variant="segmented"
|
|
814
|
+
size="sm"
|
|
815
|
+
value={explorer.layer}
|
|
816
|
+
onValueChange={changeLayer}
|
|
817
|
+
aria-label="Metric layer"
|
|
818
|
+
>
|
|
819
|
+
<ToggleGroupItem value="frequency">Frequency</ToggleGroupItem>
|
|
820
|
+
<ToggleGroupItem value="performance">Performance</ToggleGroupItem>
|
|
821
|
+
<ToggleGroupItem value="rework">Rework</ToggleGroupItem>
|
|
822
|
+
</ToggleGroup>
|
|
823
|
+
<ToolbarToggle
|
|
824
|
+
label="Show deviations from the reference model"
|
|
825
|
+
pressed={showDeviations}
|
|
826
|
+
onPressedChange={setShowDeviations}
|
|
827
|
+
>
|
|
828
|
+
<ShieldAlert aria-hidden="true" />
|
|
829
|
+
</ToolbarToggle>
|
|
830
|
+
<Popover>
|
|
831
|
+
<PopoverTrigger asChild>
|
|
832
|
+
<Button variant="ghost" size="sm">
|
|
833
|
+
<SlidersHorizontal aria-hidden="true" />
|
|
834
|
+
<span className="hidden @3xl:inline">Detail</span>
|
|
835
|
+
</Button>
|
|
836
|
+
</PopoverTrigger>
|
|
837
|
+
<PopoverContent align="start" className="flex w-80 flex-col gap-4">
|
|
838
|
+
<MetricLayerSwitch
|
|
839
|
+
layer={explorer.layer}
|
|
840
|
+
onLayerChange={explorer.setLayer}
|
|
841
|
+
metric={explorer.metric}
|
|
842
|
+
onMetricChange={explorer.setMetric}
|
|
843
|
+
/>
|
|
844
|
+
<Separator />
|
|
845
|
+
<AbstractionControls
|
|
846
|
+
abstraction={explorer.abstraction}
|
|
847
|
+
onAbstractionChange={explorer.setAbstraction}
|
|
848
|
+
graph={explorer.graph}
|
|
849
|
+
hiddenCounts={explorer.hiddenCounts}
|
|
850
|
+
/>
|
|
851
|
+
</PopoverContent>
|
|
852
|
+
</Popover>
|
|
853
|
+
<Separator orientation="vertical" className="h-5" />
|
|
854
|
+
{/* One line, summary first: the chips join the row instead of pushing the map down. */}
|
|
855
|
+
<ProcessFilterBar
|
|
856
|
+
className="min-w-0 flex-1 flex-row-reverse items-center justify-end gap-3 [&>p]:shrink-0 [&>p]:whitespace-nowrap"
|
|
857
|
+
intents={explorer.intents}
|
|
858
|
+
excludedByIntent={explorer.excludedByIntent}
|
|
859
|
+
totalCases={totalCases}
|
|
860
|
+
filteredCases={explorer.kpis.cases}
|
|
861
|
+
hiddenCounts={explorer.hiddenCounts}
|
|
862
|
+
onRemove={explorer.clearIntent}
|
|
863
|
+
onClearAll={clearFilters}
|
|
864
|
+
/>
|
|
865
|
+
<ToggleGroup
|
|
866
|
+
type="single"
|
|
867
|
+
size="sm"
|
|
868
|
+
value={direction}
|
|
869
|
+
onValueChange={(next) => next && setDirection(next as MapDirection)}
|
|
870
|
+
aria-label="Layout direction"
|
|
871
|
+
>
|
|
872
|
+
<ToggleGroupItem value="LR" aria-label="Left to right">
|
|
873
|
+
<ArrowRight aria-hidden="true" />
|
|
874
|
+
</ToggleGroupItem>
|
|
875
|
+
<ToggleGroupItem value="TB" aria-label="Top down">
|
|
876
|
+
<ArrowDown aria-hidden="true" />
|
|
877
|
+
</ToggleGroupItem>
|
|
878
|
+
</ToggleGroup>
|
|
879
|
+
<ToolbarToggle
|
|
880
|
+
label="Read the map as a table"
|
|
881
|
+
pressed={tableView}
|
|
882
|
+
onPressedChange={setTableView}
|
|
883
|
+
>
|
|
884
|
+
<Table2 aria-hidden="true" />
|
|
885
|
+
</ToolbarToggle>
|
|
886
|
+
<ToolbarToggle
|
|
887
|
+
label="Insights, variants and details"
|
|
888
|
+
pressed={railOpen}
|
|
889
|
+
onPressedChange={setRailOpen}
|
|
890
|
+
>
|
|
891
|
+
<PanelRight aria-hidden="true" />
|
|
892
|
+
</ToolbarToggle>
|
|
893
|
+
<ToolbarToggle
|
|
894
|
+
label="Timelines, workload and cases"
|
|
895
|
+
pressed={dockOpen}
|
|
896
|
+
onPressedChange={setDockOpen}
|
|
897
|
+
>
|
|
898
|
+
<PanelBottom aria-hidden="true" />
|
|
899
|
+
</ToolbarToggle>
|
|
900
|
+
</div>
|
|
901
|
+
|
|
902
|
+
<div className="flex min-h-0 flex-1">
|
|
903
|
+
<div className="min-h-0 min-w-0 flex-1">
|
|
904
|
+
{hasProcess ? (
|
|
905
|
+
<ProcessMap
|
|
906
|
+
className="h-full"
|
|
907
|
+
graph={explorer.graph}
|
|
908
|
+
metric={explorer.metric}
|
|
909
|
+
rework={explorer.rework}
|
|
910
|
+
direction={direction}
|
|
911
|
+
selection={explorer.selection}
|
|
912
|
+
onSelect={select}
|
|
913
|
+
selectionStates={explorer.selectionStates}
|
|
914
|
+
onFilterIntent={explorer.applyIntent}
|
|
915
|
+
colorScale={colorScale}
|
|
916
|
+
conformance={showDeviations ? conformance : undefined}
|
|
917
|
+
showMiniMap={false}
|
|
918
|
+
showLegend={false}
|
|
919
|
+
fitMinZoom={0.35}
|
|
920
|
+
fitPadding={0.06}
|
|
921
|
+
refitKey={`${railOpen}:${dockOpen}:${dockTall}`}
|
|
922
|
+
tableView={tableView}
|
|
923
|
+
loading={explorer.loading}
|
|
924
|
+
/>
|
|
925
|
+
) : (
|
|
926
|
+
<StatePanel
|
|
927
|
+
kind="empty"
|
|
928
|
+
title="No process to show"
|
|
929
|
+
description="No case matches this scope, so there is no directly-follows relation to discover. Widen the filter or pick another region."
|
|
930
|
+
/>
|
|
931
|
+
)}
|
|
932
|
+
</div>
|
|
933
|
+
{railOpen ? (
|
|
934
|
+
<aside className="@container hidden w-2/5 max-w-xl min-w-80 shrink-0 flex-col border-s border-border bg-surface-muted @3xl:flex">
|
|
935
|
+
<Tabs
|
|
936
|
+
value={railTab}
|
|
937
|
+
onValueChange={(next) => setRailTab(next as RailTab)}
|
|
938
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
939
|
+
>
|
|
940
|
+
<TabsList variant="underline" className="shrink-0 px-3">
|
|
941
|
+
<TabsTrigger value="insights">Insights</TabsTrigger>
|
|
942
|
+
<TabsTrigger value="variants">{`Variants ${count.format(explorer.variants.length)}`}</TabsTrigger>
|
|
943
|
+
<TabsTrigger value="selection">Selection</TabsTrigger>
|
|
944
|
+
<TabsTrigger value="deviations">Deviations</TabsTrigger>
|
|
945
|
+
</TabsList>
|
|
946
|
+
|
|
947
|
+
<TabsContent value="insights" className="min-h-0 flex-1 overflow-auto p-4">
|
|
948
|
+
<div className="flex flex-col gap-6">
|
|
949
|
+
<Figure
|
|
950
|
+
title={`${percent.format(stats.lateShare)} of closed orders missed the ${SLA_DAYS}-day SLA`}
|
|
951
|
+
note={`Days from order to payment · median ${stats.medianDays.toFixed(1)} d · P90 ${stats.p90Days.toFixed(1)} d`}
|
|
952
|
+
>
|
|
953
|
+
<div className="h-36">
|
|
954
|
+
<DistributionChart
|
|
955
|
+
accessibleLabel="Distribution of order-to-payment time in days"
|
|
956
|
+
bins={24}
|
|
957
|
+
data={stats.throughput}
|
|
958
|
+
kind="histogram"
|
|
959
|
+
referenceLines={[{ value: SLA_DAYS, label: `SLA ${SLA_DAYS} d` }]}
|
|
960
|
+
valueKey="days"
|
|
961
|
+
/>
|
|
962
|
+
</div>
|
|
963
|
+
</Figure>
|
|
964
|
+
<Figure
|
|
965
|
+
title={
|
|
966
|
+
slowest
|
|
967
|
+
? `Slowest hand-over: ${slowest.name}`
|
|
968
|
+
: "No hand-over has enough cases to rank"
|
|
969
|
+
}
|
|
970
|
+
note="Median wait in hours, tick at the 90th percentile · select a bar to find it on the map"
|
|
971
|
+
>
|
|
972
|
+
<BarChart
|
|
973
|
+
accessibleLabel="The slowest hand-overs by median waiting time"
|
|
974
|
+
data={handovers}
|
|
975
|
+
onDatapointClick={(point) =>
|
|
976
|
+
select({
|
|
977
|
+
kind: "transition",
|
|
978
|
+
id: String((point.datum as HandoverRow).id),
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
orientation="horizontal"
|
|
982
|
+
overlays={[
|
|
983
|
+
{ kind: "value", key: "p90", label: "90th percentile", marker: "tick" },
|
|
984
|
+
]}
|
|
985
|
+
plotHeight={204}
|
|
986
|
+
xDataKey="name"
|
|
987
|
+
>
|
|
988
|
+
<Grid vertical />
|
|
989
|
+
<Bar dataKey="median" fill="var(--chart-1)" lineCap="round" />
|
|
990
|
+
<BarYAxis maxWidth={216} />
|
|
991
|
+
<ChartTooltip />
|
|
992
|
+
</BarChart>
|
|
993
|
+
</Figure>
|
|
994
|
+
<Figure
|
|
995
|
+
title="Throughput by closing week"
|
|
996
|
+
note="Days · strong line: median · light line: 90th percentile"
|
|
997
|
+
>
|
|
998
|
+
<LineChart
|
|
999
|
+
accessibleLabel="Median and 90th-percentile throughput time per closing week"
|
|
1000
|
+
data={stats.weeks}
|
|
1001
|
+
plotHeight={132}
|
|
1002
|
+
>
|
|
1003
|
+
<Grid horizontal />
|
|
1004
|
+
<Line
|
|
1005
|
+
curve="monotone"
|
|
1006
|
+
dataKey="p90"
|
|
1007
|
+
stroke="var(--chart-3)"
|
|
1008
|
+
strokeWidth={1.5}
|
|
1009
|
+
/>
|
|
1010
|
+
<Line
|
|
1011
|
+
curve="monotone"
|
|
1012
|
+
dataKey="median"
|
|
1013
|
+
stroke="var(--chart-1)"
|
|
1014
|
+
strokeWidth={2.5}
|
|
1015
|
+
/>
|
|
1016
|
+
<XAxis numTicks={4} tickFormat={(date) => dayLabel.format(date)} />
|
|
1017
|
+
<YAxis />
|
|
1018
|
+
<ChartTooltip />
|
|
1019
|
+
</LineChart>
|
|
1020
|
+
</Figure>
|
|
1021
|
+
<div className="grid grid-cols-1 gap-6 @lg:grid-cols-2">
|
|
1022
|
+
<Figure title="By region" note={`Days; dashed line: ${SLA_DAYS}-day SLA`}>
|
|
1023
|
+
<div className="h-44">
|
|
1024
|
+
<DistributionChart
|
|
1025
|
+
accessibleLabel="Order-to-payment time in days by region"
|
|
1026
|
+
data={stats.throughput}
|
|
1027
|
+
groupKey="region"
|
|
1028
|
+
kind="box"
|
|
1029
|
+
referenceLines={[{ value: SLA_DAYS, label: "SLA" }]}
|
|
1030
|
+
valueKey="days"
|
|
1031
|
+
/>
|
|
1032
|
+
</div>
|
|
1033
|
+
</Figure>
|
|
1034
|
+
<Figure title="By channel" note="Days; EDI customers pay on shorter terms">
|
|
1035
|
+
<div className="h-44">
|
|
1036
|
+
<DistributionChart
|
|
1037
|
+
accessibleLabel="Order-to-payment time in days by sales channel"
|
|
1038
|
+
data={stats.throughput}
|
|
1039
|
+
groupKey="channel"
|
|
1040
|
+
kind="violin"
|
|
1041
|
+
referenceLines={[{ value: SLA_DAYS, label: "SLA" }]}
|
|
1042
|
+
valueKey="days"
|
|
1043
|
+
/>
|
|
1044
|
+
</div>
|
|
1045
|
+
</Figure>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
</TabsContent>
|
|
1049
|
+
|
|
1050
|
+
<TabsContent value="variants" className="min-h-0 flex-1 p-3">
|
|
1051
|
+
<VariantExplorer
|
|
1052
|
+
className="h-full"
|
|
1053
|
+
variants={explorer.variants}
|
|
1054
|
+
colorScale={colorScale}
|
|
1055
|
+
selectionStates={explorer.selectionStates}
|
|
1056
|
+
onSelect={applyVariantSelection}
|
|
1057
|
+
columns={["cases", "coverage"]}
|
|
1058
|
+
sequenceDisplay="swatch"
|
|
1059
|
+
loading={explorer.loading}
|
|
1060
|
+
/>
|
|
1061
|
+
</TabsContent>
|
|
1062
|
+
<TabsContent value="selection" className="min-h-0 flex-1 overflow-auto p-4">
|
|
1063
|
+
<SelectionDetail
|
|
1064
|
+
graph={explorer.graph}
|
|
1065
|
+
selection={explorer.selection}
|
|
1066
|
+
traces={traces}
|
|
1067
|
+
/>
|
|
1068
|
+
</TabsContent>
|
|
1069
|
+
<TabsContent value="deviations" className="min-h-0 flex-1 overflow-auto p-3">
|
|
1070
|
+
<ViolationList conformance={conformance} onFilterIntent={explorer.applyIntent} />
|
|
1071
|
+
</TabsContent>
|
|
1072
|
+
</Tabs>
|
|
1073
|
+
</aside>
|
|
1074
|
+
) : null}
|
|
1075
|
+
</div>
|
|
1076
|
+
|
|
1077
|
+
{/* The wide, time-based views live in a dock that stays out of the way until asked for:
|
|
1078
|
+
its tab strip is always there, its body only when open. */}
|
|
1079
|
+
<section
|
|
1080
|
+
aria-label="Timelines, workload and cases"
|
|
1081
|
+
className={`flex shrink-0 flex-col border-t border-border ${dockOpen ? (dockTall ? "h-3/5" : "h-88") : ""}`}
|
|
1082
|
+
>
|
|
1083
|
+
<Tabs
|
|
1084
|
+
value={dockOpen ? dockTab : ""}
|
|
1085
|
+
onValueChange={(next) => {
|
|
1086
|
+
setDockTab(next as DockTab);
|
|
1087
|
+
setDockOpen(true);
|
|
1088
|
+
}}
|
|
1089
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
1090
|
+
>
|
|
1091
|
+
<div className="flex shrink-0 items-center gap-2 pe-2">
|
|
1092
|
+
<TabsList variant="underline" className="min-w-0 flex-1 border-b-0 px-3">
|
|
1093
|
+
<TabsTrigger value="timeline">Dotted chart</TabsTrigger>
|
|
1094
|
+
<TabsTrigger value="spectrum">Performance spectrum</TabsTrigger>
|
|
1095
|
+
<TabsTrigger value="workload">Workload</TabsTrigger>
|
|
1096
|
+
<TabsTrigger value="cases">{`Cases ${count.format(cases.length)}`}</TabsTrigger>
|
|
1097
|
+
</TabsList>
|
|
1098
|
+
{dockOpen && dockTab === "timeline" ? (
|
|
1099
|
+
<ToggleGroup
|
|
1100
|
+
type="single"
|
|
1101
|
+
size="sm"
|
|
1102
|
+
value={timelineAxis}
|
|
1103
|
+
onValueChange={(next) => next && setTimelineAxis(next as "absolute" | "relative")}
|
|
1104
|
+
aria-label="Time axis"
|
|
1105
|
+
>
|
|
1106
|
+
<ToggleGroupItem value="relative">Since order</ToggleGroupItem>
|
|
1107
|
+
<ToggleGroupItem value="absolute">Calendar</ToggleGroupItem>
|
|
1108
|
+
</ToggleGroup>
|
|
1109
|
+
) : null}
|
|
1110
|
+
{dockOpen ? (
|
|
1111
|
+
<ToolbarToggle label="Taller" pressed={dockTall} onPressedChange={setDockTall}>
|
|
1112
|
+
<ChevronsUp aria-hidden="true" />
|
|
1113
|
+
</ToolbarToggle>
|
|
1114
|
+
) : null}
|
|
1115
|
+
</div>
|
|
1116
|
+
|
|
1117
|
+
{dockOpen ? (
|
|
1118
|
+
<>
|
|
1119
|
+
<TabsContent
|
|
1120
|
+
value="timeline"
|
|
1121
|
+
className="min-h-0 flex-1 overflow-auto border-t border-border p-3"
|
|
1122
|
+
>
|
|
1123
|
+
<DottedChart
|
|
1124
|
+
log={explorer.filteredLog}
|
|
1125
|
+
x={timelineAxis}
|
|
1126
|
+
sort={timelineAxis === "relative" ? "duration" : "start"}
|
|
1127
|
+
colorScale={colorScale}
|
|
1128
|
+
height={dockTall ? 400 : 204}
|
|
1129
|
+
onFilterIntent={explorer.applyIntent}
|
|
1130
|
+
loading={explorer.loading}
|
|
1131
|
+
/>
|
|
1132
|
+
</TabsContent>
|
|
1133
|
+
<TabsContent
|
|
1134
|
+
value="spectrum"
|
|
1135
|
+
className="min-h-0 flex-1 overflow-auto border-t border-border p-3"
|
|
1136
|
+
>
|
|
1137
|
+
<PerformanceSpectrum
|
|
1138
|
+
log={explorer.filteredLog}
|
|
1139
|
+
order="frequency"
|
|
1140
|
+
segmentLimit={dockTall ? 9 : 6}
|
|
1141
|
+
selection={explorer.selection}
|
|
1142
|
+
onFilterIntent={explorer.applyIntent}
|
|
1143
|
+
loading={explorer.loading}
|
|
1144
|
+
/>
|
|
1145
|
+
</TabsContent>
|
|
1146
|
+
<TabsContent
|
|
1147
|
+
value="workload"
|
|
1148
|
+
className="min-h-0 flex-1 overflow-auto border-t border-border p-4"
|
|
1149
|
+
>
|
|
1150
|
+
<div className="grid grid-cols-1 gap-6 @4xl:grid-cols-3">
|
|
1151
|
+
<Figure
|
|
1152
|
+
className="@4xl:col-span-2"
|
|
1153
|
+
title="When the work happens"
|
|
1154
|
+
note="Events per weekday and hour (UTC) across the quarter; the stronger the cell, the busier the hour"
|
|
1155
|
+
>
|
|
1156
|
+
<HeatmapChart
|
|
1157
|
+
data={stats.workload}
|
|
1158
|
+
valueFormat="compact"
|
|
1159
|
+
plotHeight={dockTall ? 340 : 196}
|
|
1160
|
+
valueKey="events"
|
|
1161
|
+
x="hour"
|
|
1162
|
+
xOrder={OFFICE_HOURS}
|
|
1163
|
+
y="day"
|
|
1164
|
+
yOrder={WEEKDAYS}
|
|
1165
|
+
/>
|
|
1166
|
+
</Figure>
|
|
1167
|
+
<Figure
|
|
1168
|
+
title="Who does the work"
|
|
1169
|
+
note="Events per resource, people and systems alike"
|
|
1170
|
+
>
|
|
1171
|
+
<BarChart
|
|
1172
|
+
accessibleLabel="Events per resource"
|
|
1173
|
+
data={stats.resourceLoad}
|
|
1174
|
+
orientation="horizontal"
|
|
1175
|
+
plotHeight={dockTall ? 340 : 228}
|
|
1176
|
+
xDataKey="name"
|
|
1177
|
+
>
|
|
1178
|
+
<Grid vertical />
|
|
1179
|
+
<Bar dataKey="events" fill="var(--chart-2)" lineCap="round" />
|
|
1180
|
+
<BarYAxis maxWidth={140} />
|
|
1181
|
+
<ChartTooltip />
|
|
1182
|
+
</BarChart>
|
|
1183
|
+
</Figure>
|
|
1184
|
+
</div>
|
|
1185
|
+
</TabsContent>
|
|
1186
|
+
<TabsContent
|
|
1187
|
+
value="cases"
|
|
1188
|
+
className="min-h-0 flex-1 overflow-auto border-t border-border p-3"
|
|
1189
|
+
>
|
|
1190
|
+
<CaseTable
|
|
1191
|
+
cases={cases}
|
|
1192
|
+
columns={caseColumns}
|
|
1193
|
+
onCaseOpen={setOpenCaseId}
|
|
1194
|
+
exportFileName="order-to-cash-cases"
|
|
1195
|
+
/>
|
|
1196
|
+
</TabsContent>
|
|
1197
|
+
</>
|
|
1198
|
+
) : null}
|
|
1199
|
+
</Tabs>
|
|
1200
|
+
</section>
|
|
1201
|
+
|
|
1202
|
+
{/* Drill path: variant or brush → case table → one case's timeline. */}
|
|
1203
|
+
<Sheet open={openCaseId !== null} onOpenChange={(open) => !open && setOpenCaseId(null)}>
|
|
1204
|
+
<SheetContent side="right" className="flex w-full flex-col gap-4 sm:max-w-3xl">
|
|
1205
|
+
<SheetHeader>
|
|
1206
|
+
<Button
|
|
1207
|
+
variant="ghost"
|
|
1208
|
+
size="sm"
|
|
1209
|
+
className="w-fit"
|
|
1210
|
+
onClick={() => {
|
|
1211
|
+
setOpenCaseId(null);
|
|
1212
|
+
setDockOpen(true);
|
|
1213
|
+
setDockTab("cases");
|
|
1214
|
+
}}
|
|
1215
|
+
>
|
|
1216
|
+
<ArrowLeft aria-hidden="true" />
|
|
1217
|
+
Back to cases
|
|
1218
|
+
</Button>
|
|
1219
|
+
<SheetTitle>{`Order ${openCaseId ?? ""}`}</SheetTitle>
|
|
1220
|
+
<SheetDescription>
|
|
1221
|
+
Activity durations and waiting time for this order.
|
|
1222
|
+
</SheetDescription>
|
|
1223
|
+
</SheetHeader>
|
|
1224
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
1225
|
+
{openCaseId ? <CaseTimeline caseId={openCaseId} events={openCaseEvents} /> : null}
|
|
1226
|
+
</div>
|
|
1227
|
+
</SheetContent>
|
|
1228
|
+
</Sheet>
|
|
1229
|
+
|
|
1230
|
+
<Dialog open={replayOpen} onOpenChange={setReplayOpen}>
|
|
1231
|
+
<DialogContent className="flex h-4/5 max-w-6xl flex-col">
|
|
1232
|
+
<DialogHeader>
|
|
1233
|
+
<DialogTitle>Replay the quarter</DialogTitle>
|
|
1234
|
+
<DialogDescription>
|
|
1235
|
+
Every order in scope as a token on the map, all started together so the queues are
|
|
1236
|
+
comparable. The list beside it ranks where tokens pile up.
|
|
1237
|
+
</DialogDescription>
|
|
1238
|
+
</DialogHeader>
|
|
1239
|
+
<div className="min-h-0 flex-1">
|
|
1240
|
+
{replayOpen ? (
|
|
1241
|
+
<ProcessReplay
|
|
1242
|
+
graph={explorer.graph}
|
|
1243
|
+
log={explorer.filteredLog}
|
|
1244
|
+
direction="LR"
|
|
1245
|
+
synchronizedStart
|
|
1246
|
+
defaultSpeed={4}
|
|
1247
|
+
/>
|
|
1248
|
+
) : null}
|
|
1249
|
+
</div>
|
|
1250
|
+
</DialogContent>
|
|
1251
|
+
</Dialog>
|
|
1252
|
+
</div>
|
|
1253
|
+
</ChartConfigProvider>
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const meta = {
|
|
1258
|
+
title: "Patterns/Templates/Operations/Process Explorer",
|
|
1259
|
+
parameters: {
|
|
1260
|
+
layout: "fullscreen",
|
|
1261
|
+
docs: { subtitle: "For process-mining and operations-excellence tools" },
|
|
1262
|
+
},
|
|
1263
|
+
tags: ["autodocs"],
|
|
1264
|
+
} satisfies Meta;
|
|
1265
|
+
export default meta;
|
|
1266
|
+
type Story = StoryObj<typeof meta>;
|
|
1267
|
+
|
|
1268
|
+
export const Default: Story = {
|
|
1269
|
+
render: () => (
|
|
1270
|
+
<div className="h-svh">
|
|
1271
|
+
<ProcessExplorerTemplate />
|
|
1272
|
+
</div>
|
|
1273
|
+
),
|
|
1274
|
+
play: async ({ canvasElement }) => {
|
|
1275
|
+
const canvas = within(canvasElement);
|
|
1276
|
+
await waitFor(() => expect(canvas.getByText("Order to cash")).toBeInTheDocument());
|
|
1277
|
+
|
|
1278
|
+
const filterSummary = () =>
|
|
1279
|
+
canvasElement.querySelector<HTMLElement>('[data-slot="process-filter-bar-summary"]');
|
|
1280
|
+
const kpiStrip = () =>
|
|
1281
|
+
canvasElement.querySelector<HTMLElement>('[data-slot="process-kpi-strip"]');
|
|
1282
|
+
|
|
1283
|
+
// The ribbon, not the card grid: the screen's height belongs to the map.
|
|
1284
|
+
await waitFor(() => expect(kpiStrip()).toHaveAttribute("data-layout", "inline"));
|
|
1285
|
+
await waitFor(() => expect(filterSummary()?.textContent ?? "").toMatch(/showing all/i));
|
|
1286
|
+
const initialFilterText = filterSummary()!.textContent;
|
|
1287
|
+
const initialKpiText = kpiStrip()!.textContent;
|
|
1288
|
+
|
|
1289
|
+
// Select the first (most frequent) variant from the panel — the filter chain and the KPI
|
|
1290
|
+
// ribbon both read the same `useProcessExplorer` state, so both must move.
|
|
1291
|
+
await userEvent.click(canvas.getByRole("tab", { name: /^Variants/ }));
|
|
1292
|
+
await waitFor(() =>
|
|
1293
|
+
expect(
|
|
1294
|
+
canvasElement.querySelectorAll('[data-slot="variant-explorer-row"]').length,
|
|
1295
|
+
).toBeGreaterThan(0),
|
|
1296
|
+
);
|
|
1297
|
+
const rows = canvasElement.querySelectorAll<HTMLElement>('[data-slot="variant-explorer-row"]');
|
|
1298
|
+
await userEvent.click(within(rows[0]!).getByRole("img"));
|
|
1299
|
+
|
|
1300
|
+
await waitFor(() => expect(filterSummary()!.textContent).not.toBe(initialFilterText));
|
|
1301
|
+
expect(filterSummary()!.textContent ?? "").not.toMatch(/showing all/i);
|
|
1302
|
+
await waitFor(() => expect(kpiStrip()!.textContent).not.toBe(initialKpiText));
|
|
1303
|
+
},
|
|
1304
|
+
};
|