@burdenoff/microfe-movethewheels 2026.1028.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.
Files changed (40) hide show
  1. package/dist/MoveTheWheelsRoot.js +130 -122
  2. package/dist/MoveTheWheelsRoutes.js +55 -51
  3. package/dist/components/ExportStatusBanner.js +53 -0
  4. package/dist/components/marketplace/MarketplaceDialogs.js +524 -0
  5. package/dist/generated/wspace-types.d.ts +30737 -8505
  6. package/dist/hooks/useEntityExport.js +85 -0
  7. package/dist/hooks/useMarketplace.js +265 -0
  8. package/dist/movethewheels/components/ExportStatusBanner.d.ts +7 -0
  9. package/dist/movethewheels/components/marketplace/MarketplaceDialogs.d.ts +18 -0
  10. package/dist/movethewheels/hooks/useEntityExport.d.ts +32 -0
  11. package/dist/movethewheels/hooks/useMarketplace.d.ts +121 -0
  12. package/dist/movethewheels/pages/MarketplacePage.d.ts +1 -0
  13. package/dist/movethewheels/utils/entityExport.d.ts +64 -0
  14. package/dist/node_modules/lucide-react/dist/esm/icons/badge-check.js +11 -0
  15. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +8 -0
  16. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +8 -0
  17. package/dist/node_modules/lucide-react/dist/esm/icons/pencil.js +11 -0
  18. package/dist/node_modules/lucide-react/dist/esm/icons/store.js +26 -0
  19. package/dist/pages/CustomersPage.js +64 -58
  20. package/dist/pages/DriversPage.js +113 -107
  21. package/dist/pages/MarketplacePage.js +503 -0
  22. package/dist/pages/OrdersListPage.js +85 -79
  23. package/dist/pages/ProductsPage.js +63 -57
  24. package/dist/pages/VehiclesPage.js +91 -85
  25. package/dist/utils/entityExport.js +54 -0
  26. package/package.json +3 -2
  27. package/src/generated/wspace-types.ts +34017 -8803
  28. package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
  29. package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
  30. package/src/movethewheels/components/ExportStatusBanner.tsx +78 -0
  31. package/src/movethewheels/components/marketplace/MarketplaceDialogs.tsx +633 -0
  32. package/src/movethewheels/hooks/useEntityExport.ts +130 -0
  33. package/src/movethewheels/hooks/useMarketplace.ts +526 -0
  34. package/src/movethewheels/pages/CustomersPage.tsx +11 -2
  35. package/src/movethewheels/pages/DriversPage.tsx +11 -2
  36. package/src/movethewheels/pages/MarketplacePage.tsx +571 -0
  37. package/src/movethewheels/pages/OrdersListPage.tsx +13 -2
  38. package/src/movethewheels/pages/ProductsPage.tsx +11 -2
  39. package/src/movethewheels/pages/VehiclesPage.tsx +11 -2
  40. package/src/movethewheels/utils/entityExport.ts +140 -0
@@ -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>
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Marketplace Page (BOFF-3988, MOV-UC-13)
3
+ *
4
+ * The workspace's directory of vetted service providers — carriers, 3PLs,
5
+ * warehouses and specialists — with the offerings each one exposes. Fill a
6
+ * capacity gap by finding a provider here and making it a shipment's carrier,
7
+ * instead of buying a truck or leasing more space.
8
+ */
9
+ import { useState } from 'react';
10
+ import {
11
+ BadgeCheck,
12
+ ChevronDown,
13
+ ChevronUp,
14
+ Mail,
15
+ MapPin,
16
+ Package,
17
+ Pencil,
18
+ Phone,
19
+ Plus,
20
+ RefreshCw,
21
+ Star,
22
+ Store,
23
+ Trash2,
24
+ Truck,
25
+ } from 'lucide-react';
26
+ import { CTAOverflowMenu, PagePurpose } from '@burdenoff/fe-libs/ui';
27
+ import { PageLayout } from '../components/PageLayout';
28
+ import { MetricCard, MetricGrid } from '../components/MetricCard';
29
+ import { Notice, useNotice } from '../components/Notice';
30
+ import { FormError } from '../components/FormError';
31
+ import {
32
+ Badge,
33
+ Button,
34
+ Card,
35
+ CardContent,
36
+ Input,
37
+ Select,
38
+ SelectContent,
39
+ SelectItem,
40
+ SelectTrigger,
41
+ SelectValue,
42
+ Skeleton,
43
+ } from '../components/ui';
44
+ import {
45
+ AssignShipmentDialog,
46
+ OfferingDialog,
47
+ ProviderDialog,
48
+ } from '../components/marketplace/MarketplaceDialogs';
49
+ import { useDebouncedValue } from '../hooks/useDebouncedValue';
50
+ import {
51
+ PROVIDER_TYPES,
52
+ humanizeEnum,
53
+ useMarketplaceActions,
54
+ useMarketplaceStats,
55
+ useProviderOfferings,
56
+ useServiceProviders,
57
+ useShipmentOptions,
58
+ type ProviderType,
59
+ type ServiceOffering,
60
+ type ServiceProvider,
61
+ } from '../hooks/useMarketplace';
62
+ import { formatCurrency } from '../utils/formatters';
63
+
64
+ const PAGE_SIZE = 12;
65
+ const ALL = 'ALL';
66
+
67
+ function availabilityTone(availability: string): 'default' | 'secondary' | 'outline' {
68
+ if (availability === 'AVAILABLE') return 'default';
69
+ if (availability === 'LIMITED') return 'secondary';
70
+ return 'outline';
71
+ }
72
+
73
+ function OfferingsPanel({
74
+ provider,
75
+ refreshKey,
76
+ onAdd,
77
+ onEdit,
78
+ onDelete,
79
+ }: {
80
+ provider: ServiceProvider;
81
+ refreshKey: number;
82
+ onAdd: () => void;
83
+ onEdit: (offering: ServiceOffering) => void;
84
+ onDelete: (offering: ServiceOffering) => void;
85
+ }) {
86
+ const { offerings, isLoading, error } = useProviderOfferings(provider.id, refreshKey);
87
+ const { total: carried } = useShipmentOptions({ carrierId: provider.id, limit: 1 });
88
+
89
+ return (
90
+ <div className="mt-4 space-y-3 border-t border-border-subtle pt-4">
91
+ <div className="flex flex-wrap items-center justify-between gap-2">
92
+ <p className="text-sm font-medium">Offerings</p>
93
+ <div className="flex items-center gap-3">
94
+ <span className="text-xs text-text-secondary">
95
+ Carrier on {carried} shipment{carried === 1 ? '' : 's'}
96
+ </span>
97
+ <Button size="sm" variant="outline" onClick={onAdd}>
98
+ <Plus size={14} className="mr-1" />
99
+ Add offering
100
+ </Button>
101
+ </div>
102
+ </div>
103
+ <FormError message={error?.message} />
104
+ {isLoading ? (
105
+ <Skeleton className="h-12 w-full" />
106
+ ) : offerings.length === 0 ? (
107
+ <p className="text-sm text-text-secondary">
108
+ No offerings yet. Add the lanes, services and indicative prices this provider offers.
109
+ </p>
110
+ ) : (
111
+ <ul className="space-y-2">
112
+ {offerings.map((offering) => (
113
+ <li
114
+ key={offering.id}
115
+ className="flex flex-wrap items-start justify-between gap-3 rounded-lg bg-bg-sunken px-3 py-2"
116
+ >
117
+ <div className="min-w-0">
118
+ <p className="text-sm font-medium">
119
+ {offering.name}
120
+ {!offering.isActive ? (
121
+ <span className="ml-2 text-xs text-text-tertiary">(inactive)</span>
122
+ ) : null}
123
+ </p>
124
+ <p className="text-xs text-text-secondary">
125
+ {humanizeEnum(offering.category)}
126
+ {offering.transitDays != null ? ` · ${offering.transitDays} day transit` : ''}
127
+ </p>
128
+ {offering.description ? (
129
+ <p className="mt-1 text-xs text-text-secondary">{offering.description}</p>
130
+ ) : null}
131
+ </div>
132
+ <div className="flex items-center gap-2">
133
+ <span className="text-sm font-medium">
134
+ {offering.price != null
135
+ ? `${formatCurrency(offering.price, offering.currency)}${
136
+ offering.pricingUnit ? ` / ${offering.pricingUnit}` : ''
137
+ }`
138
+ : 'Price on request'}
139
+ </span>
140
+ <Badge variant={availabilityTone(offering.availability)}>
141
+ {humanizeEnum(offering.availability)}
142
+ </Badge>
143
+ <Button
144
+ size="icon"
145
+ variant="ghost"
146
+ aria-label={`Edit ${offering.name}`}
147
+ onClick={() => onEdit(offering)}
148
+ >
149
+ <Pencil size={14} />
150
+ </Button>
151
+ <Button
152
+ size="icon"
153
+ variant="ghost"
154
+ aria-label={`Remove ${offering.name}`}
155
+ onClick={() => onDelete(offering)}
156
+ >
157
+ <Trash2 size={14} />
158
+ </Button>
159
+ </div>
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ )}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ function ProviderCard({
169
+ provider,
170
+ expanded,
171
+ refreshKey,
172
+ onToggle,
173
+ onEdit,
174
+ onDelete,
175
+ onAssign,
176
+ onAddOffering,
177
+ onEditOffering,
178
+ onDeleteOffering,
179
+ }: {
180
+ provider: ServiceProvider;
181
+ expanded: boolean;
182
+ refreshKey: number;
183
+ onToggle: () => void;
184
+ onEdit: () => void;
185
+ onDelete: () => void;
186
+ onAssign: () => void;
187
+ onAddOffering: () => void;
188
+ onEditOffering: (offering: ServiceOffering) => void;
189
+ onDeleteOffering: (offering: ServiceOffering) => void;
190
+ }) {
191
+ return (
192
+ <Card data-testid="mtw-provider-card">
193
+ <CardContent className="p-5">
194
+ <div className="flex flex-wrap items-start justify-between gap-3">
195
+ <div className="min-w-0 space-y-1">
196
+ <div className="flex flex-wrap items-center gap-2">
197
+ <h3 className="text-base font-semibold">{provider.name}</h3>
198
+ {provider.isVerified ? (
199
+ <Badge variant="default" className="gap-1">
200
+ <BadgeCheck size={12} />
201
+ Verified
202
+ </Badge>
203
+ ) : (
204
+ <Badge variant="outline">Unverified</Badge>
205
+ )}
206
+ {!provider.isActive ? <Badge variant="secondary">Inactive</Badge> : null}
207
+ </div>
208
+ <p className="text-sm text-text-secondary">
209
+ {humanizeEnum(provider.type)}
210
+ {provider.rating != null ? (
211
+ <span className="ml-2 inline-flex items-center gap-1">
212
+ <Star size={13} className="fill-current text-status-warning-text" />
213
+ {provider.rating.toFixed(1)}
214
+ </span>
215
+ ) : null}
216
+ </p>
217
+ {provider.description ? (
218
+ <p className="text-sm text-text-secondary">{provider.description}</p>
219
+ ) : null}
220
+ </div>
221
+ <div className="flex flex-wrap items-center gap-2">
222
+ <Button size="sm" onClick={onAssign}>
223
+ <Truck size={14} className="mr-1" />
224
+ Assign to shipment
225
+ </Button>
226
+ <Button
227
+ size="icon"
228
+ variant="ghost"
229
+ aria-label={`Edit ${provider.name}`}
230
+ onClick={onEdit}
231
+ >
232
+ <Pencil size={16} />
233
+ </Button>
234
+ <Button
235
+ size="icon"
236
+ variant="ghost"
237
+ aria-label={`Remove ${provider.name}`}
238
+ onClick={onDelete}
239
+ >
240
+ <Trash2 size={16} />
241
+ </Button>
242
+ </div>
243
+ </div>
244
+
245
+ <div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-text-secondary">
246
+ {provider.serviceAreas.length > 0 ? (
247
+ <span className="inline-flex items-center gap-1">
248
+ <MapPin size={12} />
249
+ {provider.serviceAreas.join(', ')}
250
+ </span>
251
+ ) : null}
252
+ {provider.contact.email ? (
253
+ <span className="inline-flex items-center gap-1">
254
+ <Mail size={12} />
255
+ {provider.contact.email}
256
+ </span>
257
+ ) : null}
258
+ {provider.contact.phone ? (
259
+ <span className="inline-flex items-center gap-1">
260
+ <Phone size={12} />
261
+ {provider.contact.phone}
262
+ </span>
263
+ ) : null}
264
+ </div>
265
+ {provider.services.length > 0 ? (
266
+ <div className="mt-3 flex flex-wrap gap-1.5">
267
+ {provider.services.map((service) => (
268
+ <Badge key={service} variant="secondary">
269
+ {service}
270
+ </Badge>
271
+ ))}
272
+ </div>
273
+ ) : null}
274
+
275
+ <button
276
+ type="button"
277
+ className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-action-primary-text"
278
+ onClick={onToggle}
279
+ aria-expanded={expanded}
280
+ >
281
+ {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
282
+ {provider.offeringsCount} offering{provider.offeringsCount === 1 ? '' : 's'}
283
+ </button>
284
+ {expanded ? (
285
+ <OfferingsPanel
286
+ provider={provider}
287
+ refreshKey={refreshKey}
288
+ onAdd={onAddOffering}
289
+ onEdit={onEditOffering}
290
+ onDelete={onDeleteOffering}
291
+ />
292
+ ) : null}
293
+ </CardContent>
294
+ </Card>
295
+ );
296
+ }
297
+
298
+ export default function MarketplacePage() {
299
+ const actions = useMarketplaceActions();
300
+ const { message: notice, notify } = useNotice();
301
+ const [actionError, setActionError] = useState<string | null>(null);
302
+ const [search, setSearch] = useState('');
303
+ const debouncedSearch = useDebouncedValue(search);
304
+ const [type, setType] = useState<ProviderType | typeof ALL>(ALL);
305
+ const [verifiedOnly, setVerifiedOnly] = useState(false);
306
+ const [page, setPage] = useState(0);
307
+ const [refreshKey, setRefreshKey] = useState(0);
308
+ const [expandedId, setExpandedId] = useState<string | null>(null);
309
+ const [providerDialog, setProviderDialog] = useState<{
310
+ open: boolean;
311
+ provider: ServiceProvider | null;
312
+ }>({ open: false, provider: null });
313
+ const [offeringDialog, setOfferingDialog] = useState<{
314
+ open: boolean;
315
+ provider: ServiceProvider | null;
316
+ offering: ServiceOffering | null;
317
+ }>({ open: false, provider: null, offering: null });
318
+ const [assignDialog, setAssignDialog] = useState<{
319
+ open: boolean;
320
+ provider: ServiceProvider | null;
321
+ }>({
322
+ open: false,
323
+ provider: null,
324
+ });
325
+
326
+ const { providers, total, isLoading, error, refetch } = useServiceProviders({
327
+ filters: {
328
+ search: debouncedSearch,
329
+ type: type === ALL ? undefined : type,
330
+ isVerified: verifiedOnly ? true : undefined,
331
+ },
332
+ limit: PAGE_SIZE,
333
+ offset: page * PAGE_SIZE,
334
+ });
335
+ const stats = useMarketplaceStats(refreshKey);
336
+ const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
337
+
338
+ const refresh = () => {
339
+ setRefreshKey((key) => key + 1);
340
+ void refetch();
341
+ };
342
+ const done = (message: string) => {
343
+ setActionError(null);
344
+ notify(message);
345
+ refresh();
346
+ };
347
+
348
+ const removeProvider = async (provider: ServiceProvider) => {
349
+ if (
350
+ !window.confirm(
351
+ `Remove ${provider.name} and its offerings from the marketplace? Shipments it already carries keep it as their carrier.`
352
+ )
353
+ )
354
+ return;
355
+ try {
356
+ await actions.deleteProvider(provider.id);
357
+ done(`Removed ${provider.name}.`);
358
+ } catch (removeError) {
359
+ setActionError(
360
+ removeError instanceof Error ? removeError.message : 'Could not remove the provider.'
361
+ );
362
+ }
363
+ };
364
+
365
+ const removeOffering = async (offering: ServiceOffering) => {
366
+ if (!window.confirm(`Remove the offering "${offering.name}"?`)) return;
367
+ try {
368
+ await actions.deleteOffering(offering.id);
369
+ done(`Removed ${offering.name}.`);
370
+ } catch (removeError) {
371
+ setActionError(
372
+ removeError instanceof Error ? removeError.message : 'Could not remove the offering.'
373
+ );
374
+ }
375
+ };
376
+
377
+ const hasFilters = Boolean(debouncedSearch.trim()) || type !== ALL || verifiedOnly;
378
+
379
+ return (
380
+ <PageLayout
381
+ title="Marketplace"
382
+ subtitle={stats ? `${stats.providers} service providers` : 'Service providers'}
383
+ icon={<Store size={24} />}
384
+ headerActions={
385
+ <CTAOverflowMenu
386
+ primary={{
387
+ label: 'Add provider',
388
+ icon: <Plus size={16} />,
389
+ onSelect: () => setProviderDialog({ open: true, provider: null }),
390
+ }}
391
+ actions={[{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: refresh }]}
392
+ />
393
+ }
394
+ >
395
+ <PagePurpose icon={<Store size={18} />}>
396
+ Your directory of vetted carriers, 3PLs, warehouses and specialists. When you are short of
397
+ capacity, find a provider here and make it a shipment&apos;s carrier instead of buying
398
+ assets.
399
+ </PagePurpose>
400
+
401
+ <Notice message={notice} />
402
+ <FormError message={actionError} />
403
+
404
+ <MetricGrid columns={4}>
405
+ <MetricCard
406
+ label="Providers"
407
+ value={stats?.providers ?? '—'}
408
+ icon={<Store size={20} />}
409
+ accent="brand"
410
+ />
411
+ <MetricCard
412
+ label="Verified"
413
+ value={stats?.verifiedProviders ?? '—'}
414
+ icon={<BadgeCheck size={20} />}
415
+ accent="success"
416
+ />
417
+ <MetricCard
418
+ label="Offerings"
419
+ value={stats?.offerings ?? '—'}
420
+ icon={<Package size={20} />}
421
+ accent="blue"
422
+ />
423
+ <MetricCard
424
+ label="Avg. rating"
425
+ value={stats?.avgRating != null ? stats.avgRating.toFixed(1) : '—'}
426
+ icon={<Star size={20} />}
427
+ accent="warning"
428
+ />
429
+ </MetricGrid>
430
+
431
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
432
+ <Input
433
+ aria-label="Search providers"
434
+ placeholder="Search by name, service or area…"
435
+ value={search}
436
+ onChange={(event) => {
437
+ setSearch(event.target.value);
438
+ setPage(0);
439
+ }}
440
+ className="sm:max-w-sm"
441
+ />
442
+ <Select
443
+ value={type}
444
+ onValueChange={(value) => {
445
+ setType(value as ProviderType | typeof ALL);
446
+ setPage(0);
447
+ }}
448
+ >
449
+ <SelectTrigger aria-label="Provider type" className="sm:w-56">
450
+ <SelectValue />
451
+ </SelectTrigger>
452
+ <SelectContent>
453
+ <SelectItem value={ALL}>All provider types</SelectItem>
454
+ {PROVIDER_TYPES.map((option) => (
455
+ <SelectItem key={option} value={option}>
456
+ {humanizeEnum(option)}
457
+ </SelectItem>
458
+ ))}
459
+ </SelectContent>
460
+ </Select>
461
+ <Button
462
+ variant={verifiedOnly ? 'default' : 'outline'}
463
+ onClick={() => {
464
+ setVerifiedOnly((value) => !value);
465
+ setPage(0);
466
+ }}
467
+ aria-pressed={verifiedOnly}
468
+ >
469
+ <BadgeCheck size={16} className="mr-1.5" />
470
+ Verified only
471
+ </Button>
472
+ </div>
473
+
474
+ <FormError message={error?.message} />
475
+
476
+ {isLoading && providers.length === 0 ? (
477
+ <div className="grid gap-4">
478
+ <Skeleton className="h-32 w-full" />
479
+ <Skeleton className="h-32 w-full" />
480
+ </div>
481
+ ) : providers.length === 0 && !error ? (
482
+ <Card>
483
+ <CardContent className="flex flex-col items-center gap-3 p-10 text-center">
484
+ <Store size={32} className="text-text-tertiary" />
485
+ <p className="font-medium">
486
+ {hasFilters ? 'No providers match these filters' : 'No providers yet'}
487
+ </p>
488
+ <p className="max-w-md text-sm text-text-secondary">
489
+ {hasFilters
490
+ ? 'Try a different search or clear the filters.'
491
+ : 'Add the carriers, 3PLs and warehouses you work with so your team can find capacity in one place.'}
492
+ </p>
493
+ {!hasFilters ? (
494
+ <Button onClick={() => setProviderDialog({ open: true, provider: null })}>
495
+ <Plus size={16} className="mr-1.5" />
496
+ Add provider
497
+ </Button>
498
+ ) : null}
499
+ </CardContent>
500
+ </Card>
501
+ ) : (
502
+ <div className="grid gap-4">
503
+ {providers.map((provider) => (
504
+ <ProviderCard
505
+ key={provider.id}
506
+ provider={provider}
507
+ expanded={expandedId === provider.id}
508
+ refreshKey={refreshKey}
509
+ onToggle={() => setExpandedId((id) => (id === provider.id ? null : provider.id))}
510
+ onEdit={() => setProviderDialog({ open: true, provider })}
511
+ onDelete={() => void removeProvider(provider)}
512
+ onAssign={() => setAssignDialog({ open: true, provider })}
513
+ onAddOffering={() => setOfferingDialog({ open: true, provider, offering: null })}
514
+ onEditOffering={(offering) => setOfferingDialog({ open: true, provider, offering })}
515
+ onDeleteOffering={(offering) => void removeOffering(offering)}
516
+ />
517
+ ))}
518
+ </div>
519
+ )}
520
+
521
+ {total > PAGE_SIZE ? (
522
+ <div className="flex items-center justify-between text-sm">
523
+ <span className="text-text-secondary">
524
+ Page {page + 1} of {pageCount} · {total} providers
525
+ </span>
526
+ <div className="flex gap-2">
527
+ <Button
528
+ variant="outline"
529
+ size="sm"
530
+ disabled={page === 0}
531
+ onClick={() => setPage((p) => p - 1)}
532
+ >
533
+ Previous
534
+ </Button>
535
+ <Button
536
+ variant="outline"
537
+ size="sm"
538
+ disabled={page + 1 >= pageCount}
539
+ onClick={() => setPage((p) => p + 1)}
540
+ >
541
+ Next
542
+ </Button>
543
+ </div>
544
+ </div>
545
+ ) : null}
546
+
547
+ <ProviderDialog
548
+ open={providerDialog.open}
549
+ provider={providerDialog.provider}
550
+ onOpenChange={(open) => setProviderDialog((state) => ({ ...state, open }))}
551
+ onDone={done}
552
+ />
553
+ <OfferingDialog
554
+ open={offeringDialog.open}
555
+ provider={offeringDialog.provider}
556
+ offering={offeringDialog.offering}
557
+ onOpenChange={(open) => setOfferingDialog((state) => ({ ...state, open }))}
558
+ onDone={(message) => {
559
+ if (offeringDialog.provider) setExpandedId(offeringDialog.provider.id);
560
+ done(message);
561
+ }}
562
+ />
563
+ <AssignShipmentDialog
564
+ open={assignDialog.open}
565
+ provider={assignDialog.provider}
566
+ onOpenChange={(open) => setAssignDialog((state) => ({ ...state, open }))}
567
+ onDone={done}
568
+ />
569
+ </PageLayout>
570
+ );
571
+ }
@@ -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>