@burdenoff/microfe-movethewheels 2026.920.1 → 2026.920.3
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/components/CustomerPicker.js +260 -0
- package/dist/components/ShippingQuoteResults.js +159 -106
- package/dist/hooks/useShippingCarriers.js +1 -1
- package/dist/movethewheels/components/CustomerPicker.d.ts +17 -0
- package/dist/movethewheels/hooks/useShippingCarriers.d.ts +17 -0
- package/dist/movethewheels/utils/navigation.d.ts +14 -0
- package/dist/pages/CreateOrderPage.js +221 -216
- package/dist/pages/CustomerDetailsPage.js +220 -220
- package/dist/pages/ShippingRatesPage.js +84 -16
- package/dist/utils/navigation.js +22 -1
- package/package.json +1 -1
- package/src/movethewheels/components/CustomerPicker.tsx +307 -0
- package/src/movethewheels/components/ShippingQuoteResults.tsx +62 -2
- package/src/movethewheels/components/maps/leaflet-setup.ts +8 -1
- package/src/movethewheels/hooks/useMultiModalShipments.ts +6 -1
- package/src/movethewheels/hooks/useShippingCarriers.ts +28 -0
- package/src/movethewheels/pages/CreateOrderPage.tsx +84 -31
- package/src/movethewheels/pages/CustomerDetailsPage.tsx +15 -6
- package/src/movethewheels/pages/ShippingRatesPage.tsx +85 -0
- package/src/movethewheels/utils/navigation.ts +32 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useEffect, useState } from 'react';
|
|
8
|
-
import { useParams } from 'react-router';
|
|
8
|
+
import { useParams, useSearchParams } from 'react-router';
|
|
9
9
|
import { Package, ArrowLeft, Save, Plus, Trash2, Users, MapPin, CalendarClock } from 'lucide-react';
|
|
10
10
|
import { PageLayout, EmptyState } from '../components/PageLayout';
|
|
11
11
|
import { FormError } from '../components/FormError';
|
|
@@ -27,9 +27,11 @@ import {
|
|
|
27
27
|
} from '../components/ui';
|
|
28
28
|
import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
29
29
|
import { useOrder, useOrders } from '../hooks/useOrders';
|
|
30
|
-
import { useCustomers } from '../hooks/useCustomers';
|
|
30
|
+
import { useCustomer, useCustomers } from '../hooks/useCustomers';
|
|
31
31
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
32
|
-
import { joinPath } from '../utils/navigation';
|
|
32
|
+
import { joinPath, buildCustomerCreatePath } from '../utils/navigation';
|
|
33
|
+
import { CustomerPicker } from '../components/CustomerPicker';
|
|
34
|
+
import type { Customer } from '../types';
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* `<input type="datetime-local">` needs a `YYYY-MM-DDTHH:mm` string in LOCAL
|
|
@@ -49,15 +51,25 @@ function toDateTimeLocalValue(date: Date | undefined): string {
|
|
|
49
51
|
|
|
50
52
|
export default function CreateOrderPage() {
|
|
51
53
|
const { orderId } = useParams<{ orderId: string }>();
|
|
54
|
+
const [searchParams] = useSearchParams();
|
|
55
|
+
const prefilledCustomerId = searchParams.get('customerId')?.trim() || undefined;
|
|
52
56
|
const { basePath, navigate } = useMoveTheWheels();
|
|
53
57
|
const { order } = useOrder(orderId);
|
|
54
58
|
const { createOrder, updateOrder } = useOrders();
|
|
55
|
-
const {
|
|
59
|
+
const {
|
|
60
|
+
customers,
|
|
61
|
+
createCustomer,
|
|
62
|
+
isLoading: customersLoading,
|
|
63
|
+
error: customersError,
|
|
64
|
+
refetch: refetchCustomers,
|
|
65
|
+
} = useCustomers({ limit: 100 });
|
|
66
|
+
const { customer: linkedCustomer } = useCustomer(prefilledCustomerId ?? order?.customerId);
|
|
56
67
|
|
|
57
68
|
const isEditing = !!orderId && !!order;
|
|
58
69
|
|
|
70
|
+
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
|
59
71
|
const [formData, setFormData] = useState({
|
|
60
|
-
customerId: order?.customerId || '',
|
|
72
|
+
customerId: prefilledCustomerId || order?.customerId || '',
|
|
61
73
|
priority: order?.priority || 'normal',
|
|
62
74
|
pickupAddress: {
|
|
63
75
|
street: order?.pickupAddress.street || '',
|
|
@@ -81,6 +93,18 @@ export default function CreateOrderPage() {
|
|
|
81
93
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
82
94
|
const [formError, setFormError] = useState<string | null>(null);
|
|
83
95
|
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!linkedCustomer) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setSelectedCustomer((previous) =>
|
|
101
|
+
previous?.id === linkedCustomer.id ? previous : linkedCustomer
|
|
102
|
+
);
|
|
103
|
+
setFormData((previous) =>
|
|
104
|
+
previous.customerId ? previous : { ...previous, customerId: linkedCustomer.id }
|
|
105
|
+
);
|
|
106
|
+
}, [linkedCustomer]);
|
|
107
|
+
|
|
84
108
|
// Prefill the form from the loaded order when editing. `order` arrives
|
|
85
109
|
// asynchronously after the initial render, so the useState initializers alone
|
|
86
110
|
// leave the fields blank until this syncs them.
|
|
@@ -112,15 +136,24 @@ export default function CreateOrderPage() {
|
|
|
112
136
|
}, [order]);
|
|
113
137
|
|
|
114
138
|
const goBack = () => {
|
|
115
|
-
if (navigate) {
|
|
116
|
-
|
|
139
|
+
if (!navigate) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!isEditing && prefilledCustomerId) {
|
|
143
|
+
navigate(joinPath(basePath, `customers/${prefilledCustomerId}`));
|
|
144
|
+
return;
|
|
117
145
|
}
|
|
146
|
+
navigate(joinPath(basePath, 'orders'));
|
|
118
147
|
};
|
|
119
148
|
|
|
149
|
+
const getSelectedCustomer = () =>
|
|
150
|
+
selectedCustomer ?? customers.find((customer) => customer.id === formData.customerId);
|
|
151
|
+
|
|
120
152
|
const validate = (): string | null => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
153
|
+
if (!formData.customerId || !getSelectedCustomer()) {
|
|
154
|
+
return customersError
|
|
155
|
+
? 'Customers could not be loaded. Retry or create a customer before continuing.'
|
|
156
|
+
: 'Select a customer for this order.';
|
|
124
157
|
}
|
|
125
158
|
if (!formData.pickupAddress.street.trim()) {
|
|
126
159
|
return 'Enter a pickup street address.';
|
|
@@ -147,9 +180,13 @@ export default function CreateOrderPage() {
|
|
|
147
180
|
return;
|
|
148
181
|
}
|
|
149
182
|
|
|
150
|
-
const selectedCustomer =
|
|
183
|
+
const selectedCustomer = getSelectedCustomer();
|
|
151
184
|
if (!selectedCustomer) {
|
|
152
|
-
setFormError(
|
|
185
|
+
setFormError(
|
|
186
|
+
customersError
|
|
187
|
+
? 'Customers could not be loaded. Retry or create a customer before continuing.'
|
|
188
|
+
: 'Select a customer for this order.'
|
|
189
|
+
);
|
|
153
190
|
return;
|
|
154
191
|
}
|
|
155
192
|
|
|
@@ -242,6 +279,14 @@ export default function CreateOrderPage() {
|
|
|
242
279
|
}));
|
|
243
280
|
};
|
|
244
281
|
|
|
282
|
+
const goToCustomerCreate = () => {
|
|
283
|
+
if (!navigate) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const orderPath = isEditing && orderId ? `orders/${orderId}/edit` : 'orders/new';
|
|
287
|
+
navigate(joinPath(basePath, buildCustomerCreatePath(orderPath)));
|
|
288
|
+
};
|
|
289
|
+
|
|
245
290
|
return (
|
|
246
291
|
<PageLayout
|
|
247
292
|
title={isEditing ? 'Edit Order' : 'Create Order'}
|
|
@@ -253,7 +298,7 @@ export default function CreateOrderPage() {
|
|
|
253
298
|
<ArrowLeft size={16} />
|
|
254
299
|
Cancel
|
|
255
300
|
</Button>
|
|
256
|
-
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
|
301
|
+
<Button onClick={handleSubmit} disabled={isSubmitting || customersLoading}>
|
|
257
302
|
<Save size={16} />
|
|
258
303
|
{isSubmitting ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Order'}
|
|
259
304
|
</Button>
|
|
@@ -304,24 +349,32 @@ export default function CreateOrderPage() {
|
|
|
304
349
|
<h2 className="text-base font-semibold text-text-primary mb-4">Order Details</h2>
|
|
305
350
|
<div className="space-y-4">
|
|
306
351
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
352
|
+
<CustomerPicker
|
|
353
|
+
customers={customers}
|
|
354
|
+
value={formData.customerId}
|
|
355
|
+
onValueChange={(value) => {
|
|
356
|
+
if (!value) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
updateField('customerId', value);
|
|
360
|
+
setSelectedCustomer(
|
|
361
|
+
customers.find((customer) => customer.id === value) ??
|
|
362
|
+
(selectedCustomer?.id === value ? selectedCustomer : null)
|
|
363
|
+
);
|
|
364
|
+
setFormError(null);
|
|
365
|
+
}}
|
|
366
|
+
onCreated={(customer) => {
|
|
367
|
+
setSelectedCustomer(customer);
|
|
368
|
+
updateField('customerId', customer.id);
|
|
369
|
+
setFormError(null);
|
|
370
|
+
}}
|
|
371
|
+
createCustomer={createCustomer}
|
|
372
|
+
isLoading={customersLoading}
|
|
373
|
+
error={customersError}
|
|
374
|
+
onRetry={refetchCustomers}
|
|
375
|
+
onOpenCustomers={goToCustomerCreate}
|
|
376
|
+
selectedCustomer={selectedCustomer}
|
|
377
|
+
/>
|
|
325
378
|
<div className="space-y-2">
|
|
326
379
|
<Label htmlFor="priority">Priority</Label>
|
|
327
380
|
<Select
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useEffect, useState } from 'react';
|
|
6
|
-
import { useParams } from 'react-router';
|
|
6
|
+
import { useParams, useSearchParams } from 'react-router';
|
|
7
7
|
import {
|
|
8
8
|
Users,
|
|
9
9
|
ArrowLeft,
|
|
@@ -40,7 +40,7 @@ import { useCustomer, useCustomers } from '../hooks/useCustomers';
|
|
|
40
40
|
import { useOrders } from '../hooks/useOrders';
|
|
41
41
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
42
42
|
import { formatAddress, formatCurrency, formatDate } from '../utils/formatters';
|
|
43
|
-
import { joinPath } from '../utils/navigation';
|
|
43
|
+
import { joinPath, buildOrderCustomerPath, getSafeCustomerReturnPath } from '../utils/navigation';
|
|
44
44
|
import { FormError } from '../components/FormError';
|
|
45
45
|
import { Notice, useNotice } from '../components/Notice';
|
|
46
46
|
|
|
@@ -62,6 +62,8 @@ function safeHttpUrl(raw: string | undefined): string | null {
|
|
|
62
62
|
|
|
63
63
|
function NewCustomerForm() {
|
|
64
64
|
const { basePath, navigate } = useMoveTheWheels();
|
|
65
|
+
const [searchParams] = useSearchParams();
|
|
66
|
+
const returnTo = getSafeCustomerReturnPath(searchParams.get('returnTo'));
|
|
65
67
|
const { createCustomer } = useCustomers();
|
|
66
68
|
const [form, setForm] = useState({
|
|
67
69
|
name: '',
|
|
@@ -91,7 +93,10 @@ function NewCustomerForm() {
|
|
|
91
93
|
phone: form.phone.trim() || undefined,
|
|
92
94
|
company: form.company.trim() || undefined,
|
|
93
95
|
});
|
|
94
|
-
|
|
96
|
+
const destination = returnTo
|
|
97
|
+
? buildOrderCustomerPath(returnTo, created.id)
|
|
98
|
+
: `customers/${created.id}`;
|
|
99
|
+
navigate?.(joinPath(basePath, destination));
|
|
95
100
|
} catch (err) {
|
|
96
101
|
console.error('Failed to create customer:', err);
|
|
97
102
|
setError(err instanceof Error ? err.message : 'Failed to create customer. Please try again.');
|
|
@@ -330,7 +335,7 @@ export default function CustomerDetailsPage() {
|
|
|
330
335
|
label: 'Book a delivery for this customer',
|
|
331
336
|
description: 'Start a new order pre-filled for this account.',
|
|
332
337
|
icon: <FileText size={16} />,
|
|
333
|
-
onClick: () => goTo('orders/new'),
|
|
338
|
+
onClick: () => goTo(buildOrderCustomerPath('orders/new', customer.id)),
|
|
334
339
|
},
|
|
335
340
|
{
|
|
336
341
|
id: 'edit',
|
|
@@ -475,7 +480,11 @@ export default function CustomerDetailsPage() {
|
|
|
475
480
|
<CardTitle className="flex items-center gap-2">
|
|
476
481
|
<Package size={18} /> Orders
|
|
477
482
|
</CardTitle>
|
|
478
|
-
<Button
|
|
483
|
+
<Button
|
|
484
|
+
variant="outline"
|
|
485
|
+
size="sm"
|
|
486
|
+
onClick={() => goTo(buildOrderCustomerPath('orders/new', customer.id))}
|
|
487
|
+
>
|
|
479
488
|
<FileText size={14} /> New order
|
|
480
489
|
</Button>
|
|
481
490
|
</CardHeader>
|
|
@@ -508,7 +517,7 @@ export default function CustomerDetailsPage() {
|
|
|
508
517
|
<p className="text-text-secondary">No orders yet for this customer</p>
|
|
509
518
|
<button
|
|
510
519
|
type="button"
|
|
511
|
-
onClick={() => goTo('orders/new')}
|
|
520
|
+
onClick={() => goTo(buildOrderCustomerPath('orders/new', customer.id))}
|
|
512
521
|
className="mt-1 text-sm text-primary hover:underline"
|
|
513
522
|
>
|
|
514
523
|
Book their first delivery
|
|
@@ -48,6 +48,9 @@ export default function ShippingRatesPage() {
|
|
|
48
48
|
const [width, setWidth] = useState('');
|
|
49
49
|
const [height, setHeight] = useState('');
|
|
50
50
|
const [cod, setCod] = useState(false);
|
|
51
|
+
const [distance, setDistance] = useState('');
|
|
52
|
+
const [costPerKm, setCostPerKm] = useState('');
|
|
53
|
+
const [fixedCost, setFixedCost] = useState('');
|
|
51
54
|
const [isLoading, setIsLoading] = useState(false);
|
|
52
55
|
const [error, setError] = useState<string | null>(null);
|
|
53
56
|
const [result, setResult] = useState<ShippingQuoteResult | null>(null);
|
|
@@ -82,11 +85,41 @@ export default function ShippingRatesPage() {
|
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
const fleetDistance = optionalNumber(distance);
|
|
89
|
+
const fleetCostPerKm = optionalNumber(costPerKm);
|
|
90
|
+
const fleetFixed = optionalNumber(fixedCost);
|
|
91
|
+
const fleetGiven = fleetDistance !== undefined || fleetCostPerKm !== undefined;
|
|
92
|
+
if (
|
|
93
|
+
fleetGiven &&
|
|
94
|
+
(!fleetDistance || fleetDistance <= 0 || !fleetCostPerKm || fleetCostPerKm <= 0)
|
|
95
|
+
) {
|
|
96
|
+
setError(
|
|
97
|
+
tr(
|
|
98
|
+
'movethewheels.shipping.form.ownFleetError',
|
|
99
|
+
'To compare with your own vehicle, enter both the distance and your cost per km'
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (fleetFixed !== undefined && fleetFixed < 0) {
|
|
105
|
+
setError(tr('movethewheels.shipping.form.fixedCostError', 'Fixed cost cannot be negative'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
const input: ShippingQuoteInput = {
|
|
86
110
|
pickupPostcode: pickup,
|
|
87
111
|
deliveryPostcode: delivery,
|
|
88
112
|
weightKg,
|
|
89
113
|
cod,
|
|
114
|
+
...(fleetGiven && fleetDistance && fleetCostPerKm
|
|
115
|
+
? {
|
|
116
|
+
ownFleet: {
|
|
117
|
+
distanceKm: fleetDistance,
|
|
118
|
+
costPerKm: fleetCostPerKm,
|
|
119
|
+
...(fleetFixed ? { fixedCost: fleetFixed } : {}),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
90
123
|
...(given === 3 ? { lengthCm: dims[0], widthCm: dims[1], heightCm: dims[2] } : {}),
|
|
91
124
|
};
|
|
92
125
|
|
|
@@ -193,6 +226,58 @@ export default function ShippingRatesPage() {
|
|
|
193
226
|
/>
|
|
194
227
|
</div>
|
|
195
228
|
</div>
|
|
229
|
+
<fieldset className="space-y-3 rounded-lg border p-4">
|
|
230
|
+
<legend className="px-1 text-sm font-medium">
|
|
231
|
+
{tr(
|
|
232
|
+
'movethewheels.shipping.form.ownFleetTitle',
|
|
233
|
+
'Compare with your own vehicle (optional)'
|
|
234
|
+
)}
|
|
235
|
+
</legend>
|
|
236
|
+
<p className="text-sm text-text-secondary">
|
|
237
|
+
{tr(
|
|
238
|
+
'movethewheels.shipping.form.ownFleetHelp',
|
|
239
|
+
'Enter the road distance and what it costs you to run a vehicle per km, and we will show it next to the courier prices.'
|
|
240
|
+
)}
|
|
241
|
+
</p>
|
|
242
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
243
|
+
<div className="space-y-1">
|
|
244
|
+
<Label htmlFor="rate-distance">
|
|
245
|
+
{tr('movethewheels.shipping.form.distance', 'Distance (km)')}
|
|
246
|
+
</Label>
|
|
247
|
+
<Input
|
|
248
|
+
id="rate-distance"
|
|
249
|
+
inputMode="decimal"
|
|
250
|
+
value={distance}
|
|
251
|
+
onChange={(e) => setDistance(e.target.value)}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="space-y-1">
|
|
255
|
+
<Label htmlFor="rate-cost-per-km">
|
|
256
|
+
{tr('movethewheels.shipping.form.costPerKm', 'Your cost per km (₹)')}
|
|
257
|
+
</Label>
|
|
258
|
+
<Input
|
|
259
|
+
id="rate-cost-per-km"
|
|
260
|
+
inputMode="decimal"
|
|
261
|
+
value={costPerKm}
|
|
262
|
+
onChange={(e) => setCostPerKm(e.target.value)}
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="space-y-1">
|
|
266
|
+
<Label htmlFor="rate-fixed-cost">
|
|
267
|
+
{tr(
|
|
268
|
+
'movethewheels.shipping.form.fixedCost',
|
|
269
|
+
'Fixed cost per trip (₹, optional)'
|
|
270
|
+
)}
|
|
271
|
+
</Label>
|
|
272
|
+
<Input
|
|
273
|
+
id="rate-fixed-cost"
|
|
274
|
+
inputMode="decimal"
|
|
275
|
+
value={fixedCost}
|
|
276
|
+
onChange={(e) => setFixedCost(e.target.value)}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</fieldset>
|
|
196
281
|
<div className="flex items-center gap-2">
|
|
197
282
|
<Switch id="rate-cod" checked={cod} onCheckedChange={setCod} />
|
|
198
283
|
<Label htmlFor="rate-cod">
|
|
@@ -68,3 +68,35 @@ export function parseQueryParams(search: string): Record<string, string> {
|
|
|
68
68
|
});
|
|
69
69
|
return params;
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
const CUSTOMER_RETURN_PATHS = [/^orders\/new$/, /^orders\/[^/?#]+\/edit$/];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Return only an internal order destination for customer create-and-return flows.
|
|
76
|
+
* Query strings are intentionally discarded so callers cannot smuggle a second
|
|
77
|
+
* return target or an open redirect through the customer form.
|
|
78
|
+
*/
|
|
79
|
+
export function getSafeCustomerReturnPath(value: string | null | undefined): string | null {
|
|
80
|
+
if (!value || value.length > 200 || value.includes('://') || value.startsWith('/')) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const [pathname] = value.split(/[?#]/, 1);
|
|
85
|
+
return CUSTOMER_RETURN_PATHS.some((pattern) => pattern.test(pathname)) ? pathname : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build an order route with a customer preselected after a customer is created.
|
|
90
|
+
*/
|
|
91
|
+
export function buildOrderCustomerPath(orderPath: string, customerId: string): string {
|
|
92
|
+
const safeOrderPath = getSafeCustomerReturnPath(orderPath);
|
|
93
|
+
return safeOrderPath ? buildUrl(safeOrderPath, { customerId }) : orderPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the local customer creation route while preserving a safe order return path.
|
|
98
|
+
*/
|
|
99
|
+
export function buildCustomerCreatePath(orderPath: string): string {
|
|
100
|
+
const safeOrderPath = getSafeCustomerReturnPath(orderPath);
|
|
101
|
+
return safeOrderPath ? buildUrl('customers/new', { returnTo: safeOrderPath }) : 'customers/new';
|
|
102
|
+
}
|