@burdenoff/microfe-movethewheels 2026.1031.1 → 2026.1031.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ShipmentRiskDialog.js +228 -0
- package/dist/components/TrackingAlertList.js +149 -0
- package/dist/generated/tracking-quality-operations.d.ts +1070 -2
- package/dist/generated/wspace-types.d.ts +2895 -50
- package/dist/hooks/useShipmentRisk.js +72 -0
- package/dist/hooks/useTracking.js +96 -82
- package/dist/movethewheels/components/ShipmentRiskDialog.d.ts +20 -0
- package/dist/movethewheels/components/TrackingAlertList.d.ts +14 -0
- package/dist/movethewheels/hooks/useShipmentRisk.d.ts +41 -0
- package/dist/movethewheels/types/index.d.ts +15 -1
- package/dist/movethewheels/utils/trackingAlerts.d.ts +36 -0
- package/dist/pages/DashboardPage.js +147 -151
- package/dist/pages/LiveTrackingPage.js +169 -169
- package/dist/utils/trackingAlerts.js +86 -0
- package/package.json +3 -2
- package/src/generated/tracking-quality-operations.ts +1409 -13
- package/src/generated/wspace-types.ts +3248 -51
- package/src/movethewheels/components/ShipmentRiskDialog.tsx +315 -0
- package/src/movethewheels/components/TrackingAlertList.tsx +188 -0
- package/src/movethewheels/hooks/useShipmentRisk.ts +195 -0
- package/src/movethewheels/hooks/useTracking.ts +66 -22
- package/src/movethewheels/pages/DashboardPage.tsx +10 -20
- package/src/movethewheels/pages/LiveTrackingPage.tsx +20 -22
- package/src/movethewheels/types/index.ts +16 -1
- package/src/movethewheels/utils/trackingAlerts.ts +151 -0
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
useDrivers,
|
|
14
14
|
} from './useFleet';
|
|
15
15
|
import { mapOrder, type ApiOrder, useOrder } from './useOrders';
|
|
16
|
+
import {
|
|
17
|
+
mapTrackingAlert,
|
|
18
|
+
sortTrackingAlerts,
|
|
19
|
+
type ApiTrackingAlert,
|
|
20
|
+
} from '../utils/trackingAlerts';
|
|
16
21
|
|
|
17
22
|
interface UseTrackingOptions {
|
|
18
23
|
entityId?: string;
|
|
@@ -205,10 +210,27 @@ const ACTIVE_DELIVERIES_QUERY = `
|
|
|
205
210
|
const TRACKING_ALERTS_QUERY = `
|
|
206
211
|
query GetTrackingAlerts {
|
|
207
212
|
trackingAlerts {
|
|
213
|
+
id
|
|
208
214
|
shipmentId
|
|
209
215
|
shipmentNumber
|
|
210
216
|
alertType
|
|
211
217
|
message
|
|
218
|
+
riskScore
|
|
219
|
+
promisedDelivery
|
|
220
|
+
predictedDelivery
|
|
221
|
+
signals
|
|
222
|
+
acknowledged
|
|
223
|
+
acknowledgedAt
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
`;
|
|
227
|
+
|
|
228
|
+
const ACKNOWLEDGE_TRACKING_ALERT_MUTATION = `
|
|
229
|
+
mutation AcknowledgeMtwTrackingAlert($shipmentId: ID!, $alertType: MtwTrackingAlertType!) {
|
|
230
|
+
acknowledgeMtwTrackingAlert(shipmentId: $shipmentId, alertType: $alertType) {
|
|
231
|
+
id
|
|
232
|
+
acknowledged
|
|
233
|
+
acknowledgedAt
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
`;
|
|
@@ -631,24 +653,11 @@ export function useTrackingAlerts() {
|
|
|
631
653
|
setIsLoading(true);
|
|
632
654
|
setError(null);
|
|
633
655
|
try {
|
|
634
|
-
const data = await executeGraphQL<{
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
shipmentNumber: string;
|
|
638
|
-
alertType: string;
|
|
639
|
-
message: string;
|
|
640
|
-
}>;
|
|
641
|
-
}>(graphQLOptions, TRACKING_ALERTS_QUERY);
|
|
642
|
-
|
|
643
|
-
setAlerts(
|
|
644
|
-
data.trackingAlerts.map((alert) => ({
|
|
645
|
-
id: `${alert.shipmentId}-${alert.alertType}`,
|
|
646
|
-
type: 'delay',
|
|
647
|
-
severity: 'medium',
|
|
648
|
-
message: `${alert.shipmentNumber}: ${alert.message}`,
|
|
649
|
-
acknowledged: false,
|
|
650
|
-
}))
|
|
656
|
+
const data = await executeGraphQL<{ trackingAlerts: ApiTrackingAlert[] }>(
|
|
657
|
+
graphQLOptions,
|
|
658
|
+
TRACKING_ALERTS_QUERY
|
|
651
659
|
);
|
|
660
|
+
setAlerts(sortTrackingAlerts(data.trackingAlerts.map(mapTrackingAlert)));
|
|
652
661
|
} catch (fetchError) {
|
|
653
662
|
setError(
|
|
654
663
|
fetchError instanceof Error ? fetchError : new Error('Failed to load tracking alerts')
|
|
@@ -664,11 +673,46 @@ export function useTrackingAlerts() {
|
|
|
664
673
|
|
|
665
674
|
const unacknowledgedAlerts = useMemo(() => alerts.filter((item) => !item.acknowledged), [alerts]);
|
|
666
675
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Persist the acknowledgement server-side (BOFF-10397) so it survives a
|
|
678
|
+
* reload and is shared with the rest of the workspace. The alert is hidden
|
|
679
|
+
* optimistically and restored if the mutation fails.
|
|
680
|
+
*/
|
|
681
|
+
const acknowledgeAlert = useCallback(
|
|
682
|
+
async (alertId: string): Promise<void> => {
|
|
683
|
+
const target = alerts.find((item) => item.id === alertId);
|
|
684
|
+
if (!target?.shipmentId || !target.kind) return;
|
|
685
|
+
setAlerts((previous) =>
|
|
686
|
+
previous.map((item) => (item.id === alertId ? { ...item, acknowledged: true } : item))
|
|
687
|
+
);
|
|
688
|
+
try {
|
|
689
|
+
const data = await executeGraphQL<{
|
|
690
|
+
acknowledgeMtwTrackingAlert: {
|
|
691
|
+
id: string;
|
|
692
|
+
acknowledged: boolean;
|
|
693
|
+
acknowledgedAt: string;
|
|
694
|
+
};
|
|
695
|
+
}>(
|
|
696
|
+
graphQLOptions,
|
|
697
|
+
ACKNOWLEDGE_TRACKING_ALERT_MUTATION,
|
|
698
|
+
// Idempotent upsert server-side, so transient retries are safe.
|
|
699
|
+
{ shipmentId: target.shipmentId, alertType: target.kind }
|
|
700
|
+
);
|
|
701
|
+
const acknowledgedAt = new Date(data.acknowledgeMtwTrackingAlert.acknowledgedAt);
|
|
702
|
+
setAlerts((previous) =>
|
|
703
|
+
previous.map((item) =>
|
|
704
|
+
item.id === alertId ? { ...item, acknowledged: true, acknowledgedAt } : item
|
|
705
|
+
)
|
|
706
|
+
);
|
|
707
|
+
} catch (ackError) {
|
|
708
|
+
setAlerts((previous) =>
|
|
709
|
+
previous.map((item) => (item.id === alertId ? { ...item, acknowledged: false } : item))
|
|
710
|
+
);
|
|
711
|
+
throw ackError instanceof Error ? ackError : new Error('Failed to acknowledge alert');
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
[alerts, graphQLOptions]
|
|
715
|
+
);
|
|
672
716
|
|
|
673
717
|
return {
|
|
674
718
|
alerts,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { PageLayout, PageSection } from '../components/PageLayout';
|
|
22
22
|
import { MetricCard, MetricGrid, type MetricDelta } from '../components/MetricCard';
|
|
23
23
|
import { StatusBadge } from '../components/StatusBadge';
|
|
24
|
+
import { TrackingAlertList } from '../components/TrackingAlertList';
|
|
24
25
|
import { LogisticsMap, type LogisticsMarker } from '../components/maps';
|
|
25
26
|
import { VolumeBar } from '../components/charts';
|
|
26
27
|
import { deliveryMarkerStatus, firstRealLatLng } from '../utils/mapMarkers';
|
|
@@ -67,7 +68,7 @@ export default function DashboardPage() {
|
|
|
67
68
|
const { stats: orderStats } = useOrderStats();
|
|
68
69
|
const { stats: fleetStats } = useFleetStats();
|
|
69
70
|
const { activeDeliveries } = useLiveTracking();
|
|
70
|
-
const { unacknowledgedAlerts } = useTrackingAlerts();
|
|
71
|
+
const { unacknowledgedAlerts, acknowledgeAlert, refetch: refetchAlerts } = useTrackingAlerts();
|
|
71
72
|
|
|
72
73
|
const orderSpark = useMemo(
|
|
73
74
|
() => analytics.trends.dailyOrders.map((d) => d.orders),
|
|
@@ -265,25 +266,14 @@ export default function DashboardPage() {
|
|
|
265
266
|
</CardHeader>
|
|
266
267
|
<CardContent>
|
|
267
268
|
{unacknowledgedAlerts.length > 0 ? (
|
|
268
|
-
<
|
|
269
|
-
{unacknowledgedAlerts
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}`}
|
|
277
|
-
>
|
|
278
|
-
<p className="font-medium">{alert.message}</p>
|
|
279
|
-
<p className="mt-1 text-xs text-text-secondary">
|
|
280
|
-
{alert.timestamp
|
|
281
|
-
? formatDate(alert.timestamp, 'relative')
|
|
282
|
-
: 'Time not recorded'}
|
|
283
|
-
</p>
|
|
284
|
-
</div>
|
|
285
|
-
))}
|
|
286
|
-
</div>
|
|
269
|
+
<TrackingAlertList
|
|
270
|
+
alerts={unacknowledgedAlerts}
|
|
271
|
+
limit={4}
|
|
272
|
+
t={t}
|
|
273
|
+
onAcknowledge={acknowledgeAlert}
|
|
274
|
+
onOpenOrder={(deliveryJobId) => goTo(`orders/${deliveryJobId}`)}
|
|
275
|
+
onChanged={refetchAlerts}
|
|
276
|
+
/>
|
|
287
277
|
) : (
|
|
288
278
|
<p className="py-4 text-center text-sm text-text-secondary">
|
|
289
279
|
{t(
|
|
@@ -28,13 +28,20 @@ import { useLiveTracking, useTrackingAlerts } from '../hooks/useTracking';
|
|
|
28
28
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
29
29
|
import { formatDate } from '../utils/formatters';
|
|
30
30
|
import { joinPath } from '../utils/navigation';
|
|
31
|
+
import { TrackingAlertList } from '../components/TrackingAlertList';
|
|
32
|
+
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
31
33
|
|
|
32
34
|
type LiveFilter = 'all' | 'in_transit' | 'out_for_delivery' | 'other';
|
|
33
35
|
|
|
34
36
|
export default function LiveTrackingPage() {
|
|
35
37
|
const { basePath, navigate } = useMoveTheWheels();
|
|
36
38
|
const { activeDeliveries, isLoading, refetch } = useLiveTracking();
|
|
37
|
-
const {
|
|
39
|
+
const { t: translate } = useI18n();
|
|
40
|
+
const t = (key: string, fallback: string): string => {
|
|
41
|
+
const value = translate(key);
|
|
42
|
+
return value === key ? fallback : value;
|
|
43
|
+
};
|
|
44
|
+
const { unacknowledgedAlerts, acknowledgeAlert, refetch: refetchAlerts } = useTrackingAlerts();
|
|
38
45
|
const [viewMode, setViewMode] = useState<'map' | 'list'>('map');
|
|
39
46
|
const [selectedDelivery, setSelectedDelivery] = useState<string | null>(null);
|
|
40
47
|
const [filter, setFilter] = useState<LiveFilter>('all');
|
|
@@ -420,7 +427,7 @@ export default function LiveTrackingPage() {
|
|
|
420
427
|
<Card>
|
|
421
428
|
<CardHeader>
|
|
422
429
|
<CardTitle className="flex items-center gap-2">
|
|
423
|
-
Alerts
|
|
430
|
+
{t('movethewheels.tracking.live.alerts', 'Alerts')}
|
|
424
431
|
{unacknowledgedAlerts.length > 0 && (
|
|
425
432
|
<Badge variant="destructive">{unacknowledgedAlerts.length}</Badge>
|
|
426
433
|
)}
|
|
@@ -428,28 +435,19 @@ export default function LiveTrackingPage() {
|
|
|
428
435
|
</CardHeader>
|
|
429
436
|
<CardContent>
|
|
430
437
|
{unacknowledgedAlerts.length > 0 ? (
|
|
431
|
-
<
|
|
432
|
-
{unacknowledgedAlerts
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}`}
|
|
440
|
-
>
|
|
441
|
-
<p className="font-medium">{alert.message}</p>
|
|
442
|
-
<p className="text-xs text-text-secondary mt-1">
|
|
443
|
-
{alert.timestamp
|
|
444
|
-
? formatDate(alert.timestamp, 'relative')
|
|
445
|
-
: 'Time not recorded'}
|
|
446
|
-
</p>
|
|
447
|
-
</div>
|
|
448
|
-
))}
|
|
449
|
-
</div>
|
|
438
|
+
<TrackingAlertList
|
|
439
|
+
alerts={unacknowledgedAlerts}
|
|
440
|
+
limit={5}
|
|
441
|
+
t={t}
|
|
442
|
+
onAcknowledge={acknowledgeAlert}
|
|
443
|
+
onOpenOrder={(deliveryJobId) => goTo(`orders/${deliveryJobId}`)}
|
|
444
|
+
onChanged={refetchAlerts}
|
|
445
|
+
/>
|
|
450
446
|
) : (
|
|
451
447
|
<div className="text-center text-text-secondary py-4">
|
|
452
|
-
<p className="text-sm">
|
|
448
|
+
<p className="text-sm">
|
|
449
|
+
{t('movethewheels.tracking.live.noAlerts', 'No active alerts')}
|
|
450
|
+
</p>
|
|
453
451
|
</div>
|
|
454
452
|
)}
|
|
455
453
|
</CardContent>
|
|
@@ -513,14 +513,29 @@ export interface Route {
|
|
|
513
513
|
}
|
|
514
514
|
|
|
515
515
|
// Tracking alert type
|
|
516
|
+
export type TrackingAlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
|
517
|
+
|
|
516
518
|
export interface TrackingAlert {
|
|
517
519
|
id: string;
|
|
518
520
|
type: 'delay' | 'deviation' | 'temperature' | 'damage' | 'security';
|
|
519
|
-
severity:
|
|
521
|
+
severity: TrackingAlertSeverity;
|
|
520
522
|
message: string;
|
|
521
523
|
timestamp?: Date;
|
|
522
524
|
acknowledged: boolean;
|
|
523
525
|
resolvedAt?: Date;
|
|
526
|
+
/** Server alert kind for shipment alerts (BOFF-10397). */
|
|
527
|
+
kind?: 'DELAYED' | 'AT_RISK';
|
|
528
|
+
shipmentId?: string;
|
|
529
|
+
shipmentNumber?: string;
|
|
530
|
+
/** 0–100 forecast risk score; AT_RISK alerts only. */
|
|
531
|
+
riskScore?: number;
|
|
532
|
+
/** The shipment's promised delivery the alert was raised against. */
|
|
533
|
+
promisedDelivery?: Date;
|
|
534
|
+
/** Earliest forecast delivery time; AT_RISK alerts only. */
|
|
535
|
+
predictedDelivery?: Date;
|
|
536
|
+
/** Contributing risk signal codes; AT_RISK alerts only. */
|
|
537
|
+
signals?: string[];
|
|
538
|
+
acknowledgedAt?: Date;
|
|
524
539
|
}
|
|
525
540
|
|
|
526
541
|
// Tracking type
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracking alert mapping (BOFF-10397).
|
|
3
|
+
*
|
|
4
|
+
* `trackingAlerts` returns two kinds of alert: `DELAYED` once a shipment's
|
|
5
|
+
* promised delivery has passed, and the predictive `AT_RISK` alert raised
|
|
6
|
+
* while the promise is still ahead but the deterministic forecast says it
|
|
7
|
+
* will be missed. These helpers turn the API rows into the UI model: a
|
|
8
|
+
* severity, the predicted window and the contributing signals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TrackingAlert, TrackingAlertSeverity } from '../types';
|
|
12
|
+
|
|
13
|
+
export type RiskSignalCode =
|
|
14
|
+
| 'LEG_SLIP'
|
|
15
|
+
| 'OPEN_LEG_OVERDUE'
|
|
16
|
+
| 'ROUTE_DURATION_OVERRUN'
|
|
17
|
+
| 'EXCESS_DWELL'
|
|
18
|
+
| 'TRACKING_STALE'
|
|
19
|
+
| 'VEHICLE_UNAVAILABLE'
|
|
20
|
+
| 'CARRIER_LATE_HISTORY';
|
|
21
|
+
|
|
22
|
+
export type TrackingAlertKind = 'DELAYED' | 'AT_RISK';
|
|
23
|
+
|
|
24
|
+
export interface ApiTrackingAlert {
|
|
25
|
+
id: string;
|
|
26
|
+
shipmentId: string;
|
|
27
|
+
shipmentNumber: string;
|
|
28
|
+
alertType: string;
|
|
29
|
+
message: string;
|
|
30
|
+
riskScore?: number | null;
|
|
31
|
+
promisedDelivery?: string | null;
|
|
32
|
+
predictedDelivery?: string | null;
|
|
33
|
+
signals?: RiskSignalCode[] | null;
|
|
34
|
+
acknowledged: boolean;
|
|
35
|
+
acknowledgedAt?: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Risk score at or above which an AT_RISK alert is shown as high severity (matches the backend HIGH level). */
|
|
39
|
+
export const HIGH_RISK_SCORE = 70;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* DELAYED is always high: the promise is already broken. AT_RISK follows the
|
|
43
|
+
* forecast's risk score; the backend only raises AT_RISK at MEDIUM or above,
|
|
44
|
+
* so anything below the HIGH band is medium.
|
|
45
|
+
*/
|
|
46
|
+
export function trackingAlertSeverity(
|
|
47
|
+
alertType: string,
|
|
48
|
+
riskScore: number | null | undefined
|
|
49
|
+
): TrackingAlertSeverity {
|
|
50
|
+
if (alertType === 'DELAYED') return 'high';
|
|
51
|
+
if (alertType === 'AT_RISK') {
|
|
52
|
+
return typeof riskScore === 'number' && riskScore >= HIGH_RISK_SCORE ? 'high' : 'medium';
|
|
53
|
+
}
|
|
54
|
+
return 'medium';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const toDate = (value: string | null | undefined): Date | undefined => {
|
|
58
|
+
if (!value) return undefined;
|
|
59
|
+
const date = new Date(value);
|
|
60
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function mapTrackingAlert(alert: ApiTrackingAlert): TrackingAlert {
|
|
64
|
+
const kind: TrackingAlertKind = alert.alertType === 'AT_RISK' ? 'AT_RISK' : 'DELAYED';
|
|
65
|
+
return {
|
|
66
|
+
id: alert.id,
|
|
67
|
+
type: 'delay',
|
|
68
|
+
kind,
|
|
69
|
+
severity: trackingAlertSeverity(alert.alertType, alert.riskScore),
|
|
70
|
+
message: alert.message,
|
|
71
|
+
shipmentId: alert.shipmentId,
|
|
72
|
+
shipmentNumber: alert.shipmentNumber,
|
|
73
|
+
riskScore: alert.riskScore ?? undefined,
|
|
74
|
+
promisedDelivery: toDate(alert.promisedDelivery),
|
|
75
|
+
predictedDelivery: toDate(alert.predictedDelivery),
|
|
76
|
+
signals: alert.signals ?? [],
|
|
77
|
+
acknowledged: alert.acknowledged,
|
|
78
|
+
acknowledgedAt: toDate(alert.acknowledgedAt),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Highest severity first, then highest risk score; stable otherwise. */
|
|
83
|
+
const SEVERITY_RANK: Record<TrackingAlertSeverity, number> = {
|
|
84
|
+
critical: 0,
|
|
85
|
+
high: 1,
|
|
86
|
+
medium: 2,
|
|
87
|
+
low: 3,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function sortTrackingAlerts(alerts: TrackingAlert[]): TrackingAlert[] {
|
|
91
|
+
return [...alerts].sort(
|
|
92
|
+
(a, b) =>
|
|
93
|
+
SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
|
|
94
|
+
(b.riskScore ?? -1) - (a.riskScore ?? -1)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Translation key + English fallback for each risk signal code. */
|
|
99
|
+
export const RISK_SIGNAL_LABELS: Record<RiskSignalCode, { key: string; fallback: string }> = {
|
|
100
|
+
LEG_SLIP: { key: 'movethewheels.risk.signals.legSlip', fallback: 'Leg running late' },
|
|
101
|
+
OPEN_LEG_OVERDUE: { key: 'movethewheels.risk.signals.openLegOverdue', fallback: 'Leg overdue' },
|
|
102
|
+
ROUTE_DURATION_OVERRUN: {
|
|
103
|
+
key: 'movethewheels.risk.signals.routeOverrun',
|
|
104
|
+
fallback: 'Route taking longer than planned',
|
|
105
|
+
},
|
|
106
|
+
EXCESS_DWELL: { key: 'movethewheels.risk.signals.excessDwell', fallback: 'Long stop' },
|
|
107
|
+
TRACKING_STALE: {
|
|
108
|
+
key: 'movethewheels.risk.signals.trackingStale',
|
|
109
|
+
fallback: 'No recent tracking',
|
|
110
|
+
},
|
|
111
|
+
VEHICLE_UNAVAILABLE: {
|
|
112
|
+
key: 'movethewheels.risk.signals.vehicleUnavailable',
|
|
113
|
+
fallback: 'Vehicle unavailable',
|
|
114
|
+
},
|
|
115
|
+
CARRIER_LATE_HISTORY: {
|
|
116
|
+
key: 'movethewheels.risk.signals.carrierLateHistory',
|
|
117
|
+
fallback: 'Carrier often late',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function riskSignalLabel(
|
|
122
|
+
code: string,
|
|
123
|
+
t: (key: string, fallback: string) => string
|
|
124
|
+
): string {
|
|
125
|
+
const label = RISK_SIGNAL_LABELS[code as RiskSignalCode];
|
|
126
|
+
return label ? t(label.key, label.fallback) : code;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Minutes between the promise and the predicted delivery, or null when unknown / not late. */
|
|
130
|
+
export function predictedSlipMinutes(
|
|
131
|
+
alert: Pick<TrackingAlert, 'promisedDelivery' | 'predictedDelivery'>
|
|
132
|
+
): number | null {
|
|
133
|
+
if (!alert.promisedDelivery || !alert.predictedDelivery) return null;
|
|
134
|
+
const minutes = Math.round(
|
|
135
|
+
(alert.predictedDelivery.getTime() - alert.promisedDelivery.getTime()) / 60_000
|
|
136
|
+
);
|
|
137
|
+
return minutes > 0 ? minutes : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** "45 min", "3 h 20 min", "2 d 4 h" — compact duration for alert cards. */
|
|
141
|
+
export function formatSlip(minutes: number): string {
|
|
142
|
+
if (minutes < 60) return `${minutes} min`;
|
|
143
|
+
const hours = Math.floor(minutes / 60);
|
|
144
|
+
if (hours < 24) {
|
|
145
|
+
const rest = minutes % 60;
|
|
146
|
+
return rest > 0 ? `${hours} h ${rest} min` : `${hours} h`;
|
|
147
|
+
}
|
|
148
|
+
const days = Math.floor(hours / 24);
|
|
149
|
+
const restHours = hours % 24;
|
|
150
|
+
return restHours > 0 ? `${days} d ${restHours} h` : `${days} d`;
|
|
151
|
+
}
|