@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.
Files changed (44) hide show
  1. package/dist/components/ExportStatusBanner.js +53 -0
  2. package/dist/components/ShipmentRiskDialog.js +228 -0
  3. package/dist/components/TrackingAlertList.js +149 -0
  4. package/dist/generated/tracking-quality-operations.d.ts +1070 -2
  5. package/dist/generated/wspace-types.d.ts +2895 -50
  6. package/dist/hooks/useEntityExport.js +85 -0
  7. package/dist/hooks/useShipmentRisk.js +72 -0
  8. package/dist/hooks/useTracking.js +96 -82
  9. package/dist/movethewheels/components/ExportStatusBanner.d.ts +7 -0
  10. package/dist/movethewheels/components/ShipmentRiskDialog.d.ts +20 -0
  11. package/dist/movethewheels/components/TrackingAlertList.d.ts +14 -0
  12. package/dist/movethewheels/hooks/useEntityExport.d.ts +32 -0
  13. package/dist/movethewheels/hooks/useShipmentRisk.d.ts +41 -0
  14. package/dist/movethewheels/types/index.d.ts +15 -1
  15. package/dist/movethewheels/utils/entityExport.d.ts +64 -0
  16. package/dist/movethewheels/utils/trackingAlerts.d.ts +36 -0
  17. package/dist/pages/CustomersPage.js +64 -58
  18. package/dist/pages/DashboardPage.js +147 -151
  19. package/dist/pages/DriversPage.js +113 -107
  20. package/dist/pages/LiveTrackingPage.js +169 -169
  21. package/dist/pages/OrdersListPage.js +85 -79
  22. package/dist/pages/ProductsPage.js +63 -57
  23. package/dist/pages/VehiclesPage.js +91 -85
  24. package/dist/utils/entityExport.js +54 -0
  25. package/dist/utils/trackingAlerts.js +86 -0
  26. package/package.json +4 -2
  27. package/src/generated/tracking-quality-operations.ts +1409 -13
  28. package/src/generated/wspace-types.ts +3248 -51
  29. package/src/movethewheels/components/ExportStatusBanner.tsx +78 -0
  30. package/src/movethewheels/components/ShipmentRiskDialog.tsx +315 -0
  31. package/src/movethewheels/components/TrackingAlertList.tsx +188 -0
  32. package/src/movethewheels/hooks/useEntityExport.ts +130 -0
  33. package/src/movethewheels/hooks/useShipmentRisk.ts +195 -0
  34. package/src/movethewheels/hooks/useTracking.ts +66 -22
  35. package/src/movethewheels/pages/CustomersPage.tsx +11 -2
  36. package/src/movethewheels/pages/DashboardPage.tsx +10 -20
  37. package/src/movethewheels/pages/DriversPage.tsx +11 -2
  38. package/src/movethewheels/pages/LiveTrackingPage.tsx +20 -22
  39. package/src/movethewheels/pages/OrdersListPage.tsx +13 -2
  40. package/src/movethewheels/pages/ProductsPage.tsx +11 -2
  41. package/src/movethewheels/pages/VehiclesPage.tsx +11 -2
  42. package/src/movethewheels/types/index.ts +16 -1
  43. package/src/movethewheels/utils/entityExport.ts +140 -0
  44. 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
+ }