@burdenoff/microfe-movethewheels 2026.703.2 → 2026.703.4
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/{AIAssistantPage-C3cPsWt-.js → AIAssistantPage-3YOnHO9Y.js} +3 -3
- package/dist/{AnalyticsPage-Dz6g2MvK.js → AnalyticsPage-CMpI1070.js} +11 -11
- package/dist/CreateOrderPage-DRAgGo9i.js +490 -0
- package/dist/CustomerDetailsPage-BCeMmOFe.js +615 -0
- package/dist/{CustomersPage-2Y05LVyh.js → CustomersPage-CaNBMXmM.js} +64 -53
- package/dist/{DashboardPage-BVjQi0EB.js → DashboardPage-DIKlXde3.js} +16 -16
- package/dist/DataTable-XvUcTjwi.js +340 -0
- package/dist/DocumentsCard-U2lSe-cn.js +169 -0
- package/dist/{DriverDetailsPage-CN3WQ0B9.js → DriverDetailsPage-BQrYd_Lp.js} +178 -195
- package/dist/{DriversPage-B0Zpx8OQ.js → DriversPage-PYmdFiby.js} +65 -54
- package/dist/FinancePage-Cwh9Bw4M.js +136 -0
- package/dist/{FleetPage-DqkFi3s3.js → FleetPage-3ZHsB-TN.js} +23 -22
- package/dist/{FormError-BlOjnXOq.js → FormError-cuLV2jVD.js} +1 -1
- package/dist/{ImportExportPage-DKJRCezl.js → ImportExportPage-C6f7DqhM.js} +50 -70
- package/dist/{InventoryPage-DwfgCUQq.js → InventoryPage-DpiqVu1v.js} +9 -9
- package/dist/{LiveTrackingPage-DCX-K-LR.js → LiveTrackingPage-FAzzlVnX.js} +9 -9
- package/dist/MarketplacePage-Bh_2ns4v.js +426 -0
- package/dist/Notice-CSoDYKw9.js +46 -0
- package/dist/OrderDetailsPage-zCj0lW0j.js +584 -0
- package/dist/{OrdersListPage-BjDlCKJd.js → OrdersListPage-CAMsuUck.js} +166 -89
- package/dist/{ProductDetailsPage-C9uOiYLp.js → ProductDetailsPage-wsxAQkA0.js} +12 -12
- package/dist/{ProductsPage-C1G9h7Xd.js → ProductsPage-DC5qDLfz.js} +60 -49
- package/dist/ReportsPage-BwUJ37eO.js +356 -0
- package/dist/RouteDetailsPage-DyMJE9AS.js +583 -0
- package/dist/{RoutesPage-DMfoicM1.js → RoutesPage-Dtc669yc.js} +57 -46
- package/dist/{SettingsPage-Bn-v0mbQ.js → SettingsPage-CpH9xhQg.js} +5 -27
- package/dist/{TrackingPage-W-jtdM3Y.js → TrackingPage-DaNEFG_h.js} +10 -10
- package/dist/{VehicleDetailsPage-Bceo5oz3.js → VehicleDetailsPage-DldYNsYV.js} +125 -142
- package/dist/{VehiclesPage-VBtOep91.js → VehiclesPage-BFw13iMt.js} +61 -50
- package/dist/{WarehouseDetailsPage-L_6sWigM.js → WarehouseDetailsPage-CMG7or6G.js} +17 -17
- package/dist/{WarehousesPage-DCbXXXWE.js → WarehousesPage-Wkytnt86.js} +58 -47
- package/dist/arrow-right-Q3XQpTPR.js +11 -0
- package/dist/arrow-up-xNF1zJUG.js +17 -0
- package/dist/chevron-left-B2_r0Elx.js +8 -0
- package/dist/{wrench-CANXWxF-.js → fuel-B6_lnKDs.js} +2 -5
- package/dist/index.js +163 -174
- package/dist/upload-CHZofOqb.js +21 -0
- package/dist/{useAnalytics-DS8n0xgg.js → useAnalytics-D7HW2XRb.js} +52 -2
- package/dist/{useCustomers-BmTNGxiI.js → useCustomers-BPW4__PR.js} +29 -26
- package/dist/{useFleet-DKqXZ16m.js → useFleet-ChBkxAd6.js} +81 -69
- package/dist/{useInventory-DkyiVY_A.js → useInventory-CAAVMRp6.js} +83 -38
- package/dist/{useOrders-Cfk1GLiq.js → useOrders-B465S2nt.js} +58 -47
- package/dist/{useReports-CTqZr-AB.js → useReports-jQ3GpNOm.js} +40 -34
- package/dist/{useRoutes-BQWO607N.js → useRoutes-hS3d5-xr.js} +49 -41
- package/dist/{useTracking-B-va1kO3.js → useTracking-RjGz9PZG.js} +2 -2
- package/dist/user-check-BEKRCi7v.js +20 -0
- package/dist/wrench-q_rgJfaP.js +8 -0
- package/dist/x-tMeDMcCw.js +11 -0
- package/package.json +1 -1
- package/src/movethewheels/components/DataTable.tsx +252 -84
- package/src/movethewheels/components/DocumentsCard.tsx +102 -0
- package/src/movethewheels/components/Notice.tsx +61 -0
- package/src/movethewheels/components/index.ts +7 -0
- package/src/movethewheels/hooks/index.ts +9 -0
- package/src/movethewheels/hooks/useAnalytics.ts +95 -0
- package/src/movethewheels/hooks/useCustomers.ts +11 -4
- package/src/movethewheels/hooks/useEntityDocuments.ts +132 -0
- package/src/movethewheels/hooks/useFleet.ts +26 -7
- package/src/movethewheels/hooks/useInventory.ts +70 -7
- package/src/movethewheels/hooks/useOrders.ts +32 -2
- package/src/movethewheels/hooks/useReports.ts +14 -4
- package/src/movethewheels/hooks/useRoutes.ts +20 -3
- package/src/movethewheels/pages/AnalyticsPage.tsx +2 -2
- package/src/movethewheels/pages/CreateOrderPage.tsx +44 -16
- package/src/movethewheels/pages/CustomerDetailsPage.tsx +239 -33
- package/src/movethewheels/pages/CustomersPage.tsx +27 -11
- package/src/movethewheels/pages/DriverDetailsPage.tsx +16 -30
- package/src/movethewheels/pages/DriversPage.tsx +26 -9
- package/src/movethewheels/pages/FinancePage.tsx +67 -84
- package/src/movethewheels/pages/InventoryPage.tsx +1 -1
- package/src/movethewheels/pages/MarketplacePage.tsx +265 -50
- package/src/movethewheels/pages/OrderDetailsPage.tsx +214 -10
- package/src/movethewheels/pages/OrdersListPage.tsx +77 -14
- package/src/movethewheels/pages/ProductsPage.tsx +27 -7
- package/src/movethewheels/pages/ReportsPage.tsx +65 -4
- package/src/movethewheels/pages/RouteDetailsPage.tsx +74 -13
- package/src/movethewheels/pages/RoutesPage.tsx +22 -6
- package/src/movethewheels/pages/SettingsPage.tsx +3 -19
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +3 -33
- package/src/movethewheels/pages/VehiclesPage.tsx +26 -5
- package/src/movethewheels/pages/WarehousesPage.tsx +22 -17
- package/src/movethewheels/types/index.ts +8 -0
- package/src/movethewheels/utils/download.ts +62 -0
- package/dist/CreateOrderPage-C5iwnjp4.js +0 -479
- package/dist/CustomerDetailsPage-B6nwFyLO.js +0 -406
- package/dist/DataTable-DmciIKKM.js +0 -249
- package/dist/FinancePage-DEDykSOP.js +0 -158
- package/dist/MarketplacePage-CIQ97_zp.js +0 -247
- package/dist/OrderDetailsPage-DYiZ-9UK.js +0 -429
- package/dist/ReportsPage-Zp53q3_8.js +0 -284
- package/dist/RouteDetailsPage-DUStXSf9.js +0 -531
- /package/dist/{MetricCard-BY0QrMD4.js → MetricCard-CVBVpu7o.js} +0 -0
- /package/dist/{StatusBadge-BZurYHMy.js → StatusBadge-C87mDpDP.js} +0 -0
- /package/dist/{box-D9Pal4HM.js → box-Eu05PXDh.js} +0 -0
- /package/dist/{chart-column-BFIJysDR.js → chart-column-ouzgxIv_.js} +0 -0
- /package/dist/{chevron-right-EMIIyyJ1.js → chevron-right-eV4LjaSi.js} +0 -0
- /package/dist/{circle-alert-iY24lSlp.js → circle-alert-Bzh2DBSF.js} +0 -0
- /package/dist/{circle-check-big-w0TrLKMF.js → circle-check-big-CGMSpQmD.js} +0 -0
- /package/dist/{clock-DJZtdl8u.js → clock-Bl3ptexL.js} +0 -0
- /package/dist/{dollar-sign-CgKQRz_e.js → dollar-sign-CAVU95SO.js} +0 -0
- /package/dist/{download-DRXxoiQR.js → download-C5NbvVDc.js} +0 -0
- /package/dist/{file-text-DWWY6_f5.js → file-text-D-1uvLZG.js} +0 -0
- /package/dist/{filter-DloLC2eE.js → filter-DUfUg6BO.js} +0 -0
- /package/dist/{formatters-CF2S2l1m.js → formatters-BQr-aR5W.js} +0 -0
- /package/dist/{map-CkJaULb2.js → map-C-M-FQLR.js} +0 -0
- /package/dist/{map-pin-BGiCvyCs.js → map-pin-Dznl5l40.js} +0 -0
- /package/dist/{navigation-D36prnAn.js → navigation-BJSJl-VG.js} +0 -0
- /package/dist/{package-BChUzpJS.js → package-CcRttV2N.js} +0 -0
- /package/dist/{phone-Ac9s3mo_.js → phone-DVSZHt_j.js} +0 -0
- /package/dist/{plus-Z76q-FZm.js → plus-C_XZDwBz.js} +0 -0
- /package/dist/{refresh-cw-DKt6f5T6.js → refresh-cw-CfgN67l3.js} +0 -0
- /package/dist/{save-CojywNhG.js → save-DJE8Fpdv.js} +0 -0
- /package/dist/{search-CDGefqb3.js → search-D_VyGcZT.js} +0 -0
- /package/dist/{square-pen-CopE9GmB.js → square-pen-D-6JYrVQ.js} +0 -0
- /package/dist/{star-nHlLjDKR.js → star-BpRlUZuU.js} +0 -0
- /package/dist/{store-BR9Voi5E.js → store-B-8HMuyZ.js} +0 -0
- /package/dist/{trash-2-BKhar6Hs.js → trash-2-8vmv-XV_.js} +0 -0
- /package/dist/{trending-up-Dohgd4t_.js → trending-up-Dz6PqPRj.js} +0 -0
- /package/dist/{triangle-alert-i-XQL9Rd.js → triangle-alert-D7Y3J_4z.js} +0 -0
- /package/dist/{truck-C0v_hbLR.js → truck-D-y2IDzv.js} +0 -0
- /package/dist/{useAssistant-D4v3oxD3.js → useAssistant-DVW4QgQb.js} +0 -0
- /package/dist/{useImportExport-DHr-SWSw.js → useImportExport-DrPDHEDK.js} +0 -0
- /package/dist/{useSettings-CkDH_-gM.js → useSettings-BB8X2RLh.js} +0 -0
- /package/dist/{user-jyFU3lXk.js → user-DzfTn4dE.js} +0 -0
- /package/dist/{users-DZ-ilS6B.js → users-CCrq-w0C.js} +0 -0
- /package/dist/{warehouse-6m5kXMLu.js → warehouse-CaqfmMKh.js} +0 -0
|
@@ -7,11 +7,15 @@ interface UseRoutesOptions {
|
|
|
7
7
|
driverId?: string;
|
|
8
8
|
vehicleId?: string;
|
|
9
9
|
status?: 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
|
10
|
+
limit?: number;
|
|
11
|
+
offset?: number;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
interface UseRoutesResult {
|
|
13
15
|
routes: Route[];
|
|
14
16
|
totalRoutes: number;
|
|
17
|
+
/** True when the last fetch returned a full page (rawCount === limit). */
|
|
18
|
+
hasMore: boolean;
|
|
15
19
|
isLoading: boolean;
|
|
16
20
|
error: Error | null;
|
|
17
21
|
refetch: () => void;
|
|
@@ -36,6 +40,7 @@ interface ApiRoute {
|
|
|
36
40
|
waypoints?: unknown;
|
|
37
41
|
estimatedDistance?: number | null;
|
|
38
42
|
estimatedDuration?: number | null;
|
|
43
|
+
fuelCost?: number | null;
|
|
39
44
|
actualDistance?: number | null;
|
|
40
45
|
actualDuration?: number | null;
|
|
41
46
|
createdAt: string;
|
|
@@ -53,6 +58,7 @@ const LIST_ROUTES_QUERY = `
|
|
|
53
58
|
waypoints
|
|
54
59
|
estimatedDistance
|
|
55
60
|
estimatedDuration
|
|
61
|
+
fuelCost
|
|
56
62
|
actualDistance
|
|
57
63
|
actualDuration
|
|
58
64
|
createdAt
|
|
@@ -72,6 +78,7 @@ const GET_ROUTE_QUERY = `
|
|
|
72
78
|
waypoints
|
|
73
79
|
estimatedDistance
|
|
74
80
|
estimatedDuration
|
|
81
|
+
fuelCost
|
|
75
82
|
actualDistance
|
|
76
83
|
actualDuration
|
|
77
84
|
createdAt
|
|
@@ -91,6 +98,7 @@ const CREATE_ROUTE_MUTATION = `
|
|
|
91
98
|
waypoints
|
|
92
99
|
estimatedDistance
|
|
93
100
|
estimatedDuration
|
|
101
|
+
fuelCost
|
|
94
102
|
actualDistance
|
|
95
103
|
actualDuration
|
|
96
104
|
createdAt
|
|
@@ -110,6 +118,7 @@ const UPDATE_ROUTE_MUTATION = `
|
|
|
110
118
|
waypoints
|
|
111
119
|
estimatedDistance
|
|
112
120
|
estimatedDuration
|
|
121
|
+
fuelCost
|
|
113
122
|
actualDistance
|
|
114
123
|
actualDuration
|
|
115
124
|
createdAt
|
|
@@ -135,6 +144,7 @@ const OPTIMIZE_ROUTE_MUTATION = `
|
|
|
135
144
|
waypoints
|
|
136
145
|
estimatedDistance
|
|
137
146
|
estimatedDuration
|
|
147
|
+
fuelCost
|
|
138
148
|
actualDistance
|
|
139
149
|
actualDuration
|
|
140
150
|
createdAt
|
|
@@ -206,6 +216,7 @@ function mapRoute(route: ApiRoute): Route {
|
|
|
206
216
|
stops: mapWaypoints(route.waypoints),
|
|
207
217
|
distance: route.estimatedDistance ?? 0,
|
|
208
218
|
estimatedDuration: route.estimatedDuration ?? 0,
|
|
219
|
+
fuelCost: route.fuelCost ?? undefined,
|
|
209
220
|
actualDuration: route.actualDuration ?? undefined,
|
|
210
221
|
startTime: undefined,
|
|
211
222
|
endTime: undefined,
|
|
@@ -232,9 +243,10 @@ function useGraphqlOptions() {
|
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
235
|
-
const { driverId, vehicleId, status } = options;
|
|
246
|
+
const { driverId, vehicleId, status, limit = 500, offset = 0 } = options;
|
|
236
247
|
const graphQLOptions = useGraphqlOptions();
|
|
237
248
|
const [routes, setRoutes] = useState<Route[]>([]);
|
|
249
|
+
const [hasMore, setHasMore] = useState(false);
|
|
238
250
|
const [isLoading, setIsLoading] = useState(false);
|
|
239
251
|
const [error, setError] = useState<Error | null>(null);
|
|
240
252
|
|
|
@@ -248,16 +260,17 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
248
260
|
vehicleId,
|
|
249
261
|
status: status ? toApiRouteStatus(status) : undefined,
|
|
250
262
|
},
|
|
251
|
-
pagination: { limit
|
|
263
|
+
pagination: { limit, offset },
|
|
252
264
|
});
|
|
253
265
|
|
|
266
|
+
setHasMore(data.routes.length === limit);
|
|
254
267
|
setRoutes(data.routes.map(mapRoute));
|
|
255
268
|
} catch (fetchError) {
|
|
256
269
|
setError(fetchError instanceof Error ? fetchError : new Error('Failed to load routes'));
|
|
257
270
|
} finally {
|
|
258
271
|
setIsLoading(false);
|
|
259
272
|
}
|
|
260
|
-
}, [driverId, graphQLOptions, status, vehicleId]);
|
|
273
|
+
}, [driverId, graphQLOptions, status, vehicleId, limit, offset]);
|
|
261
274
|
|
|
262
275
|
useEffect(() => {
|
|
263
276
|
void load();
|
|
@@ -282,6 +295,7 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
282
295
|
waypoints: route.stops ?? [],
|
|
283
296
|
estimatedDistance: route.distance ?? 0,
|
|
284
297
|
estimatedDuration: route.estimatedDuration ?? 0,
|
|
298
|
+
fuelCost: route.fuelCost,
|
|
285
299
|
actualDistance: route.distance,
|
|
286
300
|
actualDuration: route.actualDuration,
|
|
287
301
|
},
|
|
@@ -311,6 +325,7 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
311
325
|
waypoints: updates.stops,
|
|
312
326
|
estimatedDistance: updates.distance,
|
|
313
327
|
estimatedDuration: updates.estimatedDuration,
|
|
328
|
+
fuelCost: updates.fuelCost,
|
|
314
329
|
actualDistance: updates.distance,
|
|
315
330
|
actualDuration: updates.actualDuration,
|
|
316
331
|
},
|
|
@@ -376,6 +391,7 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
376
391
|
return {
|
|
377
392
|
routes,
|
|
378
393
|
totalRoutes: routes.length,
|
|
394
|
+
hasMore,
|
|
379
395
|
isLoading,
|
|
380
396
|
error,
|
|
381
397
|
refetch,
|
|
@@ -446,6 +462,7 @@ export function useRouteStats() {
|
|
|
446
462
|
completedRoutes: routes.filter((route) => route.status === 'completed').length,
|
|
447
463
|
inProgressRoutes: routes.filter((route) => route.status === 'in_progress').length,
|
|
448
464
|
plannedRoutes: routes.filter((route) => route.status === 'planned').length,
|
|
465
|
+
optimizedRoutes: routes.filter((route) => route.optimized).length,
|
|
449
466
|
};
|
|
450
467
|
}, [routes]);
|
|
451
468
|
|
|
@@ -90,7 +90,7 @@ export default function AnalyticsPage() {
|
|
|
90
90
|
<p className="text-xs">(Chart library integration needed)</p>
|
|
91
91
|
</div>
|
|
92
92
|
</div>
|
|
93
|
-
<div className="mt-4 grid grid-cols-4 gap-2">
|
|
93
|
+
<div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
94
94
|
{orderData.trends.slice(-4).map((t) => (
|
|
95
95
|
<div key={t.date} className="text-center p-2 bg-bg-sunken/50 rounded">
|
|
96
96
|
<p className="text-xs text-text-secondary">
|
|
@@ -114,7 +114,7 @@ export default function AnalyticsPage() {
|
|
|
114
114
|
<p>Revenue trend chart</p>
|
|
115
115
|
</div>
|
|
116
116
|
</div>
|
|
117
|
-
<div className="mt-4 grid grid-cols-5 gap-2">
|
|
117
|
+
<div className="mt-4 grid grid-cols-2 sm:grid-cols-5 gap-2">
|
|
118
118
|
{analytics.trends.revenue.map((r) => (
|
|
119
119
|
<div key={r.month} className="text-center p-2 bg-bg-sunken/50 rounded">
|
|
120
120
|
<p className="text-xs text-text-secondary">{r.month}</p>
|
|
@@ -8,6 +8,7 @@ import { useState } from 'react';
|
|
|
8
8
|
import { useParams } from 'react-router-dom';
|
|
9
9
|
import { Package, ArrowLeft, Save, Plus, Trash2, Users, MapPin } from 'lucide-react';
|
|
10
10
|
import { PageLayout, EmptyState } from '../components/PageLayout';
|
|
11
|
+
import { FormError } from '../components/FormError';
|
|
11
12
|
import {
|
|
12
13
|
Card,
|
|
13
14
|
CardContent,
|
|
@@ -62,6 +63,7 @@ export default function CreateOrderPage() {
|
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
66
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
65
67
|
|
|
66
68
|
const goBack = () => {
|
|
67
69
|
if (navigate) {
|
|
@@ -69,27 +71,49 @@ export default function CreateOrderPage() {
|
|
|
69
71
|
}
|
|
70
72
|
};
|
|
71
73
|
|
|
74
|
+
const validate = (): string | null => {
|
|
75
|
+
const selectedCustomer = customers.find((c) => c.id === formData.customerId);
|
|
76
|
+
if (!formData.customerId || !selectedCustomer) {
|
|
77
|
+
return 'Select a customer for this order.';
|
|
78
|
+
}
|
|
79
|
+
if (!formData.pickupAddress.street.trim()) {
|
|
80
|
+
return 'Enter a pickup street address.';
|
|
81
|
+
}
|
|
82
|
+
if (!formData.deliveryAddress.street.trim()) {
|
|
83
|
+
return 'Enter a delivery street address.';
|
|
84
|
+
}
|
|
85
|
+
if (formData.items.length === 0) {
|
|
86
|
+
return 'Add at least one item to the order.';
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
|
|
72
91
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
73
92
|
e.preventDefault();
|
|
74
|
-
|
|
93
|
+
setFormError(null);
|
|
75
94
|
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
const validationError = validate();
|
|
96
|
+
if (validationError) {
|
|
97
|
+
setFormError(validationError);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const selectedCustomer = customers.find((c) => c.id === formData.customerId);
|
|
102
|
+
if (!selectedCustomer) {
|
|
103
|
+
setFormError('Select a customer for this order.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
78
106
|
|
|
107
|
+
setIsSubmitting(true);
|
|
108
|
+
try {
|
|
79
109
|
await createOrder({
|
|
80
110
|
customerId: formData.customerId,
|
|
81
|
-
customerInfo:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
: {
|
|
89
|
-
name: 'New Customer',
|
|
90
|
-
email: 'customer@example.com',
|
|
91
|
-
phone: '+1-555-0000',
|
|
92
|
-
},
|
|
111
|
+
customerInfo: {
|
|
112
|
+
name: selectedCustomer.name,
|
|
113
|
+
email: selectedCustomer.email,
|
|
114
|
+
phone: selectedCustomer.phone,
|
|
115
|
+
company: selectedCustomer.company,
|
|
116
|
+
},
|
|
93
117
|
orderType: formData.orderType as 'b2b' | 'b2c',
|
|
94
118
|
priority: formData.priority as 'low' | 'normal' | 'high' | 'urgent',
|
|
95
119
|
pickupAddress: formData.pickupAddress,
|
|
@@ -100,7 +124,9 @@ export default function CreateOrderPage() {
|
|
|
100
124
|
|
|
101
125
|
goBack();
|
|
102
126
|
} catch (error) {
|
|
103
|
-
|
|
127
|
+
setFormError(
|
|
128
|
+
error instanceof Error ? error.message : 'Could not create the order. Please try again.'
|
|
129
|
+
);
|
|
104
130
|
} finally {
|
|
105
131
|
setIsSubmitting(false);
|
|
106
132
|
}
|
|
@@ -209,6 +235,8 @@ export default function CreateOrderPage() {
|
|
|
209
235
|
/>
|
|
210
236
|
|
|
211
237
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
238
|
+
<FormError message={formError} />
|
|
239
|
+
|
|
212
240
|
{/* Order Details — the page's single emphasis zone (the form's starting point) */}
|
|
213
241
|
<EmphasisPanel>
|
|
214
242
|
<h2 className="text-base font-semibold text-text-primary mb-4">Order Details</h2>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Customer Details Page
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { useState } from 'react';
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
6
|
import { useParams } from 'react-router-dom';
|
|
7
7
|
import {
|
|
8
8
|
Users,
|
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
Building,
|
|
16
16
|
FileText,
|
|
17
17
|
Save,
|
|
18
|
+
Package,
|
|
19
|
+
X,
|
|
18
20
|
} from 'lucide-react';
|
|
19
21
|
import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
|
|
20
|
-
import { TypeBadge } from '../components/StatusBadge';
|
|
22
|
+
import { TypeBadge, StatusBadge } from '../components/StatusBadge';
|
|
21
23
|
import {
|
|
22
24
|
Card,
|
|
23
25
|
CardContent,
|
|
@@ -35,10 +37,28 @@ import {
|
|
|
35
37
|
} from '../components/ui';
|
|
36
38
|
import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
37
39
|
import { useCustomer, useCustomers } from '../hooks/useCustomers';
|
|
40
|
+
import { useOrders } from '../hooks/useOrders';
|
|
38
41
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
39
|
-
import { formatAddress, formatDate } from '../utils/formatters';
|
|
42
|
+
import { formatAddress, formatCurrency, formatDate } from '../utils/formatters';
|
|
40
43
|
import { joinPath } from '../utils/navigation';
|
|
41
44
|
import { FormError } from '../components/FormError';
|
|
45
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Only surface http(s) links. Anything else (javascript:, data:, mailto:, etc.)
|
|
49
|
+
* is unsafe to render as a raw href, so we drop it.
|
|
50
|
+
*/
|
|
51
|
+
function safeHttpUrl(raw: string | undefined): string | null {
|
|
52
|
+
if (!raw) return null;
|
|
53
|
+
const value = raw.trim();
|
|
54
|
+
const candidate = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(candidate);
|
|
57
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
|
|
43
63
|
function NewCustomerForm() {
|
|
44
64
|
const { basePath, navigate } = useMoveTheWheels();
|
|
@@ -177,10 +197,77 @@ export default function CustomerDetailsPage() {
|
|
|
177
197
|
const { customerId } = useParams<{ customerId: string }>();
|
|
178
198
|
const { basePath, navigate } = useMoveTheWheels();
|
|
179
199
|
const { customer, isLoading, error, refetch } = useCustomer(customerId);
|
|
200
|
+
const { updateCustomer } = useCustomers();
|
|
201
|
+
const { orders, isLoading: ordersLoading } = useOrders({ filters: { customerId } });
|
|
202
|
+
const { message: notice, notify } = useNotice();
|
|
203
|
+
|
|
204
|
+
const [editing, setEditing] = useState(false);
|
|
205
|
+
const [saving, setSaving] = useState(false);
|
|
206
|
+
const [editError, setEditError] = useState<string | null>(null);
|
|
207
|
+
const [form, setForm] = useState({
|
|
208
|
+
name: '',
|
|
209
|
+
email: '',
|
|
210
|
+
phone: '',
|
|
211
|
+
company: '',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (customer) {
|
|
216
|
+
setForm({
|
|
217
|
+
name: customer.name,
|
|
218
|
+
email: customer.email,
|
|
219
|
+
phone: customer.phone,
|
|
220
|
+
company: customer.company ?? '',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}, [customer]);
|
|
180
224
|
|
|
181
225
|
const goBack = () => navigate?.(joinPath(basePath, 'customers'));
|
|
182
226
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
183
227
|
|
|
228
|
+
const startEdit = () => {
|
|
229
|
+
setEditError(null);
|
|
230
|
+
setEditing(true);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const cancelEdit = () => {
|
|
234
|
+
setEditError(null);
|
|
235
|
+
setEditing(false);
|
|
236
|
+
if (customer) {
|
|
237
|
+
setForm({
|
|
238
|
+
name: customer.name,
|
|
239
|
+
email: customer.email,
|
|
240
|
+
phone: customer.phone,
|
|
241
|
+
company: customer.company ?? '',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const handleSave = async () => {
|
|
247
|
+
if (!customerId) return;
|
|
248
|
+
if (!form.name.trim()) {
|
|
249
|
+
setEditError('Customer name is required.');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setSaving(true);
|
|
253
|
+
setEditError(null);
|
|
254
|
+
try {
|
|
255
|
+
await updateCustomer(customerId, {
|
|
256
|
+
name: form.name.trim(),
|
|
257
|
+
email: form.email.trim(),
|
|
258
|
+
phone: form.phone.trim(),
|
|
259
|
+
company: form.company.trim() || undefined,
|
|
260
|
+
});
|
|
261
|
+
notify('Customer updated.');
|
|
262
|
+
setEditing(false);
|
|
263
|
+
refetch();
|
|
264
|
+
} catch (err) {
|
|
265
|
+
setEditError(err instanceof Error ? err.message : 'Failed to update customer.');
|
|
266
|
+
} finally {
|
|
267
|
+
setSaving(false);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
184
271
|
// Handle new customer form
|
|
185
272
|
if (!customerId) {
|
|
186
273
|
return <NewCustomerForm />;
|
|
@@ -199,9 +286,20 @@ export default function CustomerDetailsPage() {
|
|
|
199
286
|
<Button variant="outline" onClick={goBack}>
|
|
200
287
|
<ArrowLeft size={16} /> Back
|
|
201
288
|
</Button>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
289
|
+
{editing ? (
|
|
290
|
+
<>
|
|
291
|
+
<Button variant="outline" onClick={cancelEdit} disabled={saving}>
|
|
292
|
+
<X size={16} /> Cancel
|
|
293
|
+
</Button>
|
|
294
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
295
|
+
<Save size={16} /> {saving ? 'Saving…' : 'Save'}
|
|
296
|
+
</Button>
|
|
297
|
+
</>
|
|
298
|
+
) : (
|
|
299
|
+
<Button onClick={startEdit}>
|
|
300
|
+
<Edit size={16} /> Edit
|
|
301
|
+
</Button>
|
|
302
|
+
)}
|
|
205
303
|
</div>
|
|
206
304
|
}
|
|
207
305
|
headerContent={
|
|
@@ -238,53 +336,112 @@ export default function CustomerDetailsPage() {
|
|
|
238
336
|
label: 'Update contact details',
|
|
239
337
|
description: 'Keep email, phone and address current.',
|
|
240
338
|
icon: <Edit size={16} />,
|
|
241
|
-
onClick:
|
|
339
|
+
onClick: startEdit,
|
|
242
340
|
},
|
|
243
341
|
]}
|
|
244
342
|
/>
|
|
245
343
|
|
|
344
|
+
{notice && (
|
|
345
|
+
<div className="mb-2">
|
|
346
|
+
<Notice message={notice} />
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
246
350
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
247
351
|
<div className="lg:col-span-2 space-y-6">
|
|
248
352
|
{/* Contact Information — the page's single emphasis zone */}
|
|
249
353
|
<EmphasisPanel>
|
|
250
354
|
<h2 className="text-base font-semibold text-text-primary mb-4">Contact Information</h2>
|
|
251
|
-
|
|
355
|
+
{editing ? (
|
|
252
356
|
<div className="space-y-4">
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
<div>
|
|
256
|
-
<
|
|
257
|
-
|
|
357
|
+
<FormError message={editError} />
|
|
358
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
359
|
+
<div className="space-y-2">
|
|
360
|
+
<Label htmlFor="edit-name">
|
|
361
|
+
Name <span className="text-status-error-text">*</span>
|
|
362
|
+
</Label>
|
|
363
|
+
<Input
|
|
364
|
+
id="edit-name"
|
|
365
|
+
value={form.name}
|
|
366
|
+
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
367
|
+
/>
|
|
258
368
|
</div>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
369
|
+
<div className="space-y-2">
|
|
370
|
+
<Label htmlFor="edit-company">Company</Label>
|
|
371
|
+
<Input
|
|
372
|
+
id="edit-company"
|
|
373
|
+
value={form.company}
|
|
374
|
+
onChange={(e) => setForm((prev) => ({ ...prev, company: e.target.value }))}
|
|
375
|
+
placeholder="Company name (for B2B accounts)"
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
<div className="space-y-2">
|
|
379
|
+
<Label htmlFor="edit-email">Email</Label>
|
|
380
|
+
<Input
|
|
381
|
+
id="edit-email"
|
|
382
|
+
type="email"
|
|
383
|
+
value={form.email}
|
|
384
|
+
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
<div className="space-y-2">
|
|
388
|
+
<Label htmlFor="edit-phone">Phone</Label>
|
|
389
|
+
<Input
|
|
390
|
+
id="edit-phone"
|
|
391
|
+
value={form.phone}
|
|
392
|
+
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
|
|
393
|
+
/>
|
|
265
394
|
</div>
|
|
266
395
|
</div>
|
|
267
396
|
</div>
|
|
268
|
-
|
|
269
|
-
|
|
397
|
+
) : (
|
|
398
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
399
|
+
<div className="space-y-4">
|
|
270
400
|
<div className="flex items-center gap-3">
|
|
271
|
-
<
|
|
401
|
+
<Mail size={18} className="text-text-secondary" />
|
|
272
402
|
<div>
|
|
273
|
-
<p className="text-sm text-text-secondary">
|
|
274
|
-
<p className="font-medium">{customer.
|
|
403
|
+
<p className="text-sm text-text-secondary">Email</p>
|
|
404
|
+
<p className="font-medium">{customer.email || '—'}</p>
|
|
275
405
|
</div>
|
|
276
406
|
</div>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
</a>
|
|
407
|
+
<div className="flex items-center gap-3">
|
|
408
|
+
<Phone size={18} className="text-text-secondary" />
|
|
409
|
+
<div>
|
|
410
|
+
<p className="text-sm text-text-secondary">Phone</p>
|
|
411
|
+
<p className="font-medium">{customer.phone || '—'}</p>
|
|
412
|
+
</div>
|
|
284
413
|
</div>
|
|
285
|
-
|
|
414
|
+
</div>
|
|
415
|
+
<div className="space-y-4">
|
|
416
|
+
{customer.company && (
|
|
417
|
+
<div className="flex items-center gap-3">
|
|
418
|
+
<Building size={18} className="text-text-secondary" />
|
|
419
|
+
<div>
|
|
420
|
+
<p className="text-sm text-text-secondary">Company</p>
|
|
421
|
+
<p className="font-medium">{customer.company}</p>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
{(() => {
|
|
426
|
+
const website = safeHttpUrl(customer.website);
|
|
427
|
+
if (!website) return null;
|
|
428
|
+
return (
|
|
429
|
+
<div>
|
|
430
|
+
<p className="text-sm text-text-secondary">Website</p>
|
|
431
|
+
<a
|
|
432
|
+
href={website}
|
|
433
|
+
target="_blank"
|
|
434
|
+
rel="noopener noreferrer"
|
|
435
|
+
className="text-primary hover:underline"
|
|
436
|
+
>
|
|
437
|
+
{website}
|
|
438
|
+
</a>
|
|
439
|
+
</div>
|
|
440
|
+
);
|
|
441
|
+
})()}
|
|
442
|
+
</div>
|
|
286
443
|
</div>
|
|
287
|
-
|
|
444
|
+
)}
|
|
288
445
|
</EmphasisPanel>
|
|
289
446
|
|
|
290
447
|
<Card>
|
|
@@ -310,6 +467,55 @@ export default function CustomerDetailsPage() {
|
|
|
310
467
|
</CardContent>
|
|
311
468
|
</Card>
|
|
312
469
|
)}
|
|
470
|
+
|
|
471
|
+
{/* Orders for this customer */}
|
|
472
|
+
<Card>
|
|
473
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
474
|
+
<CardTitle className="flex items-center gap-2">
|
|
475
|
+
<Package size={18} /> Orders
|
|
476
|
+
</CardTitle>
|
|
477
|
+
<Button variant="outline" size="sm" onClick={() => goTo('orders/new')}>
|
|
478
|
+
<FileText size={14} /> New order
|
|
479
|
+
</Button>
|
|
480
|
+
</CardHeader>
|
|
481
|
+
<CardContent>
|
|
482
|
+
{ordersLoading ? (
|
|
483
|
+
<p className="text-text-secondary text-center py-4">Loading orders…</p>
|
|
484
|
+
) : orders.length > 0 ? (
|
|
485
|
+
<div className="space-y-2">
|
|
486
|
+
{orders.map((order) => (
|
|
487
|
+
<button
|
|
488
|
+
key={order.id}
|
|
489
|
+
type="button"
|
|
490
|
+
onClick={() => goTo(`orders/${order.id}`)}
|
|
491
|
+
className="w-full flex items-center justify-between gap-3 p-3 border rounded-lg text-left hover:bg-bg-sunken/50 transition-colors"
|
|
492
|
+
>
|
|
493
|
+
<div className="min-w-0">
|
|
494
|
+
<p className="font-medium truncate">{order.orderNumber}</p>
|
|
495
|
+
<p className="text-sm text-text-secondary">
|
|
496
|
+
{formatDate(order.createdAt, 'relative')} ·{' '}
|
|
497
|
+
{formatCurrency(order.pricing.total)}
|
|
498
|
+
</p>
|
|
499
|
+
</div>
|
|
500
|
+
<StatusBadge status={order.status} type="order" size="sm" />
|
|
501
|
+
</button>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
) : (
|
|
505
|
+
<div className="text-center py-6">
|
|
506
|
+
<Package size={32} className="mx-auto mb-2 text-text-secondary opacity-50" />
|
|
507
|
+
<p className="text-text-secondary">No orders yet for this customer</p>
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
onClick={() => goTo('orders/new')}
|
|
511
|
+
className="mt-1 text-sm text-primary hover:underline"
|
|
512
|
+
>
|
|
513
|
+
Book their first delivery
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</CardContent>
|
|
518
|
+
</Card>
|
|
313
519
|
</div>
|
|
314
520
|
|
|
315
521
|
<div className="space-y-6">
|