@adcops/autocore-react 3.3.75 → 3.3.79

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