@burdenoff/microfe-movethewheels 2026.1031.1 → 2026.1031.3
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/MoveTheWheelsRoot.js +144 -136
- package/dist/MoveTheWheelsRoutes.js +61 -57
- package/dist/components/ShipmentRiskDialog.js +228 -0
- package/dist/components/TrackingAlertList.js +149 -0
- package/dist/generated/tracking-quality-operations.d.ts +1154 -2
- package/dist/generated/wspace-types.d.ts +2895 -50
- package/dist/hooks/useEntityImport.js +68 -0
- 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/useEntityImport.d.ts +19 -0
- package/dist/movethewheels/hooks/useShipmentRisk.d.ts +41 -0
- package/dist/movethewheels/pages/ImportExportPage.d.ts +9 -0
- package/dist/movethewheels/types/index.d.ts +15 -1
- package/dist/movethewheels/utils/entityImport.d.ts +109 -0
- package/dist/movethewheels/utils/trackingAlerts.d.ts +36 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-down-up.js +22 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/file-spreadsheet.js +30 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/file-up.js +22 -0
- package/dist/pages/CustomersPage.js +88 -79
- package/dist/pages/DashboardPage.js +147 -151
- package/dist/pages/DriversPage.js +115 -106
- package/dist/pages/ImportExportPage.js +435 -0
- package/dist/pages/LiveTrackingPage.js +169 -169
- package/dist/pages/ProductsPage.js +85 -76
- package/dist/pages/VehiclesPage.js +99 -90
- package/dist/pages/WarehousesPage.js +97 -88
- package/dist/utils/entityImport.js +73 -0
- package/dist/utils/trackingAlerts.js +86 -0
- package/package.json +4 -2
- package/src/generated/tracking-quality-operations.ts +1519 -1
- package/src/generated/wspace-types.ts +3248 -51
- package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/components/ShipmentRiskDialog.tsx +315 -0
- package/src/movethewheels/components/TrackingAlertList.tsx +188 -0
- package/src/movethewheels/hooks/useEntityImport.ts +108 -0
- package/src/movethewheels/hooks/useShipmentRisk.ts +195 -0
- package/src/movethewheels/hooks/useTracking.ts +66 -22
- package/src/movethewheels/pages/CustomersPage.tsx +6 -1
- package/src/movethewheels/pages/DashboardPage.tsx +10 -20
- package/src/movethewheels/pages/DriversPage.tsx +6 -0
- package/src/movethewheels/pages/ImportExportPage.tsx +483 -0
- package/src/movethewheels/pages/LiveTrackingPage.tsx +20 -22
- package/src/movethewheels/pages/ProductsPage.tsx +14 -1
- package/src/movethewheels/pages/VehiclesPage.tsx +6 -0
- package/src/movethewheels/pages/WarehousesPage.tsx +6 -1
- package/src/movethewheels/types/index.ts +16 -1
- package/src/movethewheels/utils/entityImport.ts +239 -0
- package/src/movethewheels/utils/trackingAlerts.ts +151 -0
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
DollarSign,
|
|
24
24
|
Bot,
|
|
25
25
|
Store,
|
|
26
|
+
ArrowDownUp,
|
|
26
27
|
} from 'lucide-react';
|
|
27
28
|
import { MoveTheWheelsProvider } from './providers/MoveTheWheelsProvider';
|
|
28
29
|
import { FeatureFlagProvider } from '@burdenoff/fe-libs/shared/feature-flags';
|
|
@@ -241,6 +242,13 @@ const NavRegistrar: FC<Pick<MoveTheWheelsRootProps, 'registerNavItems' | 'basePa
|
|
|
241
242
|
icon: <Store size={16} />,
|
|
242
243
|
order: 6,
|
|
243
244
|
},
|
|
245
|
+
{
|
|
246
|
+
id: 'mtw-import-export',
|
|
247
|
+
label: tr('movethewheels.root.nav.importExport', 'Import / Export'),
|
|
248
|
+
path: joinPath(basePath, 'import-export'),
|
|
249
|
+
icon: <ArrowDownUp size={16} />,
|
|
250
|
+
order: 6,
|
|
251
|
+
},
|
|
244
252
|
{
|
|
245
253
|
id: 'mtw-routes',
|
|
246
254
|
label: tr('movethewheels.root.nav.routes', 'Routes'),
|
|
@@ -72,6 +72,7 @@ const WarehouseDetailsPage = lazy(() => import('./pages/WarehouseDetailsPage'));
|
|
|
72
72
|
const CustomersPage = lazy(() => import('./pages/CustomersPage'));
|
|
73
73
|
const CustomerDetailsPage = lazy(() => import('./pages/CustomerDetailsPage'));
|
|
74
74
|
const MarketplacePage = lazy(() => import('./pages/MarketplacePage'));
|
|
75
|
+
const ImportExportPage = lazy(() => import('./pages/ImportExportPage'));
|
|
75
76
|
const RoutesPage = lazy(() => import('./pages/RoutesPage'));
|
|
76
77
|
const RouteDetailsPage = lazy(() => import('./pages/RouteDetailsPage'));
|
|
77
78
|
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));
|
|
@@ -160,6 +161,9 @@ export function MoveTheWheelsRoutes() {
|
|
|
160
161
|
{/* Marketplace — service providers and offerings (BOFF-3988) */}
|
|
161
162
|
<Route path="marketplace" element={<MarketplacePage />} />
|
|
162
163
|
|
|
164
|
+
{/* Bulk CSV/JSON import and dataset export (BOFF-3990) */}
|
|
165
|
+
<Route path="import-export" element={<ImportExportPage />} />
|
|
166
|
+
|
|
163
167
|
{/* Routes */}
|
|
164
168
|
<Route path="routes" element={<RoutesPage />} />
|
|
165
169
|
<Route path="routes/trip-utilization" element={<TripUtilizationPage />} />
|
|
@@ -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,108 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
3
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
4
|
+
import {
|
|
5
|
+
MTW_IMPORT_MAX_BYTES,
|
|
6
|
+
MTW_IMPORT_SPEC_QUERY,
|
|
7
|
+
importEntity,
|
|
8
|
+
importMutation,
|
|
9
|
+
type MtwImportEntitySpec,
|
|
10
|
+
type MtwImportEntityType,
|
|
11
|
+
type MtwImportFormat,
|
|
12
|
+
type MtwImportResult,
|
|
13
|
+
} from '../utils/entityImport';
|
|
14
|
+
|
|
15
|
+
function useGraphQLOptions() {
|
|
16
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
17
|
+
useMoveTheWheels();
|
|
18
|
+
return useMemo(
|
|
19
|
+
() => ({
|
|
20
|
+
apiGatewayUrl,
|
|
21
|
+
authToken,
|
|
22
|
+
workspaceToken,
|
|
23
|
+
workspaceId,
|
|
24
|
+
tenantId,
|
|
25
|
+
actorId: currentUser?.id,
|
|
26
|
+
}),
|
|
27
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Column specification for every importable entity. */
|
|
32
|
+
export function useImportSpec() {
|
|
33
|
+
const graphQLOptions = useGraphQLOptions();
|
|
34
|
+
const [specs, setSpecs] = useState<MtwImportEntitySpec[]>([]);
|
|
35
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
36
|
+
const [error, setError] = useState<Error | null>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!graphQLOptions.workspaceId) return;
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
setIsLoading(true);
|
|
42
|
+
executeGraphQL<{ mtwImportSpec: MtwImportEntitySpec[] }>(graphQLOptions, MTW_IMPORT_SPEC_QUERY)
|
|
43
|
+
.then((data) => {
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
setSpecs(data.mtwImportSpec);
|
|
46
|
+
setError(null);
|
|
47
|
+
})
|
|
48
|
+
.catch((caught: unknown) => {
|
|
49
|
+
if (!cancelled)
|
|
50
|
+
setError(caught instanceof Error ? caught : new Error('Could not load import columns'));
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
if (!cancelled) setIsLoading(false);
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
};
|
|
58
|
+
}, [graphQLOptions]);
|
|
59
|
+
|
|
60
|
+
return { specs, isLoading, error };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ImportRequest {
|
|
64
|
+
entityType: MtwImportEntityType;
|
|
65
|
+
format: MtwImportFormat;
|
|
66
|
+
content: string;
|
|
67
|
+
dryRun: boolean;
|
|
68
|
+
allowPartial: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Runs a validation or an import. Writes are sent once: a retry could create rows twice. */
|
|
72
|
+
export function useEntityImport() {
|
|
73
|
+
const graphQLOptions = useGraphQLOptions();
|
|
74
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
75
|
+
|
|
76
|
+
const run = useCallback(
|
|
77
|
+
async (request: ImportRequest): Promise<MtwImportResult> => {
|
|
78
|
+
if (new Blob([request.content]).size > MTW_IMPORT_MAX_BYTES) {
|
|
79
|
+
throw new Error('The file is larger than 2 MB. Split it into smaller files.');
|
|
80
|
+
}
|
|
81
|
+
const { field } = importEntity(request.entityType);
|
|
82
|
+
setIsRunning(true);
|
|
83
|
+
try {
|
|
84
|
+
const data = await executeGraphQL<Record<string, MtwImportResult>>(
|
|
85
|
+
graphQLOptions,
|
|
86
|
+
importMutation(request.entityType),
|
|
87
|
+
{
|
|
88
|
+
input: {
|
|
89
|
+
format: request.format,
|
|
90
|
+
content: request.content,
|
|
91
|
+
dryRun: request.dryRun,
|
|
92
|
+
allowPartial: request.allowPartial,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
request.dryRun ? 'RETRY_TRANSIENT' : 'SINGLE_ATTEMPT'
|
|
96
|
+
);
|
|
97
|
+
const result = data[field];
|
|
98
|
+
if (!result) throw new Error('The import returned no result');
|
|
99
|
+
return result;
|
|
100
|
+
} finally {
|
|
101
|
+
setIsRunning(false);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[graphQLOptions]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return { run, isRunning };
|
|
108
|
+
}
|