@burdenoff/microfe-movethewheels 2026.822.1 → 2026.828.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 (71) hide show
  1. package/dist/generated/wspace-operations.d.ts +168 -912
  2. package/dist/generated/wspace-types.d.ts +78432 -35831
  3. package/dist/hooks/useFleet.js +142 -74
  4. package/dist/hooks/useImportExport.js +22 -51
  5. package/dist/hooks/useInventory.js +163 -70
  6. package/dist/hooks/useOrders.js +2 -2
  7. package/dist/hooks/useReports.js +63 -73
  8. package/dist/hooks/useSettings.js +52 -44
  9. package/dist/hooks/useTracking.js +5 -5
  10. package/dist/index.js +33 -33
  11. package/dist/movethewheels/hooks/index.d.ts +2 -2
  12. package/dist/movethewheels/hooks/useFleet.d.ts +32 -1
  13. package/dist/movethewheels/hooks/useImportExport.d.ts +10 -0
  14. package/dist/movethewheels/hooks/useInventory.d.ts +67 -12
  15. package/dist/movethewheels/hooks/useOrders.d.ts +25 -2
  16. package/dist/movethewheels/pages/MarketplacePage.d.ts +7 -4
  17. package/dist/movethewheels/types/index.d.ts +2 -0
  18. package/dist/node_modules/lucide-react/dist/esm/icons/arrow-down-right.js +11 -0
  19. package/dist/node_modules/lucide-react/dist/esm/icons/arrow-left-right.js +22 -0
  20. package/dist/node_modules/lucide-react/dist/esm/icons/arrow-up-right.js +11 -0
  21. package/dist/node_modules/lucide-react/dist/esm/icons/calendar-clock.js +32 -0
  22. package/dist/node_modules/lucide-react/dist/esm/icons/history.js +18 -0
  23. package/dist/node_modules/lucide-react/dist/esm/icons/lock.js +16 -0
  24. package/dist/node_modules/lucide-react/dist/esm/icons/user-round.js +13 -0
  25. package/dist/pages/CreateOrderPage.js +246 -195
  26. package/dist/pages/DeliveryCapturePage.js +91 -78
  27. package/dist/pages/DriversPage.js +202 -108
  28. package/dist/pages/InventoryPage.js +356 -131
  29. package/dist/pages/MarketplacePage.js +11 -432
  30. package/dist/pages/ProductDetailsPage.js +212 -156
  31. package/dist/pages/ReportsPage.js +120 -109
  32. package/dist/pages/VehicleDetailsPage.js +257 -193
  33. package/dist/utils/formatters.js +4 -1
  34. package/package.json +2 -2
  35. package/src/generated/wspace-operations.ts +134 -929
  36. package/src/generated/wspace-types.ts +91730 -40059
  37. package/src/movethewheels/hooks/index.ts +9 -0
  38. package/src/movethewheels/hooks/useFleet.ts +133 -3
  39. package/src/movethewheels/hooks/useImportExport.ts +36 -72
  40. package/src/movethewheels/hooks/useInventory.ts +311 -53
  41. package/src/movethewheels/hooks/useOrders.ts +31 -7
  42. package/src/movethewheels/hooks/useReports.ts +73 -87
  43. package/src/movethewheels/hooks/useSettings.ts +50 -53
  44. package/src/movethewheels/hooks/useTracking.ts +14 -14
  45. package/src/movethewheels/pages/CreateOrderPage.tsx +67 -10
  46. package/src/movethewheels/pages/DeliveryCapturePage.tsx +8 -0
  47. package/src/movethewheels/pages/DriversPage.tsx +181 -31
  48. package/src/movethewheels/pages/InventoryPage.tsx +299 -19
  49. package/src/movethewheels/pages/MarketplacePage.tsx +16 -539
  50. package/src/movethewheels/pages/ProductDetailsPage.tsx +68 -3
  51. package/src/movethewheels/pages/ReportsPage.tsx +23 -0
  52. package/src/movethewheels/pages/VehicleDetailsPage.tsx +86 -27
  53. package/src/movethewheels/types/index.ts +2 -0
  54. package/src/operations/wspace/movethewheels/fragments/core.graphql +23 -53
  55. package/src/operations/wspace/movethewheels/fragments/core.withdrawn.graphql +59 -0
  56. package/src/operations/wspace/movethewheels/mutations/entities.graphql +5 -5
  57. package/src/operations/wspace/movethewheels/mutations/marketplace-reports.graphql +18 -78
  58. package/src/operations/wspace/movethewheels/mutations/marketplace-reports.withdrawn.graphql +93 -0
  59. package/src/operations/wspace/movethewheels/mutations/orders.graphql +7 -7
  60. package/src/operations/wspace/movethewheels/queries/dashboard.graphql +3 -3
  61. package/src/operations/wspace/movethewheels/queries/entities.graphql +3 -3
  62. package/src/operations/wspace/movethewheels/queries/logistics.graphql +3 -3
  63. package/src/operations/wspace/movethewheels/queries/marketplace-reports.withdrawn.graphql +58 -0
  64. package/src/operations/wspace/movethewheels/queries/orders.graphql +3 -3
  65. package/dist/generated/global-operations.d.ts +0 -1
  66. package/dist/generated/global-types.d.ts +0 -23526
  67. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +0 -8
  68. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +0 -8
  69. package/src/generated/global-operations.ts +0 -2
  70. package/src/generated/global-types.ts +0 -27259
  71. package/src/operations/wspace/movethewheels/queries/marketplace-reports.graphql +0 -40
@@ -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
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useEffect, useState } from 'react';
6
6
  import { useLocation, useParams } from 'react-router';
7
- import { Package, ArrowLeft, Edit, Box, DollarSign, Tag, Save } from 'lucide-react';
7
+ import { Package, ArrowLeft, Edit, Box, DollarSign, Tag, Save, Warehouse } from 'lucide-react';
8
8
  import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
9
9
  import {
10
10
  Card,
@@ -17,7 +17,7 @@ import {
17
17
  Label,
18
18
  } from '../components/ui';
19
19
  import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
20
- import { useProduct, useInventory } from '../hooks/useInventory';
20
+ import { useProduct, useInventory, useProductStockLevels } from '../hooks/useInventory';
21
21
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
22
22
  import { formatCurrency } from '../utils/formatters';
23
23
  import { joinPath } from '../utils/navigation';
@@ -191,6 +191,13 @@ export default function ProductDetailsPage() {
191
191
  const { basePath, navigate } = useMoveTheWheels();
192
192
  const { product, isLoading, error, refetch } = useProduct(productId);
193
193
  const isEditRoute = useLocation().pathname.endsWith('/edit');
194
+ // Where this item is actually stocked — inventoryItems(filter: { cargoItemId }).
195
+ const {
196
+ levels: stockLevels,
197
+ isLoading: stockLoading,
198
+ error: stockError,
199
+ } = useProductStockLevels(isEditRoute ? undefined : productId);
200
+ const totalAvailable = stockLevels.reduce((sum, level) => sum + level.available, 0);
194
201
 
195
202
  const goBack = () => navigate?.(joinPath(basePath, 'inventory/products'));
196
203
  const goTo = (path: string) => navigate?.(joinPath(basePath, path));
@@ -248,7 +255,7 @@ export default function ProductDetailsPage() {
248
255
  label: 'Edit product details',
249
256
  description: 'Update dimensions, value or handling flags.',
250
257
  icon: <Tag size={16} />,
251
- onClick: () => {},
258
+ onClick: () => goTo(`inventory/products/${productId}/edit`),
252
259
  },
253
260
  ]}
254
261
  />
@@ -335,6 +342,64 @@ export default function ProductDetailsPage() {
335
342
  <p className="text-sm text-text-secondary">Unit price</p>
336
343
  </EmphasisPanel>
337
344
 
345
+ {/* Stock levels — one row per warehouse holding this item. */}
346
+ <Card>
347
+ <CardHeader>
348
+ <CardTitle className="flex items-center gap-2">
349
+ <Warehouse size={18} /> Stock Levels
350
+ </CardTitle>
351
+ </CardHeader>
352
+ <CardContent className="space-y-3">
353
+ {stockError ? (
354
+ <p className="text-sm text-status-error-text">{stockError.message}</p>
355
+ ) : stockLoading ? (
356
+ <p className="text-sm text-text-secondary">Loading stock levels…</p>
357
+ ) : stockLevels.length === 0 ? (
358
+ <div className="space-y-3">
359
+ <p className="text-sm text-text-secondary">
360
+ This item isn&apos;t stocked in any warehouse yet, so nothing can be promised on
361
+ an order.
362
+ </p>
363
+ <Button variant="outline" size="sm" onClick={() => goTo('inventory/warehouses')}>
364
+ View warehouses
365
+ </Button>
366
+ </div>
367
+ ) : (
368
+ <>
369
+ {stockLevels.map((level) => (
370
+ <div
371
+ key={level.inventoryItemId}
372
+ className="flex items-baseline justify-between gap-3"
373
+ >
374
+ <div className="min-w-0">
375
+ <p className="truncate text-sm font-medium">{level.warehouseName}</p>
376
+ <p className="text-xs text-text-secondary">
377
+ {level.allocated.toLocaleString()} allocated
378
+ {level.reorderPoint !== undefined
379
+ ? ` · reorder at ${level.reorderPoint.toLocaleString()}`
380
+ : ''}
381
+ </p>
382
+ </div>
383
+ <span
384
+ className={
385
+ level.reorderPoint !== undefined && level.available <= level.reorderPoint
386
+ ? 'font-semibold text-status-warning-text'
387
+ : 'font-semibold'
388
+ }
389
+ >
390
+ {level.available.toLocaleString()}
391
+ </span>
392
+ </div>
393
+ ))}
394
+ <div className="flex items-baseline justify-between gap-3 border-t border-border-seam pt-3">
395
+ <p className="text-sm text-text-secondary">Available across the network</p>
396
+ <span className="font-bold">{totalAvailable.toLocaleString()}</span>
397
+ </div>
398
+ </>
399
+ )}
400
+ </CardContent>
401
+ </Card>
402
+
338
403
  <Card>
339
404
  <CardHeader>
340
405
  <CardTitle>Identifiers</CardTitle>