@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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Form for creating or editing orders.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useEffect, useState, type ChangeEvent } from 'react';
|
|
7
|
+
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
|
8
8
|
import { useParams, useSearchParams } from 'react-router';
|
|
9
9
|
import {
|
|
10
10
|
Package,
|
|
@@ -43,6 +43,8 @@ import { useCustomer, useCustomers } from '../hooks/useCustomers';
|
|
|
43
43
|
import { useRoute, useRoutes } from '../hooks/useRoutes';
|
|
44
44
|
import { useProducts } from '../hooks/useInventory';
|
|
45
45
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
46
|
+
import { useModuleSettings } from '../providers/ModuleSettingsProvider';
|
|
47
|
+
import { deliveryWindowValue, orderFormPriority } from '../utils/moduleSettings';
|
|
46
48
|
import { joinPath, buildCustomerCreatePath } from '../utils/navigation';
|
|
47
49
|
import { CustomerPicker } from '../components/CustomerPicker';
|
|
48
50
|
import { EntityCombobox } from '../components/EntityCombobox';
|
|
@@ -118,11 +120,16 @@ export default function CreateOrderPage() {
|
|
|
118
120
|
});
|
|
119
121
|
|
|
120
122
|
const isEditing = !!orderId && !!order;
|
|
123
|
+
// Workspace defaults for new orders (BOFF-10488).
|
|
124
|
+
const { settings: moduleSettings } = useModuleSettings();
|
|
125
|
+
const defaultPriority = orderFormPriority(moduleSettings.defaultOrderPriority);
|
|
126
|
+
const deliveryWindowHours = moduleSettings.defaultDeliveryWindowHours;
|
|
127
|
+
const priorityTouched = useRef(false);
|
|
121
128
|
|
|
122
129
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
|
123
130
|
const [formData, setFormData] = useState({
|
|
124
131
|
customerId: prefilledCustomerId || order?.customerId || '',
|
|
125
|
-
priority: order?.priority ||
|
|
132
|
+
priority: order?.priority || defaultPriority,
|
|
126
133
|
routeId: order?.routeId || '',
|
|
127
134
|
pickupAddress: {
|
|
128
135
|
street: order?.pickupAddress.street || '',
|
|
@@ -308,9 +315,19 @@ export default function CreateOrderPage() {
|
|
|
308
315
|
};
|
|
309
316
|
|
|
310
317
|
const updateField = (field: string, value: unknown) => {
|
|
318
|
+
if (field === 'priority') priorityTouched.current = true;
|
|
311
319
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
312
320
|
};
|
|
313
321
|
|
|
322
|
+
// The workspace defaults usually arrive after the first render; a new order
|
|
323
|
+
// picks up the default priority unless the user already chose one.
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (orderId || priorityTouched.current) return;
|
|
326
|
+
setFormData((prev) =>
|
|
327
|
+
prev.priority === defaultPriority ? prev : { ...prev, priority: defaultPriority }
|
|
328
|
+
);
|
|
329
|
+
}, [orderId, defaultPriority]);
|
|
330
|
+
|
|
314
331
|
const updateAddress = (
|
|
315
332
|
type: 'pickupAddress' | 'deliveryAddress',
|
|
316
333
|
field: string,
|
|
@@ -551,7 +568,9 @@ export default function CreateOrderPage() {
|
|
|
551
568
|
<SelectItem value="urgent">Urgent</SelectItem>
|
|
552
569
|
</SelectContent>
|
|
553
570
|
</Select>
|
|
554
|
-
<p className="text-xs text-text-secondary">
|
|
571
|
+
<p className="text-xs text-text-secondary">
|
|
572
|
+
New orders start with this workspace's default priority.
|
|
573
|
+
</p>
|
|
555
574
|
</div>
|
|
556
575
|
</div>
|
|
557
576
|
</EmphasisPanel>
|
|
@@ -879,9 +898,22 @@ export default function CreateOrderPage() {
|
|
|
879
898
|
updateField('scheduledDelivery', e.target.value)
|
|
880
899
|
}
|
|
881
900
|
/>
|
|
882
|
-
<
|
|
883
|
-
|
|
884
|
-
|
|
901
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
902
|
+
<p className="text-xs text-text-secondary">
|
|
903
|
+
Leave blank to dispatch as soon as a driver is available.
|
|
904
|
+
</p>
|
|
905
|
+
<Button
|
|
906
|
+
type="button"
|
|
907
|
+
variant="link"
|
|
908
|
+
size="sm"
|
|
909
|
+
className="h-auto p-0 text-xs"
|
|
910
|
+
onClick={() =>
|
|
911
|
+
updateField('scheduledDelivery', deliveryWindowValue(deliveryWindowHours))
|
|
912
|
+
}
|
|
913
|
+
>
|
|
914
|
+
Use the default window ({deliveryWindowHours} h from now)
|
|
915
|
+
</Button>
|
|
916
|
+
</div>
|
|
885
917
|
</div>
|
|
886
918
|
<div className="space-y-2 lg:col-span-1">
|
|
887
919
|
<Label htmlFor="specialInstructions">Special instructions</Label>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useState } from 'react';
|
|
6
|
-
import { Users, Plus, RefreshCw, Download, Star, Building2 } from 'lucide-react';
|
|
6
|
+
import { Users, Plus, RefreshCw, Download, Star, Building2, Upload } from 'lucide-react';
|
|
7
7
|
import { PageLayout } from '../components/PageLayout';
|
|
8
8
|
import { MetricCard, MetricGrid } from '../components/MetricCard';
|
|
9
9
|
import { DataTable, type Column } from '../components/DataTable';
|
|
@@ -111,6 +111,11 @@ export default function CustomersPage() {
|
|
|
111
111
|
}}
|
|
112
112
|
actions={[
|
|
113
113
|
{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
|
|
114
|
+
{
|
|
115
|
+
label: 'Import',
|
|
116
|
+
icon: <Upload size={16} />,
|
|
117
|
+
onSelect: () => goTo('import-export?entity=CUSTOMER'),
|
|
118
|
+
},
|
|
114
119
|
{
|
|
115
120
|
label: isExporting ? 'Exporting…' : 'Export',
|
|
116
121
|
icon: <Download size={16} />,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
UserMinus,
|
|
15
15
|
Filter,
|
|
16
16
|
Truck,
|
|
17
|
+
Upload,
|
|
17
18
|
} from 'lucide-react';
|
|
18
19
|
import { PageLayout } from '../components/PageLayout';
|
|
19
20
|
import { MetricCard, MetricGrid } from '../components/MetricCard';
|
|
@@ -231,6 +232,11 @@ export default function DriversPage() {
|
|
|
231
232
|
}}
|
|
232
233
|
actions={[
|
|
233
234
|
{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
|
|
235
|
+
{
|
|
236
|
+
label: 'Import',
|
|
237
|
+
icon: <Upload size={16} />,
|
|
238
|
+
onSelect: () => goTo('import-export?entity=DRIVER'),
|
|
239
|
+
},
|
|
234
240
|
{
|
|
235
241
|
label: isExporting ? 'Exporting…' : 'Export',
|
|
236
242
|
icon: <Download size={16} />,
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useState } from 'react';
|
|
6
|
-
import {
|
|
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';
|
|
@@ -12,7 +20,7 @@ import { CTAOverflowMenu, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
|
12
20
|
import { useProducts, useProductStats } from '../hooks/useInventory';
|
|
13
21
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
14
22
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
15
|
-
import { formatCurrency } from '../utils/formatters';
|
|
23
|
+
import { formatCurrency, formatWeight } from '../utils/formatters';
|
|
16
24
|
import { joinPath } from '../utils/navigation';
|
|
17
25
|
import { useEntityExport } from '../hooks/useEntityExport';
|
|
18
26
|
import { ExportStatusBanner } from '../components/ExportStatusBanner';
|
|
@@ -82,7 +90,7 @@ export default function ProductsPage() {
|
|
|
82
90
|
header: 'Weight',
|
|
83
91
|
accessorKey: 'weight',
|
|
84
92
|
sortable: true,
|
|
85
|
-
cell: (row) => (row.weight !== undefined ?
|
|
93
|
+
cell: (row) => (row.weight !== undefined ? formatWeight(row.weight) : 'Not provided'),
|
|
86
94
|
},
|
|
87
95
|
{
|
|
88
96
|
id: 'value',
|
|
@@ -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} />,
|