@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity import — client side of wspace-movethewheels-svc's bulk import (BOFF-3990).
|
|
3
|
+
*
|
|
4
|
+
* The service owns parsing and validation; this module only picks the right
|
|
5
|
+
* mutation for an entity, detects the file format, builds CSV templates from
|
|
6
|
+
* the server's column specification and shapes the result for display.
|
|
7
|
+
* Kept free of React so the contract can be unit-tested.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const MTW_IMPORT_ENTITIES = [
|
|
11
|
+
{ entityType: 'CUSTOMER', label: 'Customers', noun: 'customer', field: 'importMtwCustomers' },
|
|
12
|
+
{ entityType: 'VEHICLE', label: 'Vehicles', noun: 'vehicle', field: 'importMtwVehicles' },
|
|
13
|
+
{ entityType: 'DRIVER', label: 'Drivers', noun: 'driver', field: 'importMtwDrivers' },
|
|
14
|
+
{ entityType: 'WAREHOUSE', label: 'Warehouses', noun: 'warehouse', field: 'importMtwWarehouses' },
|
|
15
|
+
{ entityType: 'CARGO_ITEM', label: 'Products', noun: 'product', field: 'importMtwCargoItems' },
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type MtwImportEntityType = (typeof MTW_IMPORT_ENTITIES)[number]['entityType'];
|
|
19
|
+
export type MtwImportFormat = 'CSV' | 'JSON';
|
|
20
|
+
export type MtwImportStatus = 'DRY_RUN' | 'COMPLETED' | 'PARTIAL' | 'REJECTED';
|
|
21
|
+
|
|
22
|
+
/** Mirrors the service limits so an oversized file is refused before upload. */
|
|
23
|
+
export const MTW_IMPORT_MAX_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
export const MTW_IMPORT_MAX_ROWS = 1000;
|
|
25
|
+
|
|
26
|
+
export function isImportEntityType(value: string | null | undefined): value is MtwImportEntityType {
|
|
27
|
+
return MTW_IMPORT_ENTITIES.some((entity) => entity.entityType === value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function importEntity(entityType: MtwImportEntityType) {
|
|
31
|
+
const entity = MTW_IMPORT_ENTITIES.find((candidate) => candidate.entityType === entityType);
|
|
32
|
+
if (!entity) throw new Error(`Unknown import entity ${entityType}`);
|
|
33
|
+
return entity;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One static document per entity so graphql-schema-drift can validate each
|
|
37
|
+
// against the subgraph schema; the selection sets are identical.
|
|
38
|
+
export const IMPORT_MUTATIONS: Record<MtwImportEntityType, string> = {
|
|
39
|
+
CUSTOMER: `
|
|
40
|
+
mutation ImportMtwCustomers($input: MtwImportInput!) {
|
|
41
|
+
importMtwCustomers(input: $input) {
|
|
42
|
+
entityType
|
|
43
|
+
status
|
|
44
|
+
dryRun
|
|
45
|
+
totalRows
|
|
46
|
+
validRows
|
|
47
|
+
importedRows
|
|
48
|
+
failedRows
|
|
49
|
+
createdIds
|
|
50
|
+
errorsTruncated
|
|
51
|
+
errors {
|
|
52
|
+
row
|
|
53
|
+
field
|
|
54
|
+
message
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
`,
|
|
59
|
+
VEHICLE: `
|
|
60
|
+
mutation ImportMtwVehicles($input: MtwImportInput!) {
|
|
61
|
+
importMtwVehicles(input: $input) {
|
|
62
|
+
entityType
|
|
63
|
+
status
|
|
64
|
+
dryRun
|
|
65
|
+
totalRows
|
|
66
|
+
validRows
|
|
67
|
+
importedRows
|
|
68
|
+
failedRows
|
|
69
|
+
createdIds
|
|
70
|
+
errorsTruncated
|
|
71
|
+
errors {
|
|
72
|
+
row
|
|
73
|
+
field
|
|
74
|
+
message
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`,
|
|
79
|
+
DRIVER: `
|
|
80
|
+
mutation ImportMtwDrivers($input: MtwImportInput!) {
|
|
81
|
+
importMtwDrivers(input: $input) {
|
|
82
|
+
entityType
|
|
83
|
+
status
|
|
84
|
+
dryRun
|
|
85
|
+
totalRows
|
|
86
|
+
validRows
|
|
87
|
+
importedRows
|
|
88
|
+
failedRows
|
|
89
|
+
createdIds
|
|
90
|
+
errorsTruncated
|
|
91
|
+
errors {
|
|
92
|
+
row
|
|
93
|
+
field
|
|
94
|
+
message
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
`,
|
|
99
|
+
WAREHOUSE: `
|
|
100
|
+
mutation ImportMtwWarehouses($input: MtwImportInput!) {
|
|
101
|
+
importMtwWarehouses(input: $input) {
|
|
102
|
+
entityType
|
|
103
|
+
status
|
|
104
|
+
dryRun
|
|
105
|
+
totalRows
|
|
106
|
+
validRows
|
|
107
|
+
importedRows
|
|
108
|
+
failedRows
|
|
109
|
+
createdIds
|
|
110
|
+
errorsTruncated
|
|
111
|
+
errors {
|
|
112
|
+
row
|
|
113
|
+
field
|
|
114
|
+
message
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
`,
|
|
119
|
+
CARGO_ITEM: `
|
|
120
|
+
mutation ImportMtwCargoItems($input: MtwImportInput!) {
|
|
121
|
+
importMtwCargoItems(input: $input) {
|
|
122
|
+
entityType
|
|
123
|
+
status
|
|
124
|
+
dryRun
|
|
125
|
+
totalRows
|
|
126
|
+
validRows
|
|
127
|
+
importedRows
|
|
128
|
+
failedRows
|
|
129
|
+
createdIds
|
|
130
|
+
errorsTruncated
|
|
131
|
+
errors {
|
|
132
|
+
row
|
|
133
|
+
field
|
|
134
|
+
message
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The mutation document for one entity's import. */
|
|
142
|
+
export function importMutation(entityType: MtwImportEntityType): string {
|
|
143
|
+
return IMPORT_MUTATIONS[importEntity(entityType).entityType];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const MTW_IMPORT_SPEC_QUERY = `
|
|
147
|
+
query MtwImportSpec {
|
|
148
|
+
mtwImportSpec {
|
|
149
|
+
entityType
|
|
150
|
+
keyColumn
|
|
151
|
+
columns {
|
|
152
|
+
name
|
|
153
|
+
type
|
|
154
|
+
required
|
|
155
|
+
description
|
|
156
|
+
allowedValues
|
|
157
|
+
maxLength
|
|
158
|
+
min
|
|
159
|
+
max
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
export interface MtwImportColumn {
|
|
166
|
+
name: string;
|
|
167
|
+
type: string;
|
|
168
|
+
required: boolean;
|
|
169
|
+
description: string;
|
|
170
|
+
allowedValues: string[] | null;
|
|
171
|
+
maxLength: number | null;
|
|
172
|
+
min: number | null;
|
|
173
|
+
max: number | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface MtwImportEntitySpec {
|
|
177
|
+
entityType: MtwImportEntityType;
|
|
178
|
+
keyColumn: string | null;
|
|
179
|
+
columns: MtwImportColumn[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface MtwImportRowError {
|
|
183
|
+
row: number;
|
|
184
|
+
field: string | null;
|
|
185
|
+
message: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface MtwImportResult {
|
|
189
|
+
entityType: MtwImportEntityType;
|
|
190
|
+
status: MtwImportStatus;
|
|
191
|
+
dryRun: boolean;
|
|
192
|
+
totalRows: number;
|
|
193
|
+
validRows: number;
|
|
194
|
+
importedRows: number;
|
|
195
|
+
failedRows: number;
|
|
196
|
+
createdIds: string[];
|
|
197
|
+
errorsTruncated: boolean;
|
|
198
|
+
errors: MtwImportRowError[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Picks the format from the file name, falling back to the MIME type. */
|
|
202
|
+
export function detectImportFormat(fileName: string, mimeType = ''): MtwImportFormat | null {
|
|
203
|
+
const lower = fileName.toLowerCase();
|
|
204
|
+
if (lower.endsWith('.csv')) return 'CSV';
|
|
205
|
+
if (lower.endsWith('.json')) return 'JSON';
|
|
206
|
+
if (mimeType.includes('csv')) return 'CSV';
|
|
207
|
+
if (mimeType.includes('json')) return 'JSON';
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function csvCell(value: string): string {
|
|
212
|
+
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A CSV template: the header row, required columns first, then optional ones. */
|
|
216
|
+
export function buildCsvTemplate(spec: MtwImportEntitySpec): string {
|
|
217
|
+
const ordered = [
|
|
218
|
+
...spec.columns.filter((column) => column.required),
|
|
219
|
+
...spec.columns.filter((column) => !column.required),
|
|
220
|
+
];
|
|
221
|
+
return `${ordered.map((column) => csvCell(column.name)).join(',')}\n`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One-line summary of an import result for the status banner. */
|
|
225
|
+
export function summariseImport(result: MtwImportResult, noun: string): string {
|
|
226
|
+
const rows = (count: number) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
227
|
+
switch (result.status) {
|
|
228
|
+
case 'DRY_RUN':
|
|
229
|
+
return result.failedRows === 0
|
|
230
|
+
? `All ${rows(result.totalRows)} are valid. Nothing was imported yet.`
|
|
231
|
+
: `${result.validRows} of ${rows(result.totalRows)} are valid; ${result.failedRows} need fixing. Nothing was imported.`;
|
|
232
|
+
case 'COMPLETED':
|
|
233
|
+
return `Imported ${rows(result.importedRows)}.`;
|
|
234
|
+
case 'PARTIAL':
|
|
235
|
+
return `Imported ${rows(result.importedRows)}; ${result.failedRows} row${result.failedRows === 1 ? '' : 's'} failed.`;
|
|
236
|
+
case 'REJECTED':
|
|
237
|
+
return `Nothing was imported: ${result.failedRows} of ${result.totalRows} rows have problems. Fix them, or allow a partial import.`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -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
|
+
}
|