@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
|
@@ -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
|
+
}
|