@adcops/autocore-react 3.5.3 → 3.5.7

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.
Files changed (37) hide show
  1. package/dist/components/ams/AmsProvider.d.ts +14 -0
  2. package/dist/components/ams/AmsProvider.d.ts.map +1 -1
  3. package/dist/components/ams/AmsProvider.js +1 -1
  4. package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
  5. package/dist/components/ams/AssetDetailView.js +1 -1
  6. package/dist/components/ams/AssetNameplateGrid.d.ts +13 -0
  7. package/dist/components/ams/AssetNameplateGrid.d.ts.map +1 -0
  8. package/dist/components/ams/AssetNameplateGrid.js +1 -0
  9. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
  10. package/dist/components/ams/AssetRegistryTable.js +1 -1
  11. package/dist/components/ams/InstalledAssetPicker.d.ts +35 -0
  12. package/dist/components/ams/InstalledAssetPicker.d.ts.map +1 -0
  13. package/dist/components/ams/InstalledAssetPicker.js +1 -0
  14. package/dist/components/ams/index.d.ts +5 -0
  15. package/dist/components/ams/index.d.ts.map +1 -1
  16. package/dist/components/ams/index.js +1 -1
  17. package/dist/components/ams/useInstalledAsset.d.ts +26 -0
  18. package/dist/components/ams/useInstalledAsset.d.ts.map +1 -0
  19. package/dist/components/ams/useInstalledAsset.js +1 -0
  20. package/dist/components/tis/TestFieldRow.d.ts +19 -0
  21. package/dist/components/tis/TestFieldRow.d.ts.map +1 -1
  22. package/dist/components/tis/TestFieldRow.js +1 -1
  23. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  24. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  25. package/dist/components/tis-editor/types.d.ts +11 -2
  26. package/dist/components/tis-editor/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/ams/AmsProvider.tsx +36 -1
  29. package/src/components/ams/AssetDetailView.tsx +2 -20
  30. package/src/components/ams/AssetNameplateGrid.tsx +62 -0
  31. package/src/components/ams/AssetRegistryTable.tsx +40 -9
  32. package/src/components/ams/InstalledAssetPicker.tsx +209 -0
  33. package/src/components/ams/index.ts +7 -0
  34. package/src/components/ams/useInstalledAsset.ts +108 -0
  35. package/src/components/tis/TestFieldRow.tsx +31 -0
  36. package/src/components/tis-editor/editor/TestFieldDialog.tsx +59 -20
  37. package/src/components/tis-editor/types.ts +11 -2
@@ -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
- <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr',
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
+ };
@@ -164,6 +164,14 @@ export const AssetRegistryTable: React.FC = () => {
164
164
  const [filterType, setFilterType] = useState<string | null>(null);
165
165
  const [filterStatus, setFilterStatus] = useState<string | null>(null);
166
166
  const [addState, setAddState] = useState<AddDialogState>(EMPTY_ADD);
167
+ /** True from the moment Create is pressed until `ams.create_asset`
168
+ * has answered and the registry has been refreshed. Creation is a
169
+ * server round-trip, and with the buttons still live an operator
170
+ * who saw no feedback would press Create again — registering the
171
+ * same asset two or three times. While this is set the footer
172
+ * shows a spinner instead of the buttons and the dialog refuses to
173
+ * close, so there is no second press to swallow. */
174
+ const [creating, setCreating] = useState(false);
167
175
 
168
176
  // <MissingAssetsBanner> fires the `ams:prefill-add` custom event
169
177
  // when an operator clicks Register on one of its rows. Catch it
@@ -243,7 +251,10 @@ export const AssetRegistryTable: React.FC = () => {
243
251
  }, [assets, filterType, filterStatus]);
244
252
 
245
253
  const onCreate = async () => {
246
- if (!addState.assetType) return;
254
+ // Re-entry guard: belt to the footer's braces. The footer hides
255
+ // the button while `creating`, but a queued click (or a stray
256
+ // Enter key) must not post a second create.
257
+ if (!addState.assetType || creating) return;
247
258
  // Coerce the per-field strings to their declared types. Numbers
248
259
  // become JSON numbers, bools become bools; empty strings drop
249
260
  // out so the asset_json doesn't get noisy with empty values.
@@ -296,6 +307,7 @@ export const AssetRegistryTable: React.FC = () => {
296
307
  payload.sub_locations = subLocations;
297
308
  }
298
309
 
310
+ setCreating(true);
299
311
  try {
300
312
  const resp: any = await invoke('ams.create_asset' as any, MessageType.Request, payload);
301
313
  if (resp?.success) {
@@ -309,6 +321,11 @@ export const AssetRegistryTable: React.FC = () => {
309
321
  }
310
322
  } catch (e) {
311
323
  console.error('[AssetRegistryTable] create_asset threw:', e);
324
+ } finally {
325
+ // Always clear, including on the failure paths above — the
326
+ // dialog stays open with the operator's entries intact and
327
+ // the buttons come back so they can retry or cancel.
328
+ setCreating(false);
312
329
  }
313
330
  };
314
331
 
@@ -466,15 +483,29 @@ export const AssetRegistryTable: React.FC = () => {
466
483
  header="Add New Asset"
467
484
  visible={addState.open}
468
485
  style={{ width: '32rem' }}
469
- onHide={() => setAddState(EMPTY_ADD)}
486
+ /* No escape hatches while the create is in flight: the
487
+ header X, the Escape key and the mask click all route
488
+ through onHide, and closing mid-flight would drop the
489
+ operator back to a table that has not refreshed yet. */
490
+ closable={!creating}
491
+ closeOnEscape={!creating}
492
+ onHide={() => { if (!creating) setAddState(EMPTY_ADD); }}
470
493
  footer={
471
- <>
472
- <Button label="Cancel" severity="secondary" onClick={() => setAddState(EMPTY_ADD)} />
473
- <Button label="Create" icon="pi pi-check"
474
- onClick={onCreate}
475
- disabled={createDisabled}
476
- />
477
- </>
494
+ creating ? (
495
+ <div style={{ display: 'flex', justifyContent: 'flex-end',
496
+ alignItems: 'center', gap: '0.5rem' }}>
497
+ <i className="pi pi-spin pi-spinner" />
498
+ <span>Creating asset…</span>
499
+ </div>
500
+ ) : (
501
+ <>
502
+ <Button label="Cancel" severity="secondary" onClick={() => setAddState(EMPTY_ADD)} />
503
+ <Button label="Create" icon="pi pi-check"
504
+ onClick={onCreate}
505
+ disabled={createDisabled}
506
+ />
507
+ </>
508
+ )
478
509
  }
479
510
  >
480
511
  <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1rem', alignItems: 'center' }}>
@@ -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
+ }
@@ -181,6 +181,37 @@ export interface TagLookup {
181
181
  /** Matches a bound token like `${gm.press_force_min_n}` (whole-string). */
182
182
  const BOUND_TOKEN_RE = /^\$\{\s*([^}]+?)\s*\}$/;
183
183
 
184
+ /** Is this string a well-formed FQDN bound token? */
185
+ export const isBoundToken = (s: string): boolean => BOUND_TOKEN_RE.test(s.trim());
186
+
187
+ /**
188
+ * Render a bound for an authoring text field — the method editor's Min/Max.
189
+ *
190
+ * A text field, not a numeric one, because a bound is a number OR a token. The
191
+ * editor used `InputNumber`, which rendered a token as **NaN** and — worse —
192
+ * coerced anything non-numeric to `undefined` on change, so opening the dialog
193
+ * and saving silently deleted a token bound.
194
+ */
195
+ export const boundText = (b: number | string | undefined): string =>
196
+ b === undefined || b === null ? '' : String(b);
197
+
198
+ /**
199
+ * Parse what an author typed into a bound field. Blank clears it; a finite
200
+ * number stores a number; anything else is kept VERBATIM so a half-typed token
201
+ * survives the keystroke. Use `boundInvalid` to flag what will not resolve.
202
+ */
203
+ export const parseBound = (text: string): number | string | undefined => {
204
+ const t = text.trim();
205
+ if (t === '') return undefined;
206
+ const n = Number(t);
207
+ if (Number.isFinite(n)) return n;
208
+ return t;
209
+ };
210
+
211
+ /** True when a bound is a string that is not a well-formed token. */
212
+ export const boundInvalid = (b: number | string | undefined): boolean =>
213
+ typeof b === 'string' && !isBoundToken(b);
214
+
184
215
  /**
185
216
  * A declared bound resolved to a plain DISPLAY-unit number, or `undefined` for
186
217
  * "no bound".