@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.
Files changed (51) hide show
  1. package/dist/MoveTheWheelsRoot.js +144 -136
  2. package/dist/MoveTheWheelsRoutes.js +61 -57
  3. package/dist/components/ShipmentRiskDialog.js +228 -0
  4. package/dist/components/TrackingAlertList.js +149 -0
  5. package/dist/generated/tracking-quality-operations.d.ts +1154 -2
  6. package/dist/generated/wspace-types.d.ts +2895 -50
  7. package/dist/hooks/useEntityImport.js +68 -0
  8. package/dist/hooks/useShipmentRisk.js +72 -0
  9. package/dist/hooks/useTracking.js +96 -82
  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/useEntityImport.d.ts +19 -0
  13. package/dist/movethewheels/hooks/useShipmentRisk.d.ts +41 -0
  14. package/dist/movethewheels/pages/ImportExportPage.d.ts +9 -0
  15. package/dist/movethewheels/types/index.d.ts +15 -1
  16. package/dist/movethewheels/utils/entityImport.d.ts +109 -0
  17. package/dist/movethewheels/utils/trackingAlerts.d.ts +36 -0
  18. package/dist/node_modules/lucide-react/dist/esm/icons/arrow-down-up.js +22 -0
  19. package/dist/node_modules/lucide-react/dist/esm/icons/file-spreadsheet.js +30 -0
  20. package/dist/node_modules/lucide-react/dist/esm/icons/file-up.js +22 -0
  21. package/dist/pages/CustomersPage.js +88 -79
  22. package/dist/pages/DashboardPage.js +147 -151
  23. package/dist/pages/DriversPage.js +115 -106
  24. package/dist/pages/ImportExportPage.js +435 -0
  25. package/dist/pages/LiveTrackingPage.js +169 -169
  26. package/dist/pages/ProductsPage.js +85 -76
  27. package/dist/pages/VehiclesPage.js +99 -90
  28. package/dist/pages/WarehousesPage.js +97 -88
  29. package/dist/utils/entityImport.js +73 -0
  30. package/dist/utils/trackingAlerts.js +86 -0
  31. package/package.json +4 -2
  32. package/src/generated/tracking-quality-operations.ts +1519 -1
  33. package/src/generated/wspace-types.ts +3248 -51
  34. package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
  35. package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
  36. package/src/movethewheels/components/ShipmentRiskDialog.tsx +315 -0
  37. package/src/movethewheels/components/TrackingAlertList.tsx +188 -0
  38. package/src/movethewheels/hooks/useEntityImport.ts +108 -0
  39. package/src/movethewheels/hooks/useShipmentRisk.ts +195 -0
  40. package/src/movethewheels/hooks/useTracking.ts +66 -22
  41. package/src/movethewheels/pages/CustomersPage.tsx +6 -1
  42. package/src/movethewheels/pages/DashboardPage.tsx +10 -20
  43. package/src/movethewheels/pages/DriversPage.tsx +6 -0
  44. package/src/movethewheels/pages/ImportExportPage.tsx +483 -0
  45. package/src/movethewheels/pages/LiveTrackingPage.tsx +20 -22
  46. package/src/movethewheels/pages/ProductsPage.tsx +14 -1
  47. package/src/movethewheels/pages/VehiclesPage.tsx +6 -0
  48. package/src/movethewheels/pages/WarehousesPage.tsx +6 -1
  49. package/src/movethewheels/types/index.ts +16 -1
  50. package/src/movethewheels/utils/entityImport.ts +239 -0
  51. package/src/movethewheels/utils/trackingAlerts.ts +151 -0
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Import / Export Page (BOFF-3990)
3
+ *
4
+ * Bulk data in and out of MoveTheWheels. Import validates a CSV or JSON file
5
+ * row by row on the server (dry run first, then import) for customers,
6
+ * vehicles, drivers, warehouses and products. Export starts the same
7
+ * `exportMtwEntities` job the list pages use and downloads the archive.
8
+ */
9
+
10
+ import { useMemo, useRef, useState, type ChangeEvent } from 'react';
11
+ import { useSearchParams } from 'react-router';
12
+ import {
13
+ AlertCircle,
14
+ ArrowDownUp,
15
+ CheckCircle,
16
+ Download,
17
+ FileSpreadsheet,
18
+ FileUp,
19
+ Info,
20
+ Loader2,
21
+ ShieldCheck,
22
+ Upload,
23
+ } from 'lucide-react';
24
+ import { EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
25
+ import { PageLayout } from '../components/PageLayout';
26
+ import { ExportStatusBanner } from '../components/ExportStatusBanner';
27
+ import {
28
+ Badge,
29
+ Button,
30
+ Card,
31
+ CardContent,
32
+ CardDescription,
33
+ CardHeader,
34
+ CardTitle,
35
+ Checkbox,
36
+ Label,
37
+ Select,
38
+ SelectContent,
39
+ SelectItem,
40
+ SelectTrigger,
41
+ SelectValue,
42
+ Skeleton,
43
+ Table,
44
+ TableBody,
45
+ TableCell,
46
+ TableHead,
47
+ TableHeader,
48
+ TableRow,
49
+ } from '../components/ui';
50
+ import { useEntityExport } from '../hooks/useEntityExport';
51
+ import { useEntityImport, useImportSpec } from '../hooks/useEntityImport';
52
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
53
+ import {
54
+ MTW_IMPORT_ENTITIES,
55
+ MTW_IMPORT_MAX_ROWS,
56
+ buildCsvTemplate,
57
+ detectImportFormat,
58
+ importEntity,
59
+ isImportEntityType,
60
+ summariseImport,
61
+ type MtwImportColumn,
62
+ type MtwImportEntityType,
63
+ type MtwImportFormat,
64
+ type MtwImportResult,
65
+ } from '../utils/entityImport';
66
+ import type { MtwExportEntity } from '../utils/entityExport';
67
+ import { joinPath } from '../utils/navigation';
68
+
69
+ /** Where each imported entity is listed, relative to the MFE base path. */
70
+ const LIST_PATHS: Record<MtwImportEntityType, string> = {
71
+ CUSTOMER: 'customers',
72
+ VEHICLE: 'fleet/vehicles',
73
+ DRIVER: 'fleet/drivers',
74
+ WAREHOUSE: 'inventory/warehouses',
75
+ CARGO_ITEM: 'inventory/products',
76
+ };
77
+
78
+ const EXPORT_OPTIONS: Array<{ entity: MtwExportEntity; label: string; noun: string }> = [
79
+ { entity: 'orders', label: 'Orders', noun: 'order' },
80
+ { entity: 'customers', label: 'Customers', noun: 'customer' },
81
+ { entity: 'vehicles', label: 'Vehicles', noun: 'vehicle' },
82
+ { entity: 'drivers', label: 'Drivers', noun: 'driver' },
83
+ { entity: 'products', label: 'Products', noun: 'product' },
84
+ ];
85
+
86
+ interface SelectedFile {
87
+ name: string;
88
+ format: MtwImportFormat;
89
+ content: string;
90
+ }
91
+
92
+ function downloadText(fileName: string, text: string, type: string): void {
93
+ const url = URL.createObjectURL(new Blob([text], { type }));
94
+ const anchor = document.createElement('a');
95
+ anchor.href = url;
96
+ anchor.download = fileName;
97
+ document.body.appendChild(anchor);
98
+ anchor.click();
99
+ anchor.remove();
100
+ URL.revokeObjectURL(url);
101
+ }
102
+
103
+ function columnRule(column: MtwImportColumn): string {
104
+ if (column.allowedValues?.length) return column.allowedValues.join(', ');
105
+ const parts: string[] = [column.type.toLowerCase()];
106
+ if (column.maxLength !== null) parts.push(`max ${column.maxLength} chars`);
107
+ if (column.min !== null && column.max !== null) parts.push(`${column.min} to ${column.max}`);
108
+ else if (column.min !== null) parts.push(`at least ${column.min}`);
109
+ return parts.join(', ');
110
+ }
111
+
112
+ function ImportResultPanel({
113
+ result,
114
+ noun,
115
+ onOpenList,
116
+ }: {
117
+ result: MtwImportResult;
118
+ noun: string;
119
+ onOpenList: () => void;
120
+ }) {
121
+ const failed = result.status === 'REJECTED' || (result.dryRun && result.failedRows > 0);
122
+ const tone = failed
123
+ ? 'border-status-error-border bg-status-error-bg-subtle text-status-error-text'
124
+ : result.status === 'PARTIAL'
125
+ ? 'border-status-warning-border bg-status-warning-bg-subtle text-status-warning-text'
126
+ : 'border-status-success-border bg-status-success-bg-subtle text-status-success-text';
127
+ const Icon = failed ? AlertCircle : CheckCircle;
128
+
129
+ return (
130
+ <div className="space-y-3" data-testid="mtw-import-result" data-status={result.status}>
131
+ <div
132
+ role={failed ? 'alert' : 'status'}
133
+ className={`flex items-start gap-2 rounded-lg border px-4 py-3 text-sm ${tone}`}
134
+ >
135
+ <Icon size={18} className="mt-0.5 shrink-0" />
136
+ <span className="flex-1">{summariseImport(result, noun)}</span>
137
+ {result.importedRows > 0 ? (
138
+ <button
139
+ type="button"
140
+ className="font-medium underline underline-offset-2"
141
+ onClick={onOpenList}
142
+ >
143
+ View {noun}s
144
+ </button>
145
+ ) : null}
146
+ </div>
147
+ <div className="flex flex-wrap gap-2 text-sm">
148
+ <Badge variant="outline">Rows: {result.totalRows}</Badge>
149
+ <Badge variant="outline">Valid: {result.validRows}</Badge>
150
+ <Badge variant="outline">Imported: {result.importedRows}</Badge>
151
+ <Badge variant={result.failedRows > 0 ? 'destructive' : 'outline'}>
152
+ Problems: {result.failedRows}
153
+ </Badge>
154
+ </div>
155
+ {result.errors.length > 0 ? (
156
+ <div className="max-h-80 overflow-auto rounded-lg border border-border-default">
157
+ <Table>
158
+ <TableHeader>
159
+ <TableRow>
160
+ <TableHead className="w-20">Row</TableHead>
161
+ <TableHead className="w-40">Column</TableHead>
162
+ <TableHead>Problem</TableHead>
163
+ </TableRow>
164
+ </TableHeader>
165
+ <TableBody>
166
+ {result.errors.map((error, index) => (
167
+ <TableRow key={`${error.row}-${error.field ?? ''}-${index}`}>
168
+ <TableCell>{error.row}</TableCell>
169
+ <TableCell className="font-mono text-xs">{error.field ?? '—'}</TableCell>
170
+ <TableCell>{error.message}</TableCell>
171
+ </TableRow>
172
+ ))}
173
+ </TableBody>
174
+ </Table>
175
+ </div>
176
+ ) : null}
177
+ {result.errorsTruncated ? (
178
+ <p className="text-sm text-text-secondary">
179
+ Only the first {result.errors.length} problems are listed. Fix these and validate again to
180
+ see the rest.
181
+ </p>
182
+ ) : null}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function ExportRow({
188
+ entity,
189
+ label,
190
+ noun,
191
+ }: {
192
+ entity: MtwExportEntity;
193
+ label: string;
194
+ noun: string;
195
+ }) {
196
+ const { state, isExporting, startExport, dismiss } = useEntityExport(entity, noun);
197
+ return (
198
+ <div className="space-y-2 rounded-lg border border-border-default p-4">
199
+ <div className="flex items-center justify-between gap-3">
200
+ <div>
201
+ <p className="font-medium text-text-primary">{label}</p>
202
+ <p className="text-sm text-text-secondary">Every {noun} in this workspace, as CSV</p>
203
+ </div>
204
+ <Button
205
+ variant="outline"
206
+ size="sm"
207
+ disabled={isExporting}
208
+ aria-label={`Export ${label}`}
209
+ onClick={startExport}
210
+ >
211
+ {isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
212
+ <span className="ml-1">{isExporting ? 'Exporting…' : 'Export'}</span>
213
+ </Button>
214
+ </div>
215
+ <ExportStatusBanner state={state} onDismiss={dismiss} />
216
+ </div>
217
+ );
218
+ }
219
+
220
+ export default function ImportExportPage() {
221
+ const { basePath, navigate } = useMoveTheWheels();
222
+ const { specs, isLoading: specLoading, error: specError } = useImportSpec();
223
+ const { run, isRunning } = useEntityImport();
224
+ const [searchParams] = useSearchParams();
225
+ // List pages link here with ?entity=VEHICLE etc. to preselect what to import.
226
+ const [entityType, setEntityType] = useState<MtwImportEntityType>(() => {
227
+ const requested = searchParams.get('entity');
228
+ return isImportEntityType(requested) ? requested : 'CUSTOMER';
229
+ });
230
+ const [file, setFile] = useState<SelectedFile | null>(null);
231
+ const [allowPartial, setAllowPartial] = useState(false);
232
+ const [result, setResult] = useState<MtwImportResult | null>(null);
233
+ const [error, setError] = useState<string | null>(null);
234
+ const [lastRun, setLastRun] = useState<'validate' | 'import' | null>(null);
235
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
236
+
237
+ const entity = importEntity(entityType);
238
+ const spec = useMemo(
239
+ () => specs.find((candidate) => candidate.entityType === entityType) ?? null,
240
+ [specs, entityType]
241
+ );
242
+
243
+ const resetOutcome = () => {
244
+ setResult(null);
245
+ setError(null);
246
+ setLastRun(null);
247
+ };
248
+
249
+ const chooseEntity = (value: string) => {
250
+ if (!isImportEntityType(value)) return;
251
+ setEntityType(value);
252
+ setFile(null);
253
+ resetOutcome();
254
+ };
255
+
256
+ const onFileSelected = async (event: ChangeEvent<HTMLInputElement>) => {
257
+ const selected = event.target.files?.[0];
258
+ event.target.value = '';
259
+ resetOutcome();
260
+ if (!selected) return;
261
+ const format = detectImportFormat(selected.name, selected.type);
262
+ if (!format) {
263
+ setFile(null);
264
+ setError('Choose a .csv or .json file.');
265
+ return;
266
+ }
267
+ setFile({ name: selected.name, format, content: await selected.text() });
268
+ };
269
+
270
+ const submit = async (dryRun: boolean) => {
271
+ if (!file) return;
272
+ setError(null);
273
+ setLastRun(dryRun ? 'validate' : 'import');
274
+ try {
275
+ setResult(
276
+ await run({ entityType, format: file.format, content: file.content, dryRun, allowPartial })
277
+ );
278
+ } catch (caught) {
279
+ setResult(null);
280
+ setError(caught instanceof Error ? caught.message : 'The import could not be completed.');
281
+ }
282
+ };
283
+
284
+ const downloadTemplate = () => {
285
+ if (!spec) return;
286
+ downloadText(
287
+ `${entity.label.toLowerCase()}-import-template.csv`,
288
+ buildCsvTemplate(spec),
289
+ 'text/csv'
290
+ );
291
+ };
292
+
293
+ const canImport = file !== null && !isRunning && result?.status !== 'COMPLETED';
294
+
295
+ return (
296
+ <PageLayout
297
+ title="Import / Export"
298
+ subtitle="Bring master data in from a file, or take a full copy out"
299
+ icon={<ArrowDownUp size={24} />}
300
+ >
301
+ <PagePurpose icon={<ArrowDownUp size={18} />}>
302
+ Onboard or migrate without re-keying: import customers, vehicles, drivers, warehouses or
303
+ products from CSV or JSON. Every row is checked before anything is saved, so you can fix a
304
+ file and try again. Export any dataset for accounting, BI tools or backups.
305
+ </PagePurpose>
306
+
307
+ <input
308
+ ref={fileInputRef}
309
+ type="file"
310
+ accept=".csv,.json,text/csv,application/json"
311
+ className="hidden"
312
+ data-testid="mtw-import-file"
313
+ onChange={(event) => void onFileSelected(event)}
314
+ />
315
+
316
+ <div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
317
+ <EmphasisPanel className="space-y-5 xl:col-span-2">
318
+ <div className="flex flex-wrap items-center justify-between gap-3">
319
+ <h2 className="flex items-center gap-2 text-base font-semibold text-text-primary">
320
+ <Upload size={18} /> Import data
321
+ </h2>
322
+ <Badge variant="outline">CSV or JSON · up to {MTW_IMPORT_MAX_ROWS} rows · 2 MB</Badge>
323
+ </div>
324
+
325
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
326
+ <div className="space-y-2">
327
+ <Label htmlFor="mtw-import-entity">What are you importing?</Label>
328
+ <Select value={entityType} onValueChange={chooseEntity}>
329
+ <SelectTrigger id="mtw-import-entity" aria-label="Import type">
330
+ <SelectValue />
331
+ </SelectTrigger>
332
+ <SelectContent>
333
+ {MTW_IMPORT_ENTITIES.map((option) => (
334
+ <SelectItem key={option.entityType} value={option.entityType}>
335
+ {option.label}
336
+ </SelectItem>
337
+ ))}
338
+ </SelectContent>
339
+ </Select>
340
+ </div>
341
+ <div className="space-y-2">
342
+ <Label>File</Label>
343
+ <div className="flex flex-wrap items-center gap-2">
344
+ <Button variant="outline" onClick={() => fileInputRef.current?.click()}>
345
+ <FileUp size={16} />
346
+ <span className="ml-1">{file ? 'Choose another file' : 'Choose file'}</span>
347
+ </Button>
348
+ <Button
349
+ variant="ghost"
350
+ disabled={!spec}
351
+ onClick={downloadTemplate}
352
+ aria-label={`Download ${entity.label} CSV template`}
353
+ >
354
+ <FileSpreadsheet size={16} />
355
+ <span className="ml-1">Template</span>
356
+ </Button>
357
+ </div>
358
+ {file ? (
359
+ <p className="text-sm text-text-secondary" data-testid="mtw-import-file-name">
360
+ {file.name} ({file.format})
361
+ </p>
362
+ ) : null}
363
+ </div>
364
+ </div>
365
+
366
+ <div className="flex items-start gap-2">
367
+ <Checkbox
368
+ id="mtw-import-partial"
369
+ checked={allowPartial}
370
+ onCheckedChange={(checked) => {
371
+ setAllowPartial(checked === true);
372
+ if (result && !result.dryRun) resetOutcome();
373
+ }}
374
+ />
375
+ <Label htmlFor="mtw-import-partial" className="text-sm font-normal leading-snug">
376
+ Import the valid rows even if some rows have problems. When this is off, a file with
377
+ any problem imports nothing.
378
+ </Label>
379
+ </div>
380
+
381
+ <div className="flex flex-wrap gap-2">
382
+ <Button
383
+ variant="outline"
384
+ disabled={!file || isRunning}
385
+ onClick={() => void submit(true)}
386
+ >
387
+ {isRunning && lastRun === 'validate' ? (
388
+ <Loader2 size={16} className="animate-spin" />
389
+ ) : (
390
+ <ShieldCheck size={16} />
391
+ )}
392
+ <span className="ml-1">Validate</span>
393
+ </Button>
394
+ <Button disabled={!canImport} onClick={() => void submit(false)}>
395
+ {isRunning && lastRun === 'import' ? (
396
+ <Loader2 size={16} className="animate-spin" />
397
+ ) : (
398
+ <Upload size={16} />
399
+ )}
400
+ <span className="ml-1">Import {entity.label.toLowerCase()}</span>
401
+ </Button>
402
+ </div>
403
+
404
+ {error ? (
405
+ <div
406
+ role="alert"
407
+ data-testid="mtw-import-error"
408
+ className="flex items-start gap-2 rounded-lg border border-status-error-border bg-status-error-bg-subtle px-4 py-3 text-sm text-status-error-text"
409
+ >
410
+ <AlertCircle size={18} className="mt-0.5 shrink-0" />
411
+ <span>{error}</span>
412
+ </div>
413
+ ) : null}
414
+ {result ? (
415
+ <ImportResultPanel
416
+ result={result}
417
+ noun={entity.noun}
418
+ onOpenList={() => navigate?.(joinPath(basePath, LIST_PATHS[entityType]))}
419
+ />
420
+ ) : null}
421
+ </EmphasisPanel>
422
+
423
+ <Card>
424
+ <CardHeader>
425
+ <CardTitle className="flex items-center gap-2 text-base">
426
+ <Info size={18} /> {entity.label} columns
427
+ </CardTitle>
428
+ <CardDescription>
429
+ Use these column names as the CSV header or JSON keys. Values are not case-sensitive
430
+ for lists.
431
+ </CardDescription>
432
+ </CardHeader>
433
+ <CardContent>
434
+ {specLoading ? (
435
+ <div className="space-y-2">
436
+ <Skeleton className="h-6 w-full" />
437
+ <Skeleton className="h-6 w-full" />
438
+ <Skeleton className="h-6 w-2/3" />
439
+ </div>
440
+ ) : specError ? (
441
+ <p className="text-sm text-status-error-text">
442
+ The column guide could not be loaded. Imports still work.
443
+ </p>
444
+ ) : spec ? (
445
+ <ul className="space-y-3" data-testid="mtw-import-columns">
446
+ {spec.columns.map((column) => (
447
+ <li key={column.name} className="text-sm">
448
+ <div className="flex flex-wrap items-center gap-2">
449
+ <code className="font-mono text-text-primary">{column.name}</code>
450
+ {column.required ? <Badge variant="default">required</Badge> : null}
451
+ {spec.keyColumn === column.name ? (
452
+ <Badge variant="outline">unique</Badge>
453
+ ) : null}
454
+ </div>
455
+ <p className="text-text-secondary">{column.description}</p>
456
+ <p className="text-xs text-text-tertiary">{columnRule(column)}</p>
457
+ </li>
458
+ ))}
459
+ </ul>
460
+ ) : null}
461
+ </CardContent>
462
+ </Card>
463
+ </div>
464
+
465
+ <Card>
466
+ <CardHeader>
467
+ <CardTitle className="flex items-center gap-2 text-base">
468
+ <Download size={18} /> Export data
469
+ </CardTitle>
470
+ <CardDescription>
471
+ Exports run in the background and download as a ZIP. Large exports stay available on the
472
+ Exports page.
473
+ </CardDescription>
474
+ </CardHeader>
475
+ <CardContent className="grid grid-cols-1 gap-3 md:grid-cols-2">
476
+ {EXPORT_OPTIONS.map((option) => (
477
+ <ExportRow key={option.entity} {...option} />
478
+ ))}
479
+ </CardContent>
480
+ </Card>
481
+ </PageLayout>
482
+ );
483
+ }
@@ -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 { unacknowledgedAlerts } = useTrackingAlerts();
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
- <div className="space-y-3">
432
- {unacknowledgedAlerts.slice(0, 5).map((alert) => (
433
- <div
434
- key={alert.id}
435
- className={`p-3 rounded-lg text-sm ${
436
- alert.severity === 'critical' || alert.severity === 'high'
437
- ? 'bg-status-error-bg-subtle'
438
- : 'bg-status-warning-bg-subtle'
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">No active alerts</p>
448
+ <p className="text-sm">
449
+ {t('movethewheels.tracking.live.noAlerts', 'No active alerts')}
450
+ </p>
453
451
  </div>
454
452
  )}
455
453
  </CardContent>
@@ -3,7 +3,15 @@
3
3
  */
4
4
 
5
5
  import { useState } from 'react';
6
- import { Package, Plus, RefreshCw, Download, AlertTriangle, Thermometer } from 'lucide-react';
6
+ import {
7
+ Package,
8
+ Plus,
9
+ RefreshCw,
10
+ Download,
11
+ AlertTriangle,
12
+ Thermometer,
13
+ Upload,
14
+ } from 'lucide-react';
7
15
  import { PageLayout } from '../components/PageLayout';
8
16
  import { MetricCard, MetricGrid } from '../components/MetricCard';
9
17
  import { DataTable, type Column } from '../components/DataTable';
@@ -134,6 +142,11 @@ export default function ProductsPage() {
134
142
  }}
135
143
  actions={[
136
144
  { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
145
+ {
146
+ label: 'Import',
147
+ icon: <Upload size={16} />,
148
+ onSelect: () => goTo('import-export?entity=CARGO_ITEM'),
149
+ },
137
150
  {
138
151
  label: isExporting ? 'Exporting…' : 'Export',
139
152
  icon: <Download size={16} />,
@@ -12,6 +12,7 @@ import {
12
12
  Route as RouteIcon,
13
13
  Wrench,
14
14
  Filter,
15
+ Upload,
15
16
  } from 'lucide-react';
16
17
  import { PageLayout } from '../components/PageLayout';
17
18
  import { MetricCard, MetricGrid } from '../components/MetricCard';
@@ -163,6 +164,11 @@ export default function VehiclesPage() {
163
164
  }}
164
165
  actions={[
165
166
  { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
167
+ {
168
+ label: 'Import',
169
+ icon: <Upload size={16} />,
170
+ onSelect: () => goTo('import-export?entity=VEHICLE'),
171
+ },
166
172
  {
167
173
  label: isExporting ? 'Exporting…' : 'Export',
168
174
  icon: <Download size={16} />,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useState } from 'react';
6
6
  import { useTr } from '../hooks';
7
- import { Warehouse, Plus, RefreshCw, CircleCheck, Gauge, Filter } from 'lucide-react';
7
+ import { Warehouse, Plus, RefreshCw, CircleCheck, Gauge, Filter, Upload } from 'lucide-react';
8
8
  import { PageLayout } from '../components/PageLayout';
9
9
  import { MetricCard, MetricGrid } from '../components/MetricCard';
10
10
  import { DataTable, type Column } from '../components/DataTable';
@@ -154,6 +154,11 @@ export default function WarehousesPage() {
154
154
  onSelect: () => goTo('inventory/warehouses/pallet-preview'),
155
155
  },
156
156
  { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
157
+ {
158
+ label: 'Import',
159
+ icon: <Upload size={16} />,
160
+ onSelect: () => goTo('import-export?entity=WAREHOUSE'),
161
+ },
157
162
  ]}
158
163
  />
159
164
  }
@@ -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: 'low' | 'medium' | 'high' | 'critical';
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