@burdenoff/microfe-movethewheels 2026.1030.1 → 2026.1031.1

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.
@@ -37,6 +37,8 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue';
37
37
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
38
38
  import { formatDate } from '../utils/formatters';
39
39
  import { joinPath } from '../utils/navigation';
40
+ import { useEntityExport } from '../hooks/useEntityExport';
41
+ import { ExportStatusBanner } from '../components/ExportStatusBanner';
40
42
  import { DRIVER_STATUS_LABELS } from '../constants';
41
43
  import type { Driver, DriverStatus } from '../types';
42
44
 
@@ -52,6 +54,12 @@ const STATUS_OPTIONS: Array<{ value: DriverStatus | 'all'; label: string }> = [
52
54
 
53
55
  export default function DriversPage() {
54
56
  const { basePath, navigate } = useMoveTheWheels();
57
+ const {
58
+ state: exportState,
59
+ isExporting,
60
+ startExport,
61
+ dismiss: dismissExport,
62
+ } = useEntityExport('drivers', 'driver');
55
63
  const [page, setPage] = useState(0);
56
64
  const [pageSize, setPageSize] = useState(25);
57
65
  const [search, setSearch] = useState('');
@@ -224,9 +232,9 @@ export default function DriversPage() {
224
232
  actions={[
225
233
  { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
226
234
  {
227
- label: 'Export',
235
+ label: isExporting ? 'Exporting…' : 'Export',
228
236
  icon: <Download size={16} />,
229
- onSelect: () => goTo('import-export'),
237
+ onSelect: startExport,
230
238
  },
231
239
  ]}
232
240
  />
@@ -237,6 +245,7 @@ export default function DriversPage() {
237
245
  contact number and the vehicle they are on — open a driver to review their record before
238
246
  assigning them to an order or route.
239
247
  </PagePurpose>
248
+ <ExportStatusBanner state={exportState} onDismiss={dismissExport} />
240
249
 
241
250
  {/* Workforce at a glance — premium stat cards */}
242
251
  <div>
@@ -39,6 +39,8 @@ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
39
39
  import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
40
40
  import { formatCurrency, formatDate } from '../utils/formatters';
41
41
  import { joinPath } from '../utils/navigation';
42
+ import { useEntityExport } from '../hooks/useEntityExport';
43
+ import { ExportStatusBanner } from '../components/ExportStatusBanner';
42
44
  import type { Order, OrderStatus } from '../types';
43
45
 
44
46
  export default function OrdersListPage() {
@@ -48,6 +50,12 @@ export default function OrdersListPage() {
48
50
  return value === key ? fallback : value;
49
51
  };
50
52
  const { basePath, navigate } = useMoveTheWheels();
53
+ const {
54
+ state: exportState,
55
+ isExporting,
56
+ startExport,
57
+ dismiss: dismissExport,
58
+ } = useEntityExport('orders', 'order');
51
59
  const [statusFilter, setStatusFilter] = useState<OrderStatus | 'all'>('all');
52
60
  const [unassignedOnly, setUnassignedOnly] = useState(false);
53
61
  const [page, setPage] = useState(0);
@@ -219,9 +227,11 @@ export default function OrdersListPage() {
219
227
  onSelect: () => refetch(),
220
228
  },
221
229
  {
222
- label: t('movethewheels.orders.actions.export', 'Export'),
230
+ label: isExporting
231
+ ? t('movethewheels.orders.actions.exporting', 'Exporting…')
232
+ : t('movethewheels.orders.actions.export', 'Export'),
223
233
  icon: <Download size={16} />,
224
- onSelect: () => goTo('import-export'),
234
+ onSelect: startExport,
225
235
  },
226
236
  ]}
227
237
  />
@@ -233,6 +243,7 @@ export default function OrdersListPage() {
233
243
  'Every delivery job lives here — their status, priority and value. Use this list to find an order, filter by status, and open one to assign a driver, track it, or update fulfilment.'
234
244
  )}
235
245
  </PagePurpose>
246
+ <ExportStatusBanner state={exportState} onDismiss={dismissExport} />
236
247
 
237
248
  {/* Pipeline at a glance — premium stat cards */}
238
249
  <div>
@@ -14,10 +14,18 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue';
14
14
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
15
15
  import { formatCurrency } from '../utils/formatters';
16
16
  import { joinPath } from '../utils/navigation';
17
+ import { useEntityExport } from '../hooks/useEntityExport';
18
+ import { ExportStatusBanner } from '../components/ExportStatusBanner';
17
19
  import type { Product } from '../types';
18
20
 
19
21
  export default function ProductsPage() {
20
22
  const { basePath, navigate } = useMoveTheWheels();
23
+ const {
24
+ state: exportState,
25
+ isExporting,
26
+ startExport,
27
+ dismiss: dismissExport,
28
+ } = useEntityExport('products', 'product');
21
29
  const [page, setPage] = useState(0);
22
30
  const [pageSize, setPageSize] = useState(25);
23
31
  const [search, setSearch] = useState('');
@@ -127,9 +135,9 @@ export default function ProductsPage() {
127
135
  actions={[
128
136
  { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
129
137
  {
130
- label: 'Export',
138
+ label: isExporting ? 'Exporting…' : 'Export',
131
139
  icon: <Download size={16} />,
132
- onSelect: () => goTo('import-export'),
140
+ onSelect: startExport,
133
141
  },
134
142
  ]}
135
143
  />
@@ -140,6 +148,7 @@ export default function ProductsPage() {
140
148
  flags (fragile, hazardous, temperature-controlled) drive how it&apos;s packed, priced and
141
149
  routed — keep them accurate so shipments are quoted and handled correctly.
142
150
  </PagePurpose>
151
+ <ExportStatusBanner state={exportState} onDismiss={dismissExport} />
143
152
 
144
153
  {/* Catalog at a glance — premium stat cards */}
145
154
  <div>
@@ -33,6 +33,8 @@ import { useVehicles, useFleetStats } from '../hooks/useFleet';
33
33
  import { useDebouncedValue } from '../hooks/useDebouncedValue';
34
34
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
35
35
  import { joinPath } from '../utils/navigation';
36
+ import { useEntityExport } from '../hooks/useEntityExport';
37
+ import { ExportStatusBanner } from '../components/ExportStatusBanner';
36
38
  import { VEHICLE_STATUS_LABELS } from '../constants';
37
39
  import type { Vehicle, VehicleStatus } from '../types';
38
40
 
@@ -45,6 +47,12 @@ const STATUS_OPTIONS: Array<{ value: VehicleStatus | 'all'; label: string }> = [
45
47
 
46
48
  export default function VehiclesPage() {
47
49
  const { basePath, navigate } = useMoveTheWheels();
50
+ const {
51
+ state: exportState,
52
+ isExporting,
53
+ startExport,
54
+ dismiss: dismissExport,
55
+ } = useEntityExport('vehicles', 'vehicle');
48
56
  const [page, setPage] = useState(0);
49
57
  const [pageSize, setPageSize] = useState(25);
50
58
  const [search, setSearch] = useState('');
@@ -156,9 +164,9 @@ export default function VehiclesPage() {
156
164
  actions={[
157
165
  { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
158
166
  {
159
- label: 'Export',
167
+ label: isExporting ? 'Exporting…' : 'Export',
160
168
  icon: <Download size={16} />,
161
- onSelect: () => goTo('import-export'),
169
+ onSelect: startExport,
162
170
  },
163
171
  ]}
164
172
  />
@@ -169,6 +177,7 @@ export default function VehiclesPage() {
169
177
  maintenance status here so you always know which vehicles are road-ready before dispatching
170
178
  an order.
171
179
  </PagePurpose>
180
+ <ExportStatusBanner state={exportState} onDismiss={dismissExport} />
172
181
 
173
182
  {/* Fleet at a glance — premium stat cards */}
174
183
  <div>
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Entity export — starts a MoveTheWheels export job and waits for its archive.
3
+ *
4
+ * `exportMtwEntities` hands the request to wspace-export-svc, which builds a
5
+ * ZIP of the workspace's records in the background. The job is read back with
6
+ * `getExportJob` until it reports COMPLETED (with a presigned `downloadUrl`),
7
+ * FAILED/CANCELLED/EXPIRED, or the wait budget runs out. A job that is still
8
+ * running when the budget is spent keeps going server side and stays
9
+ * downloadable from the shared Exports page.
10
+ *
11
+ * Kept free of React so the polling contract can be unit-tested.
12
+ */
13
+
14
+ /** List pages that offer an export, mapped to wspace-export-svc entity types. */
15
+ export const MTW_EXPORT_ENTITY_TYPES = {
16
+ customers: 'movethewheels_customers',
17
+ drivers: 'movethewheels_drivers',
18
+ orders: 'movethewheels_delivery_jobs',
19
+ products: 'movethewheels_cargo_items',
20
+ vehicles: 'movethewheels_vehicles',
21
+ } as const;
22
+
23
+ export type MtwExportEntity = keyof typeof MTW_EXPORT_ENTITY_TYPES;
24
+
25
+ export const START_MTW_EXPORT_MUTATION = `
26
+ mutation StartMovethewheelsEntityExport($input: ExportMtwEntitiesInput!) {
27
+ exportMtwEntities(input: $input) {
28
+ id
29
+ status
30
+ downloadUrl
31
+ }
32
+ }
33
+ `;
34
+
35
+ export const GET_MTW_EXPORT_JOB_QUERY = `
36
+ query GetMovethewheelsExportJob($id: ID!) {
37
+ getExportJob(id: $id) {
38
+ id
39
+ status
40
+ progress
41
+ downloadUrl
42
+ errorMessage
43
+ }
44
+ }
45
+ `;
46
+
47
+ export interface ExportJobSnapshot {
48
+ id: string;
49
+ status: string;
50
+ progress?: number | null;
51
+ downloadUrl?: string | null;
52
+ errorMessage?: string | null;
53
+ }
54
+
55
+ export type ExportJobOutcome =
56
+ | { kind: 'ready'; jobId: string; downloadUrl: string }
57
+ | { kind: 'failed'; jobId: string; message: string }
58
+ | { kind: 'pending'; jobId: string };
59
+
60
+ const TERMINAL_FAILURES = new Set(['FAILED', 'CANCELLED', 'EXPIRED']);
61
+
62
+ /** Classify one job snapshot; `null` means keep waiting. */
63
+ export function classifyExportJob(job: ExportJobSnapshot): ExportJobOutcome | null {
64
+ if (job.status === 'COMPLETED') {
65
+ if (job.downloadUrl) return { kind: 'ready', jobId: job.id, downloadUrl: job.downloadUrl };
66
+ return {
67
+ kind: 'failed',
68
+ jobId: job.id,
69
+ message: 'The export finished without a download link.',
70
+ };
71
+ }
72
+ if (TERMINAL_FAILURES.has(job.status)) {
73
+ return {
74
+ kind: 'failed',
75
+ jobId: job.id,
76
+ message: job.errorMessage?.trim() || `The export was ${job.status.toLowerCase()}.`,
77
+ };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ export interface WaitForExportOptions {
83
+ /** Delay between reads of the job. */
84
+ intervalMs?: number;
85
+ /** Total time to wait before reporting the job as still pending. */
86
+ timeoutMs?: number;
87
+ /** Injectable for tests. */
88
+ sleep?: (ms: number) => Promise<void>;
89
+ /** Injectable for tests. */
90
+ now?: () => number;
91
+ /** Stops waiting (e.g. the page unmounted); resolves as pending. */
92
+ signal?: AbortSignal;
93
+ }
94
+
95
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
96
+
97
+ /**
98
+ * Read the job until it settles or the wait budget is spent. `initial` is the
99
+ * snapshot returned by the start mutation, so an already-finished job resolves
100
+ * without another round trip.
101
+ */
102
+ export async function waitForExportJob(
103
+ initial: ExportJobSnapshot,
104
+ fetchJob: (id: string) => Promise<ExportJobSnapshot | null>,
105
+ {
106
+ intervalMs = 2000,
107
+ timeoutMs = 90000,
108
+ sleep = defaultSleep,
109
+ now = Date.now,
110
+ signal,
111
+ }: WaitForExportOptions = {}
112
+ ): Promise<ExportJobOutcome> {
113
+ const settled = classifyExportJob(initial);
114
+ if (settled) return settled;
115
+
116
+ const deadline = now() + timeoutMs;
117
+ while (now() < deadline) {
118
+ if (signal?.aborted) break;
119
+ await sleep(intervalMs);
120
+ if (signal?.aborted) break;
121
+ const job = await fetchJob(initial.id);
122
+ if (!job) {
123
+ return { kind: 'failed', jobId: initial.id, message: 'The export job could not be found.' };
124
+ }
125
+ const outcome = classifyExportJob(job);
126
+ if (outcome) return outcome;
127
+ }
128
+ return { kind: 'pending', jobId: initial.id };
129
+ }
130
+
131
+ /** Start the archive download in the current tab without navigating away. */
132
+ export function triggerUrlDownload(url: string): void {
133
+ if (typeof document === 'undefined') return;
134
+ const anchor = document.createElement('a');
135
+ anchor.href = url;
136
+ anchor.rel = 'noopener';
137
+ document.body.appendChild(anchor);
138
+ anchor.click();
139
+ anchor.remove();
140
+ }