@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.
- package/dist/components/CustomerPicker.js +52 -48
- package/dist/components/EntityCombobox.js +25 -23
- package/dist/components/OrderItemRow.js +123 -78
- package/dist/hooks/useCustomers.js +52 -17
- package/dist/hooks/useFleet.js +126 -44
- package/dist/hooks/useInventory.js +43 -7
- package/dist/hooks/useMultiModalShipments.js +30 -26
- package/dist/hooks/useRoutes.js +55 -15
- package/dist/movethewheels/components/CustomerPicker.d.ts +5 -1
- package/dist/movethewheels/components/EntityCombobox.d.ts +6 -3
- package/dist/movethewheels/components/OrderItemRow.d.ts +7 -1
- package/dist/movethewheels/hooks/useCustomers.d.ts +3 -0
- package/dist/movethewheels/hooks/useFleet.d.ts +6 -0
- package/dist/movethewheels/hooks/useInventory.d.ts +2 -0
- package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +1 -0
- package/dist/movethewheels/hooks/useRoutes.d.ts +3 -0
- package/dist/movethewheels/utils/orderValidation.d.ts +11 -0
- package/dist/pages/CreateOrderPage.js +349 -297
- package/dist/pages/DriverDetailsPage.js +337 -333
- package/dist/pages/DriversPage.js +3 -1
- package/dist/pages/MultiModalShipmentPage.js +139 -125
- package/dist/pages/OrderDetailsPage.js +132 -140
- package/dist/pages/RouteDetailsPage.js +202 -202
- package/dist/pages/TrackingPage.js +2 -1
- package/dist/utils/orderValidation.js +18 -0
- package/package.json +1 -1
- package/src/movethewheels/components/CustomerPicker.tsx +12 -0
- package/src/movethewheels/components/EntityCombobox.tsx +30 -11
- package/src/movethewheels/components/OrderItemRow.tsx +44 -0
- package/src/movethewheels/hooks/useCustomers.ts +38 -0
- package/src/movethewheels/hooks/useFleet.ts +125 -5
- package/src/movethewheels/hooks/useInventory.ts +42 -0
- package/src/movethewheels/hooks/useMultiModalShipments.ts +9 -7
- package/src/movethewheels/hooks/useRoutes.ts +51 -0
- package/src/movethewheels/pages/CreateOrderPage.tsx +66 -2
- package/src/movethewheels/pages/DriverDetailsPage.tsx +12 -1
- package/src/movethewheels/pages/DriversPage.tsx +7 -1
- package/src/movethewheels/pages/MultiModalShipmentPage.tsx +15 -1
- package/src/movethewheels/pages/OrderDetailsPage.tsx +25 -25
- package/src/movethewheels/pages/RouteDetailsPage.tsx +8 -4
- package/src/movethewheels/pages/TrackingPage.tsx +7 -2
- package/src/movethewheels/utils/orderValidation.ts +53 -0
|
@@ -82,7 +82,7 @@ function N() {
|
|
|
82
82
|
className: "p-6",
|
|
83
83
|
children: /* @__PURE__ */ k("form", {
|
|
84
84
|
onSubmit: q,
|
|
85
|
-
className: "flex gap-4",
|
|
85
|
+
className: "flex flex-col gap-4 sm:flex-row",
|
|
86
86
|
children: [/* @__PURE__ */ k("div", {
|
|
87
87
|
className: "flex-1 relative",
|
|
88
88
|
children: [/* @__PURE__ */ O(a, { className: "absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary" }), /* @__PURE__ */ O(h, {
|
|
@@ -94,6 +94,7 @@ function N() {
|
|
|
94
94
|
}), /* @__PURE__ */ O(u, {
|
|
95
95
|
type: "submit",
|
|
96
96
|
size: "lg",
|
|
97
|
+
className: "w-full sm:w-auto",
|
|
97
98
|
disabled: !z || G,
|
|
98
99
|
children: G ? P("movethewheels.tracking.search.searching", "Searching...") : P("movethewheels.tracking.search.track", "Track")
|
|
99
100
|
})]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/movethewheels/utils/orderValidation.ts
|
|
2
|
+
var e = (e) => Number.isFinite(e) && e >= 0;
|
|
3
|
+
function t(t) {
|
|
4
|
+
let n = {}, r = t.productId.trim().length > 0, i = t.name.trim().length > 0;
|
|
5
|
+
!r && !i && (n.name = "Enter an item name or select a catalog product."), t.sku.trim().length === 0 && (n.sku = "SKU is required for every shipment item."), (!Number.isFinite(t.quantity) || t.quantity <= 0) && (n.quantity = "Quantity must be a finite number greater than 0."), e(t.weight) || (n.weight = "Weight must be a finite number of 0 or more."), e(t.value) || (n.value = "Value must be a finite number of 0 or more.");
|
|
6
|
+
let a = t.dimensions;
|
|
7
|
+
for (let t of [
|
|
8
|
+
"length",
|
|
9
|
+
"width",
|
|
10
|
+
"height"
|
|
11
|
+
]) (!a || !e(a[t])) && (n[t] = `${t[0].toUpperCase()}${t.slice(1)} must be a finite number of 0 or more.`);
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
function n(e) {
|
|
15
|
+
return Object.fromEntries(e.map((e) => [e.id, t(e)]).filter(([, e]) => Object.keys(e).length > 0));
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { n as validateOrderItems };
|
package/package.json
CHANGED
|
@@ -27,8 +27,12 @@ interface CustomerPickerProps {
|
|
|
27
27
|
onCreated: (customer: Customer) => void;
|
|
28
28
|
createCustomer: (customer: Partial<Customer>) => Promise<Customer>;
|
|
29
29
|
isLoading: boolean;
|
|
30
|
+
isLoadingMore?: boolean;
|
|
30
31
|
error: Error | null;
|
|
31
32
|
onRetry: () => void;
|
|
33
|
+
onLoadMore?: () => void | Promise<void>;
|
|
34
|
+
hasMore?: boolean;
|
|
35
|
+
totalCount?: number;
|
|
32
36
|
onOpenCustomers?: () => void;
|
|
33
37
|
selectedCustomer?: Customer | null;
|
|
34
38
|
/** Called with the (already debounced) query text as the user types in the
|
|
@@ -60,8 +64,12 @@ export function CustomerPicker({
|
|
|
60
64
|
onCreated,
|
|
61
65
|
createCustomer,
|
|
62
66
|
isLoading,
|
|
67
|
+
isLoadingMore = false,
|
|
63
68
|
error,
|
|
64
69
|
onRetry,
|
|
70
|
+
onLoadMore,
|
|
71
|
+
hasMore = false,
|
|
72
|
+
totalCount,
|
|
65
73
|
onOpenCustomers,
|
|
66
74
|
selectedCustomer,
|
|
67
75
|
onSearchQueryChange,
|
|
@@ -153,6 +161,10 @@ export function CustomerPicker({
|
|
|
153
161
|
disabled={disabled || Boolean(error)}
|
|
154
162
|
error={error ? 'Customers could not be loaded' : undefined}
|
|
155
163
|
onSearchChange={onSearchQueryChange}
|
|
164
|
+
hasMore={hasMore}
|
|
165
|
+
totalCount={totalCount}
|
|
166
|
+
onLoadMore={onLoadMore}
|
|
167
|
+
loadingMore={isLoadingMore}
|
|
156
168
|
className="flex-1"
|
|
157
169
|
/>
|
|
158
170
|
{error && (
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* already proven by `microfe-product`'s `ProductCombobox`.
|
|
9
9
|
*
|
|
10
10
|
* Every list this feeds (`useRoutes`, `useCustomers`, `useVehicles`,
|
|
11
|
-
* `useDrivers
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* `useDrivers`, and product pickers) is capped to a small server page (`limit`,
|
|
12
|
+
* default 20). When the source exposes `onLoadMore`, the accessible Load more
|
|
13
|
+
* control requests the next server page and appends it without losing the
|
|
14
|
+
* current selection.
|
|
15
15
|
*
|
|
16
16
|
* - **Internal search** (pass `onSearchChange`): the combobox renders its own
|
|
17
17
|
* debounced `CommandInput` and calls back with the debounced query so the
|
|
@@ -76,10 +76,13 @@ interface EntityComboboxProps<T> {
|
|
|
76
76
|
/** Server-side search callback (debounced). Omit to hide the built-in search
|
|
77
77
|
* input when a caller already provides one elsewhere for the same list. */
|
|
78
78
|
onSearchChange?: (value: string) => void;
|
|
79
|
-
/** True when the source hook
|
|
80
|
-
* paired with `totalCount` to render a "keep typing to narrow" hint. */
|
|
79
|
+
/** True when the source hook has another server page available. */
|
|
81
80
|
hasMore?: boolean;
|
|
82
81
|
totalCount?: number;
|
|
82
|
+
/** Fetches and appends the next server page without replacing the current options. */
|
|
83
|
+
onLoadMore?: () => void | Promise<void>;
|
|
84
|
+
/** True while the next server page is being fetched. */
|
|
85
|
+
loadingMore?: boolean;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
export function EntityCombobox<T>({
|
|
@@ -104,6 +107,8 @@ export function EntityCombobox<T>({
|
|
|
104
107
|
onSearchChange,
|
|
105
108
|
hasMore = false,
|
|
106
109
|
totalCount,
|
|
110
|
+
onLoadMore,
|
|
111
|
+
loadingMore = false,
|
|
107
112
|
}: EntityComboboxProps<T>) {
|
|
108
113
|
const [open, setOpen] = useState(false);
|
|
109
114
|
const [search, setSearch] = useState('');
|
|
@@ -140,7 +145,7 @@ export function EntityCombobox<T>({
|
|
|
140
145
|
className
|
|
141
146
|
);
|
|
142
147
|
|
|
143
|
-
const
|
|
148
|
+
const canLoadMore = Boolean(onLoadMore && hasMore);
|
|
144
149
|
|
|
145
150
|
return (
|
|
146
151
|
<Popover
|
|
@@ -223,10 +228,24 @@ export function EntityCombobox<T>({
|
|
|
223
228
|
</CommandItem>
|
|
224
229
|
);
|
|
225
230
|
})}
|
|
226
|
-
{
|
|
227
|
-
<div className="
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
{canLoadMore && (
|
|
232
|
+
<div className="border-t border-border-seam p-2">
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
className="w-full rounded-md px-3 py-2 text-sm font-medium text-action-primary-text hover:bg-action-primary-bg/10 focus:outline-none focus:ring-2 focus:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-60"
|
|
236
|
+
onClick={() => void onLoadMore?.()}
|
|
237
|
+
disabled={loading || loadingMore}
|
|
238
|
+
aria-busy={loadingMore}
|
|
239
|
+
aria-label={
|
|
240
|
+
loadingMore
|
|
241
|
+
? 'Loading more options'
|
|
242
|
+
: `Load more options${typeof totalCount === 'number' ? ` (${options.length} of ${totalCount} shown)` : ''}`
|
|
243
|
+
}
|
|
244
|
+
>
|
|
245
|
+
{loadingMore
|
|
246
|
+
? 'Loading more…'
|
|
247
|
+
: `Load more${typeof totalCount === 'number' ? ` (${options.length} of ${totalCount})` : ''}`}
|
|
248
|
+
</button>
|
|
230
249
|
</div>
|
|
231
250
|
)}
|
|
232
251
|
</CommandList>
|
|
@@ -12,12 +12,18 @@ import { Button, Input, Label } from './ui';
|
|
|
12
12
|
import { EntityCombobox } from './EntityCombobox';
|
|
13
13
|
import { useProduct, useProductStockLevels } from '../hooks/useInventory';
|
|
14
14
|
import type { Dimensions, OrderItem, Product } from '../types';
|
|
15
|
+
import type { OrderItemFieldErrors } from '../utils/orderValidation';
|
|
15
16
|
|
|
16
17
|
interface OrderItemRowProps {
|
|
17
18
|
item: OrderItem;
|
|
18
19
|
index: number;
|
|
19
20
|
products: Product[];
|
|
20
21
|
productsLoading: boolean;
|
|
22
|
+
productsLoadingMore?: boolean;
|
|
23
|
+
productsHasMore?: boolean;
|
|
24
|
+
productsTotalCount?: number;
|
|
25
|
+
onLoadMoreProducts?: () => void | Promise<void>;
|
|
26
|
+
errors?: OrderItemFieldErrors;
|
|
21
27
|
onUpdate: (index: number, field: string, value: unknown) => void;
|
|
22
28
|
onRemove: (index: number) => void;
|
|
23
29
|
}
|
|
@@ -37,6 +43,11 @@ export function OrderItemRow({
|
|
|
37
43
|
index,
|
|
38
44
|
products,
|
|
39
45
|
productsLoading,
|
|
46
|
+
productsLoadingMore = false,
|
|
47
|
+
productsHasMore = false,
|
|
48
|
+
productsTotalCount,
|
|
49
|
+
onLoadMoreProducts,
|
|
50
|
+
errors = {},
|
|
40
51
|
onUpdate,
|
|
41
52
|
onRemove,
|
|
42
53
|
}: OrderItemRowProps) {
|
|
@@ -80,6 +91,17 @@ export function OrderItemRow({
|
|
|
80
91
|
});
|
|
81
92
|
};
|
|
82
93
|
|
|
94
|
+
const fieldError = (field: keyof OrderItemFieldErrors) =>
|
|
95
|
+
errors[field] ? (
|
|
96
|
+
<p
|
|
97
|
+
id={`item-${field}-${item.id}-error`}
|
|
98
|
+
className="text-xs text-status-error-text"
|
|
99
|
+
role="alert"
|
|
100
|
+
>
|
|
101
|
+
{errors[field]}
|
|
102
|
+
</p>
|
|
103
|
+
) : null;
|
|
104
|
+
|
|
83
105
|
return (
|
|
84
106
|
<div
|
|
85
107
|
className="rounded-card border border-border-subtle bg-bg-surface p-4 shadow-elevation-1 sm:p-5"
|
|
@@ -131,6 +153,10 @@ export function OrderItemRow({
|
|
|
131
153
|
placeholder="Pick from your product catalog"
|
|
132
154
|
emptyText="No products found."
|
|
133
155
|
noneLabel="Enter details manually"
|
|
156
|
+
hasMore={productsHasMore}
|
|
157
|
+
totalCount={productsTotalCount}
|
|
158
|
+
onLoadMore={onLoadMoreProducts}
|
|
159
|
+
loadingMore={productsLoadingMore}
|
|
134
160
|
/>
|
|
135
161
|
{item.productId && !stockLoading && (
|
|
136
162
|
<p className="mt-2 text-xs text-text-secondary" role="status">
|
|
@@ -149,7 +175,10 @@ export function OrderItemRow({
|
|
|
149
175
|
onUpdate(index, 'name', e.target.value)
|
|
150
176
|
}
|
|
151
177
|
placeholder="Product name"
|
|
178
|
+
aria-invalid={Boolean(errors.name)}
|
|
179
|
+
aria-describedby={errors.name ? `item-name-${item.id}-error` : undefined}
|
|
152
180
|
/>
|
|
181
|
+
{fieldError('name')}
|
|
153
182
|
</div>
|
|
154
183
|
<div className="space-y-2 lg:col-span-3">
|
|
155
184
|
<Label htmlFor={`item-sku-${item.id}`}>SKU</Label>
|
|
@@ -160,7 +189,10 @@ export function OrderItemRow({
|
|
|
160
189
|
onUpdate(index, 'sku', e.target.value)
|
|
161
190
|
}
|
|
162
191
|
placeholder="SKU-001"
|
|
192
|
+
aria-invalid={Boolean(errors.sku)}
|
|
193
|
+
aria-describedby={errors.sku ? `item-sku-${item.id}-error` : undefined}
|
|
163
194
|
/>
|
|
195
|
+
{fieldError('sku')}
|
|
164
196
|
</div>
|
|
165
197
|
<div className="space-y-2 lg:col-span-2">
|
|
166
198
|
<Label htmlFor={`item-quantity-${item.id}`}>Quantity</Label>
|
|
@@ -172,7 +204,10 @@ export function OrderItemRow({
|
|
|
172
204
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
173
205
|
onUpdate(index, 'quantity', parseInt(e.target.value, 10) || 1)
|
|
174
206
|
}
|
|
207
|
+
aria-invalid={Boolean(errors.quantity)}
|
|
208
|
+
aria-describedby={errors.quantity ? `item-quantity-${item.id}-error` : undefined}
|
|
175
209
|
/>
|
|
210
|
+
{fieldError('quantity')}
|
|
176
211
|
</div>
|
|
177
212
|
<div className="space-y-2 lg:col-span-2">
|
|
178
213
|
<Label htmlFor={`item-weight-${item.id}`}>Weight (kg)</Label>
|
|
@@ -185,7 +220,10 @@ export function OrderItemRow({
|
|
|
185
220
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
186
221
|
onUpdate(index, 'weight', parseFloat(e.target.value) || 0)
|
|
187
222
|
}
|
|
223
|
+
aria-invalid={Boolean(errors.weight)}
|
|
224
|
+
aria-describedby={errors.weight ? `item-weight-${item.id}-error` : undefined}
|
|
188
225
|
/>
|
|
226
|
+
{fieldError('weight')}
|
|
189
227
|
</div>
|
|
190
228
|
<div className="space-y-2 lg:col-span-1">
|
|
191
229
|
<Label htmlFor={`item-value-${item.id}`}>Value ($)</Label>
|
|
@@ -198,7 +236,10 @@ export function OrderItemRow({
|
|
|
198
236
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
199
237
|
onUpdate(index, 'value', parseFloat(e.target.value) || 0)
|
|
200
238
|
}
|
|
239
|
+
aria-invalid={Boolean(errors.value)}
|
|
240
|
+
aria-describedby={errors.value ? `item-value-${item.id}-error` : undefined}
|
|
201
241
|
/>
|
|
242
|
+
{fieldError('value')}
|
|
202
243
|
</div>
|
|
203
244
|
</div>
|
|
204
245
|
|
|
@@ -221,7 +262,10 @@ export function OrderItemRow({
|
|
|
221
262
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
222
263
|
updateDimension(field, e.target.value)
|
|
223
264
|
}
|
|
265
|
+
aria-invalid={Boolean(errors[field])}
|
|
266
|
+
aria-describedby={errors[field] ? `item-${field}-${item.id}-error` : undefined}
|
|
224
267
|
/>
|
|
268
|
+
{fieldError(field)}
|
|
225
269
|
</div>
|
|
226
270
|
))}
|
|
227
271
|
</div>
|
|
@@ -14,8 +14,11 @@ interface UseCustomersResult {
|
|
|
14
14
|
totalCustomers: number;
|
|
15
15
|
hasMore: boolean;
|
|
16
16
|
isLoading: boolean;
|
|
17
|
+
/** True while an additional server page is appended. */
|
|
18
|
+
isLoadingMore: boolean;
|
|
17
19
|
error: Error | null;
|
|
18
20
|
refetch: () => void;
|
|
21
|
+
loadMore: () => Promise<void>;
|
|
19
22
|
createCustomer: (customer: Partial<Customer>) => Promise<Customer>;
|
|
20
23
|
updateCustomer: (id: string, updates: Partial<Customer>) => Promise<Customer>;
|
|
21
24
|
deleteCustomer: (id: string) => Promise<void>;
|
|
@@ -163,6 +166,7 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
|
|
|
163
166
|
const [totalCustomers, setTotalCustomers] = useState(0);
|
|
164
167
|
const [hasMore, setHasMore] = useState(false);
|
|
165
168
|
const [isLoading, setIsLoading] = useState(false);
|
|
169
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
166
170
|
const [error, setError] = useState<Error | null>(null);
|
|
167
171
|
|
|
168
172
|
const graphQLOptions = useMemo(
|
|
@@ -204,6 +208,38 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
|
|
|
204
208
|
}
|
|
205
209
|
}, [filters, graphQLOptions, limit, offset]);
|
|
206
210
|
|
|
211
|
+
const loadMore = useCallback(async () => {
|
|
212
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
213
|
+
const nextOffset = offset + customers.length;
|
|
214
|
+
setIsLoadingMore(true);
|
|
215
|
+
setError(null);
|
|
216
|
+
try {
|
|
217
|
+
const data = await executeGraphQL<{
|
|
218
|
+
customers: ApiCustomer[];
|
|
219
|
+
customersCount: number;
|
|
220
|
+
}>(graphQLOptions, LIST_CUSTOMERS_QUERY, {
|
|
221
|
+
filter: {
|
|
222
|
+
type: filters?.type,
|
|
223
|
+
search: filters?.search,
|
|
224
|
+
},
|
|
225
|
+
pagination: { limit, offset: nextOffset },
|
|
226
|
+
});
|
|
227
|
+
const mapped = data.customers.map(mapCustomer);
|
|
228
|
+
setTotalCustomers(data.customersCount);
|
|
229
|
+
setHasMore(nextOffset + mapped.length < data.customersCount);
|
|
230
|
+
setCustomers((previous) => {
|
|
231
|
+
const existingIds = new Set(previous.map((customer) => customer.id));
|
|
232
|
+
return [...previous, ...mapped.filter((customer) => !existingIds.has(customer.id))];
|
|
233
|
+
});
|
|
234
|
+
} catch (fetchError) {
|
|
235
|
+
setError(
|
|
236
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more customers')
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
setIsLoadingMore(false);
|
|
240
|
+
}
|
|
241
|
+
}, [customers.length, filters, graphQLOptions, hasMore, isLoading, isLoadingMore, limit, offset]);
|
|
242
|
+
|
|
207
243
|
useEffect(() => {
|
|
208
244
|
void loadCustomers();
|
|
209
245
|
}, [loadCustomers]);
|
|
@@ -278,8 +314,10 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
|
|
|
278
314
|
totalCustomers,
|
|
279
315
|
hasMore,
|
|
280
316
|
isLoading,
|
|
317
|
+
isLoadingMore,
|
|
281
318
|
error,
|
|
282
319
|
refetch,
|
|
320
|
+
loadMore,
|
|
283
321
|
createCustomer,
|
|
284
322
|
updateCustomer,
|
|
285
323
|
deleteCustomer,
|
|
@@ -928,8 +928,11 @@ interface UseVehiclesResult {
|
|
|
928
928
|
/** True when the last fetch returned a full page (rawCount === limit). */
|
|
929
929
|
hasMore: boolean;
|
|
930
930
|
isLoading: boolean;
|
|
931
|
+
/** True while an additional server page is appended. */
|
|
932
|
+
isLoadingMore: boolean;
|
|
931
933
|
error: Error | null;
|
|
932
934
|
refetch: () => void;
|
|
935
|
+
loadMore: () => Promise<void>;
|
|
933
936
|
updateVehicleStatus: (id: string, status: VehicleStatus) => Promise<Vehicle>;
|
|
934
937
|
assignDriver: (vehicleId: string, driverId: string) => Promise<Vehicle>;
|
|
935
938
|
deleteVehicle: (id: string) => Promise<void>;
|
|
@@ -942,13 +945,16 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
942
945
|
const [totalVehicles, setTotalVehicles] = useState(0);
|
|
943
946
|
const [hasMore, setHasMore] = useState(false);
|
|
944
947
|
const [isLoading, setIsLoading] = useState(false);
|
|
948
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
945
949
|
const [error, setError] = useState<Error | null>(null);
|
|
946
950
|
// Stable dependency key for the `ids` array — a caller passing a fresh array
|
|
947
951
|
// literal each render must not defeat the callback's memoization.
|
|
948
952
|
const idsKey = ids ? [...ids].sort().join(',') : '';
|
|
953
|
+
const idsFilterMode = ids === undefined ? 'none' : 'exact';
|
|
954
|
+
const exactIds = useMemo(() => (idsKey ? idsKey.split(',') : []), [idsKey]);
|
|
949
955
|
|
|
950
956
|
const load = useCallback(async () => {
|
|
951
|
-
if (
|
|
957
|
+
if (idsFilterMode === 'exact' && exactIds.length === 0) {
|
|
952
958
|
// Nothing to look up — skip the request and clear stale results.
|
|
953
959
|
setVehicles([]);
|
|
954
960
|
setTotalVehicles(0);
|
|
@@ -967,11 +973,14 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
967
973
|
status: status ? toApiVehicleStatus(status) : undefined,
|
|
968
974
|
type: vehicleType?.toUpperCase(),
|
|
969
975
|
driverId: driverId || undefined,
|
|
970
|
-
ids:
|
|
976
|
+
ids: exactIds.length > 0 ? exactIds : undefined,
|
|
977
|
+
},
|
|
978
|
+
pagination: {
|
|
979
|
+
limit: idsFilterMode === 'exact' ? exactIds.length : limit,
|
|
980
|
+
offset: idsFilterMode === 'exact' ? 0 : offset,
|
|
971
981
|
},
|
|
972
|
-
pagination: { limit: ids && ids.length > 0 ? ids.length : limit, offset: ids ? 0 : offset },
|
|
973
982
|
});
|
|
974
|
-
setHasMore(offset + data.vehicles.length < data.vehiclesCount);
|
|
983
|
+
setHasMore(idsFilterMode === 'none' && offset + data.vehicles.length < data.vehiclesCount);
|
|
975
984
|
setTotalVehicles(data.vehiclesCount);
|
|
976
985
|
setVehicles(data.vehicles.map(mapVehicle));
|
|
977
986
|
} catch (fetchError) {
|
|
@@ -979,7 +988,65 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
979
988
|
} finally {
|
|
980
989
|
setIsLoading(false);
|
|
981
990
|
}
|
|
982
|
-
}, [
|
|
991
|
+
}, [
|
|
992
|
+
graphQLOptions,
|
|
993
|
+
limit,
|
|
994
|
+
offset,
|
|
995
|
+
search,
|
|
996
|
+
status,
|
|
997
|
+
vehicleType,
|
|
998
|
+
driverId,
|
|
999
|
+
idsFilterMode,
|
|
1000
|
+
exactIds,
|
|
1001
|
+
]);
|
|
1002
|
+
|
|
1003
|
+
const loadMore = useCallback(async () => {
|
|
1004
|
+
if (idsFilterMode === 'exact' || !hasMore || isLoading || isLoadingMore) return;
|
|
1005
|
+
const nextOffset = offset + vehicles.length;
|
|
1006
|
+
setIsLoadingMore(true);
|
|
1007
|
+
setError(null);
|
|
1008
|
+
try {
|
|
1009
|
+
const data = await executeGraphQL<{ vehicles: ApiVehicle[]; vehiclesCount: number }>(
|
|
1010
|
+
graphQLOptions,
|
|
1011
|
+
LIST_VEHICLES_QUERY,
|
|
1012
|
+
{
|
|
1013
|
+
filter: {
|
|
1014
|
+
search: search || undefined,
|
|
1015
|
+
status: status ? toApiVehicleStatus(status) : undefined,
|
|
1016
|
+
type: vehicleType?.toUpperCase(),
|
|
1017
|
+
driverId: driverId || undefined,
|
|
1018
|
+
},
|
|
1019
|
+
pagination: { limit, offset: nextOffset },
|
|
1020
|
+
}
|
|
1021
|
+
);
|
|
1022
|
+
const mapped = data.vehicles.map(mapVehicle);
|
|
1023
|
+
setTotalVehicles(data.vehiclesCount);
|
|
1024
|
+
setHasMore(nextOffset + mapped.length < data.vehiclesCount);
|
|
1025
|
+
setVehicles((previous) => {
|
|
1026
|
+
const existingIds = new Set(previous.map((vehicle) => vehicle.id));
|
|
1027
|
+
return [...previous, ...mapped.filter((vehicle) => !existingIds.has(vehicle.id))];
|
|
1028
|
+
});
|
|
1029
|
+
} catch (fetchError) {
|
|
1030
|
+
setError(
|
|
1031
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more vehicles')
|
|
1032
|
+
);
|
|
1033
|
+
} finally {
|
|
1034
|
+
setIsLoadingMore(false);
|
|
1035
|
+
}
|
|
1036
|
+
}, [
|
|
1037
|
+
driverId,
|
|
1038
|
+
graphQLOptions,
|
|
1039
|
+
hasMore,
|
|
1040
|
+
idsFilterMode,
|
|
1041
|
+
isLoading,
|
|
1042
|
+
isLoadingMore,
|
|
1043
|
+
limit,
|
|
1044
|
+
offset,
|
|
1045
|
+
search,
|
|
1046
|
+
status,
|
|
1047
|
+
vehicleType,
|
|
1048
|
+
vehicles.length,
|
|
1049
|
+
]);
|
|
983
1050
|
|
|
984
1051
|
useEffect(() => {
|
|
985
1052
|
void load();
|
|
@@ -1044,8 +1111,10 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
1044
1111
|
totalVehicles,
|
|
1045
1112
|
hasMore,
|
|
1046
1113
|
isLoading,
|
|
1114
|
+
isLoadingMore,
|
|
1047
1115
|
error,
|
|
1048
1116
|
refetch,
|
|
1117
|
+
loadMore,
|
|
1049
1118
|
updateVehicleStatus,
|
|
1050
1119
|
assignDriver,
|
|
1051
1120
|
deleteVehicle,
|
|
@@ -1099,8 +1168,11 @@ interface UseDriversResult {
|
|
|
1099
1168
|
/** True when the last fetch returned a full page (rawCount === limit). */
|
|
1100
1169
|
hasMore: boolean;
|
|
1101
1170
|
isLoading: boolean;
|
|
1171
|
+
/** True while an additional server page is appended. */
|
|
1172
|
+
isLoadingMore: boolean;
|
|
1102
1173
|
error: Error | null;
|
|
1103
1174
|
refetch: () => void;
|
|
1175
|
+
loadMore: () => Promise<void>;
|
|
1104
1176
|
updateDriverStatus: (id: string, status: DriverStatus) => Promise<Driver>;
|
|
1105
1177
|
assignVehicle: (driverId: string, vehicleId: string | null) => Promise<Driver>;
|
|
1106
1178
|
}
|
|
@@ -1121,6 +1193,7 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
|
|
|
1121
1193
|
const [totalDrivers, setTotalDrivers] = useState(0);
|
|
1122
1194
|
const [hasMore, setHasMore] = useState(false);
|
|
1123
1195
|
const [isLoading, setIsLoading] = useState(false);
|
|
1196
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
1124
1197
|
const [error, setError] = useState<Error | null>(null);
|
|
1125
1198
|
|
|
1126
1199
|
const load = useCallback(async () => {
|
|
@@ -1149,6 +1222,51 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
|
|
|
1149
1222
|
}
|
|
1150
1223
|
}, [graphQLOptions, limit, offset, status, search, licenseClass, vehicleId]);
|
|
1151
1224
|
|
|
1225
|
+
const loadMore = useCallback(async () => {
|
|
1226
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
1227
|
+
const nextOffset = offset + drivers.length;
|
|
1228
|
+
setIsLoadingMore(true);
|
|
1229
|
+
setError(null);
|
|
1230
|
+
try {
|
|
1231
|
+
const data = await executeGraphQL<{ drivers: ApiDriver[]; driversCount: number }>(
|
|
1232
|
+
graphQLOptions,
|
|
1233
|
+
LIST_DRIVERS_QUERY,
|
|
1234
|
+
{
|
|
1235
|
+
filter: {
|
|
1236
|
+
status: status ? toApiDriverStatus(status) : undefined,
|
|
1237
|
+
search: search || undefined,
|
|
1238
|
+
licenseClass: licenseClass || undefined,
|
|
1239
|
+
vehicleId: vehicleId || undefined,
|
|
1240
|
+
},
|
|
1241
|
+
pagination: { limit, offset: nextOffset },
|
|
1242
|
+
}
|
|
1243
|
+
);
|
|
1244
|
+
const mapped = data.drivers.map(mapDriver);
|
|
1245
|
+
setTotalDrivers(data.driversCount);
|
|
1246
|
+
setHasMore(nextOffset + mapped.length < data.driversCount);
|
|
1247
|
+
setDrivers((previous) => {
|
|
1248
|
+
const existingIds = new Set(previous.map((driver) => driver.id));
|
|
1249
|
+
return [...previous, ...mapped.filter((driver) => !existingIds.has(driver.id))];
|
|
1250
|
+
});
|
|
1251
|
+
} catch (fetchError) {
|
|
1252
|
+
setError(fetchError instanceof Error ? fetchError : new Error('Failed to load more drivers'));
|
|
1253
|
+
} finally {
|
|
1254
|
+
setIsLoadingMore(false);
|
|
1255
|
+
}
|
|
1256
|
+
}, [
|
|
1257
|
+
drivers.length,
|
|
1258
|
+
graphQLOptions,
|
|
1259
|
+
hasMore,
|
|
1260
|
+
isLoading,
|
|
1261
|
+
isLoadingMore,
|
|
1262
|
+
limit,
|
|
1263
|
+
licenseClass,
|
|
1264
|
+
offset,
|
|
1265
|
+
search,
|
|
1266
|
+
status,
|
|
1267
|
+
vehicleId,
|
|
1268
|
+
]);
|
|
1269
|
+
|
|
1152
1270
|
useEffect(() => {
|
|
1153
1271
|
void load();
|
|
1154
1272
|
}, [load]);
|
|
@@ -1196,8 +1314,10 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
|
|
|
1196
1314
|
totalDrivers,
|
|
1197
1315
|
hasMore,
|
|
1198
1316
|
isLoading,
|
|
1317
|
+
isLoadingMore,
|
|
1199
1318
|
error,
|
|
1200
1319
|
refetch,
|
|
1320
|
+
loadMore,
|
|
1201
1321
|
updateDriverStatus,
|
|
1202
1322
|
assignVehicle,
|
|
1203
1323
|
};
|
|
@@ -849,6 +849,7 @@ export function useProducts(options: UseProductsOptions = {}) {
|
|
|
849
849
|
const [totalProducts, setTotalProducts] = useState(0);
|
|
850
850
|
const [hasMore, setHasMore] = useState(false);
|
|
851
851
|
const [isLoading, setIsLoading] = useState(false);
|
|
852
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
852
853
|
const [error, setError] = useState<Error | null>(null);
|
|
853
854
|
|
|
854
855
|
const load = useCallback(async () => {
|
|
@@ -872,6 +873,45 @@ export function useProducts(options: UseProductsOptions = {}) {
|
|
|
872
873
|
}
|
|
873
874
|
}, [graphQLOptions, limit, offset, search, category]);
|
|
874
875
|
|
|
876
|
+
const loadMore = useCallback(async () => {
|
|
877
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
878
|
+
const nextOffset = offset + products.length;
|
|
879
|
+
setIsLoadingMore(true);
|
|
880
|
+
setError(null);
|
|
881
|
+
try {
|
|
882
|
+
const data = await executeGraphQL<{
|
|
883
|
+
cargoItems: ApiProduct[];
|
|
884
|
+
cargoItemsCount: number;
|
|
885
|
+
}>(graphQLOptions, LIST_PRODUCTS_QUERY, {
|
|
886
|
+
filter: { search: search || undefined, category: category || undefined },
|
|
887
|
+
pagination: { limit, offset: nextOffset },
|
|
888
|
+
});
|
|
889
|
+
const mapped = data.cargoItems.map(mapProduct);
|
|
890
|
+
setTotalProducts(data.cargoItemsCount);
|
|
891
|
+
setHasMore(nextOffset + mapped.length < data.cargoItemsCount);
|
|
892
|
+
setProducts((previous) => {
|
|
893
|
+
const existingIds = new Set(previous.map((product) => product.id));
|
|
894
|
+
return [...previous, ...mapped.filter((product) => !existingIds.has(product.id))];
|
|
895
|
+
});
|
|
896
|
+
} catch (fetchError) {
|
|
897
|
+
setError(
|
|
898
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more products')
|
|
899
|
+
);
|
|
900
|
+
} finally {
|
|
901
|
+
setIsLoadingMore(false);
|
|
902
|
+
}
|
|
903
|
+
}, [
|
|
904
|
+
category,
|
|
905
|
+
graphQLOptions,
|
|
906
|
+
hasMore,
|
|
907
|
+
isLoading,
|
|
908
|
+
isLoadingMore,
|
|
909
|
+
limit,
|
|
910
|
+
offset,
|
|
911
|
+
products.length,
|
|
912
|
+
search,
|
|
913
|
+
]);
|
|
914
|
+
|
|
875
915
|
useEffect(() => {
|
|
876
916
|
void load();
|
|
877
917
|
}, [load]);
|
|
@@ -881,8 +921,10 @@ export function useProducts(options: UseProductsOptions = {}) {
|
|
|
881
921
|
totalProducts,
|
|
882
922
|
hasMore,
|
|
883
923
|
isLoading,
|
|
924
|
+
isLoadingMore,
|
|
884
925
|
error,
|
|
885
926
|
refetch: () => void load(),
|
|
927
|
+
loadMore,
|
|
886
928
|
};
|
|
887
929
|
}
|
|
888
930
|
|
|
@@ -140,11 +140,11 @@ const SHIPMENT_FIELDS = `
|
|
|
140
140
|
`;
|
|
141
141
|
|
|
142
142
|
const MULTI_MODAL_SHIPMENTS_QUERY = `
|
|
143
|
-
query ListMultiModalShipments($pagination: PaginationInput) {
|
|
144
|
-
shipments(filter: { isMultiModal: true }, pagination: $pagination) {
|
|
143
|
+
query ListMultiModalShipments($search: String, $pagination: PaginationInput) {
|
|
144
|
+
shipments(filter: { isMultiModal: true, search: $search }, pagination: $pagination) {
|
|
145
145
|
${SHIPMENT_FIELDS}
|
|
146
146
|
}
|
|
147
|
-
shipmentsCount(filter: { isMultiModal: true })
|
|
147
|
+
shipmentsCount(filter: { isMultiModal: true, search: $search })
|
|
148
148
|
}
|
|
149
149
|
`;
|
|
150
150
|
|
|
@@ -265,8 +265,10 @@ function useGraphqlOptions() {
|
|
|
265
265
|
);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
export function useMultiModalShipments(
|
|
269
|
-
|
|
268
|
+
export function useMultiModalShipments(
|
|
269
|
+
options: { limit?: number; offset?: number; search?: string } = {}
|
|
270
|
+
) {
|
|
271
|
+
const { limit = 10, offset = 0, search = '' } = options;
|
|
270
272
|
const graphQLOptions = useGraphqlOptions();
|
|
271
273
|
const [shipments, setShipments] = useState<MultiModalShipment[]>([]);
|
|
272
274
|
const [totalShipments, setTotalShipments] = useState(0);
|
|
@@ -281,7 +283,7 @@ export function useMultiModalShipments(options: { limit?: number; offset?: numbe
|
|
|
281
283
|
const data = await executeGraphQL<{ shipments: ApiShipment[]; shipmentsCount: number }>(
|
|
282
284
|
graphQLOptions,
|
|
283
285
|
MULTI_MODAL_SHIPMENTS_QUERY,
|
|
284
|
-
{ pagination: { limit, offset } }
|
|
286
|
+
{ search: search.trim() || undefined, pagination: { limit, offset } }
|
|
285
287
|
);
|
|
286
288
|
setShipments(data.shipments.map(mapShipment));
|
|
287
289
|
setTotalShipments(data.shipmentsCount);
|
|
@@ -293,7 +295,7 @@ export function useMultiModalShipments(options: { limit?: number; offset?: numbe
|
|
|
293
295
|
} finally {
|
|
294
296
|
setIsLoading(false);
|
|
295
297
|
}
|
|
296
|
-
}, [graphQLOptions, limit, offset]);
|
|
298
|
+
}, [graphQLOptions, limit, offset, search]);
|
|
297
299
|
|
|
298
300
|
useEffect(() => {
|
|
299
301
|
void load();
|