@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.
- package/dist/MoveTheWheelsRoot.js +130 -122
- package/dist/MoveTheWheelsRoutes.js +55 -51
- package/dist/components/ExportStatusBanner.js +53 -0
- package/dist/components/marketplace/MarketplaceDialogs.js +524 -0
- package/dist/generated/wspace-types.d.ts +30737 -8505
- package/dist/hooks/useEntityExport.js +85 -0
- package/dist/hooks/useMarketplace.js +265 -0
- package/dist/movethewheels/components/ExportStatusBanner.d.ts +7 -0
- package/dist/movethewheels/components/marketplace/MarketplaceDialogs.d.ts +18 -0
- package/dist/movethewheels/hooks/useEntityExport.d.ts +32 -0
- package/dist/movethewheels/hooks/useMarketplace.d.ts +121 -0
- package/dist/movethewheels/pages/MarketplacePage.d.ts +1 -0
- package/dist/movethewheels/utils/entityExport.d.ts +64 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/badge-check.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pencil.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/store.js +26 -0
- package/dist/pages/CustomersPage.js +64 -58
- package/dist/pages/DriversPage.js +113 -107
- package/dist/pages/MarketplacePage.js +503 -0
- package/dist/pages/OrdersListPage.js +85 -79
- package/dist/pages/ProductsPage.js +63 -57
- package/dist/pages/VehiclesPage.js +91 -85
- package/dist/utils/entityExport.js +54 -0
- package/package.json +3 -2
- package/src/generated/wspace-types.ts +34017 -8803
- package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/components/ExportStatusBanner.tsx +78 -0
- package/src/movethewheels/components/marketplace/MarketplaceDialogs.tsx +633 -0
- package/src/movethewheels/hooks/useEntityExport.ts +130 -0
- package/src/movethewheels/hooks/useMarketplace.ts +526 -0
- package/src/movethewheels/pages/CustomersPage.tsx +11 -2
- package/src/movethewheels/pages/DriversPage.tsx +11 -2
- package/src/movethewheels/pages/MarketplacePage.tsx +571 -0
- package/src/movethewheels/pages/OrdersListPage.tsx +13 -2
- package/src/movethewheels/pages/ProductsPage.tsx +11 -2
- package/src/movethewheels/pages/VehiclesPage.tsx +11 -2
- 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:
|
|
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'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:
|
|
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:
|
|
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:
|
|
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'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>
|