@adcops/autocore-react 3.3.61 → 3.3.63

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 (45) hide show
  1. package/dist/components/ams/AmsProvider.d.ts +45 -0
  2. package/dist/components/ams/AmsProvider.d.ts.map +1 -0
  3. package/dist/components/ams/AmsProvider.js +1 -0
  4. package/dist/components/ams/AssetDetailView.d.ts +3 -0
  5. package/dist/components/ams/AssetDetailView.d.ts.map +1 -0
  6. package/dist/components/ams/AssetDetailView.js +1 -0
  7. package/dist/components/ams/AssetRegistryTable.d.ts +3 -0
  8. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -0
  9. package/dist/components/ams/AssetRegistryTable.js +1 -0
  10. package/dist/components/ams/CalibrationEntryDialog.d.ts +10 -0
  11. package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -0
  12. package/dist/components/ams/CalibrationEntryDialog.js +1 -0
  13. package/dist/components/ams/SubLocationPicker.d.ts +3 -0
  14. package/dist/components/ams/SubLocationPicker.d.ts.map +1 -0
  15. package/dist/components/ams/SubLocationPicker.js +1 -0
  16. package/dist/components/ams/index.d.ts +6 -0
  17. package/dist/components/ams/index.d.ts.map +1 -0
  18. package/dist/components/ams/index.js +1 -0
  19. package/dist/components/index.d.ts +7 -0
  20. package/dist/components/index.d.ts.map +1 -1
  21. package/dist/components/index.js +1 -1
  22. package/dist/components/tis/TestDataView.d.ts +9 -1
  23. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  24. package/dist/components/tis/TestDataView.js +1 -1
  25. package/dist/components/tis/TisProvider.d.ts +17 -0
  26. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  27. package/dist/components/tis/TisProvider.js +1 -1
  28. package/dist/core/AutoCoreTagContext.d.ts +16 -0
  29. package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
  30. package/dist/core/AutoCoreTagContext.js +1 -1
  31. package/dist/themes/adc-dark/blue/theme.css +67 -37
  32. package/dist/themes/adc-dark/blue/theme.css.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/ams/AmsProvider.tsx +219 -0
  35. package/src/components/ams/AssetDetailView.tsx +101 -0
  36. package/src/components/ams/AssetRegistryTable.tsx +171 -0
  37. package/src/components/ams/CalibrationEntryDialog.tsx +197 -0
  38. package/src/components/ams/SubLocationPicker.tsx +146 -0
  39. package/src/components/ams/index.ts +12 -0
  40. package/src/components/index.ts +27 -0
  41. package/src/components/tis/TestDataView.tsx +321 -28
  42. package/src/components/tis/TisProvider.tsx +44 -0
  43. package/src/core/AutoCoreTagContext.tsx +114 -16
  44. package/src/themes/adc-dark/_extensions.scss +15 -0
  45. package/src/themes/adc-dark/blue/adc_theme.scss +56 -10
@@ -0,0 +1,219 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <AmsProvider> — context provider for the Asset Management System.
5
+ *
6
+ * Mirrors <TisProvider> in shape:
7
+ * 1. Schema registry (loaded once via `ams.list_schemas`).
8
+ * 2. Live alert scalars (`ams.asset_count`, `ams.alert_*`).
9
+ * 3. Asset registry cache, refreshed on `ams.asset_changed` /
10
+ * `ams.calibration_added` broadcasts.
11
+ * 4. Selection state (assetType, assetId) — pinned by user click.
12
+ *
13
+ * Drop once at the top of the HMI; the AMS components below it read
14
+ * from context and need no props.
15
+ */
16
+
17
+ import React, {
18
+ createContext,
19
+ useCallback,
20
+ useContext,
21
+ useEffect,
22
+ useMemo,
23
+ useState,
24
+ type ReactNode,
25
+ } from 'react';
26
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
27
+ import { MessageType } from '../../hub/CommandMessage';
28
+
29
+ // -------------------------------------------------------------------------
30
+ // Types
31
+ // -------------------------------------------------------------------------
32
+
33
+ export type AmsTypeSchema = any;
34
+ export type AmsSchemaRegistry = { [assetType: string]: AmsTypeSchema };
35
+
36
+ export interface AmsAlerts {
37
+ assetCount: number;
38
+ calibrationOverdue: number;
39
+ laneUnavailable: number;
40
+ }
41
+
42
+ export interface AmsAssetEntry {
43
+ asset_id: string;
44
+ asset_type: string;
45
+ serial: string;
46
+ location: string;
47
+ status: 'active' | 'retired' | 'out_for_service';
48
+ current_calibration_id: string | null;
49
+ }
50
+
51
+ export interface AmsSelection {
52
+ assetType: string | null;
53
+ assetId: string | null;
54
+ }
55
+
56
+ export interface AmsContextValue {
57
+ schemas: AmsSchemaRegistry;
58
+ schemasLoaded: boolean;
59
+ alerts: AmsAlerts;
60
+ assets: AmsAssetEntry[];
61
+ refreshAssets: () => Promise<void>;
62
+ readAsset: (assetId: string) => Promise<any | null>;
63
+ listCalibrations: (assetId: string) => Promise<string[]>;
64
+ readCalibration: (assetId: string, calId: string) => Promise<any | null>;
65
+ readUsage: (assetId: string) => Promise<any | null>;
66
+ selection: AmsSelection;
67
+ setSelection: (patch: Partial<AmsSelection>) => void;
68
+ }
69
+
70
+ const EMPTY_ALERTS: AmsAlerts = {
71
+ assetCount: 0, calibrationOverdue: 0, laneUnavailable: 0,
72
+ };
73
+
74
+ const AmsContext = createContext<AmsContextValue>({
75
+ schemas: {},
76
+ schemasLoaded: false,
77
+ alerts: EMPTY_ALERTS,
78
+ assets: [],
79
+ refreshAssets: async () => {},
80
+ readAsset: async () => null,
81
+ listCalibrations: async () => [],
82
+ readCalibration: async () => null,
83
+ readUsage: async () => null,
84
+ selection: { assetType: null, assetId: null },
85
+ setSelection: () => {},
86
+ });
87
+
88
+ // -------------------------------------------------------------------------
89
+ // Provider
90
+ // -------------------------------------------------------------------------
91
+
92
+ export interface AmsProviderProps {
93
+ children: ReactNode;
94
+ }
95
+
96
+ export const AmsProvider: React.FC<AmsProviderProps> = ({ children }) => {
97
+ const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
98
+
99
+ const [schemas, setSchemas] = useState<AmsSchemaRegistry>({});
100
+ const [schemasLoaded, setSchemasLoaded] = useState(false);
101
+ const [alerts, setAlerts] = useState<AmsAlerts>(EMPTY_ALERTS);
102
+ const [assets, setAssets] = useState<AmsAssetEntry[]>([]);
103
+ const [selection, setSelectionState] = useState<AmsSelection>({ assetType: null, assetId: null });
104
+
105
+ const setSelection = useCallback((patch: Partial<AmsSelection>) => {
106
+ setSelectionState(prev => ({ ...prev, ...patch }));
107
+ }, []);
108
+
109
+ // -----------------------------------------------------------------
110
+ // Schema load — once on mount.
111
+ // -----------------------------------------------------------------
112
+ useEffect(() => {
113
+ let cancelled = false;
114
+ (async () => {
115
+ try {
116
+ const resp: any = await invoke('ams.list_schemas' as any, MessageType.Request, {} as any);
117
+ if (cancelled) return;
118
+ if (resp?.success && resp.data) {
119
+ setSchemas((resp.data.asset_types ?? {}) as AmsSchemaRegistry);
120
+ setSchemasLoaded(true);
121
+ } else {
122
+ console.warn('[AmsProvider] ams.list_schemas failed:', resp?.error_message);
123
+ }
124
+ } catch (e) {
125
+ console.error('[AmsProvider] ams.list_schemas threw:', e);
126
+ }
127
+ })();
128
+ return () => { cancelled = true; };
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, []);
131
+
132
+ // -----------------------------------------------------------------
133
+ // Asset list refresh — fetch via ams.list_assets, then re-fetch
134
+ // whenever an asset_changed or calibration_added broadcast fires.
135
+ // -----------------------------------------------------------------
136
+ const refreshAssets = useCallback(async () => {
137
+ try {
138
+ const resp: any = await invoke('ams.list_assets' as any, MessageType.Request, { include_retired: true } as any);
139
+ if (resp?.success) {
140
+ setAssets((resp.data.assets ?? []) as AmsAssetEntry[]);
141
+ }
142
+ } catch (e) {
143
+ console.error('[AmsProvider] list_assets threw:', e);
144
+ }
145
+ }, [invoke]);
146
+
147
+ useEffect(() => { refreshAssets(); }, [refreshAssets]);
148
+
149
+ useEffect(() => {
150
+ const subs = [
151
+ subscribe('ams.asset_changed', () => { refreshAssets(); }),
152
+ subscribe('ams.calibration_added', () => { refreshAssets(); }),
153
+ subscribe('ams.asset_count', (v: any) => setAlerts(a => ({ ...a, assetCount: Number(v) || 0 }))),
154
+ subscribe('ams.alert_calibration_overdue', (v: any) => setAlerts(a => ({ ...a, calibrationOverdue: Number(v) || 0 }))),
155
+ subscribe('ams.alert_lane_unavailable', (v: any) => setAlerts(a => ({ ...a, laneUnavailable: Number(v) || 0 }))),
156
+ ];
157
+ return () => { subs.forEach(unsubscribe); };
158
+ }, [subscribe, unsubscribe, refreshAssets]);
159
+
160
+ // -----------------------------------------------------------------
161
+ // RPC helpers
162
+ // -----------------------------------------------------------------
163
+ const readAsset = useCallback(async (assetId: string) => {
164
+ try {
165
+ const resp: any = await invoke('ams.read_asset' as any, MessageType.Request, { asset_id: assetId } as any);
166
+ return resp?.success ? resp.data : null;
167
+ } catch { return null; }
168
+ }, [invoke]);
169
+
170
+ const listCalibrations = useCallback(async (assetId: string): Promise<string[]> => {
171
+ try {
172
+ const resp: any = await invoke('ams.list_calibrations' as any, MessageType.Request, { asset_id: assetId } as any);
173
+ return resp?.success ? (resp.data.cal_ids ?? []) : [];
174
+ } catch { return []; }
175
+ }, [invoke]);
176
+
177
+ const readCalibration = useCallback(async (assetId: string, calId: string) => {
178
+ try {
179
+ const resp: any = await invoke('ams.read_calibration' as any, MessageType.Request, { asset_id: assetId, cal_id: calId } as any);
180
+ return resp?.success ? resp.data : null;
181
+ } catch { return null; }
182
+ }, [invoke]);
183
+
184
+ const readUsage = useCallback(async (assetId: string) => {
185
+ try {
186
+ const resp: any = await invoke('ams.read_usage' as any, MessageType.Request, { asset_id: assetId } as any);
187
+ return resp?.success ? resp.data : null;
188
+ } catch { return null; }
189
+ }, [invoke]);
190
+
191
+ const value = useMemo<AmsContextValue>(() => ({
192
+ schemas,
193
+ schemasLoaded,
194
+ alerts,
195
+ assets,
196
+ refreshAssets,
197
+ readAsset,
198
+ listCalibrations,
199
+ readCalibration,
200
+ readUsage,
201
+ selection,
202
+ setSelection,
203
+ }), [schemas, schemasLoaded, alerts, assets, refreshAssets, readAsset, listCalibrations, readCalibration, readUsage, selection, setSelection]);
204
+
205
+ return <AmsContext.Provider value={value}>{children}</AmsContext.Provider>;
206
+ };
207
+
208
+ // -------------------------------------------------------------------------
209
+ // Hooks
210
+ // -------------------------------------------------------------------------
211
+
212
+ export const useAms = () => useContext(AmsContext);
213
+ export const useAmsSchemas = () => useContext(AmsContext).schemas;
214
+ export const useAmsAlerts = () => useContext(AmsContext).alerts;
215
+ export const useAmsAssets = () => useContext(AmsContext).assets;
216
+ export const useAmsSelection = () => {
217
+ const ctx = useContext(AmsContext);
218
+ return [ctx.selection, ctx.setSelection] as const;
219
+ };
@@ -0,0 +1,101 @@
1
+ /*
2
+ * <AssetDetailView> — read-only summary of one asset selected in the
3
+ * <AssetRegistryTable>: header (id, type, serial, location, status,
4
+ * current calibration), calibration history table, current usage
5
+ * counters. Provides a "+ Calibration" button that opens
6
+ * <CalibrationEntryDialog>.
7
+ */
8
+
9
+ import React, { useEffect, useState } from 'react';
10
+ import { Button } from 'primereact/button';
11
+ import { DataTable } from 'primereact/datatable';
12
+ import { Column } from 'primereact/column';
13
+ import { useAms } from './AmsProvider';
14
+ import { CalibrationEntryDialog } from './CalibrationEntryDialog';
15
+
16
+ export const AssetDetailView: React.FC = () => {
17
+ const { selection, schemas, readAsset, listCalibrations, readCalibration, readUsage } = useAms();
18
+ const [asset, setAsset] = useState<any | null>(null);
19
+ const [cals, setCals] = useState<any[]>([]);
20
+ const [usage, setUsage] = useState<any | null>(null);
21
+ const [calDialogOpen, setCalDialogOpen] = useState(false);
22
+
23
+ const refresh = async () => {
24
+ if (!selection.assetId) {
25
+ setAsset(null); setCals([]); setUsage(null);
26
+ return;
27
+ }
28
+ const a = await readAsset(selection.assetId);
29
+ setAsset(a);
30
+ const ids = await listCalibrations(selection.assetId);
31
+ const records = await Promise.all(ids.map(id => readCalibration(selection.assetId!, id)));
32
+ setCals(records.filter(Boolean));
33
+ const u = await readUsage(selection.assetId);
34
+ setUsage(u);
35
+ };
36
+
37
+ useEffect(() => { refresh(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [selection.assetId]);
38
+
39
+ if (!selection.assetId) {
40
+ return (
41
+ <div style={{ padding: '1rem', color: '#9ca3af' }}>
42
+ Select an asset from the registry to see its details.
43
+ </div>
44
+ );
45
+ }
46
+ if (!asset) {
47
+ return <div style={{ padding: '1rem' }}>Loading…</div>;
48
+ }
49
+
50
+ const typeLabel = schemas[asset.asset_type]?.label ?? asset.asset_type;
51
+
52
+ return (
53
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
54
+ <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto 1fr', gap: '0.5rem 1.5rem', alignItems: 'baseline' }}>
55
+ <strong>Asset ID</strong> <span>{asset.asset_id}</span>
56
+ <strong>Type</strong> <span>{typeLabel}</span>
57
+
58
+ <strong>Serial</strong> <span>{asset.serial || <em style={{ color: '#9ca3af' }}>(none)</em>}</span>
59
+ <strong>Location</strong> <span>{asset.location || <em style={{ color: '#9ca3af' }}>(none)</em>}</span>
60
+
61
+ <strong>Status</strong> <span>{asset.status}</span>
62
+ <strong>Installed</strong>
63
+ <span>{asset.install_date ? new Date(asset.install_date).toLocaleString() : '—'}</span>
64
+
65
+ <strong>Current Cal</strong>
66
+ <span>{asset.current_calibration_id ?? <em style={{ color: '#f59e0b' }}>none</em>}</span>
67
+ <strong>Cycles</strong> <span>{usage?.cycles ?? 0}</span>
68
+ </div>
69
+
70
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
71
+ <h4 style={{ margin: 0 }}>Calibration History</h4>
72
+ <Button label="+ Calibration" icon="pi pi-plus"
73
+ onClick={() => setCalDialogOpen(true)} />
74
+ </div>
75
+ <DataTable value={cals} size="small" stripedRows
76
+ emptyMessage="No calibrations recorded for this asset."
77
+ >
78
+ <Column field="cal_id" header="Cal ID" />
79
+ <Column field="performed_at" header="Performed"
80
+ body={(r) => r.performed_at ? new Date(r.performed_at).toLocaleString() : '—'}
81
+ />
82
+ <Column field="performed_by" header="By" />
83
+ <Column field="expires_at" header="Expires"
84
+ body={(r) => r.expires_at ? new Date(r.expires_at).toLocaleDateString() : '—'}
85
+ />
86
+ <Column field="cert_ref" header="Cert" />
87
+ <Column header="Values"
88
+ body={(r) => <code style={{ fontSize: '0.75rem' }}>{JSON.stringify(r.values)}</code>}
89
+ />
90
+ </DataTable>
91
+
92
+ <CalibrationEntryDialog
93
+ visible={calDialogOpen}
94
+ assetId={asset.asset_id}
95
+ assetType={asset.asset_type}
96
+ onHide={() => setCalDialogOpen(false)}
97
+ onAdded={() => { refresh(); }}
98
+ />
99
+ </div>
100
+ );
101
+ };
@@ -0,0 +1,171 @@
1
+ /*
2
+ * <AssetRegistryTable> — list every asset in the AMS, with quick
3
+ * filters by type / status and a "+ Add" button that pops a creation
4
+ * dialog. Click a row → pins selection.assetId so <AssetDetailView>
5
+ * can render the chosen asset.
6
+ */
7
+
8
+ import React, { useContext, useMemo, useState } from 'react';
9
+ import { Button } from 'primereact/button';
10
+ import { DataTable } from 'primereact/datatable';
11
+ import { Column } from 'primereact/column';
12
+ import { Dropdown } from 'primereact/dropdown';
13
+ import { InputText } from 'primereact/inputtext';
14
+ import { Dialog } from 'primereact/dialog';
15
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
16
+ import { MessageType } from '../../hub/CommandMessage';
17
+ import { useAms, type AmsAssetEntry } from './AmsProvider';
18
+
19
+ interface AddDialogState {
20
+ open: boolean;
21
+ assetType: string;
22
+ serial: string;
23
+ location: string;
24
+ }
25
+
26
+ const EMPTY_ADD: AddDialogState = { open: false, assetType: '', serial: '', location: '' };
27
+
28
+ export const AssetRegistryTable: React.FC = () => {
29
+ const { schemas, assets, refreshAssets, setSelection, selection } = useAms();
30
+ const { invoke } = useContext(EventEmitterContext);
31
+
32
+ const [filterType, setFilterType] = useState<string | null>(null);
33
+ const [filterStatus, setFilterStatus] = useState<string | null>(null);
34
+ const [addState, setAddState] = useState<AddDialogState>(EMPTY_ADD);
35
+
36
+ const typeOptions = useMemo(
37
+ () => Object.keys(schemas).map(k => ({ label: schemas[k]?.label ?? k, value: k })),
38
+ [schemas],
39
+ );
40
+
41
+ const filtered = useMemo(() => {
42
+ return assets.filter(a => {
43
+ if (filterType && a.asset_type !== filterType) return false;
44
+ if (filterStatus && a.status !== filterStatus) return false;
45
+ return true;
46
+ });
47
+ }, [assets, filterType, filterStatus]);
48
+
49
+ const onCreate = async () => {
50
+ if (!addState.assetType) return;
51
+ try {
52
+ const resp: any = await invoke('ams.create_asset' as any, MessageType.Request, {
53
+ asset_type: addState.assetType,
54
+ serial: addState.serial,
55
+ location: addState.location,
56
+ } as any);
57
+ if (resp?.success) {
58
+ setAddState(EMPTY_ADD);
59
+ await refreshAssets();
60
+ if (resp.data?.asset_id) {
61
+ setSelection({ assetType: addState.assetType, assetId: resp.data.asset_id });
62
+ }
63
+ } else {
64
+ console.warn('[AssetRegistryTable] create_asset failed:', resp?.error_message);
65
+ }
66
+ } catch (e) {
67
+ console.error('[AssetRegistryTable] create_asset threw:', e);
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
73
+ <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
74
+ <Dropdown
75
+ value={filterType}
76
+ options={[{ label: 'All Types', value: null }, ...typeOptions]}
77
+ onChange={(e) => setFilterType(e.value)}
78
+ placeholder="Filter by type"
79
+ />
80
+ <Dropdown
81
+ value={filterStatus}
82
+ options={[
83
+ { label: 'All Statuses', value: null },
84
+ { label: 'Active', value: 'active' },
85
+ { label: 'Out for Service', value: 'out_for_service' },
86
+ { label: 'Retired', value: 'retired' },
87
+ ]}
88
+ onChange={(e) => setFilterStatus(e.value)}
89
+ placeholder="Filter by status"
90
+ />
91
+ <span style={{ marginLeft: 'auto' }}>
92
+ <Button
93
+ icon="pi pi-plus"
94
+ label="Add Asset"
95
+ onClick={() => setAddState(s => ({ ...s, open: true }))}
96
+ disabled={typeOptions.length === 0}
97
+ />
98
+ </span>
99
+ </div>
100
+
101
+ <DataTable
102
+ value={filtered}
103
+ selectionMode="single"
104
+ selection={filtered.find(a => a.asset_id === selection.assetId) ?? null}
105
+ onSelectionChange={(e) => {
106
+ const row = e.value as AmsAssetEntry | null;
107
+ if (row) setSelection({ assetType: row.asset_type, assetId: row.asset_id });
108
+ }}
109
+ dataKey="asset_id"
110
+ emptyMessage={
111
+ typeOptions.length === 0
112
+ ? 'AMS not enabled in this project (no asset_types declared).'
113
+ : 'No assets registered yet.'
114
+ }
115
+ size="small"
116
+ stripedRows
117
+ >
118
+ <Column field="asset_id" header="Asset ID" />
119
+ <Column field="asset_type" header="Type"
120
+ body={(r: AmsAssetEntry) => schemas[r.asset_type]?.label ?? r.asset_type}
121
+ />
122
+ <Column field="serial" header="Serial" />
123
+ <Column field="location" header="Location" />
124
+ <Column field="status" header="Status" />
125
+ <Column header="Calibration"
126
+ body={(r: AmsAssetEntry) =>
127
+ r.current_calibration_id
128
+ ? <span title={r.current_calibration_id}>✓</span>
129
+ : <span style={{ color: '#f59e0b' }}>none</span>
130
+ }
131
+ />
132
+ </DataTable>
133
+
134
+ <Dialog
135
+ header="Add New Asset"
136
+ visible={addState.open}
137
+ style={{ width: '32rem' }}
138
+ onHide={() => setAddState(EMPTY_ADD)}
139
+ footer={
140
+ <>
141
+ <Button label="Cancel" severity="secondary" onClick={() => setAddState(EMPTY_ADD)} />
142
+ <Button label="Create" icon="pi pi-check"
143
+ onClick={onCreate}
144
+ disabled={!addState.assetType}
145
+ />
146
+ </>
147
+ }
148
+ >
149
+ <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1rem', alignItems: 'center' }}>
150
+ <label>Type *</label>
151
+ <Dropdown
152
+ value={addState.assetType}
153
+ options={typeOptions}
154
+ onChange={(e) => setAddState(s => ({ ...s, assetType: e.value }))}
155
+ placeholder="Choose asset type"
156
+ />
157
+ <label>Serial</label>
158
+ <InputText value={addState.serial}
159
+ onChange={(e) => setAddState(s => ({ ...s, serial: e.target.value }))} />
160
+ <label>Location</label>
161
+ <InputText value={addState.location}
162
+ onChange={(e) => setAddState(s => ({ ...s, location: e.target.value }))} />
163
+ </div>
164
+ <p style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '1rem' }}>
165
+ The asset_id is generated by the server (format: <code>{addState.assetType ? (schemas[addState.assetType]?.id_prefix ?? 'A-') : 'A-'}YYYYMMDDTHHMMSS</code>).
166
+ Manufacturer serial is recorded for traceability but is not used as the unique key.
167
+ </p>
168
+ </Dialog>
169
+ </div>
170
+ );
171
+ };
@@ -0,0 +1,197 @@
1
+ /*
2
+ * <CalibrationEntryDialog> — form for adding a calibration record to
3
+ * an asset. Renders a dynamic field set built from the asset_type's
4
+ * `calibration_fields` schema, plus a few standard meta fields.
5
+ *
6
+ * Triggered by <AssetDetailView>'s "+ Calibration" button. Submits via
7
+ * `ams.add_calibration`; on success the AmsProvider's
8
+ * `ams.calibration_added` subscription refreshes the registry.
9
+ */
10
+
11
+ import React, { useContext, useEffect, useState } from 'react';
12
+ import { Button } from 'primereact/button';
13
+ import { Dialog } from 'primereact/dialog';
14
+ import { InputText } from 'primereact/inputtext';
15
+ import { InputNumber } from 'primereact/inputnumber';
16
+ import { Calendar } from 'primereact/calendar';
17
+ import { Dropdown } from 'primereact/dropdown';
18
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
19
+ import { MessageType } from '../../hub/CommandMessage';
20
+ import { useAmsSchemas } from './AmsProvider';
21
+
22
+ export interface CalibrationEntryDialogProps {
23
+ visible: boolean;
24
+ assetId: string;
25
+ assetType: string;
26
+ onHide: () => void;
27
+ onAdded?: (calId: string) => void;
28
+ }
29
+
30
+ interface FieldSpec {
31
+ name: string;
32
+ type: string;
33
+ required?: boolean;
34
+ label?: string;
35
+ units?: string;
36
+ description?: string;
37
+ values?: string[]; // for enum
38
+ }
39
+
40
+ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
41
+ visible, assetId, assetType, onHide, onAdded,
42
+ }) => {
43
+ const schemas = useAmsSchemas();
44
+ const { invoke } = useContext(EventEmitterContext);
45
+
46
+ const fields: FieldSpec[] = (schemas[assetType]?.calibration_fields ?? []) as FieldSpec[];
47
+
48
+ const [values, setValues] = useState<{ [k: string]: any }>({});
49
+ const [performedBy, setPerformedBy] = useState('');
50
+ const [expiresAt, setExpiresAt] = useState<Date | null>(null);
51
+ const [certRef, setCertRef] = useState('');
52
+ const [notes, setNotes] = useState('');
53
+ const [submitting, setSubmitting] = useState(false);
54
+ const [error, setError] = useState<string | null>(null);
55
+
56
+ useEffect(() => {
57
+ if (visible) {
58
+ setValues({});
59
+ setPerformedBy('');
60
+ setExpiresAt(null);
61
+ setCertRef('');
62
+ setNotes('');
63
+ setError(null);
64
+ }
65
+ }, [visible]);
66
+
67
+ const renderField = (f: FieldSpec) => {
68
+ const labelText = `${f.label ?? f.name}${f.units ? ` [${f.units}]` : ''}${f.required ? ' *' : ''}`;
69
+ const labelEl = (
70
+ <label title={f.description ?? ''}>
71
+ {labelText}
72
+ </label>
73
+ );
74
+
75
+ const onChange = (val: any) => setValues(v => ({ ...v, [f.name]: val }));
76
+
77
+ let input: React.ReactNode;
78
+ switch (f.type) {
79
+ case 'string':
80
+ input = <InputText value={values[f.name] ?? ''} onChange={(e) => onChange(e.target.value)} />;
81
+ break;
82
+ case 'enum':
83
+ input = (
84
+ <Dropdown
85
+ value={values[f.name] ?? null}
86
+ options={(f.values ?? []).map(v => ({ label: v, value: v }))}
87
+ onChange={(e) => onChange(e.value)}
88
+ placeholder="Choose…"
89
+ />
90
+ );
91
+ break;
92
+ case 'bool':
93
+ input = (
94
+ <Dropdown
95
+ value={values[f.name] ?? null}
96
+ options={[{ label: 'true', value: true }, { label: 'false', value: false }]}
97
+ onChange={(e) => onChange(e.value)}
98
+ />
99
+ );
100
+ break;
101
+ case 'u8': case 'u16': case 'u32': case 'u64':
102
+ case 'i8': case 'i16': case 'i32': case 'i64':
103
+ case 'f32': case 'f64':
104
+ input = (
105
+ <InputNumber
106
+ value={values[f.name] ?? null}
107
+ onValueChange={(e) => onChange(e.value)}
108
+ useGrouping={false}
109
+ maxFractionDigits={f.type.startsWith('f') ? 9 : 0}
110
+ />
111
+ );
112
+ break;
113
+ default:
114
+ input = <InputText value={values[f.name] ?? ''} onChange={(e) => onChange(e.target.value)} />;
115
+ break;
116
+ }
117
+ return [labelEl, input] as const;
118
+ };
119
+
120
+ 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;
126
+ }
127
+ }
128
+ setSubmitting(true);
129
+ setError(null);
130
+ try {
131
+ const resp: any = await invoke('ams.add_calibration' as any, MessageType.Request, {
132
+ asset_id: assetId,
133
+ performed_by: performedBy,
134
+ expires_at: expiresAt ? expiresAt.toISOString() : null,
135
+ cert_ref: certRef,
136
+ notes,
137
+ values,
138
+ } as any);
139
+ if (resp?.success) {
140
+ onAdded?.(resp.data.cal_id);
141
+ onHide();
142
+ } else {
143
+ setError(resp?.error_message ?? 'add_calibration failed');
144
+ }
145
+ } catch (e: any) {
146
+ setError(String(e?.message ?? e));
147
+ } finally {
148
+ setSubmitting(false);
149
+ }
150
+ };
151
+
152
+ return (
153
+ <Dialog
154
+ header={`Add Calibration — ${assetId}`}
155
+ visible={visible}
156
+ onHide={onHide}
157
+ style={{ width: '40rem' }}
158
+ footer={
159
+ <>
160
+ <Button label="Cancel" severity="secondary" onClick={onHide} disabled={submitting} />
161
+ <Button label="Save" icon="pi pi-check" onClick={onSubmit} loading={submitting} />
162
+ </>
163
+ }
164
+ >
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>
189
+
190
+ {error && (
191
+ <div style={{ marginTop: '1rem', color: '#ef4444' }}>
192
+ {error}
193
+ </div>
194
+ )}
195
+ </Dialog>
196
+ );
197
+ };