@burdenoff/microfe-movethewheels 2026.1026.3 → 2026.1030.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 (29) hide show
  1. package/dist/MoveTheWheelsRoot.js +130 -122
  2. package/dist/MoveTheWheelsRoutes.js +55 -51
  3. package/dist/components/ProofOfDeliveryCard.js +1 -1
  4. package/dist/components/UpcomingDemandPanel.js +1 -1
  5. package/dist/components/marketplace/MarketplaceDialogs.js +524 -0
  6. package/dist/generated/wspace-types.d.ts +30737 -8505
  7. package/dist/hooks/useMarketplace.js +265 -0
  8. package/dist/movethewheels/components/marketplace/MarketplaceDialogs.d.ts +18 -0
  9. package/dist/movethewheels/hooks/useMarketplace.d.ts +121 -0
  10. package/dist/movethewheels/pages/MarketplacePage.d.ts +1 -0
  11. package/dist/node_modules/lucide-react/dist/esm/icons/badge-check.js +11 -0
  12. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +8 -0
  13. package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +8 -0
  14. package/dist/node_modules/lucide-react/dist/esm/icons/pencil.js +11 -0
  15. package/dist/node_modules/lucide-react/dist/esm/icons/store.js +26 -0
  16. package/dist/pages/ContainersPage.js +3 -3
  17. package/dist/pages/MarketplacePage.js +503 -0
  18. package/dist/pages/TrackingPage.js +111 -56
  19. package/package.json +2 -1
  20. package/src/generated/wspace-types.ts +34017 -8803
  21. package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
  22. package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
  23. package/src/movethewheels/components/ProofOfDeliveryCard.tsx +1 -1
  24. package/src/movethewheels/components/UpcomingDemandPanel.tsx +2 -2
  25. package/src/movethewheels/components/marketplace/MarketplaceDialogs.tsx +633 -0
  26. package/src/movethewheels/hooks/useMarketplace.ts +526 -0
  27. package/src/movethewheels/pages/ContainersPage.tsx +3 -3
  28. package/src/movethewheels/pages/MarketplacePage.tsx +571 -0
  29. package/src/movethewheels/pages/TrackingPage.tsx +54 -9
@@ -22,6 +22,7 @@ import {
22
22
  ClipboardList,
23
23
  DollarSign,
24
24
  Bot,
25
+ Store,
25
26
  } from 'lucide-react';
26
27
  import { MoveTheWheelsProvider } from './providers/MoveTheWheelsProvider';
27
28
  import { FeatureFlagProvider } from '@burdenoff/fe-libs/shared/feature-flags';
@@ -233,6 +234,13 @@ const NavRegistrar: FC<Pick<MoveTheWheelsRootProps, 'registerNavItems' | 'basePa
233
234
  icon: <Users size={16} />,
234
235
  order: 6,
235
236
  },
237
+ {
238
+ id: 'mtw-marketplace',
239
+ label: tr('movethewheels.root.nav.marketplace', 'Marketplace'),
240
+ path: joinPath(basePath, 'marketplace'),
241
+ icon: <Store size={16} />,
242
+ order: 6,
243
+ },
236
244
  {
237
245
  id: 'mtw-routes',
238
246
  label: tr('movethewheels.root.nav.routes', 'Routes'),
@@ -71,6 +71,7 @@ const WarehousesPage = lazy(() => import('./pages/WarehousesPage'));
71
71
  const WarehouseDetailsPage = lazy(() => import('./pages/WarehouseDetailsPage'));
72
72
  const CustomersPage = lazy(() => import('./pages/CustomersPage'));
73
73
  const CustomerDetailsPage = lazy(() => import('./pages/CustomerDetailsPage'));
74
+ const MarketplacePage = lazy(() => import('./pages/MarketplacePage'));
74
75
  const RoutesPage = lazy(() => import('./pages/RoutesPage'));
75
76
  const RouteDetailsPage = lazy(() => import('./pages/RouteDetailsPage'));
76
77
  const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));
@@ -156,6 +157,9 @@ export function MoveTheWheelsRoutes() {
156
157
  <Route path="customers/new" element={<CustomerDetailsPage />} />
157
158
  <Route path="customers/:customerId" element={<CustomerDetailsPage />} />
158
159
 
160
+ {/* Marketplace — service providers and offerings (BOFF-3988) */}
161
+ <Route path="marketplace" element={<MarketplacePage />} />
162
+
159
163
  {/* Routes */}
160
164
  <Route path="routes" element={<RoutesPage />} />
161
165
  <Route path="routes/trip-utilization" element={<TripUtilizationPage />} />
@@ -100,7 +100,7 @@ export function ProofOfDeliveryCard({ deliveryJobId }: ProofOfDeliveryCardProps)
100
100
  Signature {epod.signatureType === 'typed' ? '(typed)' : '(drawn)'}
101
101
  </p>
102
102
  {epod.signatureType === 'drawn' ? (
103
- <div className="border rounded-lg p-2 inline-block bg-white">
103
+ <div className="border rounded-lg p-2 inline-block bg-bg-surface">
104
104
  <img src={epod.signatureData} alt="Recipient signature" className="h-16" />
105
105
  </div>
106
106
  ) : (
@@ -212,8 +212,8 @@ function VarianceList({ rows }: VarianceListProps) {
212
212
  const isOvershoot = row.volumeVariance > 0;
213
213
  const Icon = isOvershoot ? TrendingUp : TrendingDown;
214
214
  const tone = isOvershoot
215
- ? 'text-emerald-600 dark:text-emerald-400'
216
- : 'text-rose-600 dark:text-rose-400';
215
+ ? 'text-status-success-text'
216
+ : 'text-status-error-text';
217
217
  return (
218
218
  <li
219
219
  key={`${row.scopeType}-${row.scopeKey}-${row.periodStart}`}
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Marketplace dialogs (BOFF-3988): add/edit a service provider, add/edit an
3
+ * offering, and make a provider the carrier of a shipment. Every write goes
4
+ * through `useMarketplaceActions`; the service validates and scopes it.
5
+ */
6
+ import { useEffect, useState } from 'react';
7
+ import {
8
+ ResponsiveDialog,
9
+ ResponsiveDialogContent,
10
+ ResponsiveDialogDescription,
11
+ ResponsiveDialogFooter,
12
+ ResponsiveDialogHeader,
13
+ ResponsiveDialogTitle,
14
+ } from '@burdenoff/fe-libs/ui';
15
+ import {
16
+ Button,
17
+ Input,
18
+ Label,
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ Switch,
25
+ Textarea,
26
+ } from '../ui';
27
+ import { EntityCombobox } from '../EntityCombobox';
28
+ import { FormError } from '../FormError';
29
+ import {
30
+ AVAILABILITIES,
31
+ OFFERING_CATEGORIES,
32
+ PROVIDER_TYPES,
33
+ humanizeEnum,
34
+ useMarketplaceActions,
35
+ useShipmentOptions,
36
+ type Availability,
37
+ type OfferingCategory,
38
+ type OfferingInput,
39
+ type ProviderInput,
40
+ type ProviderType,
41
+ type ServiceOffering,
42
+ type ServiceProvider,
43
+ } from '../../hooks/useMarketplace';
44
+
45
+ interface DialogBaseProps {
46
+ open: boolean;
47
+ onOpenChange: (open: boolean) => void;
48
+ /** Called after a successful write with a short human-readable summary. */
49
+ onDone: (message: string) => void;
50
+ }
51
+
52
+ function errorMessage(error: unknown, fallback: string): string {
53
+ return error instanceof Error && error.message ? error.message : fallback;
54
+ }
55
+
56
+ function splitList(raw: string): string[] {
57
+ return raw
58
+ .split(',')
59
+ .map((entry) => entry.trim())
60
+ .filter(Boolean);
61
+ }
62
+
63
+ function optionalNumber(raw: string): number | null | 'invalid' {
64
+ const trimmed = raw.trim();
65
+ if (!trimmed) return null;
66
+ const value = Number(trimmed);
67
+ return Number.isFinite(value) ? value : 'invalid';
68
+ }
69
+
70
+ function emptyToNull(raw: string): string | null {
71
+ const trimmed = raw.trim();
72
+ return trimmed ? trimmed : null;
73
+ }
74
+
75
+ // ─── Provider ────────────────────────────────────────────────────────────────
76
+
77
+ export function ProviderDialog({
78
+ open,
79
+ onOpenChange,
80
+ onDone,
81
+ provider,
82
+ }: DialogBaseProps & { provider: ServiceProvider | null }) {
83
+ const actions = useMarketplaceActions();
84
+ const [name, setName] = useState('');
85
+ const [type, setType] = useState<ProviderType>('CARRIER');
86
+ const [description, setDescription] = useState('');
87
+ const [rating, setRating] = useState('');
88
+ const [contactName, setContactName] = useState('');
89
+ const [email, setEmail] = useState('');
90
+ const [phone, setPhone] = useState('');
91
+ const [website, setWebsite] = useState('');
92
+ const [services, setServices] = useState('');
93
+ const [serviceAreas, setServiceAreas] = useState('');
94
+ const [isVerified, setIsVerified] = useState(false);
95
+ const [isActive, setIsActive] = useState(true);
96
+ const [saving, setSaving] = useState(false);
97
+ const [error, setError] = useState<string | null>(null);
98
+
99
+ useEffect(() => {
100
+ if (!open) return;
101
+ setName(provider?.name ?? '');
102
+ setType(provider?.type ?? 'CARRIER');
103
+ setDescription(provider?.description ?? '');
104
+ setRating(provider?.rating != null ? String(provider.rating) : '');
105
+ setContactName(provider?.contact.contactName ?? '');
106
+ setEmail(provider?.contact.email ?? '');
107
+ setPhone(provider?.contact.phone ?? '');
108
+ setWebsite(provider?.contact.website ?? '');
109
+ setServices(provider?.services.join(', ') ?? '');
110
+ setServiceAreas(provider?.serviceAreas.join(', ') ?? '');
111
+ setIsVerified(provider?.isVerified ?? false);
112
+ setIsActive(provider?.isActive ?? true);
113
+ setError(null);
114
+ }, [open, provider]);
115
+
116
+ const submit = async () => {
117
+ if (!name.trim()) {
118
+ setError('Enter the provider name.');
119
+ return;
120
+ }
121
+ const parsedRating = optionalNumber(rating);
122
+ if (
123
+ parsedRating === 'invalid' ||
124
+ (parsedRating !== null && (parsedRating < 0 || parsedRating > 5))
125
+ ) {
126
+ setError('Rating must be a number from 0 to 5.');
127
+ return;
128
+ }
129
+ const contact = {
130
+ ...(contactName.trim() ? { contactName: contactName.trim() } : {}),
131
+ ...(email.trim() ? { email: email.trim() } : {}),
132
+ ...(phone.trim() ? { phone: phone.trim() } : {}),
133
+ ...(website.trim() ? { website: website.trim() } : {}),
134
+ };
135
+ const input: ProviderInput = {
136
+ name: name.trim(),
137
+ type,
138
+ description: emptyToNull(description),
139
+ rating: parsedRating,
140
+ contactInfo: Object.keys(contact).length > 0 ? contact : null,
141
+ services: splitList(services),
142
+ serviceAreas: splitList(serviceAreas),
143
+ isVerified,
144
+ isActive,
145
+ };
146
+ setSaving(true);
147
+ setError(null);
148
+ try {
149
+ if (provider) {
150
+ await actions.updateProvider(provider.id, input);
151
+ onDone(`Updated ${input.name}.`);
152
+ } else {
153
+ await actions.createProvider(input);
154
+ onDone(`Added ${input.name} to the marketplace.`);
155
+ }
156
+ onOpenChange(false);
157
+ } catch (saveError) {
158
+ setError(errorMessage(saveError, 'Could not save the provider.'));
159
+ } finally {
160
+ setSaving(false);
161
+ }
162
+ };
163
+
164
+ return (
165
+ <ResponsiveDialog open={open} onOpenChange={onOpenChange}>
166
+ <ResponsiveDialogContent className="sm:max-w-xl">
167
+ <ResponsiveDialogHeader>
168
+ <ResponsiveDialogTitle>
169
+ {provider ? 'Edit provider' : 'Add a service provider'}
170
+ </ResponsiveDialogTitle>
171
+ <ResponsiveDialogDescription>
172
+ Carriers, 3PLs, warehouses and specialists you can call on when you need capacity.
173
+ </ResponsiveDialogDescription>
174
+ </ResponsiveDialogHeader>
175
+ <form
176
+ className="grid gap-4"
177
+ onSubmit={(event) => {
178
+ event.preventDefault();
179
+ void submit();
180
+ }}
181
+ >
182
+ <FormError message={error} />
183
+ <div className="grid gap-4 sm:grid-cols-2">
184
+ <div className="grid gap-1.5">
185
+ <Label htmlFor="mtw-provider-name">Name</Label>
186
+ <Input
187
+ id="mtw-provider-name"
188
+ value={name}
189
+ maxLength={255}
190
+ onChange={(event) => setName(event.target.value)}
191
+ required
192
+ />
193
+ </div>
194
+ <div className="grid gap-1.5">
195
+ <Label htmlFor="mtw-provider-type">Type</Label>
196
+ <Select value={type} onValueChange={(value) => setType(value as ProviderType)}>
197
+ <SelectTrigger id="mtw-provider-type">
198
+ <SelectValue />
199
+ </SelectTrigger>
200
+ <SelectContent>
201
+ {PROVIDER_TYPES.map((option) => (
202
+ <SelectItem key={option} value={option}>
203
+ {humanizeEnum(option)}
204
+ </SelectItem>
205
+ ))}
206
+ </SelectContent>
207
+ </Select>
208
+ </div>
209
+ </div>
210
+ <div className="grid gap-1.5">
211
+ <Label htmlFor="mtw-provider-description">Description</Label>
212
+ <Textarea
213
+ id="mtw-provider-description"
214
+ value={description}
215
+ maxLength={2000}
216
+ rows={3}
217
+ onChange={(event) => setDescription(event.target.value)}
218
+ />
219
+ </div>
220
+ <div className="grid gap-4 sm:grid-cols-2">
221
+ <div className="grid gap-1.5">
222
+ <Label htmlFor="mtw-provider-services">Services (comma separated)</Label>
223
+ <Input
224
+ id="mtw-provider-services"
225
+ value={services}
226
+ placeholder="Reefer, Hazmat, Same day"
227
+ onChange={(event) => setServices(event.target.value)}
228
+ />
229
+ </div>
230
+ <div className="grid gap-1.5">
231
+ <Label htmlFor="mtw-provider-areas">Service areas (comma separated)</Label>
232
+ <Input
233
+ id="mtw-provider-areas"
234
+ value={serviceAreas}
235
+ placeholder="Mumbai, Pune, NH48 corridor"
236
+ onChange={(event) => setServiceAreas(event.target.value)}
237
+ />
238
+ </div>
239
+ <div className="grid gap-1.5">
240
+ <Label htmlFor="mtw-provider-contact">Contact person</Label>
241
+ <Input
242
+ id="mtw-provider-contact"
243
+ value={contactName}
244
+ onChange={(event) => setContactName(event.target.value)}
245
+ />
246
+ </div>
247
+ <div className="grid gap-1.5">
248
+ <Label htmlFor="mtw-provider-email">Email</Label>
249
+ <Input
250
+ id="mtw-provider-email"
251
+ type="email"
252
+ value={email}
253
+ onChange={(event) => setEmail(event.target.value)}
254
+ />
255
+ </div>
256
+ <div className="grid gap-1.5">
257
+ <Label htmlFor="mtw-provider-phone">Phone</Label>
258
+ <Input
259
+ id="mtw-provider-phone"
260
+ value={phone}
261
+ onChange={(event) => setPhone(event.target.value)}
262
+ />
263
+ </div>
264
+ <div className="grid gap-1.5">
265
+ <Label htmlFor="mtw-provider-website">Website</Label>
266
+ <Input
267
+ id="mtw-provider-website"
268
+ value={website}
269
+ onChange={(event) => setWebsite(event.target.value)}
270
+ />
271
+ </div>
272
+ <div className="grid gap-1.5">
273
+ <Label htmlFor="mtw-provider-rating">Your rating (0–5)</Label>
274
+ <Input
275
+ id="mtw-provider-rating"
276
+ inputMode="decimal"
277
+ value={rating}
278
+ onChange={(event) => setRating(event.target.value)}
279
+ />
280
+ </div>
281
+ </div>
282
+ <div className="flex flex-wrap gap-6">
283
+ <label className="flex items-center gap-2 text-sm" htmlFor="mtw-provider-verified">
284
+ <Switch
285
+ id="mtw-provider-verified"
286
+ checked={isVerified}
287
+ onCheckedChange={setIsVerified}
288
+ />
289
+ Verified by your team
290
+ </label>
291
+ <label className="flex items-center gap-2 text-sm" htmlFor="mtw-provider-active">
292
+ <Switch id="mtw-provider-active" checked={isActive} onCheckedChange={setIsActive} />
293
+ Active
294
+ </label>
295
+ </div>
296
+ <ResponsiveDialogFooter>
297
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
298
+ Cancel
299
+ </Button>
300
+ <Button type="submit" disabled={saving}>
301
+ {saving ? 'Saving…' : provider ? 'Save changes' : 'Add provider'}
302
+ </Button>
303
+ </ResponsiveDialogFooter>
304
+ </form>
305
+ </ResponsiveDialogContent>
306
+ </ResponsiveDialog>
307
+ );
308
+ }
309
+
310
+ // ─── Offering ────────────────────────────────────────────────────────────────
311
+
312
+ export function OfferingDialog({
313
+ open,
314
+ onOpenChange,
315
+ onDone,
316
+ provider,
317
+ offering,
318
+ }: DialogBaseProps & { provider: ServiceProvider | null; offering: ServiceOffering | null }) {
319
+ const actions = useMarketplaceActions();
320
+ const [name, setName] = useState('');
321
+ const [category, setCategory] = useState<OfferingCategory>('FULL_TRUCKLOAD');
322
+ const [description, setDescription] = useState('');
323
+ const [price, setPrice] = useState('');
324
+ const [currency, setCurrency] = useState('USD');
325
+ const [pricingUnit, setPricingUnit] = useState('');
326
+ const [transitDays, setTransitDays] = useState('');
327
+ const [availability, setAvailability] = useState<Availability>('AVAILABLE');
328
+ const [isActive, setIsActive] = useState(true);
329
+ const [saving, setSaving] = useState(false);
330
+ const [error, setError] = useState<string | null>(null);
331
+
332
+ useEffect(() => {
333
+ if (!open) return;
334
+ setName(offering?.name ?? '');
335
+ setCategory(offering?.category ?? 'FULL_TRUCKLOAD');
336
+ setDescription(offering?.description ?? '');
337
+ setPrice(offering?.price != null ? String(offering.price) : '');
338
+ setCurrency(offering?.currency ?? 'USD');
339
+ setPricingUnit(offering?.pricingUnit ?? '');
340
+ setTransitDays(offering?.transitDays != null ? String(offering.transitDays) : '');
341
+ setAvailability(offering?.availability ?? 'AVAILABLE');
342
+ setIsActive(offering?.isActive ?? true);
343
+ setError(null);
344
+ }, [open, offering]);
345
+
346
+ const submit = async () => {
347
+ if (!provider) return;
348
+ if (!name.trim()) {
349
+ setError('Enter the offering name.');
350
+ return;
351
+ }
352
+ const parsedPrice = optionalNumber(price);
353
+ if (parsedPrice === 'invalid' || (parsedPrice !== null && parsedPrice < 0)) {
354
+ setError('Price must be zero or a positive number, or left blank for "on request".');
355
+ return;
356
+ }
357
+ const parsedTransit = optionalNumber(transitDays);
358
+ if (
359
+ parsedTransit === 'invalid' ||
360
+ (parsedTransit !== null && (!Number.isInteger(parsedTransit) || parsedTransit < 0))
361
+ ) {
362
+ setError('Transit days must be a whole number.');
363
+ return;
364
+ }
365
+ if (!/^[A-Za-z]{3}$/.test(currency.trim())) {
366
+ setError('Currency must be a three-letter code such as USD or INR.');
367
+ return;
368
+ }
369
+ const input: OfferingInput = {
370
+ name: name.trim(),
371
+ category,
372
+ description: emptyToNull(description),
373
+ price: parsedPrice,
374
+ currency: currency.trim().toUpperCase(),
375
+ pricingUnit: emptyToNull(pricingUnit),
376
+ transitDays: parsedTransit,
377
+ availability,
378
+ isActive,
379
+ };
380
+ setSaving(true);
381
+ setError(null);
382
+ try {
383
+ if (offering) {
384
+ await actions.updateOffering(offering.id, input);
385
+ onDone(`Updated ${input.name}.`);
386
+ } else {
387
+ await actions.createOffering(provider.id, input);
388
+ onDone(`Added ${input.name} to ${provider.name}.`);
389
+ }
390
+ onOpenChange(false);
391
+ } catch (saveError) {
392
+ setError(errorMessage(saveError, 'Could not save the offering.'));
393
+ } finally {
394
+ setSaving(false);
395
+ }
396
+ };
397
+
398
+ return (
399
+ <ResponsiveDialog open={open} onOpenChange={onOpenChange}>
400
+ <ResponsiveDialogContent className="sm:max-w-lg">
401
+ <ResponsiveDialogHeader>
402
+ <ResponsiveDialogTitle>
403
+ {offering ? 'Edit offering' : 'Add an offering'}
404
+ </ResponsiveDialogTitle>
405
+ <ResponsiveDialogDescription>
406
+ {provider ? `A service ${provider.name} offers, with an indicative price.` : null}
407
+ </ResponsiveDialogDescription>
408
+ </ResponsiveDialogHeader>
409
+ <form
410
+ className="grid gap-4"
411
+ onSubmit={(event) => {
412
+ event.preventDefault();
413
+ void submit();
414
+ }}
415
+ >
416
+ <FormError message={error} />
417
+ <div className="grid gap-1.5">
418
+ <Label htmlFor="mtw-offering-name">Name</Label>
419
+ <Input
420
+ id="mtw-offering-name"
421
+ value={name}
422
+ maxLength={255}
423
+ onChange={(event) => setName(event.target.value)}
424
+ required
425
+ />
426
+ </div>
427
+ <div className="grid gap-4 sm:grid-cols-2">
428
+ <div className="grid gap-1.5">
429
+ <Label htmlFor="mtw-offering-category">Category</Label>
430
+ <Select
431
+ value={category}
432
+ onValueChange={(value) => setCategory(value as OfferingCategory)}
433
+ >
434
+ <SelectTrigger id="mtw-offering-category">
435
+ <SelectValue />
436
+ </SelectTrigger>
437
+ <SelectContent>
438
+ {OFFERING_CATEGORIES.map((option) => (
439
+ <SelectItem key={option} value={option}>
440
+ {humanizeEnum(option)}
441
+ </SelectItem>
442
+ ))}
443
+ </SelectContent>
444
+ </Select>
445
+ </div>
446
+ <div className="grid gap-1.5">
447
+ <Label htmlFor="mtw-offering-availability">Availability</Label>
448
+ <Select
449
+ value={availability}
450
+ onValueChange={(value) => setAvailability(value as Availability)}
451
+ >
452
+ <SelectTrigger id="mtw-offering-availability">
453
+ <SelectValue />
454
+ </SelectTrigger>
455
+ <SelectContent>
456
+ {AVAILABILITIES.map((option) => (
457
+ <SelectItem key={option} value={option}>
458
+ {humanizeEnum(option)}
459
+ </SelectItem>
460
+ ))}
461
+ </SelectContent>
462
+ </Select>
463
+ </div>
464
+ <div className="grid gap-1.5">
465
+ <Label htmlFor="mtw-offering-price">Price (blank = on request)</Label>
466
+ <Input
467
+ id="mtw-offering-price"
468
+ inputMode="decimal"
469
+ value={price}
470
+ onChange={(event) => setPrice(event.target.value)}
471
+ />
472
+ </div>
473
+ <div className="grid gap-1.5">
474
+ <Label htmlFor="mtw-offering-currency">Currency</Label>
475
+ <Input
476
+ id="mtw-offering-currency"
477
+ value={currency}
478
+ maxLength={3}
479
+ onChange={(event) => setCurrency(event.target.value)}
480
+ />
481
+ </div>
482
+ <div className="grid gap-1.5">
483
+ <Label htmlFor="mtw-offering-unit">Priced per</Label>
484
+ <Input
485
+ id="mtw-offering-unit"
486
+ value={pricingUnit}
487
+ placeholder="load, km, pallet…"
488
+ maxLength={40}
489
+ onChange={(event) => setPricingUnit(event.target.value)}
490
+ />
491
+ </div>
492
+ <div className="grid gap-1.5">
493
+ <Label htmlFor="mtw-offering-transit">Transit days</Label>
494
+ <Input
495
+ id="mtw-offering-transit"
496
+ inputMode="numeric"
497
+ value={transitDays}
498
+ onChange={(event) => setTransitDays(event.target.value)}
499
+ />
500
+ </div>
501
+ </div>
502
+ <div className="grid gap-1.5">
503
+ <Label htmlFor="mtw-offering-description">Description</Label>
504
+ <Textarea
505
+ id="mtw-offering-description"
506
+ value={description}
507
+ maxLength={2000}
508
+ rows={2}
509
+ onChange={(event) => setDescription(event.target.value)}
510
+ />
511
+ </div>
512
+ <label className="flex items-center gap-2 text-sm" htmlFor="mtw-offering-active">
513
+ <Switch id="mtw-offering-active" checked={isActive} onCheckedChange={setIsActive} />
514
+ Active
515
+ </label>
516
+ <ResponsiveDialogFooter>
517
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
518
+ Cancel
519
+ </Button>
520
+ <Button type="submit" disabled={saving}>
521
+ {saving ? 'Saving…' : offering ? 'Save changes' : 'Add offering'}
522
+ </Button>
523
+ </ResponsiveDialogFooter>
524
+ </form>
525
+ </ResponsiveDialogContent>
526
+ </ResponsiveDialog>
527
+ );
528
+ }
529
+
530
+ // ─── Assign to shipment ──────────────────────────────────────────────────────
531
+
532
+ export function AssignShipmentDialog({
533
+ open,
534
+ onOpenChange,
535
+ onDone,
536
+ provider,
537
+ }: DialogBaseProps & { provider: ServiceProvider | null }) {
538
+ const actions = useMarketplaceActions();
539
+ const [search, setSearch] = useState('');
540
+ const [shipmentId, setShipmentId] = useState('');
541
+ const [saving, setSaving] = useState(false);
542
+ const [error, setError] = useState<string | null>(null);
543
+ const { shipments, total, isLoading } = useShipmentOptions({ search, limit: 20 });
544
+
545
+ useEffect(() => {
546
+ if (!open) return;
547
+ setSearch('');
548
+ setShipmentId('');
549
+ setError(null);
550
+ }, [open]);
551
+
552
+ const selected = shipments.find((shipment) => shipment.id === shipmentId) ?? null;
553
+
554
+ const submit = async () => {
555
+ if (!provider) return;
556
+ if (!shipmentId) {
557
+ setError('Choose the shipment this provider will carry.');
558
+ return;
559
+ }
560
+ setSaving(true);
561
+ setError(null);
562
+ try {
563
+ const result = await actions.assignShipment(shipmentId, provider.id);
564
+ onDone(`${provider.name} is now the carrier for shipment ${result.shipmentNumber}.`);
565
+ onOpenChange(false);
566
+ } catch (saveError) {
567
+ setError(errorMessage(saveError, 'Could not assign the provider.'));
568
+ } finally {
569
+ setSaving(false);
570
+ }
571
+ };
572
+
573
+ return (
574
+ <ResponsiveDialog open={open} onOpenChange={onOpenChange}>
575
+ <ResponsiveDialogContent className="sm:max-w-md">
576
+ <ResponsiveDialogHeader>
577
+ <ResponsiveDialogTitle>Assign to a shipment</ResponsiveDialogTitle>
578
+ <ResponsiveDialogDescription>
579
+ {provider
580
+ ? `Make ${provider.name} the carrier of a shipment.${
581
+ provider.isVerified && provider.isActive
582
+ ? ''
583
+ : ' This provider is not verified or not active — check before you commit freight.'
584
+ }`
585
+ : null}
586
+ </ResponsiveDialogDescription>
587
+ </ResponsiveDialogHeader>
588
+ <form
589
+ className="grid gap-4"
590
+ onSubmit={(event) => {
591
+ event.preventDefault();
592
+ void submit();
593
+ }}
594
+ >
595
+ <FormError message={error} />
596
+ <div className="grid gap-1.5">
597
+ <Label htmlFor="mtw-assign-shipment">Shipment</Label>
598
+ <EntityCombobox
599
+ id="mtw-assign-shipment"
600
+ value={shipmentId}
601
+ onChange={setShipmentId}
602
+ options={shipments}
603
+ loading={isLoading}
604
+ required
605
+ getId={(shipment) => shipment.id}
606
+ getLabel={(shipment) => shipment.shipmentNumber}
607
+ renderOption={(shipment) => (
608
+ <>
609
+ {shipment.shipmentNumber}{' '}
610
+ <span className="text-text-tertiary">({humanizeEnum(shipment.status)})</span>
611
+ </>
612
+ )}
613
+ selectedOption={selected}
614
+ placeholder="Search shipments"
615
+ searchPlaceholder="Search by shipment number…"
616
+ emptyText="No shipments found."
617
+ onSearchChange={setSearch}
618
+ totalCount={total}
619
+ />
620
+ </div>
621
+ <ResponsiveDialogFooter>
622
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
623
+ Cancel
624
+ </Button>
625
+ <Button type="submit" disabled={saving}>
626
+ {saving ? 'Assigning…' : 'Assign carrier'}
627
+ </Button>
628
+ </ResponsiveDialogFooter>
629
+ </form>
630
+ </ResponsiveDialogContent>
631
+ </ResponsiveDialog>
632
+ );
633
+ }