@burdenoff/microfe-movethewheels 2026.923.1 → 2026.924.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-movethewheels",
3
- "version": "2026.923.1",
3
+ "version": "2026.924.1",
4
4
  "description": "Burdenoff MoveTheWheels Microfrontend - Logistics & Supply Chain Management Platform",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,6 +16,7 @@ import {
16
16
  SelectTrigger,
17
17
  SelectValue,
18
18
  } from './ui';
19
+ import { EntityCombobox } from './EntityCombobox';
19
20
  import type { Customer } from '../types';
20
21
  import { FormError } from './FormError';
21
22
 
@@ -30,7 +31,8 @@ interface CustomerPickerProps {
30
31
  onRetry: () => void;
31
32
  onOpenCustomers?: () => void;
32
33
  selectedCustomer?: Customer | null;
33
- searchQuery?: string;
34
+ /** Called with the (already debounced) query text as the user types in the
35
+ * combobox's search box, so the caller can re-fetch `customers` server-side. */
34
36
  onSearchQueryChange?: (query: string) => void;
35
37
  disabled?: boolean;
36
38
  }
@@ -51,13 +53,6 @@ const EMPTY_FORM: CustomerFormState = {
51
53
  company: '',
52
54
  };
53
55
 
54
- function mergeCustomers(customers: Customer[], selectedCustomer?: Customer | null): Customer[] {
55
- if (!selectedCustomer || customers.some((customer) => customer.id === selectedCustomer.id)) {
56
- return customers;
57
- }
58
- return [selectedCustomer, ...customers];
59
- }
60
-
61
56
  export function CustomerPicker({
62
57
  customers,
63
58
  value,
@@ -69,7 +64,6 @@ export function CustomerPicker({
69
64
  onRetry,
70
65
  onOpenCustomers,
71
66
  selectedCustomer,
72
- searchQuery = '',
73
67
  onSearchQueryChange,
74
68
  disabled = false,
75
69
  }: CustomerPickerProps) {
@@ -77,7 +71,6 @@ export function CustomerPicker({
77
71
  const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
78
72
  const [isCreating, setIsCreating] = useState(false);
79
73
  const [createError, setCreateError] = useState<string | null>(null);
80
- const options = mergeCustomers(customers, selectedCustomer);
81
74
 
82
75
  const openCreateDialog = () => {
83
76
  setCreateError(null);
@@ -139,32 +132,29 @@ export function CustomerPicker({
139
132
  </Button>
140
133
  </div>
141
134
  <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
142
- {onSearchQueryChange && (
143
- <Input
144
- value={searchQuery}
145
- onChange={(event) => onSearchQueryChange(event.target.value)}
146
- placeholder="Search customers"
147
- aria-label="Search customers"
148
- disabled={disabled}
149
- className="sm:max-w-56"
150
- />
151
- )}
152
- <Select
135
+ <EntityCombobox
136
+ id="customer"
153
137
  value={value}
154
- onValueChange={onValueChange}
155
- disabled={disabled || isLoading || Boolean(error)}
156
- >
157
- <SelectTrigger id="customer" className="flex-1">
158
- <SelectValue placeholder={isLoading ? 'Loading customers...' : 'Select customer'} />
159
- </SelectTrigger>
160
- <SelectContent>
161
- {options.map((customer) => (
162
- <SelectItem key={customer.id} value={customer.id}>
163
- {customer.name} {customer.email ? `(${customer.email})` : ''}
164
- </SelectItem>
165
- ))}
166
- </SelectContent>
167
- </Select>
138
+ onChange={onValueChange}
139
+ options={customers}
140
+ loading={isLoading}
141
+ getId={(customer) => customer.id}
142
+ getLabel={(customer) => `${customer.name}${customer.email ? ` (${customer.email})` : ''}`}
143
+ renderOption={(customer) => (
144
+ <>
145
+ {customer.name} {customer.email ? `(${customer.email})` : ''}
146
+ </>
147
+ )}
148
+ selectedOption={selectedCustomer}
149
+ required
150
+ placeholder={isLoading ? 'Loading customers...' : 'Select customer'}
151
+ searchPlaceholder="Search customers"
152
+ emptyText="No customers found."
153
+ disabled={disabled || Boolean(error)}
154
+ error={error ? 'Customers could not be loaded' : undefined}
155
+ onSearchChange={onSearchQueryChange}
156
+ className="flex-1"
157
+ />
168
158
  {error && (
169
159
  <Button type="button" variant="outline" size="sm" onClick={onRetry}>
170
160
  <RefreshCw size={14} className="mr-1" />
@@ -183,7 +173,7 @@ export function CustomerPicker({
183
173
  </Button>
184
174
  )}
185
175
  </div>
186
- ) : !isLoading && options.length === 0 ? (
176
+ ) : !isLoading && customers.length === 0 ? (
187
177
  <div className="rounded-lg border border-border-seam bg-bg-sunken/40 p-3">
188
178
  <div className="flex items-start gap-2">
189
179
  <UserPlus size={16} className="mt-0.5 shrink-0 text-text-secondary" />
@@ -0,0 +1,237 @@
1
+ /**
2
+ * EntityCombobox — searchable single-select for backend-record lists (routes,
3
+ * customers, vehicles, drivers, …).
4
+ *
5
+ * A button trigger shows the current selection's label (or a placeholder) and
6
+ * opens a Popover with a cmdk Command. Built entirely on existing fe-libs `ui`
7
+ * primitives (Command + Popover) — no new dependency, following the pattern
8
+ * already proven by `microfe-product`'s `ProductCombobox`.
9
+ *
10
+ * Every list this feeds (`useRoutes`, `useCustomers`, `useVehicles`,
11
+ * `useDrivers`) is capped to a small server page (`limit`, default 20) so the
12
+ * option list is never "fetch everything" — a workspace with thousands of
13
+ * records would otherwise either hang the request or silently truncate. Two
14
+ * search modes cover both call shapes in this app:
15
+ *
16
+ * - **Internal search** (pass `onSearchChange`): the combobox renders its own
17
+ * debounced `CommandInput` and calls back with the debounced query so the
18
+ * caller's hook re-fetches server-side. Used where each field owns its own
19
+ * search (saved route, customer).
20
+ * - **External search** (omit `onSearchChange`): no input is rendered inside
21
+ * the popover; the caller is expected to already be filtering `options` via
22
+ * a search control elsewhere on the page. Used where one shared search box
23
+ * drives two comboboxes at once (vehicle + driver assignment), so a second,
24
+ * redundant input isn't stacked on top of it.
25
+ *
26
+ * In both modes, the current selection's label is resolved independently via
27
+ * `selectedOption`/`selectedLoading` (typically a single-record lookup hook
28
+ * like `useRoute(value)`), so it keeps rendering correctly even once it falls
29
+ * outside the current search results or page.
30
+ */
31
+ import { useEffect, useState } from 'react';
32
+ import { Check, ChevronsUpDown } from 'lucide-react';
33
+ import {
34
+ Command,
35
+ CommandEmpty,
36
+ CommandInput,
37
+ CommandItem,
38
+ CommandList,
39
+ Popover,
40
+ PopoverContent,
41
+ PopoverTrigger,
42
+ } from './ui';
43
+ import { cn } from '../utils/cn';
44
+ import { useDebouncedValue } from '../hooks/useDebouncedValue';
45
+
46
+ interface EntityComboboxProps<T> {
47
+ /** Selected record id (controlled). Empty string = nothing selected. */
48
+ value: string;
49
+ /** Called with the selected record id ('' when cleared). */
50
+ onChange: (id: string) => void;
51
+ /** The current (already fetched/filtered) page of selectable records. */
52
+ options: T[];
53
+ /** True while `options` is (re)loading. */
54
+ loading?: boolean;
55
+ getId: (option: T) => string;
56
+ /** Rich content for a list row. */
57
+ renderOption: (option: T) => React.ReactNode;
58
+ /** Plain-text label for the trigger button (must be a string, not JSX). */
59
+ getLabel: (option: T) => string;
60
+ /** Independently-loaded current selection, so its label still renders even
61
+ * when it isn't present in `options` (filtered out or paginated away). */
62
+ selectedOption?: T | null;
63
+ selectedLoading?: boolean;
64
+ /** Renders a clearable "None" row when true (hidden when `required`). */
65
+ required?: boolean;
66
+ placeholder?: string;
67
+ searchPlaceholder?: string;
68
+ emptyText?: string;
69
+ noneLabel?: string;
70
+ disabled?: boolean;
71
+ id?: string;
72
+ /** Toggles the invalid border + aria-invalid. */
73
+ error?: string;
74
+ /** Extra classes merged onto the trigger button. */
75
+ className?: string;
76
+ /** Server-side search callback (debounced). Omit to hide the built-in search
77
+ * input when a caller already provides one elsewhere for the same list. */
78
+ onSearchChange?: (value: string) => void;
79
+ /** True when the source hook's page doesn't hold every matching record —
80
+ * paired with `totalCount` to render a "keep typing to narrow" hint. */
81
+ hasMore?: boolean;
82
+ totalCount?: number;
83
+ }
84
+
85
+ export function EntityCombobox<T>({
86
+ value,
87
+ onChange,
88
+ options,
89
+ loading = false,
90
+ getId,
91
+ renderOption,
92
+ getLabel,
93
+ selectedOption = null,
94
+ selectedLoading = false,
95
+ required = false,
96
+ placeholder = 'Select…',
97
+ searchPlaceholder = 'Search…',
98
+ emptyText = 'No results found.',
99
+ noneLabel = 'None',
100
+ disabled = false,
101
+ id,
102
+ error,
103
+ className,
104
+ onSearchChange,
105
+ hasMore = false,
106
+ totalCount,
107
+ }: EntityComboboxProps<T>) {
108
+ const [open, setOpen] = useState(false);
109
+ const [search, setSearch] = useState('');
110
+ const debouncedSearch = useDebouncedValue(search.trim(), 200);
111
+
112
+ useEffect(() => {
113
+ if (onSearchChange) {
114
+ onSearchChange(debouncedSearch);
115
+ }
116
+ // Only internal-search mode reacts to keystrokes; external-search callers
117
+ // manage their own query state and don't pass `onSearchChange`.
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, [debouncedSearch]);
120
+
121
+ const triggerLabel = selectedOption
122
+ ? getLabel(selectedOption)
123
+ : value
124
+ ? selectedLoading
125
+ ? 'Loading…'
126
+ : value
127
+ : placeholder;
128
+
129
+ const triggerClassName = cn(
130
+ 'w-full flex items-center justify-between gap-2 px-3 py-2',
131
+ 'bg-surface',
132
+ 'border border-default',
133
+ 'rounded-lg',
134
+ 'text-left text-sm text-text-primary',
135
+ 'focus:outline-none focus:ring-2 focus:ring-[var(--color-border-focus)]',
136
+ 'disabled:bg-bg-sunken disabled:cursor-not-allowed',
137
+ 'transition-all duration-200',
138
+ error && 'border-[var(--color-status-error)]',
139
+ !selectedOption && 'text-text-tertiary',
140
+ className
141
+ );
142
+
143
+ const showMoreHint = hasMore && typeof totalCount === 'number' && totalCount > options.length;
144
+
145
+ return (
146
+ <Popover
147
+ open={open}
148
+ onOpenChange={(next: boolean) => {
149
+ setOpen(next);
150
+ // Reset the query on close so reopening starts fresh, not pre-filtered.
151
+ if (!next) setSearch('');
152
+ }}
153
+ >
154
+ <PopoverTrigger asChild>
155
+ <button
156
+ type="button"
157
+ id={id}
158
+ role="combobox"
159
+ aria-expanded={open}
160
+ aria-required={required}
161
+ aria-invalid={error ? true : undefined}
162
+ disabled={disabled}
163
+ className={triggerClassName}
164
+ >
165
+ <span className="truncate">{triggerLabel}</span>
166
+ <ChevronsUpDown size={16} className="shrink-0 text-text-tertiary" aria-hidden="true" />
167
+ </button>
168
+ </PopoverTrigger>
169
+ <PopoverContent
170
+ align="start"
171
+ className="w-[var(--radix-popover-trigger-width)] p-0 border border-default bg-surface"
172
+ >
173
+ {/* Search is server-side (or driven externally) → disable cmdk's
174
+ client filter so we render exactly what was fetched. */}
175
+ <Command shouldFilter={false} className="bg-surface text-text-primary">
176
+ {onSearchChange && (
177
+ <CommandInput
178
+ value={search}
179
+ onValueChange={setSearch}
180
+ placeholder={searchPlaceholder}
181
+ className="text-text-primary placeholder:text-text-tertiary"
182
+ />
183
+ )}
184
+ <CommandList>
185
+ <CommandEmpty className="text-text-tertiary">
186
+ {loading ? 'Searching…' : emptyText}
187
+ </CommandEmpty>
188
+ {!required && (
189
+ <CommandItem
190
+ value="__none__"
191
+ onSelect={() => {
192
+ onChange('');
193
+ setOpen(false);
194
+ }}
195
+ className="text-text-tertiary"
196
+ >
197
+ <Check
198
+ size={16}
199
+ className={cn('shrink-0', value === '' ? 'opacity-100' : 'opacity-0')}
200
+ aria-hidden="true"
201
+ />
202
+ <span className="truncate italic">{noneLabel}</span>
203
+ </CommandItem>
204
+ )}
205
+ {options.map((option) => {
206
+ const optionId = getId(option);
207
+ return (
208
+ <CommandItem
209
+ key={optionId}
210
+ value={optionId}
211
+ onSelect={() => {
212
+ onChange(optionId);
213
+ setOpen(false);
214
+ }}
215
+ className="text-text-primary"
216
+ >
217
+ <Check
218
+ size={16}
219
+ className={cn('shrink-0', optionId === value ? 'opacity-100' : 'opacity-0')}
220
+ aria-hidden="true"
221
+ />
222
+ <span className="truncate">{renderOption(option)}</span>
223
+ </CommandItem>
224
+ );
225
+ })}
226
+ {showMoreHint && (
227
+ <div className="px-2 py-1.5 text-xs text-text-tertiary border-t border-border-seam">
228
+ Showing {options.length} of {totalCount}
229
+ {onSearchChange ? ' — keep typing to narrow results.' : '.'}
230
+ </div>
231
+ )}
232
+ </CommandList>
233
+ </Command>
234
+ </PopoverContent>
235
+ </Popover>
236
+ );
237
+ }
@@ -21,6 +21,15 @@ export {
21
21
  SelectItem,
22
22
  SelectTrigger,
23
23
  SelectValue,
24
+ Command,
25
+ CommandEmpty,
26
+ CommandGroup,
27
+ CommandInput,
28
+ CommandItem,
29
+ CommandList,
30
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
24
33
  Tabs,
25
34
  TabsList,
26
35
  TabsTrigger,
@@ -702,6 +702,8 @@ interface UseVehiclesOptions extends UseListOptions {
702
702
  search?: string;
703
703
  status?: VehicleStatus;
704
704
  vehicleType?: Vehicle['type'];
705
+ /** Narrows to vehicles linked to this driver server-side (VehicleFilterInput.driverId). */
706
+ driverId?: string;
705
707
  }
706
708
 
707
709
  interface UseVehiclesResult {
@@ -717,7 +719,7 @@ interface UseVehiclesResult {
717
719
  }
718
720
 
719
721
  export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult {
720
- const { limit = 20, offset = 0, search, status, vehicleType } = options;
722
+ const { limit = 20, offset = 0, search, status, vehicleType, driverId } = options;
721
723
  const graphQLOptions = useGraphqlOptions();
722
724
  const [vehicles, setVehicles] = useState<Vehicle[]>([]);
723
725
  const [totalVehicles, setTotalVehicles] = useState(0);
@@ -737,6 +739,7 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
737
739
  search: search || undefined,
738
740
  status: status ? toApiVehicleStatus(status) : undefined,
739
741
  type: vehicleType?.toUpperCase(),
742
+ driverId: driverId || undefined,
740
743
  },
741
744
  pagination: { limit, offset },
742
745
  });
@@ -748,7 +751,7 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
748
751
  } finally {
749
752
  setIsLoading(false);
750
753
  }
751
- }, [graphQLOptions, limit, offset, search, status, vehicleType]);
754
+ }, [graphQLOptions, limit, offset, search, status, vehicleType, driverId]);
752
755
 
753
756
  useEffect(() => {
754
757
  void load();
@@ -866,10 +869,12 @@ interface UseDriversOptions extends UseListOptions {
866
869
  status?: DriverStatus;
867
870
  search?: string;
868
871
  licenseClass?: string;
872
+ /** Narrows to drivers linked to this vehicle server-side (DriverFilterInput.vehicleId). */
873
+ vehicleId?: string;
869
874
  }
870
875
 
871
876
  export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
872
- const { limit = 20, offset = 0, status, search, licenseClass } = options;
877
+ const { limit = 20, offset = 0, status, search, licenseClass, vehicleId } = options;
873
878
  const graphQLOptions = useGraphqlOptions();
874
879
  const [drivers, setDrivers] = useState<Driver[]>([]);
875
880
  const [totalDrivers, setTotalDrivers] = useState(0);
@@ -889,6 +894,7 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
889
894
  status: status ? toApiDriverStatus(status) : undefined,
890
895
  search: search || undefined,
891
896
  licenseClass: licenseClass || undefined,
897
+ vehicleId: vehicleId || undefined,
892
898
  },
893
899
  pagination: { limit, offset },
894
900
  });
@@ -900,7 +906,7 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
900
906
  } finally {
901
907
  setIsLoading(false);
902
908
  }
903
- }, [graphQLOptions, limit, offset, status, search, licenseClass]);
909
+ }, [graphQLOptions, limit, offset, status, search, licenseClass, vehicleId]);
904
910
 
905
911
  useEffect(() => {
906
912
  void load();
@@ -38,11 +38,11 @@ import {
38
38
  import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
39
39
  import { useOrder, useOrders } from '../hooks/useOrders';
40
40
  import { useCustomer, useCustomers } from '../hooks/useCustomers';
41
- import { useRoutes } from '../hooks/useRoutes';
42
- import { useDebouncedValue } from '../hooks/useDebouncedValue';
41
+ import { useRoute, useRoutes } from '../hooks/useRoutes';
43
42
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
44
43
  import { joinPath, buildCustomerCreatePath } from '../utils/navigation';
45
44
  import { CustomerPicker } from '../components/CustomerPicker';
45
+ import { EntityCombobox } from '../components/EntityCombobox';
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,19 @@ 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,
90
93
  limit: 20,
91
94
  });
92
95
 
@@ -116,6 +119,13 @@ export default function CreateOrderPage() {
116
119
  specialInstructions: order?.specialInstructions || '',
117
120
  });
118
121
 
122
+ // Resolved independently of the (searchable, 20-row) `routes` page so the
123
+ // saved-route combobox's trigger still shows the right name even when the
124
+ // selection has scrolled out of the current search/page.
125
+ const { route: selectedRoute, isLoading: selectedRouteLoading } = useRoute(
126
+ formData.routeId || undefined
127
+ );
128
+
119
129
  const [isSubmitting, setIsSubmitting] = useState(false);
120
130
  const [formError, setFormError] = useState<string | null>(null);
121
131
 
@@ -275,7 +285,7 @@ export default function CreateOrderPage() {
275
285
  };
276
286
 
277
287
  const selectSavedRoute = (value: string) => {
278
- if (value === 'none') {
288
+ if (!value) {
279
289
  updateField('routeId', '');
280
290
  return;
281
291
  }
@@ -431,7 +441,6 @@ export default function CreateOrderPage() {
431
441
  onRetry={refetchCustomers}
432
442
  onOpenCustomers={goToCustomerCreate}
433
443
  selectedCustomer={selectedCustomer}
434
- searchQuery={customerSearch}
435
444
  onSearchQueryChange={setCustomerSearch}
436
445
  />
437
446
  <div className="space-y-2">
@@ -464,19 +473,30 @@ export default function CreateOrderPage() {
464
473
  </CardTitle>
465
474
  </CardHeader>
466
475
  <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>
476
+ <EntityCombobox
477
+ id="saved-route"
478
+ value={formData.routeId}
479
+ onChange={selectSavedRoute}
480
+ options={routes}
481
+ loading={routesLoading}
482
+ getId={(route) => route.id}
483
+ getLabel={(route) => `${route.name} · ${route.stops.length} stops`}
484
+ renderOption={(route) => (
485
+ <>
486
+ {route.name} · {route.stops.length} stops
487
+ </>
488
+ )}
489
+ selectedOption={selectedRoute}
490
+ selectedLoading={selectedRouteLoading}
491
+ placeholder="Choose a saved route"
492
+ searchPlaceholder="Search saved routes"
493
+ emptyText="No saved routes found."
494
+ noneLabel="No saved route"
495
+ error={routesError ? 'Saved routes are unavailable' : undefined}
496
+ onSearchChange={setRouteSearch}
497
+ hasMore={routesHaveMore}
498
+ totalCount={totalRoutes}
499
+ />
480
500
  <p className="text-xs text-text-secondary">
481
501
  {routesLoading
482
502
  ? 'Loading planned routes…'