@adcops/autocore-react 3.3.124 → 3.5.5
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/ams/AmsProvider.d.ts +14 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AmsProvider.js +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetNameplateGrid.d.ts +13 -0
- package/dist/components/ams/AssetNameplateGrid.d.ts.map +1 -0
- package/dist/components/ams/AssetNameplateGrid.js +1 -0
- package/dist/components/ams/InstalledAssetPicker.d.ts +35 -0
- package/dist/components/ams/InstalledAssetPicker.d.ts.map +1 -0
- package/dist/components/ams/InstalledAssetPicker.js +1 -0
- package/dist/components/ams/index.d.ts +5 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/ams/useInstalledAsset.d.ts +26 -0
- package/dist/components/ams/useInstalledAsset.d.ts.map +1 -0
- package/dist/components/ams/useInstalledAsset.js +1 -0
- package/dist/components/index.d.ts +4 -0
- 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.map +1 -1
- package/dist/components/tis/ScienceTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +16 -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 +88 -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/TestFieldDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
- package/dist/components/tis-editor/types.d.ts +6 -2
- 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/ams/AmsProvider.tsx +36 -1
- package/src/components/ams/AssetDetailView.tsx +2 -20
- package/src/components/ams/AssetNameplateGrid.tsx +62 -0
- package/src/components/ams/InstalledAssetPicker.tsx +209 -0
- package/src/components/ams/index.ts +7 -0
- package/src/components/ams/useInstalledAsset.ts +108 -0
- package/src/components/index.ts +4 -0
- package/src/components/tis/ResultHistoryTable.tsx +89 -6
- package/src/components/tis/ScienceTable.tsx +13 -0
- package/src/components/tis/TestDataView.tsx +153 -35
- package/src/components/tis/TestFieldRow.tsx +157 -19
- package/src/components/tis/TestSetupForm.tsx +35 -7
- package/src/components/tis/TisProvider.tsx +46 -3
- package/src/components/tis-editor/editor/TestFieldDialog.tsx +29 -18
- package/src/components/tis-editor/types.ts +6 -2
- 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
|
@@ -105,6 +105,20 @@ export interface AmsContextValue {
|
|
|
105
105
|
listCalibrations: (assetId: string) => Promise<string[]>;
|
|
106
106
|
readCalibration: (assetId: string, calId: string) => Promise<any | null>;
|
|
107
107
|
readUsage: (assetId: string) => Promise<any | null>;
|
|
108
|
+
/**
|
|
109
|
+
* Declare which asset is INSTALLED at a role (`ams.assign_role`).
|
|
110
|
+
*
|
|
111
|
+
* Pass `null` for `assetId` to record that nothing is fitted. One RPC,
|
|
112
|
+
* not two `updateAsset` calls: the server vacates the role and installs
|
|
113
|
+
* the new asset atomically, so the role is never momentarily empty or
|
|
114
|
+
* double-claimed — `resolve_method_refs` takes the FIRST active asset at
|
|
115
|
+
* a location, so a double-claimed role resolves to an arbitrary one.
|
|
116
|
+
*
|
|
117
|
+
* Resolves to `null` on success, or the server's message on refusal
|
|
118
|
+
* (wrong asset type for the role, asset not active, unknown id) so the
|
|
119
|
+
* caller can show the operator WHY the swap was refused.
|
|
120
|
+
*/
|
|
121
|
+
assignRole: (location: string, assetId: string | null) => Promise<string | null>;
|
|
108
122
|
selection: AmsSelection;
|
|
109
123
|
setSelection: (patch: Partial<AmsSelection>) => void;
|
|
110
124
|
}
|
|
@@ -125,6 +139,7 @@ const AmsContext = createContext<AmsContextValue>({
|
|
|
125
139
|
listCalibrations: async () => [],
|
|
126
140
|
readCalibration: async () => null,
|
|
127
141
|
readUsage: async () => null,
|
|
142
|
+
assignRole: async () => 'AmsProvider is not mounted',
|
|
128
143
|
selection: { assetType: null, assetId: null },
|
|
129
144
|
setSelection: () => {},
|
|
130
145
|
});
|
|
@@ -257,6 +272,25 @@ export const AmsProvider: React.FC<AmsProviderProps> = ({ children }) => {
|
|
|
257
272
|
} catch { return null; }
|
|
258
273
|
}, [invoke]);
|
|
259
274
|
|
|
275
|
+
const assignRole = useCallback(async (location: string, assetId: string | null): Promise<string | null> => {
|
|
276
|
+
try {
|
|
277
|
+
const resp: any = await invoke('ams.assign_role' as any, MessageType.Request, {
|
|
278
|
+
location, asset_id: assetId,
|
|
279
|
+
} as any);
|
|
280
|
+
if (resp?.success) {
|
|
281
|
+
// The server broadcasts ams.asset_changed for every asset it
|
|
282
|
+
// touched, which already triggers a refresh — but the refresh
|
|
283
|
+
// is awaited here too so a caller that re-renders off `assets`
|
|
284
|
+
// sees the new occupant by the time this resolves.
|
|
285
|
+
await refreshAssets();
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return resp?.error_message || 'ams.assign_role failed';
|
|
289
|
+
} catch (e: any) {
|
|
290
|
+
return String(e?.message ?? e);
|
|
291
|
+
}
|
|
292
|
+
}, [invoke, refreshAssets]);
|
|
293
|
+
|
|
260
294
|
const value = useMemo<AmsContextValue>(() => ({
|
|
261
295
|
schemas,
|
|
262
296
|
schemasLoaded,
|
|
@@ -269,9 +303,10 @@ export const AmsProvider: React.FC<AmsProviderProps> = ({ children }) => {
|
|
|
269
303
|
listCalibrations,
|
|
270
304
|
readCalibration,
|
|
271
305
|
readUsage,
|
|
306
|
+
assignRole,
|
|
272
307
|
selection,
|
|
273
308
|
setSelection,
|
|
274
|
-
}), [schemas, schemasLoaded, roles, rolesLoaded, alerts, assets, refreshAssets, readAsset, listCalibrations, readCalibration, readUsage, selection, setSelection]);
|
|
309
|
+
}), [schemas, schemasLoaded, roles, rolesLoaded, alerts, assets, refreshAssets, readAsset, listCalibrations, readCalibration, readUsage, assignRole, selection, setSelection]);
|
|
275
310
|
|
|
276
311
|
return <AmsContext.Provider value={value}>{children}</AmsContext.Provider>;
|
|
277
312
|
};
|
|
@@ -16,6 +16,7 @@ import { MessageType } from '../../hub/CommandMessage';
|
|
|
16
16
|
import { useAms } from './AmsProvider';
|
|
17
17
|
import { CalibrationEntryDialog } from './CalibrationEntryDialog';
|
|
18
18
|
import { AssetEditDialog } from './AssetEditDialog';
|
|
19
|
+
import { AssetNameplateGrid } from './AssetNameplateGrid';
|
|
19
20
|
|
|
20
21
|
export const AssetDetailView: React.FC = () => {
|
|
21
22
|
const { selection, schemas, roles, readAsset, listCalibrations, readCalibration, readUsage,
|
|
@@ -189,26 +190,7 @@ export const AssetDetailView: React.FC = () => {
|
|
|
189
190
|
{schemaFields.length > 0 && (
|
|
190
191
|
<div>
|
|
191
192
|
<h4 style={{ margin: '0 0 0.5rem 0' }}>Nameplate</h4>
|
|
192
|
-
<
|
|
193
|
-
gap: '0.25rem 1rem', alignItems: 'baseline',
|
|
194
|
-
fontSize: '0.9rem' }}>
|
|
195
|
-
{schemaFields.map((f: any) => {
|
|
196
|
-
const v = custom[f.name];
|
|
197
|
-
const present = v !== undefined && v !== null && v !== '';
|
|
198
|
-
const label = f.label ?? f.name;
|
|
199
|
-
const display = present
|
|
200
|
-
? (f.units ? `${v} ${f.units}` : String(v))
|
|
201
|
-
: '(not set)';
|
|
202
|
-
return (
|
|
203
|
-
<React.Fragment key={f.name}>
|
|
204
|
-
<strong title={f.description ?? undefined}>{label}</strong>
|
|
205
|
-
<span style={{ color: present ? undefined : '#f59e0b' }}>
|
|
206
|
-
{display}
|
|
207
|
-
</span>
|
|
208
|
-
</React.Fragment>
|
|
209
|
-
);
|
|
210
|
-
})}
|
|
211
|
-
</div>
|
|
193
|
+
<AssetNameplateGrid schemaFields={schemaFields} custom={custom} />
|
|
212
194
|
</div>
|
|
213
195
|
)}
|
|
214
196
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <AssetNameplateGrid> — the nameplate field/value grid for one asset.
|
|
5
|
+
*
|
|
6
|
+
* Extracted so <AssetDetailView> (the full record) and
|
|
7
|
+
* <InstalledAssetPicker> (what is fitted right now) render a nameplate the
|
|
8
|
+
* same way. A field the schema declares but the record has not filled in is
|
|
9
|
+
* drawn as an amber "(not set)" rather than blank space: that is exactly the
|
|
10
|
+
* state the placeholder resolver refuses to start a module on, so it is worth
|
|
11
|
+
* drawing the eye to.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React from 'react';
|
|
15
|
+
|
|
16
|
+
export interface AssetNameplateGridProps {
|
|
17
|
+
/** The asset_type schema's `fields` array. */
|
|
18
|
+
schemaFields: any[];
|
|
19
|
+
/** The asset record's `custom` object (nameplate values). */
|
|
20
|
+
custom: Record<string, any>;
|
|
21
|
+
/** Only render these field names, in this order. Omit for all of them. */
|
|
22
|
+
only?: string[];
|
|
23
|
+
/** Font size for the grid; the detail view runs slightly larger. */
|
|
24
|
+
fontSize?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const AssetNameplateGrid: React.FC<AssetNameplateGridProps> = ({
|
|
28
|
+
schemaFields,
|
|
29
|
+
custom,
|
|
30
|
+
only,
|
|
31
|
+
fontSize = '0.9rem',
|
|
32
|
+
}) => {
|
|
33
|
+
const fields = only
|
|
34
|
+
? only
|
|
35
|
+
.map(name => schemaFields.find((f: any) => f.name === name))
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
: schemaFields;
|
|
38
|
+
|
|
39
|
+
if (fields.length === 0) return null;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr',
|
|
43
|
+
gap: '0.25rem 1rem', alignItems: 'baseline', fontSize }}>
|
|
44
|
+
{fields.map((f: any) => {
|
|
45
|
+
const v = custom[f.name];
|
|
46
|
+
const present = v !== undefined && v !== null && v !== '';
|
|
47
|
+
const label = f.label ?? f.name;
|
|
48
|
+
const display = present
|
|
49
|
+
? (f.units ? `${v} ${f.units}` : String(v))
|
|
50
|
+
: '(not set)';
|
|
51
|
+
return (
|
|
52
|
+
<React.Fragment key={f.name}>
|
|
53
|
+
<strong title={f.description ?? undefined}>{label}</strong>
|
|
54
|
+
<span style={{ color: present ? undefined : '#f59e0b' }}>
|
|
55
|
+
{display}
|
|
56
|
+
</span>
|
|
57
|
+
</React.Fragment>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <InstalledAssetPicker> — "which asset is fitted in this role right now?"
|
|
5
|
+
*
|
|
6
|
+
* The operator-facing answer to a swap. A machine with a 2 kN and a 20 kN load
|
|
7
|
+
* cell in the drawer has ONE of them bolted to the press; this picker is where
|
|
8
|
+
* they say which, and everything downstream — the test record's asset
|
|
9
|
+
* snapshot, module placeholders, the control program's AssetWatch — follows
|
|
10
|
+
* from that one statement.
|
|
11
|
+
*
|
|
12
|
+
* It writes through `ams.assign_role`, never two `ams.update_asset` calls: the
|
|
13
|
+
* server vacates the role and installs the replacement atomically, because
|
|
14
|
+
* `resolve_method_refs` takes the FIRST active asset it finds at a location and
|
|
15
|
+
* a momentarily double-claimed role would therefore resolve to an arbitrary one.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT a nameplate editor. The nameplate belongs to the asset
|
|
18
|
+
* record (Assets tab / <AssetDetailView>) — one source of truth. This picker
|
|
19
|
+
* only chooses which record is in force, and shows what that record says so the
|
|
20
|
+
* operator can confirm they grabbed the right hardware before pressing on.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
24
|
+
import { Dropdown } from 'primereact/dropdown';
|
|
25
|
+
import { Button } from 'primereact/button';
|
|
26
|
+
import { Dialog } from 'primereact/dialog';
|
|
27
|
+
import { Message } from 'primereact/message';
|
|
28
|
+
import { AssetNameplateGrid } from './AssetNameplateGrid';
|
|
29
|
+
import { useInstalledAsset } from './useInstalledAsset';
|
|
30
|
+
|
|
31
|
+
export interface InstalledAssetPickerProps {
|
|
32
|
+
/** Asset type the role takes, e.g. `"load_cell"`. */
|
|
33
|
+
assetType: string;
|
|
34
|
+
/** The role's `location` string, e.g. `"press_load_cell"`. */
|
|
35
|
+
location: string;
|
|
36
|
+
/**
|
|
37
|
+
* Heading for the picker. Defaults to the role's label from
|
|
38
|
+
* `ams.list_roles`, then to the raw location.
|
|
39
|
+
*/
|
|
40
|
+
label?: string;
|
|
41
|
+
/** One line under the dropdown explaining what this role drives. */
|
|
42
|
+
description?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Nameplate field names folded into each option's text, so the operator can
|
|
45
|
+
* tell two assets apart without opening them (e.g.
|
|
46
|
+
* `["capacity", "capacity_units"]` → "2000 N"). Serial is always shown.
|
|
47
|
+
*/
|
|
48
|
+
summaryFields?: string[];
|
|
49
|
+
/** Render the fitted asset's full nameplate below the dropdown. */
|
|
50
|
+
showNameplate?: boolean;
|
|
51
|
+
/** Render the fitted asset's calibration state below the nameplate. */
|
|
52
|
+
showCalibration?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Block the swap. Use for "a test is running" — changing the fitted asset
|
|
55
|
+
* mid-run would re-scale a channel under the data being recorded.
|
|
56
|
+
*/
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** Why it is blocked. Shown in place of the help text. */
|
|
59
|
+
disabledReason?: string;
|
|
60
|
+
/** Fired after a successful assignment with the new asset id (or null). */
|
|
61
|
+
onAssigned?: (assetId: string | null) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Option value standing for "nothing is fitted". */
|
|
65
|
+
const NONE = '__none__';
|
|
66
|
+
|
|
67
|
+
export const InstalledAssetPicker: React.FC<InstalledAssetPickerProps> = ({
|
|
68
|
+
assetType,
|
|
69
|
+
location,
|
|
70
|
+
label,
|
|
71
|
+
description,
|
|
72
|
+
summaryFields = [],
|
|
73
|
+
showNameplate = true,
|
|
74
|
+
showCalibration = true,
|
|
75
|
+
disabled = false,
|
|
76
|
+
disabledReason,
|
|
77
|
+
onAssigned,
|
|
78
|
+
}) => {
|
|
79
|
+
const {
|
|
80
|
+
candidates, records, installed, installedRecord,
|
|
81
|
+
calibration, calibrationOverdue, role, schemaFields, assign,
|
|
82
|
+
} = useInstalledAsset(assetType, location);
|
|
83
|
+
|
|
84
|
+
const [error, setError] = useState<string | null>(null);
|
|
85
|
+
const [busy, setBusy] = useState(false);
|
|
86
|
+
/** Non-null while the confirm dialog is open; carries the pending choice. */
|
|
87
|
+
const [pending, setPending] = useState<{ assetId: string | null } | null>(null);
|
|
88
|
+
|
|
89
|
+
const roleLabel = label ?? role?.label ?? location;
|
|
90
|
+
const roleHelp = description ?? role?.description ?? null;
|
|
91
|
+
|
|
92
|
+
/** "SN 4471-A — 2000 N" — enough to tell two assets apart in a list. */
|
|
93
|
+
const optionText = useCallback((assetId: string): string => {
|
|
94
|
+
const entry = candidates.find(c => c.asset_id === assetId);
|
|
95
|
+
const custom = (records[assetId]?.custom ?? {}) as Record<string, any>;
|
|
96
|
+
const bits: string[] = [entry?.serial ? `SN ${entry.serial}` : assetId];
|
|
97
|
+
const summary = summaryFields
|
|
98
|
+
.map(name => custom[name])
|
|
99
|
+
.filter(v => v !== undefined && v !== null && v !== '')
|
|
100
|
+
.join(' ');
|
|
101
|
+
if (summary) bits.push(summary);
|
|
102
|
+
return bits.join(' — ');
|
|
103
|
+
}, [candidates, records, summaryFields]);
|
|
104
|
+
|
|
105
|
+
const options = useMemo(() => [
|
|
106
|
+
...candidates.map(c => ({ label: optionText(c.asset_id), value: c.asset_id })),
|
|
107
|
+
{ label: '— nothing fitted —', value: NONE },
|
|
108
|
+
], [candidates, optionText]);
|
|
109
|
+
|
|
110
|
+
const commit = async (assetId: string | null) => {
|
|
111
|
+
setBusy(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
const err = await assign(assetId);
|
|
114
|
+
setBusy(false);
|
|
115
|
+
setPending(null);
|
|
116
|
+
if (err) { setError(err); return; }
|
|
117
|
+
onAssigned?.(assetId);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const calExpiry = calibration?.expires_at ? new Date(calibration.expires_at) : null;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
124
|
+
<div className="p-inputgroup">
|
|
125
|
+
<span className="p-inputgroup-addon" style={{ flexGrow: 1 }}>
|
|
126
|
+
{roleLabel}
|
|
127
|
+
</span>
|
|
128
|
+
<Dropdown
|
|
129
|
+
value={installed?.asset_id ?? NONE}
|
|
130
|
+
options={options}
|
|
131
|
+
disabled={disabled || busy}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
const next = e.value === NONE ? null : (e.value as string);
|
|
134
|
+
if (next === (installed?.asset_id ?? null)) return;
|
|
135
|
+
setPending({ assetId: next });
|
|
136
|
+
}}
|
|
137
|
+
placeholder="Select the fitted asset"
|
|
138
|
+
style={{ minWidth: '18rem' }}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{/* Help text, or the reason the operator can't change it. On screen
|
|
143
|
+
rather than in a hover tooltip — this runs on a touchscreen,
|
|
144
|
+
where a tooltip is unreachable. */}
|
|
145
|
+
{disabled && disabledReason
|
|
146
|
+
? <small style={{ color: '#f59e0b' }}>{disabledReason}</small>
|
|
147
|
+
: roleHelp && <small style={{ color: '#9ca3af' }}>{roleHelp}</small>}
|
|
148
|
+
|
|
149
|
+
{candidates.length === 0 && (
|
|
150
|
+
<Message severity="warn"
|
|
151
|
+
text={`No active ${assetType.replace(/_/g, ' ')} is registered. Add one on the Assets screen first.`} />
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{!installed && candidates.length > 0 && (
|
|
155
|
+
<Message severity="warn"
|
|
156
|
+
text="Nothing is recorded as fitted in this role. Tests that need it will refuse to start." />
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{error && <Message severity="error" text={error} />}
|
|
160
|
+
|
|
161
|
+
{installed && showNameplate && (
|
|
162
|
+
<AssetNameplateGrid
|
|
163
|
+
schemaFields={schemaFields}
|
|
164
|
+
custom={(installedRecord?.custom ?? {}) as Record<string, any>}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{installed && showCalibration && (
|
|
169
|
+
<small style={{ color: calibrationOverdue ? '#f59e0b' : '#9ca3af' }}>
|
|
170
|
+
{!installed.current_calibration_id
|
|
171
|
+
? 'No calibration on record for this asset.'
|
|
172
|
+
: calibrationOverdue
|
|
173
|
+
? `Calibration ${installed.current_calibration_id} expired ${calExpiry!.toLocaleDateString()}.`
|
|
174
|
+
: `Calibration ${installed.current_calibration_id}${
|
|
175
|
+
calExpiry ? `, in date until ${calExpiry.toLocaleDateString()}` : ''}.`}
|
|
176
|
+
</small>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/* Confirm, because this restates what hardware is bolted to the
|
|
180
|
+
machine: downstream it re-scales a measurement channel and is
|
|
181
|
+
frozen into every test record started afterwards. */}
|
|
182
|
+
<Dialog
|
|
183
|
+
visible={pending !== null}
|
|
184
|
+
header={`Change the ${roleLabel}?`}
|
|
185
|
+
modal
|
|
186
|
+
onHide={() => setPending(null)}
|
|
187
|
+
footer={
|
|
188
|
+
<>
|
|
189
|
+
<Button label="Cancel" outlined onClick={() => setPending(null)} />
|
|
190
|
+
<Button label="Confirm" loading={busy}
|
|
191
|
+
onClick={() => { void commit(pending?.assetId ?? null); }} />
|
|
192
|
+
</>
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
<p style={{ margin: 0 }}>
|
|
196
|
+
{pending?.assetId
|
|
197
|
+
? <>Record <strong>{optionText(pending.assetId)}</strong> as the asset fitted
|
|
198
|
+
in <strong>{roleLabel}</strong>.</>
|
|
199
|
+
: <>Record that <strong>nothing</strong> is fitted in <strong>{roleLabel}</strong>.</>}
|
|
200
|
+
</p>
|
|
201
|
+
<p style={{ marginBottom: 0, color: '#9ca3af' }}>
|
|
202
|
+
Confirm only if this matches the hardware actually installed. The
|
|
203
|
+
machine reconfigures the channel from this asset's nameplate, and
|
|
204
|
+
every test started from now on records it.
|
|
205
|
+
</p>
|
|
206
|
+
</Dialog>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
@@ -14,3 +14,10 @@ export { AssetEditDialog } from './AssetEditDialog';
|
|
|
14
14
|
export { SubLocationPicker } from './SubLocationPicker';
|
|
15
15
|
export { PlaceholderHealthPanel } from './PlaceholderHealthPanel';
|
|
16
16
|
export { MissingAssetsBanner } from './MissingAssetsBanner';
|
|
17
|
+
// "What is fitted right now" — the operator's swap surface, and the
|
|
18
|
+
// nameplate grid it shares with <AssetDetailView>.
|
|
19
|
+
export { InstalledAssetPicker } from './InstalledAssetPicker';
|
|
20
|
+
export type { InstalledAssetPickerProps } from './InstalledAssetPicker';
|
|
21
|
+
export { AssetNameplateGrid } from './AssetNameplateGrid';
|
|
22
|
+
export { useInstalledAsset } from './useInstalledAsset';
|
|
23
|
+
export type { InstalledAssetState } from './useInstalledAsset';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* useInstalledAsset — "what is fitted in this role, and what does its record
|
|
5
|
+
* say?" for one (assetType, location) pair.
|
|
6
|
+
*
|
|
7
|
+
* The registry cache in <AmsProvider> carries only identity (id, serial,
|
|
8
|
+
* status, location); anything that has to show or act on a NAMEPLATE needs the
|
|
9
|
+
* full record. This hook is the one place that fetch happens, so
|
|
10
|
+
* <InstalledAssetPicker> and a product's own panel (e.g. a load-channel
|
|
11
|
+
* verification table comparing the nameplate against what the hardware
|
|
12
|
+
* actually holds) read the same data instead of each querying for it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
16
|
+
import { useAms, type AmsAssetEntry, type AmsRole } from './AmsProvider';
|
|
17
|
+
|
|
18
|
+
export interface InstalledAssetState {
|
|
19
|
+
/** Every ACTIVE asset of this type — the assets that could be fitted. */
|
|
20
|
+
candidates: AmsAssetEntry[];
|
|
21
|
+
/** Full records for `candidates`, keyed by asset_id. */
|
|
22
|
+
records: Record<string, any>;
|
|
23
|
+
/** The registry entry fitted in this role, or null when nothing is. */
|
|
24
|
+
installed: AmsAssetEntry | null;
|
|
25
|
+
/** The fitted asset's full record; nameplate values live under `.custom`. */
|
|
26
|
+
installedRecord: any | null;
|
|
27
|
+
/** The fitted asset's current calibration record, or null. */
|
|
28
|
+
calibration: any | null;
|
|
29
|
+
/** True once the calibration on record has passed its `expires_at`. */
|
|
30
|
+
calibrationOverdue: boolean;
|
|
31
|
+
/** This role's metadata from `ams.list_roles`, when the project declares it. */
|
|
32
|
+
role: AmsRole | null;
|
|
33
|
+
/** The asset_type schema's nameplate `fields` array (empty if unknown). */
|
|
34
|
+
schemaFields: any[];
|
|
35
|
+
/**
|
|
36
|
+
* Declare what is fitted (`null` = nothing). Resolves to `null` on success
|
|
37
|
+
* or the server's refusal message.
|
|
38
|
+
*/
|
|
39
|
+
assign: (assetId: string | null) => Promise<string | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useInstalledAsset(assetType: string, location: string): InstalledAssetState {
|
|
43
|
+
const { assets, schemas, roles, readAsset, readCalibration, assignRole } = useAms();
|
|
44
|
+
|
|
45
|
+
const [records, setRecords] = useState<Record<string, any>>({});
|
|
46
|
+
const [calibration, setCalibration] = useState<any | null>(null);
|
|
47
|
+
|
|
48
|
+
const candidates = useMemo(
|
|
49
|
+
() => assets.filter(a => a.asset_type === assetType && a.status === 'active'),
|
|
50
|
+
[assets, assetType],
|
|
51
|
+
);
|
|
52
|
+
const installed = useMemo(
|
|
53
|
+
() => candidates.find(a => a.location === location) ?? null,
|
|
54
|
+
[candidates, location],
|
|
55
|
+
);
|
|
56
|
+
const role = useMemo(
|
|
57
|
+
() => (roles[assetType] ?? []).find(r => r.location === location) ?? null,
|
|
58
|
+
[roles, assetType, location],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Fetch the full record for every candidate. Keyed on the id list, so a
|
|
62
|
+
// registry refresh that left the candidate set alone does no I/O.
|
|
63
|
+
const candidateKey = candidates.map(c => c.asset_id).join(',');
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
(async () => {
|
|
67
|
+
const next: Record<string, any> = {};
|
|
68
|
+
for (const c of candidates) {
|
|
69
|
+
const rec = await readAsset(c.asset_id);
|
|
70
|
+
if (cancelled) return;
|
|
71
|
+
if (rec) next[c.asset_id] = rec;
|
|
72
|
+
}
|
|
73
|
+
if (!cancelled) setRecords(next);
|
|
74
|
+
})();
|
|
75
|
+
return () => { cancelled = true; };
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [candidateKey, readAsset]);
|
|
78
|
+
|
|
79
|
+
// The fitted asset's current calibration, for the "is it in date?" line.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
(async () => {
|
|
83
|
+
if (!installed?.current_calibration_id) { setCalibration(null); return; }
|
|
84
|
+
const cal = await readCalibration(installed.asset_id, installed.current_calibration_id);
|
|
85
|
+
if (!cancelled) setCalibration(cal);
|
|
86
|
+
})();
|
|
87
|
+
return () => { cancelled = true; };
|
|
88
|
+
}, [installed?.asset_id, installed?.current_calibration_id, readCalibration]);
|
|
89
|
+
|
|
90
|
+
const assign = useCallback(
|
|
91
|
+
(assetId: string | null) => assignRole(location, assetId),
|
|
92
|
+
[assignRole, location],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const expiry = calibration?.expires_at ? new Date(calibration.expires_at) : null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
candidates,
|
|
99
|
+
records,
|
|
100
|
+
installed,
|
|
101
|
+
installedRecord: installed ? (records[installed.asset_id] ?? null) : null,
|
|
102
|
+
calibration,
|
|
103
|
+
calibrationOverdue: !!expiry && expiry.getTime() < Date.now(),
|
|
104
|
+
role,
|
|
105
|
+
schemaFields: Array.isArray(schemas[assetType]?.fields) ? schemas[assetType].fields : [],
|
|
106
|
+
assign,
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -63,6 +63,10 @@ export { SampleSummaryPanel } from './tis/SampleSummaryPanel';
|
|
|
63
63
|
export type { SampleSummaryPanelProps } from './tis/SampleSummaryPanel';
|
|
64
64
|
|
|
65
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',
|
|
@@ -580,14 +652,25 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
|
|
|
580
652
|
tooltip="Complete / abandon / reopen this test"
|
|
581
653
|
tooltipOptions={{ position: 'left' }}
|
|
582
654
|
onClick={(e) => {
|
|
583
|
-
|
|
584
|
-
|
|
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
|
+
});
|
|
585
664
|
}}
|
|
586
665
|
/>
|
|
587
666
|
)}
|
|
588
667
|
/>
|
|
589
668
|
</DataTable>
|
|
590
|
-
<Menu
|
|
669
|
+
<Menu
|
|
670
|
+
model={menuRequest ? rowMenuItems(menuRequest.row) : []}
|
|
671
|
+
popup
|
|
672
|
+
ref={rowMenuRef}
|
|
673
|
+
/>
|
|
591
674
|
</div>
|
|
592
675
|
);
|
|
593
676
|
};
|