@burdenoff/microfe-movethewheels 2026.1031.2 → 2026.1031.4
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 +121 -104
- package/dist/MoveTheWheelsRoutes.js +142 -133
- package/dist/components/DocumentsCard.js +11 -11
- package/dist/components/ProofOfDeliveryCard.js +13 -13
- package/dist/components/ShipmentRiskDialog.js +24 -24
- package/dist/components/ShippingQuoteResults.js +46 -46
- package/dist/components/TrackingAlertList.js +17 -17
- package/dist/generated/tracking-quality-operations.d.ts +140 -0
- package/dist/hooks/useEntityImport.js +68 -0
- package/dist/hooks/useOrderDispatch.js +17 -17
- package/dist/movethewheels/hooks/useEntityImport.d.ts +19 -0
- package/dist/movethewheels/pages/ImportExportPage.d.ts +9 -0
- package/dist/movethewheels/pages/SettingsPage.d.ts +1 -0
- package/dist/movethewheels/providers/ModuleSettingsProvider.d.ts +17 -0
- package/dist/movethewheels/utils/entityImport.d.ts +109 -0
- package/dist/movethewheels/utils/formatters.d.ts +23 -3
- package/dist/movethewheels/utils/moduleSettings.d.ts +52 -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/bell.js +11 -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/node_modules/lucide-react/dist/esm/icons/key-round.js +14 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/plug.js +22 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/rotate-ccw.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/settings.js +13 -0
- package/dist/pages/AnalyticsPage.js +27 -27
- package/dist/pages/CreateOrderPage.js +340 -317
- package/dist/pages/CustomerDetailsPage.js +246 -246
- package/dist/pages/CustomersPage.js +88 -79
- package/dist/pages/DashboardPage.js +24 -24
- package/dist/pages/DeliveryCapturePage.js +74 -74
- package/dist/pages/DriverDetailsPage.js +337 -337
- package/dist/pages/DriversPage.js +119 -110
- package/dist/pages/FinancePage.js +26 -26
- package/dist/pages/ImportExportPage.js +435 -0
- package/dist/pages/InventoryPage.js +28 -28
- package/dist/pages/LiveTrackingPage.js +108 -108
- package/dist/pages/MarketplacePage.js +44 -44
- package/dist/pages/OrderDetailsPage.js +306 -306
- package/dist/pages/OrdersListPage.js +27 -27
- package/dist/pages/ProductDetailsPage.js +89 -89
- package/dist/pages/ProductsPage.js +86 -77
- package/dist/pages/RouteDetailsPage.js +282 -285
- package/dist/pages/RoutesPage.js +78 -78
- package/dist/pages/SettingsPage.js +459 -0
- package/dist/pages/TrackingPage.js +41 -41
- package/dist/pages/VehicleDetailsPage.js +207 -207
- package/dist/pages/VehiclesPage.js +100 -90
- package/dist/pages/WarehouseDetailsPage.js +132 -132
- package/dist/pages/WarehousesPage.js +97 -88
- package/dist/providers/ModuleSettingsProvider.js +74 -0
- package/dist/utils/entityImport.js +73 -0
- package/dist/utils/formatters.js +53 -26
- package/dist/utils/moduleSettings.js +132 -0
- package/package.json +4 -2
- package/src/generated/tracking-quality-operations.ts +184 -0
- package/src/movethewheels/MoveTheWheelsRoot.tsx +22 -3
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +13 -0
- package/src/movethewheels/hooks/useEntityImport.ts +108 -0
- package/src/movethewheels/pages/CreateOrderPage.tsx +38 -6
- package/src/movethewheels/pages/CustomersPage.tsx +6 -1
- package/src/movethewheels/pages/DriversPage.tsx +6 -0
- package/src/movethewheels/pages/ImportExportPage.tsx +483 -0
- package/src/movethewheels/pages/ProductsPage.tsx +16 -3
- package/src/movethewheels/pages/RouteDetailsPage.tsx +3 -6
- package/src/movethewheels/pages/RoutesPage.tsx +2 -2
- package/src/movethewheels/pages/SettingsPage.tsx +516 -0
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +4 -2
- package/src/movethewheels/pages/VehiclesPage.tsx +8 -1
- package/src/movethewheels/pages/WarehousesPage.tsx +6 -1
- package/src/movethewheels/providers/ModuleSettingsProvider.tsx +133 -0
- package/src/movethewheels/utils/entityImport.ts +239 -0
- package/src/movethewheels/utils/formatters.ts +69 -10
- package/src/movethewheels/utils/moduleSettings.ts +224 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module settings provider (BOFF-10488).
|
|
3
|
+
*
|
|
4
|
+
* Loads the workspace's MoveTheWheels operational defaults once per workspace
|
|
5
|
+
* and applies them to the shared formatters, so amounts, distances, weights
|
|
6
|
+
* and dates across every page follow the workspace's currency, units and time
|
|
7
|
+
* zone. Until the settings load — or if they cannot be read — the platform
|
|
8
|
+
* defaults apply, which is exactly what the formatters showed before.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createContext,
|
|
12
|
+
useCallback,
|
|
13
|
+
useContext,
|
|
14
|
+
useEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useState,
|
|
17
|
+
type FC,
|
|
18
|
+
type PropsWithChildren,
|
|
19
|
+
} from 'react';
|
|
20
|
+
import { useMoveTheWheels } from './MoveTheWheelsProvider';
|
|
21
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
22
|
+
import { setFormatPreferences } from '../utils/formatters';
|
|
23
|
+
import {
|
|
24
|
+
MTW_MODULE_SETTINGS_QUERY,
|
|
25
|
+
UPDATE_MTW_MODULE_SETTINGS_MUTATION,
|
|
26
|
+
platformDefaults,
|
|
27
|
+
toFormatPreferences,
|
|
28
|
+
type MtwModuleSettings,
|
|
29
|
+
type MtwModuleSettingsInput,
|
|
30
|
+
} from '../utils/moduleSettings';
|
|
31
|
+
|
|
32
|
+
interface ModuleSettingsContextValue {
|
|
33
|
+
settings: MtwModuleSettings;
|
|
34
|
+
/** True once the workspace's settings were read (or the read failed). */
|
|
35
|
+
isLoaded: boolean;
|
|
36
|
+
error: Error | null;
|
|
37
|
+
save: (input: MtwModuleSettingsInput) => Promise<MtwModuleSettings>;
|
|
38
|
+
reload: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ModuleSettingsContext = createContext<ModuleSettingsContextValue | null>(null);
|
|
42
|
+
|
|
43
|
+
export const ModuleSettingsProvider: FC<PropsWithChildren> = ({ children }) => {
|
|
44
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
45
|
+
useMoveTheWheels();
|
|
46
|
+
const graphQLOptions = useMemo(
|
|
47
|
+
() => ({
|
|
48
|
+
apiGatewayUrl,
|
|
49
|
+
authToken,
|
|
50
|
+
workspaceToken,
|
|
51
|
+
workspaceId,
|
|
52
|
+
tenantId,
|
|
53
|
+
actorId: currentUser?.id,
|
|
54
|
+
}),
|
|
55
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
56
|
+
);
|
|
57
|
+
const [settings, setSettings] = useState<MtwModuleSettings>(() =>
|
|
58
|
+
platformDefaults(workspaceId ?? '')
|
|
59
|
+
);
|
|
60
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
61
|
+
const [error, setError] = useState<Error | null>(null);
|
|
62
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
63
|
+
|
|
64
|
+
const apply = useCallback((next: MtwModuleSettings) => {
|
|
65
|
+
setFormatPreferences(toFormatPreferences(next));
|
|
66
|
+
setSettings(next);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!graphQLOptions.workspaceId) return;
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
executeGraphQL<{ mtwModuleSettings: MtwModuleSettings }>(
|
|
73
|
+
graphQLOptions,
|
|
74
|
+
MTW_MODULE_SETTINGS_QUERY
|
|
75
|
+
)
|
|
76
|
+
.then((data) => {
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
apply(data.mtwModuleSettings);
|
|
79
|
+
setError(null);
|
|
80
|
+
})
|
|
81
|
+
.catch((caught: unknown) => {
|
|
82
|
+
if (cancelled) return;
|
|
83
|
+
// Keep the platform defaults: a failed read must not blank any page.
|
|
84
|
+
apply(platformDefaults(graphQLOptions.workspaceId ?? ''));
|
|
85
|
+
setError(caught instanceof Error ? caught : new Error('Could not load settings'));
|
|
86
|
+
})
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (!cancelled) setIsLoaded(true);
|
|
89
|
+
});
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
};
|
|
93
|
+
}, [graphQLOptions, reloadKey, apply]);
|
|
94
|
+
|
|
95
|
+
const save = useCallback(
|
|
96
|
+
async (input: MtwModuleSettingsInput) => {
|
|
97
|
+
const data = await executeGraphQL<{ updateMtwModuleSettings: MtwModuleSettings }>(
|
|
98
|
+
graphQLOptions,
|
|
99
|
+
UPDATE_MTW_MODULE_SETTINGS_MUTATION,
|
|
100
|
+
{ input }
|
|
101
|
+
);
|
|
102
|
+
apply(data.updateMtwModuleSettings);
|
|
103
|
+
setError(null);
|
|
104
|
+
return data.updateMtwModuleSettings;
|
|
105
|
+
},
|
|
106
|
+
[graphQLOptions, apply]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const reload = useCallback(() => setReloadKey((key) => key + 1), []);
|
|
110
|
+
|
|
111
|
+
const value = useMemo(
|
|
112
|
+
() => ({ settings, isLoaded, error, save, reload }),
|
|
113
|
+
[settings, isLoaded, error, save, reload]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return <ModuleSettingsContext.Provider value={value}>{children}</ModuleSettingsContext.Provider>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The workspace's MoveTheWheels defaults. Outside the provider (stories,
|
|
121
|
+
* isolated component tests) it returns the platform defaults read-only.
|
|
122
|
+
*/
|
|
123
|
+
export function useModuleSettings(): ModuleSettingsContextValue {
|
|
124
|
+
const context = useContext(ModuleSettingsContext);
|
|
125
|
+
if (context) return context;
|
|
126
|
+
return {
|
|
127
|
+
settings: platformDefaults(),
|
|
128
|
+
isLoaded: true,
|
|
129
|
+
error: null,
|
|
130
|
+
save: () => Promise.reject(new Error('Module settings are not available here')),
|
|
131
|
+
reload: () => undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,46 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Address } from '../types';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Workspace display preferences (BOFF-10488). The module settings provider
|
|
9
|
+
* applies the workspace's saved MoveTheWheels defaults here once they load,
|
|
10
|
+
* so every formatter below honours them without each call site passing them.
|
|
11
|
+
* Until then the platform defaults apply.
|
|
12
|
+
*/
|
|
13
|
+
export interface FormatPreferences {
|
|
14
|
+
/** ISO 4217 code for amounts that carry no currency of their own. */
|
|
15
|
+
currency: string;
|
|
16
|
+
distanceUnit: 'KM' | 'MI';
|
|
17
|
+
weightUnit: 'KG' | 'LB';
|
|
18
|
+
/** IANA zone; null formats in the viewer's own local time. */
|
|
19
|
+
timeZone: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_FORMAT_PREFERENCES: Readonly<FormatPreferences> = {
|
|
23
|
+
currency: 'USD',
|
|
24
|
+
distanceUnit: 'KM',
|
|
25
|
+
weightUnit: 'KG',
|
|
26
|
+
timeZone: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let preferences: FormatPreferences = { ...DEFAULT_FORMAT_PREFERENCES };
|
|
30
|
+
|
|
31
|
+
export function setFormatPreferences(next: FormatPreferences): void {
|
|
32
|
+
preferences = { ...next };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getFormatPreferences(): Readonly<FormatPreferences> {
|
|
36
|
+
return preferences;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The workspace time zone for Intl options; undefined means the viewer's local zone. */
|
|
40
|
+
function zone(): { timeZone?: string } {
|
|
41
|
+
return preferences.timeZone ? { timeZone: preferences.timeZone } : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const KM_PER_MILE = 1.609344;
|
|
45
|
+
const KG_PER_POUND = 0.45359237;
|
|
46
|
+
|
|
7
47
|
/**
|
|
8
48
|
* Format an address object into a readable string.
|
|
9
49
|
*/
|
|
@@ -19,12 +59,13 @@ export function formatAddress(address: Partial<Address>): string {
|
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
/**
|
|
22
|
-
* Format a number as currency.
|
|
62
|
+
* Format a number as currency. Amounts without their own currency use the
|
|
63
|
+
* workspace default currency.
|
|
23
64
|
*/
|
|
24
|
-
export function formatCurrency(amount: number, currency
|
|
65
|
+
export function formatCurrency(amount: number, currency?: string): string {
|
|
25
66
|
return new Intl.NumberFormat('en-US', {
|
|
26
67
|
style: 'currency',
|
|
27
|
-
currency,
|
|
68
|
+
currency: currency || preferences.currency,
|
|
28
69
|
minimumFractionDigits: 2,
|
|
29
70
|
maximumFractionDigits: 2,
|
|
30
71
|
}).format(amount);
|
|
@@ -50,6 +91,7 @@ export function formatDate(
|
|
|
50
91
|
hour: 'numeric',
|
|
51
92
|
minute: '2-digit',
|
|
52
93
|
hour12: true,
|
|
94
|
+
...zone(),
|
|
53
95
|
}).format(d);
|
|
54
96
|
}
|
|
55
97
|
|
|
@@ -62,6 +104,7 @@ export function formatDate(
|
|
|
62
104
|
hour: 'numeric',
|
|
63
105
|
minute: '2-digit',
|
|
64
106
|
hour12: true,
|
|
107
|
+
...zone(),
|
|
65
108
|
}).format(d);
|
|
66
109
|
}
|
|
67
110
|
|
|
@@ -70,6 +113,7 @@ export function formatDate(
|
|
|
70
113
|
year: 'numeric',
|
|
71
114
|
month: 'short',
|
|
72
115
|
day: 'numeric',
|
|
116
|
+
...zone(),
|
|
73
117
|
}).format(d);
|
|
74
118
|
}
|
|
75
119
|
|
|
@@ -85,6 +129,7 @@ export function formatDateTime(date: Date | string): string {
|
|
|
85
129
|
hour: 'numeric',
|
|
86
130
|
minute: '2-digit',
|
|
87
131
|
hour12: true,
|
|
132
|
+
...zone(),
|
|
88
133
|
}).format(d);
|
|
89
134
|
}
|
|
90
135
|
|
|
@@ -97,6 +142,7 @@ export function formatTime(date: Date | string): string {
|
|
|
97
142
|
hour: 'numeric',
|
|
98
143
|
minute: '2-digit',
|
|
99
144
|
hour12: true,
|
|
145
|
+
...zone(),
|
|
100
146
|
}).format(d);
|
|
101
147
|
}
|
|
102
148
|
|
|
@@ -144,24 +190,37 @@ export function formatPercentage(value: number, decimals = 1): string {
|
|
|
144
190
|
return `${value.toFixed(decimals)}%`;
|
|
145
191
|
}
|
|
146
192
|
|
|
193
|
+
const twoDecimals = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
|
194
|
+
const oneDecimal = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
|
195
|
+
|
|
147
196
|
/**
|
|
148
|
-
* Format weight
|
|
197
|
+
* Format a weight given in kilograms, in the workspace weight unit: kg (and
|
|
198
|
+
* tonnes from 1,000 kg) or lb.
|
|
149
199
|
*/
|
|
150
200
|
export function formatWeight(kg: number): string {
|
|
151
|
-
if (
|
|
152
|
-
return `${(kg /
|
|
201
|
+
if (preferences.weightUnit === 'LB') {
|
|
202
|
+
return `${twoDecimals.format(kg / KG_PER_POUND)} lb`;
|
|
153
203
|
}
|
|
154
|
-
|
|
204
|
+
if (Math.abs(kg) >= 1000) {
|
|
205
|
+
return `${twoDecimals.format(kg / 1000)} tonnes`;
|
|
206
|
+
}
|
|
207
|
+
return `${twoDecimals.format(kg)} kg`;
|
|
155
208
|
}
|
|
156
209
|
|
|
157
210
|
/**
|
|
158
|
-
* Format distance
|
|
211
|
+
* Format a distance given in kilometres, in the workspace distance unit: km
|
|
212
|
+
* (metres below 1 km) or mi (feet below 0.1 mi).
|
|
159
213
|
*/
|
|
160
214
|
export function formatDistance(km: number): string {
|
|
161
|
-
if (
|
|
215
|
+
if (preferences.distanceUnit === 'MI') {
|
|
216
|
+
const miles = km / KM_PER_MILE;
|
|
217
|
+
if (Math.abs(miles) < 0.1) return `${Math.round(miles * 5280)} ft`;
|
|
218
|
+
return `${oneDecimal.format(miles)} mi`;
|
|
219
|
+
}
|
|
220
|
+
if (Math.abs(km) < 1) {
|
|
162
221
|
return `${Math.round(km * 1000)} m`;
|
|
163
222
|
}
|
|
164
|
-
return `${
|
|
223
|
+
return `${oneDecimal.format(km)} km`;
|
|
165
224
|
}
|
|
166
225
|
|
|
167
226
|
/**
|