@burdenoff/microfe-movethewheels 2026.923.1 → 2026.924.2

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.
@@ -11,7 +11,6 @@ import {
11
11
  ArrowLeft,
12
12
  Save,
13
13
  Plus,
14
- Trash2,
15
14
  Users,
16
15
  MapPin,
17
16
  CalendarClock,
@@ -33,16 +32,17 @@ import {
33
32
  SelectItem,
34
33
  SelectTrigger,
35
34
  SelectValue,
36
- Separator,
37
35
  } from '../components/ui';
38
36
  import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
39
37
  import { useOrder, useOrders } from '../hooks/useOrders';
40
38
  import { useCustomer, useCustomers } from '../hooks/useCustomers';
41
- import { useRoutes } from '../hooks/useRoutes';
42
- import { useDebouncedValue } from '../hooks/useDebouncedValue';
39
+ import { useRoute, useRoutes } from '../hooks/useRoutes';
40
+ import { useProducts } from '../hooks/useInventory';
43
41
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
44
42
  import { joinPath, buildCustomerCreatePath } from '../utils/navigation';
45
43
  import { CustomerPicker } from '../components/CustomerPicker';
44
+ import { EntityCombobox } from '../components/EntityCombobox';
45
+ import { OrderItemRow } from '../components/OrderItemRow';
46
46
  import type { Customer } from '../types';
47
47
 
48
48
  /**
@@ -69,7 +69,6 @@ export default function CreateOrderPage() {
69
69
  const { order } = useOrder(orderId);
70
70
  const { createOrder, updateOrder } = useOrders();
71
71
  const [customerSearch, setCustomerSearch] = useState('');
72
- const debouncedCustomerSearch = useDebouncedValue(customerSearch);
73
72
  const {
74
73
  customers,
75
74
  createCustomer,
@@ -78,15 +77,24 @@ export default function CreateOrderPage() {
78
77
  refetch: refetchCustomers,
79
78
  } = useCustomers({
80
79
  limit: 20,
81
- filters: { search: debouncedCustomerSearch || undefined },
80
+ filters: { search: customerSearch || undefined },
82
81
  });
83
82
  const { customer: linkedCustomer } = useCustomer(prefilledCustomerId ?? order?.customerId);
83
+ const [routeSearch, setRouteSearch] = useState('');
84
84
  const {
85
85
  routes,
86
86
  isLoading: routesLoading,
87
87
  error: routesError,
88
+ hasMore: routesHaveMore,
89
+ totalRoutes,
88
90
  } = useRoutes({
89
91
  status: 'planned',
92
+ search: routeSearch || undefined,
93
+ limit: 20,
94
+ });
95
+ const [productSearch, setProductSearch] = useState('');
96
+ const { products, isLoading: productsLoading } = useProducts({
97
+ search: productSearch || undefined,
90
98
  limit: 20,
91
99
  });
92
100
 
@@ -116,6 +124,13 @@ export default function CreateOrderPage() {
116
124
  specialInstructions: order?.specialInstructions || '',
117
125
  });
118
126
 
127
+ // Resolved independently of the (searchable, 20-row) `routes` page so the
128
+ // saved-route combobox's trigger still shows the right name even when the
129
+ // selection has scrolled out of the current search/page.
130
+ const { route: selectedRoute, isLoading: selectedRouteLoading } = useRoute(
131
+ formData.routeId || undefined
132
+ );
133
+
119
134
  const [isSubmitting, setIsSubmitting] = useState(false);
120
135
  const [formError, setFormError] = useState<string | null>(null);
121
136
 
@@ -275,7 +290,7 @@ export default function CreateOrderPage() {
275
290
  };
276
291
 
277
292
  const selectSavedRoute = (value: string) => {
278
- if (value === 'none') {
293
+ if (!value) {
279
294
  updateField('routeId', '');
280
295
  return;
281
296
  }
@@ -431,7 +446,6 @@ export default function CreateOrderPage() {
431
446
  onRetry={refetchCustomers}
432
447
  onOpenCustomers={goToCustomerCreate}
433
448
  selectedCustomer={selectedCustomer}
434
- searchQuery={customerSearch}
435
449
  onSearchQueryChange={setCustomerSearch}
436
450
  />
437
451
  <div className="space-y-2">
@@ -464,19 +478,30 @@ export default function CreateOrderPage() {
464
478
  </CardTitle>
465
479
  </CardHeader>
466
480
  <CardContent className="space-y-2">
467
- <Select value={formData.routeId || 'none'} onValueChange={selectSavedRoute}>
468
- <SelectTrigger id="saved-route">
469
- <SelectValue placeholder="Choose a saved route" />
470
- </SelectTrigger>
471
- <SelectContent>
472
- <SelectItem value="none">No saved route</SelectItem>
473
- {routes.map((route) => (
474
- <SelectItem key={route.id} value={route.id}>
475
- {route.name} · {route.stops.length} stops
476
- </SelectItem>
477
- ))}
478
- </SelectContent>
479
- </Select>
481
+ <EntityCombobox
482
+ id="saved-route"
483
+ value={formData.routeId}
484
+ onChange={selectSavedRoute}
485
+ options={routes}
486
+ loading={routesLoading}
487
+ getId={(route) => route.id}
488
+ getLabel={(route) => `${route.name} · ${route.stops.length} stops`}
489
+ renderOption={(route) => (
490
+ <>
491
+ {route.name} · {route.stops.length} stops
492
+ </>
493
+ )}
494
+ selectedOption={selectedRoute}
495
+ selectedLoading={selectedRouteLoading}
496
+ placeholder="Choose a saved route"
497
+ searchPlaceholder="Search saved routes"
498
+ emptyText="No saved routes found."
499
+ noneLabel="No saved route"
500
+ error={routesError ? 'Saved routes are unavailable' : undefined}
501
+ onSearchChange={setRouteSearch}
502
+ hasMore={routesHaveMore}
503
+ totalCount={totalRoutes}
504
+ />
480
505
  <p className="text-xs text-text-secondary">
481
506
  {routesLoading
482
507
  ? 'Loading planned routes…'
@@ -630,74 +655,31 @@ export default function CreateOrderPage() {
630
655
  />
631
656
  ) : (
632
657
  <div className="space-y-4">
658
+ <div className="space-y-2">
659
+ <Label htmlFor="item-product-search">Search product catalog</Label>
660
+ <Input
661
+ id="item-product-search"
662
+ value={productSearch}
663
+ onChange={(e) => setProductSearch(e.target.value)}
664
+ placeholder="Search by name or SKU"
665
+ />
666
+ <p className="text-xs text-text-secondary">
667
+ Narrows the "Catalog item" picker below for every row. Picking a catalog item
668
+ fills in name, SKU, weight, and value — you can still edit them, or skip the
669
+ picker and enter details manually.
670
+ </p>
671
+ </div>
633
672
  {formData.items.map((item, index) => (
634
- <div key={item.id}>
635
- {index > 0 && <Separator className="my-4" />}
636
- <div className="grid grid-cols-1 md:grid-cols-7 gap-4">
637
- <div className="md:col-span-2 space-y-2">
638
- <Label>Item Name</Label>
639
- <Input
640
- value={item.name}
641
- onChange={(e) => updateItem(index, 'name', e.target.value)}
642
- placeholder="Product name"
643
- />
644
- </div>
645
- <div className="space-y-2">
646
- <Label>SKU</Label>
647
- <Input
648
- value={item.sku}
649
- onChange={(e) => updateItem(index, 'sku', e.target.value)}
650
- placeholder="SKU-001"
651
- />
652
- </div>
653
- <div className="space-y-2">
654
- <Label>Quantity</Label>
655
- <Input
656
- type="number"
657
- min="1"
658
- value={item.quantity}
659
- onChange={(e) =>
660
- updateItem(index, 'quantity', parseInt(e.target.value) || 1)
661
- }
662
- />
663
- </div>
664
- <div className="space-y-2">
665
- <Label>Weight (kg)</Label>
666
- <Input
667
- type="number"
668
- min="0"
669
- step="0.1"
670
- value={item.weight}
671
- onChange={(e) =>
672
- updateItem(index, 'weight', parseFloat(e.target.value) || 0)
673
- }
674
- />
675
- </div>
676
- <div className="space-y-2">
677
- <Label>Value ($)</Label>
678
- <Input
679
- type="number"
680
- min="0"
681
- step="0.01"
682
- value={item.value}
683
- onChange={(e) =>
684
- updateItem(index, 'value', parseFloat(e.target.value) || 0)
685
- }
686
- />
687
- </div>
688
- <div className="flex items-end">
689
- <Button
690
- type="button"
691
- variant="ghost"
692
- size="icon"
693
- className="text-status-error-text"
694
- onClick={() => removeItem(index)}
695
- >
696
- <Trash2 size={16} />
697
- </Button>
698
- </div>
699
- </div>
700
- </div>
673
+ <OrderItemRow
674
+ key={item.id}
675
+ item={item}
676
+ index={index}
677
+ isFirst={index === 0}
678
+ products={products}
679
+ productsLoading={productsLoading}
680
+ onUpdate={updateItem}
681
+ onRemove={removeItem}
682
+ />
701
683
  ))}
702
684
  </div>
703
685
  )}
@@ -36,11 +36,6 @@ import {
36
36
  Separator,
37
37
  Input,
38
38
  Label,
39
- Select,
40
- SelectContent,
41
- SelectItem,
42
- SelectTrigger,
43
- SelectValue,
44
39
  Table,
45
40
  TableBody,
46
41
  TableCell,
@@ -48,9 +43,10 @@ import {
48
43
  TableHeader,
49
44
  TableRow,
50
45
  } from '../components/ui';
46
+ import { EntityCombobox } from '../components/EntityCombobox';
51
47
  import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
52
48
  import { useOrder, useOrders } from '../hooks/useOrders';
53
- import { useFleet } from '../hooks/useFleet';
49
+ import { useVehicles, useVehicle, useDrivers, useDriver } from '../hooks/useFleet';
54
50
  import { useDebouncedValue } from '../hooks/useDebouncedValue';
55
51
  import { FormError } from '../components/FormError';
56
52
  import { Notice, useNotice } from '../components/Notice';
@@ -105,20 +101,25 @@ export default function OrderDetailsPage() {
105
101
  const [advancing, setAdvancing] = useState(false);
106
102
  const [actionError, setActionError] = useState<string | null>(null);
107
103
  const debouncedFleetSearch = useDebouncedValue(fleetSearch);
108
- const { vehicles, isLoading: vehiclesLoading } = useFleet({
104
+ const { vehicles, isLoading: vehiclesLoading } = useVehicles({
109
105
  limit: 20,
110
- filters: {
111
- search: debouncedFleetSearch || undefined,
112
- driverId: selectedDriver || undefined,
113
- },
106
+ search: debouncedFleetSearch || undefined,
107
+ driverId: selectedDriver || undefined,
114
108
  });
115
- const { drivers, isLoading: driversLoading } = useFleet({
109
+ const { drivers, isLoading: driversLoading } = useDrivers({
116
110
  limit: 20,
117
- filters: {
118
- search: debouncedFleetSearch || undefined,
119
- vehicleId: selectedVehicle || undefined,
120
- },
111
+ search: debouncedFleetSearch || undefined,
112
+ vehicleId: selectedVehicle || undefined,
121
113
  });
114
+ // Resolved independently of the (searched, 20-row) lists above so the
115
+ // trigger still shows the right label once a pick scrolls out of the
116
+ // current search results.
117
+ const { vehicle: selectedVehicleRecord, isLoading: selectedVehicleLoading } = useVehicle(
118
+ selectedVehicle || undefined
119
+ );
120
+ const { driver: selectedDriverRecord, isLoading: selectedDriverLoading } = useDriver(
121
+ selectedDriver || undefined
122
+ );
122
123
  const { message: notice, notify } = useNotice();
123
124
  const availableVehicles = vehicles.filter(
124
125
  (vehicle) => vehicle.status === 'available' || vehicle.id === order?.vehicleId
@@ -564,54 +565,54 @@ export default function OrderDetailsPage() {
564
565
  </div>
565
566
  <div className="space-y-1.5">
566
567
  <Label htmlFor="assign-vehicle">Vehicle</Label>
567
- <Select value={selectedVehicle} onValueChange={setSelectedVehicle}>
568
- <SelectTrigger id="assign-vehicle">
569
- <SelectValue
570
- placeholder={
571
- order.vehicleId ? `Current: ${order.vehicleId}` : 'Select a vehicle'
572
- }
573
- />
574
- </SelectTrigger>
575
- <SelectContent>
576
- {availableVehicles.length === 0 ? (
577
- <SelectItem value="none" disabled>
578
- No available vehicles
579
- </SelectItem>
580
- ) : (
581
- availableVehicles.map((vehicle) => (
582
- <SelectItem key={vehicle.id} value={vehicle.id}>
583
- {vehicle.vehicleNumber} ({vehicle.type})
584
- </SelectItem>
585
- ))
586
- )}
587
- </SelectContent>
588
- </Select>
568
+ <EntityCombobox
569
+ id="assign-vehicle"
570
+ value={selectedVehicle}
571
+ onChange={setSelectedVehicle}
572
+ options={availableVehicles}
573
+ loading={vehiclesLoading}
574
+ getId={(vehicle) => vehicle.id}
575
+ getLabel={(vehicle) => `${vehicle.vehicleNumber} (${vehicle.type})`}
576
+ renderOption={(vehicle) => (
577
+ <>
578
+ {vehicle.vehicleNumber} ({vehicle.type})
579
+ </>
580
+ )}
581
+ selectedOption={selectedVehicleRecord}
582
+ selectedLoading={selectedVehicleLoading}
583
+ required
584
+ placeholder={
585
+ order.vehicleId ? `Current: ${order.vehicleId}` : 'Select a vehicle'
586
+ }
587
+ emptyText="No available vehicles."
588
+ // Search is driven by the shared "Search fleet" box above —
589
+ // no second input inside the popover.
590
+ />
589
591
  </div>
590
592
 
591
593
  <div className="space-y-1.5">
592
594
  <Label htmlFor="assign-driver">Driver</Label>
593
- <Select value={selectedDriver} onValueChange={setSelectedDriver}>
594
- <SelectTrigger id="assign-driver">
595
- <SelectValue
596
- placeholder={
597
- order.driverId ? `Current: ${order.driverId}` : 'Select a driver'
598
- }
599
- />
600
- </SelectTrigger>
601
- <SelectContent>
602
- {availableDrivers.length === 0 ? (
603
- <SelectItem value="none" disabled>
604
- No available drivers
605
- </SelectItem>
606
- ) : (
607
- availableDrivers.map((driver) => (
608
- <SelectItem key={driver.id} value={driver.id}>
609
- {driver.fullName} ({driver.employeeId})
610
- </SelectItem>
611
- ))
612
- )}
613
- </SelectContent>
614
- </Select>
595
+ <EntityCombobox
596
+ id="assign-driver"
597
+ value={selectedDriver}
598
+ onChange={setSelectedDriver}
599
+ options={availableDrivers}
600
+ loading={driversLoading}
601
+ getId={(driver) => driver.id}
602
+ getLabel={(driver) => `${driver.fullName} (${driver.employeeId})`}
603
+ renderOption={(driver) => (
604
+ <>
605
+ {driver.fullName} ({driver.employeeId})
606
+ </>
607
+ )}
608
+ selectedOption={selectedDriverRecord}
609
+ selectedLoading={selectedDriverLoading}
610
+ required
611
+ placeholder={
612
+ order.driverId ? `Current: ${order.driverId}` : 'Select a driver'
613
+ }
614
+ emptyText="No available drivers."
615
+ />
615
616
  </div>
616
617
 
617
618
  <Button
@@ -39,8 +39,9 @@ import {
39
39
  import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
40
40
  import { RouteMap, type RouteStop as MapRouteStop } from '../components/maps';
41
41
  import { firstRealLatLng } from '../utils/mapMarkers';
42
+ import { EntityCombobox } from '../components/EntityCombobox';
42
43
  import { useRoute, useRoutes } from '../hooks/useRoutes';
43
- import { useFleet, useVehicle, useDriver } from '../hooks/useFleet';
44
+ import { useVehicles, useVehicle, useDrivers, useDriver } from '../hooks/useFleet';
44
45
  import { useAssignmentSuggestions } from '../hooks/useFleetIntelligence';
45
46
  import { AssignmentSuggestions } from '../components/AssignmentSuggestions';
46
47
  import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
@@ -51,15 +52,7 @@ import { joinPath } from '../utils/navigation';
51
52
  import { ROUTE_STATUS_COLORS } from '../constants';
52
53
  import { FormError } from '../components/FormError';
53
54
  import { Notice, useNotice } from '../components/Notice';
54
- import type { Route as RouteType, RouteStop, Vehicle, Driver } from '../types';
55
-
56
- /** Keeps a currently selected fleet item visible even when it falls outside the current search page. */
57
- function mergeSelected<T extends { id: string }>(items: T[], selected: T | null | undefined): T[] {
58
- if (!selected || items.some((item) => item.id === selected.id)) {
59
- return items;
60
- }
61
- return [selected, ...items];
62
- }
55
+ import type { Route as RouteType, RouteStop } from '../types';
63
56
 
64
57
  const ROUTE_STATUS_OPTIONS: { value: RouteType['status']; label: string }[] = [
65
58
  { value: 'planned', label: 'Planned' },
@@ -88,27 +81,25 @@ function NewRouteForm({ editId }: { editId?: string }) {
88
81
  driverId: '',
89
82
  stops: [{ address: '', type: 'delivery' }],
90
83
  });
91
- const { vehicles } = useFleet({
84
+ const { vehicles, isLoading: vehiclesLoading } = useVehicles({
92
85
  limit: 20,
93
- filters: {
94
- search: debouncedFleetSearch || undefined,
95
- driverId: form.driverId || undefined,
96
- },
86
+ search: debouncedFleetSearch || undefined,
87
+ driverId: form.driverId || undefined,
97
88
  });
98
- const { drivers } = useFleet({
89
+ const { drivers, isLoading: driversLoading } = useDrivers({
99
90
  limit: 20,
100
- filters: {
101
- search: debouncedFleetSearch || undefined,
102
- vehicleId: form.vehicleId || undefined,
103
- },
91
+ search: debouncedFleetSearch || undefined,
92
+ vehicleId: form.vehicleId || undefined,
104
93
  });
105
94
  // The selected vehicle/driver may fall outside the current search page (e.g. when
106
95
  // editing a route or after the assignee no longer matches the search term); fetch
107
- // them directly so the Select can still render the current selection.
108
- const { vehicle: selectedVehicle } = useVehicle(form.vehicleId || undefined);
109
- const { driver: selectedDriver } = useDriver(form.driverId || undefined);
110
- const vehicleOptions: Vehicle[] = mergeSelected(vehicles, selectedVehicle);
111
- const driverOptions: Driver[] = mergeSelected(drivers, selectedDriver);
96
+ // them directly so the combobox can still render the current selection's label.
97
+ const { vehicle: selectedVehicle, isLoading: selectedVehicleLoading } = useVehicle(
98
+ form.vehicleId || undefined
99
+ );
100
+ const { driver: selectedDriver, isLoading: selectedDriverLoading } = useDriver(
101
+ form.driverId || undefined
102
+ );
112
103
  const [submitting, setSubmitting] = useState(false);
113
104
  const [error, setError] = useState<string | null>(null);
114
105
 
@@ -296,39 +287,48 @@ function NewRouteForm({ editId }: { editId?: string }) {
296
287
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
297
288
  <div className="space-y-2">
298
289
  <Label htmlFor="vehicle">Vehicle (optional)</Label>
299
- <Select
290
+ <EntityCombobox
291
+ id="vehicle"
300
292
  value={form.vehicleId}
301
- onValueChange={(value) => setForm((prev) => ({ ...prev, vehicleId: value }))}
302
- >
303
- <SelectTrigger id="vehicle">
304
- <SelectValue placeholder="Assign later" />
305
- </SelectTrigger>
306
- <SelectContent>
307
- {vehicleOptions.map((vehicle) => (
308
- <SelectItem key={vehicle.id} value={vehicle.id}>
309
- {vehicle.vehicleNumber} ({vehicle.type})
310
- </SelectItem>
311
- ))}
312
- </SelectContent>
313
- </Select>
293
+ onChange={(value) => setForm((prev) => ({ ...prev, vehicleId: value }))}
294
+ options={vehicles}
295
+ loading={vehiclesLoading}
296
+ getId={(vehicle) => vehicle.id}
297
+ getLabel={(vehicle) => `${vehicle.vehicleNumber} (${vehicle.type})`}
298
+ renderOption={(vehicle) => (
299
+ <>
300
+ {vehicle.vehicleNumber} ({vehicle.type})
301
+ </>
302
+ )}
303
+ selectedOption={selectedVehicle}
304
+ selectedLoading={selectedVehicleLoading}
305
+ required
306
+ placeholder="Assign later"
307
+ emptyText="No available vehicles."
308
+ // Search is driven by the shared "Search fleet" box above.
309
+ />
314
310
  </div>
315
311
  <div className="space-y-2">
316
312
  <Label htmlFor="driver">Driver (optional)</Label>
317
- <Select
313
+ <EntityCombobox
314
+ id="driver"
318
315
  value={form.driverId}
319
- onValueChange={(value) => setForm((prev) => ({ ...prev, driverId: value }))}
320
- >
321
- <SelectTrigger id="driver">
322
- <SelectValue placeholder="Assign later" />
323
- </SelectTrigger>
324
- <SelectContent>
325
- {driverOptions.map((driver) => (
326
- <SelectItem key={driver.id} value={driver.id}>
327
- {driver.fullName} ({driver.employeeId})
328
- </SelectItem>
329
- ))}
330
- </SelectContent>
331
- </Select>
316
+ onChange={(value) => setForm((prev) => ({ ...prev, driverId: value }))}
317
+ options={drivers}
318
+ loading={driversLoading}
319
+ getId={(driver) => driver.id}
320
+ getLabel={(driver) => `${driver.fullName} (${driver.employeeId})`}
321
+ renderOption={(driver) => (
322
+ <>
323
+ {driver.fullName} ({driver.employeeId})
324
+ </>
325
+ )}
326
+ selectedOption={selectedDriver}
327
+ selectedLoading={selectedDriverLoading}
328
+ required
329
+ placeholder="Assign later"
330
+ emptyText="No available drivers."
331
+ />
332
332
  </div>
333
333
  </div>
334
334
  </div>