@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
|
@@ -14,15 +14,15 @@ var _ = /^[1-9]\d{5}$/, v = (e) => {
|
|
|
14
14
|
return e.trim() !== "" && Number.isFinite(t) ? t : void 0;
|
|
15
15
|
};
|
|
16
16
|
function y() {
|
|
17
|
-
let y = t(), b = p(), [x, S] = m(""), [C, w] = m(""), [T, E] = m(""), [D, O] = m(""), [k, A] = m(""), [j, M] = m(""), [N, P] = m(!1), [F, I] = m(
|
|
18
|
-
e.preventDefault(),
|
|
17
|
+
let y = t(), b = p(), [x, S] = m(""), [C, w] = m(""), [T, E] = m(""), [D, O] = m(""), [k, A] = m(""), [j, M] = m(""), [N, P] = m(!1), [F, I] = m(""), [L, R] = m(""), [z, B] = m(""), [V, H] = m(!1), [U, W] = m(null), [G, K] = m(null), q = async (e) => {
|
|
18
|
+
e.preventDefault(), W(null);
|
|
19
19
|
let t = v(T);
|
|
20
20
|
if (!_.test(x) || !_.test(C)) {
|
|
21
|
-
|
|
21
|
+
W(y("movethewheels.shipping.form.postcodeError", "Enter valid 6-digit pickup and delivery postcodes"));
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
if (t === void 0 || t <= 0) {
|
|
25
|
-
|
|
25
|
+
W(y("movethewheels.shipping.form.weightError", "Enter the package weight in kg"));
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
let n = [
|
|
@@ -31,27 +31,41 @@ function y() {
|
|
|
31
31
|
v(j)
|
|
32
32
|
], r = n.filter((e) => e !== void 0).length;
|
|
33
33
|
if (r !== 0 && r !== 3) {
|
|
34
|
-
|
|
34
|
+
W(y("movethewheels.shipping.form.dimensionsError", "Enter length, width and height together, or leave all three empty"));
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
|
-
let i =
|
|
37
|
+
let i = v(F), a = v(L), o = v(z), s = i !== void 0 || a !== void 0;
|
|
38
|
+
if (s && (!i || i <= 0 || !a || a <= 0)) {
|
|
39
|
+
W(y("movethewheels.shipping.form.ownFleetError", "To compare with your own vehicle, enter both the distance and your cost per km"));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (o !== void 0 && o < 0) {
|
|
43
|
+
W(y("movethewheels.shipping.form.fixedCostError", "Fixed cost cannot be negative"));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let c = {
|
|
38
47
|
pickupPostcode: x,
|
|
39
48
|
deliveryPostcode: C,
|
|
40
49
|
weightKg: t,
|
|
41
50
|
cod: N,
|
|
51
|
+
...s && i && a ? { ownFleet: {
|
|
52
|
+
distanceKm: i,
|
|
53
|
+
costPerKm: a,
|
|
54
|
+
...o ? { fixedCost: o } : {}
|
|
55
|
+
} } : {},
|
|
42
56
|
...r === 3 ? {
|
|
43
57
|
lengthCm: n[0],
|
|
44
58
|
widthCm: n[1],
|
|
45
59
|
heightCm: n[2]
|
|
46
60
|
} : {}
|
|
47
61
|
};
|
|
48
|
-
|
|
62
|
+
H(!0);
|
|
49
63
|
try {
|
|
50
|
-
|
|
64
|
+
K(await b(c));
|
|
51
65
|
} catch (e) {
|
|
52
|
-
|
|
66
|
+
K(null), W(e instanceof Error ? e.message : y("movethewheels.shipping.form.failed", "Could not fetch rates"));
|
|
53
67
|
} finally {
|
|
54
|
-
|
|
68
|
+
H(!1);
|
|
55
69
|
}
|
|
56
70
|
};
|
|
57
71
|
return /* @__PURE__ */ h(u, {
|
|
@@ -61,7 +75,7 @@ function y() {
|
|
|
61
75
|
children: /* @__PURE__ */ g("div", {
|
|
62
76
|
className: "space-y-6",
|
|
63
77
|
children: [/* @__PURE__ */ g(r, { children: [/* @__PURE__ */ h(a, { children: /* @__PURE__ */ h(o, { children: y("movethewheels.shipping.form.title", "Shipment details") }) }), /* @__PURE__ */ h(i, { children: /* @__PURE__ */ g("form", {
|
|
64
|
-
onSubmit: (e) => void
|
|
78
|
+
onSubmit: (e) => void q(e),
|
|
65
79
|
className: "space-y-4",
|
|
66
80
|
children: [
|
|
67
81
|
/* @__PURE__ */ g("div", {
|
|
@@ -148,6 +162,60 @@ function y() {
|
|
|
148
162
|
})
|
|
149
163
|
]
|
|
150
164
|
}),
|
|
165
|
+
/* @__PURE__ */ g("fieldset", {
|
|
166
|
+
className: "space-y-3 rounded-lg border p-4",
|
|
167
|
+
children: [
|
|
168
|
+
/* @__PURE__ */ h("legend", {
|
|
169
|
+
className: "px-1 text-sm font-medium",
|
|
170
|
+
children: y("movethewheels.shipping.form.ownFleetTitle", "Compare with your own vehicle (optional)")
|
|
171
|
+
}),
|
|
172
|
+
/* @__PURE__ */ h("p", {
|
|
173
|
+
className: "text-sm text-text-secondary",
|
|
174
|
+
children: y("movethewheels.shipping.form.ownFleetHelp", "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.")
|
|
175
|
+
}),
|
|
176
|
+
/* @__PURE__ */ g("div", {
|
|
177
|
+
className: "grid gap-3 sm:grid-cols-3",
|
|
178
|
+
children: [
|
|
179
|
+
/* @__PURE__ */ g("div", {
|
|
180
|
+
className: "space-y-1",
|
|
181
|
+
children: [/* @__PURE__ */ h(c, {
|
|
182
|
+
htmlFor: "rate-distance",
|
|
183
|
+
children: y("movethewheels.shipping.form.distance", "Distance (km)")
|
|
184
|
+
}), /* @__PURE__ */ h(s, {
|
|
185
|
+
id: "rate-distance",
|
|
186
|
+
inputMode: "decimal",
|
|
187
|
+
value: F,
|
|
188
|
+
onChange: (e) => I(e.target.value)
|
|
189
|
+
})]
|
|
190
|
+
}),
|
|
191
|
+
/* @__PURE__ */ g("div", {
|
|
192
|
+
className: "space-y-1",
|
|
193
|
+
children: [/* @__PURE__ */ h(c, {
|
|
194
|
+
htmlFor: "rate-cost-per-km",
|
|
195
|
+
children: y("movethewheels.shipping.form.costPerKm", "Your cost per km (₹)")
|
|
196
|
+
}), /* @__PURE__ */ h(s, {
|
|
197
|
+
id: "rate-cost-per-km",
|
|
198
|
+
inputMode: "decimal",
|
|
199
|
+
value: L,
|
|
200
|
+
onChange: (e) => R(e.target.value)
|
|
201
|
+
})]
|
|
202
|
+
}),
|
|
203
|
+
/* @__PURE__ */ g("div", {
|
|
204
|
+
className: "space-y-1",
|
|
205
|
+
children: [/* @__PURE__ */ h(c, {
|
|
206
|
+
htmlFor: "rate-fixed-cost",
|
|
207
|
+
children: y("movethewheels.shipping.form.fixedCost", "Fixed cost per trip (₹, optional)")
|
|
208
|
+
}), /* @__PURE__ */ h(s, {
|
|
209
|
+
id: "rate-fixed-cost",
|
|
210
|
+
inputMode: "decimal",
|
|
211
|
+
value: z,
|
|
212
|
+
onChange: (e) => B(e.target.value)
|
|
213
|
+
})]
|
|
214
|
+
})
|
|
215
|
+
]
|
|
216
|
+
})
|
|
217
|
+
]
|
|
218
|
+
}),
|
|
151
219
|
/* @__PURE__ */ g("div", {
|
|
152
220
|
className: "flex items-center gap-2",
|
|
153
221
|
children: [/* @__PURE__ */ h(l, {
|
|
@@ -159,15 +227,15 @@ function y() {
|
|
|
159
227
|
children: y("movethewheels.shipping.form.cod", "Cash on delivery")
|
|
160
228
|
})]
|
|
161
229
|
}),
|
|
162
|
-
/* @__PURE__ */ h(d, { message:
|
|
230
|
+
/* @__PURE__ */ h(d, { message: U }),
|
|
163
231
|
/* @__PURE__ */ h(n, {
|
|
164
232
|
type: "submit",
|
|
165
|
-
disabled:
|
|
166
|
-
children:
|
|
233
|
+
disabled: V,
|
|
234
|
+
children: V ? y("movethewheels.shipping.form.comparing", "Comparing…") : y("movethewheels.shipping.form.compare", "Compare rates")
|
|
167
235
|
})
|
|
168
236
|
]
|
|
169
|
-
}) })] }),
|
|
170
|
-
result:
|
|
237
|
+
}) })] }), G && /* @__PURE__ */ h(f, {
|
|
238
|
+
result: G,
|
|
171
239
|
t: y
|
|
172
240
|
})]
|
|
173
241
|
})
|
package/dist/utils/navigation.js
CHANGED
|
@@ -2,5 +2,26 @@
|
|
|
2
2
|
function e(e, t) {
|
|
3
3
|
return (e.endsWith("/") ? e.slice(0, -1) : e) + (t.startsWith("/") ? t : `/${t}`);
|
|
4
4
|
}
|
|
5
|
+
function t(e, t) {
|
|
6
|
+
if (!t || Object.keys(t).length === 0) return e;
|
|
7
|
+
let n = new URLSearchParams();
|
|
8
|
+
for (let [e, r] of Object.entries(t)) r != null && r !== "" && n.set(e, String(r));
|
|
9
|
+
let r = n.toString();
|
|
10
|
+
return r ? `${e}?${r}` : e;
|
|
11
|
+
}
|
|
12
|
+
var n = [/^orders\/new$/, /^orders\/[^/?#]+\/edit$/];
|
|
13
|
+
function r(e) {
|
|
14
|
+
if (!e || e.length > 200 || e.includes("://") || e.startsWith("/")) return null;
|
|
15
|
+
let [t] = e.split(/[?#]/, 1);
|
|
16
|
+
return n.some((e) => e.test(t)) ? t : null;
|
|
17
|
+
}
|
|
18
|
+
function i(e, n) {
|
|
19
|
+
let i = r(e);
|
|
20
|
+
return i ? t(i, { customerId: n }) : e;
|
|
21
|
+
}
|
|
22
|
+
function a(e) {
|
|
23
|
+
let n = r(e);
|
|
24
|
+
return n ? t("customers/new", { returnTo: n }) : "customers/new";
|
|
25
|
+
}
|
|
5
26
|
//#endregion
|
|
6
|
-
export { e as joinPath };
|
|
27
|
+
export { a as buildCustomerCreatePath, i as buildOrderCustomerPath, r as getSafeCustomerReturnPath, e as joinPath };
|
package/package.json
CHANGED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { useState, type ChangeEvent, type FormEvent } from 'react';
|
|
2
|
+
import { Plus, RefreshCw, UserPlus } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
Input,
|
|
12
|
+
Label,
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from './ui';
|
|
19
|
+
import type { Customer } from '../types';
|
|
20
|
+
import { FormError } from './FormError';
|
|
21
|
+
|
|
22
|
+
interface CustomerPickerProps {
|
|
23
|
+
customers: Customer[];
|
|
24
|
+
value: string;
|
|
25
|
+
onValueChange: (value: string) => void;
|
|
26
|
+
onCreated: (customer: Customer) => void;
|
|
27
|
+
createCustomer: (customer: Partial<Customer>) => Promise<Customer>;
|
|
28
|
+
isLoading: boolean;
|
|
29
|
+
error: Error | null;
|
|
30
|
+
onRetry: () => void;
|
|
31
|
+
onOpenCustomers?: () => void;
|
|
32
|
+
selectedCustomer?: Customer | null;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CustomerFormState {
|
|
37
|
+
name: string;
|
|
38
|
+
type: Customer['type'];
|
|
39
|
+
email: string;
|
|
40
|
+
phone: string;
|
|
41
|
+
company: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EMPTY_FORM: CustomerFormState = {
|
|
45
|
+
name: '',
|
|
46
|
+
type: 'b2c',
|
|
47
|
+
email: '',
|
|
48
|
+
phone: '',
|
|
49
|
+
company: '',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function mergeCustomers(customers: Customer[], selectedCustomer?: Customer | null): Customer[] {
|
|
53
|
+
if (!selectedCustomer || customers.some((customer) => customer.id === selectedCustomer.id)) {
|
|
54
|
+
return customers;
|
|
55
|
+
}
|
|
56
|
+
return [selectedCustomer, ...customers];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function CustomerPicker({
|
|
60
|
+
customers,
|
|
61
|
+
value,
|
|
62
|
+
onValueChange,
|
|
63
|
+
onCreated,
|
|
64
|
+
createCustomer,
|
|
65
|
+
isLoading,
|
|
66
|
+
error,
|
|
67
|
+
onRetry,
|
|
68
|
+
onOpenCustomers,
|
|
69
|
+
selectedCustomer,
|
|
70
|
+
disabled = false,
|
|
71
|
+
}: CustomerPickerProps) {
|
|
72
|
+
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
73
|
+
const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
|
|
74
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
75
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
76
|
+
const options = mergeCustomers(customers, selectedCustomer);
|
|
77
|
+
|
|
78
|
+
const openCreateDialog = () => {
|
|
79
|
+
setCreateError(null);
|
|
80
|
+
setForm(EMPTY_FORM);
|
|
81
|
+
setIsCreateOpen(true);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const closeCreateDialog = () => {
|
|
85
|
+
if (isCreating) return;
|
|
86
|
+
setIsCreateOpen(false);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
event.stopPropagation();
|
|
92
|
+
const name = form.name.trim();
|
|
93
|
+
if (!name) {
|
|
94
|
+
setCreateError('Customer name is required.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setIsCreating(true);
|
|
99
|
+
setCreateError(null);
|
|
100
|
+
try {
|
|
101
|
+
const created = await createCustomer({
|
|
102
|
+
name,
|
|
103
|
+
type: form.type,
|
|
104
|
+
email: form.email.trim() || undefined,
|
|
105
|
+
phone: form.phone.trim() || undefined,
|
|
106
|
+
company: form.company.trim() || undefined,
|
|
107
|
+
});
|
|
108
|
+
onCreated(created);
|
|
109
|
+
setIsCreateOpen(false);
|
|
110
|
+
setForm(EMPTY_FORM);
|
|
111
|
+
} catch (creationError) {
|
|
112
|
+
setCreateError(
|
|
113
|
+
creationError instanceof Error
|
|
114
|
+
? creationError.message
|
|
115
|
+
: 'Could not create the customer. Please try again.'
|
|
116
|
+
);
|
|
117
|
+
} finally {
|
|
118
|
+
setIsCreating(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="space-y-2">
|
|
124
|
+
<div className="flex items-center justify-between gap-2">
|
|
125
|
+
<Label htmlFor="customer">Customer</Label>
|
|
126
|
+
<Button
|
|
127
|
+
type="button"
|
|
128
|
+
variant="ghost"
|
|
129
|
+
size="sm"
|
|
130
|
+
onClick={openCreateDialog}
|
|
131
|
+
disabled={disabled || isLoading}
|
|
132
|
+
>
|
|
133
|
+
<Plus size={14} className="mr-1" />
|
|
134
|
+
Add customer
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
|
138
|
+
<Select
|
|
139
|
+
value={value}
|
|
140
|
+
onValueChange={onValueChange}
|
|
141
|
+
disabled={disabled || isLoading || Boolean(error)}
|
|
142
|
+
>
|
|
143
|
+
<SelectTrigger id="customer" className="flex-1">
|
|
144
|
+
<SelectValue placeholder={isLoading ? 'Loading customers...' : 'Select customer'} />
|
|
145
|
+
</SelectTrigger>
|
|
146
|
+
<SelectContent>
|
|
147
|
+
{options.map((customer) => (
|
|
148
|
+
<SelectItem key={customer.id} value={customer.id}>
|
|
149
|
+
{customer.name} {customer.email ? `(${customer.email})` : ''}
|
|
150
|
+
</SelectItem>
|
|
151
|
+
))}
|
|
152
|
+
</SelectContent>
|
|
153
|
+
</Select>
|
|
154
|
+
{error && (
|
|
155
|
+
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
|
156
|
+
<RefreshCw size={14} className="mr-1" />
|
|
157
|
+
Retry
|
|
158
|
+
</Button>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{error ? (
|
|
162
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
163
|
+
<p role="alert" className="text-sm text-status-error-text">
|
|
164
|
+
Customers could not be loaded. Retry or add a customer to continue.
|
|
165
|
+
</p>
|
|
166
|
+
{onOpenCustomers && (
|
|
167
|
+
<Button type="button" variant="outline" size="sm" onClick={onOpenCustomers}>
|
|
168
|
+
Open customer page
|
|
169
|
+
</Button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
) : !isLoading && options.length === 0 ? (
|
|
173
|
+
<div className="rounded-lg border border-border-seam bg-bg-sunken/40 p-3">
|
|
174
|
+
<div className="flex items-start gap-2">
|
|
175
|
+
<UserPlus size={16} className="mt-0.5 shrink-0 text-text-secondary" />
|
|
176
|
+
<div>
|
|
177
|
+
<p className="text-sm font-medium text-text-primary">Add a customer to continue</p>
|
|
178
|
+
<p className="text-sm text-text-secondary">
|
|
179
|
+
The customer will be selected automatically and your order details will stay here.
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<Button
|
|
184
|
+
type="button"
|
|
185
|
+
size="sm"
|
|
186
|
+
className="mt-3"
|
|
187
|
+
onClick={openCreateDialog}
|
|
188
|
+
disabled={disabled}
|
|
189
|
+
>
|
|
190
|
+
<Plus size={14} className="mr-1.5" />
|
|
191
|
+
Create customer
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
) : null}
|
|
195
|
+
|
|
196
|
+
<Dialog
|
|
197
|
+
open={isCreateOpen}
|
|
198
|
+
onOpenChange={(open: boolean) => (open ? setIsCreateOpen(true) : closeCreateDialog())}
|
|
199
|
+
>
|
|
200
|
+
<DialogContent>
|
|
201
|
+
<DialogHeader>
|
|
202
|
+
<DialogTitle>Create customer</DialogTitle>
|
|
203
|
+
<DialogDescription>
|
|
204
|
+
Add the customer without leaving this order. They will be selected automatically.
|
|
205
|
+
</DialogDescription>
|
|
206
|
+
</DialogHeader>
|
|
207
|
+
<form onSubmit={handleCreate} className="space-y-4">
|
|
208
|
+
<FormError message={createError} />
|
|
209
|
+
<div className="space-y-2">
|
|
210
|
+
<Label htmlFor="quick-customer-name">Name</Label>
|
|
211
|
+
<Input
|
|
212
|
+
id="quick-customer-name"
|
|
213
|
+
value={form.name}
|
|
214
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
215
|
+
setForm((previous) => ({ ...previous, name: event.target.value }))
|
|
216
|
+
}
|
|
217
|
+
placeholder="Acme Corp or Jane Doe"
|
|
218
|
+
autoFocus
|
|
219
|
+
required
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
<Label htmlFor="quick-customer-type">Customer type</Label>
|
|
224
|
+
<Select
|
|
225
|
+
value={form.type}
|
|
226
|
+
onValueChange={(value: string) =>
|
|
227
|
+
setForm((previous) => ({ ...previous, type: value as Customer['type'] }))
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
<SelectTrigger id="quick-customer-type">
|
|
231
|
+
<SelectValue />
|
|
232
|
+
</SelectTrigger>
|
|
233
|
+
<SelectContent>
|
|
234
|
+
<SelectItem value="b2c">B2C (Individual)</SelectItem>
|
|
235
|
+
<SelectItem value="b2b">B2B (Business)</SelectItem>
|
|
236
|
+
</SelectContent>
|
|
237
|
+
</Select>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
240
|
+
<div className="space-y-2">
|
|
241
|
+
<Label htmlFor="quick-customer-email">Email</Label>
|
|
242
|
+
<Input
|
|
243
|
+
id="quick-customer-email"
|
|
244
|
+
type="email"
|
|
245
|
+
value={form.email}
|
|
246
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
247
|
+
setForm((previous) => ({ ...previous, email: event.target.value }))
|
|
248
|
+
}
|
|
249
|
+
placeholder="customer@example.com"
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="space-y-2">
|
|
253
|
+
<Label htmlFor="quick-customer-phone">Phone</Label>
|
|
254
|
+
<Input
|
|
255
|
+
id="quick-customer-phone"
|
|
256
|
+
value={form.phone}
|
|
257
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
258
|
+
setForm((previous) => ({ ...previous, phone: event.target.value }))
|
|
259
|
+
}
|
|
260
|
+
placeholder="+1-555-0100"
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div className="space-y-2">
|
|
265
|
+
<Label htmlFor="quick-customer-company">Company</Label>
|
|
266
|
+
<Input
|
|
267
|
+
id="quick-customer-company"
|
|
268
|
+
value={form.company}
|
|
269
|
+
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
270
|
+
setForm((previous) => ({ ...previous, company: event.target.value }))
|
|
271
|
+
}
|
|
272
|
+
placeholder="Company name (optional)"
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
<DialogFooter>
|
|
276
|
+
{onOpenCustomers && (
|
|
277
|
+
<Button
|
|
278
|
+
type="button"
|
|
279
|
+
variant="outline"
|
|
280
|
+
onClick={() => {
|
|
281
|
+
closeCreateDialog();
|
|
282
|
+
onOpenCustomers();
|
|
283
|
+
}}
|
|
284
|
+
disabled={isCreating}
|
|
285
|
+
>
|
|
286
|
+
Use customer page
|
|
287
|
+
</Button>
|
|
288
|
+
)}
|
|
289
|
+
<Button
|
|
290
|
+
type="button"
|
|
291
|
+
variant="outline"
|
|
292
|
+
onClick={closeCreateDialog}
|
|
293
|
+
disabled={isCreating}
|
|
294
|
+
>
|
|
295
|
+
Cancel
|
|
296
|
+
</Button>
|
|
297
|
+
<Button type="submit" disabled={isCreating}>
|
|
298
|
+
<Plus size={16} />
|
|
299
|
+
{isCreating ? 'Creating...' : 'Create and select'}
|
|
300
|
+
</Button>
|
|
301
|
+
</DialogFooter>
|
|
302
|
+
</form>
|
|
303
|
+
</DialogContent>
|
|
304
|
+
</Dialog>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* number people compare on — off-screen, so each courier is a card instead.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { AlertTriangle } from 'lucide-react';
|
|
12
|
+
import { AlertTriangle, Truck } from 'lucide-react';
|
|
13
13
|
|
|
14
14
|
import { StatusBadge } from './StatusBadge';
|
|
15
15
|
import {
|
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
TableRow,
|
|
26
26
|
} from './ui';
|
|
27
27
|
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
OwnFleetEstimate,
|
|
30
|
+
ShippingQuote,
|
|
31
|
+
ShippingQuoteResult,
|
|
32
|
+
} from '../hooks/useShippingCarriers';
|
|
29
33
|
import { formatCurrency } from '../utils/formatters';
|
|
30
34
|
|
|
31
35
|
interface ShippingQuoteResultsProps {
|
|
@@ -83,6 +87,60 @@ function QuoteBadges({ quote, t }: { quote: ShippingQuote; t: ShippingQuoteResul
|
|
|
83
87
|
);
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
function OwnFleetCard({
|
|
91
|
+
estimate,
|
|
92
|
+
t,
|
|
93
|
+
}: {
|
|
94
|
+
estimate: OwnFleetEstimate;
|
|
95
|
+
t: ShippingQuoteResultsProps['t'];
|
|
96
|
+
}) {
|
|
97
|
+
const savings = estimate.savingsVsCheapestCourier;
|
|
98
|
+
return (
|
|
99
|
+
<Card>
|
|
100
|
+
<CardHeader>
|
|
101
|
+
<CardTitle className="flex items-center gap-2">
|
|
102
|
+
<Truck size={18} aria-hidden="true" />
|
|
103
|
+
{t('movethewheels.shipping.ownFleet.title', 'Your own vehicle')}
|
|
104
|
+
<StatusBadge
|
|
105
|
+
status={t('movethewheels.shipping.ownFleet.estimate', 'Your estimate')}
|
|
106
|
+
customColors={INFO}
|
|
107
|
+
size="sm"
|
|
108
|
+
showDot={false}
|
|
109
|
+
/>
|
|
110
|
+
</CardTitle>
|
|
111
|
+
</CardHeader>
|
|
112
|
+
<CardContent className="space-y-2">
|
|
113
|
+
<p className="text-2xl font-semibold">{formatCurrency(estimate.cost, estimate.currency)}</p>
|
|
114
|
+
<p className="text-sm text-text-secondary">
|
|
115
|
+
{estimate.distanceKm} km × {formatCurrency(estimate.costPerKm, estimate.currency)}/km
|
|
116
|
+
{estimate.fixedCost > 0 && ` + ${formatCurrency(estimate.fixedCost, estimate.currency)}`}
|
|
117
|
+
</p>
|
|
118
|
+
{savings !== null && (
|
|
119
|
+
<p
|
|
120
|
+
className={`text-sm font-medium ${
|
|
121
|
+
estimate.isCheaperThanCouriers
|
|
122
|
+
? 'text-status-success-text'
|
|
123
|
+
: 'text-status-warning-text'
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
{estimate.isCheaperThanCouriers
|
|
127
|
+
? `${t('movethewheels.shipping.ownFleet.cheaper', 'Your own vehicle is cheaper than the cheapest courier by')} ${formatCurrency(savings, estimate.currency)}`
|
|
128
|
+
: savings === 0
|
|
129
|
+
? t('movethewheels.shipping.ownFleet.same', 'Same cost as the cheapest courier')
|
|
130
|
+
: `${t('movethewheels.shipping.ownFleet.dearer', 'The cheapest courier is cheaper by')} ${formatCurrency(-savings, estimate.currency)}`}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
<p className="text-xs text-text-secondary">
|
|
134
|
+
{t(
|
|
135
|
+
'movethewheels.shipping.ownFleet.note',
|
|
136
|
+
'Based only on the distance and cost per km you entered — not a quote.'
|
|
137
|
+
)}
|
|
138
|
+
</p>
|
|
139
|
+
</CardContent>
|
|
140
|
+
</Card>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
86
144
|
export function ShippingQuoteResults({ result, t }: ShippingQuoteResultsProps) {
|
|
87
145
|
const failed = result.providers.filter((provider) => !provider.ok);
|
|
88
146
|
|
|
@@ -99,6 +157,8 @@ export function ShippingQuoteResults({ result, t }: ShippingQuoteResultsProps) {
|
|
|
99
157
|
</div>
|
|
100
158
|
))}
|
|
101
159
|
|
|
160
|
+
{result.ownFleet && <OwnFleetCard estimate={result.ownFleet} t={t} />}
|
|
161
|
+
|
|
102
162
|
<Card>
|
|
103
163
|
<CardHeader>
|
|
104
164
|
<CardTitle>
|
|
@@ -13,7 +13,14 @@ export const OSM_ATTRIBUTION =
|
|
|
13
13
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
14
14
|
|
|
15
15
|
export type MapMarkerStatus =
|
|
16
|
-
|
|
16
|
+
| 'active'
|
|
17
|
+
| 'in_transit'
|
|
18
|
+
| 'idle'
|
|
19
|
+
| 'delayed'
|
|
20
|
+
| 'delivered'
|
|
21
|
+
| 'origin'
|
|
22
|
+
| 'destination'
|
|
23
|
+
| 'default';
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* A coordinate is "real" only when it is finite and not the null-island (0,0)
|
|
@@ -12,7 +12,12 @@ import { executeGraphQL } from '../utils/graphqlClient';
|
|
|
12
12
|
|
|
13
13
|
export type TransportMode = 'ROAD' | 'AIR' | 'OCEAN' | 'RAIL';
|
|
14
14
|
export type ShipmentLegStatus =
|
|
15
|
-
|
|
15
|
+
| 'PLANNED'
|
|
16
|
+
| 'BOOKED'
|
|
17
|
+
| 'IN_TRANSIT'
|
|
18
|
+
| 'ARRIVED'
|
|
19
|
+
| 'DELIVERED'
|
|
20
|
+
| 'CANCELLED';
|
|
16
21
|
export type OceanContainerType = 'LCL' | 'FCL_20FT' | 'FCL_40FT' | 'FCL_40FT_HC';
|
|
17
22
|
|
|
18
23
|
export interface FreightRateBreakdownLine {
|
|
@@ -11,6 +11,23 @@ import { executeGraphQL } from '../utils/graphqlClient';
|
|
|
11
11
|
export type CarrierProvider = 'SHIPROCKET';
|
|
12
12
|
export type CarrierServiceMode = 'SURFACE' | 'AIR';
|
|
13
13
|
|
|
14
|
+
export interface OwnFleetInput {
|
|
15
|
+
distanceKm: number;
|
|
16
|
+
costPerKm: number;
|
|
17
|
+
fixedCost?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OwnFleetEstimate {
|
|
21
|
+
distanceKm: number;
|
|
22
|
+
costPerKm: number;
|
|
23
|
+
fixedCost: number;
|
|
24
|
+
cost: number;
|
|
25
|
+
currency: string;
|
|
26
|
+
/** Cheapest courier price minus this estimate; positive = your own vehicle is cheaper. */
|
|
27
|
+
savingsVsCheapestCourier: number | null;
|
|
28
|
+
isCheaperThanCouriers: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
export interface ShippingQuoteInput {
|
|
15
32
|
pickupPostcode: string;
|
|
16
33
|
deliveryPostcode: string;
|
|
@@ -19,6 +36,7 @@ export interface ShippingQuoteInput {
|
|
|
19
36
|
widthCm?: number;
|
|
20
37
|
heightCm?: number;
|
|
21
38
|
cod?: boolean;
|
|
39
|
+
ownFleet?: OwnFleetInput;
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
export interface ShippingQuote {
|
|
@@ -45,6 +63,7 @@ export interface CarrierOutcome {
|
|
|
45
63
|
|
|
46
64
|
export interface ShippingQuoteResult {
|
|
47
65
|
chargeableWeightKg: number;
|
|
66
|
+
ownFleet: OwnFleetEstimate | null;
|
|
48
67
|
quotes: ShippingQuote[];
|
|
49
68
|
providers: CarrierOutcome[];
|
|
50
69
|
}
|
|
@@ -53,6 +72,15 @@ const SHIPPING_QUOTES_QUERY = `
|
|
|
53
72
|
query ShippingQuotes($input: ShippingQuoteInput!) {
|
|
54
73
|
shippingQuotes(input: $input) {
|
|
55
74
|
chargeableWeightKg
|
|
75
|
+
ownFleet {
|
|
76
|
+
distanceKm
|
|
77
|
+
costPerKm
|
|
78
|
+
fixedCost
|
|
79
|
+
cost
|
|
80
|
+
currency
|
|
81
|
+
savingsVsCheapestCourier
|
|
82
|
+
isCheaperThanCouriers
|
|
83
|
+
}
|
|
56
84
|
quotes {
|
|
57
85
|
provider
|
|
58
86
|
courierId
|