@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.
- package/dist/components/ams/AmsProvider.d.ts +45 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -0
- package/dist/components/ams/AmsProvider.js +1 -0
- package/dist/components/ams/AssetDetailView.d.ts +3 -0
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -0
- package/dist/components/ams/AssetDetailView.js +1 -0
- package/dist/components/ams/AssetRegistryTable.d.ts +3 -0
- package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -0
- package/dist/components/ams/AssetRegistryTable.js +1 -0
- package/dist/components/ams/CalibrationEntryDialog.d.ts +10 -0
- package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -0
- package/dist/components/ams/CalibrationEntryDialog.js +1 -0
- package/dist/components/ams/SubLocationPicker.d.ts +3 -0
- package/dist/components/ams/SubLocationPicker.d.ts.map +1 -0
- package/dist/components/ams/SubLocationPicker.js +1 -0
- package/dist/components/ams/index.d.ts +6 -0
- package/dist/components/ams/index.d.ts.map +1 -0
- package/dist/components/ams/index.js +1 -0
- package/dist/components/index.d.ts +7 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +9 -1
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +17 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/core/AutoCoreTagContext.d.ts +16 -0
- package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.js +1 -1
- package/dist/themes/adc-dark/blue/theme.css +67 -37
- package/dist/themes/adc-dark/blue/theme.css.map +1 -1
- package/package.json +1 -1
- package/src/components/ams/AmsProvider.tsx +219 -0
- package/src/components/ams/AssetDetailView.tsx +101 -0
- package/src/components/ams/AssetRegistryTable.tsx +171 -0
- package/src/components/ams/CalibrationEntryDialog.tsx +197 -0
- package/src/components/ams/SubLocationPicker.tsx +146 -0
- package/src/components/ams/index.ts +12 -0
- package/src/components/index.ts +27 -0
- package/src/components/tis/TestDataView.tsx +321 -28
- package/src/components/tis/TisProvider.tsx +44 -0
- package/src/core/AutoCoreTagContext.tsx +114 -16
- package/src/themes/adc-dark/_extensions.scss +15 -0
- 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
|
+
};
|