@adcops/autocore-react 3.3.75 → 3.3.77
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/Indicator.d.ts +29 -52
- package/dist/components/Indicator.d.ts.map +1 -1
- package/dist/components/Indicator.js +1 -1
- package/dist/components/ams/AmsProvider.d.ts +7 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
- package/dist/components/ams/AssetRegistryTable.js +1 -1
- package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -1
- package/dist/components/ams/CalibrationEntryDialog.js +1 -1
- package/dist/components/ams/MissingAssetsBanner.d.ts +11 -0
- package/dist/components/ams/MissingAssetsBanner.d.ts.map +1 -0
- package/dist/components/ams/MissingAssetsBanner.js +1 -0
- package/dist/components/ams/PlaceholderHealthPanel.d.ts +3 -0
- package/dist/components/ams/PlaceholderHealthPanel.d.ts.map +1 -0
- package/dist/components/ams/PlaceholderHealthPanel.js +1 -0
- package/dist/components/ams/index.d.ts +2 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/network/NetworkPanel.d.ts +8 -0
- package/dist/components/network/NetworkPanel.d.ts.map +1 -0
- package/dist/components/network/NetworkPanel.js +1 -0
- package/dist/components/network/NetworkProvider.d.ts +72 -0
- package/dist/components/network/NetworkProvider.d.ts.map +1 -0
- package/dist/components/network/NetworkProvider.js +1 -0
- package/dist/components/network/StagedChangeBanner.d.ts +8 -0
- package/dist/components/network/StagedChangeBanner.d.ts.map +1 -0
- package/dist/components/network/StagedChangeBanner.js +1 -0
- package/dist/components/network/index.d.ts +7 -0
- package/dist/components/network/index.d.ts.map +1 -0
- package/dist/components/network/index.js +1 -0
- package/dist/components/tis/ProjectManager.d.ts +7 -0
- package/dist/components/tis/ProjectManager.d.ts.map +1 -0
- package/dist/components/tis/ProjectManager.js +1 -0
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/package.json +1 -1
- package/src/components/Indicator.tsx +166 -162
- package/src/components/ams/AmsProvider.tsx +7 -0
- package/src/components/ams/AssetDetailView.tsx +287 -4
- package/src/components/ams/AssetRegistryTable.tsx +325 -21
- package/src/components/ams/CalibrationEntryDialog.tsx +163 -30
- package/src/components/ams/MissingAssetsBanner.tsx +124 -0
- package/src/components/ams/PlaceholderHealthPanel.tsx +188 -0
- package/src/components/ams/index.ts +2 -0
- package/src/components/index.ts +26 -0
- package/src/components/network/NetworkPanel.tsx +363 -0
- package/src/components/network/NetworkProvider.tsx +349 -0
- package/src/components/network/StagedChangeBanner.tsx +101 -0
- package/src/components/network/index.ts +17 -0
- package/src/components/tis/ProjectManager.tsx +392 -0
- package/src/components/tis/ResultHistoryTable.tsx +126 -188
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* can render the chosen asset.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useContext, useMemo, useState } from 'react';
|
|
8
|
+
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
|
9
9
|
import { Button } from 'primereact/button';
|
|
10
10
|
import { DataTable } from 'primereact/datatable';
|
|
11
11
|
import { Column } from 'primereact/column';
|
|
@@ -31,10 +31,24 @@ interface AddDialogState {
|
|
|
31
31
|
* matches `roleSelection` for known roles. This is what gets sent
|
|
32
32
|
* to the server as `location`. */
|
|
33
33
|
location: string;
|
|
34
|
+
/** Operator-entered values for the schema's nameplate `fields`
|
|
35
|
+
* (capacity_n, sensitivity_mv_v, manufacturer, model, etc.).
|
|
36
|
+
* Keyed by field name. Posted to the server as `custom` on
|
|
37
|
+
* `ams.create_asset` so the placeholder resolver can read them
|
|
38
|
+
* at module-spawn time. */
|
|
39
|
+
customFields: Record<string, string>;
|
|
40
|
+
/** Per-axis matrix values for multi-axis types like the
|
|
41
|
+
* triaxial transducer. Outer key is the sub-location name
|
|
42
|
+
* (`fx`, `fy`, ...); inner key is the per-axis field name
|
|
43
|
+
* (`capacity_n`, `sensitivity_mv_v`, ...). Empty / missing
|
|
44
|
+
* entries are dropped on POST; required fields gate Create.
|
|
45
|
+
* Posted as `sub_locations` on `ams.create_asset`. */
|
|
46
|
+
subLocationFields: Record<string, Record<string, string>>;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
const EMPTY_ADD: AddDialogState = {
|
|
37
50
|
open: false, assetType: '', serial: '', roleSelection: '', location: '',
|
|
51
|
+
customFields: {}, subLocationFields: {},
|
|
38
52
|
};
|
|
39
53
|
|
|
40
54
|
/** Pretty label for one role in the dropdown. */
|
|
@@ -42,14 +56,78 @@ function roleLabel(r: AmsRole): string {
|
|
|
42
56
|
return r.label ?? r.location;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
/** Human-readable summary of
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
59
|
+
/** Human-readable summary of who'll pick up an asset in this role.
|
|
60
|
+
* Covers both test_methods (via asset_refs at start_test) and hardware
|
|
61
|
+
* modules (via `${ams.by_location.*}` placeholders resolved at module
|
|
62
|
+
* spawn). One line per kind; empty if neither references the role. */
|
|
49
63
|
function roleUsageSummary(r: AmsRole): string {
|
|
50
|
-
|
|
51
|
-
if (r.used_by.length
|
|
52
|
-
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
if (r.used_by.length > 0) {
|
|
66
|
+
parts.push(`Used by test method${r.used_by.length === 1 ? '' : 's'}: ${r.used_by.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
if (r.used_by_modules.length > 0) {
|
|
69
|
+
parts.push(`Used by module${r.used_by_modules.length === 1 ? '' : 's'}: ${r.used_by_modules.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
if (parts.length === 0) return 'Not referenced by any test method or module.';
|
|
72
|
+
return parts.join(' • ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render-ready descriptor for one schema-declared field. Schemas come
|
|
76
|
+
* back from `ams.list_schemas` as untyped JSON; this narrows the
|
|
77
|
+
* fields we actually care about for the Add dialog. */
|
|
78
|
+
interface SchemaField {
|
|
79
|
+
name: string;
|
|
80
|
+
type: string;
|
|
81
|
+
units?: string;
|
|
82
|
+
label?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
required?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The keyed-fields sub_locations schema declared on an asset_type
|
|
88
|
+
* (multi-axis transducers etc.). `keys` fixes the row set; `fields`
|
|
89
|
+
* defines the columns. Mirrors `SubLocationsSchema` on the server. */
|
|
90
|
+
interface SubLocationsSchema {
|
|
91
|
+
label?: string;
|
|
92
|
+
key_label?: string;
|
|
93
|
+
keys: string[];
|
|
94
|
+
fields: SchemaField[];
|
|
95
|
+
calibration_fields?: SchemaField[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Pull the schema's `fields` array. Empty when no schema is loaded for
|
|
99
|
+
* this type (built-in or custom). */
|
|
100
|
+
function fieldsFor(schemas: any, assetType: string): SchemaField[] {
|
|
101
|
+
const arr = schemas?.[assetType]?.fields;
|
|
102
|
+
return Array.isArray(arr) ? arr as SchemaField[] : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pull the keyed-fields sub_locations schema for an asset_type, if it
|
|
106
|
+
* has one. Returns null for asset types using the surface-style
|
|
107
|
+
* positional shape (no `keys`) or that don't declare sub_locations. */
|
|
108
|
+
function subLocationsFor(schemas: any, assetType: string): SubLocationsSchema | null {
|
|
109
|
+
const sl = schemas?.[assetType]?.sub_locations;
|
|
110
|
+
if (!sl || !Array.isArray(sl.keys) || !Array.isArray(sl.fields)) return null;
|
|
111
|
+
return sl as SubLocationsSchema;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Coerce a per-cell string from the matrix input back to its declared
|
|
115
|
+
* type. Numeric strings → JSON numbers; booleans → bool; everything
|
|
116
|
+
* else stays as a string. Matches the top-level fields coercion. */
|
|
117
|
+
function coerceField(field: SchemaField, raw: string): any {
|
|
118
|
+
if (raw === undefined || raw === '') return undefined;
|
|
119
|
+
switch (field.type) {
|
|
120
|
+
case 'f32': case 'f64':
|
|
121
|
+
case 'i8': case 'i16': case 'i32': case 'i64':
|
|
122
|
+
case 'u8': case 'u16': case 'u32': case 'u64': {
|
|
123
|
+
const n = Number(raw);
|
|
124
|
+
return Number.isFinite(n) ? n : undefined;
|
|
125
|
+
}
|
|
126
|
+
case 'bool':
|
|
127
|
+
return raw === 'true' || raw === '1';
|
|
128
|
+
default:
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
53
131
|
}
|
|
54
132
|
|
|
55
133
|
export const AssetRegistryTable: React.FC = () => {
|
|
@@ -60,6 +138,29 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
60
138
|
const [filterStatus, setFilterStatus] = useState<string | null>(null);
|
|
61
139
|
const [addState, setAddState] = useState<AddDialogState>(EMPTY_ADD);
|
|
62
140
|
|
|
141
|
+
// <MissingAssetsBanner> fires the `ams:prefill-add` custom event
|
|
142
|
+
// when an operator clicks Register on one of its rows. Catch it
|
|
143
|
+
// here and open the Add dialog pre-populated with the asset_type
|
|
144
|
+
// and role — the operator just fills in the nameplate values.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const handler = (e: Event) => {
|
|
147
|
+
const detail = (e as CustomEvent).detail as
|
|
148
|
+
{ assetType?: string; location?: string } | undefined;
|
|
149
|
+
const assetType = detail?.assetType ?? '';
|
|
150
|
+
const location = detail?.location ?? '';
|
|
151
|
+
if (!assetType) return;
|
|
152
|
+
setAddState({
|
|
153
|
+
...EMPTY_ADD,
|
|
154
|
+
open: true,
|
|
155
|
+
assetType,
|
|
156
|
+
roleSelection: location,
|
|
157
|
+
location,
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
window.addEventListener('ams:prefill-add', handler);
|
|
161
|
+
return () => window.removeEventListener('ams:prefill-add', handler);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
63
164
|
const typeOptions = useMemo(
|
|
64
165
|
() => Object.keys(schemas).map(k => ({ label: schemas[k]?.label ?? k, value: k })),
|
|
65
166
|
[schemas],
|
|
@@ -111,12 +212,60 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
111
212
|
|
|
112
213
|
const onCreate = async () => {
|
|
113
214
|
if (!addState.assetType) return;
|
|
215
|
+
// Coerce the per-field strings to their declared types. Numbers
|
|
216
|
+
// become JSON numbers, bools become bools; empty strings drop
|
|
217
|
+
// out so the asset_json doesn't get noisy with empty values.
|
|
218
|
+
// The placeholder resolver expects native types — a string
|
|
219
|
+
// "5000" wouldn't satisfy a `${ams.*}` reference asking for a
|
|
220
|
+
// numeric max_val.
|
|
221
|
+
const custom: Record<string, any> = {};
|
|
222
|
+
for (const field of fieldsForType) {
|
|
223
|
+
const raw = addState.customFields[field.name];
|
|
224
|
+
if (raw === undefined || raw === '') continue;
|
|
225
|
+
switch (field.type) {
|
|
226
|
+
case 'f32': case 'f64': case 'i8': case 'i16': case 'i32': case 'i64':
|
|
227
|
+
case 'u8': case 'u16': case 'u32': case 'u64': {
|
|
228
|
+
const n = Number(raw);
|
|
229
|
+
if (Number.isFinite(n)) custom[field.name] = n;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case 'bool':
|
|
233
|
+
custom[field.name] = raw === 'true' || raw === '1';
|
|
234
|
+
break;
|
|
235
|
+
default:
|
|
236
|
+
custom[field.name] = raw;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Pack the per-axis matrix into a sub_locations object. Only
|
|
241
|
+
// included when the asset_type declares a keyed-fields schema;
|
|
242
|
+
// for surface-style positional types the server still
|
|
243
|
+
// materializes its own defaults from the schema.
|
|
244
|
+
const subLocations: Record<string, Record<string, any>> = {};
|
|
245
|
+
if (subLocationsSchema) {
|
|
246
|
+
for (const key of subLocationsSchema.keys) {
|
|
247
|
+
const row: Record<string, any> = {};
|
|
248
|
+
for (const field of subLocationsSchema.fields) {
|
|
249
|
+
const raw = addState.subLocationFields[key]?.[field.name] ?? '';
|
|
250
|
+
const coerced = coerceField(field, raw);
|
|
251
|
+
if (coerced !== undefined) row[field.name] = coerced;
|
|
252
|
+
}
|
|
253
|
+
subLocations[key] = row;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const payload: any = {
|
|
258
|
+
asset_type: addState.assetType,
|
|
259
|
+
serial: addState.serial,
|
|
260
|
+
location: addState.location,
|
|
261
|
+
custom,
|
|
262
|
+
};
|
|
263
|
+
if (subLocationsSchema) {
|
|
264
|
+
payload.sub_locations = subLocations;
|
|
265
|
+
}
|
|
266
|
+
|
|
114
267
|
try {
|
|
115
|
-
const resp: any = await invoke('ams.create_asset' as any, MessageType.Request,
|
|
116
|
-
asset_type: addState.assetType,
|
|
117
|
-
serial: addState.serial,
|
|
118
|
-
location: addState.location,
|
|
119
|
-
} as any);
|
|
268
|
+
const resp: any = await invoke('ams.create_asset' as any, MessageType.Request, payload);
|
|
120
269
|
if (resp?.success) {
|
|
121
270
|
setAddState(EMPTY_ADD);
|
|
122
271
|
await refreshAssets();
|
|
@@ -134,16 +283,37 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
134
283
|
/** When the operator changes asset type in the dialog, pre-select
|
|
135
284
|
* the only role if there's exactly one (the common case for fixed
|
|
136
285
|
* hardware), or leave it empty so they make an explicit choice
|
|
137
|
-
* when there are multiple. Hide the field entirely when none.
|
|
286
|
+
* when there are multiple. Hide the field entirely when none.
|
|
287
|
+
* Also resets the nameplate-field map — fields are type-specific
|
|
288
|
+
* and the previous type's inputs would not be relevant. */
|
|
138
289
|
const onAssetTypeChange = (newType: string) => {
|
|
139
290
|
const list = roles[newType] ?? [];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
291
|
+
const base = list.length === 1
|
|
292
|
+
? { roleSelection: list[0].location, location: list[0].location }
|
|
293
|
+
: { roleSelection: '', location: '' };
|
|
294
|
+
setAddState(s => ({
|
|
295
|
+
...s, assetType: newType, ...base,
|
|
296
|
+
customFields: {}, subLocationFields: {},
|
|
297
|
+
}));
|
|
145
298
|
};
|
|
146
299
|
|
|
300
|
+
/** Schema-declared `fields` for the selected asset_type, filtered
|
|
301
|
+
* to the ones the dialog actually renders. We render everything
|
|
302
|
+
* the catalog declares (manufacturer, model, capacity_n, etc.).
|
|
303
|
+
* The dialog's existing `serial` input remains separate because
|
|
304
|
+
* serial is a top-level Asset field, not a custom one. */
|
|
305
|
+
const fieldsForType = useMemo(
|
|
306
|
+
() => fieldsFor(schemas, addState.assetType),
|
|
307
|
+
[schemas, addState.assetType],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
/** Keyed-fields sub_locations schema for the selected asset_type,
|
|
311
|
+
* if any. Drives the per-axis matrix render below. */
|
|
312
|
+
const subLocationsSchema = useMemo(
|
|
313
|
+
() => subLocationsFor(schemas, addState.assetType),
|
|
314
|
+
[schemas, addState.assetType],
|
|
315
|
+
);
|
|
316
|
+
|
|
147
317
|
/** Role dropdown change handler. ROLE_OTHER puts us into free-form
|
|
148
318
|
* mode where the operator types into a text field. */
|
|
149
319
|
const onRoleChange = (value: string) => {
|
|
@@ -155,13 +325,24 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
155
325
|
};
|
|
156
326
|
|
|
157
327
|
/** Disable Create until the operator has picked a role (or supplied
|
|
158
|
-
* free-form text)
|
|
328
|
+
* free-form text), filled in every required top-level field, and
|
|
329
|
+
* filled in every required per-axis field on every declared key
|
|
330
|
+
* (when the asset_type uses a keyed-fields sub_locations schema). */
|
|
159
331
|
const createDisabled =
|
|
160
332
|
!addState.assetType ||
|
|
161
333
|
(typeHasRoles && (
|
|
162
334
|
addState.roleSelection === '' ||
|
|
163
335
|
(addState.roleSelection === ROLE_OTHER && !addState.location.trim())
|
|
164
|
-
))
|
|
336
|
+
)) ||
|
|
337
|
+
fieldsForType.some(f =>
|
|
338
|
+
f.required && !(addState.customFields[f.name]?.toString().trim())
|
|
339
|
+
) ||
|
|
340
|
+
(subLocationsSchema?.keys.some(key =>
|
|
341
|
+
subLocationsSchema.fields.some(field =>
|
|
342
|
+
field.required &&
|
|
343
|
+
!(addState.subLocationFields[key]?.[field.name]?.toString().trim())
|
|
344
|
+
)
|
|
345
|
+
) ?? false);
|
|
165
346
|
|
|
166
347
|
return (
|
|
167
348
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
@@ -284,8 +465,131 @@ export const AssetRegistryTable: React.FC = () => {
|
|
|
284
465
|
)}
|
|
285
466
|
</>
|
|
286
467
|
)}
|
|
468
|
+
|
|
469
|
+
{/* Schema-declared nameplate fields (capacity_n,
|
|
470
|
+
sensitivity_mv_v, manufacturer, model, etc.).
|
|
471
|
+
The values land in asset.custom on the server and
|
|
472
|
+
are what the placeholder resolver reads to seed
|
|
473
|
+
NI bridge channels and EL3356 SDOs. Required
|
|
474
|
+
fields gate Create. */}
|
|
475
|
+
{fieldsForType.map(field => {
|
|
476
|
+
const label = field.label ?? field.name;
|
|
477
|
+
const fullLabel = field.units
|
|
478
|
+
? `${label} [${field.units}]${field.required ? ' *' : ''}`
|
|
479
|
+
: `${label}${field.required ? ' *' : ''}`;
|
|
480
|
+
const isNum = field.type !== 'string' && field.type !== 'bool';
|
|
481
|
+
return (
|
|
482
|
+
<React.Fragment key={field.name}>
|
|
483
|
+
<label title={field.description ?? undefined}>
|
|
484
|
+
{fullLabel}
|
|
485
|
+
</label>
|
|
486
|
+
{field.type === 'bool' ? (
|
|
487
|
+
<Dropdown
|
|
488
|
+
value={addState.customFields[field.name] ?? ''}
|
|
489
|
+
options={[
|
|
490
|
+
{ label: 'true', value: 'true' },
|
|
491
|
+
{ label: 'false', value: 'false' },
|
|
492
|
+
]}
|
|
493
|
+
onChange={(e) => setAddState(s => ({
|
|
494
|
+
...s,
|
|
495
|
+
customFields: { ...s.customFields, [field.name]: e.value },
|
|
496
|
+
}))}
|
|
497
|
+
placeholder="—"
|
|
498
|
+
/>
|
|
499
|
+
) : (
|
|
500
|
+
<InputText
|
|
501
|
+
value={addState.customFields[field.name] ?? ''}
|
|
502
|
+
keyfilter={isNum ? 'num' : undefined}
|
|
503
|
+
placeholder={field.description ?? undefined}
|
|
504
|
+
onChange={(e) => setAddState(s => ({
|
|
505
|
+
...s,
|
|
506
|
+
customFields: { ...s.customFields, [field.name]: e.target.value },
|
|
507
|
+
}))}
|
|
508
|
+
/>
|
|
509
|
+
)}
|
|
510
|
+
</React.Fragment>
|
|
511
|
+
);
|
|
512
|
+
})}
|
|
287
513
|
</div>
|
|
288
514
|
|
|
515
|
+
{/* Per-axis matrix for asset types with a keyed-fields
|
|
516
|
+
sub_locations schema (multi-axis transducers). One
|
|
517
|
+
row per declared key (fx/fy/fz/mx/my/mz); columns
|
|
518
|
+
are the schema's per-axis fields. Each cell is the
|
|
519
|
+
same input control the top-level fields above use.
|
|
520
|
+
Required cells gate Create — the server validates
|
|
521
|
+
again with a per-key, per-field problem list. */}
|
|
522
|
+
{subLocationsSchema && (
|
|
523
|
+
<div style={{ marginTop: '1.5rem' }}>
|
|
524
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>
|
|
525
|
+
{subLocationsSchema.label ?? 'Sub-locations'}
|
|
526
|
+
</h4>
|
|
527
|
+
<div style={{ overflowX: 'auto' }}>
|
|
528
|
+
<table style={{
|
|
529
|
+
width: '100%', borderCollapse: 'collapse',
|
|
530
|
+
fontSize: '0.875rem',
|
|
531
|
+
}}>
|
|
532
|
+
<thead>
|
|
533
|
+
<tr>
|
|
534
|
+
<th style={{ textAlign: 'left', padding: '0.25rem 0.5rem',
|
|
535
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
536
|
+
{subLocationsSchema.key_label ?? 'Key'}
|
|
537
|
+
</th>
|
|
538
|
+
{subLocationsSchema.fields.map(f => (
|
|
539
|
+
<th key={f.name}
|
|
540
|
+
title={f.description ?? undefined}
|
|
541
|
+
style={{ textAlign: 'left', padding: '0.25rem 0.5rem',
|
|
542
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
543
|
+
{(f.label ?? f.name)}
|
|
544
|
+
{f.units ? ` [${f.units}]` : ''}
|
|
545
|
+
{f.required ? ' *' : ''}
|
|
546
|
+
</th>
|
|
547
|
+
))}
|
|
548
|
+
</tr>
|
|
549
|
+
</thead>
|
|
550
|
+
<tbody>
|
|
551
|
+
{subLocationsSchema.keys.map(key => (
|
|
552
|
+
<tr key={key}>
|
|
553
|
+
<td style={{ padding: '0.25rem 0.5rem', fontWeight: 600 }}>
|
|
554
|
+
{key}
|
|
555
|
+
</td>
|
|
556
|
+
{subLocationsSchema.fields.map(field => {
|
|
557
|
+
const cellValue =
|
|
558
|
+
addState.subLocationFields[key]?.[field.name] ?? '';
|
|
559
|
+
const isNum =
|
|
560
|
+
field.type !== 'string' && field.type !== 'bool';
|
|
561
|
+
return (
|
|
562
|
+
<td key={field.name}
|
|
563
|
+
style={{ padding: '0.25rem 0.5rem' }}>
|
|
564
|
+
<InputText
|
|
565
|
+
value={cellValue}
|
|
566
|
+
keyfilter={isNum ? 'num' : undefined}
|
|
567
|
+
onChange={(e) => {
|
|
568
|
+
const v = e.target.value;
|
|
569
|
+
setAddState(s => ({
|
|
570
|
+
...s,
|
|
571
|
+
subLocationFields: {
|
|
572
|
+
...s.subLocationFields,
|
|
573
|
+
[key]: {
|
|
574
|
+
...(s.subLocationFields[key] ?? {}),
|
|
575
|
+
[field.name]: v,
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
}));
|
|
579
|
+
}}
|
|
580
|
+
style={{ width: '100%' }}
|
|
581
|
+
/>
|
|
582
|
+
</td>
|
|
583
|
+
);
|
|
584
|
+
})}
|
|
585
|
+
</tr>
|
|
586
|
+
))}
|
|
587
|
+
</tbody>
|
|
588
|
+
</table>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
)}
|
|
592
|
+
|
|
289
593
|
{/* Help text + match indicator. Updates as the operator
|
|
290
594
|
picks a role so they see exactly which test methods
|
|
291
595
|
will pick up the asset they're about to register. */}
|
|
@@ -37,6 +37,18 @@ interface FieldSpec {
|
|
|
37
37
|
values?: string[]; // for enum
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Keyed-fields sub_locations schema mirroring the server's
|
|
41
|
+
* `SubLocationsSchema`. When present and `calibration_fields` is
|
|
42
|
+
* non-empty, the dialog renders a per-axis grid instead of the flat
|
|
43
|
+
* field list, and `values` posts as `{ <key>: { <field>: ... } }`. */
|
|
44
|
+
interface SubLocationsSchema {
|
|
45
|
+
label?: string;
|
|
46
|
+
key_label?: string;
|
|
47
|
+
keys: string[];
|
|
48
|
+
fields?: FieldSpec[];
|
|
49
|
+
calibration_fields?: FieldSpec[];
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
41
53
|
visible, assetId, assetType, onHide, onAdded,
|
|
42
54
|
}) => {
|
|
@@ -45,7 +57,23 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
45
57
|
|
|
46
58
|
const fields: FieldSpec[] = (schemas[assetType]?.calibration_fields ?? []) as FieldSpec[];
|
|
47
59
|
|
|
60
|
+
// Per-axis schema if this asset_type declares one. When set AND
|
|
61
|
+
// its calibration_fields is non-empty, we switch to the matrix UI.
|
|
62
|
+
const subSchema: SubLocationsSchema | null = (() => {
|
|
63
|
+
const sl = schemas[assetType]?.sub_locations;
|
|
64
|
+
if (!sl || !Array.isArray(sl.keys)) return null;
|
|
65
|
+
return sl as SubLocationsSchema;
|
|
66
|
+
})();
|
|
67
|
+
const perAxisFields: FieldSpec[] = subSchema?.calibration_fields ?? [];
|
|
68
|
+
const isPerAxis = perAxisFields.length > 0;
|
|
69
|
+
|
|
70
|
+
// Two value shapes: flat (existing) for single-cell types, and
|
|
71
|
+
// matrix for per-axis types. We hold them separately so toggling
|
|
72
|
+
// `isPerAxis` doesn't crosstalk.
|
|
48
73
|
const [values, setValues] = useState<{ [k: string]: any }>({});
|
|
74
|
+
const [perAxisValues, setPerAxisValues] = useState<{
|
|
75
|
+
[key: string]: { [field: string]: any }
|
|
76
|
+
}>({});
|
|
49
77
|
const [performedBy, setPerformedBy] = useState('');
|
|
50
78
|
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
|
51
79
|
const [certRef, setCertRef] = useState('');
|
|
@@ -56,6 +84,7 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
56
84
|
useEffect(() => {
|
|
57
85
|
if (visible) {
|
|
58
86
|
setValues({});
|
|
87
|
+
setPerAxisValues({});
|
|
59
88
|
setPerformedBy('');
|
|
60
89
|
setExpiresAt(null);
|
|
61
90
|
setCertRef('');
|
|
@@ -118,13 +147,32 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
118
147
|
};
|
|
119
148
|
|
|
120
149
|
const onSubmit = async () => {
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
// Required-field check. Per-axis mode walks the matrix; flat
|
|
151
|
+
// mode walks the existing schema fields.
|
|
152
|
+
if (isPerAxis && subSchema) {
|
|
153
|
+
for (const key of subSchema.keys) {
|
|
154
|
+
for (const f of perAxisFields) {
|
|
155
|
+
if (!f.required) continue;
|
|
156
|
+
const v = perAxisValues[key]?.[f.name];
|
|
157
|
+
if (v === undefined || v === null || v === '') {
|
|
158
|
+
setError(`Field "${f.label ?? f.name}" on axis "${key}" is required.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
for (const f of fields) {
|
|
165
|
+
if (f.required && (values[f.name] === undefined || values[f.name] === null || values[f.name] === '')) {
|
|
166
|
+
setError(`Field "${f.label ?? f.name}" is required.`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
126
169
|
}
|
|
127
170
|
}
|
|
171
|
+
|
|
172
|
+
// Build the values payload. Per-axis mode posts the matrix
|
|
173
|
+
// shape; flat mode posts the existing single-cell shape.
|
|
174
|
+
const valuesPayload: any = isPerAxis ? perAxisValues : values;
|
|
175
|
+
|
|
128
176
|
setSubmitting(true);
|
|
129
177
|
setError(null);
|
|
130
178
|
try {
|
|
@@ -134,7 +182,7 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
134
182
|
expires_at: expiresAt ? expiresAt.toISOString() : null,
|
|
135
183
|
cert_ref: certRef,
|
|
136
184
|
notes,
|
|
137
|
-
values,
|
|
185
|
+
values: valuesPayload,
|
|
138
186
|
} as any);
|
|
139
187
|
if (resp?.success) {
|
|
140
188
|
onAdded?.(resp.data.cal_id);
|
|
@@ -162,30 +210,115 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
162
210
|
</>
|
|
163
211
|
}
|
|
164
212
|
>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
213
|
+
{isPerAxis && subSchema ? (
|
|
214
|
+
<div>
|
|
215
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>
|
|
216
|
+
{subSchema.label ?? 'Per-axis calibration'}
|
|
217
|
+
</h4>
|
|
218
|
+
<div style={{ overflowX: 'auto', marginBottom: '1rem' }}>
|
|
219
|
+
<table style={{ width: '100%', borderCollapse: 'collapse',
|
|
220
|
+
fontSize: '0.875rem' }}>
|
|
221
|
+
<thead>
|
|
222
|
+
<tr>
|
|
223
|
+
<th style={{ textAlign: 'left', padding: '0.25rem 0.5rem',
|
|
224
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
225
|
+
{subSchema.key_label ?? 'Key'}
|
|
226
|
+
</th>
|
|
227
|
+
{perAxisFields.map(f => (
|
|
228
|
+
<th key={f.name}
|
|
229
|
+
title={f.description ?? undefined}
|
|
230
|
+
style={{ textAlign: 'left', padding: '0.25rem 0.5rem',
|
|
231
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
232
|
+
{(f.label ?? f.name)}
|
|
233
|
+
{f.units ? ` [${f.units}]` : ''}
|
|
234
|
+
{f.required ? ' *' : ''}
|
|
235
|
+
</th>
|
|
236
|
+
))}
|
|
237
|
+
</tr>
|
|
238
|
+
</thead>
|
|
239
|
+
<tbody>
|
|
240
|
+
{subSchema.keys.map(key => (
|
|
241
|
+
<tr key={key}>
|
|
242
|
+
<td style={{ padding: '0.25rem 0.5rem', fontWeight: 600 }}>
|
|
243
|
+
{key}
|
|
244
|
+
</td>
|
|
245
|
+
{perAxisFields.map(field => {
|
|
246
|
+
const cellValue = perAxisValues[key]?.[field.name];
|
|
247
|
+
const isNum = field.type !== 'string' && field.type !== 'bool';
|
|
248
|
+
return (
|
|
249
|
+
<td key={field.name} style={{ padding: '0.25rem 0.5rem' }}>
|
|
250
|
+
{isNum ? (
|
|
251
|
+
<InputNumber
|
|
252
|
+
value={cellValue ?? null}
|
|
253
|
+
onValueChange={(e) => {
|
|
254
|
+
setPerAxisValues(v => ({
|
|
255
|
+
...v,
|
|
256
|
+
[key]: { ...(v[key] ?? {}), [field.name]: e.value },
|
|
257
|
+
}));
|
|
258
|
+
}}
|
|
259
|
+
useGrouping={false}
|
|
260
|
+
maxFractionDigits={field.type.startsWith('f') ? 9 : 0}
|
|
261
|
+
inputStyle={{ width: '100%' }}
|
|
262
|
+
/>
|
|
263
|
+
) : (
|
|
264
|
+
<InputText
|
|
265
|
+
value={cellValue ?? ''}
|
|
266
|
+
onChange={(e) => {
|
|
267
|
+
setPerAxisValues(v => ({
|
|
268
|
+
...v,
|
|
269
|
+
[key]: { ...(v[key] ?? {}), [field.name]: e.target.value },
|
|
270
|
+
}));
|
|
271
|
+
}}
|
|
272
|
+
style={{ width: '100%' }}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
275
|
+
</td>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
</tr>
|
|
279
|
+
))}
|
|
280
|
+
</tbody>
|
|
281
|
+
</table>
|
|
282
|
+
</div>
|
|
283
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr',
|
|
284
|
+
gap: '0.5rem 1rem', alignItems: 'center' }}>
|
|
285
|
+
<label>Performed by</label>
|
|
286
|
+
<InputText value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
|
287
|
+
<label>Expires at</label>
|
|
288
|
+
<Calendar value={expiresAt} onChange={(e) => setExpiresAt((e.value as Date) ?? null)}
|
|
289
|
+
showIcon dateFormat="yy-mm-dd" />
|
|
290
|
+
<label>Certificate ref</label>
|
|
291
|
+
<InputText value={certRef} onChange={(e) => setCertRef(e.target.value)} />
|
|
292
|
+
<label>Notes</label>
|
|
293
|
+
<InputText value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
) : (
|
|
297
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1rem', alignItems: 'center' }}>
|
|
298
|
+
{fields.flatMap((f, i) => {
|
|
299
|
+
const [l, inp] = renderField(f);
|
|
300
|
+
return [
|
|
301
|
+
<React.Fragment key={`l-${i}`}>{l}</React.Fragment>,
|
|
302
|
+
<React.Fragment key={`i-${i}`}>{inp}</React.Fragment>,
|
|
303
|
+
];
|
|
304
|
+
})}
|
|
305
|
+
|
|
306
|
+
<hr style={{ gridColumn: '1 / span 2', width: '100%' }} />
|
|
307
|
+
|
|
308
|
+
<label>Performed by</label>
|
|
309
|
+
<InputText value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
|
310
|
+
|
|
311
|
+
<label>Expires at</label>
|
|
312
|
+
<Calendar value={expiresAt} onChange={(e) => setExpiresAt((e.value as Date) ?? null)}
|
|
313
|
+
showIcon dateFormat="yy-mm-dd" />
|
|
314
|
+
|
|
315
|
+
<label>Certificate ref</label>
|
|
316
|
+
<InputText value={certRef} onChange={(e) => setCertRef(e.target.value)} />
|
|
317
|
+
|
|
318
|
+
<label>Notes</label>
|
|
319
|
+
<InputText value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
189
322
|
|
|
190
323
|
{error && (
|
|
191
324
|
<div style={{ marginTop: '1rem', color: '#ef4444' }}>
|