@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.
- package/dist/MoveTheWheelsRoot.js +130 -122
- package/dist/MoveTheWheelsRoutes.js +55 -51
- package/dist/components/ProofOfDeliveryCard.js +1 -1
- package/dist/components/UpcomingDemandPanel.js +1 -1
- package/dist/components/marketplace/MarketplaceDialogs.js +524 -0
- package/dist/generated/wspace-types.d.ts +30737 -8505
- package/dist/hooks/useMarketplace.js +265 -0
- package/dist/movethewheels/components/marketplace/MarketplaceDialogs.d.ts +18 -0
- package/dist/movethewheels/hooks/useMarketplace.d.ts +121 -0
- package/dist/movethewheels/pages/MarketplacePage.d.ts +1 -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/ContainersPage.js +3 -3
- package/dist/pages/MarketplacePage.js +503 -0
- package/dist/pages/TrackingPage.js +111 -56
- package/package.json +2 -1
- 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/ProofOfDeliveryCard.tsx +1 -1
- package/src/movethewheels/components/UpcomingDemandPanel.tsx +2 -2
- package/src/movethewheels/components/marketplace/MarketplaceDialogs.tsx +633 -0
- package/src/movethewheels/hooks/useMarketplace.ts +526 -0
- package/src/movethewheels/pages/ContainersPage.tsx +3 -3
- package/src/movethewheels/pages/MarketplacePage.tsx +571 -0
- 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-
|
|
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-
|
|
216
|
-
: 'text-
|
|
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
|
+
}
|