@burdenoff/microfe-movethewheels 2026.827.1 → 2026.830.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 (39) hide show
  1. package/dist/MoveTheWheelsRoot.js +86 -83
  2. package/dist/MoveTheWheelsRoutes.js +107 -104
  3. package/dist/hooks/index.js +1 -0
  4. package/dist/hooks/useImportExport.js +22 -51
  5. package/dist/hooks/useReports.js +63 -73
  6. package/dist/hooks/useSettings.js +52 -44
  7. package/dist/index.js +38 -37
  8. package/dist/movethewheels/hooks/index.d.ts +1 -0
  9. package/dist/movethewheels/hooks/useImportExport.d.ts +10 -0
  10. package/dist/movethewheels/pages/MarketplacePage.d.ts +7 -4
  11. package/dist/movethewheels/shared/hooks/useTr.d.ts +16 -0
  12. package/dist/pages/AIAssistantPage.js +45 -43
  13. package/dist/pages/DashboardPage.js +6 -6
  14. package/dist/pages/FleetPage.js +7 -7
  15. package/dist/pages/InventoryPage.js +6 -6
  16. package/dist/pages/MarketplacePage.js +11 -432
  17. package/dist/pages/OrdersListPage.js +5 -5
  18. package/dist/pages/ReportsPage.js +120 -109
  19. package/dist/pages/SettingsPage.js +175 -154
  20. package/dist/pages/TrackingPage.js +5 -5
  21. package/dist/shared/hooks/useTr.js +12 -0
  22. package/package.json +2 -2
  23. package/src/movethewheels/MoveTheWheelsRoot.tsx +28 -24
  24. package/src/movethewheels/MoveTheWheelsRoutes.tsx +5 -2
  25. package/src/movethewheels/hooks/index.ts +3 -0
  26. package/src/movethewheels/hooks/useImportExport.ts +36 -72
  27. package/src/movethewheels/hooks/useReports.ts +73 -87
  28. package/src/movethewheels/hooks/useSettings.ts +50 -53
  29. package/src/movethewheels/pages/AIAssistantPage.tsx +50 -32
  30. package/src/movethewheels/pages/MarketplacePage.tsx +16 -539
  31. package/src/movethewheels/pages/ReportsPage.tsx +23 -0
  32. package/src/movethewheels/pages/SettingsPage.tsx +154 -70
  33. package/src/movethewheels/shared/hooks/useTr.ts +37 -0
  34. package/dist/generated/global-operations.d.ts +0 -1
  35. package/dist/generated/global-types.d.ts +0 -23526
  36. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +0 -8
  37. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +0 -8
  38. package/src/generated/global-operations.ts +0 -2
  39. package/src/generated/global-types.ts +0 -27259
@@ -1,559 +1,36 @@
1
1
  /**
2
2
  * Marketplace Page
3
3
  *
4
- * Browse third-party logistics service providers and their offerings. Providers
5
- * are paginated server-side (offset); clicking a provider expands its offerings
6
- * inline. The Filters control narrows the current page by type, verification and
7
- * minimum rating.
4
+ * The collaborative logistics marketplace (service providers / service offerings)
5
+ * was withdrawn from wspace-movethewheels-svc by migration
6
+ * 20260707000001_movethewheels_reuse_cleanup. The original query and mutation
7
+ * documents are preserved verbatim in
8
+ * src/operations/wspace/movethewheels/{queries,mutations}/marketplace-reports.withdrawn.graphql
9
+ * so they can be restored if the surface returns. Until then the page renders a
10
+ * static "coming soon" state.
8
11
  */
9
12
 
10
- import { useCallback, useEffect, useMemo, useState } from 'react';
11
- import {
12
- Store,
13
- Star,
14
- MapPin,
15
- Clock,
16
- Filter,
17
- Search,
18
- ChevronDown,
19
- ChevronUp,
20
- ChevronLeft,
21
- ChevronRight,
22
- } from 'lucide-react';
13
+ import { Store } from 'lucide-react';
23
14
  import { PageLayout } from '../components/PageLayout';
24
- import {
25
- Card,
26
- CardContent,
27
- CardHeader,
28
- CardTitle,
29
- Button,
30
- Badge,
31
- Input,
32
- Skeleton,
33
- Select,
34
- SelectContent,
35
- SelectItem,
36
- SelectTrigger,
37
- SelectValue,
38
- } from '../components/ui';
39
- import { EmphasisPanel, IllustratedEmptyState, PagePurpose } from '@burdenoff/fe-libs/ui';
40
- import { useFeatureFlag } from '@burdenoff/fe-libs/shared/feature-flags';
41
- import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
42
- import { formatCurrency } from '../utils/formatters';
43
- import { executeGraphQL } from '../utils/graphqlClient';
44
-
45
- /**
46
- * The backend has no ServiceProvider/ServiceOffering model (dropped by
47
- * migration 20260707000001_movethewheels_reuse_cleanup) — `serviceProviders`
48
- * and `serviceOfferings` are not resolvable fields today. Gate the whole page
49
- * behind a flag that defaults to **disabled** so it fails closed (an empty
50
- * "coming soon" state) instead of erroring on every load. Flip this on only
51
- * once a serviceProviders/serviceOfferings resolver ships.
52
- */
53
- const MARKETPLACE_FEATURE_FLAG = 'movethewheels.marketplace';
54
-
55
- interface ApiServiceProvider {
56
- id: string;
57
- name: string;
58
- type: string;
59
- description?: string | null;
60
- rating?: number | null;
61
- isVerified: boolean;
62
- isActive: boolean;
63
- services?: unknown;
64
- contactInfo?: unknown;
65
- }
66
-
67
- interface ApiServiceOffering {
68
- id: string;
69
- providerId: string;
70
- name: string;
71
- description?: string | null;
72
- category?: string | null;
73
- price: number;
74
- currency: string;
75
- availability?: unknown;
76
- provider?: {
77
- id: string;
78
- name: string;
79
- type: string;
80
- rating?: number | null;
81
- } | null;
82
- }
83
-
84
- const PROVIDERS_QUERY = `
85
- query ListServiceProviders($filter: ServiceProviderFilterInput, $pagination: PaginationInput) {
86
- serviceProviders(filter: $filter, pagination: $pagination) {
87
- id
88
- name
89
- type
90
- description
91
- rating
92
- isVerified
93
- isActive
94
- services
95
- contactInfo
96
- }
97
- }
98
- `;
99
-
100
- const OFFERINGS_QUERY = `
101
- query ListServiceOfferings($filter: ServiceOfferingFilterInput, $pagination: PaginationInput) {
102
- serviceOfferings(filter: $filter, pagination: $pagination) {
103
- id
104
- providerId
105
- name
106
- description
107
- category
108
- price
109
- currency
110
- availability
111
- provider {
112
- id
113
- name
114
- type
115
- rating
116
- }
117
- }
118
- }
119
- `;
120
-
121
- const PROVIDERS_PAGE_SIZE = 12;
122
-
123
- const RATING_OPTIONS: { value: string; label: string }[] = [
124
- { value: '0', label: 'Any rating' },
125
- { value: '3', label: '3.0+' },
126
- { value: '4', label: '4.0+' },
127
- { value: '4.5', label: '4.5+' },
128
- ];
15
+ import { Card, CardContent } from '../components/ui';
16
+ import { IllustratedEmptyState } from '@burdenoff/fe-libs/ui';
129
17
 
130
18
  export default function MarketplacePage() {
131
- const marketplaceEnabled = useFeatureFlag(MARKETPLACE_FEATURE_FLAG, false);
132
- const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
133
- useMoveTheWheels();
134
- const [providers, setProviders] = useState<ApiServiceProvider[]>([]);
135
- const [offerings, setOfferings] = useState<ApiServiceOffering[]>([]);
136
- const [search, setSearch] = useState('');
137
- const [isLoading, setIsLoading] = useState(false);
138
- const [error, setError] = useState<Error | null>(null);
139
- const [page, setPage] = useState(0);
140
- const [hasMore, setHasMore] = useState(false);
141
-
142
- // Inline filters (toggled by the "Filters" button in the header).
143
- const [showFilters, setShowFilters] = useState(false);
144
- const [typeFilter, setTypeFilter] = useState('all');
145
- const [verifiedOnly, setVerifiedOnly] = useState(false);
146
- const [minRating, setMinRating] = useState('0');
147
-
148
- // Provider whose offerings are currently expanded.
149
- const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
150
-
151
- const graphQLOptions = useMemo(
152
- () => ({
153
- apiGatewayUrl,
154
- authToken,
155
- workspaceToken,
156
- workspaceId,
157
- tenantId,
158
- actorId: currentUser?.id,
159
- }),
160
- [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
161
- );
162
-
163
- const load = useCallback(async () => {
164
- if (!marketplaceEnabled) return;
165
- setIsLoading(true);
166
- setError(null);
167
- try {
168
- const [providersData, offeringsData] = await Promise.all([
169
- executeGraphQL<{ serviceProviders: ApiServiceProvider[] }>(
170
- graphQLOptions,
171
- PROVIDERS_QUERY,
172
- {
173
- pagination: { limit: PROVIDERS_PAGE_SIZE, offset: page * PROVIDERS_PAGE_SIZE },
174
- }
175
- ),
176
- executeGraphQL<{ serviceOfferings: ApiServiceOffering[] }>(
177
- graphQLOptions,
178
- OFFERINGS_QUERY,
179
- {
180
- pagination: { limit: 200, offset: 0 },
181
- }
182
- ),
183
- ]);
184
-
185
- setProviders(providersData.serviceProviders);
186
- setHasMore(providersData.serviceProviders.length === PROVIDERS_PAGE_SIZE);
187
- setOfferings(offeringsData.serviceOfferings);
188
- } catch (fetchError) {
189
- setError(fetchError instanceof Error ? fetchError : new Error('Failed to load marketplace'));
190
- } finally {
191
- setIsLoading(false);
192
- }
193
- }, [graphQLOptions, page, marketplaceEnabled]);
194
-
195
- useEffect(() => {
196
- void load();
197
- }, [load]);
198
-
199
- const providerTypes = useMemo(() => {
200
- const set = new Set<string>();
201
- providers.forEach((provider) => set.add(provider.type));
202
- return Array.from(set).sort();
203
- }, [providers]);
204
-
205
- const filteredProviders = useMemo(() => {
206
- const term = search.trim().toLowerCase();
207
- const rating = Number(minRating);
208
- return providers.filter((provider) => {
209
- if (term) {
210
- const matches =
211
- provider.name.toLowerCase().includes(term) ||
212
- (provider.description ?? '').toLowerCase().includes(term) ||
213
- provider.type.toLowerCase().includes(term);
214
- if (!matches) return false;
215
- }
216
- if (typeFilter !== 'all' && provider.type !== typeFilter) return false;
217
- if (verifiedOnly && !provider.isVerified) return false;
218
- if (rating > 0 && (provider.rating ?? 0) < rating) return false;
219
- return true;
220
- });
221
- }, [providers, search, typeFilter, verifiedOnly, minRating]);
222
-
223
- const filteredOfferings = useMemo(() => {
224
- const term = search.trim().toLowerCase();
225
- if (!term) return offerings;
226
- return offerings.filter(
227
- (offering) =>
228
- offering.name.toLowerCase().includes(term) ||
229
- (offering.description ?? '').toLowerCase().includes(term) ||
230
- (offering.category ?? '').toLowerCase().includes(term)
231
- );
232
- }, [offerings, search]);
233
-
234
- const offeringsByProvider = useCallback(
235
- (providerId: string) => offerings.filter((offering) => offering.providerId === providerId),
236
- [offerings]
237
- );
238
-
239
- const filtersActive = typeFilter !== 'all' || verifiedOnly || minRating !== '0';
240
-
241
- if (!marketplaceEnabled) {
242
- return (
243
- <PageLayout
244
- title="Marketplace"
245
- subtitle="Find logistics service providers"
246
- icon={<Store size={24} />}
247
- >
248
- <Card>
249
- <CardContent className="p-0">
250
- <IllustratedEmptyState
251
- illustration="empty-people"
252
- title="Marketplace is coming soon"
253
- description="The collaborative logistics marketplace is not open yet. Check back after it launches to browse verified third-party service providers."
254
- />
255
- </CardContent>
256
- </Card>
257
- </PageLayout>
258
- );
259
- }
260
-
261
19
  return (
262
20
  <PageLayout
263
21
  title="Marketplace"
264
22
  subtitle="Find logistics service providers"
265
23
  icon={<Store size={24} />}
266
- headerActions={
267
- <Button
268
- variant={showFilters || filtersActive ? 'default' : 'outline'}
269
- onClick={() => setShowFilters((prev) => !prev)}
270
- >
271
- <Filter size={16} /> Filters
272
- </Button>
273
- }
274
24
  >
275
- <PagePurpose icon={<Store size={18} />}>
276
- Don&apos;t own every leg of the journey? Find vetted third-party logistics providers and
277
- service offerings here — compare ratings, verification status and pricing, then engage a
278
- partner to cover routes, warehousing or specialised handling you can&apos;t do in-house.
279
- </PagePurpose>
280
-
281
25
  <Card>
282
- <CardContent className="p-4 space-y-4">
283
- <div className="relative">
284
- <Search
285
- className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
286
- size={20}
287
- />
288
- <Input
289
- placeholder="Search services, providers, locations..."
290
- className="pl-10"
291
- value={search}
292
- onChange={(event) => setSearch(event.target.value)}
293
- />
294
- </div>
295
-
296
- {showFilters && (
297
- <div className="flex flex-wrap items-center gap-3 border-t border-border-seam pt-4">
298
- <div className="flex items-center gap-2">
299
- <span className="text-sm font-medium text-text-secondary">Type</span>
300
- <Select value={typeFilter} onValueChange={setTypeFilter}>
301
- <SelectTrigger className="w-[160px]">
302
- <SelectValue placeholder="All types" />
303
- </SelectTrigger>
304
- <SelectContent>
305
- <SelectItem value="all">All types</SelectItem>
306
- {providerTypes.map((type) => (
307
- <SelectItem key={type} value={type}>
308
- {type}
309
- </SelectItem>
310
- ))}
311
- </SelectContent>
312
- </Select>
313
- </div>
314
- <div className="flex items-center gap-2">
315
- <span className="text-sm font-medium text-text-secondary">Min rating</span>
316
- <Select value={minRating} onValueChange={setMinRating}>
317
- <SelectTrigger className="w-[130px]">
318
- <SelectValue />
319
- </SelectTrigger>
320
- <SelectContent>
321
- {RATING_OPTIONS.map((option) => (
322
- <SelectItem key={option.value} value={option.value}>
323
- {option.label}
324
- </SelectItem>
325
- ))}
326
- </SelectContent>
327
- </Select>
328
- </div>
329
- <Button
330
- variant={verifiedOnly ? 'default' : 'outline'}
331
- size="sm"
332
- onClick={() => setVerifiedOnly((prev) => !prev)}
333
- >
334
- Verified only
335
- </Button>
336
- {filtersActive && (
337
- <Button
338
- variant="ghost"
339
- size="sm"
340
- onClick={() => {
341
- setTypeFilter('all');
342
- setVerifiedOnly(false);
343
- setMinRating('0');
344
- }}
345
- >
346
- Clear filters
347
- </Button>
348
- )}
349
- </div>
350
- )}
26
+ <CardContent className="p-0">
27
+ <IllustratedEmptyState
28
+ illustration="empty-people"
29
+ title="Marketplace is coming soon"
30
+ description="The collaborative logistics marketplace is not open yet. Check back after it launches to browse verified third-party service providers."
31
+ />
351
32
  </CardContent>
352
33
  </Card>
353
-
354
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
355
- <div className="lg:col-span-2 space-y-4">
356
- <h3 className="font-semibold">Service Providers ({filteredProviders.length})</h3>
357
- {error && !isLoading && (
358
- <Card className="border-status-error-border">
359
- <CardContent className="p-6 text-center space-y-3">
360
- <p className="text-status-error-text font-medium">Couldn&apos;t load providers</p>
361
- <p className="text-sm text-text-secondary">{error.message}</p>
362
- <Button variant="outline" onClick={() => void load()}>
363
- Retry
364
- </Button>
365
- </CardContent>
366
- </Card>
367
- )}
368
- {isLoading &&
369
- Array.from({ length: 3 }).map((_, i) => (
370
- <Skeleton key={i} className="h-32 w-full rounded-lg" />
371
- ))}
372
- {!isLoading && !error && filteredProviders.length === 0 && (
373
- <Card>
374
- <CardContent className="p-0">
375
- <IllustratedEmptyState
376
- illustration={search.trim() || filtersActive ? 'empty-search' : 'empty-people'}
377
- title={
378
- search.trim() || filtersActive
379
- ? 'No providers match your filters'
380
- : 'No providers yet'
381
- }
382
- description={
383
- search.trim() || filtersActive
384
- ? 'Adjust your search or filters to find logistics partners.'
385
- : 'Verified logistics service providers will appear here as they join the marketplace.'
386
- }
387
- />
388
- </CardContent>
389
- </Card>
390
- )}
391
- {!isLoading &&
392
- !error &&
393
- filteredProviders.map((provider) => {
394
- const expanded = expandedProviderId === provider.id;
395
- const providerOfferings = offeringsByProvider(provider.id);
396
- return (
397
- <Card key={provider.id}>
398
- <button
399
- type="button"
400
- aria-expanded={expanded}
401
- className="w-full text-left"
402
- onClick={() => setExpandedProviderId(expanded ? null : provider.id)}
403
- >
404
- <CardContent className="p-6">
405
- <div className="flex items-start justify-between gap-3">
406
- <div className="min-w-0">
407
- <div className="flex items-center gap-2 flex-wrap">
408
- <h4 className="font-semibold text-lg">{provider.name}</h4>
409
- {provider.isVerified && <Badge variant="default">Verified</Badge>}
410
- <Badge variant={provider.isActive ? 'default' : 'secondary'}>
411
- {provider.isActive ? 'active' : 'inactive'}
412
- </Badge>
413
- </div>
414
- <p className="text-text-secondary mt-1">{provider.description}</p>
415
- <div className="flex items-center gap-4 mt-3 text-sm text-text-secondary">
416
- <span className="flex items-center gap-1">
417
- <Star size={14} className="text-status-warning-text fill-current" />{' '}
418
- {provider.rating ?? 0}
419
- </span>
420
- <span className="flex items-center gap-1">
421
- <MapPin size={14} /> {provider.type}
422
- </span>
423
- <span className="flex items-center gap-1">
424
- <Clock size={14} /> Listed
425
- </span>
426
- </div>
427
- </div>
428
- <div className="text-right shrink-0">
429
- <p className="text-sm text-text-secondary">Services</p>
430
- <p className="text-lg font-bold">{providerOfferings.length}</p>
431
- <span className="mt-1 inline-flex items-center gap-1 text-xs text-primary">
432
- {expanded ? (
433
- <>
434
- Hide <ChevronUp size={14} />
435
- </>
436
- ) : (
437
- <>
438
- View <ChevronDown size={14} />
439
- </>
440
- )}
441
- </span>
442
- </div>
443
- </div>
444
- </CardContent>
445
- </button>
446
- {expanded && (
447
- <div className="border-t border-border-seam px-6 py-4 space-y-3">
448
- <p className="text-sm font-medium text-text-secondary">
449
- Offerings from {provider.name}
450
- </p>
451
- {providerOfferings.length === 0 ? (
452
- <p className="text-sm text-text-secondary">
453
- This provider hasn&apos;t listed any offerings yet.
454
- </p>
455
- ) : (
456
- providerOfferings.map((offering) => (
457
- <div
458
- key={offering.id}
459
- className="flex items-center justify-between gap-3 rounded-lg border border-border-seam p-3"
460
- >
461
- <div className="min-w-0">
462
- <p className="font-medium truncate">{offering.name}</p>
463
- <p className="text-sm text-text-secondary truncate">
464
- {offering.category ?? 'general'}
465
- </p>
466
- </div>
467
- <div className="text-right shrink-0">
468
- <p className="font-bold">{formatCurrency(offering.price)}</p>
469
- <p className="text-xs text-text-secondary">{offering.currency}</p>
470
- </div>
471
- </div>
472
- ))
473
- )}
474
- </div>
475
- )}
476
- </Card>
477
- );
478
- })}
479
-
480
- {!isLoading && !error && (providers.length > 0 || page > 0) && (
481
- <div className="flex flex-wrap items-center justify-between gap-3 pt-1">
482
- <span className="text-sm text-text-secondary">
483
- Showing {providers.length === 0 ? 0 : page * PROVIDERS_PAGE_SIZE + 1} to{' '}
484
- {page * PROVIDERS_PAGE_SIZE + providers.length}
485
- </span>
486
- <div className="flex items-center gap-1">
487
- <Button
488
- variant="outline"
489
- size="icon"
490
- className="h-8 w-8"
491
- aria-label="Previous page"
492
- onClick={() => {
493
- setExpandedProviderId(null);
494
- setPage((p) => Math.max(0, p - 1));
495
- }}
496
- disabled={page === 0}
497
- >
498
- <ChevronLeft className="h-4 w-4" />
499
- </Button>
500
- <span className="px-3 text-sm">Page {page + 1}</span>
501
- <Button
502
- variant="outline"
503
- size="icon"
504
- className="h-8 w-8"
505
- aria-label="Next page"
506
- onClick={() => {
507
- setExpandedProviderId(null);
508
- setPage((p) => p + 1);
509
- }}
510
- disabled={!hasMore}
511
- >
512
- <ChevronRight className="h-4 w-4" />
513
- </Button>
514
- </div>
515
- </div>
516
- )}
517
- </div>
518
-
519
- {/* Popular Services — the page's single emphasis zone */}
520
- <EmphasisPanel className="space-y-4">
521
- <h3 className="text-base font-semibold text-text-primary">Popular Services</h3>
522
- {filteredOfferings.length === 0 && (
523
- <IllustratedEmptyState
524
- illustration={search.trim() ? 'empty-search' : 'empty-generic'}
525
- title={search.trim() ? 'No matching services' : 'No services listed yet'}
526
- description={
527
- search.trim()
528
- ? 'Adjust your search to discover service offerings.'
529
- : 'Service offerings from providers will be listed here.'
530
- }
531
- />
532
- )}
533
- {filteredOfferings.map((service) => (
534
- <Card key={service.id}>
535
- <CardHeader>
536
- <CardTitle className="text-base">{service.name}</CardTitle>
537
- </CardHeader>
538
- <CardContent>
539
- <p className="text-sm text-text-secondary mb-3">{service.description}</p>
540
- <div className="flex flex-wrap gap-1 mb-3">
541
- <Badge variant="outline" className="text-xs">
542
- {service.category ?? 'general'}
543
- </Badge>
544
- <Badge variant="outline" className="text-xs">
545
- {service.provider?.name ?? 'provider'}
546
- </Badge>
547
- </div>
548
- <div className="flex items-center justify-between">
549
- <span className="font-bold">{formatCurrency(service.price)}</span>
550
- <span className="text-sm text-text-secondary">{service.currency}</span>
551
- </div>
552
- </CardContent>
553
- </Card>
554
- ))}
555
- </EmphasisPanel>
556
- </div>
557
34
  </PageLayout>
558
35
  );
559
36
  }
@@ -36,6 +36,8 @@ import { EmphasisPanel, IllustratedEmptyState, PagePurpose } from '@burdenoff/fe
36
36
  import { VolumeBar } from '../components/charts';
37
37
  import { useReports, type CreateReportInput } from '../hooks/useReports';
38
38
  import { useAnalytics } from '../hooks/useAnalytics';
39
+ import { useDemandForecast } from '../hooks/useDemandForecast';
40
+ import { UpcomingDemandPanel } from '../components/UpcomingDemandPanel';
39
41
  import { formatDate } from '../utils/formatters';
40
42
  import { downloadFile, downloadJson, recordToCsv, slugifyForFilename } from '../utils/download';
41
43
 
@@ -62,6 +64,15 @@ export default function ReportsPage() {
62
64
  });
63
65
  const { analytics } = useAnalytics();
64
66
  const previewData = analytics.trends.dailyOrders.slice(-7);
67
+ const {
68
+ laneForecast,
69
+ customerForecast,
70
+ laneVariance,
71
+ customerVariance,
72
+ isLoading: forecastLoading,
73
+ error: forecastError,
74
+ refetch: refetchForecast,
75
+ } = useDemandForecast(4, 8);
65
76
  const [reportType, setReportType] = useState('operations');
66
77
  const [busyId, setBusyId] = useState<string | null>(null);
67
78
  const [creating, setCreating] = useState(false);
@@ -280,6 +291,18 @@ export default function ReportsPage() {
280
291
  </div>
281
292
 
282
293
  <div className="space-y-6">
294
+ {/* Demand forecast (BOFF-4393) — shared with AnalyticsPage so reports
295
+ and analytics show the same planning signal. */}
296
+ <UpcomingDemandPanel
297
+ laneForecast={laneForecast}
298
+ customerForecast={customerForecast}
299
+ laneVariance={laneVariance}
300
+ customerVariance={customerVariance}
301
+ isLoading={forecastLoading}
302
+ error={forecastError}
303
+ onRetry={refetchForecast}
304
+ />
305
+
283
306
  {/* Data preview — a real snapshot (recent order volume) of what a
284
307
  generated report draws from. Honest empty state when no data. */}
285
308
  <Card>