@adcops/autocore-react 3.3.84 → 3.3.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/components/ValueInput.css +9 -12
  2. package/dist/components/ValueInput.d.ts +45 -154
  3. package/dist/components/ValueInput.d.ts.map +1 -1
  4. package/dist/components/ValueInput.js +1 -1
  5. package/dist/components/ams/AssetEditDialog.d.ts.map +1 -1
  6. package/dist/components/ams/AssetEditDialog.js +1 -1
  7. package/dist/components/forms/FormRow.d.ts +20 -0
  8. package/dist/components/forms/FormRow.d.ts.map +1 -0
  9. package/dist/components/forms/FormRow.js +1 -0
  10. package/dist/components/forms/FormSection.d.ts +19 -0
  11. package/dist/components/forms/FormSection.d.ts.map +1 -0
  12. package/dist/components/forms/FormSection.js +1 -0
  13. package/dist/components/forms/forms.css +89 -0
  14. package/dist/components/forms/index.d.ts +3 -0
  15. package/dist/components/forms/index.d.ts.map +1 -0
  16. package/dist/components/forms/index.js +1 -0
  17. package/dist/components/tis-editor/TisConfigEditor.css +121 -0
  18. package/dist/components/tis-editor/TisConfigEditor.d.ts +28 -0
  19. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -0
  20. package/dist/components/tis-editor/TisConfigEditor.js +1 -0
  21. package/dist/components/tis-editor/editor/AnalysisEditor.d.ts +7 -0
  22. package/dist/components/tis-editor/editor/AnalysisEditor.d.ts.map +1 -0
  23. package/dist/components/tis-editor/editor/AnalysisEditor.js +1 -0
  24. package/dist/components/tis-editor/editor/AssetRefsEditor.d.ts +10 -0
  25. package/dist/components/tis-editor/editor/AssetRefsEditor.d.ts.map +1 -0
  26. package/dist/components/tis-editor/editor/AssetRefsEditor.js +1 -0
  27. package/dist/components/tis-editor/editor/ChartViewDialog.d.ts +16 -0
  28. package/dist/components/tis-editor/editor/ChartViewDialog.d.ts.map +1 -0
  29. package/dist/components/tis-editor/editor/ChartViewDialog.js +1 -0
  30. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts +8 -0
  31. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -0
  32. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -0
  33. package/dist/components/tis-editor/editor/IdentitySection.d.ts +7 -0
  34. package/dist/components/tis-editor/editor/IdentitySection.d.ts.map +1 -0
  35. package/dist/components/tis-editor/editor/IdentitySection.js +1 -0
  36. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts +20 -0
  37. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -0
  38. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -0
  39. package/dist/components/tis-editor/editor/RawDataEditor.d.ts +7 -0
  40. package/dist/components/tis-editor/editor/RawDataEditor.d.ts.map +1 -0
  41. package/dist/components/tis-editor/editor/RawDataEditor.js +1 -0
  42. package/dist/components/tis-editor/editor/SaveDiffDialog.d.ts +22 -0
  43. package/dist/components/tis-editor/editor/SaveDiffDialog.d.ts.map +1 -0
  44. package/dist/components/tis-editor/editor/SaveDiffDialog.js +1 -0
  45. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +11 -0
  46. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -0
  47. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -0
  48. package/dist/components/tis-editor/editor/ViewsEditor.d.ts +7 -0
  49. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -0
  50. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -0
  51. package/dist/components/tis-editor/types.d.ts +78 -0
  52. package/dist/components/tis-editor/types.d.ts.map +1 -0
  53. package/dist/components/tis-editor/types.js +1 -0
  54. package/dist/components/tis-editor/validation.d.ts +20 -0
  55. package/dist/components/tis-editor/validation.d.ts.map +1 -0
  56. package/dist/components/tis-editor/validation.js +1 -0
  57. package/dist/hooks/useAmsAssetTypes.d.ts +23 -0
  58. package/dist/hooks/useAmsAssetTypes.d.ts.map +1 -0
  59. package/dist/hooks/useAmsAssetTypes.js +1 -0
  60. package/dist/hooks/useTisConfig.d.ts +51 -0
  61. package/dist/hooks/useTisConfig.d.ts.map +1 -0
  62. package/dist/hooks/useTisConfig.js +1 -0
  63. package/package.json +9 -3
  64. package/src/components/ValueInput.css +9 -12
  65. package/src/components/ValueInput.tsx +132 -317
  66. package/src/components/ams/AssetEditDialog.tsx +357 -20
  67. package/src/components/forms/FormRow.tsx +37 -0
  68. package/src/components/forms/FormSection.tsx +39 -0
  69. package/src/components/forms/forms.css +89 -0
  70. package/src/components/forms/index.ts +2 -0
  71. package/src/components/tis-editor/TisConfigEditor.css +121 -0
  72. package/src/components/tis-editor/TisConfigEditor.tsx +321 -0
  73. package/src/components/tis-editor/editor/AnalysisEditor.tsx +54 -0
  74. package/src/components/tis-editor/editor/AssetRefsEditor.tsx +187 -0
  75. package/src/components/tis-editor/editor/ChartViewDialog.tsx +170 -0
  76. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +131 -0
  77. package/src/components/tis-editor/editor/IdentitySection.tsx +36 -0
  78. package/src/components/tis-editor/editor/MethodFormEditor.tsx +176 -0
  79. package/src/components/tis-editor/editor/RawDataEditor.tsx +117 -0
  80. package/src/components/tis-editor/editor/SaveDiffDialog.tsx +160 -0
  81. package/src/components/tis-editor/editor/TestFieldDialog.tsx +134 -0
  82. package/src/components/tis-editor/editor/ViewsEditor.tsx +101 -0
  83. package/src/components/tis-editor/types.ts +95 -0
  84. package/src/components/tis-editor/validation.ts +104 -0
  85. package/src/hooks/useAmsAssetTypes.ts +70 -0
  86. package/src/hooks/useTisConfig.ts +164 -0
@@ -1,27 +1,40 @@
1
1
  /*
2
- * <AssetEditDialog> — modal for editing an existing asset's role,
3
- * nameplate (custom fields), and per-axis sub_locations matrix.
2
+ * <AssetEditDialog> — modal for editing an existing asset.
4
3
  *
5
- * The server's `ams.update_asset` treats `asset_id`, `asset_type`,
6
- * `serial`, and `install_date` as immutable; this dialog mirrors that
7
- * by rendering those four as read-only context up top. Status stays
8
- * out of the form because the Retire button on <AssetDetailView>
9
- * already owns that transition keeping the dialog focused on
10
- * "fix the values".
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.
11
13
  *
12
- * Sibling <AssetRegistryTable>'s Add dialog covers creation. The two
13
- * forms have similar shape but enough divergent rules (which fields
14
- * are editable; role becomes a dropdown vs. read-only; etc.) that
15
- * sharing one component would be more confusing than two focused
16
- * dialogs. If we ever add a third mode the right answer is to
17
- * extract a shared <AssetFieldsForm> sub-component then.
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.
18
28
  */
19
29
 
20
30
  import React, { useContext, useEffect, useMemo, useState } from 'react';
21
31
  import { Button } from 'primereact/button';
32
+ import { Calendar } from 'primereact/calendar';
22
33
  import { Dialog } from 'primereact/dialog';
23
34
  import { Dropdown } from 'primereact/dropdown';
24
35
  import { InputText } from 'primereact/inputtext';
36
+ import { InputTextarea } from 'primereact/inputtextarea';
37
+ import { TabPanel, TabView } from 'primereact/tabview';
25
38
  import { EventEmitterContext } from '../../core/EventEmitterContext';
26
39
  import { MessageType } from '../../hub/CommandMessage';
27
40
  import { useAms, type AmsRole } from './AmsProvider';
@@ -70,6 +83,26 @@ function subLocationsFor(schemas: any, assetType: string): SubLocationsSchema |
70
83
  return sl as SubLocationsSchema;
71
84
  }
72
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
+
73
106
  /** Coerce a per-cell string back to the field's declared type.
74
107
  * Mirrors the Add dialog's helper so empty strings drop out, numbers
75
108
  * come back as JSON numbers, bools as bools. */
@@ -102,7 +135,7 @@ function seedFromCustom(custom: Record<string, any> | undefined, name: string):
102
135
  export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
103
136
  visible, asset, onHide, onSaved,
104
137
  }) => {
105
- const { schemas, roles } = useAms();
138
+ const { schemas, roles, readCalibration } = useAms();
106
139
  const { invoke } = useContext(EventEmitterContext);
107
140
 
108
141
  const assetType = asset?.asset_type ?? '';
@@ -110,6 +143,19 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
110
143
  const subLocationsSchema = useMemo(
111
144
  () => subLocationsFor(schemas, assetType), [schemas, assetType],
112
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;
113
159
  const rolesForType: AmsRole[] = useMemo(
114
160
  () => (assetType ? roles[assetType] ?? [] : []),
115
161
  [assetType, roles],
@@ -136,6 +182,30 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
136
182
  const [submitting, setSubmitting] = useState(false);
137
183
  const [error, setError] = useState<string | null>(null);
138
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
+
139
209
  // Seed every input from the asset when the dialog opens. Re-seeds
140
210
  // on every open so editing one asset, cancelling, then opening
141
211
  // another doesn't carry the previous values forward.
@@ -143,6 +213,7 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
143
213
  if (!visible || !asset) return;
144
214
  setError(null);
145
215
  setSubmitting(false);
216
+ setActiveTab(0);
146
217
 
147
218
  // Role: pick the dropdown option when the asset's location
148
219
  // matches a declared role; otherwise route into ROLE_OTHER so
@@ -185,7 +256,66 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
185
256
  }
186
257
  }
187
258
  setSubLocationFields(seededSub);
188
- }, [visible, asset, fields, subLocationsSchema, rolesForType]);
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
+ ]);
189
319
 
190
320
  const onRoleChange = (value: string) => {
191
321
  if (value === ROLE_OTHER) {
@@ -243,12 +373,41 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
243
373
  const resp: any = await invoke(
244
374
  'ams.update_asset' as any, MessageType.Request, payload,
245
375
  );
246
- if (resp?.success) {
247
- onSaved?.();
248
- onHide();
249
- } else {
376
+ if (!resp?.success) {
250
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
+ }
251
407
  }
408
+
409
+ onSaved?.();
410
+ onHide();
252
411
  } catch (e: any) {
253
412
  setError(String(e?.message ?? e));
254
413
  } finally {
@@ -256,6 +415,33 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
256
415
  }
257
416
  };
258
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
+
259
445
  // Gate Save on the same required-field rules the Add dialog uses,
260
446
  // applied to the *current* (possibly edited) values. The server
261
447
  // re-validates and we surface its error if anything slips through.
@@ -315,6 +501,8 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
315
501
  )}
316
502
  </div>
317
503
 
504
+ <TabView activeIndex={activeTab} onTabChange={(e) => setActiveTab(e.index)}>
505
+ <TabPanel header="Asset">
318
506
  <div style={{ display: 'grid',
319
507
  gridTemplateColumns: 'auto 1fr',
320
508
  gap: '0.5rem 1rem',
@@ -452,6 +640,155 @@ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
452
640
  </div>
453
641
  </div>
454
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>
455
792
 
456
793
  {error && (
457
794
  <div style={{ marginTop: '1rem', color: '#ef4444' }}>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * FormRow — grid-aligned label/input pair with optional required marker
3
+ * and inline error text. Drop inside a FormSection.
4
+ */
5
+
6
+ import * as React from 'react';
7
+ import './forms.css';
8
+
9
+ export interface FormRowProps {
10
+ label: React.ReactNode;
11
+ required?: boolean;
12
+ /** Optional descriptive hint shown beneath the label. */
13
+ hint?: React.ReactNode;
14
+ /** Inline error text shown beneath the input. */
15
+ error?: React.ReactNode;
16
+ /** Optional `htmlFor` association on the label tag. */
17
+ htmlFor?: string;
18
+ children?: React.ReactNode;
19
+ }
20
+
21
+ export const FormRow: React.FC<FormRowProps> = ({ label, required, hint, error, htmlFor, children }) => {
22
+ return (
23
+ <div className={`ac-form-row${error ? ' ac-form-row--error' : ''}`}>
24
+ <label className="ac-form-row__label" htmlFor={htmlFor}>
25
+ {label}
26
+ {required && <span className="ac-form-row__required" aria-hidden> *</span>}
27
+ {hint && <small className="ac-form-row__hint">{hint}</small>}
28
+ </label>
29
+ <div className="ac-form-row__field">
30
+ {children}
31
+ {error && <small className="ac-form-row__error">{error}</small>}
32
+ </div>
33
+ </div>
34
+ );
35
+ };
36
+
37
+ export default FormRow;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * FormSection — labeled bordered region containing a group of FormRow rows.
3
+ *
4
+ * Used by the TIS editor subforms and intended for app-level settings
5
+ * pages. Replaces the ad-hoc `.ac-form-grid` divs scattered across project
6
+ * settings views.
7
+ */
8
+
9
+ import * as React from 'react';
10
+ import './forms.css';
11
+
12
+ export interface FormSectionProps {
13
+ title?: React.ReactNode;
14
+ description?: React.ReactNode;
15
+ children?: React.ReactNode;
16
+ /** Right-aligned controls (buttons, dirty pill, etc.). */
17
+ actions?: React.ReactNode;
18
+ }
19
+
20
+ export const FormSection: React.FC<FormSectionProps> = ({ title, description, actions, children }) => {
21
+ return (
22
+ <section className="ac-form-section">
23
+ {(title || actions) && (
24
+ <header className="ac-form-section__header">
25
+ <div>
26
+ {title && <h3 className="ac-form-section__title">{title}</h3>}
27
+ {description && <small className="ac-form-section__desc">{description}</small>}
28
+ </div>
29
+ {actions && <div className="ac-form-section__actions">{actions}</div>}
30
+ </header>
31
+ )}
32
+ <div className="ac-form-section__body">
33
+ {children}
34
+ </div>
35
+ </section>
36
+ );
37
+ };
38
+
39
+ export default FormSection;
@@ -0,0 +1,89 @@
1
+ .ac-form-section {
2
+ border: 1px solid var(--surface-d, #e2e8f0);
3
+ border-radius: 6px;
4
+ background: var(--surface-card, #fff);
5
+ margin-bottom: 1rem;
6
+ }
7
+
8
+ .ac-form-section__header {
9
+ display: flex;
10
+ align-items: flex-start;
11
+ justify-content: space-between;
12
+ padding: 0.5rem 1rem;
13
+ border-bottom: 1px solid var(--surface-d, #e2e8f0);
14
+ background: var(--surface-b, #f8fafc);
15
+ border-top-left-radius: 6px;
16
+ border-top-right-radius: 6px;
17
+ }
18
+
19
+ .ac-form-section__title {
20
+ margin: 0;
21
+ font-size: 0.95rem;
22
+ font-weight: 600;
23
+ }
24
+
25
+ .ac-form-section__desc {
26
+ color: var(--text-color-secondary, #64748b);
27
+ display: block;
28
+ margin-top: 0.15rem;
29
+ }
30
+
31
+ .ac-form-section__actions {
32
+ display: flex;
33
+ gap: 0.5rem;
34
+ }
35
+
36
+ .ac-form-section__body {
37
+ padding: 0.75rem 1rem;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 0.5rem;
41
+ }
42
+
43
+ .ac-form-row {
44
+ display: grid;
45
+ grid-template-columns: minmax(8rem, 14rem) 1fr;
46
+ gap: 0.5rem 1rem;
47
+ align-items: start;
48
+ }
49
+
50
+ .ac-form-row--error .ac-form-row__field input,
51
+ .ac-form-row--error .ac-form-row__field .p-inputtext {
52
+ border-color: #dc2626;
53
+ }
54
+
55
+ .ac-form-row__label {
56
+ font-weight: 500;
57
+ padding-top: 0.4rem;
58
+ display: flex;
59
+ flex-direction: column;
60
+ }
61
+
62
+ .ac-form-row__required {
63
+ color: #dc2626;
64
+ }
65
+
66
+ .ac-form-row__hint {
67
+ color: var(--text-color-secondary, #64748b);
68
+ font-weight: 400;
69
+ font-size: 0.75rem;
70
+ margin-top: 0.15rem;
71
+ }
72
+
73
+ .ac-form-row__field {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 0.25rem;
77
+ }
78
+
79
+ .ac-form-row__field > input,
80
+ .ac-form-row__field > .p-inputtext,
81
+ .ac-form-row__field > .p-dropdown,
82
+ .ac-form-row__field > .p-inputtextarea {
83
+ width: 100%;
84
+ }
85
+
86
+ .ac-form-row__error {
87
+ color: #dc2626;
88
+ font-size: 0.75rem;
89
+ }
@@ -0,0 +1,2 @@
1
+ export { FormSection, type FormSectionProps } from './FormSection';
2
+ export { FormRow, type FormRowProps } from './FormRow';