@adcops/autocore-react 3.3.111 → 3.3.113

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 (61) hide show
  1. package/dist/assets/JogYDownLeft.d.ts +4 -0
  2. package/dist/assets/JogYDownLeft.d.ts.map +1 -0
  3. package/dist/assets/JogYDownLeft.js +1 -0
  4. package/dist/assets/JogYUpRight.d.ts +4 -0
  5. package/dist/assets/JogYUpRight.d.ts.map +1 -0
  6. package/dist/assets/JogYUpRight.js +1 -0
  7. package/dist/assets/index.d.ts +2 -2
  8. package/dist/assets/index.d.ts.map +1 -1
  9. package/dist/assets/index.js +1 -1
  10. package/dist/components/JogPanel.js +1 -1
  11. package/dist/components/ams/AmsProvider.d.ts.map +1 -1
  12. package/dist/components/ams/AmsProvider.js +1 -1
  13. package/dist/components/ams-editor/AssetFieldArray.d.ts +21 -0
  14. package/dist/components/ams-editor/AssetFieldArray.d.ts.map +1 -0
  15. package/dist/components/ams-editor/AssetFieldArray.js +1 -0
  16. package/dist/components/ams-editor/AssetTypeEditor.css +122 -0
  17. package/dist/components/ams-editor/AssetTypeEditor.d.ts +26 -0
  18. package/dist/components/ams-editor/AssetTypeEditor.d.ts.map +1 -0
  19. package/dist/components/ams-editor/AssetTypeEditor.js +1 -0
  20. package/dist/components/ams-editor/AssetTypeFormEditor.d.ts +21 -0
  21. package/dist/components/ams-editor/AssetTypeFormEditor.d.ts.map +1 -0
  22. package/dist/components/ams-editor/AssetTypeFormEditor.js +1 -0
  23. package/dist/components/ams-editor/SubLocationsEditor.d.ts +17 -0
  24. package/dist/components/ams-editor/SubLocationsEditor.d.ts.map +1 -0
  25. package/dist/components/ams-editor/SubLocationsEditor.js +1 -0
  26. package/dist/components/ams-editor/index.d.ts +7 -0
  27. package/dist/components/ams-editor/index.d.ts.map +1 -0
  28. package/dist/components/ams-editor/index.js +1 -0
  29. package/dist/components/ams-editor/types.d.ts +34 -0
  30. package/dist/components/ams-editor/types.d.ts.map +1 -0
  31. package/dist/components/ams-editor/types.js +1 -0
  32. package/dist/components/tis-editor/TisConfigEditor.d.ts +1 -1
  33. package/dist/components/tis-editor/editor/SaveDiffDialog.d.ts.map +1 -1
  34. package/dist/components/tis-editor/editor/SaveDiffDialog.js +1 -1
  35. package/dist/hooks/useAmsSchemaConfig.d.ts +39 -0
  36. package/dist/hooks/useAmsSchemaConfig.d.ts.map +1 -0
  37. package/dist/hooks/useAmsSchemaConfig.js +1 -0
  38. package/dist/hooks/useTisConfig.d.ts +1 -1
  39. package/package.json +1 -1
  40. package/src/assets/{JogYPos.tsx → JogYDownLeft.tsx} +2 -2
  41. package/src/assets/{JogYNeg.tsx → JogYUpRight.tsx} +3 -2
  42. package/src/assets/index.ts +2 -2
  43. package/src/components/JogPanel.tsx +7 -7
  44. package/src/components/ams/AmsProvider.tsx +20 -15
  45. package/src/components/ams-editor/AssetFieldArray.tsx +103 -0
  46. package/src/components/ams-editor/AssetTypeEditor.css +122 -0
  47. package/src/components/ams-editor/AssetTypeEditor.tsx +295 -0
  48. package/src/components/ams-editor/AssetTypeFormEditor.tsx +178 -0
  49. package/src/components/ams-editor/SubLocationsEditor.tsx +151 -0
  50. package/src/components/ams-editor/index.ts +16 -0
  51. package/src/components/ams-editor/types.ts +40 -0
  52. package/src/components/tis-editor/TisConfigEditor.tsx +1 -1
  53. package/src/components/tis-editor/editor/SaveDiffDialog.tsx +4 -3
  54. package/src/hooks/useAmsSchemaConfig.ts +142 -0
  55. package/src/hooks/useTisConfig.ts +1 -1
  56. package/dist/assets/JogYNeg.d.ts +0 -4
  57. package/dist/assets/JogYNeg.d.ts.map +0 -1
  58. package/dist/assets/JogYNeg.js +0 -1
  59. package/dist/assets/JogYPos.d.ts +0 -4
  60. package/dist/assets/JogYPos.d.ts.map +0 -1
  61. package/dist/assets/JogYPos.js +0 -1
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { SVGProps } from "react";
9
9
 
10
- export const SvgJogYPos = (props: SVGProps<SVGSVGElement>) => (
10
+ export const SvgJogYUpRight = (props: SVGProps<SVGSVGElement>) => (
11
11
  <svg
12
12
  xmlns="http://www.w3.org/2000/svg"
13
13
  width="24"
@@ -27,4 +27,5 @@ export const SvgJogYPos = (props: SVGProps<SVGSVGElement>) => (
27
27
  </svg>
28
28
  );
29
29
 
30
- export default SvgJogYPos;
30
+ export default SvgJogYUpRight;
31
+
@@ -5,8 +5,8 @@ export { default as JogMedium } from "./JogMedium";
5
5
  export { default as JogShort } from "./JogShort";
6
6
  export { default as JogXRight } from "./JogXRight";
7
7
  export { default as JogXLeft } from "./JogXLeft";
8
- export { default as JogYPos } from "./JogYPos";
9
- export { default as JogYNeg } from "./JogYNeg";
8
+ export { default as JogYUpRight } from "./JogYUpRight";
9
+ export { default as JogYDownLeft } from "./JogYDownLeft";
10
10
  export { default as JogZPos } from "./JogZPos";
11
11
  export { default as JogZNeg } from "./JogZNeg";
12
12
  export { default as On } from "./On";
@@ -19,7 +19,7 @@ import { Distance, JogShort, JogMedium, JogLong } from '../assets';
19
19
  import { Speed, SpeedSlow, SpeedMedium, SpeedFast } from '../assets';
20
20
  import { RotationCcwA, RotationCcwB, RotationCcwC } from '../assets';
21
21
  import { RotationCwA, RotationCwB, RotationCwC } from '../assets';
22
- import { JogXLeft, JogXRight, JogYPos, JogYNeg, JogZPos, JogZNeg } from '../assets';
22
+ import { JogXLeft, JogXRight, JogYDownLeft, JogYUpRight, JogZPos, JogZNeg } from '../assets';
23
23
 
24
24
  /**
25
25
  * Enumerates the source button or action when an event occurs.
@@ -137,9 +137,9 @@ interface JogPanelState {
137
137
  * A default jog button configuration for linear 3D motion.
138
138
  */
139
139
  export const DefaultLinearJogButtons : (JogPanelButtonDefinition | undefined)[][] = [
140
- [undefined, { icon: "jog_z_up", action: JogPanelAction.zPositive, alt: "Z Positive" }, { icon: "jog_y_down_left", action: JogPanelAction.yNegative, alt: "Y Negative" }],
140
+ [undefined, { icon: "jog_z_up", action: JogPanelAction.zPositive, alt: "Z Positive" }, { icon: "jog_y_up_right", action: JogPanelAction.yNegative, alt: "Y Negative" }],
141
141
  [{ icon: "jog_x_left", action: JogPanelAction.xNegative, alt: "X Negative" }, undefined, { icon: "jog_x_right", action: JogPanelAction.xPositive, alt: "X Positive" }],
142
- [{ icon: "jog_y_up_right", action: JogPanelAction.yPositive, alt: "Y Positive" }, { icon: "jog_z_down", action: JogPanelAction.zNegative, alt: "Z Negative" }, undefined],
142
+ [{ icon: "jog_y_down_left", action: JogPanelAction.yPositive, alt: "Y Positive" }, { icon: "jog_z_down", action: JogPanelAction.zNegative, alt: "Z Negative" }, undefined],
143
143
  ];
144
144
 
145
145
  /**
@@ -272,8 +272,8 @@ export class JogPanel extends React.Component<JogPanelProps, JogPanelState> {
272
272
  // Direction keys
273
273
  case 'jog_x_left': return <JogXLeft />
274
274
  case 'jog_x_right': return <JogXRight />
275
- case 'jog_y_up_right': return <JogYPos />
276
- case 'jog_y_down_left': return <JogYNeg />
275
+ case 'jog_y_up_right': return <JogYUpRight />
276
+ case 'jog_y_down_left': return <JogYDownLeft />
277
277
  case 'jog_z_up': return <JogZPos />
278
278
  case 'jog_z_down': return <JogZNeg />
279
279
  case 'rot_a_cw': return <RotationCwA />
@@ -288,8 +288,8 @@ export class JogPanel extends React.Component<JogPanelProps, JogPanelState> {
288
288
  case 'pi pi-arrow-right': return <JogXRight />
289
289
  case 'pi pi-arrow-up': return <JogZPos />
290
290
  case 'pi pi-arrow-down': return <JogZNeg />
291
- case 'pi pi-arrow-up-right': return <JogYPos />
292
- case 'pi pi-arrow-down-left': return <JogYNeg />
291
+ case 'pi pi-arrow-up-right': return <JogYUpRight />
292
+ case 'pi pi-arrow-down-left': return <JogYDownLeft />
293
293
 
294
294
  default: return <i />
295
295
  }
@@ -153,24 +153,28 @@ export const AmsProvider: React.FC<AmsProviderProps> = ({ children }) => {
153
153
  }, []);
154
154
 
155
155
  // -----------------------------------------------------------------
156
+ // Re-fetch the schema catalog. Called once on mount and again whenever
157
+ // the asset-TYPE editor broadcasts `ams.schema_changed` after a save, so
158
+ // open pickers/forms pick up new or edited types without a reload.
159
+ const loadSchemas = useCallback(async () => {
160
+ try {
161
+ const resp: any = await invoke('ams.list_schemas' as any, MessageType.Request, {} as any);
162
+ if (resp?.success && resp.data) {
163
+ setSchemas((resp.data.asset_types ?? {}) as AmsSchemaRegistry);
164
+ setSchemasLoaded(true);
165
+ } else {
166
+ console.warn('[AmsProvider] ams.list_schemas failed:', resp?.error_message);
167
+ }
168
+ } catch (e) {
169
+ console.error('[AmsProvider] ams.list_schemas threw:', e);
170
+ }
171
+ }, [invoke]);
172
+
156
173
  // Schema + role load — once on mount.
157
174
  // -----------------------------------------------------------------
158
175
  useEffect(() => {
159
176
  let cancelled = false;
160
- (async () => {
161
- try {
162
- const resp: any = await invoke('ams.list_schemas' as any, MessageType.Request, {} as any);
163
- if (cancelled) return;
164
- if (resp?.success && resp.data) {
165
- setSchemas((resp.data.asset_types ?? {}) as AmsSchemaRegistry);
166
- setSchemasLoaded(true);
167
- } else {
168
- console.warn('[AmsProvider] ams.list_schemas failed:', resp?.error_message);
169
- }
170
- } catch (e) {
171
- console.error('[AmsProvider] ams.list_schemas threw:', e);
172
- }
173
- })();
177
+ void loadSchemas();
174
178
  (async () => {
175
179
  try {
176
180
  const resp: any = await invoke('ams.list_roles' as any, MessageType.Request, {} as any);
@@ -214,12 +218,13 @@ export const AmsProvider: React.FC<AmsProviderProps> = ({ children }) => {
214
218
  subscribe('ams.asset_changed', () => { refreshAssets(); }),
215
219
  subscribe('ams.calibration_added', () => { refreshAssets(); }),
216
220
  subscribe('ams.calibration_updated', () => { refreshAssets(); }),
221
+ subscribe('ams.schema_changed', () => { loadSchemas(); }),
217
222
  subscribe('ams.asset_count', (v: any) => setAlerts(a => ({ ...a, assetCount: Number(v) || 0 }))),
218
223
  subscribe('ams.alert_calibration_overdue', (v: any) => setAlerts(a => ({ ...a, calibrationOverdue: Number(v) || 0 }))),
219
224
  subscribe('ams.alert_lane_unavailable', (v: any) => setAlerts(a => ({ ...a, laneUnavailable: Number(v) || 0 }))),
220
225
  ];
221
226
  return () => { subs.forEach(unsubscribe); };
222
- }, [subscribe, unsubscribe, refreshAssets]);
227
+ }, [subscribe, unsubscribe, refreshAssets, loadSchemas]);
223
228
 
224
229
  // -----------------------------------------------------------------
225
230
  // RPC helpers
@@ -0,0 +1,103 @@
1
+ /**
2
+ * AssetFieldArray — add/remove/reorder editor for a plain `TestField[]`.
3
+ *
4
+ * A decoupled sibling of the TIS `FieldArrayEditor` (which is bound to a
5
+ * `TestMethod` keyed by `FieldArrayKey`). Asset types carry bare field
6
+ * lists (`fields`, `calibration_fields`, and the per-key lists inside a
7
+ * keyed `sub_locations`), so this operates directly on an array + onChange.
8
+ *
9
+ * Reuses the TIS `TestFieldDialog` for the per-field modal — asset fields
10
+ * have no cross-test aggregate concept, so `isResultsField` is left false.
11
+ */
12
+
13
+ import { useState } from 'react';
14
+ import { DataTable } from 'primereact/datatable';
15
+ import { Column } from 'primereact/column';
16
+ import { Button } from 'primereact/button';
17
+ import { FormSection } from '../forms/FormSection';
18
+ import { TestFieldDialog } from '../tis-editor/editor/TestFieldDialog';
19
+ import type { TestField } from '../tis-editor/types';
20
+
21
+ export interface AssetFieldArrayProps {
22
+ title: string;
23
+ description?: string;
24
+ fields: TestField[];
25
+ onChange: (next: TestField[]) => void;
26
+ }
27
+
28
+ export const AssetFieldArray: React.FC<AssetFieldArrayProps> = ({
29
+ title, description, fields, onChange,
30
+ }) => {
31
+ const [dialogOpen, setDialogOpen] = useState(false);
32
+ const [editingIdx, setEditingIdx] = useState<number | null>(null);
33
+
34
+ const handleAdd = () => { setEditingIdx(null); setDialogOpen(true); };
35
+ const handleEdit = (idx: number) => { setEditingIdx(idx); setDialogOpen(true); };
36
+
37
+ const handleSaveField = (f: TestField) => {
38
+ const next = [...fields];
39
+ if (editingIdx === null) next.push(f);
40
+ else next[editingIdx] = f;
41
+ onChange(next);
42
+ setDialogOpen(false);
43
+ };
44
+
45
+ const handleRemove = (idx: number) => {
46
+ const target = fields[idx];
47
+ if (!target) return;
48
+ if (!window.confirm(`Remove field "${target.name}"?`)) return;
49
+ onChange(fields.filter((_, i) => i !== idx));
50
+ };
51
+
52
+ const handleMove = (idx: number, dir: -1 | 1) => {
53
+ const j = idx + dir;
54
+ if (j < 0 || j >= fields.length) return;
55
+ const next = [...fields];
56
+ [next[idx], next[j]] = [next[j], next[idx]];
57
+ onChange(next);
58
+ };
59
+
60
+ const rowActions = (_row: TestField, opts: { rowIndex: number }) => (
61
+ <div style={{ display: 'flex', gap: '0.25rem' }}>
62
+ <Button icon="pi pi-arrow-up" className="p-button-text p-button-sm"
63
+ disabled={opts.rowIndex === 0}
64
+ onClick={() => handleMove(opts.rowIndex, -1)} aria-label="Move up" />
65
+ <Button icon="pi pi-arrow-down" className="p-button-text p-button-sm"
66
+ disabled={opts.rowIndex === fields.length - 1}
67
+ onClick={() => handleMove(opts.rowIndex, 1)} aria-label="Move down" />
68
+ <Button icon="pi pi-pencil" className="p-button-text p-button-sm"
69
+ onClick={() => handleEdit(opts.rowIndex)} aria-label="Edit" />
70
+ <Button icon="pi pi-trash" className="p-button-text p-button-danger p-button-sm"
71
+ onClick={() => handleRemove(opts.rowIndex)} aria-label="Remove" />
72
+ </div>
73
+ );
74
+
75
+ return (
76
+ <>
77
+ <FormSection
78
+ title={title}
79
+ description={description}
80
+ actions={<Button label="Add field" icon="pi pi-plus" size="small" onClick={handleAdd} />}
81
+ >
82
+ <DataTable value={fields} dataKey="name" emptyMessage="No fields defined.">
83
+ <Column field="name" header="Name" />
84
+ <Column field="type" header="Type" style={{ width: '6rem' }} />
85
+ <Column field="units" header="Units" style={{ width: '6rem' }} />
86
+ <Column header="Req" body={(r: TestField) => r.required ? '✓' : ''} style={{ width: '4rem' }} />
87
+ <Column field="label" header="Label" />
88
+ <Column header="" body={rowActions} style={{ width: '9rem' }} />
89
+ </DataTable>
90
+ </FormSection>
91
+ <TestFieldDialog
92
+ visible={dialogOpen}
93
+ initial={editingIdx !== null ? (fields[editingIdx] ?? null) : null}
94
+ siblingNames={fields.map(f => f.name)}
95
+ isResultsField={false}
96
+ onCancel={() => setDialogOpen(false)}
97
+ onSave={handleSaveField}
98
+ />
99
+ </>
100
+ );
101
+ };
102
+
103
+ export default AssetFieldArray;
@@ -0,0 +1,122 @@
1
+ .ams-typeeditor {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ min-height: 0;
6
+ }
7
+
8
+ .ams-typeeditor__header {
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: space-between;
12
+ padding: 0.75rem 1rem;
13
+ border-bottom: 1px solid var(--surface-d, #e2e8f0);
14
+ }
15
+
16
+ .ams-typeeditor__header h2 {
17
+ margin: 0;
18
+ font-size: 1.125rem;
19
+ }
20
+
21
+ .ams-typeeditor__header-actions {
22
+ display: flex;
23
+ gap: 0.5rem;
24
+ }
25
+
26
+ .ams-typeeditor__dirty-pill {
27
+ display: inline-block;
28
+ background: #ea580c;
29
+ color: white;
30
+ font-size: 0.7rem;
31
+ font-weight: 600;
32
+ text-transform: uppercase;
33
+ letter-spacing: 0.05em;
34
+ padding: 0.15rem 0.5rem;
35
+ border-radius: 999px;
36
+ margin-left: 0.5rem;
37
+ vertical-align: middle;
38
+ }
39
+
40
+ .ams-typeeditor__body {
41
+ flex: 1;
42
+ display: flex;
43
+ min-height: 0;
44
+ }
45
+
46
+ .ams-typeeditor__sidebar {
47
+ flex: 0 0 320px;
48
+ border-right: 1px solid var(--surface-d, #e2e8f0);
49
+ display: flex;
50
+ flex-direction: column;
51
+ min-height: 0;
52
+ }
53
+
54
+ .ams-typeeditor__sidebar-actions {
55
+ display: flex;
56
+ gap: 0.25rem;
57
+ padding: 0.5rem;
58
+ border-bottom: 1px solid var(--surface-d, #e2e8f0);
59
+ flex-wrap: wrap;
60
+ }
61
+
62
+ .ams-typeeditor__sidebar-actions .p-button {
63
+ flex: 1 1 auto;
64
+ min-width: 0;
65
+ }
66
+
67
+ .ams-typeeditor__builtins {
68
+ padding: 0.5rem;
69
+ border-top: 1px solid var(--surface-d, #e2e8f0);
70
+ color: var(--text-color-secondary, #64748b);
71
+ }
72
+
73
+ .ams-typeeditor__detail {
74
+ flex: 1;
75
+ display: flex;
76
+ flex-direction: column;
77
+ min-height: 0;
78
+ }
79
+
80
+ .ams-typeeditor__error {
81
+ background: #fef2f2;
82
+ color: #991b1b;
83
+ border-left: 3px solid #dc2626;
84
+ padding: 0.5rem 1rem;
85
+ margin: 0.5rem 1rem;
86
+ font-size: 0.875rem;
87
+ }
88
+
89
+ .ams-typeeditor__error pre {
90
+ white-space: pre-wrap;
91
+ margin: 0.25rem 0 0 0;
92
+ font-family: ui-monospace, monospace;
93
+ font-size: 0.8rem;
94
+ }
95
+
96
+ .ams-typeeditor__empty {
97
+ flex: 1;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ color: var(--text-color-secondary, #64748b);
102
+ }
103
+
104
+ .ams-typeeditor__new-label {
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 0.25rem;
108
+ margin-bottom: 0.25rem;
109
+ }
110
+
111
+ /* Portrait HMIs: stack the panes (see TisConfigEditor.css rationale). */
112
+ @media (orientation: portrait) {
113
+ .ams-typeeditor__body {
114
+ flex-direction: column;
115
+ }
116
+
117
+ .ams-typeeditor__sidebar {
118
+ flex: 0 1 35%;
119
+ border-right: none;
120
+ border-bottom: 1px solid var(--surface-d, #e2e8f0);
121
+ }
122
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * AssetTypeEditor — master-detail UI for authoring AMS custom asset TYPES
3
+ * from the operator HMI (the Instron-style "define your own asset type"
4
+ * capability). The instance-level UI (AssetRegistryTable / AssetEditDialog)
5
+ * edits asset records; this edits the type *schemas* those records conform
6
+ * to.
7
+ *
8
+ * Left: custom types (editable). Right: tabbed AssetTypeFormEditor.
9
+ * Action bar: New / Duplicate / Delete / Save / Revert.
10
+ *
11
+ * Staged-then-saved, mirroring the TIS test-method editor: New/Apply/Delete
12
+ * stage into the AMS module's in-memory buffer (dirty); Save persists to the
13
+ * `asset_management.json` sidecar (ams.save_schema_config) and broadcasts
14
+ * ams.schema_changed so open pickers/forms refresh. Built-in types
15
+ * (load_cell/linear_encoder/spring) are code-defined and shown read-only for
16
+ * reference — they can't be edited here.
17
+ */
18
+
19
+ import { useEffect, useMemo, useState } from 'react';
20
+ import { useContext } from 'react';
21
+ import { DataTable } from 'primereact/datatable';
22
+ import { Column } from 'primereact/column';
23
+ import { Button } from 'primereact/button';
24
+ import { InputText } from 'primereact/inputtext';
25
+ import { Dialog } from 'primereact/dialog';
26
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
27
+ import { MessageType } from '../../hub/CommandMessage';
28
+ import { useAmsSchemaConfig } from '../../hooks/useAmsSchemaConfig';
29
+ import { useAmsAssetTypes } from '../../hooks/useAmsAssetTypes';
30
+ import type { TisIpcInvoker } from '../../hooks/useTisConfig';
31
+ import { AssetTypeFormEditor } from './AssetTypeFormEditor';
32
+ import type { AssetTypeDef } from './types';
33
+
34
+ import './AssetTypeEditor.css';
35
+
36
+ export interface AssetTypeEditorProps {
37
+ /** Optional invoker override — primarily for the playground / tests. */
38
+ invoker?: TisIpcInvoker;
39
+ }
40
+
41
+ interface TypeRow {
42
+ id: string;
43
+ label: string;
44
+ }
45
+
46
+ const EMPTY_TYPE: AssetTypeDef = {
47
+ label: '',
48
+ description: '',
49
+ fields: [],
50
+ calibration_fields: [],
51
+ };
52
+
53
+ export const AssetTypeEditor: React.FC<AssetTypeEditorProps> = ({ invoker }) => {
54
+ const ctx = useContext(EventEmitterContext);
55
+ // Resolve the invoker ONCE and memoize — both hooks key effects off it,
56
+ // and an invoker rebuilt every render drives a refetch loop that wipes
57
+ // in-progress edits (see useAmsSchemaConfig's ref note).
58
+ const effectiveInvoker: TisIpcInvoker = useMemo(
59
+ () => invoker
60
+ ?? (async (topic, payload) => await ctx.invoke(topic as any, MessageType.Request, payload as any)),
61
+ [invoker, ctx],
62
+ );
63
+
64
+ const ams = useAmsSchemaConfig({ invoker: effectiveInvoker });
65
+ // Full merged catalog (built-in + custom) — used to derive the built-in
66
+ // names for the `extends` dropdown and to warn on name collisions.
67
+ const catalog = useAmsAssetTypes({ invoker: effectiveInvoker });
68
+
69
+ const [selectedId, setSelectedId] = useState<string | null>(null);
70
+ const [draftError, setDraftError] = useState<string | null>(null);
71
+ const [busy, setBusy] = useState<boolean>(false);
72
+ const [newDialogOpen, setNewDialogOpen] = useState<boolean>(false);
73
+ const [newId, setNewId] = useState<string>('');
74
+
75
+ const customIds = useMemo(
76
+ () => Object.keys(ams.config?.assetTypes ?? {}),
77
+ [ams.config],
78
+ );
79
+ const builtinTypes = useMemo(
80
+ () => catalog.types.filter(t => !customIds.includes(t)),
81
+ [catalog.types, customIds],
82
+ );
83
+
84
+ const rows: TypeRow[] = useMemo(() => {
85
+ if (!ams.config) return [];
86
+ return Object.entries(ams.config.assetTypes).map(([id, d]) => ({
87
+ id,
88
+ label: (d as AssetTypeDef)?.label ?? '',
89
+ }));
90
+ }, [ams.config]);
91
+
92
+ useEffect(() => {
93
+ if (!selectedId && ams.config) {
94
+ setSelectedId(Object.keys(ams.config.assetTypes)[0] ?? null);
95
+ }
96
+ }, [ams.config, selectedId]);
97
+
98
+ useEffect(() => { setDraftError(null); }, [selectedId]);
99
+
100
+ const onApply = async (next: AssetTypeDef) => {
101
+ if (!selectedId) return;
102
+ setBusy(true);
103
+ try {
104
+ await ams.putType(selectedId, next);
105
+ setDraftError(null);
106
+ } catch (e: any) {
107
+ setDraftError(String(e?.message ?? e));
108
+ } finally {
109
+ setBusy(false);
110
+ }
111
+ };
112
+
113
+ const onCreate = async () => {
114
+ const id = newId.trim();
115
+ if (!id) return;
116
+ if (ams.config?.assetTypes[id]) {
117
+ setDraftError(`A custom type named "${id}" already exists.`);
118
+ return;
119
+ }
120
+ if (builtinTypes.includes(id)) {
121
+ // Allowed (it becomes an override/extension of the built-in), but
122
+ // warn so the operator knows they're shadowing a built-in.
123
+ if (!window.confirm(
124
+ `"${id}" is a built-in type. Creating a custom type with this id ` +
125
+ `overrides/extends the built-in. Continue?`)) return;
126
+ }
127
+ setBusy(true);
128
+ try {
129
+ await ams.putType(id, EMPTY_TYPE);
130
+ setSelectedId(id);
131
+ setNewDialogOpen(false);
132
+ setNewId('');
133
+ } catch (e: any) {
134
+ setDraftError(String(e?.message ?? e));
135
+ } finally {
136
+ setBusy(false);
137
+ }
138
+ };
139
+
140
+ const onDuplicate = async () => {
141
+ if (!selectedId || !ams.config) return;
142
+ const source = ams.config.assetTypes[selectedId];
143
+ if (!source) return;
144
+ let candidate = `${selectedId}_copy`;
145
+ let n = 2;
146
+ while (ams.config.assetTypes[candidate]) candidate = `${selectedId}_copy_${n++}`;
147
+ setBusy(true);
148
+ try {
149
+ await ams.putType(candidate, JSON.parse(JSON.stringify(source)));
150
+ setSelectedId(candidate);
151
+ } catch (e: any) {
152
+ setDraftError(String(e?.message ?? e));
153
+ } finally {
154
+ setBusy(false);
155
+ }
156
+ };
157
+
158
+ const onDelete = async () => {
159
+ if (!selectedId) return;
160
+ if (!window.confirm(`Remove type "${selectedId}"? This is staged — Save persists it.`)) return;
161
+ setBusy(true);
162
+ try {
163
+ await ams.removeType(selectedId);
164
+ setSelectedId(null);
165
+ } catch (e: any) {
166
+ // Server refuses when assets still use the type — surface it.
167
+ setDraftError(String(e?.message ?? e));
168
+ } finally {
169
+ setBusy(false);
170
+ }
171
+ };
172
+
173
+ const onSave = async () => {
174
+ if (!window.confirm('Save asset-type changes to disk?')) return;
175
+ setBusy(true);
176
+ try {
177
+ await ams.save();
178
+ } catch (e: any) {
179
+ setDraftError(String(e?.message ?? e));
180
+ } finally {
181
+ setBusy(false);
182
+ }
183
+ };
184
+
185
+ const onRevert = async () => {
186
+ if (!window.confirm('Discard all in-progress edits? This cannot be undone.')) return;
187
+ setBusy(true);
188
+ try {
189
+ await ams.revert();
190
+ } catch (e: any) {
191
+ setDraftError(String(e?.message ?? e));
192
+ } finally {
193
+ setBusy(false);
194
+ }
195
+ };
196
+
197
+ return (
198
+ <div className="ams-typeeditor">
199
+ <header className="ams-typeeditor__header">
200
+ <h2>
201
+ Asset Types{' '}
202
+ {ams.config?.dirty && <span className="ams-typeeditor__dirty-pill">unsaved</span>}
203
+ </h2>
204
+ <div className="ams-typeeditor__header-actions">
205
+ <Button label="Save…" icon="pi pi-save"
206
+ disabled={busy || !ams.config?.dirty} onClick={onSave} />
207
+ <Button label="Revert" icon="pi pi-undo" className="p-button-secondary"
208
+ disabled={busy || !ams.config?.dirty} onClick={onRevert} />
209
+ </div>
210
+ </header>
211
+
212
+ {ams.error && (
213
+ <div className="ams-typeeditor__error">
214
+ <strong>Error:</strong> <pre>{ams.error}</pre>
215
+ </div>
216
+ )}
217
+
218
+ <div className="ams-typeeditor__body">
219
+ <aside className="ams-typeeditor__sidebar">
220
+ <div className="ams-typeeditor__sidebar-actions">
221
+ <Button label="New" icon="pi pi-plus"
222
+ disabled={busy} onClick={() => setNewDialogOpen(true)} />
223
+ <Button label="Duplicate" icon="pi pi-clone" className="p-button-secondary"
224
+ disabled={busy || !selectedId} onClick={onDuplicate} />
225
+ <Button label="Delete" icon="pi pi-trash" className="p-button-danger"
226
+ disabled={busy || !selectedId} onClick={onDelete} />
227
+ </div>
228
+ <DataTable
229
+ value={rows}
230
+ selection={rows.find(r => r.id === selectedId) ?? null}
231
+ onSelectionChange={(e) => setSelectedId((e.value as TypeRow | null)?.id ?? null)}
232
+ selectionMode="single"
233
+ dataKey="id"
234
+ scrollable
235
+ scrollHeight="flex"
236
+ emptyMessage={ams.loading ? 'Loading…' : 'No custom asset types defined.'}
237
+ >
238
+ <Column field="id" header="Type ID" />
239
+ <Column field="label" header="Label" />
240
+ </DataTable>
241
+ {builtinTypes.length > 0 && (
242
+ <div className="ams-typeeditor__builtins">
243
+ <small>Built-in (read-only): {builtinTypes.join(', ')}</small>
244
+ </div>
245
+ )}
246
+ </aside>
247
+
248
+ <section className="ams-typeeditor__detail">
249
+ {selectedId && ams.config?.assetTypes[selectedId] ? (
250
+ <>
251
+ {draftError && (
252
+ <div className="ams-typeeditor__error"><pre>{draftError}</pre></div>
253
+ )}
254
+ <AssetTypeFormEditor
255
+ typeId={selectedId}
256
+ def={ams.config.assetTypes[selectedId] as AssetTypeDef}
257
+ onApply={onApply}
258
+ busy={busy}
259
+ builtinTypes={builtinTypes}
260
+ />
261
+ </>
262
+ ) : (
263
+ <div className="ams-typeeditor__empty">
264
+ Select a custom asset type on the left, or create a new one.
265
+ </div>
266
+ )}
267
+ </section>
268
+ </div>
269
+
270
+ <Dialog
271
+ header="New Asset Type"
272
+ visible={newDialogOpen}
273
+ onHide={() => setNewDialogOpen(false)}
274
+ style={{ width: '24rem' }}
275
+ >
276
+ <label className="ams-typeeditor__new-label">
277
+ Type ID
278
+ <InputText
279
+ value={newId}
280
+ onChange={(e) => setNewId(e.target.value)}
281
+ placeholder="e.g. triaxial_transducer"
282
+ autoFocus
283
+ />
284
+ </label>
285
+ <small>Canonical key — appears in wire payloads, on-disk paths, and generated code.</small>
286
+ <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
287
+ <Button label="Cancel" className="p-button-text" onClick={() => setNewDialogOpen(false)} />
288
+ <Button label="Create" disabled={!newId.trim() || busy} onClick={onCreate} />
289
+ </div>
290
+ </Dialog>
291
+ </div>
292
+ );
293
+ };
294
+
295
+ export default AssetTypeEditor;