@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/dist/components/CustomerPicker.js +94 -101
- package/dist/components/EntityCombobox.js +105 -0
- package/dist/components/ui.js +2 -2
- package/dist/hooks/useFleet.js +53 -49
- package/dist/index.js +22 -22
- package/dist/movethewheels/components/CustomerPicker.d.ts +3 -2
- package/dist/movethewheels/components/EntityCombobox.d.ts +40 -0
- package/dist/movethewheels/components/ui.d.ts +1 -1
- package/dist/movethewheels/hooks/useFleet.d.ts +4 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/chevrons-up-down.js +11 -0
- package/dist/pages/CreateOrderPage.js +175 -169
- package/dist/pages/OrderDetailsPage.js +291 -294
- package/dist/pages/RouteDetailsPage.js +298 -296
- package/package.json +1 -1
- package/src/movethewheels/components/CustomerPicker.tsx +26 -36
- package/src/movethewheels/components/EntityCombobox.tsx +237 -0
- package/src/movethewheels/components/ui.tsx +9 -0
- package/src/movethewheels/hooks/useFleet.ts +10 -4
- package/src/movethewheels/pages/CreateOrderPage.tsx +39 -19
- package/src/movethewheels/pages/OrderDetailsPage.tsx +61 -60
- package/src/movethewheels/pages/RouteDetailsPage.tsx +53 -53
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 &&
|
|
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
|
+
}
|
|
@@ -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:
|
|
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
|
|
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
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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…'
|