@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.
Files changed (126) hide show
  1. package/dist/{AIAssistantPage-C3cPsWt-.js → AIAssistantPage-3YOnHO9Y.js} +3 -3
  2. package/dist/{AnalyticsPage-Dz6g2MvK.js → AnalyticsPage-CMpI1070.js} +11 -11
  3. package/dist/CreateOrderPage-DRAgGo9i.js +490 -0
  4. package/dist/CustomerDetailsPage-BCeMmOFe.js +615 -0
  5. package/dist/{CustomersPage-2Y05LVyh.js → CustomersPage-CaNBMXmM.js} +64 -53
  6. package/dist/{DashboardPage-BVjQi0EB.js → DashboardPage-DIKlXde3.js} +16 -16
  7. package/dist/DataTable-XvUcTjwi.js +340 -0
  8. package/dist/DocumentsCard-U2lSe-cn.js +169 -0
  9. package/dist/{DriverDetailsPage-CN3WQ0B9.js → DriverDetailsPage-BQrYd_Lp.js} +178 -195
  10. package/dist/{DriversPage-B0Zpx8OQ.js → DriversPage-PYmdFiby.js} +65 -54
  11. package/dist/FinancePage-Cwh9Bw4M.js +136 -0
  12. package/dist/{FleetPage-DqkFi3s3.js → FleetPage-3ZHsB-TN.js} +23 -22
  13. package/dist/{FormError-BlOjnXOq.js → FormError-cuLV2jVD.js} +1 -1
  14. package/dist/{ImportExportPage-DKJRCezl.js → ImportExportPage-C6f7DqhM.js} +50 -70
  15. package/dist/{InventoryPage-DwfgCUQq.js → InventoryPage-DpiqVu1v.js} +9 -9
  16. package/dist/{LiveTrackingPage-DCX-K-LR.js → LiveTrackingPage-FAzzlVnX.js} +9 -9
  17. package/dist/MarketplacePage-Bh_2ns4v.js +426 -0
  18. package/dist/Notice-CSoDYKw9.js +46 -0
  19. package/dist/OrderDetailsPage-zCj0lW0j.js +584 -0
  20. package/dist/{OrdersListPage-BjDlCKJd.js → OrdersListPage-CAMsuUck.js} +166 -89
  21. package/dist/{ProductDetailsPage-C9uOiYLp.js → ProductDetailsPage-wsxAQkA0.js} +12 -12
  22. package/dist/{ProductsPage-C1G9h7Xd.js → ProductsPage-DC5qDLfz.js} +60 -49
  23. package/dist/ReportsPage-BwUJ37eO.js +356 -0
  24. package/dist/RouteDetailsPage-DyMJE9AS.js +583 -0
  25. package/dist/{RoutesPage-DMfoicM1.js → RoutesPage-Dtc669yc.js} +57 -46
  26. package/dist/{SettingsPage-Bn-v0mbQ.js → SettingsPage-CpH9xhQg.js} +5 -27
  27. package/dist/{TrackingPage-W-jtdM3Y.js → TrackingPage-DaNEFG_h.js} +10 -10
  28. package/dist/{VehicleDetailsPage-Bceo5oz3.js → VehicleDetailsPage-DldYNsYV.js} +125 -142
  29. package/dist/{VehiclesPage-VBtOep91.js → VehiclesPage-BFw13iMt.js} +61 -50
  30. package/dist/{WarehouseDetailsPage-L_6sWigM.js → WarehouseDetailsPage-CMG7or6G.js} +17 -17
  31. package/dist/{WarehousesPage-DCbXXXWE.js → WarehousesPage-Wkytnt86.js} +58 -47
  32. package/dist/arrow-right-Q3XQpTPR.js +11 -0
  33. package/dist/arrow-up-xNF1zJUG.js +17 -0
  34. package/dist/chevron-left-B2_r0Elx.js +8 -0
  35. package/dist/{wrench-CANXWxF-.js → fuel-B6_lnKDs.js} +2 -5
  36. package/dist/index.js +163 -174
  37. package/dist/upload-CHZofOqb.js +21 -0
  38. package/dist/{useAnalytics-DS8n0xgg.js → useAnalytics-D7HW2XRb.js} +52 -2
  39. package/dist/{useCustomers-BmTNGxiI.js → useCustomers-BPW4__PR.js} +29 -26
  40. package/dist/{useFleet-DKqXZ16m.js → useFleet-ChBkxAd6.js} +81 -69
  41. package/dist/{useInventory-DkyiVY_A.js → useInventory-CAAVMRp6.js} +83 -38
  42. package/dist/{useOrders-Cfk1GLiq.js → useOrders-B465S2nt.js} +58 -47
  43. package/dist/{useReports-CTqZr-AB.js → useReports-jQ3GpNOm.js} +40 -34
  44. package/dist/{useRoutes-BQWO607N.js → useRoutes-hS3d5-xr.js} +49 -41
  45. package/dist/{useTracking-B-va1kO3.js → useTracking-RjGz9PZG.js} +2 -2
  46. package/dist/user-check-BEKRCi7v.js +20 -0
  47. package/dist/wrench-q_rgJfaP.js +8 -0
  48. package/dist/x-tMeDMcCw.js +11 -0
  49. package/package.json +1 -1
  50. package/src/movethewheels/components/DataTable.tsx +252 -84
  51. package/src/movethewheels/components/DocumentsCard.tsx +102 -0
  52. package/src/movethewheels/components/Notice.tsx +61 -0
  53. package/src/movethewheels/components/index.ts +7 -0
  54. package/src/movethewheels/hooks/index.ts +9 -0
  55. package/src/movethewheels/hooks/useAnalytics.ts +95 -0
  56. package/src/movethewheels/hooks/useCustomers.ts +11 -4
  57. package/src/movethewheels/hooks/useEntityDocuments.ts +132 -0
  58. package/src/movethewheels/hooks/useFleet.ts +26 -7
  59. package/src/movethewheels/hooks/useInventory.ts +70 -7
  60. package/src/movethewheels/hooks/useOrders.ts +32 -2
  61. package/src/movethewheels/hooks/useReports.ts +14 -4
  62. package/src/movethewheels/hooks/useRoutes.ts +20 -3
  63. package/src/movethewheels/pages/AnalyticsPage.tsx +2 -2
  64. package/src/movethewheels/pages/CreateOrderPage.tsx +44 -16
  65. package/src/movethewheels/pages/CustomerDetailsPage.tsx +239 -33
  66. package/src/movethewheels/pages/CustomersPage.tsx +27 -11
  67. package/src/movethewheels/pages/DriverDetailsPage.tsx +16 -30
  68. package/src/movethewheels/pages/DriversPage.tsx +26 -9
  69. package/src/movethewheels/pages/FinancePage.tsx +67 -84
  70. package/src/movethewheels/pages/InventoryPage.tsx +1 -1
  71. package/src/movethewheels/pages/MarketplacePage.tsx +265 -50
  72. package/src/movethewheels/pages/OrderDetailsPage.tsx +214 -10
  73. package/src/movethewheels/pages/OrdersListPage.tsx +77 -14
  74. package/src/movethewheels/pages/ProductsPage.tsx +27 -7
  75. package/src/movethewheels/pages/ReportsPage.tsx +65 -4
  76. package/src/movethewheels/pages/RouteDetailsPage.tsx +74 -13
  77. package/src/movethewheels/pages/RoutesPage.tsx +22 -6
  78. package/src/movethewheels/pages/SettingsPage.tsx +3 -19
  79. package/src/movethewheels/pages/VehicleDetailsPage.tsx +3 -33
  80. package/src/movethewheels/pages/VehiclesPage.tsx +26 -5
  81. package/src/movethewheels/pages/WarehousesPage.tsx +22 -17
  82. package/src/movethewheels/types/index.ts +8 -0
  83. package/src/movethewheels/utils/download.ts +62 -0
  84. package/dist/CreateOrderPage-C5iwnjp4.js +0 -479
  85. package/dist/CustomerDetailsPage-B6nwFyLO.js +0 -406
  86. package/dist/DataTable-DmciIKKM.js +0 -249
  87. package/dist/FinancePage-DEDykSOP.js +0 -158
  88. package/dist/MarketplacePage-CIQ97_zp.js +0 -247
  89. package/dist/OrderDetailsPage-DYiZ-9UK.js +0 -429
  90. package/dist/ReportsPage-Zp53q3_8.js +0 -284
  91. package/dist/RouteDetailsPage-DUStXSf9.js +0 -531
  92. /package/dist/{MetricCard-BY0QrMD4.js → MetricCard-CVBVpu7o.js} +0 -0
  93. /package/dist/{StatusBadge-BZurYHMy.js → StatusBadge-C87mDpDP.js} +0 -0
  94. /package/dist/{box-D9Pal4HM.js → box-Eu05PXDh.js} +0 -0
  95. /package/dist/{chart-column-BFIJysDR.js → chart-column-ouzgxIv_.js} +0 -0
  96. /package/dist/{chevron-right-EMIIyyJ1.js → chevron-right-eV4LjaSi.js} +0 -0
  97. /package/dist/{circle-alert-iY24lSlp.js → circle-alert-Bzh2DBSF.js} +0 -0
  98. /package/dist/{circle-check-big-w0TrLKMF.js → circle-check-big-CGMSpQmD.js} +0 -0
  99. /package/dist/{clock-DJZtdl8u.js → clock-Bl3ptexL.js} +0 -0
  100. /package/dist/{dollar-sign-CgKQRz_e.js → dollar-sign-CAVU95SO.js} +0 -0
  101. /package/dist/{download-DRXxoiQR.js → download-C5NbvVDc.js} +0 -0
  102. /package/dist/{file-text-DWWY6_f5.js → file-text-D-1uvLZG.js} +0 -0
  103. /package/dist/{filter-DloLC2eE.js → filter-DUfUg6BO.js} +0 -0
  104. /package/dist/{formatters-CF2S2l1m.js → formatters-BQr-aR5W.js} +0 -0
  105. /package/dist/{map-CkJaULb2.js → map-C-M-FQLR.js} +0 -0
  106. /package/dist/{map-pin-BGiCvyCs.js → map-pin-Dznl5l40.js} +0 -0
  107. /package/dist/{navigation-D36prnAn.js → navigation-BJSJl-VG.js} +0 -0
  108. /package/dist/{package-BChUzpJS.js → package-CcRttV2N.js} +0 -0
  109. /package/dist/{phone-Ac9s3mo_.js → phone-DVSZHt_j.js} +0 -0
  110. /package/dist/{plus-Z76q-FZm.js → plus-C_XZDwBz.js} +0 -0
  111. /package/dist/{refresh-cw-DKt6f5T6.js → refresh-cw-CfgN67l3.js} +0 -0
  112. /package/dist/{save-CojywNhG.js → save-DJE8Fpdv.js} +0 -0
  113. /package/dist/{search-CDGefqb3.js → search-D_VyGcZT.js} +0 -0
  114. /package/dist/{square-pen-CopE9GmB.js → square-pen-D-6JYrVQ.js} +0 -0
  115. /package/dist/{star-nHlLjDKR.js → star-BpRlUZuU.js} +0 -0
  116. /package/dist/{store-BR9Voi5E.js → store-B-8HMuyZ.js} +0 -0
  117. /package/dist/{trash-2-BKhar6Hs.js → trash-2-8vmv-XV_.js} +0 -0
  118. /package/dist/{trending-up-Dohgd4t_.js → trending-up-Dz6PqPRj.js} +0 -0
  119. /package/dist/{triangle-alert-i-XQL9Rd.js → triangle-alert-D7Y3J_4z.js} +0 -0
  120. /package/dist/{truck-C0v_hbLR.js → truck-D-y2IDzv.js} +0 -0
  121. /package/dist/{useAssistant-D4v3oxD3.js → useAssistant-DVW4QgQb.js} +0 -0
  122. /package/dist/{useImportExport-DHr-SWSw.js → useImportExport-DrPDHEDK.js} +0 -0
  123. /package/dist/{useSettings-CkDH_-gM.js → useSettings-BB8X2RLh.js} +0 -0
  124. /package/dist/{user-jyFU3lXk.js → user-DzfTn4dE.js} +0 -0
  125. /package/dist/{users-DZ-ilS6B.js → users-CCrq-w0C.js} +0 -0
  126. /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: 500, offset: 0 },
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
- setIsSubmitting(true);
93
+ setFormError(null);
75
94
 
76
- try {
77
- const selectedCustomer = customers.find((c) => c.id === formData.customerId);
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: selectedCustomer
82
- ? {
83
- name: selectedCustomer.name,
84
- email: selectedCustomer.email,
85
- phone: selectedCustomer.phone,
86
- company: selectedCustomer.company,
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
- console.error('Failed to create order:', error);
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
- <Button>
203
- <Edit size={16} /> Edit
204
- </Button>
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
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
355
+ {editing ? (
252
356
  <div className="space-y-4">
253
- <div className="flex items-center gap-3">
254
- <Mail size={18} className="text-text-secondary" />
255
- <div>
256
- <p className="text-sm text-text-secondary">Email</p>
257
- <p className="font-medium">{customer.email}</p>
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
- </div>
260
- <div className="flex items-center gap-3">
261
- <Phone size={18} className="text-text-secondary" />
262
- <div>
263
- <p className="text-sm text-text-secondary">Phone</p>
264
- <p className="font-medium">{customer.phone}</p>
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
- <div className="space-y-4">
269
- {customer.company && (
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
- <Building size={18} className="text-text-secondary" />
401
+ <Mail size={18} className="text-text-secondary" />
272
402
  <div>
273
- <p className="text-sm text-text-secondary">Company</p>
274
- <p className="font-medium">{customer.company}</p>
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
- {customer.website && (
279
- <div>
280
- <p className="text-sm text-text-secondary">Website</p>
281
- <a href={customer.website} className="text-primary hover:underline">
282
- {customer.website}
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
- </div>
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">