@burdenoff/microfe-movethewheels 2026.1030.1 → 2026.1031.2
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/ExportStatusBanner.js +53 -0
- package/dist/components/ShipmentRiskDialog.js +228 -0
- package/dist/components/TrackingAlertList.js +149 -0
- package/dist/generated/tracking-quality-operations.d.ts +1070 -2
- package/dist/generated/wspace-types.d.ts +2895 -50
- package/dist/hooks/useEntityExport.js +85 -0
- package/dist/hooks/useShipmentRisk.js +72 -0
- package/dist/hooks/useTracking.js +96 -82
- package/dist/movethewheels/components/ExportStatusBanner.d.ts +7 -0
- package/dist/movethewheels/components/ShipmentRiskDialog.d.ts +20 -0
- package/dist/movethewheels/components/TrackingAlertList.d.ts +14 -0
- package/dist/movethewheels/hooks/useEntityExport.d.ts +32 -0
- package/dist/movethewheels/hooks/useShipmentRisk.d.ts +41 -0
- package/dist/movethewheels/types/index.d.ts +15 -1
- package/dist/movethewheels/utils/entityExport.d.ts +64 -0
- package/dist/movethewheels/utils/trackingAlerts.d.ts +36 -0
- package/dist/pages/CustomersPage.js +64 -58
- package/dist/pages/DashboardPage.js +147 -151
- package/dist/pages/DriversPage.js +113 -107
- package/dist/pages/LiveTrackingPage.js +169 -169
- package/dist/pages/OrdersListPage.js +85 -79
- package/dist/pages/ProductsPage.js +63 -57
- package/dist/pages/VehiclesPage.js +91 -85
- package/dist/utils/entityExport.js +54 -0
- package/dist/utils/trackingAlerts.js +86 -0
- package/package.json +4 -2
- package/src/generated/tracking-quality-operations.ts +1409 -13
- package/src/generated/wspace-types.ts +3248 -51
- package/src/movethewheels/components/ExportStatusBanner.tsx +78 -0
- package/src/movethewheels/components/ShipmentRiskDialog.tsx +315 -0
- package/src/movethewheels/components/TrackingAlertList.tsx +188 -0
- package/src/movethewheels/hooks/useEntityExport.ts +130 -0
- package/src/movethewheels/hooks/useShipmentRisk.ts +195 -0
- package/src/movethewheels/hooks/useTracking.ts +66 -22
- package/src/movethewheels/pages/CustomersPage.tsx +11 -2
- package/src/movethewheels/pages/DashboardPage.tsx +10 -20
- package/src/movethewheels/pages/DriversPage.tsx +11 -2
- package/src/movethewheels/pages/LiveTrackingPage.tsx +20 -22
- package/src/movethewheels/pages/OrdersListPage.tsx +13 -2
- package/src/movethewheels/pages/ProductsPage.tsx +11 -2
- package/src/movethewheels/pages/VehiclesPage.tsx +11 -2
- package/src/movethewheels/types/index.ts +16 -1
- package/src/movethewheels/utils/entityExport.ts +140 -0
- package/src/movethewheels/utils/trackingAlerts.ts +151 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
3
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
4
|
+
import {
|
|
5
|
+
GET_MTW_EXPORT_JOB_QUERY,
|
|
6
|
+
MTW_EXPORT_ENTITY_TYPES,
|
|
7
|
+
START_MTW_EXPORT_MUTATION,
|
|
8
|
+
triggerUrlDownload,
|
|
9
|
+
waitForExportJob,
|
|
10
|
+
type ExportJobSnapshot,
|
|
11
|
+
type MtwExportEntity,
|
|
12
|
+
} from '../utils/entityExport';
|
|
13
|
+
|
|
14
|
+
export type EntityExportState =
|
|
15
|
+
| { phase: 'idle' }
|
|
16
|
+
| { phase: 'running'; message: string }
|
|
17
|
+
| { phase: 'ready'; message: string }
|
|
18
|
+
| { phase: 'pending'; message: string; jobId: string }
|
|
19
|
+
| { phase: 'error'; message: string };
|
|
20
|
+
|
|
21
|
+
export interface UseEntityExportResult {
|
|
22
|
+
state: EntityExportState;
|
|
23
|
+
isExporting: boolean;
|
|
24
|
+
/** Start a CSV export of every record of this entity in the workspace. */
|
|
25
|
+
startExport: () => void;
|
|
26
|
+
dismiss: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drives the list-page "Export" action: starts an `exportMtwEntities` job for
|
|
31
|
+
* the page's entity type, waits for wspace-export-svc to build the archive and
|
|
32
|
+
* starts the download. A job that takes longer than the wait budget keeps
|
|
33
|
+
* running and is reported as pending, with its id, so the user can collect it
|
|
34
|
+
* from the shared Exports page.
|
|
35
|
+
*/
|
|
36
|
+
export function useEntityExport(entity: MtwExportEntity, label: string): UseEntityExportResult {
|
|
37
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
38
|
+
useMoveTheWheels();
|
|
39
|
+
const [state, setState] = useState<EntityExportState>({ phase: 'idle' });
|
|
40
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
41
|
+
const runningRef = useRef(false);
|
|
42
|
+
|
|
43
|
+
const graphQLOptions = useMemo(
|
|
44
|
+
() => ({
|
|
45
|
+
apiGatewayUrl,
|
|
46
|
+
authToken,
|
|
47
|
+
workspaceToken,
|
|
48
|
+
workspaceId,
|
|
49
|
+
tenantId,
|
|
50
|
+
actorId: currentUser?.id,
|
|
51
|
+
}),
|
|
52
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
56
|
+
|
|
57
|
+
const startExport = useCallback(() => {
|
|
58
|
+
// Starting a second job while one is being prepared would only duplicate work.
|
|
59
|
+
if (runningRef.current) return;
|
|
60
|
+
if (!workspaceId) {
|
|
61
|
+
setState({ phase: 'error', message: 'Select a workspace before exporting.' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
abortRef.current?.abort();
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
abortRef.current = controller;
|
|
67
|
+
setState({ phase: 'running', message: `Preparing your ${label} export…` });
|
|
68
|
+
|
|
69
|
+
const run = async () => {
|
|
70
|
+
runningRef.current = true;
|
|
71
|
+
try {
|
|
72
|
+
const started = await executeGraphQL<{ exportMtwEntities: ExportJobSnapshot }>(
|
|
73
|
+
graphQLOptions,
|
|
74
|
+
START_MTW_EXPORT_MUTATION,
|
|
75
|
+
{
|
|
76
|
+
input: {
|
|
77
|
+
workspaceId,
|
|
78
|
+
entityTypes: [MTW_EXPORT_ENTITY_TYPES[entity]],
|
|
79
|
+
format: 'CSV',
|
|
80
|
+
includeMetadata: true,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
// Starting a job is not idempotent: never replay it on a transient error.
|
|
84
|
+
'SINGLE_ATTEMPT'
|
|
85
|
+
);
|
|
86
|
+
const outcome = await waitForExportJob(
|
|
87
|
+
started.exportMtwEntities,
|
|
88
|
+
async (id) => {
|
|
89
|
+
const data = await executeGraphQL<{ getExportJob: ExportJobSnapshot | null }>(
|
|
90
|
+
{ ...graphQLOptions, signal: controller.signal },
|
|
91
|
+
GET_MTW_EXPORT_JOB_QUERY,
|
|
92
|
+
{ id }
|
|
93
|
+
);
|
|
94
|
+
return data.getExportJob;
|
|
95
|
+
},
|
|
96
|
+
{ signal: controller.signal }
|
|
97
|
+
);
|
|
98
|
+
if (controller.signal.aborted) return;
|
|
99
|
+
if (outcome.kind === 'ready') {
|
|
100
|
+
triggerUrlDownload(outcome.downloadUrl);
|
|
101
|
+
setState({ phase: 'ready', message: `Your ${label} export is downloading.` });
|
|
102
|
+
} else if (outcome.kind === 'pending') {
|
|
103
|
+
setState({
|
|
104
|
+
phase: 'pending',
|
|
105
|
+
jobId: outcome.jobId,
|
|
106
|
+
message: `Your ${label} export is still being prepared. It will be available on the Exports page when it finishes.`,
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
setState({ phase: 'error', message: `Export failed: ${outcome.message}` });
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (controller.signal.aborted) return;
|
|
113
|
+
setState({
|
|
114
|
+
phase: 'error',
|
|
115
|
+
message:
|
|
116
|
+
error instanceof Error && error.message
|
|
117
|
+
? `Export failed: ${error.message}`
|
|
118
|
+
: 'Export failed. Please try again.',
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
runningRef.current = false;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
void run();
|
|
125
|
+
}, [entity, graphQLOptions, label, workspaceId]);
|
|
126
|
+
|
|
127
|
+
const dismiss = useCallback(() => setState({ phase: 'idle' }), []);
|
|
128
|
+
|
|
129
|
+
return { state, isExporting: state.phase === 'running', startExport, dismiss };
|
|
130
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipment delivery-risk forecast (BOFF-10397).
|
|
3
|
+
*
|
|
4
|
+
* Reads `mtwShipmentRiskForecast` for one shipment — the deterministic
|
|
5
|
+
* forecast wspace-movethewheels-svc computes from the shipment's legs,
|
|
6
|
+
* routes, tracking events, vehicle and carrier history — together with the
|
|
7
|
+
* few shipment fields the recommended actions need. The actions call the
|
|
8
|
+
* mutation the forecast names (`updateShipment` to re-promise,
|
|
9
|
+
* `addTrackingEvent` to record a tracking update); reassigning a vehicle
|
|
10
|
+
* happens on the order page.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
14
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
15
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
16
|
+
import type { RiskSignalCode } from '../utils/trackingAlerts';
|
|
17
|
+
|
|
18
|
+
export type RiskLevel = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
|
|
19
|
+
export type RiskRecommendedAction =
|
|
20
|
+
'NONE' | 'MONITOR' | 'REPROMISE_DELIVERY' | 'REQUEST_TRACKING_UPDATE' | 'REASSIGN_VEHICLE';
|
|
21
|
+
|
|
22
|
+
export interface RiskSignal {
|
|
23
|
+
code: RiskSignalCode;
|
|
24
|
+
weight: number;
|
|
25
|
+
minutes: number | null;
|
|
26
|
+
ratio: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ShipmentRiskForecast {
|
|
30
|
+
shipmentId: string;
|
|
31
|
+
shipmentNumber: string;
|
|
32
|
+
status: string;
|
|
33
|
+
method: string;
|
|
34
|
+
promisedDelivery: string | null;
|
|
35
|
+
predictedDeliveryEarliest: string | null;
|
|
36
|
+
predictedDeliveryLatest: string | null;
|
|
37
|
+
predictedSlipMinutes: number;
|
|
38
|
+
riskScore: number;
|
|
39
|
+
riskLevel: RiskLevel;
|
|
40
|
+
atRisk: boolean;
|
|
41
|
+
breached: boolean;
|
|
42
|
+
signals: RiskSignal[];
|
|
43
|
+
recommendedAction: RiskRecommendedAction;
|
|
44
|
+
recommendedMutation: string | null;
|
|
45
|
+
evaluatedAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RiskShipment {
|
|
49
|
+
id: string;
|
|
50
|
+
deliveryJobId: string | null;
|
|
51
|
+
estimatedDelivery: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SHIPMENT_RISK_QUERY = `
|
|
55
|
+
query ShipmentRiskForecast($shipmentId: ID!) {
|
|
56
|
+
mtwShipmentRiskForecast(shipmentId: $shipmentId) {
|
|
57
|
+
shipmentId
|
|
58
|
+
shipmentNumber
|
|
59
|
+
status
|
|
60
|
+
method
|
|
61
|
+
promisedDelivery
|
|
62
|
+
predictedDeliveryEarliest
|
|
63
|
+
predictedDeliveryLatest
|
|
64
|
+
predictedSlipMinutes
|
|
65
|
+
riskScore
|
|
66
|
+
riskLevel
|
|
67
|
+
atRisk
|
|
68
|
+
breached
|
|
69
|
+
signals {
|
|
70
|
+
code
|
|
71
|
+
weight
|
|
72
|
+
minutes
|
|
73
|
+
ratio
|
|
74
|
+
}
|
|
75
|
+
recommendedAction
|
|
76
|
+
recommendedMutation
|
|
77
|
+
evaluatedAt
|
|
78
|
+
}
|
|
79
|
+
shipment(id: $shipmentId) {
|
|
80
|
+
id
|
|
81
|
+
deliveryJobId
|
|
82
|
+
estimatedDelivery
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const REPROMISE_SHIPMENT_MUTATION = `
|
|
88
|
+
mutation RepromiseShipment($id: ID!, $input: UpdateShipmentInput!) {
|
|
89
|
+
updateShipment(id: $id, input: $input) {
|
|
90
|
+
id
|
|
91
|
+
estimatedDelivery
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const RECORD_TRACKING_UPDATE_MUTATION = `
|
|
97
|
+
mutation RecordTrackingUpdate($input: AddTrackingEventInput!) {
|
|
98
|
+
addTrackingEvent(input: $input) {
|
|
99
|
+
id
|
|
100
|
+
timestamp
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
function useGraphqlOptions() {
|
|
106
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
107
|
+
useMoveTheWheels();
|
|
108
|
+
return useMemo(
|
|
109
|
+
() => ({
|
|
110
|
+
apiGatewayUrl,
|
|
111
|
+
authToken,
|
|
112
|
+
workspaceToken,
|
|
113
|
+
workspaceId,
|
|
114
|
+
tenantId,
|
|
115
|
+
actorId: currentUser?.id,
|
|
116
|
+
}),
|
|
117
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useShipmentRisk(shipmentId: string | undefined) {
|
|
122
|
+
const graphQLOptions = useGraphqlOptions();
|
|
123
|
+
const [forecast, setForecast] = useState<ShipmentRiskForecast | null>(null);
|
|
124
|
+
const [shipment, setShipment] = useState<RiskShipment | null>(null);
|
|
125
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
126
|
+
const [error, setError] = useState<Error | null>(null);
|
|
127
|
+
|
|
128
|
+
const load = useCallback(async () => {
|
|
129
|
+
if (!shipmentId) {
|
|
130
|
+
setForecast(null);
|
|
131
|
+
setShipment(null);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setIsLoading(true);
|
|
135
|
+
setError(null);
|
|
136
|
+
try {
|
|
137
|
+
const data = await executeGraphQL<{
|
|
138
|
+
mtwShipmentRiskForecast: ShipmentRiskForecast;
|
|
139
|
+
shipment: RiskShipment | null;
|
|
140
|
+
}>(graphQLOptions, SHIPMENT_RISK_QUERY, { shipmentId });
|
|
141
|
+
setForecast(data.mtwShipmentRiskForecast);
|
|
142
|
+
setShipment(data.shipment);
|
|
143
|
+
} catch (fetchError) {
|
|
144
|
+
setError(
|
|
145
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load the risk forecast')
|
|
146
|
+
);
|
|
147
|
+
} finally {
|
|
148
|
+
setIsLoading(false);
|
|
149
|
+
}
|
|
150
|
+
}, [graphQLOptions, shipmentId]);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
void load();
|
|
154
|
+
}, [load]);
|
|
155
|
+
|
|
156
|
+
/** Move the shipment's promised delivery to `estimatedDelivery` (ISO date-time). */
|
|
157
|
+
const repromise = useCallback(
|
|
158
|
+
async (estimatedDelivery: string): Promise<void> => {
|
|
159
|
+
if (!shipmentId) return;
|
|
160
|
+
await executeGraphQL(
|
|
161
|
+
graphQLOptions,
|
|
162
|
+
REPROMISE_SHIPMENT_MUTATION,
|
|
163
|
+
{ id: shipmentId, input: { estimatedDelivery } },
|
|
164
|
+
'SINGLE_ATTEMPT'
|
|
165
|
+
);
|
|
166
|
+
await load();
|
|
167
|
+
},
|
|
168
|
+
[graphQLOptions, load, shipmentId]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
/** Record a fresh in-transit tracking update reported by the driver or carrier. */
|
|
172
|
+
const recordTrackingUpdate = useCallback(
|
|
173
|
+
async (details: string): Promise<void> => {
|
|
174
|
+
if (!shipmentId) return;
|
|
175
|
+
await executeGraphQL(
|
|
176
|
+
graphQLOptions,
|
|
177
|
+
RECORD_TRACKING_UPDATE_MUTATION,
|
|
178
|
+
{ input: { shipmentId, eventType: 'IN_TRANSIT', details } },
|
|
179
|
+
'SINGLE_ATTEMPT'
|
|
180
|
+
);
|
|
181
|
+
await load();
|
|
182
|
+
},
|
|
183
|
+
[graphQLOptions, load, shipmentId]
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
forecast,
|
|
188
|
+
shipment,
|
|
189
|
+
isLoading,
|
|
190
|
+
error,
|
|
191
|
+
refetch: () => void load(),
|
|
192
|
+
repromise,
|
|
193
|
+
recordTrackingUpdate,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
useDrivers,
|
|
14
14
|
} from './useFleet';
|
|
15
15
|
import { mapOrder, type ApiOrder, useOrder } from './useOrders';
|
|
16
|
+
import {
|
|
17
|
+
mapTrackingAlert,
|
|
18
|
+
sortTrackingAlerts,
|
|
19
|
+
type ApiTrackingAlert,
|
|
20
|
+
} from '../utils/trackingAlerts';
|
|
16
21
|
|
|
17
22
|
interface UseTrackingOptions {
|
|
18
23
|
entityId?: string;
|
|
@@ -205,10 +210,27 @@ const ACTIVE_DELIVERIES_QUERY = `
|
|
|
205
210
|
const TRACKING_ALERTS_QUERY = `
|
|
206
211
|
query GetTrackingAlerts {
|
|
207
212
|
trackingAlerts {
|
|
213
|
+
id
|
|
208
214
|
shipmentId
|
|
209
215
|
shipmentNumber
|
|
210
216
|
alertType
|
|
211
217
|
message
|
|
218
|
+
riskScore
|
|
219
|
+
promisedDelivery
|
|
220
|
+
predictedDelivery
|
|
221
|
+
signals
|
|
222
|
+
acknowledged
|
|
223
|
+
acknowledgedAt
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
`;
|
|
227
|
+
|
|
228
|
+
const ACKNOWLEDGE_TRACKING_ALERT_MUTATION = `
|
|
229
|
+
mutation AcknowledgeMtwTrackingAlert($shipmentId: ID!, $alertType: MtwTrackingAlertType!) {
|
|
230
|
+
acknowledgeMtwTrackingAlert(shipmentId: $shipmentId, alertType: $alertType) {
|
|
231
|
+
id
|
|
232
|
+
acknowledged
|
|
233
|
+
acknowledgedAt
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
`;
|
|
@@ -631,24 +653,11 @@ export function useTrackingAlerts() {
|
|
|
631
653
|
setIsLoading(true);
|
|
632
654
|
setError(null);
|
|
633
655
|
try {
|
|
634
|
-
const data = await executeGraphQL<{
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
shipmentNumber: string;
|
|
638
|
-
alertType: string;
|
|
639
|
-
message: string;
|
|
640
|
-
}>;
|
|
641
|
-
}>(graphQLOptions, TRACKING_ALERTS_QUERY);
|
|
642
|
-
|
|
643
|
-
setAlerts(
|
|
644
|
-
data.trackingAlerts.map((alert) => ({
|
|
645
|
-
id: `${alert.shipmentId}-${alert.alertType}`,
|
|
646
|
-
type: 'delay',
|
|
647
|
-
severity: 'medium',
|
|
648
|
-
message: `${alert.shipmentNumber}: ${alert.message}`,
|
|
649
|
-
acknowledged: false,
|
|
650
|
-
}))
|
|
656
|
+
const data = await executeGraphQL<{ trackingAlerts: ApiTrackingAlert[] }>(
|
|
657
|
+
graphQLOptions,
|
|
658
|
+
TRACKING_ALERTS_QUERY
|
|
651
659
|
);
|
|
660
|
+
setAlerts(sortTrackingAlerts(data.trackingAlerts.map(mapTrackingAlert)));
|
|
652
661
|
} catch (fetchError) {
|
|
653
662
|
setError(
|
|
654
663
|
fetchError instanceof Error ? fetchError : new Error('Failed to load tracking alerts')
|
|
@@ -664,11 +673,46 @@ export function useTrackingAlerts() {
|
|
|
664
673
|
|
|
665
674
|
const unacknowledgedAlerts = useMemo(() => alerts.filter((item) => !item.acknowledged), [alerts]);
|
|
666
675
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Persist the acknowledgement server-side (BOFF-10397) so it survives a
|
|
678
|
+
* reload and is shared with the rest of the workspace. The alert is hidden
|
|
679
|
+
* optimistically and restored if the mutation fails.
|
|
680
|
+
*/
|
|
681
|
+
const acknowledgeAlert = useCallback(
|
|
682
|
+
async (alertId: string): Promise<void> => {
|
|
683
|
+
const target = alerts.find((item) => item.id === alertId);
|
|
684
|
+
if (!target?.shipmentId || !target.kind) return;
|
|
685
|
+
setAlerts((previous) =>
|
|
686
|
+
previous.map((item) => (item.id === alertId ? { ...item, acknowledged: true } : item))
|
|
687
|
+
);
|
|
688
|
+
try {
|
|
689
|
+
const data = await executeGraphQL<{
|
|
690
|
+
acknowledgeMtwTrackingAlert: {
|
|
691
|
+
id: string;
|
|
692
|
+
acknowledged: boolean;
|
|
693
|
+
acknowledgedAt: string;
|
|
694
|
+
};
|
|
695
|
+
}>(
|
|
696
|
+
graphQLOptions,
|
|
697
|
+
ACKNOWLEDGE_TRACKING_ALERT_MUTATION,
|
|
698
|
+
// Idempotent upsert server-side, so transient retries are safe.
|
|
699
|
+
{ shipmentId: target.shipmentId, alertType: target.kind }
|
|
700
|
+
);
|
|
701
|
+
const acknowledgedAt = new Date(data.acknowledgeMtwTrackingAlert.acknowledgedAt);
|
|
702
|
+
setAlerts((previous) =>
|
|
703
|
+
previous.map((item) =>
|
|
704
|
+
item.id === alertId ? { ...item, acknowledged: true, acknowledgedAt } : item
|
|
705
|
+
)
|
|
706
|
+
);
|
|
707
|
+
} catch (ackError) {
|
|
708
|
+
setAlerts((previous) =>
|
|
709
|
+
previous.map((item) => (item.id === alertId ? { ...item, acknowledged: false } : item))
|
|
710
|
+
);
|
|
711
|
+
throw ackError instanceof Error ? ackError : new Error('Failed to acknowledge alert');
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
[alerts, graphQLOptions]
|
|
715
|
+
);
|
|
672
716
|
|
|
673
717
|
return {
|
|
674
718
|
alerts,
|
|
@@ -15,10 +15,18 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
|
15
15
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
16
16
|
import { formatDate } from '../utils/formatters';
|
|
17
17
|
import { joinPath } from '../utils/navigation';
|
|
18
|
+
import { useEntityExport } from '../hooks/useEntityExport';
|
|
19
|
+
import { ExportStatusBanner } from '../components/ExportStatusBanner';
|
|
18
20
|
import type { Customer } from '../types';
|
|
19
21
|
|
|
20
22
|
export default function CustomersPage() {
|
|
21
23
|
const { basePath, navigate } = useMoveTheWheels();
|
|
24
|
+
const {
|
|
25
|
+
state: exportState,
|
|
26
|
+
isExporting,
|
|
27
|
+
startExport,
|
|
28
|
+
dismiss: dismissExport,
|
|
29
|
+
} = useEntityExport('customers', 'customer');
|
|
22
30
|
const [page, setPage] = useState(0);
|
|
23
31
|
const [pageSize, setPageSize] = useState(25);
|
|
24
32
|
const [search, setSearch] = useState('');
|
|
@@ -104,9 +112,9 @@ export default function CustomersPage() {
|
|
|
104
112
|
actions={[
|
|
105
113
|
{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
|
|
106
114
|
{
|
|
107
|
-
label: 'Export',
|
|
115
|
+
label: isExporting ? 'Exporting…' : 'Export',
|
|
108
116
|
icon: <Download size={16} />,
|
|
109
|
-
onSelect:
|
|
117
|
+
onSelect: startExport,
|
|
110
118
|
},
|
|
111
119
|
]}
|
|
112
120
|
/>
|
|
@@ -117,6 +125,7 @@ export default function CustomersPage() {
|
|
|
117
125
|
satisfaction ratings here, and open a customer to see their order history before booking the
|
|
118
126
|
next shipment.
|
|
119
127
|
</PagePurpose>
|
|
128
|
+
<ExportStatusBanner state={exportState} onDismiss={dismissExport} />
|
|
120
129
|
|
|
121
130
|
{/* Customer base at a glance — premium stat cards */}
|
|
122
131
|
<div>
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { PageLayout, PageSection } from '../components/PageLayout';
|
|
22
22
|
import { MetricCard, MetricGrid, type MetricDelta } from '../components/MetricCard';
|
|
23
23
|
import { StatusBadge } from '../components/StatusBadge';
|
|
24
|
+
import { TrackingAlertList } from '../components/TrackingAlertList';
|
|
24
25
|
import { LogisticsMap, type LogisticsMarker } from '../components/maps';
|
|
25
26
|
import { VolumeBar } from '../components/charts';
|
|
26
27
|
import { deliveryMarkerStatus, firstRealLatLng } from '../utils/mapMarkers';
|
|
@@ -67,7 +68,7 @@ export default function DashboardPage() {
|
|
|
67
68
|
const { stats: orderStats } = useOrderStats();
|
|
68
69
|
const { stats: fleetStats } = useFleetStats();
|
|
69
70
|
const { activeDeliveries } = useLiveTracking();
|
|
70
|
-
const { unacknowledgedAlerts } = useTrackingAlerts();
|
|
71
|
+
const { unacknowledgedAlerts, acknowledgeAlert, refetch: refetchAlerts } = useTrackingAlerts();
|
|
71
72
|
|
|
72
73
|
const orderSpark = useMemo(
|
|
73
74
|
() => analytics.trends.dailyOrders.map((d) => d.orders),
|
|
@@ -265,25 +266,14 @@ export default function DashboardPage() {
|
|
|
265
266
|
</CardHeader>
|
|
266
267
|
<CardContent>
|
|
267
268
|
{unacknowledgedAlerts.length > 0 ? (
|
|
268
|
-
<
|
|
269
|
-
{unacknowledgedAlerts
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}`}
|
|
277
|
-
>
|
|
278
|
-
<p className="font-medium">{alert.message}</p>
|
|
279
|
-
<p className="mt-1 text-xs text-text-secondary">
|
|
280
|
-
{alert.timestamp
|
|
281
|
-
? formatDate(alert.timestamp, 'relative')
|
|
282
|
-
: 'Time not recorded'}
|
|
283
|
-
</p>
|
|
284
|
-
</div>
|
|
285
|
-
))}
|
|
286
|
-
</div>
|
|
269
|
+
<TrackingAlertList
|
|
270
|
+
alerts={unacknowledgedAlerts}
|
|
271
|
+
limit={4}
|
|
272
|
+
t={t}
|
|
273
|
+
onAcknowledge={acknowledgeAlert}
|
|
274
|
+
onOpenOrder={(deliveryJobId) => goTo(`orders/${deliveryJobId}`)}
|
|
275
|
+
onChanged={refetchAlerts}
|
|
276
|
+
/>
|
|
287
277
|
) : (
|
|
288
278
|
<p className="py-4 text-center text-sm text-text-secondary">
|
|
289
279
|
{t(
|
|
@@ -37,6 +37,8 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
|
37
37
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
38
38
|
import { formatDate } from '../utils/formatters';
|
|
39
39
|
import { joinPath } from '../utils/navigation';
|
|
40
|
+
import { useEntityExport } from '../hooks/useEntityExport';
|
|
41
|
+
import { ExportStatusBanner } from '../components/ExportStatusBanner';
|
|
40
42
|
import { DRIVER_STATUS_LABELS } from '../constants';
|
|
41
43
|
import type { Driver, DriverStatus } from '../types';
|
|
42
44
|
|
|
@@ -52,6 +54,12 @@ const STATUS_OPTIONS: Array<{ value: DriverStatus | 'all'; label: string }> = [
|
|
|
52
54
|
|
|
53
55
|
export default function DriversPage() {
|
|
54
56
|
const { basePath, navigate } = useMoveTheWheels();
|
|
57
|
+
const {
|
|
58
|
+
state: exportState,
|
|
59
|
+
isExporting,
|
|
60
|
+
startExport,
|
|
61
|
+
dismiss: dismissExport,
|
|
62
|
+
} = useEntityExport('drivers', 'driver');
|
|
55
63
|
const [page, setPage] = useState(0);
|
|
56
64
|
const [pageSize, setPageSize] = useState(25);
|
|
57
65
|
const [search, setSearch] = useState('');
|
|
@@ -224,9 +232,9 @@ export default function DriversPage() {
|
|
|
224
232
|
actions={[
|
|
225
233
|
{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
|
|
226
234
|
{
|
|
227
|
-
label: 'Export',
|
|
235
|
+
label: isExporting ? 'Exporting…' : 'Export',
|
|
228
236
|
icon: <Download size={16} />,
|
|
229
|
-
onSelect:
|
|
237
|
+
onSelect: startExport,
|
|
230
238
|
},
|
|
231
239
|
]}
|
|
232
240
|
/>
|
|
@@ -237,6 +245,7 @@ export default function DriversPage() {
|
|
|
237
245
|
contact number and the vehicle they are on — open a driver to review their record before
|
|
238
246
|
assigning them to an order or route.
|
|
239
247
|
</PagePurpose>
|
|
248
|
+
<ExportStatusBanner state={exportState} onDismiss={dismissExport} />
|
|
240
249
|
|
|
241
250
|
{/* Workforce at a glance — premium stat cards */}
|
|
242
251
|
<div>
|
|
@@ -28,13 +28,20 @@ import { useLiveTracking, useTrackingAlerts } from '../hooks/useTracking';
|
|
|
28
28
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
29
29
|
import { formatDate } from '../utils/formatters';
|
|
30
30
|
import { joinPath } from '../utils/navigation';
|
|
31
|
+
import { TrackingAlertList } from '../components/TrackingAlertList';
|
|
32
|
+
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
31
33
|
|
|
32
34
|
type LiveFilter = 'all' | 'in_transit' | 'out_for_delivery' | 'other';
|
|
33
35
|
|
|
34
36
|
export default function LiveTrackingPage() {
|
|
35
37
|
const { basePath, navigate } = useMoveTheWheels();
|
|
36
38
|
const { activeDeliveries, isLoading, refetch } = useLiveTracking();
|
|
37
|
-
const {
|
|
39
|
+
const { t: translate } = useI18n();
|
|
40
|
+
const t = (key: string, fallback: string): string => {
|
|
41
|
+
const value = translate(key);
|
|
42
|
+
return value === key ? fallback : value;
|
|
43
|
+
};
|
|
44
|
+
const { unacknowledgedAlerts, acknowledgeAlert, refetch: refetchAlerts } = useTrackingAlerts();
|
|
38
45
|
const [viewMode, setViewMode] = useState<'map' | 'list'>('map');
|
|
39
46
|
const [selectedDelivery, setSelectedDelivery] = useState<string | null>(null);
|
|
40
47
|
const [filter, setFilter] = useState<LiveFilter>('all');
|
|
@@ -420,7 +427,7 @@ export default function LiveTrackingPage() {
|
|
|
420
427
|
<Card>
|
|
421
428
|
<CardHeader>
|
|
422
429
|
<CardTitle className="flex items-center gap-2">
|
|
423
|
-
Alerts
|
|
430
|
+
{t('movethewheels.tracking.live.alerts', 'Alerts')}
|
|
424
431
|
{unacknowledgedAlerts.length > 0 && (
|
|
425
432
|
<Badge variant="destructive">{unacknowledgedAlerts.length}</Badge>
|
|
426
433
|
)}
|
|
@@ -428,28 +435,19 @@ export default function LiveTrackingPage() {
|
|
|
428
435
|
</CardHeader>
|
|
429
436
|
<CardContent>
|
|
430
437
|
{unacknowledgedAlerts.length > 0 ? (
|
|
431
|
-
<
|
|
432
|
-
{unacknowledgedAlerts
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}`}
|
|
440
|
-
>
|
|
441
|
-
<p className="font-medium">{alert.message}</p>
|
|
442
|
-
<p className="text-xs text-text-secondary mt-1">
|
|
443
|
-
{alert.timestamp
|
|
444
|
-
? formatDate(alert.timestamp, 'relative')
|
|
445
|
-
: 'Time not recorded'}
|
|
446
|
-
</p>
|
|
447
|
-
</div>
|
|
448
|
-
))}
|
|
449
|
-
</div>
|
|
438
|
+
<TrackingAlertList
|
|
439
|
+
alerts={unacknowledgedAlerts}
|
|
440
|
+
limit={5}
|
|
441
|
+
t={t}
|
|
442
|
+
onAcknowledge={acknowledgeAlert}
|
|
443
|
+
onOpenOrder={(deliveryJobId) => goTo(`orders/${deliveryJobId}`)}
|
|
444
|
+
onChanged={refetchAlerts}
|
|
445
|
+
/>
|
|
450
446
|
) : (
|
|
451
447
|
<div className="text-center text-text-secondary py-4">
|
|
452
|
-
<p className="text-sm">
|
|
448
|
+
<p className="text-sm">
|
|
449
|
+
{t('movethewheels.tracking.live.noAlerts', 'No active alerts')}
|
|
450
|
+
</p>
|
|
453
451
|
</div>
|
|
454
452
|
)}
|
|
455
453
|
</CardContent>
|