@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.
@@ -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
+ }