@burdenoff/microfe-movethewheels 2026.703.2 → 2026.703.4

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 (126) hide show
  1. package/dist/{AIAssistantPage-C3cPsWt-.js → AIAssistantPage-3YOnHO9Y.js} +3 -3
  2. package/dist/{AnalyticsPage-Dz6g2MvK.js → AnalyticsPage-CMpI1070.js} +11 -11
  3. package/dist/CreateOrderPage-DRAgGo9i.js +490 -0
  4. package/dist/CustomerDetailsPage-BCeMmOFe.js +615 -0
  5. package/dist/{CustomersPage-2Y05LVyh.js → CustomersPage-CaNBMXmM.js} +64 -53
  6. package/dist/{DashboardPage-BVjQi0EB.js → DashboardPage-DIKlXde3.js} +16 -16
  7. package/dist/DataTable-XvUcTjwi.js +340 -0
  8. package/dist/DocumentsCard-U2lSe-cn.js +169 -0
  9. package/dist/{DriverDetailsPage-CN3WQ0B9.js → DriverDetailsPage-BQrYd_Lp.js} +178 -195
  10. package/dist/{DriversPage-B0Zpx8OQ.js → DriversPage-PYmdFiby.js} +65 -54
  11. package/dist/FinancePage-Cwh9Bw4M.js +136 -0
  12. package/dist/{FleetPage-DqkFi3s3.js → FleetPage-3ZHsB-TN.js} +23 -22
  13. package/dist/{FormError-BlOjnXOq.js → FormError-cuLV2jVD.js} +1 -1
  14. package/dist/{ImportExportPage-DKJRCezl.js → ImportExportPage-C6f7DqhM.js} +50 -70
  15. package/dist/{InventoryPage-DwfgCUQq.js → InventoryPage-DpiqVu1v.js} +9 -9
  16. package/dist/{LiveTrackingPage-DCX-K-LR.js → LiveTrackingPage-FAzzlVnX.js} +9 -9
  17. package/dist/MarketplacePage-Bh_2ns4v.js +426 -0
  18. package/dist/Notice-CSoDYKw9.js +46 -0
  19. package/dist/OrderDetailsPage-zCj0lW0j.js +584 -0
  20. package/dist/{OrdersListPage-BjDlCKJd.js → OrdersListPage-CAMsuUck.js} +166 -89
  21. package/dist/{ProductDetailsPage-C9uOiYLp.js → ProductDetailsPage-wsxAQkA0.js} +12 -12
  22. package/dist/{ProductsPage-C1G9h7Xd.js → ProductsPage-DC5qDLfz.js} +60 -49
  23. package/dist/ReportsPage-BwUJ37eO.js +356 -0
  24. package/dist/RouteDetailsPage-DyMJE9AS.js +583 -0
  25. package/dist/{RoutesPage-DMfoicM1.js → RoutesPage-Dtc669yc.js} +57 -46
  26. package/dist/{SettingsPage-Bn-v0mbQ.js → SettingsPage-CpH9xhQg.js} +5 -27
  27. package/dist/{TrackingPage-W-jtdM3Y.js → TrackingPage-DaNEFG_h.js} +10 -10
  28. package/dist/{VehicleDetailsPage-Bceo5oz3.js → VehicleDetailsPage-DldYNsYV.js} +125 -142
  29. package/dist/{VehiclesPage-VBtOep91.js → VehiclesPage-BFw13iMt.js} +61 -50
  30. package/dist/{WarehouseDetailsPage-L_6sWigM.js → WarehouseDetailsPage-CMG7or6G.js} +17 -17
  31. package/dist/{WarehousesPage-DCbXXXWE.js → WarehousesPage-Wkytnt86.js} +58 -47
  32. package/dist/arrow-right-Q3XQpTPR.js +11 -0
  33. package/dist/arrow-up-xNF1zJUG.js +17 -0
  34. package/dist/chevron-left-B2_r0Elx.js +8 -0
  35. package/dist/{wrench-CANXWxF-.js → fuel-B6_lnKDs.js} +2 -5
  36. package/dist/index.js +163 -174
  37. package/dist/upload-CHZofOqb.js +21 -0
  38. package/dist/{useAnalytics-DS8n0xgg.js → useAnalytics-D7HW2XRb.js} +52 -2
  39. package/dist/{useCustomers-BmTNGxiI.js → useCustomers-BPW4__PR.js} +29 -26
  40. package/dist/{useFleet-DKqXZ16m.js → useFleet-ChBkxAd6.js} +81 -69
  41. package/dist/{useInventory-DkyiVY_A.js → useInventory-CAAVMRp6.js} +83 -38
  42. package/dist/{useOrders-Cfk1GLiq.js → useOrders-B465S2nt.js} +58 -47
  43. package/dist/{useReports-CTqZr-AB.js → useReports-jQ3GpNOm.js} +40 -34
  44. package/dist/{useRoutes-BQWO607N.js → useRoutes-hS3d5-xr.js} +49 -41
  45. package/dist/{useTracking-B-va1kO3.js → useTracking-RjGz9PZG.js} +2 -2
  46. package/dist/user-check-BEKRCi7v.js +20 -0
  47. package/dist/wrench-q_rgJfaP.js +8 -0
  48. package/dist/x-tMeDMcCw.js +11 -0
  49. package/package.json +1 -1
  50. package/src/movethewheels/components/DataTable.tsx +252 -84
  51. package/src/movethewheels/components/DocumentsCard.tsx +102 -0
  52. package/src/movethewheels/components/Notice.tsx +61 -0
  53. package/src/movethewheels/components/index.ts +7 -0
  54. package/src/movethewheels/hooks/index.ts +9 -0
  55. package/src/movethewheels/hooks/useAnalytics.ts +95 -0
  56. package/src/movethewheels/hooks/useCustomers.ts +11 -4
  57. package/src/movethewheels/hooks/useEntityDocuments.ts +132 -0
  58. package/src/movethewheels/hooks/useFleet.ts +26 -7
  59. package/src/movethewheels/hooks/useInventory.ts +70 -7
  60. package/src/movethewheels/hooks/useOrders.ts +32 -2
  61. package/src/movethewheels/hooks/useReports.ts +14 -4
  62. package/src/movethewheels/hooks/useRoutes.ts +20 -3
  63. package/src/movethewheels/pages/AnalyticsPage.tsx +2 -2
  64. package/src/movethewheels/pages/CreateOrderPage.tsx +44 -16
  65. package/src/movethewheels/pages/CustomerDetailsPage.tsx +239 -33
  66. package/src/movethewheels/pages/CustomersPage.tsx +27 -11
  67. package/src/movethewheels/pages/DriverDetailsPage.tsx +16 -30
  68. package/src/movethewheels/pages/DriversPage.tsx +26 -9
  69. package/src/movethewheels/pages/FinancePage.tsx +67 -84
  70. package/src/movethewheels/pages/InventoryPage.tsx +1 -1
  71. package/src/movethewheels/pages/MarketplacePage.tsx +265 -50
  72. package/src/movethewheels/pages/OrderDetailsPage.tsx +214 -10
  73. package/src/movethewheels/pages/OrdersListPage.tsx +77 -14
  74. package/src/movethewheels/pages/ProductsPage.tsx +27 -7
  75. package/src/movethewheels/pages/ReportsPage.tsx +65 -4
  76. package/src/movethewheels/pages/RouteDetailsPage.tsx +74 -13
  77. package/src/movethewheels/pages/RoutesPage.tsx +22 -6
  78. package/src/movethewheels/pages/SettingsPage.tsx +3 -19
  79. package/src/movethewheels/pages/VehicleDetailsPage.tsx +3 -33
  80. package/src/movethewheels/pages/VehiclesPage.tsx +26 -5
  81. package/src/movethewheels/pages/WarehousesPage.tsx +22 -17
  82. package/src/movethewheels/types/index.ts +8 -0
  83. package/src/movethewheels/utils/download.ts +62 -0
  84. package/dist/CreateOrderPage-C5iwnjp4.js +0 -479
  85. package/dist/CustomerDetailsPage-B6nwFyLO.js +0 -406
  86. package/dist/DataTable-DmciIKKM.js +0 -249
  87. package/dist/FinancePage-DEDykSOP.js +0 -158
  88. package/dist/MarketplacePage-CIQ97_zp.js +0 -247
  89. package/dist/OrderDetailsPage-DYiZ-9UK.js +0 -429
  90. package/dist/ReportsPage-Zp53q3_8.js +0 -284
  91. package/dist/RouteDetailsPage-DUStXSf9.js +0 -531
  92. /package/dist/{MetricCard-BY0QrMD4.js → MetricCard-CVBVpu7o.js} +0 -0
  93. /package/dist/{StatusBadge-BZurYHMy.js → StatusBadge-C87mDpDP.js} +0 -0
  94. /package/dist/{box-D9Pal4HM.js → box-Eu05PXDh.js} +0 -0
  95. /package/dist/{chart-column-BFIJysDR.js → chart-column-ouzgxIv_.js} +0 -0
  96. /package/dist/{chevron-right-EMIIyyJ1.js → chevron-right-eV4LjaSi.js} +0 -0
  97. /package/dist/{circle-alert-iY24lSlp.js → circle-alert-Bzh2DBSF.js} +0 -0
  98. /package/dist/{circle-check-big-w0TrLKMF.js → circle-check-big-CGMSpQmD.js} +0 -0
  99. /package/dist/{clock-DJZtdl8u.js → clock-Bl3ptexL.js} +0 -0
  100. /package/dist/{dollar-sign-CgKQRz_e.js → dollar-sign-CAVU95SO.js} +0 -0
  101. /package/dist/{download-DRXxoiQR.js → download-C5NbvVDc.js} +0 -0
  102. /package/dist/{file-text-DWWY6_f5.js → file-text-D-1uvLZG.js} +0 -0
  103. /package/dist/{filter-DloLC2eE.js → filter-DUfUg6BO.js} +0 -0
  104. /package/dist/{formatters-CF2S2l1m.js → formatters-BQr-aR5W.js} +0 -0
  105. /package/dist/{map-CkJaULb2.js → map-C-M-FQLR.js} +0 -0
  106. /package/dist/{map-pin-BGiCvyCs.js → map-pin-Dznl5l40.js} +0 -0
  107. /package/dist/{navigation-D36prnAn.js → navigation-BJSJl-VG.js} +0 -0
  108. /package/dist/{package-BChUzpJS.js → package-CcRttV2N.js} +0 -0
  109. /package/dist/{phone-Ac9s3mo_.js → phone-DVSZHt_j.js} +0 -0
  110. /package/dist/{plus-Z76q-FZm.js → plus-C_XZDwBz.js} +0 -0
  111. /package/dist/{refresh-cw-DKt6f5T6.js → refresh-cw-CfgN67l3.js} +0 -0
  112. /package/dist/{save-CojywNhG.js → save-DJE8Fpdv.js} +0 -0
  113. /package/dist/{search-CDGefqb3.js → search-D_VyGcZT.js} +0 -0
  114. /package/dist/{square-pen-CopE9GmB.js → square-pen-D-6JYrVQ.js} +0 -0
  115. /package/dist/{star-nHlLjDKR.js → star-BpRlUZuU.js} +0 -0
  116. /package/dist/{store-BR9Voi5E.js → store-B-8HMuyZ.js} +0 -0
  117. /package/dist/{trash-2-BKhar6Hs.js → trash-2-8vmv-XV_.js} +0 -0
  118. /package/dist/{trending-up-Dohgd4t_.js → trending-up-Dz6PqPRj.js} +0 -0
  119. /package/dist/{triangle-alert-i-XQL9Rd.js → triangle-alert-D7Y3J_4z.js} +0 -0
  120. /package/dist/{truck-C0v_hbLR.js → truck-D-y2IDzv.js} +0 -0
  121. /package/dist/{useAssistant-D4v3oxD3.js → useAssistant-DVW4QgQb.js} +0 -0
  122. /package/dist/{useImportExport-DHr-SWSw.js → useImportExport-DrPDHEDK.js} +0 -0
  123. /package/dist/{useSettings-CkDH_-gM.js → useSettings-BB8X2RLh.js} +0 -0
  124. /package/dist/{user-jyFU3lXk.js → user-DzfTn4dE.js} +0 -0
  125. /package/dist/{users-DZ-ilS6B.js → users-CCrq-w0C.js} +0 -0
  126. /package/dist/{warehouse-6m5kXMLu.js → warehouse-CaqfmMKh.js} +0 -0
@@ -66,12 +66,25 @@ interface DataTableProps<T> {
66
66
  emptyAction?: ReactNode;
67
67
  className?: string;
68
68
  getRowId?: (row: T) => string;
69
+ /**
70
+ * Controlled (server-side) pagination. When `onPageChange` is provided the
71
+ * table stops slicing client-side — `data` is treated as the current page —
72
+ * and the pager Prev/Next/First/Last call the callbacks instead. Provide
73
+ * either `totalItems` (a real grand total is known) or `hasNextPage`
74
+ * (unknown total — Next is enabled while the last fetch returned a full page).
75
+ */
76
+ pageIndex?: number;
77
+ onPageChange?: (pageIndex: number) => void;
78
+ onPageSizeChange?: (pageSize: number) => void;
79
+ totalItems?: number;
80
+ hasNextPage?: boolean;
69
81
  }
70
82
 
71
83
  type SortDirection = 'asc' | 'desc' | null;
72
84
 
73
85
  /**
74
- * DataTable - A reusable data table component with sorting, search, and pagination
86
+ * DataTable - A reusable data table component with sorting, search, and both
87
+ * client-side and controlled (server-side) pagination.
75
88
  */
76
89
  export function DataTable<T extends object>({
77
90
  columns,
@@ -81,8 +94,8 @@ export function DataTable<T extends object>({
81
94
  searchPlaceholder = 'Search...',
82
95
  searchFields,
83
96
  pagination = true,
84
- pageSize: initialPageSize = 10,
85
- pageSizeOptions = [5, 10, 20, 50],
97
+ pageSize: pageSizeProp = 10,
98
+ pageSizeOptions = [10, 25, 50, 100],
86
99
  onRowClick,
87
100
  rowClassName,
88
101
  emptyMessage = 'No data found',
@@ -92,14 +105,25 @@ export function DataTable<T extends object>({
92
105
  emptyAction,
93
106
  className,
94
107
  getRowId,
108
+ pageIndex,
109
+ onPageChange,
110
+ onPageSizeChange,
111
+ totalItems,
112
+ hasNextPage,
95
113
  }: DataTableProps<T>) {
114
+ const isControlled = typeof onPageChange === 'function';
115
+ const controlledPageIndex = pageIndex ?? 0;
116
+
96
117
  const [searchQuery, setSearchQuery] = useState('');
97
118
  const [sortColumn, setSortColumn] = useState<string | null>(null);
98
119
  const [sortDirection, setSortDirection] = useState<SortDirection>(null);
99
120
  const [currentPage, setCurrentPage] = useState(1);
100
- const [pageSize, setPageSize] = useState(initialPageSize);
121
+ const [internalPageSize, setInternalPageSize] = useState(pageSizeProp);
122
+
123
+ const activePageSize = isControlled ? pageSizeProp : internalPageSize;
101
124
 
102
- // Filter data based on search
125
+ // Filter data based on search (client-side; in controlled mode this scopes to
126
+ // the current page only, which is expected for server-paginated lists).
103
127
  const filteredData = useMemo(() => {
104
128
  if (!searchQuery || !searchFields || searchFields.length === 0) {
105
129
  return data;
@@ -147,15 +171,20 @@ export function DataTable<T extends object>({
147
171
  });
148
172
  }, [filteredData, sortColumn, sortDirection, columns]);
149
173
 
150
- // Paginate data
174
+ // Paginate data — controlled mode never slices (server already did).
151
175
  const paginatedData = useMemo(() => {
152
- if (!pagination) return sortedData;
176
+ if (!pagination || isControlled) return sortedData;
153
177
 
154
- const start = (currentPage - 1) * pageSize;
155
- return sortedData.slice(start, start + pageSize);
156
- }, [sortedData, currentPage, pageSize, pagination]);
178
+ const start = (currentPage - 1) * internalPageSize;
179
+ return sortedData.slice(start, start + internalPageSize);
180
+ }, [sortedData, currentPage, internalPageSize, pagination, isControlled]);
157
181
 
158
- const totalPages = Math.ceil(sortedData.length / pageSize);
182
+ const totalPagesClient = Math.max(1, Math.ceil(sortedData.length / internalPageSize));
183
+
184
+ const hasTotal = typeof totalItems === 'number';
185
+ const totalPagesControlled = hasTotal
186
+ ? Math.max(1, Math.ceil(totalItems / activePageSize))
187
+ : undefined;
159
188
 
160
189
  // Handle sort
161
190
  const handleSort = (columnId: string) => {
@@ -206,7 +235,8 @@ export function DataTable<T extends object>({
206
235
  <Skeleton className="h-10 w-64" />
207
236
  </div>
208
237
  )}
209
- <div className="rounded-card border border-border-seam bg-bg-surface shadow-elevation-1">
238
+ {/* Desktop skeleton */}
239
+ <div className="hidden md:block rounded-card border border-border-seam bg-bg-surface shadow-elevation-1">
210
240
  <Table>
211
241
  <TableHeader>
212
242
  <TableRow>
@@ -230,10 +260,49 @@ export function DataTable<T extends object>({
230
260
  </TableBody>
231
261
  </Table>
232
262
  </div>
263
+ {/* Mobile skeleton */}
264
+ <div className="md:hidden space-y-3">
265
+ {Array.from({ length: 4 }).map((_, i) => (
266
+ <div
267
+ key={i}
268
+ className="rounded-card border border-border-seam bg-bg-surface p-4 shadow-elevation-1"
269
+ >
270
+ <Skeleton className="h-4 w-2/3 mb-2 motion-safe:animate-vc-shimmer" />
271
+ <Skeleton className="h-4 w-1/2 motion-safe:animate-vc-shimmer" />
272
+ </div>
273
+ ))}
274
+ </div>
233
275
  </div>
234
276
  );
235
277
  }
236
278
 
279
+ const isEmpty = paginatedData.length === 0;
280
+
281
+ const renderEmpty = () =>
282
+ emptyIllustration ? (
283
+ searchQuery ? (
284
+ <IllustratedEmptyState
285
+ illustration="empty-search"
286
+ title="No matches"
287
+ description={`Nothing matches “${searchQuery}”. Try a different search.`}
288
+ />
289
+ ) : (
290
+ <IllustratedEmptyState
291
+ illustration={emptyIllustration}
292
+ title={emptyTitle ?? emptyMessage}
293
+ description={emptyDescription}
294
+ action={emptyAction}
295
+ />
296
+ )
297
+ ) : (
298
+ <p className="text-text-secondary">{emptyMessage}</p>
299
+ );
300
+
301
+ // Pagination footer visibility
302
+ const showFooter =
303
+ pagination &&
304
+ (isControlled ? sortedData.length > 0 || controlledPageIndex > 0 : sortedData.length > 0);
305
+
237
306
  return (
238
307
  <div className={cn('space-y-4', className)}>
239
308
  {/* Search */}
@@ -254,8 +323,8 @@ export function DataTable<T extends object>({
254
323
  </div>
255
324
  )}
256
325
 
257
- {/* Table */}
258
- <div className="overflow-x-auto rounded-card border border-border-seam bg-bg-surface shadow-elevation-1">
326
+ {/* Table (desktop / tablet) */}
327
+ <div className="hidden md:block overflow-x-auto rounded-card border border-border-seam bg-bg-surface shadow-elevation-1">
259
328
  <Table>
260
329
  <TableHeader>
261
330
  <TableRow>
@@ -277,27 +346,10 @@ export function DataTable<T extends object>({
277
346
  </TableRow>
278
347
  </TableHeader>
279
348
  <TableBody>
280
- {paginatedData.length === 0 ? (
349
+ {isEmpty ? (
281
350
  <TableRow>
282
351
  <TableCell colSpan={columns.length} className="py-8 text-center">
283
- {emptyIllustration ? (
284
- searchQuery ? (
285
- <IllustratedEmptyState
286
- illustration="empty-search"
287
- title="No matches"
288
- description={`Nothing matches “${searchQuery}”. Try a different search.`}
289
- />
290
- ) : (
291
- <IllustratedEmptyState
292
- illustration={emptyIllustration}
293
- title={emptyTitle ?? emptyMessage}
294
- description={emptyDescription}
295
- action={emptyAction}
296
- />
297
- )
298
- ) : (
299
- <p className="text-text-secondary">{emptyMessage}</p>
300
- )}
352
+ {renderEmpty()}
301
353
  </TableCell>
302
354
  </TableRow>
303
355
  ) : (
@@ -325,19 +377,78 @@ export function DataTable<T extends object>({
325
377
  </Table>
326
378
  </div>
327
379
 
380
+ {/* Card list (mobile) */}
381
+ <div className="md:hidden space-y-3">
382
+ {isEmpty ? (
383
+ <div className="rounded-card border border-border-seam bg-bg-surface p-6 text-center shadow-elevation-1">
384
+ {renderEmpty()}
385
+ </div>
386
+ ) : (
387
+ paginatedData.map((row, index) => {
388
+ const rowId = getRowId ? getRowId(row) : index;
389
+ const rowClasses =
390
+ typeof rowClassName === 'function' ? rowClassName(row) : rowClassName;
391
+
392
+ return (
393
+ <div
394
+ key={rowId}
395
+ role={onRowClick ? 'button' : undefined}
396
+ tabIndex={onRowClick ? 0 : undefined}
397
+ className={cn(
398
+ 'rounded-card border border-border-seam bg-bg-surface p-4 shadow-elevation-1',
399
+ onRowClick && 'cursor-pointer hover:bg-bg-sunken/50',
400
+ rowClasses
401
+ )}
402
+ onClick={() => onRowClick?.(row)}
403
+ onKeyDown={(e) => {
404
+ if (onRowClick && (e.key === 'Enter' || e.key === ' ')) {
405
+ e.preventDefault();
406
+ onRowClick(row);
407
+ }
408
+ }}
409
+ >
410
+ <dl className="space-y-2">
411
+ {columns.map((column) => (
412
+ <div key={column.id} className="flex items-start justify-between gap-3">
413
+ <dt className="text-xs font-medium text-text-secondary shrink-0">
414
+ {column.header}
415
+ </dt>
416
+ <dd className="text-sm text-right min-w-0">{getCellValue(row, column)}</dd>
417
+ </div>
418
+ ))}
419
+ </dl>
420
+ </div>
421
+ );
422
+ })
423
+ )}
424
+ </div>
425
+
328
426
  {/* Pagination */}
329
- {pagination && sortedData.length > 0 && (
330
- <div className="flex items-center justify-between">
331
- <div className="flex items-center gap-2 text-sm text-text-secondary">
332
- <span>
333
- Showing {(currentPage - 1) * pageSize + 1} to{' '}
334
- {Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length}
335
- </span>
427
+ {showFooter && (
428
+ <div className="flex flex-wrap items-center justify-between gap-3">
429
+ <div className="flex flex-wrap items-center gap-2 text-sm text-text-secondary">
430
+ {isControlled ? (
431
+ <span>
432
+ Showing {sortedData.length === 0 ? 0 : controlledPageIndex * activePageSize + 1} to{' '}
433
+ {controlledPageIndex * activePageSize + sortedData.length}
434
+ {hasTotal ? ` of ${totalItems}` : ''}
435
+ </span>
436
+ ) : (
437
+ <span>
438
+ Showing {(currentPage - 1) * internalPageSize + 1} to{' '}
439
+ {Math.min(currentPage * internalPageSize, sortedData.length)} of {sortedData.length}
440
+ </span>
441
+ )}
336
442
  <Select
337
- value={String(pageSize)}
443
+ value={String(activePageSize)}
338
444
  onValueChange={(value) => {
339
- setPageSize(Number(value));
340
- setCurrentPage(1);
445
+ const next = Number(value);
446
+ if (isControlled) {
447
+ onPageSizeChange?.(next);
448
+ } else {
449
+ setInternalPageSize(next);
450
+ setCurrentPage(1);
451
+ }
341
452
  }}
342
453
  >
343
454
  <SelectTrigger className="w-[70px] h-8">
@@ -354,47 +465,104 @@ export function DataTable<T extends object>({
354
465
  <span>per page</span>
355
466
  </div>
356
467
 
357
- <div className="flex items-center gap-1">
358
- <Button
359
- variant="outline"
360
- size="icon"
361
- className="h-8 w-8"
362
- onClick={() => setCurrentPage(1)}
363
- disabled={currentPage === 1}
364
- >
365
- <ChevronsLeft className="h-4 w-4" />
366
- </Button>
367
- <Button
368
- variant="outline"
369
- size="icon"
370
- className="h-8 w-8"
371
- onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
372
- disabled={currentPage === 1}
373
- >
374
- <ChevronLeft className="h-4 w-4" />
375
- </Button>
376
- <span className="px-3 text-sm">
377
- Page {currentPage} of {totalPages}
378
- </span>
379
- <Button
380
- variant="outline"
381
- size="icon"
382
- className="h-8 w-8"
383
- onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
384
- disabled={currentPage === totalPages}
385
- >
386
- <ChevronRight className="h-4 w-4" />
387
- </Button>
388
- <Button
389
- variant="outline"
390
- size="icon"
391
- className="h-8 w-8"
392
- onClick={() => setCurrentPage(totalPages)}
393
- disabled={currentPage === totalPages}
394
- >
395
- <ChevronsRight className="h-4 w-4" />
396
- </Button>
397
- </div>
468
+ {isControlled ? (
469
+ <div className="flex items-center gap-1">
470
+ <Button
471
+ variant="outline"
472
+ size="icon"
473
+ className="h-8 w-8"
474
+ aria-label="First page"
475
+ onClick={() => onPageChange?.(0)}
476
+ disabled={controlledPageIndex === 0}
477
+ >
478
+ <ChevronsLeft className="h-4 w-4" />
479
+ </Button>
480
+ <Button
481
+ variant="outline"
482
+ size="icon"
483
+ className="h-8 w-8"
484
+ aria-label="Previous page"
485
+ onClick={() => onPageChange?.(Math.max(0, controlledPageIndex - 1))}
486
+ disabled={controlledPageIndex === 0}
487
+ >
488
+ <ChevronLeft className="h-4 w-4" />
489
+ </Button>
490
+ <span className="px-3 text-sm">
491
+ Page {controlledPageIndex + 1}
492
+ {hasTotal ? ` of ${totalPagesControlled}` : ''}
493
+ </span>
494
+ <Button
495
+ variant="outline"
496
+ size="icon"
497
+ className="h-8 w-8"
498
+ aria-label="Next page"
499
+ onClick={() => onPageChange?.(controlledPageIndex + 1)}
500
+ disabled={
501
+ hasTotal ? controlledPageIndex + 1 >= (totalPagesControlled ?? 1) : !hasNextPage
502
+ }
503
+ >
504
+ <ChevronRight className="h-4 w-4" />
505
+ </Button>
506
+ {hasTotal && (
507
+ <Button
508
+ variant="outline"
509
+ size="icon"
510
+ className="h-8 w-8"
511
+ aria-label="Last page"
512
+ onClick={() => onPageChange?.((totalPagesControlled ?? 1) - 1)}
513
+ disabled={controlledPageIndex + 1 >= (totalPagesControlled ?? 1)}
514
+ >
515
+ <ChevronsRight className="h-4 w-4" />
516
+ </Button>
517
+ )}
518
+ </div>
519
+ ) : (
520
+ <div className="flex items-center gap-1">
521
+ <Button
522
+ variant="outline"
523
+ size="icon"
524
+ className="h-8 w-8"
525
+ aria-label="First page"
526
+ onClick={() => setCurrentPage(1)}
527
+ disabled={currentPage === 1}
528
+ >
529
+ <ChevronsLeft className="h-4 w-4" />
530
+ </Button>
531
+ <Button
532
+ variant="outline"
533
+ size="icon"
534
+ className="h-8 w-8"
535
+ aria-label="Previous page"
536
+ onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
537
+ disabled={currentPage === 1}
538
+ >
539
+ <ChevronLeft className="h-4 w-4" />
540
+ </Button>
541
+ <span className="px-3 text-sm">
542
+ Page {currentPage} of {totalPagesClient}
543
+ </span>
544
+ <Button
545
+ variant="outline"
546
+ size="icon"
547
+ className="h-8 w-8"
548
+ aria-label="Next page"
549
+ onClick={() => setCurrentPage((p) => Math.min(totalPagesClient, p + 1))}
550
+ disabled={currentPage === totalPagesClient}
551
+ >
552
+ <ChevronRight className="h-4 w-4" />
553
+ </Button>
554
+ <Button
555
+ variant="outline"
556
+ size="icon"
557
+ className="h-8 w-8"
558
+ aria-label="Last page"
559
+ onClick={() => setCurrentPage(totalPagesClient)}
560
+ disabled={currentPage === totalPagesClient}
561
+ >
562
+ <ChevronsRight className="h-4 w-4" />
563
+ </Button>
564
+ </div>
565
+ )}
398
566
  </div>
399
567
  )}
400
568
  </div>
@@ -0,0 +1,102 @@
1
+ /**
2
+ * DocumentsCard — reuse of the platform Files module (wspace-files-svc).
3
+ *
4
+ * Lists the real files tagged for a MoveTheWheels entity (vehicle / driver /
5
+ * order) and deep-links to the Files module for uploading and managing them.
6
+ * MoveTheWheels does not store documents itself — this is honest cross-module
7
+ * reuse. When there are no files (or the query errors), we show an honest empty
8
+ * state with a working link to Files rather than any fabricated rows.
9
+ */
10
+
11
+ import { FileText, ExternalLink, Download, Upload } from 'lucide-react';
12
+ import { Card, CardContent, CardHeader, CardTitle, Button } from './ui';
13
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
14
+ import { useEntityDocuments, type EntityDocumentType } from '../hooks/useEntityDocuments';
15
+ import { formatDate } from '../utils/formatters';
16
+
17
+ interface DocumentsCardProps {
18
+ entityType: EntityDocumentType;
19
+ entityId: string;
20
+ }
21
+
22
+ function formatFileSize(bytes: number): string {
23
+ if (!Number.isFinite(bytes) || bytes <= 0) return '—';
24
+ const units = ['B', 'KB', 'MB', 'GB'];
25
+ let value = bytes;
26
+ let unit = 0;
27
+ while (value >= 1024 && unit < units.length - 1) {
28
+ value /= 1024;
29
+ unit += 1;
30
+ }
31
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
32
+ }
33
+
34
+ export function DocumentsCard({ entityType, entityId }: DocumentsCardProps) {
35
+ const { navigate } = useMoveTheWheels();
36
+ const { documents, isLoading } = useEntityDocuments(entityType, entityId);
37
+
38
+ // Deep-link to the host Files module (mounted at /files in the app shell).
39
+ // This is a host-absolute path, so it uses the raw navigate — not the
40
+ // MoveTheWheels base-path-joined one.
41
+ const openFiles = () => navigate('/files/browse');
42
+
43
+ return (
44
+ <Card>
45
+ <CardHeader className="flex flex-row items-center justify-between">
46
+ <CardTitle className="flex items-center gap-2">
47
+ <FileText size={18} /> Documents
48
+ </CardTitle>
49
+ <Button variant="outline" size="sm" onClick={openFiles}>
50
+ <Upload size={14} /> Add / Manage
51
+ </Button>
52
+ </CardHeader>
53
+ <CardContent>
54
+ {isLoading ? (
55
+ <p className="text-text-secondary text-center py-4">Loading documents…</p>
56
+ ) : documents.length > 0 ? (
57
+ <div className="space-y-3">
58
+ {documents.map((doc) => (
59
+ <div
60
+ key={doc.id}
61
+ className="flex items-center justify-between gap-3 p-3 border rounded-lg"
62
+ >
63
+ <div className="min-w-0">
64
+ <p className="font-medium truncate">{doc.name}</p>
65
+ <p className="text-sm text-text-secondary">
66
+ {formatFileSize(doc.size)} · {formatDate(doc.createdAt, 'relative')}
67
+ </p>
68
+ </div>
69
+ {doc.downloadUrl ? (
70
+ <a
71
+ href={doc.downloadUrl}
72
+ target="_blank"
73
+ rel="noopener noreferrer"
74
+ className="shrink-0 inline-flex items-center gap-1 text-sm text-primary hover:underline"
75
+ >
76
+ <Download size={14} /> Open
77
+ </a>
78
+ ) : (
79
+ <Button variant="ghost" size="sm" onClick={openFiles}>
80
+ <ExternalLink size={14} /> View
81
+ </Button>
82
+ )}
83
+ </div>
84
+ ))}
85
+ </div>
86
+ ) : (
87
+ <div className="text-center py-6">
88
+ <FileText size={32} className="mx-auto mb-2 text-text-secondary opacity-50" />
89
+ <p className="text-text-secondary">No documents yet</p>
90
+ <button
91
+ type="button"
92
+ onClick={openFiles}
93
+ className="mt-1 text-sm text-primary hover:underline"
94
+ >
95
+ Add them in Files
96
+ </button>
97
+ </div>
98
+ )}
99
+ </CardContent>
100
+ </Card>
101
+ );
102
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Notice — inline success / info feedback banner.
3
+ *
4
+ * The MoveTheWheels MFE does not mount its own sonner Toaster (mounting a second
5
+ * Toaster inside a host shell causes duplicate toasts), so action feedback is
6
+ * shown inline via this banner. Renders nothing when there is no message, so it
7
+ * can be dropped in unconditionally. Uses semantic status tokens so it themes
8
+ * correctly. Pair it with `useNotice` for auto-dismiss behaviour.
9
+ */
10
+
11
+ import { useCallback, useRef, useState } from 'react';
12
+ import { CheckCircle, Info } from 'lucide-react';
13
+
14
+ type NoticeVariant = 'success' | 'info';
15
+
16
+ interface NoticeProps {
17
+ message?: string | null;
18
+ variant?: NoticeVariant;
19
+ }
20
+
21
+ export function Notice({ message, variant = 'success' }: NoticeProps) {
22
+ if (!message) return null;
23
+ const isSuccess = variant === 'success';
24
+ return (
25
+ <div
26
+ role="status"
27
+ className={`flex items-start gap-2 rounded-lg border px-4 py-3 text-sm ${
28
+ isSuccess
29
+ ? 'border-status-success-border bg-status-success-bg-subtle text-status-success-text'
30
+ : 'border-status-info-border bg-status-info-bg-subtle text-status-info-text'
31
+ }`}
32
+ >
33
+ {isSuccess ? (
34
+ <CheckCircle size={18} className="mt-0.5 shrink-0" />
35
+ ) : (
36
+ <Info size={18} className="mt-0.5 shrink-0" />
37
+ )}
38
+ <span>{message}</span>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ /**
44
+ * useNotice — transient success/info message that auto-clears after a delay.
45
+ * Returns the current message plus a `notify` setter, mirroring a toast API.
46
+ */
47
+ export function useNotice(autoDismissMs = 4000) {
48
+ const [message, setMessage] = useState<string | null>(null);
49
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
50
+
51
+ const notify = useCallback(
52
+ (next: string) => {
53
+ setMessage(next);
54
+ if (timer.current) clearTimeout(timer.current);
55
+ timer.current = setTimeout(() => setMessage(null), autoDismissMs);
56
+ },
57
+ [autoDismissMs]
58
+ );
59
+
60
+ return { message, notify };
61
+ }
@@ -24,3 +24,10 @@ export {
24
24
 
25
25
  // Table components
26
26
  export { DataTable, type Column } from './DataTable';
27
+
28
+ // Feedback components
29
+ export { FormError } from './FormError';
30
+ export { Notice, useNotice } from './Notice';
31
+
32
+ // Documents (reuse of wspace-files-svc)
33
+ export { DocumentsCard } from './DocumentsCard';
@@ -41,6 +41,8 @@ export {
41
41
  useOrderAnalytics,
42
42
  useFleetAnalytics,
43
43
  usePerformanceAnalytics,
44
+ useFinanceMetrics,
45
+ type FinanceMetrics,
44
46
  } from './useAnalytics';
45
47
 
46
48
  // Tracking
@@ -65,3 +67,10 @@ export { useImportExport } from './useImportExport';
65
67
 
66
68
  // Module settings
67
69
  export { useModuleSettings } from './useSettings';
70
+
71
+ // Entity documents (reuse of wspace-files-svc)
72
+ export {
73
+ useEntityDocuments,
74
+ type EntityDocument,
75
+ type EntityDocumentType,
76
+ } from './useEntityDocuments';