@burdenoff/microfe-movethewheels 2026.703.6 → 2026.703.8

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 (113) hide show
  1. package/dist/{AIAssistantPage-BmILdHgO.js → AIAssistantPage-MWJLAMN_.js} +5 -5
  2. package/dist/{AnalyticsPage-BSlsiSDE.js → AnalyticsPage-900jpV2T.js} +9 -9
  3. package/dist/{CreateOrderPage-DaCgVaO3.js → CreateOrderPage-V8aXfcpN.js} +11 -11
  4. package/dist/{CustomerDetailsPage-DErpbafW.js → CustomerDetailsPage-CHzg75Q5.js} +16 -16
  5. package/dist/{CustomersPage-C1lRU6xs.js → CustomersPage-CwtVspuq.js} +24 -22
  6. package/dist/{DashboardPage-Q5-5EKAU.js → DashboardPage-DiD-cWwD.js} +17 -17
  7. package/dist/DataTable-CmdZd6iV.js +369 -0
  8. package/dist/{DocumentsCard-T9y6cv4-.js → DocumentsCard-y2ybnAlu.js} +4 -4
  9. package/dist/{DriverDetailsPage-DnHGkDC7.js → DriverDetailsPage-DjaXaATQ.js} +14 -14
  10. package/dist/{DriversPage-C0ujWVOn.js → DriversPage-DW-FGPFW.js} +26 -24
  11. package/dist/{FinancePage-CA2XEgsQ.js → FinancePage-DYadOUzC.js} +9 -9
  12. package/dist/{FleetPage-DrvXW7wK.js → FleetPage-P9k4zZqP.js} +10 -10
  13. package/dist/{FormError-CRsMP7TQ.js → FormError-D4T5FWZA.js} +1 -1
  14. package/dist/{ImportExportPage-Cac9HjFx.js → ImportExportPage-BhWGfbH3.js} +6 -6
  15. package/dist/{InventoryPage-Ezf4gqKI.js → InventoryPage-BWikjZMW.js} +10 -10
  16. package/dist/{LiveTrackingPage-CzbVwDqK.js → LiveTrackingPage-UQBWlthJ.js} +10 -10
  17. package/dist/{MarketplacePage-BHDmSQmo.js → MarketplacePage-DMH0HNDH.js} +9 -9
  18. package/dist/{MetricCard-DQlDNqmK.js → MetricCard-CqU5WsyW.js} +2 -2
  19. package/dist/{Notice-GmA7JDR3.js → Notice-BrXJ85dA.js} +2 -2
  20. package/dist/{OrderDetailsPage-CR0Wc1aN.js → OrderDetailsPage-2J4ua0wd.js} +28 -24
  21. package/dist/OrdersListPage-DvICveRv.js +388 -0
  22. package/dist/{PageLayout-DX1Ou6V6.js → PageLayout-j9Zw3wBN.js} +542 -517
  23. package/dist/{ProductDetailsPage-B_ns2MOe.js → ProductDetailsPage-BSjWLrty.js} +9 -9
  24. package/dist/{ProductsPage-c92BsEoA.js → ProductsPage-Bn1U2jTj.js} +21 -19
  25. package/dist/{ReportsPage-CPLXtLT2.js → ReportsPage-MU8F9DQC.js} +9 -9
  26. package/dist/{RouteDetailsPage-DOfcRiFv.js → RouteDetailsPage-CgSY1CTd.js} +18 -18
  27. package/dist/{RoutesPage-BZGublby.js → RoutesPage-Dqd5Don6.js} +19 -17
  28. package/dist/{SettingsPage-5cweX_xh.js → SettingsPage-2a05Sq2k.js} +4 -4
  29. package/dist/{Sparkline-zSXvG6vA.js → Sparkline-DTGdlxUH.js} +1 -1
  30. package/dist/{StatusBadge-oZjownr1.js → StatusBadge-BfiF-nNI.js} +1 -1
  31. package/dist/{TrackingPage-Ln9qVflK.js → TrackingPage-SnjW6qeD.js} +11 -11
  32. package/dist/{VehicleDetailsPage-CrkzE0dN.js → VehicleDetailsPage-CvRMJ2zC.js} +12 -12
  33. package/dist/{VehiclesPage-CTQWH7af.js → VehiclesPage-qxf-1C1B.js} +26 -24
  34. package/dist/{WarehouseDetailsPage-CwDLaG65.js → WarehouseDetailsPage-D0Wyyj1Y.js} +11 -11
  35. package/dist/{WarehousesPage-KW02DvZg.js → WarehousesPage-44Wj7AGT.js} +19 -17
  36. package/dist/{arrow-left-DJDw_Yqv.js → arrow-left-1dVWv2dg.js} +1 -1
  37. package/dist/{arrow-right-Cb2fLTPJ.js → arrow-right-PUCNATnk.js} +1 -1
  38. package/dist/{arrow-up-PxaOIyiT.js → arrow-up-VQuGMABN.js} +1 -1
  39. package/dist/{box-hSJVtvGf.js → box-DW1KNP8j.js} +1 -1
  40. package/dist/{boxes-DDnxEkT5.js → boxes-D6I00XqA.js} +1 -1
  41. package/dist/{chart-column-xXgAZvRl.js → chart-column-BwQ5H9ol.js} +1 -1
  42. package/dist/{charts-CfCwQg6o.js → charts-Dv_zxP7L.js} +2 -2
  43. package/dist/{chevron-left-dhEGJYdh.js → chevron-left-DzNxoaBG.js} +1 -1
  44. package/dist/{chevron-right-Cud2NDvm.js → chevron-right-CKm3ZC1T.js} +1 -1
  45. package/dist/{circle-alert-Rx0p3D5w.js → circle-alert-CtBm6oIb.js} +1 -1
  46. package/dist/{circle-check-BCxRFdFo.js → circle-check-B_Y7FDyR.js} +1 -1
  47. package/dist/{circle-check-big-BcIJ8xjx.js → circle-check-big-H5pLiMBW.js} +1 -1
  48. package/dist/{clock-B2_YxJgB.js → clock-B4-qjGuB.js} +1 -1
  49. package/dist/{dollar-sign-CQmAICv4.js → dollar-sign-CYGHy2oS.js} +1 -1
  50. package/dist/{download-CG4Tponq.js → download-BJonq2ty.js} +1 -1
  51. package/dist/{file-text-CzH-zMub.js → file-text-C-cZx58r.js} +1 -1
  52. package/dist/{filter-C0u2Lwfn.js → filter-B_S6WOqr.js} +1 -1
  53. package/dist/{fuel-C2KELcM-.js → fuel-Cjt6IxKr.js} +1 -1
  54. package/dist/index.js +33 -33
  55. package/dist/{map-FuPu5aTw.js → map-CO0aWUae.js} +1 -1
  56. package/dist/{map-pin-czpLv9uy.js → map-pin-BISLzLwb.js} +1 -1
  57. package/dist/{maps-D9m7PKrs.js → maps-CtfuCAmh.js} +1 -1
  58. package/dist/{navigation-D5_pdCSS.js → navigation-BFki5UJz.js} +1 -1
  59. package/dist/{package-B0sdi6lU.js → package-UyaWiALC.js} +1 -1
  60. package/dist/{phone-V2nuzPu9.js → phone-Bav_V7rR.js} +1 -1
  61. package/dist/{plus-BqQxkfoU.js → plus-Bk_G-lFC.js} +1 -1
  62. package/dist/{refresh-cw-CEOAPEcV.js → refresh-cw-C4-jecjy.js} +1 -1
  63. package/dist/{route-CZWcW-Dw.js → route-WpCP6F1x.js} +1 -1
  64. package/dist/{save-DDFvrxWl.js → save-CcfoIn2J.js} +1 -1
  65. package/dist/{search-D7Ya6jxj.js → search-C3YRxW9e.js} +1 -1
  66. package/dist/{square-pen-DoZZS4Lw.js → square-pen-Dr1X-P2s.js} +1 -1
  67. package/dist/{star-B7PgVu5j.js → star-1Ixfuzai.js} +1 -1
  68. package/dist/{store-YVzcIG4A.js → store-BwU935v5.js} +1 -1
  69. package/dist/{trash-2-BegnBzwI.js → trash-2-CC4KugEJ.js} +1 -1
  70. package/dist/{trending-up-DBd3XNdb.js → trending-up-1brglj1E.js} +1 -1
  71. package/dist/{triangle-alert-Bxk3Kgs6.js → triangle-alert-Dh56Yu3-.js} +1 -1
  72. package/dist/{truck-Clrn0oHp.js → truck-DxhtLfkG.js} +1 -1
  73. package/dist/{upload-Df5JmMwH.js → upload-CLf0JLXW.js} +1 -1
  74. package/dist/{useAssistant-Cb8nnIVC.js → useAssistant-B9qBtRKm.js} +1 -1
  75. package/dist/{useCustomers-Dq2IR2HQ.js → useCustomers-BQ4AIEAT.js} +1 -1
  76. package/dist/{useFleet-CapnWBcA.js → useFleet-BXSocBD_.js} +1 -1
  77. package/dist/{useImportExport-BoGzrlJy.js → useImportExport-7-O8noRJ.js} +1 -1
  78. package/dist/{useInventory-Ci6M2d9f.js → useInventory-DmZficcp.js} +1 -1
  79. package/dist/{useOrders-BuQU1OHX.js → useOrders-B5FV0kcz.js} +1 -1
  80. package/dist/{useReports-DIx8W7CO.js → useReports-hUXb8ryJ.js} +1 -1
  81. package/dist/{useRoutes-D7EvI_pm.js → useRoutes-C_ZCYJ_h.js} +1 -1
  82. package/dist/{useSettings-BhX251UQ.js → useSettings-CteNasBE.js} +1 -1
  83. package/dist/{useTracking-C40jmZ6W.js → useTracking-B7ttexRe.js} +3 -3
  84. package/dist/{user-BlcJrbtE.js → user-B1IjWJtn.js} +1 -1
  85. package/dist/{user-check-DtnQmimP.js → user-check-Cx6fHHll.js} +1 -1
  86. package/dist/{users-Cvwko-Dx.js → users-BKmsb79k.js} +1 -1
  87. package/dist/{warehouse-LWLVTk6G.js → warehouse-DE1irFyJ.js} +1 -1
  88. package/dist/{wrench-Cme0RbyR.js → wrench-DTJGFs17.js} +1 -1
  89. package/dist/{x-D8C9Pg5V.js → x-CvsUGJy1.js} +1 -1
  90. package/package.json +1 -1
  91. package/src/movethewheels/components/DataTable.tsx +40 -0
  92. package/src/movethewheels/components/GlassPanel.tsx +1 -3
  93. package/src/movethewheels/components/MetricCard.tsx +16 -3
  94. package/src/movethewheels/components/charts/StatusDonut.tsx +2 -6
  95. package/src/movethewheels/components/charts/TrendChart.tsx +2 -6
  96. package/src/movethewheels/components/maps/LogisticsMap.tsx +1 -4
  97. package/src/movethewheels/components/maps/RouteMap.tsx +3 -5
  98. package/src/movethewheels/components/maps/TrackingMap.tsx +11 -2
  99. package/src/movethewheels/pages/CustomerDetailsPage.tsx +3 -2
  100. package/src/movethewheels/pages/CustomersPage.tsx +3 -1
  101. package/src/movethewheels/pages/DriverDetailsPage.tsx +2 -2
  102. package/src/movethewheels/pages/DriversPage.tsx +3 -1
  103. package/src/movethewheels/pages/OrderDetailsPage.tsx +2 -1
  104. package/src/movethewheels/pages/OrdersListPage.tsx +3 -1
  105. package/src/movethewheels/pages/ProductsPage.tsx +3 -1
  106. package/src/movethewheels/pages/RoutesPage.tsx +3 -1
  107. package/src/movethewheels/pages/VehicleDetailsPage.tsx +2 -2
  108. package/src/movethewheels/pages/VehiclesPage.tsx +12 -2
  109. package/src/movethewheels/pages/WarehouseDetailsPage.tsx +3 -1
  110. package/src/movethewheels/pages/WarehousesPage.tsx +3 -1
  111. package/src/movethewheels/utils/graphqlClient.ts +103 -8
  112. package/dist/DataTable-KjzbwnRL.js +0 -340
  113. package/dist/OrdersListPage-C8Bu6wXM.js +0 -386
@@ -78,7 +78,13 @@ const accentChipClasses: Record<MetricAccent, string> = {
78
78
  function ArrowIcon({ direction }: { direction: 'up' | 'down' | 'flat' }) {
79
79
  if (direction === 'flat') {
80
80
  return (
81
- <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
81
+ <svg
82
+ className="h-3.5 w-3.5"
83
+ fill="none"
84
+ viewBox="0 0 24 24"
85
+ stroke="currentColor"
86
+ aria-hidden
87
+ >
82
88
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 12h14" />
83
89
  </svg>
84
90
  );
@@ -91,7 +97,12 @@ function ArrowIcon({ direction }: { direction: 'up' | 'down' | 'flat' }) {
91
97
  stroke="currentColor"
92
98
  aria-hidden
93
99
  >
94
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 19V5m0 0l-6 6m6-6l6 6" />
100
+ <path
101
+ strokeLinecap="round"
102
+ strokeLinejoin="round"
103
+ strokeWidth={2.5}
104
+ d="M12 19V5m0 0l-6 6m6-6l6 6"
105
+ />
95
106
  </svg>
96
107
  );
97
108
  }
@@ -278,7 +289,9 @@ export function MetricCardSkeleton({ size = 'md' }: { size?: 'sm' | 'md' | 'lg'
278
289
  <div className="h-8 w-32 rounded bg-bg-sunken motion-safe:animate-vc-shimmer" />
279
290
  <div className="h-3 w-20 rounded bg-bg-sunken motion-safe:animate-vc-shimmer" />
280
291
  </div>
281
- <div className={cn('rounded-xl bg-bg-sunken motion-safe:animate-vc-shimmer', styles.icon)} />
292
+ <div
293
+ className={cn('rounded-xl bg-bg-sunken motion-safe:animate-vc-shimmer', styles.icon)}
294
+ />
282
295
  </div>
283
296
  </div>
284
297
  </GlassCard>
@@ -67,9 +67,7 @@ export function StatusDonut({
67
67
  <Tooltip
68
68
  content={
69
69
  <ChartTooltip
70
- formatter={(v) =>
71
- valueFormatter ? valueFormatter(Number(v)) : String(v)
72
- }
70
+ formatter={(v) => (valueFormatter ? valueFormatter(Number(v)) : String(v))}
73
71
  />
74
72
  }
75
73
  />
@@ -77,9 +75,7 @@ export function StatusDonut({
77
75
  </ResponsiveContainer>
78
76
  {centerLabel != null && (
79
77
  <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
80
- <span className="text-2xl font-bold tabular-nums text-text-primary">
81
- {centerLabel}
82
- </span>
78
+ <span className="text-2xl font-bold tabular-nums text-text-primary">{centerLabel}</span>
83
79
  {centerSubLabel && (
84
80
  <span className="text-xs text-text-secondary">{centerSubLabel}</span>
85
81
  )}
@@ -99,9 +99,7 @@ export function TrendChart({
99
99
  tickLine={false}
100
100
  axisLine={false}
101
101
  width={44}
102
- tickFormatter={
103
- valueFormatter ? (v: number) => valueFormatter(v, '') : undefined
104
- }
102
+ tickFormatter={valueFormatter ? (v: number) => valueFormatter(v, '') : undefined}
105
103
  />
106
104
  <Tooltip
107
105
  cursor={{ stroke: chartTokens.axis, strokeOpacity: 0.3 }}
@@ -144,9 +142,7 @@ export function TrendChart({
144
142
  tickLine={false}
145
143
  axisLine={false}
146
144
  width={44}
147
- tickFormatter={
148
- valueFormatter ? (v: number) => valueFormatter(v, '') : undefined
149
- }
145
+ tickFormatter={valueFormatter ? (v: number) => valueFormatter(v, '') : undefined}
150
146
  />
151
147
  <Tooltip
152
148
  cursor={{ stroke: chartTokens.axis, strokeOpacity: 0.3 }}
@@ -40,10 +40,7 @@ export function LogisticsMap({
40
40
  fitToMarkers = true,
41
41
  emptyMessage = 'No live locations yet',
42
42
  }: LogisticsMapProps) {
43
- const validMarkers = useMemo(
44
- () => markers.filter((m) => isRealCoord(m.lat, m.lng)),
45
- [markers]
46
- );
43
+ const validMarkers = useMemo(() => markers.filter((m) => isRealCoord(m.lat, m.lng)), [markers]);
47
44
 
48
45
  const validRoutes = useMemo(
49
46
  () =>
@@ -31,7 +31,8 @@ export function RouteMap({
31
31
  emptyMessage = 'No stops with coordinates yet',
32
32
  }: RouteMapProps) {
33
33
  const ordered = useMemo(
34
- () => [...stops].filter((s) => isRealCoord(s.lat, s.lng)).sort((a, b) => a.sequence - b.sequence),
34
+ () =>
35
+ [...stops].filter((s) => isRealCoord(s.lat, s.lng)).sort((a, b) => a.sequence - b.sequence),
35
36
  [stops]
36
37
  );
37
38
 
@@ -48,10 +49,7 @@ export function RouteMap({
48
49
  emptyState={<p className="text-sm text-text-secondary">{emptyMessage}</p>}
49
50
  >
50
51
  {line.length >= 2 && (
51
- <Polyline
52
- positions={line}
53
- pathOptions={{ color: routeColor, weight: 4, opacity: 0.85 }}
54
- />
52
+ <Polyline positions={line} pathOptions={{ color: routeColor, weight: 4, opacity: 0.85 }} />
55
53
  )}
56
54
  {ordered.map((s) => (
57
55
  <Marker
@@ -70,11 +70,20 @@ export function TrackingMap({
70
70
  {trailLine.length >= 2 && (
71
71
  <Polyline
72
72
  positions={trailLine}
73
- pathOptions={{ color: trailColor, weight: 4, opacity: 0.85, dashArray: '1 8', lineCap: 'round' }}
73
+ pathOptions={{
74
+ color: trailColor,
75
+ weight: 4,
76
+ opacity: 0.85,
77
+ dashArray: '1 8',
78
+ lineCap: 'round',
79
+ }}
74
80
  />
75
81
  )}
76
82
  {originOk && (
77
- <Marker position={[originOk.lat, originOk.lng]} icon={createPinIcon('origin', { glyph: false })}>
83
+ <Marker
84
+ position={[originOk.lat, originOk.lng]}
85
+ icon={createPinIcon('origin', { glyph: false })}
86
+ >
78
87
  <Popup>
79
88
  <span className="font-semibold">{originOk.label ?? 'Origin'}</span>
80
89
  </Popup>
@@ -116,8 +116,9 @@ function NewCustomerForm() {
116
116
  }
117
117
  >
118
118
  <PagePurpose icon={<Users size={18} />}>
119
- Add a customer once and their contact details auto-fill every future order you book for them.
120
- Only a name is required — add email, phone and company to make deliveries easier to arrange.
119
+ Add a customer once and their contact details auto-fill every future order you book for
120
+ them. Only a name is required — add email, phone and company to make deliveries easier to
121
+ arrange.
121
122
  </PagePurpose>
122
123
 
123
124
  <form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
@@ -20,7 +20,7 @@ export default function CustomersPage() {
20
20
  const { basePath, navigate } = useMoveTheWheels();
21
21
  const [page, setPage] = useState(0);
22
22
  const [pageSize, setPageSize] = useState(25);
23
- const { customers, hasMore, isLoading, refetch } = useCustomers({
23
+ const { customers, hasMore, isLoading, error, refetch } = useCustomers({
24
24
  limit: pageSize,
25
25
  offset: page * pageSize,
26
26
  });
@@ -151,6 +151,8 @@ export default function CustomersPage() {
151
151
  columns={columns}
152
152
  data={customers}
153
153
  isLoading={isLoading}
154
+ error={error}
155
+ onRetry={refetch}
154
156
  searchable
155
157
  searchPlaceholder="Search customers..."
156
158
  searchFields={['name', 'email', 'company'] as (keyof Customer)[]}
@@ -94,8 +94,8 @@ function NewDriverForm() {
94
94
  }
95
95
  >
96
96
  <PagePurpose icon={<Users size={18} />}>
97
- Add a driver so they can be assigned to orders and routes. An employee ID and name get them on
98
- the roster — add contact details and license number to keep the record complete.
97
+ Add a driver so they can be assigned to orders and routes. An employee ID and name get them
98
+ on the roster — add contact details and license number to keep the record complete.
99
99
  </PagePurpose>
100
100
 
101
101
  <form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
@@ -20,7 +20,7 @@ export default function DriversPage() {
20
20
  const { basePath, navigate } = useMoveTheWheels();
21
21
  const [page, setPage] = useState(0);
22
22
  const [pageSize, setPageSize] = useState(25);
23
- const { drivers, isLoading, refetch } = useDrivers({
23
+ const { drivers, isLoading, error, refetch } = useDrivers({
24
24
  limit: pageSize,
25
25
  offset: page * pageSize,
26
26
  });
@@ -161,6 +161,8 @@ export default function DriversPage() {
161
161
  columns={columns}
162
162
  data={drivers}
163
163
  isLoading={isLoading}
164
+ error={error}
165
+ onRetry={refetch}
164
166
  searchable
165
167
  searchPlaceholder="Search drivers..."
166
168
  searchFields={['fullName', 'employeeId'] as (keyof Driver)[]}
@@ -548,7 +548,8 @@ export default function OrderDetailsPage() {
548
548
 
549
549
  <div className="space-y-2">
550
550
  <p className="text-sm text-text-secondary">
551
- Current status: <span className="font-medium">{STATUS_LABELS[order.status]}</span>
551
+ Current status:{' '}
552
+ <span className="font-medium">{STATUS_LABELS[order.status]}</span>
552
553
  </p>
553
554
  {(() => {
554
555
  const upcoming = nextStatus(order.status);
@@ -55,7 +55,7 @@ export default function OrdersListPage() {
55
55
 
56
56
  const filtersActive = statusFilter !== 'all' || typeFilter !== 'all' || unassignedOnly;
57
57
 
58
- const { orders, hasMore, isLoading, refetch } = useOrders({
58
+ const { orders, hasMore, isLoading, error, refetch } = useOrders({
59
59
  filters: {
60
60
  status: statusFilter === 'all' ? undefined : statusFilter,
61
61
  orderType: typeFilter === 'all' ? undefined : typeFilter,
@@ -346,6 +346,8 @@ export default function OrdersListPage() {
346
346
  columns={columns}
347
347
  data={orders}
348
348
  isLoading={isLoading}
349
+ error={error}
350
+ onRetry={refetch}
349
351
  searchable
350
352
  searchPlaceholder={t('movethewheels.orders.searchPlaceholder', 'Search orders...')}
351
353
  searchFields={['orderNumber', 'customerInfo'] as (keyof Order)[]}
@@ -19,7 +19,7 @@ export default function ProductsPage() {
19
19
  const { basePath, navigate } = useMoveTheWheels();
20
20
  const [page, setPage] = useState(0);
21
21
  const [pageSize, setPageSize] = useState(25);
22
- const { products, hasMore, isLoading, refetch } = useProducts({
22
+ const { products, hasMore, isLoading, error, refetch } = useProducts({
23
23
  limit: pageSize,
24
24
  offset: page * pageSize,
25
25
  });
@@ -159,6 +159,8 @@ export default function ProductsPage() {
159
159
  columns={columns}
160
160
  data={products}
161
161
  isLoading={isLoading}
162
+ error={error}
163
+ onRetry={refetch}
162
164
  searchable
163
165
  searchPlaceholder="Search products..."
164
166
  searchFields={['name', 'sku', 'category'] as (keyof Product)[]}
@@ -20,7 +20,7 @@ export default function RoutesPage() {
20
20
  const { basePath, navigate } = useMoveTheWheels();
21
21
  const [page, setPage] = useState(0);
22
22
  const [pageSize, setPageSize] = useState(25);
23
- const { routes, hasMore, isLoading, refetch } = useRoutes({
23
+ const { routes, hasMore, isLoading, error, refetch } = useRoutes({
24
24
  limit: pageSize,
25
25
  offset: page * pageSize,
26
26
  });
@@ -126,6 +126,8 @@ export default function RoutesPage() {
126
126
  columns={columns}
127
127
  data={routes}
128
128
  isLoading={isLoading}
129
+ error={error}
130
+ onRetry={refetch}
129
131
  searchable
130
132
  searchPlaceholder="Search routes..."
131
133
  searchFields={['name'] as (keyof Route)[]}
@@ -97,8 +97,8 @@ function NewVehicleForm() {
97
97
  }
98
98
  >
99
99
  <PagePurpose icon={<Truck size={18} />}>
100
- Add a vehicle so it can be assigned to deliveries and routes. Give it a fleet number and type
101
- to start — make, model and plate help identify it on the road.
100
+ Add a vehicle so it can be assigned to deliveries and routes. Give it a fleet number and
101
+ type to start — make, model and plate help identify it on the road.
102
102
  </PagePurpose>
103
103
 
104
104
  <form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
@@ -3,7 +3,15 @@
3
3
  */
4
4
 
5
5
  import { useState } from 'react';
6
- import { Truck, Plus, RefreshCw, Download, CircleCheck, Route as RouteIcon, Wrench } from 'lucide-react';
6
+ import {
7
+ Truck,
8
+ Plus,
9
+ RefreshCw,
10
+ Download,
11
+ CircleCheck,
12
+ Route as RouteIcon,
13
+ Wrench,
14
+ } from 'lucide-react';
7
15
  import { PageLayout } from '../components/PageLayout';
8
16
  import { MetricCard, MetricGrid } from '../components/MetricCard';
9
17
  import { DataTable, type Column } from '../components/DataTable';
@@ -19,7 +27,7 @@ export default function VehiclesPage() {
19
27
  const { basePath, navigate } = useMoveTheWheels();
20
28
  const [page, setPage] = useState(0);
21
29
  const [pageSize, setPageSize] = useState(25);
22
- const { vehicles, isLoading, refetch } = useVehicles({
30
+ const { vehicles, isLoading, error, refetch } = useVehicles({
23
31
  limit: pageSize,
24
32
  offset: page * pageSize,
25
33
  });
@@ -163,6 +171,8 @@ export default function VehiclesPage() {
163
171
  columns={columns}
164
172
  data={vehicles}
165
173
  isLoading={isLoading}
174
+ error={error}
175
+ onRetry={refetch}
166
176
  searchable
167
177
  searchPlaceholder="Search vehicles..."
168
178
  searchFields={['vehicleNumber', 'make', 'model'] as (keyof Vehicle)[]}
@@ -90,7 +90,9 @@ function NewWarehouseForm() {
90
90
  navigate?.(joinPath(basePath, `inventory/warehouses/${created.id}`));
91
91
  } catch (err) {
92
92
  console.error('Failed to create warehouse:', err);
93
- setError(err instanceof Error ? err.message : 'Failed to create warehouse. Please try again.');
93
+ setError(
94
+ err instanceof Error ? err.message : 'Failed to create warehouse. Please try again.'
95
+ );
94
96
  setSubmitting(false);
95
97
  }
96
98
  };
@@ -18,7 +18,7 @@ export default function WarehousesPage() {
18
18
  const { basePath, navigate } = useMoveTheWheels();
19
19
  const [page, setPage] = useState(0);
20
20
  const [pageSize, setPageSize] = useState(25);
21
- const { warehouses, hasMore, isLoading, refetch } = useWarehouses({
21
+ const { warehouses, hasMore, isLoading, error, refetch } = useWarehouses({
22
22
  limit: pageSize,
23
23
  offset: page * pageSize,
24
24
  });
@@ -152,6 +152,8 @@ export default function WarehousesPage() {
152
152
  columns={columns}
153
153
  data={warehouses}
154
154
  isLoading={isLoading}
155
+ error={error}
156
+ onRetry={refetch}
155
157
  searchable
156
158
  searchPlaceholder="Search warehouses..."
157
159
  searchFields={['name', 'code'] as (keyof WarehouseType)[]}
@@ -21,12 +21,25 @@ export function resolveGraphqlUrl(apiGatewayUrl?: string): string {
21
21
 
22
22
  const MAX_RETRIES = 3;
23
23
 
24
+ /**
25
+ * Per-attempt hard timeout. The workspace service runs a lean single-replica
26
+ * pool with no PgBouncer, so under a burst of concurrent queries a connection
27
+ * can be accepted by the gateway but never answered — the request then hangs
28
+ * indefinitely. `fetch` has no built-in timeout, so without this an in-flight
29
+ * request never settles and the caller's `isLoading` sticks true forever
30
+ * (perpetual skeletons). A bounded abort turns a hang into a transient error
31
+ * that the retry loop can recover from on a fresh connection. Real queries
32
+ * return in well under a second, so 12s is generous headroom.
33
+ */
34
+ const REQUEST_TIMEOUT_MS = 12000;
35
+
24
36
  /**
25
37
  * A transient failure is a momentary gateway↔service connectivity blip (the
26
38
  * workspace service runs a lean replica pool with no PgBouncer, so a burst of
27
39
  * concurrent dashboard queries can briefly exhaust connections and the gateway
28
- * returns an upstream-connect / 5xx / reset error). These are safe to retry;
29
- * genuine validation / auth / business errors are not.
40
+ * returns an upstream-connect / 5xx / reset error, or the request simply hangs
41
+ * until we abort it). These are safe to retry; genuine validation / auth /
42
+ * business errors are not.
30
43
  */
31
44
  function isTransientMessage(message?: string): boolean {
32
45
  if (!message) return false;
@@ -40,8 +53,56 @@ function isTransientStatus(status: number): boolean {
40
53
  return status === 429 || status === 502 || status === 503 || status === 504;
41
54
  }
42
55
 
56
+ /**
57
+ * A fetch aborted by our timeout rejects with an `AbortError`. It arrives as a
58
+ * `DOMException`, which is not reliably an `instanceof Error` across engines, so
59
+ * we duck-type the `name` field rather than relying on the prototype chain.
60
+ */
61
+ function isAbortError(error: unknown): boolean {
62
+ return (
63
+ typeof error === 'object' &&
64
+ error !== null &&
65
+ 'name' in error &&
66
+ (error as { name?: unknown }).name === 'AbortError'
67
+ );
68
+ }
69
+
43
70
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
44
71
 
72
+ /**
73
+ * Global in-flight cap across every GraphQL call this MFE makes. The workspace
74
+ * service runs a single lean replica (no PgBouncer), and a page like the
75
+ * dashboard fans out ~7 queries at once; multiplied by the retry loop below a
76
+ * transient blip could balloon into dozens of simultaneous requests and tip the
77
+ * replica over. A small semaphore keeps the fan-out polite — requests beyond the
78
+ * limit queue for a moment instead of stampeding the service. 4 is high enough
79
+ * that normal navigation feels instant, low enough that a burst never floods.
80
+ */
81
+ const MAX_CONCURRENT = 4;
82
+ let activeRequests = 0;
83
+ const pendingAcquirers: Array<() => void> = [];
84
+
85
+ function acquireSlot(): Promise<void> {
86
+ return new Promise((resolve) => {
87
+ if (activeRequests < MAX_CONCURRENT) {
88
+ activeRequests += 1;
89
+ resolve();
90
+ } else {
91
+ pendingAcquirers.push(resolve);
92
+ }
93
+ });
94
+ }
95
+
96
+ function releaseSlot(): void {
97
+ const next = pendingAcquirers.shift();
98
+ if (next) {
99
+ // Hand the slot straight to the next waiter — activeRequests stays put.
100
+ next();
101
+ } else {
102
+ activeRequests -= 1;
103
+ }
104
+ }
105
+
45
106
  export async function executeGraphQL<TData>(
46
107
  options: GraphQLRequestOptions,
47
108
  query: string,
@@ -61,15 +122,38 @@ export async function executeGraphQL<TData>(
61
122
  };
62
123
  const body = JSON.stringify({ query, variables });
63
124
 
125
+ // Serialize past the global concurrency cap before doing any network work,
126
+ // and always release the slot when this call settles (success or failure).
127
+ await acquireSlot();
128
+ try {
129
+ return await runWithRetries<TData>(url, headers, body);
130
+ } finally {
131
+ releaseSlot();
132
+ }
133
+ }
134
+
135
+ async function runWithRetries<TData>(
136
+ url: string,
137
+ headers: Record<string, string>,
138
+ body: string
139
+ ): Promise<TData> {
64
140
  let lastError: Error = new Error('GraphQL request failed');
65
141
 
66
142
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
67
143
  const canRetry = attempt < MAX_RETRIES;
68
144
  // Exponential backoff with jitter before each retry (not the first attempt).
69
- if (attempt > 0) await sleep(Math.min(250 * 2 ** (attempt - 1), 2000) + Math.floor(Math.random() * 150));
145
+ if (attempt > 0)
146
+ await sleep(Math.min(250 * 2 ** (attempt - 1), 2000) + Math.floor(Math.random() * 150));
70
147
 
148
+ const controller = new AbortController();
149
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
71
150
  try {
72
- const response = await fetch(url, { method: 'POST', headers, body });
151
+ const response = await fetch(url, {
152
+ method: 'POST',
153
+ headers,
154
+ body,
155
+ signal: controller.signal,
156
+ });
73
157
 
74
158
  if (!response.ok) {
75
159
  lastError = new Error(`GraphQL request failed with status ${response.status}`);
@@ -101,11 +185,22 @@ export async function executeGraphQL<TData>(
101
185
 
102
186
  return payload.data;
103
187
  } catch (error) {
104
- // A thrown fetch is a network error (TypeError "Failed to fetch") — transient.
105
- const message = error instanceof Error ? error.message : String(error);
106
- lastError = error instanceof Error ? error : new Error(message);
107
- if (canRetry && (isTransientMessage(message) || error instanceof TypeError)) continue;
188
+ const aborted = isAbortError(error);
189
+ // A thrown fetch is a network error (TypeError "Failed to fetch") or our
190
+ // own timeout abort — both transient and safe to retry on a fresh
191
+ // connection.
192
+ const message = aborted
193
+ ? `GraphQL request timed out after ${REQUEST_TIMEOUT_MS}ms`
194
+ : error instanceof Error
195
+ ? error.message
196
+ : String(error);
197
+ lastError = !aborted && error instanceof Error ? error : new Error(message);
198
+ if (canRetry && (aborted || isTransientMessage(message) || error instanceof TypeError)) {
199
+ continue;
200
+ }
108
201
  throw lastError;
202
+ } finally {
203
+ clearTimeout(timeoutId);
109
204
  }
110
205
  }
111
206