@adcops/autocore-react 3.3.83 → 3.3.85
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/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetEditDialog.d.ts +13 -0
- package/dist/components/ams/AssetEditDialog.d.ts.map +1 -0
- package/dist/components/ams/AssetEditDialog.js +1 -0
- package/dist/components/ams/index.d.ts +1 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/network/NetworkPanel.d.ts.map +1 -1
- package/dist/components/network/NetworkPanel.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +5 -0
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts +15 -1
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/useRawCycleData.d.ts.map +1 -1
- package/dist/components/tis/useRawCycleData.js +1 -1
- package/package.json +1 -1
- package/src/components/ams/AssetDetailView.tsx +31 -0
- package/src/components/ams/AssetEditDialog.tsx +800 -0
- package/src/components/ams/index.ts +1 -0
- package/src/components/network/NetworkPanel.tsx +13 -1
- package/src/components/tis/TestDataView.tsx +80 -16
- package/src/components/tis/TestSetupForm.tsx +60 -6
- package/src/components/tis/useRawCycleData.ts +132 -31
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* <AssetEditDialog> — modal for editing an existing asset.
|
|
3
|
+
*
|
|
4
|
+
* Two tabs:
|
|
5
|
+
* 1. "Asset" — role, nameplate (custom fields), and per-axis
|
|
6
|
+
* sub_locations matrix. Posts via `ams.update_asset`.
|
|
7
|
+
* 2. "Calibration" — performed_by, expires_at, cert_ref, notes, and
|
|
8
|
+
* the calibration values (flat or per-axis). Enabled only when
|
|
9
|
+
* `asset.current_calibration_id` is non-null. Posts via
|
|
10
|
+
* `ams.update_calibration` (server enforces "current only"). Tab
|
|
11
|
+
* is hidden entirely for assets that never had a calibration —
|
|
12
|
+
* "+ Calibration" on <AssetDetailView> is the path to add one.
|
|
13
|
+
*
|
|
14
|
+
* The server treats `asset_id`, `asset_type`, `serial`, and
|
|
15
|
+
* `install_date` as immutable; the read-only header strip mirrors
|
|
16
|
+
* that. Status stays out of this dialog because the Retire button on
|
|
17
|
+
* <AssetDetailView> already owns that transition.
|
|
18
|
+
*
|
|
19
|
+
* Save commits the Asset tab first, then (if a calibration is loaded)
|
|
20
|
+
* the Calibration tab. Either failure surfaces inline and stops; the
|
|
21
|
+
* dialog stays open so the operator can fix the input and retry.
|
|
22
|
+
*
|
|
23
|
+
* Sibling <CalibrationEntryDialog> still covers add-new-calibration
|
|
24
|
+
* and the in-place edit launched from the History table's pencil
|
|
25
|
+
* icon — this dialog overlaps the latter on purpose, since operators
|
|
26
|
+
* naturally expect calibration metadata to live alongside the asset
|
|
27
|
+
* they're editing.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
|
31
|
+
import { Button } from 'primereact/button';
|
|
32
|
+
import { Calendar } from 'primereact/calendar';
|
|
33
|
+
import { Dialog } from 'primereact/dialog';
|
|
34
|
+
import { Dropdown } from 'primereact/dropdown';
|
|
35
|
+
import { InputText } from 'primereact/inputtext';
|
|
36
|
+
import { InputTextarea } from 'primereact/inputtextarea';
|
|
37
|
+
import { TabPanel, TabView } from 'primereact/tabview';
|
|
38
|
+
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
39
|
+
import { MessageType } from '../../hub/CommandMessage';
|
|
40
|
+
import { useAms, type AmsRole } from './AmsProvider';
|
|
41
|
+
|
|
42
|
+
// Sentinel: keeps the Role dropdown consistent with the Add dialog's
|
|
43
|
+
// "Other..." escape hatch so operators can move an asset to a custom
|
|
44
|
+
// role that isn't declared in project.json.
|
|
45
|
+
const ROLE_OTHER = '__other__';
|
|
46
|
+
|
|
47
|
+
interface SchemaField {
|
|
48
|
+
name: string;
|
|
49
|
+
type: string;
|
|
50
|
+
units?: string;
|
|
51
|
+
label?: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
required?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SubLocationsSchema {
|
|
57
|
+
label?: string;
|
|
58
|
+
key_label?: string;
|
|
59
|
+
keys: string[];
|
|
60
|
+
fields: SchemaField[];
|
|
61
|
+
calibration_fields?: SchemaField[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AssetEditDialogProps {
|
|
65
|
+
visible: boolean;
|
|
66
|
+
/** Full asset record as returned by `ams.read_asset`. The dialog
|
|
67
|
+
* pre-seeds every editable input from this; on submit it pins
|
|
68
|
+
* asset_id from here onto the update_asset call. */
|
|
69
|
+
asset: any | null;
|
|
70
|
+
onHide: () => void;
|
|
71
|
+
/** Fires after a successful update so the parent can refresh. */
|
|
72
|
+
onSaved?: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fieldsFor(schemas: any, assetType: string): SchemaField[] {
|
|
76
|
+
const arr = schemas?.[assetType]?.fields;
|
|
77
|
+
return Array.isArray(arr) ? arr as SchemaField[] : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function subLocationsFor(schemas: any, assetType: string): SubLocationsSchema | null {
|
|
81
|
+
const sl = schemas?.[assetType]?.sub_locations;
|
|
82
|
+
if (!sl || !Array.isArray(sl.keys) || !Array.isArray(sl.fields)) return null;
|
|
83
|
+
return sl as SubLocationsSchema;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Flat top-level calibration_fields declared on the asset_type, if any.
|
|
87
|
+
* Empty array when the type uses the per-axis form or declares no
|
|
88
|
+
* calibration values at all. */
|
|
89
|
+
function calibrationFieldsFor(schemas: any, assetType: string): SchemaField[] {
|
|
90
|
+
const arr = schemas?.[assetType]?.calibration_fields;
|
|
91
|
+
return Array.isArray(arr) ? arr as SchemaField[] : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Per-axis calibration_fields declared on the asset_type's
|
|
95
|
+
* sub_locations schema, if any. The asset_type uses *one* of:
|
|
96
|
+
* flat top-level `calibration_fields`, or per-axis
|
|
97
|
+
* `sub_locations.calibration_fields`. We render whichever is
|
|
98
|
+
* non-empty (top-level wins on collision since
|
|
99
|
+
* CalibrationEntryDialog handles them the same way). */
|
|
100
|
+
function perAxisCalibrationFieldsFor(
|
|
101
|
+
sub: SubLocationsSchema | null,
|
|
102
|
+
): SchemaField[] {
|
|
103
|
+
return (sub?.calibration_fields ?? []) as SchemaField[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Coerce a per-cell string back to the field's declared type.
|
|
107
|
+
* Mirrors the Add dialog's helper so empty strings drop out, numbers
|
|
108
|
+
* come back as JSON numbers, bools as bools. */
|
|
109
|
+
function coerceField(field: SchemaField, raw: string): any {
|
|
110
|
+
if (raw === undefined || raw === '') return undefined;
|
|
111
|
+
switch (field.type) {
|
|
112
|
+
case 'f32': case 'f64':
|
|
113
|
+
case 'i8': case 'i16': case 'i32': case 'i64':
|
|
114
|
+
case 'u8': case 'u16': case 'u32': case 'u64': {
|
|
115
|
+
const n = Number(raw);
|
|
116
|
+
return Number.isFinite(n) ? n : undefined;
|
|
117
|
+
}
|
|
118
|
+
case 'bool':
|
|
119
|
+
return raw === 'true' || raw === '1';
|
|
120
|
+
default:
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Default a field's input from whatever's in the asset's custom blob.
|
|
126
|
+
* Numbers / bools serialize back to strings for the text inputs to
|
|
127
|
+
* consume; missing values become "". */
|
|
128
|
+
function seedFromCustom(custom: Record<string, any> | undefined, name: string): string {
|
|
129
|
+
if (!custom) return '';
|
|
130
|
+
const v = custom[name];
|
|
131
|
+
if (v === undefined || v === null) return '';
|
|
132
|
+
return String(v);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
|
|
136
|
+
visible, asset, onHide, onSaved,
|
|
137
|
+
}) => {
|
|
138
|
+
const { schemas, roles, readCalibration } = useAms();
|
|
139
|
+
const { invoke } = useContext(EventEmitterContext);
|
|
140
|
+
|
|
141
|
+
const assetType = asset?.asset_type ?? '';
|
|
142
|
+
const fields = useMemo(() => fieldsFor(schemas, assetType), [schemas, assetType]);
|
|
143
|
+
const subLocationsSchema = useMemo(
|
|
144
|
+
() => subLocationsFor(schemas, assetType), [schemas, assetType],
|
|
145
|
+
);
|
|
146
|
+
const calibrationFields = useMemo(
|
|
147
|
+
() => calibrationFieldsFor(schemas, assetType), [schemas, assetType],
|
|
148
|
+
);
|
|
149
|
+
const perAxisCalibrationFields = useMemo(
|
|
150
|
+
() => perAxisCalibrationFieldsFor(subLocationsSchema), [subLocationsSchema],
|
|
151
|
+
);
|
|
152
|
+
/** Asset has per-axis calibration if its sub_locations schema
|
|
153
|
+
* declares calibration_fields. Otherwise it's flat (or has no
|
|
154
|
+
* cal values at all). Mirrors the rule CalibrationEntryDialog
|
|
155
|
+
* uses to pick its UI mode. */
|
|
156
|
+
const isPerAxisCalibration = perAxisCalibrationFields.length > 0;
|
|
157
|
+
const hasAnyCalibrationFields =
|
|
158
|
+
isPerAxisCalibration || calibrationFields.length > 0;
|
|
159
|
+
const rolesForType: AmsRole[] = useMemo(
|
|
160
|
+
() => (assetType ? roles[assetType] ?? [] : []),
|
|
161
|
+
[assetType, roles],
|
|
162
|
+
);
|
|
163
|
+
const typeHasRoles = rolesForType.length > 0;
|
|
164
|
+
|
|
165
|
+
const roleDropdownOptions = useMemo(() => {
|
|
166
|
+
const opts = rolesForType.map(r => ({
|
|
167
|
+
label: r.label ?? r.location, value: r.location,
|
|
168
|
+
}));
|
|
169
|
+
opts.push({ label: 'Other (advanced — type a custom role)', value: ROLE_OTHER });
|
|
170
|
+
return opts;
|
|
171
|
+
}, [rolesForType]);
|
|
172
|
+
|
|
173
|
+
// Form state — kept local to the dialog so editing one asset
|
|
174
|
+
// doesn't leak into the next open. Seeded inside the `visible`
|
|
175
|
+
// effect below.
|
|
176
|
+
const [roleSelection, setRoleSelection] = useState<string>('');
|
|
177
|
+
const [location, setLocation] = useState<string>('');
|
|
178
|
+
const [customFields, setCustomFields] =
|
|
179
|
+
useState<Record<string, string>>({});
|
|
180
|
+
const [subLocationFields, setSubLocationFields] =
|
|
181
|
+
useState<Record<string, Record<string, string>>>({});
|
|
182
|
+
const [submitting, setSubmitting] = useState(false);
|
|
183
|
+
const [error, setError] = useState<string | null>(null);
|
|
184
|
+
|
|
185
|
+
// Active tab. Defaults to the Asset tab on open. Stored in state
|
|
186
|
+
// so a parent re-render doesn't reset it while the operator is
|
|
187
|
+
// mid-edit on the Calibration tab.
|
|
188
|
+
const [activeTab, setActiveTab] = useState<number>(0);
|
|
189
|
+
|
|
190
|
+
// ── Calibration tab state ────────────────────────────────────────
|
|
191
|
+
// Loaded from `ams.read_calibration` when the dialog opens and the
|
|
192
|
+
// asset has a current calibration. `null` means "no current cal —
|
|
193
|
+
// tab is hidden." The operator can edit the meta fields and the
|
|
194
|
+
// values; on Save we POST `ams.update_calibration` with the
|
|
195
|
+
// current cal_id pinned. The server enforces that this matches
|
|
196
|
+
// the asset's current_calibration_id, so the tab only ever edits
|
|
197
|
+
// the cal currently in effect — older history is frozen.
|
|
198
|
+
const [calRecord, setCalRecord] = useState<any | null>(null);
|
|
199
|
+
const [calPerformedBy, setCalPerformedBy] = useState<string>('');
|
|
200
|
+
const [calExpiresAt, setCalExpiresAt] = useState<Date | null>(null);
|
|
201
|
+
const [calCertRef, setCalCertRef] = useState<string>('');
|
|
202
|
+
const [calNotes, setCalNotes] = useState<string>('');
|
|
203
|
+
/** Flat-schema cal values, e.g. `{ scale: 9.81, offset: 0 }`. */
|
|
204
|
+
const [calValues, setCalValues] = useState<Record<string, string>>({});
|
|
205
|
+
/** Per-axis cal matrix, `{ fx: { scale: "9.81", offset: "0" }, ... }`. */
|
|
206
|
+
const [calPerAxisValues, setCalPerAxisValues] =
|
|
207
|
+
useState<Record<string, Record<string, string>>>({});
|
|
208
|
+
|
|
209
|
+
// Seed every input from the asset when the dialog opens. Re-seeds
|
|
210
|
+
// on every open so editing one asset, cancelling, then opening
|
|
211
|
+
// another doesn't carry the previous values forward.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!visible || !asset) return;
|
|
214
|
+
setError(null);
|
|
215
|
+
setSubmitting(false);
|
|
216
|
+
setActiveTab(0);
|
|
217
|
+
|
|
218
|
+
// Role: pick the dropdown option when the asset's location
|
|
219
|
+
// matches a declared role; otherwise route into ROLE_OTHER so
|
|
220
|
+
// the operator can keep the current custom string.
|
|
221
|
+
const loc = typeof asset.location === 'string' ? asset.location : '';
|
|
222
|
+
const declaredHit = rolesForType.find(r => r.location === loc);
|
|
223
|
+
if (loc === '') {
|
|
224
|
+
setRoleSelection('');
|
|
225
|
+
setLocation('');
|
|
226
|
+
} else if (declaredHit) {
|
|
227
|
+
setRoleSelection(loc);
|
|
228
|
+
setLocation(loc);
|
|
229
|
+
} else {
|
|
230
|
+
setRoleSelection(ROLE_OTHER);
|
|
231
|
+
setLocation(loc);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Nameplate: stringify each declared field's current value.
|
|
235
|
+
const seededCustom: Record<string, string> = {};
|
|
236
|
+
for (const f of fields) {
|
|
237
|
+
seededCustom[f.name] = seedFromCustom(asset.custom, f.name);
|
|
238
|
+
}
|
|
239
|
+
setCustomFields(seededCustom);
|
|
240
|
+
|
|
241
|
+
// Per-axis matrix: same treatment, walking each declared key
|
|
242
|
+
// and each declared field.
|
|
243
|
+
const seededSub: Record<string, Record<string, string>> = {};
|
|
244
|
+
if (subLocationsSchema) {
|
|
245
|
+
const subSrc = (asset.sub_locations && typeof asset.sub_locations === 'object')
|
|
246
|
+
? asset.sub_locations as Record<string, any> : {};
|
|
247
|
+
for (const key of subLocationsSchema.keys) {
|
|
248
|
+
const row = (subSrc[key] && typeof subSrc[key] === 'object')
|
|
249
|
+
? subSrc[key] as Record<string, any> : {};
|
|
250
|
+
const seededRow: Record<string, string> = {};
|
|
251
|
+
for (const f of subLocationsSchema.fields) {
|
|
252
|
+
const v = row[f.name];
|
|
253
|
+
seededRow[f.name] = (v === undefined || v === null) ? '' : String(v);
|
|
254
|
+
}
|
|
255
|
+
seededSub[key] = seededRow;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
setSubLocationFields(seededSub);
|
|
259
|
+
|
|
260
|
+
// ── Calibration tab: load the current cal record (if any)
|
|
261
|
+
// and seed every input from it. Wiped between opens by the
|
|
262
|
+
// initial setters below — handles "asset has no cal" and
|
|
263
|
+
// "edit a different asset" cleanly without leftover state.
|
|
264
|
+
setCalRecord(null);
|
|
265
|
+
setCalPerformedBy('');
|
|
266
|
+
setCalExpiresAt(null);
|
|
267
|
+
setCalCertRef('');
|
|
268
|
+
setCalNotes('');
|
|
269
|
+
setCalValues({});
|
|
270
|
+
setCalPerAxisValues({});
|
|
271
|
+
|
|
272
|
+
const calId = typeof asset.current_calibration_id === 'string'
|
|
273
|
+
? asset.current_calibration_id : '';
|
|
274
|
+
if (!calId) return;
|
|
275
|
+
|
|
276
|
+
let cancelled = false;
|
|
277
|
+
(async () => {
|
|
278
|
+
const rec = await readCalibration(asset.asset_id, calId);
|
|
279
|
+
if (cancelled || !rec) return;
|
|
280
|
+
setCalRecord(rec);
|
|
281
|
+
setCalPerformedBy(typeof rec.performed_by === 'string' ? rec.performed_by : '');
|
|
282
|
+
setCalCertRef(typeof rec.cert_ref === 'string' ? rec.cert_ref : '');
|
|
283
|
+
setCalNotes(typeof rec.notes === 'string' ? rec.notes : '');
|
|
284
|
+
setCalExpiresAt(rec.expires_at ? new Date(rec.expires_at) : null);
|
|
285
|
+
|
|
286
|
+
// Cal values come in the same shape we render. For a
|
|
287
|
+
// per-axis schema, we expect `values.<key>.<field>`; for
|
|
288
|
+
// a flat schema, `values.<field>` directly.
|
|
289
|
+
const values = (rec.values && typeof rec.values === 'object')
|
|
290
|
+
? rec.values as Record<string, any> : {};
|
|
291
|
+
if (isPerAxisCalibration && subLocationsSchema) {
|
|
292
|
+
const seededPa: Record<string, Record<string, string>> = {};
|
|
293
|
+
for (const key of subLocationsSchema.keys) {
|
|
294
|
+
const row = (values[key] && typeof values[key] === 'object')
|
|
295
|
+
? values[key] as Record<string, any> : {};
|
|
296
|
+
const r: Record<string, string> = {};
|
|
297
|
+
for (const f of perAxisCalibrationFields) {
|
|
298
|
+
const v = row[f.name];
|
|
299
|
+
r[f.name] = (v === undefined || v === null) ? '' : String(v);
|
|
300
|
+
}
|
|
301
|
+
seededPa[key] = r;
|
|
302
|
+
}
|
|
303
|
+
setCalPerAxisValues(seededPa);
|
|
304
|
+
} else {
|
|
305
|
+
const seededFlat: Record<string, string> = {};
|
|
306
|
+
for (const f of calibrationFields) {
|
|
307
|
+
const v = values[f.name];
|
|
308
|
+
seededFlat[f.name] = (v === undefined || v === null) ? '' : String(v);
|
|
309
|
+
}
|
|
310
|
+
setCalValues(seededFlat);
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
return () => { cancelled = true; };
|
|
314
|
+
}, [
|
|
315
|
+
visible, asset, fields, subLocationsSchema, rolesForType,
|
|
316
|
+
calibrationFields, perAxisCalibrationFields, isPerAxisCalibration,
|
|
317
|
+
readCalibration,
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const onRoleChange = (value: string) => {
|
|
321
|
+
if (value === ROLE_OTHER) {
|
|
322
|
+
setRoleSelection(ROLE_OTHER);
|
|
323
|
+
// Keep whatever string is currently in `location` so the
|
|
324
|
+
// operator can tweak it rather than retyping.
|
|
325
|
+
} else {
|
|
326
|
+
setRoleSelection(value);
|
|
327
|
+
setLocation(value);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const onSubmit = async () => {
|
|
332
|
+
if (!asset?.asset_id) return;
|
|
333
|
+
setSubmitting(true);
|
|
334
|
+
setError(null);
|
|
335
|
+
|
|
336
|
+
// Build `custom` from the current input map, coercing each
|
|
337
|
+
// value back to its declared JSON type. Empty inputs drop out
|
|
338
|
+
// so the asset.json doesn't carry empty strings that the
|
|
339
|
+
// placeholder resolver would treat as "missing".
|
|
340
|
+
const custom: Record<string, any> = {};
|
|
341
|
+
for (const field of fields) {
|
|
342
|
+
const raw = customFields[field.name];
|
|
343
|
+
const coerced = coerceField(field, raw ?? '');
|
|
344
|
+
if (coerced !== undefined) custom[field.name] = coerced;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Per-axis matrix: only included when the asset_type declares
|
|
348
|
+
// a keyed-fields schema. Empty cells drop out per-axis; the
|
|
349
|
+
// server re-validates with a per-key, per-field problem list
|
|
350
|
+
// and we surface that error inline if anything's wrong.
|
|
351
|
+
let subLocations: Record<string, Record<string, any>> | undefined;
|
|
352
|
+
if (subLocationsSchema) {
|
|
353
|
+
subLocations = {};
|
|
354
|
+
for (const key of subLocationsSchema.keys) {
|
|
355
|
+
const row: Record<string, any> = {};
|
|
356
|
+
for (const field of subLocationsSchema.fields) {
|
|
357
|
+
const raw = subLocationFields[key]?.[field.name] ?? '';
|
|
358
|
+
const coerced = coerceField(field, raw);
|
|
359
|
+
if (coerced !== undefined) row[field.name] = coerced;
|
|
360
|
+
}
|
|
361
|
+
subLocations[key] = row;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const payload: any = {
|
|
366
|
+
asset_id: asset.asset_id,
|
|
367
|
+
location,
|
|
368
|
+
custom,
|
|
369
|
+
};
|
|
370
|
+
if (subLocations) payload.sub_locations = subLocations;
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const resp: any = await invoke(
|
|
374
|
+
'ams.update_asset' as any, MessageType.Request, payload,
|
|
375
|
+
);
|
|
376
|
+
if (!resp?.success) {
|
|
377
|
+
setError(resp?.error_message ?? 'update_asset failed');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// If a calibration is loaded, save it too. Sequential is
|
|
382
|
+
// intentional — if the asset update succeeded but cal
|
|
383
|
+
// failed, the operator should retry the cal alone rather
|
|
384
|
+
// than re-submit the asset. The server enforces "cal_id
|
|
385
|
+
// must equal current_calibration_id", which guards against
|
|
386
|
+
// a stale dialog editing a cal that's since been replaced.
|
|
387
|
+
if (calRecord) {
|
|
388
|
+
const calPayload: any = {
|
|
389
|
+
asset_id: asset.asset_id,
|
|
390
|
+
cal_id: calRecord.cal_id,
|
|
391
|
+
performed_by: calPerformedBy,
|
|
392
|
+
expires_at: calExpiresAt ? calExpiresAt.toISOString() : null,
|
|
393
|
+
cert_ref: calCertRef,
|
|
394
|
+
notes: calNotes,
|
|
395
|
+
values: calValuesPayload(),
|
|
396
|
+
};
|
|
397
|
+
const calResp: any = await invoke(
|
|
398
|
+
'ams.update_calibration' as any, MessageType.Request, calPayload,
|
|
399
|
+
);
|
|
400
|
+
if (!calResp?.success) {
|
|
401
|
+
setError(calResp?.error_message ?? 'update_calibration failed');
|
|
402
|
+
// Switch the operator to the Calibration tab so
|
|
403
|
+
// they see the inputs that the error refers to.
|
|
404
|
+
setActiveTab(1);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
onSaved?.();
|
|
410
|
+
onHide();
|
|
411
|
+
} catch (e: any) {
|
|
412
|
+
setError(String(e?.message ?? e));
|
|
413
|
+
} finally {
|
|
414
|
+
setSubmitting(false);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/** Build the `values` field for `ams.update_calibration`. Picks
|
|
419
|
+
* the per-axis matrix or the flat map based on the asset_type's
|
|
420
|
+
* schema; cells with empty input drop out so the on-disk record
|
|
421
|
+
* doesn't accumulate empty strings. */
|
|
422
|
+
const calValuesPayload = (): any => {
|
|
423
|
+
if (isPerAxisCalibration && subLocationsSchema) {
|
|
424
|
+
const out: Record<string, Record<string, any>> = {};
|
|
425
|
+
for (const key of subLocationsSchema.keys) {
|
|
426
|
+
const row: Record<string, any> = {};
|
|
427
|
+
for (const f of perAxisCalibrationFields) {
|
|
428
|
+
const raw = calPerAxisValues[key]?.[f.name] ?? '';
|
|
429
|
+
const coerced = coerceField(f, raw);
|
|
430
|
+
if (coerced !== undefined) row[f.name] = coerced;
|
|
431
|
+
}
|
|
432
|
+
out[key] = row;
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
const out: Record<string, any> = {};
|
|
437
|
+
for (const f of calibrationFields) {
|
|
438
|
+
const raw = calValues[f.name];
|
|
439
|
+
const coerced = coerceField(f, raw ?? '');
|
|
440
|
+
if (coerced !== undefined) out[f.name] = coerced;
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Gate Save on the same required-field rules the Add dialog uses,
|
|
446
|
+
// applied to the *current* (possibly edited) values. The server
|
|
447
|
+
// re-validates and we surface its error if anything slips through.
|
|
448
|
+
const saveDisabled =
|
|
449
|
+
!asset?.asset_id ||
|
|
450
|
+
submitting ||
|
|
451
|
+
(typeHasRoles && (
|
|
452
|
+
roleSelection === '' ||
|
|
453
|
+
(roleSelection === ROLE_OTHER && !location.trim())
|
|
454
|
+
)) ||
|
|
455
|
+
fields.some(f =>
|
|
456
|
+
f.required && !(customFields[f.name]?.toString().trim()),
|
|
457
|
+
) ||
|
|
458
|
+
(subLocationsSchema?.keys.some(key =>
|
|
459
|
+
subLocationsSchema.fields.some(field =>
|
|
460
|
+
field.required &&
|
|
461
|
+
!(subLocationFields[key]?.[field.name]?.toString().trim()),
|
|
462
|
+
),
|
|
463
|
+
) ?? false);
|
|
464
|
+
|
|
465
|
+
if (!asset) return null;
|
|
466
|
+
const typeLabel = schemas[assetType]?.label ?? assetType;
|
|
467
|
+
|
|
468
|
+
return (
|
|
469
|
+
<Dialog
|
|
470
|
+
header={`Edit Asset — ${asset.asset_id}`}
|
|
471
|
+
visible={visible}
|
|
472
|
+
style={{ width: '32rem' }}
|
|
473
|
+
onHide={() => { if (!submitting) onHide(); }}
|
|
474
|
+
footer={
|
|
475
|
+
<>
|
|
476
|
+
<Button label="Cancel" severity="secondary"
|
|
477
|
+
onClick={onHide} disabled={submitting} />
|
|
478
|
+
<Button label="Save" icon="pi pi-check"
|
|
479
|
+
onClick={onSubmit} loading={submitting}
|
|
480
|
+
disabled={saveDisabled} />
|
|
481
|
+
</>
|
|
482
|
+
}
|
|
483
|
+
>
|
|
484
|
+
{/* Read-only context strip — type, serial, install_date.
|
|
485
|
+
The server treats these as immutable; surfacing them
|
|
486
|
+
here keeps the operator oriented without inviting an
|
|
487
|
+
edit that would silently no-op. */}
|
|
488
|
+
<div style={{ display: 'grid',
|
|
489
|
+
gridTemplateColumns: 'auto 1fr',
|
|
490
|
+
gap: '0.25rem 1rem',
|
|
491
|
+
fontSize: '0.875rem',
|
|
492
|
+
color: 'var(--text-secondary-color)',
|
|
493
|
+
marginBottom: '0.75rem' }}>
|
|
494
|
+
<span>Type</span> <span>{typeLabel}</span>
|
|
495
|
+
<span>Serial</span> <span>{asset.serial || <em>(none)</em>}</span>
|
|
496
|
+
{asset.install_date && (
|
|
497
|
+
<>
|
|
498
|
+
<span>Installed</span>
|
|
499
|
+
<span>{new Date(asset.install_date).toLocaleString()}</span>
|
|
500
|
+
</>
|
|
501
|
+
)}
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<TabView activeIndex={activeTab} onTabChange={(e) => setActiveTab(e.index)}>
|
|
505
|
+
<TabPanel header="Asset">
|
|
506
|
+
<div style={{ display: 'grid',
|
|
507
|
+
gridTemplateColumns: 'auto 1fr',
|
|
508
|
+
gap: '0.5rem 1rem',
|
|
509
|
+
alignItems: 'center' }}>
|
|
510
|
+
{/* Role field. Asset types referenced only by_id_field
|
|
511
|
+
(no by_location asset_ref) come back with an empty
|
|
512
|
+
role list — we hide the row entirely so the operator
|
|
513
|
+
doesn't try to set a role that no test method or
|
|
514
|
+
module will pick up. */}
|
|
515
|
+
{typeHasRoles && (
|
|
516
|
+
<>
|
|
517
|
+
<label>Role *</label>
|
|
518
|
+
<Dropdown
|
|
519
|
+
value={roleSelection}
|
|
520
|
+
options={roleDropdownOptions}
|
|
521
|
+
onChange={(e) => onRoleChange(e.value)}
|
|
522
|
+
placeholder="Choose where this asset is mounted"
|
|
523
|
+
/>
|
|
524
|
+
{roleSelection === ROLE_OTHER && (
|
|
525
|
+
<>
|
|
526
|
+
<label>Custom role</label>
|
|
527
|
+
<InputText
|
|
528
|
+
value={location}
|
|
529
|
+
placeholder="e.g. tsdr_secondary"
|
|
530
|
+
onChange={(e) => setLocation(e.target.value)}
|
|
531
|
+
/>
|
|
532
|
+
</>
|
|
533
|
+
)}
|
|
534
|
+
</>
|
|
535
|
+
)}
|
|
536
|
+
|
|
537
|
+
{/* Schema-declared nameplate fields. */}
|
|
538
|
+
{fields.map(field => {
|
|
539
|
+
const label = field.label ?? field.name;
|
|
540
|
+
const fullLabel = field.units
|
|
541
|
+
? `${label} [${field.units}]${field.required ? ' *' : ''}`
|
|
542
|
+
: `${label}${field.required ? ' *' : ''}`;
|
|
543
|
+
const isNum = field.type !== 'string' && field.type !== 'bool';
|
|
544
|
+
return (
|
|
545
|
+
<React.Fragment key={field.name}>
|
|
546
|
+
<label title={field.description ?? undefined}>
|
|
547
|
+
{fullLabel}
|
|
548
|
+
</label>
|
|
549
|
+
{field.type === 'bool' ? (
|
|
550
|
+
<Dropdown
|
|
551
|
+
value={customFields[field.name] ?? ''}
|
|
552
|
+
options={[
|
|
553
|
+
{ label: 'true', value: 'true' },
|
|
554
|
+
{ label: 'false', value: 'false' },
|
|
555
|
+
]}
|
|
556
|
+
onChange={(e) => setCustomFields(s => ({
|
|
557
|
+
...s, [field.name]: e.value,
|
|
558
|
+
}))}
|
|
559
|
+
placeholder="—"
|
|
560
|
+
/>
|
|
561
|
+
) : (
|
|
562
|
+
<InputText
|
|
563
|
+
value={customFields[field.name] ?? ''}
|
|
564
|
+
keyfilter={isNum ? 'num' : undefined}
|
|
565
|
+
placeholder={field.description ?? undefined}
|
|
566
|
+
onChange={(e) => setCustomFields(s => ({
|
|
567
|
+
...s, [field.name]: e.target.value,
|
|
568
|
+
}))}
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
571
|
+
</React.Fragment>
|
|
572
|
+
);
|
|
573
|
+
})}
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
{/* Per-axis matrix (multi-axis transducer types etc.) */}
|
|
577
|
+
{subLocationsSchema && (
|
|
578
|
+
<div style={{ marginTop: '1.5rem' }}>
|
|
579
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>
|
|
580
|
+
{subLocationsSchema.label ?? 'Sub-locations'}
|
|
581
|
+
</h4>
|
|
582
|
+
<div style={{ overflowX: 'auto' }}>
|
|
583
|
+
<table style={{ width: '100%', borderCollapse: 'collapse',
|
|
584
|
+
fontSize: '0.875rem' }}>
|
|
585
|
+
<thead>
|
|
586
|
+
<tr>
|
|
587
|
+
<th style={{ textAlign: 'left', padding: '0.25rem 0.5rem',
|
|
588
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
589
|
+
{subLocationsSchema.key_label ?? 'Key'}
|
|
590
|
+
</th>
|
|
591
|
+
{subLocationsSchema.fields.map(f => (
|
|
592
|
+
<th key={f.name}
|
|
593
|
+
title={f.description ?? undefined}
|
|
594
|
+
style={{ textAlign: 'left',
|
|
595
|
+
padding: '0.25rem 0.5rem',
|
|
596
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
597
|
+
{(f.label ?? f.name)}
|
|
598
|
+
{f.units ? ` [${f.units}]` : ''}
|
|
599
|
+
{f.required ? ' *' : ''}
|
|
600
|
+
</th>
|
|
601
|
+
))}
|
|
602
|
+
</tr>
|
|
603
|
+
</thead>
|
|
604
|
+
<tbody>
|
|
605
|
+
{subLocationsSchema.keys.map(key => (
|
|
606
|
+
<tr key={key}>
|
|
607
|
+
<td style={{ padding: '0.25rem 0.5rem', fontWeight: 600 }}>
|
|
608
|
+
{key}
|
|
609
|
+
</td>
|
|
610
|
+
{subLocationsSchema.fields.map(field => {
|
|
611
|
+
const cellValue =
|
|
612
|
+
subLocationFields[key]?.[field.name] ?? '';
|
|
613
|
+
const isNum =
|
|
614
|
+
field.type !== 'string' && field.type !== 'bool';
|
|
615
|
+
return (
|
|
616
|
+
<td key={field.name}
|
|
617
|
+
style={{ padding: '0.25rem 0.5rem' }}>
|
|
618
|
+
<InputText
|
|
619
|
+
value={cellValue}
|
|
620
|
+
keyfilter={isNum ? 'num' : undefined}
|
|
621
|
+
onChange={(e) => {
|
|
622
|
+
const v = e.target.value;
|
|
623
|
+
setSubLocationFields(s => ({
|
|
624
|
+
...s,
|
|
625
|
+
[key]: {
|
|
626
|
+
...(s[key] ?? {}),
|
|
627
|
+
[field.name]: v,
|
|
628
|
+
},
|
|
629
|
+
}));
|
|
630
|
+
}}
|
|
631
|
+
style={{ width: '100%' }}
|
|
632
|
+
/>
|
|
633
|
+
</td>
|
|
634
|
+
);
|
|
635
|
+
})}
|
|
636
|
+
</tr>
|
|
637
|
+
))}
|
|
638
|
+
</tbody>
|
|
639
|
+
</table>
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
</TabPanel>
|
|
644
|
+
|
|
645
|
+
{/* Calibration tab — present only when the asset has
|
|
646
|
+
a current calibration. We hide the tab entirely
|
|
647
|
+
rather than showing it disabled because operators
|
|
648
|
+
asked for the affordance to be visually obvious
|
|
649
|
+
when it's available, not a permanent grey tease
|
|
650
|
+
of "you can't edit this yet". Adding a first cal
|
|
651
|
+
flows through `+ Calibration` on the parent. */}
|
|
652
|
+
{calRecord && (
|
|
653
|
+
<TabPanel header={`Calibration (${calRecord.cal_id})`}>
|
|
654
|
+
{/* Meta fields — performed_by, expires_at,
|
|
655
|
+
cert_ref, notes. Same shape as the entry
|
|
656
|
+
dialog so operators see consistent inputs
|
|
657
|
+
either way they reach the cal form. */}
|
|
658
|
+
<div style={{ display: 'grid',
|
|
659
|
+
gridTemplateColumns: 'auto 1fr',
|
|
660
|
+
gap: '0.5rem 1rem',
|
|
661
|
+
alignItems: 'center' }}>
|
|
662
|
+
<label>Performed by</label>
|
|
663
|
+
<InputText value={calPerformedBy}
|
|
664
|
+
onChange={(e) => setCalPerformedBy(e.target.value)} />
|
|
665
|
+
|
|
666
|
+
<label>Expires at</label>
|
|
667
|
+
<Calendar value={calExpiresAt}
|
|
668
|
+
onChange={(e) => setCalExpiresAt((e.value as Date) ?? null)}
|
|
669
|
+
showIcon dateFormat="yy-mm-dd" />
|
|
670
|
+
|
|
671
|
+
<label>Certificate ref</label>
|
|
672
|
+
<InputText value={calCertRef}
|
|
673
|
+
onChange={(e) => setCalCertRef(e.target.value)}
|
|
674
|
+
placeholder="e.g. ADC-CAL-2026-1043"
|
|
675
|
+
/>
|
|
676
|
+
|
|
677
|
+
<label>Notes</label>
|
|
678
|
+
<InputTextarea value={calNotes} rows={3}
|
|
679
|
+
onChange={(e) => setCalNotes(e.target.value)}
|
|
680
|
+
autoResize />
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
{/* Cal values. Per-axis when the schema
|
|
684
|
+
declares them; otherwise the flat field
|
|
685
|
+
list. Skipped entirely when the asset_type
|
|
686
|
+
declares no calibration_fields — the meta
|
|
687
|
+
fields above still let the operator fix
|
|
688
|
+
cert_ref / notes / expiry on a values-less
|
|
689
|
+
calibration. */}
|
|
690
|
+
{hasAnyCalibrationFields && (
|
|
691
|
+
<div style={{ marginTop: '1rem' }}>
|
|
692
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>
|
|
693
|
+
{isPerAxisCalibration
|
|
694
|
+
? (subLocationsSchema?.label ?? 'Per-axis calibration')
|
|
695
|
+
: 'Calibration values'}
|
|
696
|
+
</h4>
|
|
697
|
+
{isPerAxisCalibration && subLocationsSchema ? (
|
|
698
|
+
<div style={{ overflowX: 'auto' }}>
|
|
699
|
+
<table style={{ width: '100%', borderCollapse: 'collapse',
|
|
700
|
+
fontSize: '0.875rem' }}>
|
|
701
|
+
<thead>
|
|
702
|
+
<tr>
|
|
703
|
+
<th style={{ textAlign: 'left',
|
|
704
|
+
padding: '0.25rem 0.5rem',
|
|
705
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
706
|
+
{subLocationsSchema.key_label ?? 'Key'}
|
|
707
|
+
</th>
|
|
708
|
+
{perAxisCalibrationFields.map(f => (
|
|
709
|
+
<th key={f.name}
|
|
710
|
+
title={f.description ?? undefined}
|
|
711
|
+
style={{ textAlign: 'left',
|
|
712
|
+
padding: '0.25rem 0.5rem',
|
|
713
|
+
borderBottom: '1px solid var(--surface-border)' }}>
|
|
714
|
+
{(f.label ?? f.name)}
|
|
715
|
+
{f.units ? ` [${f.units}]` : ''}
|
|
716
|
+
{f.required ? ' *' : ''}
|
|
717
|
+
</th>
|
|
718
|
+
))}
|
|
719
|
+
</tr>
|
|
720
|
+
</thead>
|
|
721
|
+
<tbody>
|
|
722
|
+
{subLocationsSchema.keys.map(key => (
|
|
723
|
+
<tr key={key}>
|
|
724
|
+
<td style={{ padding: '0.25rem 0.5rem', fontWeight: 600 }}>
|
|
725
|
+
{key}
|
|
726
|
+
</td>
|
|
727
|
+
{perAxisCalibrationFields.map(field => {
|
|
728
|
+
const cellValue =
|
|
729
|
+
calPerAxisValues[key]?.[field.name] ?? '';
|
|
730
|
+
const isNum =
|
|
731
|
+
field.type !== 'string' && field.type !== 'bool';
|
|
732
|
+
return (
|
|
733
|
+
<td key={field.name}
|
|
734
|
+
style={{ padding: '0.25rem 0.5rem' }}>
|
|
735
|
+
<InputText
|
|
736
|
+
value={cellValue}
|
|
737
|
+
keyfilter={isNum ? 'num' : undefined}
|
|
738
|
+
onChange={(e) => {
|
|
739
|
+
const v = e.target.value;
|
|
740
|
+
setCalPerAxisValues(s => ({
|
|
741
|
+
...s,
|
|
742
|
+
[key]: {
|
|
743
|
+
...(s[key] ?? {}),
|
|
744
|
+
[field.name]: v,
|
|
745
|
+
},
|
|
746
|
+
}));
|
|
747
|
+
}}
|
|
748
|
+
style={{ width: '100%' }}
|
|
749
|
+
/>
|
|
750
|
+
</td>
|
|
751
|
+
);
|
|
752
|
+
})}
|
|
753
|
+
</tr>
|
|
754
|
+
))}
|
|
755
|
+
</tbody>
|
|
756
|
+
</table>
|
|
757
|
+
</div>
|
|
758
|
+
) : (
|
|
759
|
+
<div style={{ display: 'grid',
|
|
760
|
+
gridTemplateColumns: 'auto 1fr',
|
|
761
|
+
gap: '0.5rem 1rem',
|
|
762
|
+
alignItems: 'center' }}>
|
|
763
|
+
{calibrationFields.map(field => {
|
|
764
|
+
const label = field.label ?? field.name;
|
|
765
|
+
const fullLabel = field.units
|
|
766
|
+
? `${label} [${field.units}]${field.required ? ' *' : ''}`
|
|
767
|
+
: `${label}${field.required ? ' *' : ''}`;
|
|
768
|
+
const isNum = field.type !== 'string'
|
|
769
|
+
&& field.type !== 'bool';
|
|
770
|
+
return (
|
|
771
|
+
<React.Fragment key={field.name}>
|
|
772
|
+
<label title={field.description ?? undefined}>
|
|
773
|
+
{fullLabel}
|
|
774
|
+
</label>
|
|
775
|
+
<InputText
|
|
776
|
+
value={calValues[field.name] ?? ''}
|
|
777
|
+
keyfilter={isNum ? 'num' : undefined}
|
|
778
|
+
onChange={(e) => setCalValues(s => ({
|
|
779
|
+
...s, [field.name]: e.target.value,
|
|
780
|
+
}))}
|
|
781
|
+
/>
|
|
782
|
+
</React.Fragment>
|
|
783
|
+
);
|
|
784
|
+
})}
|
|
785
|
+
</div>
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
)}
|
|
789
|
+
</TabPanel>
|
|
790
|
+
)}
|
|
791
|
+
</TabView>
|
|
792
|
+
|
|
793
|
+
{error && (
|
|
794
|
+
<div style={{ marginTop: '1rem', color: '#ef4444' }}>
|
|
795
|
+
{error}
|
|
796
|
+
</div>
|
|
797
|
+
)}
|
|
798
|
+
</Dialog>
|
|
799
|
+
);
|
|
800
|
+
};
|