@burdenoff/microfe-movethewheels 2026.1031.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/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/useShipmentRisk.js +72 -0
- package/dist/hooks/useTracking.js +96 -82
- package/dist/movethewheels/components/ShipmentRiskDialog.d.ts +20 -0
- package/dist/movethewheels/components/TrackingAlertList.d.ts +14 -0
- package/dist/movethewheels/hooks/useShipmentRisk.d.ts +41 -0
- package/dist/movethewheels/types/index.d.ts +15 -1
- package/dist/movethewheels/utils/trackingAlerts.d.ts +36 -0
- package/dist/pages/DashboardPage.js +147 -151
- package/dist/pages/LiveTrackingPage.js +169 -169
- package/dist/utils/trackingAlerts.js +86 -0
- package/package.json +3 -2
- package/src/generated/tracking-quality-operations.ts +1409 -13
- package/src/generated/wspace-types.ts +3248 -51
- package/src/movethewheels/components/ShipmentRiskDialog.tsx +315 -0
- package/src/movethewheels/components/TrackingAlertList.tsx +188 -0
- package/src/movethewheels/hooks/useShipmentRisk.ts +195 -0
- package/src/movethewheels/hooks/useTracking.ts +66 -22
- package/src/movethewheels/pages/DashboardPage.tsx +10 -20
- package/src/movethewheels/pages/LiveTrackingPage.tsx +20 -22
- package/src/movethewheels/types/index.ts +16 -1
- package/src/movethewheels/utils/trackingAlerts.ts +151 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShipmentRiskDialog (BOFF-10397)
|
|
3
|
+
*
|
|
4
|
+
* The risk panel for one shipment, backed by `mtwShipmentRiskForecast`: risk
|
|
5
|
+
* score and level, promised vs predicted delivery window, the contributing
|
|
6
|
+
* signals with their weights, and the recommended action wired to the
|
|
7
|
+
* mutation the forecast names.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Badge,
|
|
14
|
+
Button,
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogDescription,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
Skeleton,
|
|
21
|
+
Textarea,
|
|
22
|
+
} from './ui';
|
|
23
|
+
import {
|
|
24
|
+
useShipmentRisk,
|
|
25
|
+
type RiskLevel,
|
|
26
|
+
type RiskRecommendedAction,
|
|
27
|
+
} from '../hooks/useShipmentRisk';
|
|
28
|
+
import { formatDateTime } from '../utils/formatters';
|
|
29
|
+
import { formatSlip, riskSignalLabel } from '../utils/trackingAlerts';
|
|
30
|
+
|
|
31
|
+
type Translate = (key: string, fallback: string) => string;
|
|
32
|
+
|
|
33
|
+
interface ShipmentRiskDialogProps {
|
|
34
|
+
shipmentId: string | undefined;
|
|
35
|
+
shipmentNumber: string | undefined;
|
|
36
|
+
open: boolean;
|
|
37
|
+
onOpenChange: (open: boolean) => void;
|
|
38
|
+
t: Translate;
|
|
39
|
+
onOpenOrder: (deliveryJobId: string) => void;
|
|
40
|
+
onChanged: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LEVEL_LABELS: Record<RiskLevel, { key: string; fallback: string }> = {
|
|
44
|
+
NONE: { key: 'movethewheels.risk.level.none', fallback: 'No risk' },
|
|
45
|
+
LOW: { key: 'movethewheels.risk.level.low', fallback: 'Low' },
|
|
46
|
+
MEDIUM: { key: 'movethewheels.risk.level.medium', fallback: 'Medium' },
|
|
47
|
+
HIGH: { key: 'movethewheels.risk.level.high', fallback: 'High' },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ACTION_LABELS: Record<RiskRecommendedAction, { key: string; fallback: string }> = {
|
|
51
|
+
NONE: { key: 'movethewheels.risk.action.none', fallback: 'No action needed' },
|
|
52
|
+
MONITOR: { key: 'movethewheels.risk.action.monitor', fallback: 'Keep monitoring this shipment' },
|
|
53
|
+
REPROMISE_DELIVERY: {
|
|
54
|
+
key: 'movethewheels.risk.action.repromise',
|
|
55
|
+
fallback: 'Re-promise the delivery to the predicted time',
|
|
56
|
+
},
|
|
57
|
+
REQUEST_TRACKING_UPDATE: {
|
|
58
|
+
key: 'movethewheels.risk.action.trackingUpdate',
|
|
59
|
+
fallback: 'Get a tracking update from the driver or carrier',
|
|
60
|
+
},
|
|
61
|
+
REASSIGN_VEHICLE: {
|
|
62
|
+
key: 'movethewheels.risk.action.reassign',
|
|
63
|
+
fallback: 'Reassign the vehicle on this order',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function describeSignal(
|
|
68
|
+
signal: { minutes: number | null; ratio: number | null },
|
|
69
|
+
t: Translate
|
|
70
|
+
): string | null {
|
|
71
|
+
if (signal.ratio !== null) {
|
|
72
|
+
return `${Math.round(signal.ratio * 100)}% ${t('movethewheels.risk.lateRate', 'late recently')}`;
|
|
73
|
+
}
|
|
74
|
+
if (signal.minutes !== null && signal.minutes > 0) return formatSlip(signal.minutes);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ShipmentRiskDialog({
|
|
79
|
+
shipmentId,
|
|
80
|
+
shipmentNumber,
|
|
81
|
+
open,
|
|
82
|
+
onOpenChange,
|
|
83
|
+
t,
|
|
84
|
+
onOpenOrder,
|
|
85
|
+
onChanged,
|
|
86
|
+
}: ShipmentRiskDialogProps) {
|
|
87
|
+
const { forecast, shipment, isLoading, error, refetch, repromise, recordTrackingUpdate } =
|
|
88
|
+
useShipmentRisk(open ? shipmentId : undefined);
|
|
89
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
90
|
+
const [actionDone, setActionDone] = useState<string | null>(null);
|
|
91
|
+
const [busy, setBusy] = useState(false);
|
|
92
|
+
const [details, setDetails] = useState('');
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setActionError(null);
|
|
96
|
+
setActionDone(null);
|
|
97
|
+
setDetails('');
|
|
98
|
+
}, [shipmentId, open]);
|
|
99
|
+
|
|
100
|
+
const run = async (work: () => Promise<void>, done: string) => {
|
|
101
|
+
setBusy(true);
|
|
102
|
+
setActionError(null);
|
|
103
|
+
setActionDone(null);
|
|
104
|
+
try {
|
|
105
|
+
await work();
|
|
106
|
+
setActionDone(done);
|
|
107
|
+
onChanged();
|
|
108
|
+
} catch (failure) {
|
|
109
|
+
setActionError(
|
|
110
|
+
failure instanceof Error
|
|
111
|
+
? failure.message
|
|
112
|
+
: t('movethewheels.risk.actionFailed', 'The action failed')
|
|
113
|
+
);
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(false);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const repromiseTo = forecast?.predictedDeliveryLatest ?? forecast?.predictedDeliveryEarliest;
|
|
120
|
+
const reassignJobId = shipment?.deliveryJobId ?? null;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
124
|
+
<DialogContent className="max-w-lg">
|
|
125
|
+
<DialogHeader>
|
|
126
|
+
<DialogTitle>
|
|
127
|
+
{t('movethewheels.risk.title', 'Delivery risk')}
|
|
128
|
+
{shipmentNumber ? ` · ${shipmentNumber}` : ''}
|
|
129
|
+
</DialogTitle>
|
|
130
|
+
<DialogDescription>
|
|
131
|
+
{t(
|
|
132
|
+
'movethewheels.risk.description',
|
|
133
|
+
'Forecast from this shipment’s legs, route, tracking, vehicle and carrier history.'
|
|
134
|
+
)}
|
|
135
|
+
</DialogDescription>
|
|
136
|
+
</DialogHeader>
|
|
137
|
+
|
|
138
|
+
{isLoading && !forecast && (
|
|
139
|
+
<div className="space-y-2" aria-busy="true">
|
|
140
|
+
<Skeleton className="h-6 w-1/2" />
|
|
141
|
+
<Skeleton className="h-16 w-full" />
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{error && !isLoading && (
|
|
146
|
+
<div role="alert" className="space-y-2 text-sm">
|
|
147
|
+
<p className="text-status-error-text">{error.message}</p>
|
|
148
|
+
<Button size="sm" variant="outline" onClick={refetch}>
|
|
149
|
+
{t('movethewheels.risk.retry', 'Retry')}
|
|
150
|
+
</Button>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{forecast && (
|
|
155
|
+
<div className="space-y-4 text-sm" data-testid="shipment-risk-panel">
|
|
156
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
157
|
+
<Badge variant={forecast.riskLevel === 'HIGH' ? 'destructive' : 'secondary'}>
|
|
158
|
+
{t(LEVEL_LABELS[forecast.riskLevel].key, LEVEL_LABELS[forecast.riskLevel].fallback)}
|
|
159
|
+
</Badge>
|
|
160
|
+
<span className="text-text-secondary">
|
|
161
|
+
{t('movethewheels.risk.score', 'Risk score')}:{' '}
|
|
162
|
+
<span className="font-medium text-text-primary">{forecast.riskScore}/100</span>
|
|
163
|
+
</span>
|
|
164
|
+
{forecast.breached && (
|
|
165
|
+
<Badge variant="destructive">
|
|
166
|
+
{t('movethewheels.risk.breached', 'Promise missed')}
|
|
167
|
+
</Badge>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
172
|
+
<div>
|
|
173
|
+
<dt className="text-xs text-text-secondary">
|
|
174
|
+
{t('movethewheels.alerts.promised', 'Promised')}
|
|
175
|
+
</dt>
|
|
176
|
+
<dd>
|
|
177
|
+
{forecast.promisedDelivery
|
|
178
|
+
? formatDateTime(forecast.promisedDelivery)
|
|
179
|
+
: t('movethewheels.risk.noPromise', 'No promised time')}
|
|
180
|
+
</dd>
|
|
181
|
+
</div>
|
|
182
|
+
<div>
|
|
183
|
+
<dt className="text-xs text-text-secondary">
|
|
184
|
+
{t('movethewheels.risk.window', 'Predicted window')}
|
|
185
|
+
</dt>
|
|
186
|
+
<dd>
|
|
187
|
+
{forecast.predictedDeliveryEarliest
|
|
188
|
+
? `${formatDateTime(forecast.predictedDeliveryEarliest)}${
|
|
189
|
+
forecast.predictedDeliveryLatest &&
|
|
190
|
+
forecast.predictedDeliveryLatest !== forecast.predictedDeliveryEarliest
|
|
191
|
+
? ` – ${formatDateTime(forecast.predictedDeliveryLatest)}`
|
|
192
|
+
: ''
|
|
193
|
+
}`
|
|
194
|
+
: t('movethewheels.risk.noPrediction', 'Not enough data to predict')}
|
|
195
|
+
{forecast.predictedSlipMinutes > 0 && (
|
|
196
|
+
<span className="ml-1 text-status-error-text">
|
|
197
|
+
(+{formatSlip(forecast.predictedSlipMinutes)})
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
</dd>
|
|
201
|
+
</div>
|
|
202
|
+
</dl>
|
|
203
|
+
|
|
204
|
+
<div>
|
|
205
|
+
<p className="mb-1 text-xs text-text-secondary">
|
|
206
|
+
{t('movethewheels.alerts.signals', 'Risk signals')}
|
|
207
|
+
</p>
|
|
208
|
+
{forecast.signals.length > 0 ? (
|
|
209
|
+
<ul className="space-y-1">
|
|
210
|
+
{forecast.signals.map((signal) => {
|
|
211
|
+
const detail = describeSignal(signal, t);
|
|
212
|
+
return (
|
|
213
|
+
<li
|
|
214
|
+
key={signal.code}
|
|
215
|
+
className="flex items-center justify-between rounded border border-border-primary px-2 py-1"
|
|
216
|
+
>
|
|
217
|
+
<span>
|
|
218
|
+
{riskSignalLabel(signal.code, t)}
|
|
219
|
+
{detail && <span className="ml-1 text-text-secondary">· {detail}</span>}
|
|
220
|
+
</span>
|
|
221
|
+
<span className="text-xs text-text-secondary">+{signal.weight}</span>
|
|
222
|
+
</li>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
</ul>
|
|
226
|
+
) : (
|
|
227
|
+
<p className="text-text-secondary">
|
|
228
|
+
{t('movethewheels.risk.noSignals', 'No risk signals right now')}
|
|
229
|
+
</p>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="rounded-lg bg-status-info-bg-subtle p-3">
|
|
234
|
+
<p className="text-xs text-text-secondary">
|
|
235
|
+
{t('movethewheels.risk.recommended', 'Recommended action')}
|
|
236
|
+
</p>
|
|
237
|
+
<p className="font-medium">
|
|
238
|
+
{t(
|
|
239
|
+
ACTION_LABELS[forecast.recommendedAction].key,
|
|
240
|
+
ACTION_LABELS[forecast.recommendedAction].fallback
|
|
241
|
+
)}
|
|
242
|
+
</p>
|
|
243
|
+
|
|
244
|
+
{forecast.recommendedAction === 'REPROMISE_DELIVERY' && repromiseTo && (
|
|
245
|
+
<Button
|
|
246
|
+
size="sm"
|
|
247
|
+
className="mt-2"
|
|
248
|
+
disabled={busy}
|
|
249
|
+
onClick={() =>
|
|
250
|
+
void run(
|
|
251
|
+
() => repromise(repromiseTo),
|
|
252
|
+
t('movethewheels.risk.repromised', 'Delivery re-promised')
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
>
|
|
256
|
+
{t('movethewheels.risk.repromiseTo', 'Re-promise to')}{' '}
|
|
257
|
+
{formatDateTime(repromiseTo)}
|
|
258
|
+
</Button>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{forecast.recommendedAction === 'REQUEST_TRACKING_UPDATE' && (
|
|
262
|
+
<div className="mt-2 space-y-2">
|
|
263
|
+
<Textarea
|
|
264
|
+
value={details}
|
|
265
|
+
onChange={(event) => setDetails(event.target.value)}
|
|
266
|
+
maxLength={500}
|
|
267
|
+
aria-label={t('movethewheels.risk.updateDetails', 'Tracking update')}
|
|
268
|
+
placeholder={t(
|
|
269
|
+
'movethewheels.risk.updatePlaceholder',
|
|
270
|
+
'What did the driver or carrier report?'
|
|
271
|
+
)}
|
|
272
|
+
/>
|
|
273
|
+
<Button
|
|
274
|
+
size="sm"
|
|
275
|
+
disabled={busy || details.trim().length === 0}
|
|
276
|
+
onClick={() =>
|
|
277
|
+
void run(
|
|
278
|
+
() => recordTrackingUpdate(details.trim()),
|
|
279
|
+
t('movethewheels.risk.updateRecorded', 'Tracking update recorded')
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
>
|
|
283
|
+
{t('movethewheels.risk.recordUpdate', 'Record tracking update')}
|
|
284
|
+
</Button>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
{forecast.recommendedAction === 'REASSIGN_VEHICLE' && reassignJobId && (
|
|
289
|
+
<Button size="sm" className="mt-2" onClick={() => onOpenOrder(reassignJobId)}>
|
|
290
|
+
{t('movethewheels.risk.openOrder', 'Open order to reassign')}
|
|
291
|
+
</Button>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
{actionDone && (
|
|
295
|
+
<p role="status" className="mt-2 text-xs text-status-success-text">
|
|
296
|
+
{actionDone}
|
|
297
|
+
</p>
|
|
298
|
+
)}
|
|
299
|
+
{actionError && (
|
|
300
|
+
<p role="alert" className="mt-2 text-xs text-status-error-text">
|
|
301
|
+
{actionError}
|
|
302
|
+
</p>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<p className="text-xs text-text-secondary">
|
|
307
|
+
{t('movethewheels.risk.evaluated', 'Evaluated')}{' '}
|
|
308
|
+
{formatDateTime(forecast.evaluatedAt)}
|
|
309
|
+
</p>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</DialogContent>
|
|
313
|
+
</Dialog>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TrackingAlertList (BOFF-10397)
|
|
3
|
+
*
|
|
4
|
+
* Shipment exception alerts for the Dashboard and Live Tracking pages. Each
|
|
5
|
+
* card shows the alert's severity, the promised vs predicted delivery, the
|
|
6
|
+
* contributing risk signals, and lets the operator acknowledge it (persisted
|
|
7
|
+
* server-side) or open the shipment's risk forecast.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { CheckCircle2, Clock, Gauge } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
import { Badge, Button } from './ui';
|
|
14
|
+
import { ShipmentRiskDialog } from './ShipmentRiskDialog';
|
|
15
|
+
import type { TrackingAlert } from '../types';
|
|
16
|
+
import { formatDateTime } from '../utils/formatters';
|
|
17
|
+
import { formatSlip, predictedSlipMinutes, riskSignalLabel } from '../utils/trackingAlerts';
|
|
18
|
+
|
|
19
|
+
type Translate = (key: string, fallback: string) => string;
|
|
20
|
+
|
|
21
|
+
interface TrackingAlertListProps {
|
|
22
|
+
alerts: TrackingAlert[];
|
|
23
|
+
limit: number;
|
|
24
|
+
t: Translate;
|
|
25
|
+
onAcknowledge: (alertId: string) => Promise<void>;
|
|
26
|
+
/** Opens the order (delivery job) page, where a vehicle can be reassigned. */
|
|
27
|
+
onOpenOrder: (deliveryJobId: string) => void;
|
|
28
|
+
/** Called after a risk action changed the shipment, so callers can reload alerts. */
|
|
29
|
+
onChanged: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SEVERITY_STYLES: Record<TrackingAlert['severity'], string> = {
|
|
33
|
+
critical: 'bg-status-error-bg-subtle border-status-error-border',
|
|
34
|
+
high: 'bg-status-error-bg-subtle border-status-error-border',
|
|
35
|
+
medium: 'bg-status-warning-bg-subtle border-status-warning-border',
|
|
36
|
+
low: 'bg-surface-secondary border-border-primary',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function SeverityBadge({ alert, t }: { alert: TrackingAlert; t: Translate }) {
|
|
40
|
+
const high = alert.severity === 'high' || alert.severity === 'critical';
|
|
41
|
+
const label =
|
|
42
|
+
alert.kind === 'AT_RISK'
|
|
43
|
+
? t('movethewheels.alerts.kind.atRisk', 'At risk')
|
|
44
|
+
: t('movethewheels.alerts.kind.delayed', 'Delayed');
|
|
45
|
+
return (
|
|
46
|
+
<Badge variant={high ? 'destructive' : 'secondary'} className="shrink-0">
|
|
47
|
+
{label}
|
|
48
|
+
{alert.kind === 'AT_RISK' && typeof alert.riskScore === 'number'
|
|
49
|
+
? ` · ${alert.riskScore}`
|
|
50
|
+
: ''}
|
|
51
|
+
</Badge>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function DeliveryWindow({ alert, t }: { alert: TrackingAlert; t: Translate }) {
|
|
56
|
+
const slip = predictedSlipMinutes(alert);
|
|
57
|
+
if (!alert.promisedDelivery && !alert.predictedDelivery) return null;
|
|
58
|
+
return (
|
|
59
|
+
<dl className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-text-secondary sm:grid-cols-2">
|
|
60
|
+
{alert.promisedDelivery && (
|
|
61
|
+
<div className="flex items-center gap-1">
|
|
62
|
+
<Clock size={12} aria-hidden="true" />
|
|
63
|
+
<dt>{t('movethewheels.alerts.promised', 'Promised')}:</dt>
|
|
64
|
+
<dd className="text-text-primary">{formatDateTime(alert.promisedDelivery)}</dd>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
{alert.predictedDelivery && (
|
|
68
|
+
<div className="flex items-center gap-1">
|
|
69
|
+
<Gauge size={12} aria-hidden="true" />
|
|
70
|
+
<dt>{t('movethewheels.alerts.predicted', 'Predicted')}:</dt>
|
|
71
|
+
<dd className="text-text-primary">
|
|
72
|
+
{formatDateTime(alert.predictedDelivery)}
|
|
73
|
+
{slip !== null && (
|
|
74
|
+
<span className="ml-1 text-status-error-text">(+{formatSlip(slip)})</span>
|
|
75
|
+
)}
|
|
76
|
+
</dd>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</dl>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function TrackingAlertList({
|
|
84
|
+
alerts,
|
|
85
|
+
limit,
|
|
86
|
+
t,
|
|
87
|
+
onAcknowledge,
|
|
88
|
+
onOpenOrder,
|
|
89
|
+
onChanged,
|
|
90
|
+
}: TrackingAlertListProps) {
|
|
91
|
+
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
92
|
+
const [ackError, setAckError] = useState<string | null>(null);
|
|
93
|
+
const [riskAlert, setRiskAlert] = useState<TrackingAlert | null>(null);
|
|
94
|
+
|
|
95
|
+
const acknowledge = async (alertId: string) => {
|
|
96
|
+
setPendingId(alertId);
|
|
97
|
+
setAckError(null);
|
|
98
|
+
try {
|
|
99
|
+
await onAcknowledge(alertId);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
setAckError(
|
|
102
|
+
error instanceof Error
|
|
103
|
+
? error.message
|
|
104
|
+
: t('movethewheels.alerts.ackFailed', 'Could not acknowledge the alert')
|
|
105
|
+
);
|
|
106
|
+
} finally {
|
|
107
|
+
setPendingId(null);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
{ackError && (
|
|
114
|
+
<p role="alert" className="mb-2 text-xs text-status-error-text">
|
|
115
|
+
{ackError}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
<ul className="space-y-3" aria-label={t('movethewheels.alerts.listLabel', 'Shipment alerts')}>
|
|
119
|
+
{alerts.slice(0, limit).map((alert) => (
|
|
120
|
+
<li
|
|
121
|
+
key={alert.id}
|
|
122
|
+
data-testid="tracking-alert"
|
|
123
|
+
data-alert-kind={alert.kind}
|
|
124
|
+
className={`rounded-lg border p-3 text-sm ${SEVERITY_STYLES[alert.severity]}`}
|
|
125
|
+
>
|
|
126
|
+
<div className="flex items-start justify-between gap-2">
|
|
127
|
+
<p className="font-medium">
|
|
128
|
+
{alert.shipmentNumber ? `${alert.shipmentNumber}: ` : ''}
|
|
129
|
+
{alert.message}
|
|
130
|
+
</p>
|
|
131
|
+
<SeverityBadge alert={alert} t={t} />
|
|
132
|
+
</div>
|
|
133
|
+
<DeliveryWindow alert={alert} t={t} />
|
|
134
|
+
{alert.signals && alert.signals.length > 0 && (
|
|
135
|
+
<ul
|
|
136
|
+
className="mt-2 flex flex-wrap gap-1"
|
|
137
|
+
aria-label={t('movethewheels.alerts.signals', 'Risk signals')}
|
|
138
|
+
>
|
|
139
|
+
{alert.signals.map((code) => (
|
|
140
|
+
<li
|
|
141
|
+
key={code}
|
|
142
|
+
className="rounded-full border border-border-primary bg-surface-primary px-2 py-0.5 text-xs text-text-secondary"
|
|
143
|
+
>
|
|
144
|
+
{riskSignalLabel(code, t)}
|
|
145
|
+
</li>
|
|
146
|
+
))}
|
|
147
|
+
</ul>
|
|
148
|
+
)}
|
|
149
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
150
|
+
{alert.shipmentId && (
|
|
151
|
+
<Button size="sm" variant="outline" onClick={() => setRiskAlert(alert)}>
|
|
152
|
+
{t('movethewheels.alerts.viewRisk', 'View risk')}
|
|
153
|
+
</Button>
|
|
154
|
+
)}
|
|
155
|
+
{alert.shipmentId && alert.kind && (
|
|
156
|
+
<Button
|
|
157
|
+
size="sm"
|
|
158
|
+
variant="ghost"
|
|
159
|
+
disabled={pendingId === alert.id}
|
|
160
|
+
onClick={() => void acknowledge(alert.id)}
|
|
161
|
+
>
|
|
162
|
+
<CheckCircle2 size={14} aria-hidden="true" />
|
|
163
|
+
{pendingId === alert.id
|
|
164
|
+
? t('movethewheels.alerts.acknowledging', 'Acknowledging…')
|
|
165
|
+
: t('movethewheels.alerts.acknowledge', 'Acknowledge')}
|
|
166
|
+
</Button>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
</li>
|
|
170
|
+
))}
|
|
171
|
+
</ul>
|
|
172
|
+
<ShipmentRiskDialog
|
|
173
|
+
shipmentId={riskAlert?.shipmentId}
|
|
174
|
+
shipmentNumber={riskAlert?.shipmentNumber}
|
|
175
|
+
open={riskAlert !== null}
|
|
176
|
+
onOpenChange={(open) => {
|
|
177
|
+
if (!open) setRiskAlert(null);
|
|
178
|
+
}}
|
|
179
|
+
t={t}
|
|
180
|
+
onOpenOrder={(deliveryJobId) => {
|
|
181
|
+
setRiskAlert(null);
|
|
182
|
+
onOpenOrder(deliveryJobId);
|
|
183
|
+
}}
|
|
184
|
+
onChanged={onChanged}
|
|
185
|
+
/>
|
|
186
|
+
</>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|