@adcops/autocore-react 3.3.123 → 3.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/UnitSystemSelector.d.ts +17 -0
- package/dist/components/UnitSystemSelector.d.ts.map +1 -0
- package/dist/components/UnitSystemSelector.js +1 -0
- package/dist/components/UnitsEditor.d.ts +11 -0
- package/dist/components/UnitsEditor.d.ts.map +1 -0
- package/dist/components/UnitsEditor.js +1 -0
- package/dist/components/index.d.ts +5 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/ScienceTable.d.ts +14 -0
- package/dist/components/tis/ScienceTable.d.ts.map +1 -1
- package/dist/components/tis/ScienceTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +33 -4
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestFieldRow.d.ts +69 -7
- package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
- package/dist/components/tis/TestFieldRow.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis-editor/editor/ChartViewDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/ChartViewDialog.js +1 -1
- package/dist/components/tis-editor/types.d.ts +6 -0
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.js +1 -1
- package/dist/core/AutoCoreTagTypes.d.ts +82 -7
- package/dist/core/AutoCoreTagTypes.d.ts.map +1 -1
- package/dist/core/formatScaled.d.ts +16 -0
- package/dist/core/formatScaled.d.ts.map +1 -0
- package/dist/core/formatScaled.js +1 -0
- package/dist/hooks/useAutoCoreTag.d.ts +11 -3
- package/dist/hooks/useAutoCoreTag.d.ts.map +1 -1
- package/dist/hooks/useAutoCoreTag.js +1 -1
- package/package.json +1 -1
- package/src/components/UnitSystemSelector.tsx +88 -0
- package/src/components/UnitsEditor.tsx +317 -0
- package/src/components/index.ts +5 -1
- package/src/components/tis/ResultHistoryTable.tsx +96 -6
- package/src/components/tis/ScienceTable.tsx +33 -1
- package/src/components/tis/TestDataView.tsx +203 -33
- package/src/components/tis/TestFieldRow.tsx +126 -19
- package/src/components/tis/TestSetupForm.tsx +35 -7
- package/src/components/tis/TisProvider.tsx +46 -3
- package/src/components/tis-editor/editor/ChartViewDialog.tsx +27 -0
- package/src/components/tis-editor/types.ts +6 -0
- package/src/core/AutoCoreTagContext.tsx +165 -16
- package/src/core/AutoCoreTagTypes.ts +91 -7
- package/src/core/formatScaled.ts +72 -0
- package/src/hooks/useAutoCoreTag.ts +51 -3
- package/todo.md +6 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* UnitsEditor — operator-facing editor for the machine's `units.json`.
|
|
5
|
+
*
|
|
6
|
+
* This is the UI half of the units design (autocore-server/UNITS_PLAN.md). The
|
|
7
|
+
* file it edits is a SIDECAR next to project.json precisely so that this
|
|
8
|
+
* operator work is not wiped by an engineering project.json push.
|
|
9
|
+
*
|
|
10
|
+
* What the operator may change (all display-only, none of it reaches the control
|
|
11
|
+
* program): the display `label`, the `scalar` and `offset` that convert the
|
|
12
|
+
* backend value, and the display `precision` / `precision_mode`. They may also
|
|
13
|
+
* add and remove named systems.
|
|
14
|
+
*
|
|
15
|
+
* What they may NOT change here: `backend` — the unit GM/GNV actually holds. It
|
|
16
|
+
* is a commissioning declaration; changing it on a machine with recorded data is
|
|
17
|
+
* a data migration, not a settings tweak. It is shown, read-only, because you
|
|
18
|
+
* cannot sanely set a scalar without knowing what you are converting *from*.
|
|
19
|
+
*
|
|
20
|
+
* Precision is a first-class column, not an advanced setting: wrong precision is
|
|
21
|
+
* one of the most common customer complaints, and the whole point of putting this
|
|
22
|
+
* screen on the machine is that nobody has to wait for a release to fix it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
26
|
+
import { DataTable } from "primereact/datatable";
|
|
27
|
+
import { Column } from "primereact/column";
|
|
28
|
+
import { InputText } from "primereact/inputtext";
|
|
29
|
+
import { InputNumber } from "primereact/inputnumber";
|
|
30
|
+
import { Dropdown } from "primereact/dropdown";
|
|
31
|
+
import { Button } from "primereact/button";
|
|
32
|
+
import { SelectButton } from "primereact/selectbutton";
|
|
33
|
+
import { Message } from "primereact/message";
|
|
34
|
+
import { EventEmitterContext } from "../core/EventEmitterContext";
|
|
35
|
+
import { MessageType } from "../hub/CommandMessage";
|
|
36
|
+
import type { PrecisionMode, UnitsTable } from "../core/AutoCoreTagTypes";
|
|
37
|
+
|
|
38
|
+
const PRECISION_MODES: { label: string; value: PrecisionMode }[] = [
|
|
39
|
+
{ label: "Decimals", value: "decimals" },
|
|
40
|
+
{ label: "Significant", value: "significant" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** One editable row: a quantity, seen through the system being edited. */
|
|
44
|
+
interface Row {
|
|
45
|
+
quantity: string;
|
|
46
|
+
backend: string;
|
|
47
|
+
kind: "delta" | "absolute";
|
|
48
|
+
label: string;
|
|
49
|
+
scalar: number;
|
|
50
|
+
offset: number;
|
|
51
|
+
precision: number;
|
|
52
|
+
precisionMode: PrecisionMode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UnitsEditorProps {
|
|
56
|
+
/** Heading text. Omit for none. */
|
|
57
|
+
title?: string;
|
|
58
|
+
/** Called after a successful save. */
|
|
59
|
+
onSaved?: (units: UnitsTable) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const UnitsEditor: React.FC<UnitsEditorProps> = ({ title = "Units", onSaved }) => {
|
|
63
|
+
const { invoke } = useContext(EventEmitterContext);
|
|
64
|
+
|
|
65
|
+
const [units, setUnits] = useState<UnitsTable | null>(null);
|
|
66
|
+
const [editSystem, setEditSystem] = useState<string | null>(null);
|
|
67
|
+
const [dirty, setDirty] = useState(false);
|
|
68
|
+
const [busy, setBusy] = useState(false);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
71
|
+
|
|
72
|
+
const load = useCallback(async () => {
|
|
73
|
+
setBusy(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
try {
|
|
76
|
+
const resp: any = await invoke("system.get_units" as any, MessageType.Request, {} as any);
|
|
77
|
+
const payload = resp?.data ?? resp;
|
|
78
|
+
const table = payload?.units as UnitsTable | null;
|
|
79
|
+
if (!table) {
|
|
80
|
+
setError(
|
|
81
|
+
"This machine has no units.json. Display values are shown in backend " +
|
|
82
|
+
"units until one is installed.",
|
|
83
|
+
);
|
|
84
|
+
setUnits(null);
|
|
85
|
+
} else {
|
|
86
|
+
setUnits(table);
|
|
87
|
+
setEditSystem((prev) =>
|
|
88
|
+
prev && table.systems.includes(prev) ? prev : table.default_system,
|
|
89
|
+
);
|
|
90
|
+
setDirty(false);
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
setError(`Could not read units: ${e instanceof Error ? e.message : String(e)}`);
|
|
94
|
+
}
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}, [invoke]);
|
|
97
|
+
|
|
98
|
+
useEffect(() => { void load(); }, [load]);
|
|
99
|
+
|
|
100
|
+
/** Flatten the table into rows for the system being edited. */
|
|
101
|
+
const rows = useMemo<Row[]>(() => {
|
|
102
|
+
if (!units || !editSystem) return [];
|
|
103
|
+
return Object.entries(units.scales)
|
|
104
|
+
.map(([quantity, scale]: [string, any]) => {
|
|
105
|
+
const entry = scale?.[editSystem] ?? {};
|
|
106
|
+
return {
|
|
107
|
+
quantity,
|
|
108
|
+
backend: scale?.backend ?? "",
|
|
109
|
+
kind: (scale?.kind ?? "delta") as "delta" | "absolute",
|
|
110
|
+
label: entry.label ?? "",
|
|
111
|
+
scalar: typeof entry.scalar === "number" ? entry.scalar : 1,
|
|
112
|
+
offset: typeof entry.offset === "number" ? entry.offset : 0,
|
|
113
|
+
precision: typeof entry.precision === "number" ? entry.precision : 3,
|
|
114
|
+
precisionMode: (entry.precision_mode ?? "decimals") as PrecisionMode,
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.sort((a, b) => a.quantity.localeCompare(b.quantity));
|
|
118
|
+
}, [units, editSystem]);
|
|
119
|
+
|
|
120
|
+
/** Write one field of one (quantity, system) entry. */
|
|
121
|
+
const patch = (quantity: string, field: keyof Row, value: any) => {
|
|
122
|
+
if (!units || !editSystem) return;
|
|
123
|
+
const next: UnitsTable = JSON.parse(JSON.stringify(units));
|
|
124
|
+
const scale: any = next.scales[quantity];
|
|
125
|
+
if (!scale) return;
|
|
126
|
+
const entry = { ...(scale[editSystem] ?? {}) };
|
|
127
|
+
switch (field) {
|
|
128
|
+
case "label": entry.label = value; break;
|
|
129
|
+
case "scalar": entry.scalar = value; break;
|
|
130
|
+
case "offset": entry.offset = value; break;
|
|
131
|
+
case "precision": entry.precision = value; break;
|
|
132
|
+
case "precisionMode": entry.precision_mode = value; break;
|
|
133
|
+
default: return;
|
|
134
|
+
}
|
|
135
|
+
scale[editSystem] = entry;
|
|
136
|
+
setUnits(next);
|
|
137
|
+
setDirty(true);
|
|
138
|
+
setNotice(null);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const save = async () => {
|
|
142
|
+
if (!units) return;
|
|
143
|
+
setBusy(true);
|
|
144
|
+
setError(null);
|
|
145
|
+
setNotice(null);
|
|
146
|
+
try {
|
|
147
|
+
// The server validates authoritatively and REFUSES a bad table —
|
|
148
|
+
// an invalid scale would break every display on the machine, so a
|
|
149
|
+
// rejected save must leave the stored file untouched.
|
|
150
|
+
const resp: any = await invoke(
|
|
151
|
+
"system.save_units" as any, MessageType.Request, { units } as any,
|
|
152
|
+
);
|
|
153
|
+
if (!resp?.success) {
|
|
154
|
+
setError(resp?.error_message ?? "Save was refused.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
setDirty(false);
|
|
158
|
+
setNotice("Saved.");
|
|
159
|
+
onSaved?.(units);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
setError(`Save failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
162
|
+
} finally {
|
|
163
|
+
setBusy(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (!units) {
|
|
168
|
+
return (
|
|
169
|
+
<div>
|
|
170
|
+
{title ? <h3 style={{ marginTop: 0 }}>{title}</h3> : null}
|
|
171
|
+
{error ? <Message severity="warn" text={error} /> : null}
|
|
172
|
+
<div style={{ marginTop: "0.75rem" }}>
|
|
173
|
+
<Button label="Reload" icon="pi pi-refresh" onClick={() => void load()} loading={busy} />
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div>
|
|
181
|
+
{title ? <h3 style={{ marginTop: 0 }}>{title}</h3> : null}
|
|
182
|
+
|
|
183
|
+
<div style={{
|
|
184
|
+
display: "flex", alignItems: "center", gap: "1rem",
|
|
185
|
+
flexWrap: "wrap", marginBottom: "0.75rem",
|
|
186
|
+
}}>
|
|
187
|
+
<div>
|
|
188
|
+
<div style={{ fontWeight: 600, marginBottom: "0.3rem" }}>Editing system</div>
|
|
189
|
+
<SelectButton
|
|
190
|
+
value={editSystem}
|
|
191
|
+
options={units.systems.map((s) => ({ label: s, value: s }))}
|
|
192
|
+
onChange={(e) => { if (e.value) setEditSystem(e.value); }}
|
|
193
|
+
allowEmpty={false}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
|
|
197
|
+
<Button
|
|
198
|
+
label="Revert"
|
|
199
|
+
icon="pi pi-undo"
|
|
200
|
+
className="p-button-text"
|
|
201
|
+
disabled={!dirty || busy}
|
|
202
|
+
onClick={() => void load()}
|
|
203
|
+
/>
|
|
204
|
+
<Button
|
|
205
|
+
label="Save"
|
|
206
|
+
icon="pi pi-check"
|
|
207
|
+
disabled={!dirty || busy}
|
|
208
|
+
loading={busy}
|
|
209
|
+
onClick={() => void save()}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{error ? <Message severity="error" text={error} style={{ marginBottom: "0.5rem" }} /> : null}
|
|
215
|
+
{notice ? <Message severity="success" text={notice} style={{ marginBottom: "0.5rem" }} /> : null}
|
|
216
|
+
|
|
217
|
+
<Message
|
|
218
|
+
severity="info"
|
|
219
|
+
style={{ marginBottom: "0.75rem" }}
|
|
220
|
+
text={
|
|
221
|
+
"Display only — these settings never change what the machine stores or " +
|
|
222
|
+
"how it moves. 'Backend' is the unit the controller holds and is set at " +
|
|
223
|
+
"commissioning."
|
|
224
|
+
}
|
|
225
|
+
/>
|
|
226
|
+
|
|
227
|
+
<DataTable value={rows} size="small" stripedRows scrollable scrollHeight="26rem">
|
|
228
|
+
<Column field="quantity" header="Quantity" style={{ minWidth: "11rem" }} />
|
|
229
|
+
<Column
|
|
230
|
+
field="backend"
|
|
231
|
+
header="Backend"
|
|
232
|
+
style={{ width: "7rem" }}
|
|
233
|
+
body={(r: Row) => (
|
|
234
|
+
<span style={{ opacity: 0.7 }}>
|
|
235
|
+
{r.backend || "—"}
|
|
236
|
+
{r.kind === "absolute" ? " (abs)" : ""}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
/>
|
|
240
|
+
<Column
|
|
241
|
+
header="Label"
|
|
242
|
+
style={{ width: "8rem" }}
|
|
243
|
+
body={(r: Row) => (
|
|
244
|
+
<InputText
|
|
245
|
+
value={r.label}
|
|
246
|
+
style={{ width: "100%" }}
|
|
247
|
+
onChange={(e) => patch(r.quantity, "label", e.target.value)}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
/>
|
|
251
|
+
<Column
|
|
252
|
+
header="Scalar"
|
|
253
|
+
style={{ width: "11rem" }}
|
|
254
|
+
body={(r: Row) => (
|
|
255
|
+
<InputNumber
|
|
256
|
+
value={r.scalar}
|
|
257
|
+
minFractionDigits={0}
|
|
258
|
+
maxFractionDigits={10}
|
|
259
|
+
style={{ width: "100%" }}
|
|
260
|
+
inputStyle={{ width: "100%" }}
|
|
261
|
+
onValueChange={(e) => patch(r.quantity, "scalar", e.value ?? 1)}
|
|
262
|
+
/>
|
|
263
|
+
)}
|
|
264
|
+
/>
|
|
265
|
+
<Column
|
|
266
|
+
header="Offset"
|
|
267
|
+
style={{ width: "8rem" }}
|
|
268
|
+
body={(r: Row) =>
|
|
269
|
+
// Only an absolute quantity takes an offset. A temperature
|
|
270
|
+
// DIFFERENCE must not: a 10 °C rise is 18 °F, not 50 °F.
|
|
271
|
+
r.kind === "absolute" ? (
|
|
272
|
+
<InputNumber
|
|
273
|
+
value={r.offset}
|
|
274
|
+
minFractionDigits={0}
|
|
275
|
+
maxFractionDigits={6}
|
|
276
|
+
style={{ width: "100%" }}
|
|
277
|
+
inputStyle={{ width: "100%" }}
|
|
278
|
+
onValueChange={(e) => patch(r.quantity, "offset", e.value ?? 0)}
|
|
279
|
+
/>
|
|
280
|
+
) : (
|
|
281
|
+
<span style={{ opacity: 0.4 }}>n/a</span>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
/>
|
|
285
|
+
<Column
|
|
286
|
+
header="Precision"
|
|
287
|
+
style={{ width: "7rem" }}
|
|
288
|
+
body={(r: Row) => (
|
|
289
|
+
<InputNumber
|
|
290
|
+
value={r.precision}
|
|
291
|
+
min={0}
|
|
292
|
+
max={12}
|
|
293
|
+
showButtons
|
|
294
|
+
style={{ width: "100%" }}
|
|
295
|
+
inputStyle={{ width: "100%" }}
|
|
296
|
+
onValueChange={(e) => patch(r.quantity, "precision", e.value ?? 0)}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
/>
|
|
300
|
+
<Column
|
|
301
|
+
header="Mode"
|
|
302
|
+
style={{ width: "9rem" }}
|
|
303
|
+
body={(r: Row) => (
|
|
304
|
+
<Dropdown
|
|
305
|
+
value={r.precisionMode}
|
|
306
|
+
options={PRECISION_MODES}
|
|
307
|
+
style={{ width: "100%" }}
|
|
308
|
+
onChange={(e) => patch(r.quantity, "precisionMode", e.value)}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
/>
|
|
312
|
+
</DataTable>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export default UnitsEditor;
|
package/src/components/index.ts
CHANGED
|
@@ -62,7 +62,11 @@ export type { ResultHistoryTableProps } from './tis/ResultHistoryTable';
|
|
|
62
62
|
export { SampleSummaryPanel } from './tis/SampleSummaryPanel';
|
|
63
63
|
export type { SampleSummaryPanelProps } from './tis/SampleSummaryPanel';
|
|
64
64
|
|
|
65
|
-
export { TestDataView }
|
|
65
|
+
export { TestDataView, DEFAULT_X_DECIMALS } from './tis/TestDataView';
|
|
66
|
+
export { UnitSystemSelector } from './UnitSystemSelector';
|
|
67
|
+
export type { UnitSystemSelectorProps } from './UnitSystemSelector';
|
|
68
|
+
export { UnitsEditor } from './UnitsEditor';
|
|
69
|
+
export type { UnitsEditorProps } from './UnitsEditor';
|
|
66
70
|
export type { TestDataViewProps, ChartAxis, ChartSeries, ChartView, RawDataShape } from './tis/TestDataView';
|
|
67
71
|
|
|
68
72
|
export { TestRawDataView } from './tis/TestRawDataView';
|
|
@@ -77,10 +77,20 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
77
77
|
const [downloading, setDownloading] = useState<InFlight | null>(null);
|
|
78
78
|
const [projectBusy, setProjectBusy] = useState<ProjectDownloadKind | null>(null);
|
|
79
79
|
const [excludingRunId, setExcludingRunId] = useState<string | null>(null);
|
|
80
|
-
// Per-row lifecycle menu (complete / abandon / reopen). One Menu
|
|
81
|
-
//
|
|
80
|
+
// Per-row lifecycle menu (close / complete / abandon / reopen). One Menu
|
|
81
|
+
// instance is reused.
|
|
82
|
+
//
|
|
83
|
+
// The row is carried in an OPEN REQUEST rather than plain state, and the
|
|
84
|
+
// popup is shown from an effect once React has committed the new model.
|
|
85
|
+
// Opening it inline in onClick (the original shape) raced the state update:
|
|
86
|
+
// the popup rendered with the *previous* row's model — or an empty one on the
|
|
87
|
+
// first click — so an action either did nothing or silently hit the wrong
|
|
88
|
+
// run. Each click makes a fresh request object so re-opening the same row
|
|
89
|
+
// still fires the effect.
|
|
82
90
|
const rowMenuRef = useRef<Menu>(null);
|
|
83
|
-
const [
|
|
91
|
+
const [menuRequest, setMenuRequest] =
|
|
92
|
+
useState<{ row: any; anchor: HTMLElement; seq: number } | null>(null);
|
|
93
|
+
const menuSeqRef = useRef(0);
|
|
84
94
|
const [lifecycleBusy, setLifecycleBusy] = useState<string | null>(null);
|
|
85
95
|
const { invoke } = useContext(EventEmitterContext);
|
|
86
96
|
|
|
@@ -118,6 +128,15 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
118
128
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
129
|
}, [projectId, methodId, tis.state.activeRunId, tis.state.active]);
|
|
120
130
|
|
|
131
|
+
// Show the popup only after React has committed the model for `menuRequest`
|
|
132
|
+
// (see the note on `menuRequest`). `anchor` is the captured DOM node — the
|
|
133
|
+
// original event's currentTarget is null by now.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!menuRequest) return;
|
|
136
|
+
rowMenuRef.current?.show({ currentTarget: menuRequest.anchor } as any);
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
|
+
}, [menuRequest]);
|
|
139
|
+
|
|
121
140
|
const formatDate = (dateStr: string) => {
|
|
122
141
|
if (!dateStr) return '';
|
|
123
142
|
return new Date(dateStr).toLocaleString();
|
|
@@ -233,6 +252,10 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
233
252
|
/**
|
|
234
253
|
* Menu items for a row, gated on its lifecycle state:
|
|
235
254
|
* - open (in_progress / paused) → Complete, Abandon
|
|
255
|
+
* - interrupted (auto-closed) → Close (acknowledge it as done), Reopen,
|
|
256
|
+
* Abandon. Abandon is offered here but NOT for a run a person completed:
|
|
257
|
+
* nobody vouched for an interrupted run, so clearing a junk one out of
|
|
258
|
+
* the history is a judgement the operator is entitled to make.
|
|
236
259
|
* - completed but not finalized → Reopen (add more cycles under the same
|
|
237
260
|
* Test ID — the multi-specimen case where a set turned out to be bigger)
|
|
238
261
|
* - finalized (abandoned) → nothing; `finalized` exists precisely to
|
|
@@ -242,6 +265,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
242
265
|
const status = rowData?.status;
|
|
243
266
|
const finalized = rowData?.finalized === true;
|
|
244
267
|
const isOpen = status === 'in_progress' || status === 'paused';
|
|
268
|
+
// Closed by the TIS startup sweep, not by a person. It is already out of
|
|
269
|
+
// "In Progress", but nobody has confirmed the run is done with — so it
|
|
270
|
+
// still reads as Interrupted until someone closes it.
|
|
271
|
+
const isInterrupted =
|
|
272
|
+
status === 'completed' && rowData?.auto_closed === true && !finalized;
|
|
245
273
|
const items: any[] = [];
|
|
246
274
|
|
|
247
275
|
if (isOpen) {
|
|
@@ -264,6 +292,50 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
264
292
|
accept: () => handleLifecycle(rowData, 'abandon'),
|
|
265
293
|
}),
|
|
266
294
|
});
|
|
295
|
+
} else if (isInterrupted) {
|
|
296
|
+
items.push({
|
|
297
|
+
label: 'Close Test',
|
|
298
|
+
icon: 'pi pi-check-circle',
|
|
299
|
+
command: () => confirmDialog({
|
|
300
|
+
header: 'Close Interrupted Test',
|
|
301
|
+
message:
|
|
302
|
+
`Close ${rowData?.sample_id || rowData?.run_id}? It was closed automatically ` +
|
|
303
|
+
`when the software restarted mid-test. Closing it marks it Completed and ` +
|
|
304
|
+
`keeps the recorded end time. Recorded data is not changed, and the test ` +
|
|
305
|
+
`can still be reopened later.`,
|
|
306
|
+
icon: 'pi pi-check-circle',
|
|
307
|
+
acceptLabel: 'Close Test',
|
|
308
|
+
accept: () => handleLifecycle(rowData, 'complete'),
|
|
309
|
+
}),
|
|
310
|
+
});
|
|
311
|
+
items.push({
|
|
312
|
+
label: 'Reopen Test',
|
|
313
|
+
icon: 'pi pi-replay',
|
|
314
|
+
command: () => confirmDialog({
|
|
315
|
+
header: 'Reopen Test',
|
|
316
|
+
message:
|
|
317
|
+
`Reopen ${rowData?.sample_id || rowData?.run_id} so new cycles are added to ` +
|
|
318
|
+
`it? Cycle numbering continues from where it stopped. This changes where ` +
|
|
319
|
+
`data is recorded — it does not start the machine.`,
|
|
320
|
+
icon: 'pi pi-replay',
|
|
321
|
+
acceptLabel: 'Reopen',
|
|
322
|
+
accept: () => handleLifecycle(rowData, 'reopen'),
|
|
323
|
+
}),
|
|
324
|
+
});
|
|
325
|
+
items.push({
|
|
326
|
+
label: 'Abandon Test',
|
|
327
|
+
icon: 'pi pi-ban',
|
|
328
|
+
command: () => confirmDialog({
|
|
329
|
+
header: 'Abandon Test',
|
|
330
|
+
message:
|
|
331
|
+
`Mark ${rowData?.sample_id || rowData?.run_id} as abandoned? ` +
|
|
332
|
+
`Recorded data is kept, but the test can no longer be reopened.`,
|
|
333
|
+
icon: 'pi pi-exclamation-triangle',
|
|
334
|
+
acceptClassName: 'p-button-danger',
|
|
335
|
+
acceptLabel: 'Abandon',
|
|
336
|
+
accept: () => handleLifecycle(rowData, 'abandon'),
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
267
339
|
} else if (status === 'completed' && !finalized) {
|
|
268
340
|
items.push({
|
|
269
341
|
label: 'Reopen Test',
|
|
@@ -377,6 +449,13 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
377
449
|
const statusOf = (rowData: any): { label: string; severity: 'success' | 'info' | 'warning' | 'danger' | undefined } => {
|
|
378
450
|
if (rowData?.finalized === true) return { label: 'Abandoned', severity: 'danger' };
|
|
379
451
|
const status = typeof rowData?.status === 'string' ? rowData.status : 'completed';
|
|
452
|
+
// A run TIS closed at startup because the previous process stopped
|
|
453
|
+
// mid-test. It is closed (not "In Progress"), but the operator never
|
|
454
|
+
// finished it, so calling it "Completed" would overstate what happened.
|
|
455
|
+
// Still reopenable — `finalized` is deliberately not set by that sweep.
|
|
456
|
+
if (status === 'completed' && rowData?.auto_closed === true) {
|
|
457
|
+
return { label: 'Interrupted', severity: 'warning' };
|
|
458
|
+
}
|
|
380
459
|
switch (status) {
|
|
381
460
|
case 'in_progress': return { label: 'In Progress', severity: 'info' };
|
|
382
461
|
case 'paused': return { label: 'Paused', severity: 'warning' };
|
|
@@ -573,14 +652,25 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
573
652
|
tooltip="Complete / abandon / reopen this test"
|
|
574
653
|
tooltipOptions={{ position: 'left' }}
|
|
575
654
|
onClick={(e) => {
|
|
576
|
-
|
|
577
|
-
|
|
655
|
+
// Capture the DOM anchor now: React clears
|
|
656
|
+
// currentTarget once the handler returns, and the
|
|
657
|
+
// effect needs it to position the popup.
|
|
658
|
+
menuSeqRef.current += 1;
|
|
659
|
+
setMenuRequest({
|
|
660
|
+
row: rowData,
|
|
661
|
+
anchor: e.currentTarget as HTMLElement,
|
|
662
|
+
seq: menuSeqRef.current,
|
|
663
|
+
});
|
|
578
664
|
}}
|
|
579
665
|
/>
|
|
580
666
|
)}
|
|
581
667
|
/>
|
|
582
668
|
</DataTable>
|
|
583
|
-
<Menu
|
|
669
|
+
<Menu
|
|
670
|
+
model={menuRequest ? rowMenuItems(menuRequest.row) : []}
|
|
671
|
+
popup
|
|
672
|
+
ref={rowMenuRef}
|
|
673
|
+
/>
|
|
584
674
|
</div>
|
|
585
675
|
);
|
|
586
676
|
};
|
|
@@ -52,6 +52,20 @@ export interface ScienceTableProps {
|
|
|
52
52
|
showIndex?: boolean;
|
|
53
53
|
/** Header for the index column. Default "#". */
|
|
54
54
|
indexHeader?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Which way the row index counts.
|
|
57
|
+
*
|
|
58
|
+
* `'ascending'` (default) numbers top-to-bottom, 1..n — right for a table
|
|
59
|
+
* whose first row is its first record.
|
|
60
|
+
*
|
|
61
|
+
* `'descending'` numbers n..1, so the TOP row carries the highest number.
|
|
62
|
+
* Use it whenever `rows` is newest-first: the number then reads as "this is
|
|
63
|
+
* cycle 47 of 47" instead of restarting at 1 on every new arrival, which is
|
|
64
|
+
* what an operator watching a running test expects. Getting this wrong is
|
|
65
|
+
* actively misleading — an ascending index over newest-first rows labels the
|
|
66
|
+
* newest cycle "1" and the oldest one "47".
|
|
67
|
+
*/
|
|
68
|
+
indexOrder?: 'ascending' | 'descending';
|
|
55
69
|
emptyMessage?: string;
|
|
56
70
|
/** Enable virtual scrolling; pair with `scrollHeight` for large runs. */
|
|
57
71
|
scrollable?: boolean;
|
|
@@ -80,6 +94,7 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
80
94
|
rows,
|
|
81
95
|
showIndex = true,
|
|
82
96
|
indexHeader = '#',
|
|
97
|
+
indexOrder = 'ascending',
|
|
83
98
|
emptyMessage = 'No data.',
|
|
84
99
|
scrollable = false,
|
|
85
100
|
scrollHeight,
|
|
@@ -87,6 +102,18 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
87
102
|
onCellEdit,
|
|
88
103
|
}) => {
|
|
89
104
|
const virtual = scrollable && !!scrollHeight;
|
|
105
|
+
|
|
106
|
+
// Force a remount when the COLUMN DEFINITION changes — which is what a
|
|
107
|
+
// Metric/Imperial switch does (new label, new unit, new body formatter).
|
|
108
|
+
//
|
|
109
|
+
// PrimeReact memoises rendered rows against the row data, so with the same
|
|
110
|
+
// `rows` array it keeps the old cells even though every column's `body`
|
|
111
|
+
// closure is new: the header flipped to "lbf" while the numbers stayed in N,
|
|
112
|
+
// and the operator had to leave the tab and come back. Keying on the column
|
|
113
|
+
// signature (not on every render) confines the remount to an actual change.
|
|
114
|
+
const columnSignature = columns
|
|
115
|
+
.map((c) => `${c.field}\u0000${c.label}\u0000${c.units ?? ''}`)
|
|
116
|
+
.join('\u0001');
|
|
90
117
|
// Cell edit mode is switched on only when a column asks for it AND a commit
|
|
91
118
|
// handler exists, so a plain report table keeps DataTable's read-only path.
|
|
92
119
|
const anyEditable = !!onCellEdit && columns.some((c) => c.editable);
|
|
@@ -95,6 +122,7 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
95
122
|
{title != null && <div className="ac-science-table-title">{title}</div>}
|
|
96
123
|
<div className="ac-science-table-scroll">
|
|
97
124
|
<DataTable
|
|
125
|
+
key={columnSignature}
|
|
98
126
|
className="ac-science-table"
|
|
99
127
|
value={rows}
|
|
100
128
|
scrollable={scrollable}
|
|
@@ -111,7 +139,11 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
|
|
|
111
139
|
align="center"
|
|
112
140
|
alignHeader="center"
|
|
113
141
|
style={{ width: '3.5rem', maxWidth: '3.5rem' }}
|
|
114
|
-
body={(_data: any, options: ColumnBodyOptions) =>
|
|
142
|
+
body={(_data: any, options: ColumnBodyOptions) =>
|
|
143
|
+
indexOrder === 'descending'
|
|
144
|
+
? rows.length - options.rowIndex
|
|
145
|
+
: options.rowIndex + 1
|
|
146
|
+
}
|
|
115
147
|
/>
|
|
116
148
|
) : null}
|
|
117
149
|
{columns.map((c) => {
|