@burdenoff/microfe-movethewheels 2026.929.4 → 2026.930.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/CustomerPicker.js +52 -48
  2. package/dist/components/EntityCombobox.js +25 -23
  3. package/dist/components/OrderItemRow.js +123 -78
  4. package/dist/hooks/useCustomers.js +52 -17
  5. package/dist/hooks/useFleet.js +126 -44
  6. package/dist/hooks/useInventory.js +43 -7
  7. package/dist/hooks/useMultiModalShipments.js +30 -26
  8. package/dist/hooks/useRoutes.js +55 -15
  9. package/dist/movethewheels/components/CustomerPicker.d.ts +5 -1
  10. package/dist/movethewheels/components/EntityCombobox.d.ts +6 -3
  11. package/dist/movethewheels/components/OrderItemRow.d.ts +7 -1
  12. package/dist/movethewheels/hooks/useCustomers.d.ts +3 -0
  13. package/dist/movethewheels/hooks/useFleet.d.ts +6 -0
  14. package/dist/movethewheels/hooks/useInventory.d.ts +2 -0
  15. package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +1 -0
  16. package/dist/movethewheels/hooks/useRoutes.d.ts +3 -0
  17. package/dist/movethewheels/utils/orderValidation.d.ts +11 -0
  18. package/dist/pages/CreateOrderPage.js +349 -297
  19. package/dist/pages/DriverDetailsPage.js +337 -333
  20. package/dist/pages/DriversPage.js +3 -1
  21. package/dist/pages/MultiModalShipmentPage.js +139 -125
  22. package/dist/pages/OrderDetailsPage.js +132 -140
  23. package/dist/pages/RouteDetailsPage.js +202 -202
  24. package/dist/pages/TrackingPage.js +2 -1
  25. package/dist/utils/orderValidation.js +18 -0
  26. package/package.json +1 -1
  27. package/src/movethewheels/components/CustomerPicker.tsx +12 -0
  28. package/src/movethewheels/components/EntityCombobox.tsx +30 -11
  29. package/src/movethewheels/components/OrderItemRow.tsx +44 -0
  30. package/src/movethewheels/hooks/useCustomers.ts +38 -0
  31. package/src/movethewheels/hooks/useFleet.ts +125 -5
  32. package/src/movethewheels/hooks/useInventory.ts +42 -0
  33. package/src/movethewheels/hooks/useMultiModalShipments.ts +9 -7
  34. package/src/movethewheels/hooks/useRoutes.ts +51 -0
  35. package/src/movethewheels/pages/CreateOrderPage.tsx +66 -2
  36. package/src/movethewheels/pages/DriverDetailsPage.tsx +12 -1
  37. package/src/movethewheels/pages/DriversPage.tsx +7 -1
  38. package/src/movethewheels/pages/MultiModalShipmentPage.tsx +15 -1
  39. package/src/movethewheels/pages/OrderDetailsPage.tsx +25 -25
  40. package/src/movethewheels/pages/RouteDetailsPage.tsx +8 -4
  41. package/src/movethewheels/pages/TrackingPage.tsx +7 -2
  42. package/src/movethewheels/utils/orderValidation.ts +53 -0
@@ -18,8 +18,11 @@ interface UseRoutesResult {
18
18
  /** True when the last fetch has another server page. */
19
19
  hasMore: boolean;
20
20
  isLoading: boolean;
21
+ /** True while an additional server page is appended. */
22
+ isLoadingMore: boolean;
21
23
  error: Error | null;
22
24
  refetch: () => void;
25
+ loadMore: () => Promise<void>;
23
26
  createRoute: (route: Partial<Route>) => Promise<Route>;
24
27
  updateRoute: (
25
28
  id: string,
@@ -275,6 +278,7 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
275
278
  const [totalRoutes, setTotalRoutes] = useState(0);
276
279
  const [hasMore, setHasMore] = useState(false);
277
280
  const [isLoading, setIsLoading] = useState(false);
281
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
278
282
  const [error, setError] = useState<Error | null>(null);
279
283
 
280
284
  const load = useCallback(async () => {
@@ -304,6 +308,51 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
304
308
  }
305
309
  }, [driverId, graphQLOptions, search, status, vehicleId, limit, offset]);
306
310
 
311
+ const loadMore = useCallback(async () => {
312
+ if (!hasMore || isLoading || isLoadingMore) return;
313
+ const nextOffset = offset + routes.length;
314
+ setIsLoadingMore(true);
315
+ setError(null);
316
+ try {
317
+ const data = await executeGraphQL<{ routes: ApiRoute[]; routesCount: number }>(
318
+ graphQLOptions,
319
+ LIST_ROUTES_QUERY,
320
+ {
321
+ filter: {
322
+ driverId,
323
+ vehicleId,
324
+ status: status ? toApiRouteStatus(status) : undefined,
325
+ search: search || undefined,
326
+ },
327
+ pagination: { limit, offset: nextOffset },
328
+ }
329
+ );
330
+ const mapped = data.routes.map(mapRoute);
331
+ setTotalRoutes(data.routesCount);
332
+ setHasMore(nextOffset + mapped.length < data.routesCount);
333
+ setRoutes((previous) => {
334
+ const existingIds = new Set(previous.map((route) => route.id));
335
+ return [...previous, ...mapped.filter((route) => !existingIds.has(route.id))];
336
+ });
337
+ } catch (fetchError) {
338
+ setError(fetchError instanceof Error ? fetchError : new Error('Failed to load more routes'));
339
+ } finally {
340
+ setIsLoadingMore(false);
341
+ }
342
+ }, [
343
+ driverId,
344
+ graphQLOptions,
345
+ hasMore,
346
+ isLoading,
347
+ isLoadingMore,
348
+ limit,
349
+ offset,
350
+ routes.length,
351
+ search,
352
+ status,
353
+ vehicleId,
354
+ ]);
355
+
307
356
  useEffect(() => {
308
357
  void load();
309
358
  }, [load]);
@@ -436,8 +485,10 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
436
485
  totalRoutes,
437
486
  hasMore,
438
487
  isLoading,
488
+ isLoadingMore,
439
489
  error,
440
490
  refetch,
491
+ loadMore,
441
492
  createRoute,
442
493
  updateRoute,
443
494
  deleteRoute,
@@ -48,6 +48,7 @@ import { CustomerPicker } from '../components/CustomerPicker';
48
48
  import { EntityCombobox } from '../components/EntityCombobox';
49
49
  import { OrderItemRow } from '../components/OrderItemRow';
50
50
  import type { Customer } from '../types';
51
+ import { validateOrderItems, type OrderItemFieldErrors } from '../utils/orderValidation';
51
52
 
52
53
  /**
53
54
  * `<input type="datetime-local">` needs a `YYYY-MM-DDTHH:mm` string in LOCAL
@@ -77,8 +78,12 @@ export default function CreateOrderPage() {
77
78
  customers,
78
79
  createCustomer,
79
80
  isLoading: customersLoading,
81
+ isLoadingMore: customersLoadingMore,
80
82
  error: customersError,
81
83
  refetch: refetchCustomers,
84
+ loadMore: loadMoreCustomers,
85
+ hasMore: customersHaveMore,
86
+ totalCustomers,
82
87
  } = useCustomers({
83
88
  limit: 20,
84
89
  filters: { search: customerSearch || undefined },
@@ -91,13 +96,22 @@ export default function CreateOrderPage() {
91
96
  error: routesError,
92
97
  hasMore: routesHaveMore,
93
98
  totalRoutes,
99
+ loadMore: loadMoreRoutes,
100
+ isLoadingMore: routesLoadingMore,
94
101
  } = useRoutes({
95
102
  status: 'planned',
96
103
  search: routeSearch || undefined,
97
104
  limit: 20,
98
105
  });
99
106
  const [productSearch, setProductSearch] = useState('');
100
- const { products, isLoading: productsLoading } = useProducts({
107
+ const {
108
+ products,
109
+ isLoading: productsLoading,
110
+ isLoadingMore: productsLoadingMore,
111
+ hasMore: productsHaveMore,
112
+ totalProducts,
113
+ loadMore: loadMoreProducts,
114
+ } = useProducts({
101
115
  search: productSearch || undefined,
102
116
  limit: 20,
103
117
  });
@@ -124,6 +138,7 @@ export default function CreateOrderPage() {
124
138
  country: order?.deliveryAddress.country || 'USA',
125
139
  },
126
140
  items: order?.items || [],
141
+ trackingNumber: order?.trackingNumber || '',
127
142
  scheduledDelivery: toDateTimeLocalValue(order?.scheduledDelivery),
128
143
  specialInstructions: order?.specialInstructions || '',
129
144
  });
@@ -137,6 +152,7 @@ export default function CreateOrderPage() {
137
152
 
138
153
  const [isSubmitting, setIsSubmitting] = useState(false);
139
154
  const [formError, setFormError] = useState<string | null>(null);
155
+ const [itemErrors, setItemErrors] = useState<Record<string, OrderItemFieldErrors>>({});
140
156
 
141
157
  useEffect(() => {
142
158
  if (!linkedCustomer) {
@@ -176,6 +192,7 @@ export default function CreateOrderPage() {
176
192
  country: order.deliveryAddress.country || 'USA',
177
193
  },
178
194
  items: order.items || [],
195
+ trackingNumber: order.trackingNumber || '',
179
196
  scheduledDelivery: toDateTimeLocalValue(order.scheduledDelivery),
180
197
  specialInstructions: order.specialInstructions || '',
181
198
  });
@@ -210,6 +227,11 @@ export default function CreateOrderPage() {
210
227
  if (formData.items.length === 0) {
211
228
  return 'Add at least one item to the order.';
212
229
  }
230
+ const nextItemErrors = validateOrderItems(formData.items);
231
+ setItemErrors(nextItemErrors);
232
+ if (Object.keys(nextItemErrors).length > 0) {
233
+ return 'Fix the highlighted shipment item fields before continuing.';
234
+ }
213
235
  if (formData.scheduledDelivery && Number.isNaN(Date.parse(formData.scheduledDelivery))) {
214
236
  return 'Enter a valid scheduled delivery date and time.';
215
237
  }
@@ -251,6 +273,7 @@ export default function CreateOrderPage() {
251
273
  deliveryAddress: formData.deliveryAddress,
252
274
  items: formData.items,
253
275
  // `null` clears an existing slot on edit; the service field is nullable.
276
+ trackingNumber: formData.trackingNumber.trim() || undefined,
254
277
  scheduledDelivery: formData.scheduledDelivery ? new Date(formData.scheduledDelivery) : null,
255
278
  routeId: formData.routeId || undefined,
256
279
  specialInstructions: formData.specialInstructions,
@@ -346,6 +369,14 @@ export default function CreateOrderPage() {
346
369
  };
347
370
 
348
371
  const removeItem = (index: number) => {
372
+ const itemId = formData.items[index]?.id;
373
+ if (itemId) {
374
+ setItemErrors((previous) => {
375
+ const next = { ...previous };
376
+ delete next[itemId];
377
+ return next;
378
+ });
379
+ }
349
380
  setFormData((prev) => ({
350
381
  ...prev,
351
382
  items: prev.items.filter((_, i) => i !== index),
@@ -353,6 +384,14 @@ export default function CreateOrderPage() {
353
384
  };
354
385
 
355
386
  const updateItem = (index: number, field: string, value: unknown) => {
387
+ const itemId = formData.items[index]?.id;
388
+ if (itemId && itemErrors[itemId]) {
389
+ setItemErrors((previous) => {
390
+ const next = { ...previous };
391
+ delete next[itemId];
392
+ return next;
393
+ });
394
+ }
356
395
  setFormData((prev) => ({
357
396
  ...prev,
358
397
  items: prev.items.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
@@ -489,6 +528,10 @@ export default function CreateOrderPage() {
489
528
  onOpenCustomers={goToCustomerCreate}
490
529
  selectedCustomer={selectedCustomer}
491
530
  onSearchQueryChange={setCustomerSearch}
531
+ hasMore={customersHaveMore}
532
+ totalCount={totalCustomers}
533
+ onLoadMore={loadMoreCustomers}
534
+ isLoadingMore={customersLoadingMore}
492
535
  />
493
536
  <div className="space-y-2">
494
537
  <Label htmlFor="priority">Priority</Label>
@@ -555,6 +598,8 @@ export default function CreateOrderPage() {
555
598
  onSearchChange={setRouteSearch}
556
599
  hasMore={routesHaveMore}
557
600
  totalCount={totalRoutes}
601
+ onLoadMore={loadMoreRoutes}
602
+ loadingMore={routesLoadingMore}
558
603
  />
559
604
  <p className="mt-2 text-xs text-text-secondary" role="status">
560
605
  {routesLoading
@@ -782,6 +827,11 @@ export default function CreateOrderPage() {
782
827
  index={index}
783
828
  products={products}
784
829
  productsLoading={productsLoading}
830
+ productsLoadingMore={productsLoadingMore}
831
+ productsHasMore={productsHaveMore}
832
+ productsTotalCount={totalProducts}
833
+ onLoadMoreProducts={loadMoreProducts}
834
+ errors={itemErrors[item.id]}
785
835
  onUpdate={updateItem}
786
836
  onRemove={removeItem}
787
837
  />
@@ -803,6 +853,20 @@ export default function CreateOrderPage() {
803
853
  description="Optional delivery timing and instructions for the carrier."
804
854
  >
805
855
  <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
856
+ <div className="space-y-2">
857
+ <Label htmlFor="trackingNumber">Tracking / reference number</Label>
858
+ <Input
859
+ id="trackingNumber"
860
+ value={formData.trackingNumber}
861
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
862
+ updateField('trackingNumber', e.target.value)
863
+ }
864
+ placeholder="Optional carrier reference"
865
+ />
866
+ <p className="text-xs text-text-secondary">
867
+ Use this when a carrier or customer already supplied a tracking reference.
868
+ </p>
869
+ </div>
806
870
  <div className="space-y-2">
807
871
  <Label htmlFor="scheduledDelivery">Scheduled delivery</Label>
808
872
  <Input
@@ -817,7 +881,7 @@ export default function CreateOrderPage() {
817
881
  Leave blank to dispatch as soon as a driver is available.
818
882
  </p>
819
883
  </div>
820
- <div className="space-y-2 lg:col-span-2">
884
+ <div className="space-y-2 lg:col-span-1">
821
885
  <Label htmlFor="specialInstructions">Special instructions</Label>
822
886
  <Textarea
823
887
  id="specialInstructions"
@@ -132,7 +132,14 @@ function NewDriverForm({ editId }: { editId?: string }) {
132
132
 
133
133
  const [vehicleSearch, setVehicleSearch] = useState('');
134
134
  const debouncedVehicleSearch = useDebouncedValue(vehicleSearch);
135
- const { vehicles, isLoading: vehiclesLoading } = useVehicles({
135
+ const {
136
+ vehicles,
137
+ isLoading: vehiclesLoading,
138
+ isLoadingMore: vehiclesLoadingMore,
139
+ hasMore: vehiclesHaveMore,
140
+ totalVehicles,
141
+ loadMore: loadMoreVehicles,
142
+ } = useVehicles({
136
143
  limit: 20,
137
144
  search: debouncedVehicleSearch || undefined,
138
145
  });
@@ -434,6 +441,10 @@ function NewDriverForm({ editId }: { editId?: string }) {
434
441
  )}
435
442
  selectedOption={selectedVehicle}
436
443
  selectedLoading={selectedVehicleLoading}
444
+ hasMore={vehiclesHaveMore}
445
+ totalCount={totalVehicles}
446
+ onLoadMore={loadMoreVehicles}
447
+ loadingMore={vehiclesLoadingMore}
437
448
  placeholder="Unassigned"
438
449
  emptyText="No vehicles match that search."
439
450
  />
@@ -299,7 +299,13 @@ export default function DriversPage() {
299
299
  ))}
300
300
  </SelectContent>
301
301
  </Select>
302
- <Select value={licenseClassFilter} onValueChange={setLicenseClassFilter}>
302
+ <Select
303
+ value={licenseClassFilter}
304
+ onValueChange={(value) => {
305
+ setLicenseClassFilter(value);
306
+ setPage(0);
307
+ }}
308
+ >
303
309
  <SelectTrigger className="w-[180px]">
304
310
  <SelectValue placeholder="License class" />
305
311
  </SelectTrigger>
@@ -81,6 +81,8 @@ function formatMoney(
81
81
  export default function MultiModalShipmentPage() {
82
82
  const [pageIndex, setPageIndex] = useState(0);
83
83
  const pageSize = 10;
84
+ const [shipmentNumber, setShipmentNumber] = useState('');
85
+ const [shipmentSearch, setShipmentSearch] = useState('');
84
86
  const {
85
87
  shipments,
86
88
  totalShipments,
@@ -92,9 +94,9 @@ export default function MultiModalShipmentPage() {
92
94
  } = useMultiModalShipments({
93
95
  limit: pageSize,
94
96
  offset: pageIndex * pageSize,
97
+ search: shipmentSearch,
95
98
  });
96
99
 
97
- const [shipmentNumber, setShipmentNumber] = useState('');
98
100
  const [originAddress, setOriginAddress] = useState('');
99
101
  const [destinationAddress, setDestinationAddress] = useState('');
100
102
  const [legs, setLegs] = useState<ShipmentLegDraft[]>([emptyLeg()]);
@@ -529,6 +531,18 @@ export default function MultiModalShipmentPage() {
529
531
  </PageSection>
530
532
 
531
533
  <PageSection title="Planned Multi-Modal Shipments">
534
+ <div className="mb-4 max-w-xl space-y-2">
535
+ <Label htmlFor="planned-shipment-search">Search planned shipments</Label>
536
+ <Input
537
+ id="planned-shipment-search"
538
+ value={shipmentSearch}
539
+ onChange={(event) => {
540
+ setShipmentSearch(event.target.value);
541
+ setPageIndex(0);
542
+ }}
543
+ placeholder="Search by shipment or tracking number"
544
+ />
545
+ </div>
532
546
  {isLoading ? (
533
547
  <LoadingState message="Loading multi-modal shipments..." />
534
548
  ) : error ? (
@@ -4,7 +4,7 @@
4
4
  * Displays detailed information about a specific order.
5
5
  */
6
6
 
7
- import { useEffect, useState } from 'react';
7
+ import { useState } from 'react';
8
8
  import { useParams } from 'react-router';
9
9
  import {
10
10
  Package,
@@ -105,15 +105,27 @@ export default function OrderDetailsPage() {
105
105
  const [advancing, setAdvancing] = useState(false);
106
106
  const [actionError, setActionError] = useState<string | null>(null);
107
107
  const debouncedFleetSearch = useDebouncedValue(fleetSearch);
108
- const { vehicles, isLoading: vehiclesLoading } = useVehicles({
108
+ const {
109
+ vehicles,
110
+ isLoading: vehiclesLoading,
111
+ isLoadingMore: vehiclesLoadingMore,
112
+ hasMore: vehiclesHaveMore,
113
+ totalVehicles,
114
+ loadMore: loadMoreVehicles,
115
+ } = useVehicles({
109
116
  limit: 20,
110
117
  search: debouncedFleetSearch || undefined,
111
- driverId: selectedDriver || undefined,
112
118
  });
113
- const { drivers, isLoading: driversLoading } = useDrivers({
119
+ const {
120
+ drivers,
121
+ isLoading: driversLoading,
122
+ isLoadingMore: driversLoadingMore,
123
+ hasMore: driversHaveMore,
124
+ totalDrivers,
125
+ loadMore: loadMoreDrivers,
126
+ } = useDrivers({
114
127
  limit: 20,
115
128
  search: debouncedFleetSearch || undefined,
116
- vehicleId: selectedVehicle || undefined,
117
129
  });
118
130
  // Resolved independently of the (searched, 20-row) lists above so the
119
131
  // trigger still shows the right label once a pick scrolls out of the
@@ -132,26 +144,6 @@ export default function OrderDetailsPage() {
132
144
  (driver) => driver.status === 'available' || driver.id === order?.driverId
133
145
  );
134
146
 
135
- useEffect(() => {
136
- if (
137
- !driversLoading &&
138
- selectedDriver &&
139
- !drivers.some((driver) => driver.id === selectedDriver)
140
- ) {
141
- setSelectedDriver('');
142
- }
143
- }, [drivers, driversLoading, selectedDriver]);
144
-
145
- useEffect(() => {
146
- if (
147
- !vehiclesLoading &&
148
- selectedVehicle &&
149
- !vehicles.some((vehicle) => vehicle.id === selectedVehicle)
150
- ) {
151
- setSelectedVehicle('');
152
- }
153
- }, [selectedVehicle, vehicles, vehiclesLoading]);
154
-
155
147
  const handleAssign = async () => {
156
148
  if (!orderId || !order) {
157
149
  setActionError('This order is no longer available. Refresh and try again.');
@@ -607,6 +599,10 @@ export default function OrderDetailsPage() {
607
599
  )}
608
600
  selectedOption={selectedVehicleRecord}
609
601
  selectedLoading={selectedVehicleLoading}
602
+ hasMore={vehiclesHaveMore}
603
+ totalCount={totalVehicles}
604
+ onLoadMore={loadMoreVehicles}
605
+ loadingMore={vehiclesLoadingMore}
610
606
  required
611
607
  placeholder={
612
608
  order.vehicleId ? `Current: ${order.vehicleId}` : 'Select a vehicle'
@@ -634,6 +630,10 @@ export default function OrderDetailsPage() {
634
630
  )}
635
631
  selectedOption={selectedDriverRecord}
636
632
  selectedLoading={selectedDriverLoading}
633
+ hasMore={driversHaveMore}
634
+ totalCount={totalDrivers}
635
+ onLoadMore={loadMoreDrivers}
636
+ loadingMore={driversLoadingMore}
637
637
  required
638
638
  placeholder={
639
639
  order.driverId ? `Current: ${order.driverId}` : 'Select a driver'
@@ -95,20 +95,22 @@ function NewRouteForm({ editId }: { editId?: string }) {
95
95
  isLoading: vehiclesLoading,
96
96
  hasMore: vehiclesHaveMore,
97
97
  totalVehicles,
98
+ loadMore: loadMoreVehicles,
99
+ isLoadingMore: vehiclesLoadingMore,
98
100
  } = useVehicles({
99
101
  limit: 20,
100
102
  search: vehicleSearch || undefined,
101
- driverId: form.driverId || undefined,
102
103
  });
103
104
  const {
104
105
  drivers,
105
106
  isLoading: driversLoading,
106
107
  hasMore: driversHaveMore,
107
108
  totalDrivers,
109
+ loadMore: loadMoreDrivers,
110
+ isLoadingMore: driversLoadingMore,
108
111
  } = useDrivers({
109
112
  limit: 20,
110
113
  search: driverSearch || undefined,
111
- vehicleId: form.vehicleId || undefined,
112
114
  });
113
115
  // The selected vehicle/driver may fall outside the current search page (e.g. when
114
116
  // editing a route or after the assignee no longer matches the search term); fetch
@@ -314,7 +316,8 @@ function NewRouteForm({ editId }: { editId?: string }) {
314
316
  searchPlaceholder="Search vehicle number or type…"
315
317
  hasMore={vehiclesHaveMore}
316
318
  totalCount={totalVehicles}
317
- required
319
+ onLoadMore={loadMoreVehicles}
320
+ loadingMore={vehiclesLoadingMore}
318
321
  placeholder="Assign later"
319
322
  emptyText="No available vehicles."
320
323
  />
@@ -340,7 +343,8 @@ function NewRouteForm({ editId }: { editId?: string }) {
340
343
  searchPlaceholder="Search driver name or employee ID…"
341
344
  hasMore={driversHaveMore}
342
345
  totalCount={totalDrivers}
343
- required
346
+ onLoadMore={loadMoreDrivers}
347
+ loadingMore={driversLoadingMore}
344
348
  placeholder="Assign later"
345
349
  emptyText="No available drivers."
346
350
  />
@@ -120,7 +120,7 @@ export default function TrackingPage() {
120
120
  {/* Search Form */}
121
121
  <Card>
122
122
  <CardContent className="p-6">
123
- <form onSubmit={handleSearch} className="flex gap-4">
123
+ <form onSubmit={handleSearch} className="flex flex-col gap-4 sm:flex-row">
124
124
  <div className="flex-1 relative">
125
125
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary" />
126
126
  <Input
@@ -133,7 +133,12 @@ export default function TrackingPage() {
133
133
  className="pl-10 h-12 text-lg"
134
134
  />
135
135
  </div>
136
- <Button type="submit" size="lg" disabled={!trackingInput || isLoading}>
136
+ <Button
137
+ type="submit"
138
+ size="lg"
139
+ className="w-full sm:w-auto"
140
+ disabled={!trackingInput || isLoading}
141
+ >
137
142
  {isLoading
138
143
  ? t('movethewheels.tracking.search.searching', 'Searching...')
139
144
  : t('movethewheels.tracking.search.track', 'Track')}
@@ -0,0 +1,53 @@
1
+ import type { OrderItem } from '../types';
2
+
3
+ export type OrderItemField =
4
+ 'name' | 'sku' | 'quantity' | 'weight' | 'value' | 'length' | 'width' | 'height';
5
+
6
+ export type OrderItemFieldErrors = Partial<Record<OrderItemField, string>>;
7
+
8
+ const isFiniteNonNegative = (value: number): boolean => Number.isFinite(value) && value >= 0;
9
+
10
+ /**
11
+ * Validates one delivery-job cargo line without imposing pricing or order
12
+ * semantics that belong to the service. A catalog product may supply the name;
13
+ * every line still carries its SKU because cargo-item records require one.
14
+ */
15
+ export function validateOrderItem(item: OrderItem): OrderItemFieldErrors {
16
+ const errors: OrderItemFieldErrors = {};
17
+ const hasProduct = item.productId.trim().length > 0;
18
+ const hasName = item.name.trim().length > 0;
19
+
20
+ if (!hasProduct && !hasName) {
21
+ errors.name = 'Enter an item name or select a catalog product.';
22
+ }
23
+ if (item.sku.trim().length === 0) {
24
+ errors.sku = 'SKU is required for every shipment item.';
25
+ }
26
+ if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
27
+ errors.quantity = 'Quantity must be a finite number greater than 0.';
28
+ }
29
+ if (!isFiniteNonNegative(item.weight)) {
30
+ errors.weight = 'Weight must be a finite number of 0 or more.';
31
+ }
32
+ if (!isFiniteNonNegative(item.value)) {
33
+ errors.value = 'Value must be a finite number of 0 or more.';
34
+ }
35
+
36
+ const dimensions = item.dimensions;
37
+ for (const field of ['length', 'width', 'height'] as const) {
38
+ if (!dimensions || !isFiniteNonNegative(dimensions[field])) {
39
+ errors[field] =
40
+ `${field[0].toUpperCase()}${field.slice(1)} must be a finite number of 0 or more.`;
41
+ }
42
+ }
43
+
44
+ return errors;
45
+ }
46
+
47
+ export function validateOrderItems(items: OrderItem[]): Record<string, OrderItemFieldErrors> {
48
+ return Object.fromEntries(
49
+ items
50
+ .map((item) => [item.id, validateOrderItem(item)] as const)
51
+ .filter(([, errors]) => Object.keys(errors).length > 0)
52
+ );
53
+ }