@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.
@@ -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
+ };