@burdenoff/microfe-movethewheels 2026.929.4 → 2026.930.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/CustomerPicker.js +52 -48
  2. package/dist/components/EntityCombobox.js +25 -23
  3. package/dist/components/OrderItemRow.js +123 -78
  4. package/dist/hooks/useCustomers.js +52 -17
  5. package/dist/hooks/useFleet.js +126 -44
  6. package/dist/hooks/useInventory.js +43 -7
  7. package/dist/hooks/useMultiModalShipments.js +30 -26
  8. package/dist/hooks/useRoutes.js +55 -15
  9. package/dist/movethewheels/components/CustomerPicker.d.ts +5 -1
  10. package/dist/movethewheels/components/EntityCombobox.d.ts +6 -3
  11. package/dist/movethewheels/components/OrderItemRow.d.ts +7 -1
  12. package/dist/movethewheels/hooks/useCustomers.d.ts +3 -0
  13. package/dist/movethewheels/hooks/useFleet.d.ts +6 -0
  14. package/dist/movethewheels/hooks/useInventory.d.ts +2 -0
  15. package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +1 -0
  16. package/dist/movethewheels/hooks/useRoutes.d.ts +3 -0
  17. package/dist/movethewheels/utils/orderValidation.d.ts +11 -0
  18. package/dist/pages/CreateOrderPage.js +349 -297
  19. package/dist/pages/DriverDetailsPage.js +337 -333
  20. package/dist/pages/DriversPage.js +3 -1
  21. package/dist/pages/MultiModalShipmentPage.js +139 -125
  22. package/dist/pages/OrderDetailsPage.js +132 -140
  23. package/dist/pages/RouteDetailsPage.js +202 -202
  24. package/dist/pages/TrackingPage.js +2 -1
  25. package/dist/utils/orderValidation.js +18 -0
  26. package/package.json +1 -1
  27. package/src/movethewheels/components/CustomerPicker.tsx +12 -0
  28. package/src/movethewheels/components/EntityCombobox.tsx +30 -11
  29. package/src/movethewheels/components/OrderItemRow.tsx +44 -0
  30. package/src/movethewheels/hooks/useCustomers.ts +38 -0
  31. package/src/movethewheels/hooks/useFleet.ts +125 -5
  32. package/src/movethewheels/hooks/useInventory.ts +42 -0
  33. package/src/movethewheels/hooks/useMultiModalShipments.ts +9 -7
  34. package/src/movethewheels/hooks/useRoutes.ts +51 -0
  35. package/src/movethewheels/pages/CreateOrderPage.tsx +66 -2
  36. package/src/movethewheels/pages/DriverDetailsPage.tsx +12 -1
  37. package/src/movethewheels/pages/DriversPage.tsx +7 -1
  38. package/src/movethewheels/pages/MultiModalShipmentPage.tsx +15 -1
  39. package/src/movethewheels/pages/OrderDetailsPage.tsx +25 -25
  40. package/src/movethewheels/pages/RouteDetailsPage.tsx +8 -4
  41. package/src/movethewheels/pages/TrackingPage.tsx +7 -2
  42. package/src/movethewheels/utils/orderValidation.ts +53 -0
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-movethewheels",
3
- "version": "2026.929.4",
3
+ "version": "2026.930.1",
4
4
  "description": "Burdenoff MoveTheWheels Microfrontend - Logistics & Supply Chain Management Platform",
5
5
  "type": "module",
6
6
  "files": [
@@ -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`) 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:
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's page doesn't hold every matching record —
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 showMoreHint = hasMore && typeof totalCount === 'number' && totalCount > options.length;
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
- {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.' : '.'}
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 (ids && ids.length === 0) {
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: ids && ids.length > 0 ? [...ids] : undefined,
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
- }, [graphQLOptions, limit, offset, search, status, vehicleType, driverId, idsKey]);
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(options: { limit?: number; offset?: number } = {}) {
269
- const { limit = 10, offset = 0 } = options;
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();