@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExportStatusBanner — inline feedback for a list page's "Export" action.
|
|
3
|
+
*
|
|
4
|
+
* The MFE does not mount its own toaster, so export progress, success and
|
|
5
|
+
* failure are shown inline. A job that outlives the wait budget links to the
|
|
6
|
+
* host shell's shared Exports page, where it can be downloaded once ready.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AlertCircle, CheckCircle, Info, Loader2, X } from 'lucide-react';
|
|
10
|
+
import type { EntityExportState } from '../hooks/useEntityExport';
|
|
11
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
12
|
+
|
|
13
|
+
interface ExportStatusBannerProps {
|
|
14
|
+
state: EntityExportState;
|
|
15
|
+
onDismiss: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Host-shell route of the shared export module (microfe-export). */
|
|
19
|
+
const EXPORTS_PATH = '/export';
|
|
20
|
+
|
|
21
|
+
export function ExportStatusBanner({ state, onDismiss }: ExportStatusBannerProps) {
|
|
22
|
+
const { navigate } = useMoveTheWheels();
|
|
23
|
+
if (state.phase === 'idle') return null;
|
|
24
|
+
|
|
25
|
+
const tone =
|
|
26
|
+
state.phase === 'error'
|
|
27
|
+
? 'border-status-error-border bg-status-error-bg-subtle text-status-error-text'
|
|
28
|
+
: state.phase === 'ready'
|
|
29
|
+
? 'border-status-success-border bg-status-success-bg-subtle text-status-success-text'
|
|
30
|
+
: 'border-status-info-border bg-status-info-bg-subtle text-status-info-text';
|
|
31
|
+
|
|
32
|
+
const icon =
|
|
33
|
+
state.phase === 'error' ? (
|
|
34
|
+
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
|
35
|
+
) : state.phase === 'ready' ? (
|
|
36
|
+
<CheckCircle size={18} className="mt-0.5 shrink-0" />
|
|
37
|
+
) : state.phase === 'running' ? (
|
|
38
|
+
<Loader2 size={18} className="mt-0.5 shrink-0 animate-spin" />
|
|
39
|
+
) : (
|
|
40
|
+
<Info size={18} className="mt-0.5 shrink-0" />
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
role={state.phase === 'error' ? 'alert' : 'status'}
|
|
46
|
+
data-testid="mtw-export-status"
|
|
47
|
+
data-phase={state.phase}
|
|
48
|
+
className={`flex items-start gap-2 rounded-lg border px-4 py-3 text-sm ${tone}`}
|
|
49
|
+
>
|
|
50
|
+
{icon}
|
|
51
|
+
<span className="flex-1">
|
|
52
|
+
{state.message}
|
|
53
|
+
{state.phase === 'pending' ? (
|
|
54
|
+
<>
|
|
55
|
+
{' '}
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className="font-medium underline underline-offset-2"
|
|
59
|
+
onClick={() => navigate(`${EXPORTS_PATH}/${state.jobId}`)}
|
|
60
|
+
>
|
|
61
|
+
Open Exports
|
|
62
|
+
</button>
|
|
63
|
+
</>
|
|
64
|
+
) : null}
|
|
65
|
+
</span>
|
|
66
|
+
{state.phase !== 'running' ? (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
aria-label="Dismiss export status"
|
|
70
|
+
className="shrink-0 opacity-70 hover:opacity-100"
|
|
71
|
+
onClick={onDismiss}
|
|
72
|
+
>
|
|
73
|
+
<X size={16} />
|
|
74
|
+
</button>
|
|
75
|
+
) : null}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -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
|
+
}
|