@burdenoff/microfe-movethewheels 2026.929.4 → 2026.930.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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/useRefetchOnLeaveEdit.js +10 -0
  9. package/dist/hooks/useRoutes.js +55 -15
  10. package/dist/movethewheels/components/CustomerPicker.d.ts +5 -1
  11. package/dist/movethewheels/components/EntityCombobox.d.ts +6 -3
  12. package/dist/movethewheels/components/OrderItemRow.d.ts +7 -1
  13. package/dist/movethewheels/hooks/useCustomers.d.ts +3 -0
  14. package/dist/movethewheels/hooks/useFleet.d.ts +6 -0
  15. package/dist/movethewheels/hooks/useInventory.d.ts +2 -0
  16. package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +1 -0
  17. package/dist/movethewheels/hooks/useRefetchOnLeaveEdit.d.ts +7 -0
  18. package/dist/movethewheels/hooks/useRoutes.d.ts +3 -0
  19. package/dist/movethewheels/utils/orderValidation.d.ts +11 -0
  20. package/dist/pages/CreateOrderPage.js +349 -297
  21. package/dist/pages/DriverDetailsPage.js +258 -251
  22. package/dist/pages/DriversPage.js +3 -1
  23. package/dist/pages/MultiModalShipmentPage.js +139 -125
  24. package/dist/pages/OrderDetailsPage.js +132 -140
  25. package/dist/pages/ProductDetailsPage.js +22 -19
  26. package/dist/pages/RouteDetailsPage.js +284 -281
  27. package/dist/pages/TrackingPage.js +2 -1
  28. package/dist/pages/VehicleDetailsPage.js +28 -25
  29. package/dist/utils/orderValidation.js +18 -0
  30. package/package.json +1 -1
  31. package/src/movethewheels/components/CustomerPicker.tsx +12 -0
  32. package/src/movethewheels/components/EntityCombobox.tsx +30 -11
  33. package/src/movethewheels/components/OrderItemRow.tsx +44 -0
  34. package/src/movethewheels/hooks/useCustomers.ts +38 -0
  35. package/src/movethewheels/hooks/useFleet.ts +125 -5
  36. package/src/movethewheels/hooks/useInventory.ts +42 -0
  37. package/src/movethewheels/hooks/useMultiModalShipments.ts +9 -7
  38. package/src/movethewheels/hooks/useRefetchOnLeaveEdit.ts +18 -0
  39. package/src/movethewheels/hooks/useRoutes.ts +51 -0
  40. package/src/movethewheels/pages/CreateOrderPage.tsx +66 -2
  41. package/src/movethewheels/pages/DriverDetailsPage.tsx +14 -1
  42. package/src/movethewheels/pages/DriversPage.tsx +7 -1
  43. package/src/movethewheels/pages/MultiModalShipmentPage.tsx +15 -1
  44. package/src/movethewheels/pages/OrderDetailsPage.tsx +25 -25
  45. package/src/movethewheels/pages/ProductDetailsPage.tsx +2 -0
  46. package/src/movethewheels/pages/RouteDetailsPage.tsx +10 -4
  47. package/src/movethewheels/pages/TrackingPage.tsx +7 -2
  48. package/src/movethewheels/pages/VehicleDetailsPage.tsx +2 -0
  49. 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
  })]
@@ -19,15 +19,16 @@ import { FormError as L } from "../components/FormError.js";
19
19
  import { Notice as R, useNotice as z } from "../components/Notice.js";
20
20
  import { formatDate as B, getInitials as V } from "../utils/formatters.js";
21
21
  import { DocumentsCard as H } from "../components/DocumentsCard.js";
22
- import { useRecordVehicleService as U } from "../hooks/useFleetIntelligence.js";
23
- import { VehicleServiceCard as W } from "../components/VehicleServiceCard.js";
22
+ import { useRefetchOnLeaveEdit as U } from "../hooks/useRefetchOnLeaveEdit.js";
23
+ import { useRecordVehicleService as W } from "../hooks/useFleetIntelligence.js";
24
+ import { VehicleServiceCard as ee } from "../components/VehicleServiceCard.js";
24
25
  import { useEffect as G, useState as K } from "react";
25
- import { useLocation as ee, useParams as te } from "react-router";
26
+ import { useLocation as te, useParams as ne } from "react-router";
26
27
  import { jsx as q, jsxs as J } from "react/jsx-runtime";
27
- import { useI18n as ne } from "@burdenoff/fe-libs/shared/providers/shell/I18nProvider";
28
- import { EmphasisPanel as Y, NextSteps as re, PagePurpose as X, ResponsiveDialog as Z, ResponsiveDialogContent as Q, ResponsiveDialogDescription as $, ResponsiveDialogFooter as ie, ResponsiveDialogHeader as ae, ResponsiveDialogTitle as oe } from "@burdenoff/fe-libs/ui";
28
+ import { useI18n as re } from "@burdenoff/fe-libs/shared/providers/shell/I18nProvider";
29
+ import { EmphasisPanel as Y, NextSteps as ie, PagePurpose as X, ResponsiveDialog as Z, ResponsiveDialogContent as Q, ResponsiveDialogDescription as $, ResponsiveDialogFooter as ae, ResponsiveDialogHeader as oe, ResponsiveDialogTitle as se } from "@burdenoff/fe-libs/ui";
29
30
  //#region src/movethewheels/pages/VehicleDetailsPage.tsx
30
- var se = [
31
+ var ce = [
31
32
  {
32
33
  value: "TRUCK",
33
34
  label: "Truck"
@@ -60,10 +61,10 @@ var se = [
60
61
  value: "DRONE",
61
62
  label: "Drone"
62
63
  }
63
- ], ce = Object.entries(P).map(([e, t]) => ({
64
+ ], le = Object.entries(P).map(([e, t]) => ({
64
65
  value: e,
65
66
  label: t
66
- })), le = [
67
+ })), ue = [
67
68
  {
68
69
  value: "DIESEL",
69
70
  label: "Diesel"
@@ -84,7 +85,7 @@ var se = [
84
85
  value: "HYBRID",
85
86
  label: "Hybrid"
86
87
  }
87
- ], ue = [
88
+ ], de = [
88
89
  {
89
90
  value: "owned",
90
91
  label: "Owned"
@@ -98,7 +99,7 @@ var se = [
98
99
  label: "Rented"
99
100
  }
100
101
  ];
101
- function de({ editId: t }) {
102
+ function fe({ editId: t }) {
102
103
  let { basePath: n, navigate: r } = d(), { createVehicle: i, updateVehicle: o, deleteVehicle: l } = p(), { vehicle: h } = m(t), g = !!t, [_, T] = K({
103
104
  vehicleNumber: "",
104
105
  type: "VAN",
@@ -240,7 +241,7 @@ function de({ editId: t }) {
240
241
  children: [/* @__PURE__ */ q(k, {
241
242
  id: "type",
242
243
  children: /* @__PURE__ */ q(A, {})
243
- }), /* @__PURE__ */ q(D, { children: se.map((e) => /* @__PURE__ */ q(O, {
244
+ }), /* @__PURE__ */ q(D, { children: ce.map((e) => /* @__PURE__ */ q(O, {
244
245
  value: e.value,
245
246
  children: e.label
246
247
  }, e.value)) })]
@@ -314,7 +315,7 @@ function de({ editId: t }) {
314
315
  children: [/* @__PURE__ */ q(k, {
315
316
  id: "ownershipType",
316
317
  children: /* @__PURE__ */ q(A, { placeholder: "Select ownership" })
317
- }), /* @__PURE__ */ q(D, { children: ue.map((e) => /* @__PURE__ */ q(O, {
318
+ }), /* @__PURE__ */ q(D, { children: de.map((e) => /* @__PURE__ */ q(O, {
318
319
  value: e.value,
319
320
  children: e.label
320
321
  }, e.value)) })]
@@ -352,7 +353,7 @@ function de({ editId: t }) {
352
353
  children: [/* @__PURE__ */ q(k, {
353
354
  id: "vehicleStatus",
354
355
  children: /* @__PURE__ */ q(A, {})
355
- }), /* @__PURE__ */ q(D, { children: ce.map((e) => /* @__PURE__ */ q(O, {
356
+ }), /* @__PURE__ */ q(D, { children: le.map((e) => /* @__PURE__ */ q(O, {
356
357
  value: e.value,
357
358
  children: e.label
358
359
  }, e.value)) })]
@@ -370,7 +371,7 @@ function de({ editId: t }) {
370
371
  }), /* @__PURE__ */ J(E, {
371
372
  value: _.fuelType || void 0,
372
373
  onValueChange: (e) => {
373
- let t = le.find((t) => t.value === e)?.value;
374
+ let t = ue.find((t) => t.value === e)?.value;
374
375
  T((e) => ({
375
376
  ...e,
376
377
  fuelType: t ?? ""
@@ -379,7 +380,7 @@ function de({ editId: t }) {
379
380
  children: [/* @__PURE__ */ q(k, {
380
381
  id: "fuelType",
381
382
  children: /* @__PURE__ */ q(A, { placeholder: "Select fuel type" })
382
- }), /* @__PURE__ */ q(D, { children: le.map((e) => /* @__PURE__ */ q(O, {
383
+ }), /* @__PURE__ */ q(D, { children: ue.map((e) => /* @__PURE__ */ q(O, {
383
384
  value: e.value,
384
385
  children: e.label
385
386
  }, e.value)) })]
@@ -484,7 +485,7 @@ function de({ editId: t }) {
484
485
  open: I,
485
486
  onOpenChange: R,
486
487
  children: /* @__PURE__ */ J(Q, { children: [
487
- /* @__PURE__ */ J(ae, { children: [/* @__PURE__ */ q(oe, { children: "Delete this vehicle?" }), /* @__PURE__ */ J($, { children: [
488
+ /* @__PURE__ */ J(oe, { children: [/* @__PURE__ */ q(se, { children: "Delete this vehicle?" }), /* @__PURE__ */ J($, { children: [
488
489
  "This removes ",
489
490
  _.vehicleNumber || "this vehicle",
490
491
  " from the active fleet. It cannot be undone from here."
@@ -493,7 +494,7 @@ function de({ editId: t }) {
493
494
  className: "px-1",
494
495
  children: /* @__PURE__ */ q(L, { message: V })
495
496
  }),
496
- /* @__PURE__ */ J(ie, { children: [/* @__PURE__ */ q(v, {
497
+ /* @__PURE__ */ J(ae, { children: [/* @__PURE__ */ q(v, {
497
498
  variant: "outline",
498
499
  onClick: () => R(!1),
499
500
  disabled: z,
@@ -520,12 +521,14 @@ function de({ editId: t }) {
520
521
  ]
521
522
  });
522
523
  }
523
- function fe() {
524
- let { vehicleId: a } = te(), { basePath: s, navigate: f } = d(), { vehicle: p, isLoading: C, error: w, refetch: E } = m(a), D = U(), { message: O, notify: k } = z(), { t: A } = ne(), P = (e, t) => {
524
+ function pe() {
525
+ let { vehicleId: a } = ne(), { basePath: s, navigate: f } = d(), { vehicle: p, isLoading: C, error: w, refetch: E } = m(a), D = W(), { message: O, notify: k } = z(), { t: A } = re(), P = (e, t) => {
525
526
  let n = A(e);
526
527
  return n === e ? t : n;
527
- }, L = ee().pathname.endsWith("/edit"), { drivers: G, isLoading: K, error: Z } = h(L ? void 0 : a), Q = () => f?.(u(s, "fleet/vehicles")), $ = (e) => f?.(u(s, e));
528
- return a ? L ? /* @__PURE__ */ q(de, { editId: a }) : C ? /* @__PURE__ */ q(M, { message: "Loading vehicle details..." }) : w || !p ? /* @__PURE__ */ q(j, {
528
+ }, L = te().pathname.endsWith("/edit");
529
+ U(L, E);
530
+ let { drivers: G, isLoading: K, error: Z } = h(L ? void 0 : a), Q = () => f?.(u(s, "fleet/vehicles")), $ = (e) => f?.(u(s, e));
531
+ return a ? L ? /* @__PURE__ */ q(fe, { editId: a }) : C ? /* @__PURE__ */ q(M, { message: "Loading vehicle details..." }) : w || !p ? /* @__PURE__ */ q(j, {
529
532
  title: "Vehicle not found",
530
533
  onRetry: E
531
534
  }) : /* @__PURE__ */ J(N, {
@@ -555,7 +558,7 @@ function fe() {
555
558
  icon: /* @__PURE__ */ q(c, { size: 18 }),
556
559
  children: "The full record for this vehicle — specs, capacity, fuel and mileage, who is driving it and where it is right now. Use it to confirm a vehicle is fuelled, crewed and the right size before dispatching it on a delivery."
557
560
  }),
558
- /* @__PURE__ */ q(re, {
561
+ /* @__PURE__ */ q(ie, {
559
562
  storageKey: "movethewheels-vehicle-details",
560
563
  steps: [{
561
564
  id: "assign",
@@ -773,7 +776,7 @@ function fe() {
773
776
  })]
774
777
  }, e.id))
775
778
  })] }),
776
- /* @__PURE__ */ q(W, {
779
+ /* @__PURE__ */ q(ee, {
777
780
  vehicle: p,
778
781
  t: P,
779
782
  onRecord: async (e) => {
@@ -794,7 +797,7 @@ function fe() {
794
797
  })]
795
798
  })
796
799
  ]
797
- }) : /* @__PURE__ */ q(de, {});
800
+ }) : /* @__PURE__ */ q(fe, {});
798
801
  }
799
802
  //#endregion
800
- export { fe as default };
803
+ export { pe as default };
@@ -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.2",
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,