@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.
- package/dist/generated/wspace-operations.d.ts +168 -912
- package/dist/generated/wspace-types.d.ts +78432 -35831
- package/dist/hooks/useFleet.js +142 -74
- package/dist/hooks/useImportExport.js +22 -51
- package/dist/hooks/useInventory.js +163 -70
- package/dist/hooks/useOrders.js +2 -2
- package/dist/hooks/useReports.js +63 -73
- package/dist/hooks/useSettings.js +52 -44
- package/dist/hooks/useTracking.js +5 -5
- package/dist/index.js +33 -33
- package/dist/movethewheels/hooks/index.d.ts +2 -2
- package/dist/movethewheels/hooks/useFleet.d.ts +32 -1
- package/dist/movethewheels/hooks/useImportExport.d.ts +10 -0
- package/dist/movethewheels/hooks/useInventory.d.ts +67 -12
- package/dist/movethewheels/hooks/useOrders.d.ts +25 -2
- package/dist/movethewheels/pages/MarketplacePage.d.ts +7 -4
- package/dist/movethewheels/types/index.d.ts +2 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-down-right.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-left-right.js +22 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/arrow-up-right.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calendar-clock.js +32 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/history.js +18 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/lock.js +16 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/user-round.js +13 -0
- package/dist/pages/CreateOrderPage.js +246 -195
- package/dist/pages/DeliveryCapturePage.js +91 -78
- package/dist/pages/DriversPage.js +202 -108
- package/dist/pages/InventoryPage.js +356 -131
- package/dist/pages/MarketplacePage.js +11 -432
- package/dist/pages/ProductDetailsPage.js +212 -156
- package/dist/pages/ReportsPage.js +120 -109
- package/dist/pages/VehicleDetailsPage.js +257 -193
- package/dist/utils/formatters.js +4 -1
- package/package.json +2 -2
- package/src/generated/wspace-operations.ts +134 -929
- package/src/generated/wspace-types.ts +91730 -40059
- package/src/movethewheels/hooks/index.ts +9 -0
- package/src/movethewheels/hooks/useFleet.ts +133 -3
- package/src/movethewheels/hooks/useImportExport.ts +36 -72
- package/src/movethewheels/hooks/useInventory.ts +311 -53
- package/src/movethewheels/hooks/useOrders.ts +31 -7
- package/src/movethewheels/hooks/useReports.ts +73 -87
- package/src/movethewheels/hooks/useSettings.ts +50 -53
- package/src/movethewheels/hooks/useTracking.ts +14 -14
- package/src/movethewheels/pages/CreateOrderPage.tsx +67 -10
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +8 -0
- package/src/movethewheels/pages/DriversPage.tsx +181 -31
- package/src/movethewheels/pages/InventoryPage.tsx +299 -19
- package/src/movethewheels/pages/MarketplacePage.tsx +16 -539
- package/src/movethewheels/pages/ProductDetailsPage.tsx +68 -3
- package/src/movethewheels/pages/ReportsPage.tsx +23 -0
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +86 -27
- package/src/movethewheels/types/index.ts +2 -0
- package/src/operations/wspace/movethewheels/fragments/core.graphql +23 -53
- package/src/operations/wspace/movethewheels/fragments/core.withdrawn.graphql +59 -0
- package/src/operations/wspace/movethewheels/mutations/entities.graphql +5 -5
- package/src/operations/wspace/movethewheels/mutations/marketplace-reports.graphql +18 -78
- package/src/operations/wspace/movethewheels/mutations/marketplace-reports.withdrawn.graphql +93 -0
- package/src/operations/wspace/movethewheels/mutations/orders.graphql +7 -7
- package/src/operations/wspace/movethewheels/queries/dashboard.graphql +3 -3
- package/src/operations/wspace/movethewheels/queries/entities.graphql +3 -3
- package/src/operations/wspace/movethewheels/queries/logistics.graphql +3 -3
- package/src/operations/wspace/movethewheels/queries/marketplace-reports.withdrawn.graphql +58 -0
- package/src/operations/wspace/movethewheels/queries/orders.graphql +3 -3
- package/dist/generated/global-operations.d.ts +0 -1
- package/dist/generated/global-types.d.ts +0 -23526
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +0 -8
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +0 -8
- package/src/generated/global-operations.ts +0 -2
- package/src/generated/global-types.ts +0 -27259
- package/src/operations/wspace/movethewheels/queries/marketplace-reports.graphql +0 -40
|
@@ -1,559 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Marketplace Page
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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'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't do in-house.
|
|
279
|
-
</PagePurpose>
|
|
280
|
-
|
|
281
25
|
<Card>
|
|
282
|
-
<CardContent className="p-
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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'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'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'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>
|