@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.
- package/dist/components/CustomerPicker.js +52 -48
- package/dist/components/EntityCombobox.js +25 -23
- package/dist/components/OrderItemRow.js +123 -78
- package/dist/hooks/useCustomers.js +52 -17
- package/dist/hooks/useFleet.js +126 -44
- package/dist/hooks/useInventory.js +43 -7
- package/dist/hooks/useMultiModalShipments.js +30 -26
- package/dist/hooks/useRefetchOnLeaveEdit.js +10 -0
- package/dist/hooks/useRoutes.js +55 -15
- package/dist/movethewheels/components/CustomerPicker.d.ts +5 -1
- package/dist/movethewheels/components/EntityCombobox.d.ts +6 -3
- package/dist/movethewheels/components/OrderItemRow.d.ts +7 -1
- package/dist/movethewheels/hooks/useCustomers.d.ts +3 -0
- package/dist/movethewheels/hooks/useFleet.d.ts +6 -0
- package/dist/movethewheels/hooks/useInventory.d.ts +2 -0
- package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +1 -0
- package/dist/movethewheels/hooks/useRefetchOnLeaveEdit.d.ts +7 -0
- package/dist/movethewheels/hooks/useRoutes.d.ts +3 -0
- package/dist/movethewheels/utils/orderValidation.d.ts +11 -0
- package/dist/pages/CreateOrderPage.js +349 -297
- package/dist/pages/DriverDetailsPage.js +258 -251
- package/dist/pages/DriversPage.js +3 -1
- package/dist/pages/MultiModalShipmentPage.js +139 -125
- package/dist/pages/OrderDetailsPage.js +132 -140
- package/dist/pages/ProductDetailsPage.js +22 -19
- package/dist/pages/RouteDetailsPage.js +284 -281
- package/dist/pages/TrackingPage.js +2 -1
- package/dist/pages/VehicleDetailsPage.js +28 -25
- package/dist/utils/orderValidation.js +18 -0
- package/package.json +1 -1
- package/src/movethewheels/components/CustomerPicker.tsx +12 -0
- package/src/movethewheels/components/EntityCombobox.tsx +30 -11
- package/src/movethewheels/components/OrderItemRow.tsx +44 -0
- package/src/movethewheels/hooks/useCustomers.ts +38 -0
- package/src/movethewheels/hooks/useFleet.ts +125 -5
- package/src/movethewheels/hooks/useInventory.ts +42 -0
- package/src/movethewheels/hooks/useMultiModalShipments.ts +9 -7
- package/src/movethewheels/hooks/useRefetchOnLeaveEdit.ts +18 -0
- package/src/movethewheels/hooks/useRoutes.ts +51 -0
- package/src/movethewheels/pages/CreateOrderPage.tsx +66 -2
- package/src/movethewheels/pages/DriverDetailsPage.tsx +14 -1
- package/src/movethewheels/pages/DriversPage.tsx +7 -1
- package/src/movethewheels/pages/MultiModalShipmentPage.tsx +15 -1
- package/src/movethewheels/pages/OrderDetailsPage.tsx +25 -25
- package/src/movethewheels/pages/ProductDetailsPage.tsx +2 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +10 -4
- package/src/movethewheels/pages/TrackingPage.tsx +7 -2
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +2 -0
- package/src/movethewheels/utils/orderValidation.ts +53 -0
|
@@ -928,8 +928,11 @@ interface UseVehiclesResult {
|
|
|
928
928
|
/** True when the last fetch returned a full page (rawCount === limit). */
|
|
929
929
|
hasMore: boolean;
|
|
930
930
|
isLoading: boolean;
|
|
931
|
+
/** True while an additional server page is appended. */
|
|
932
|
+
isLoadingMore: boolean;
|
|
931
933
|
error: Error | null;
|
|
932
934
|
refetch: () => void;
|
|
935
|
+
loadMore: () => Promise<void>;
|
|
933
936
|
updateVehicleStatus: (id: string, status: VehicleStatus) => Promise<Vehicle>;
|
|
934
937
|
assignDriver: (vehicleId: string, driverId: string) => Promise<Vehicle>;
|
|
935
938
|
deleteVehicle: (id: string) => Promise<void>;
|
|
@@ -942,13 +945,16 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
942
945
|
const [totalVehicles, setTotalVehicles] = useState(0);
|
|
943
946
|
const [hasMore, setHasMore] = useState(false);
|
|
944
947
|
const [isLoading, setIsLoading] = useState(false);
|
|
948
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
945
949
|
const [error, setError] = useState<Error | null>(null);
|
|
946
950
|
// Stable dependency key for the `ids` array — a caller passing a fresh array
|
|
947
951
|
// literal each render must not defeat the callback's memoization.
|
|
948
952
|
const idsKey = ids ? [...ids].sort().join(',') : '';
|
|
953
|
+
const idsFilterMode = ids === undefined ? 'none' : 'exact';
|
|
954
|
+
const exactIds = useMemo(() => (idsKey ? idsKey.split(',') : []), [idsKey]);
|
|
949
955
|
|
|
950
956
|
const load = useCallback(async () => {
|
|
951
|
-
if (
|
|
957
|
+
if (idsFilterMode === 'exact' && exactIds.length === 0) {
|
|
952
958
|
// Nothing to look up — skip the request and clear stale results.
|
|
953
959
|
setVehicles([]);
|
|
954
960
|
setTotalVehicles(0);
|
|
@@ -967,11 +973,14 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
967
973
|
status: status ? toApiVehicleStatus(status) : undefined,
|
|
968
974
|
type: vehicleType?.toUpperCase(),
|
|
969
975
|
driverId: driverId || undefined,
|
|
970
|
-
ids:
|
|
976
|
+
ids: exactIds.length > 0 ? exactIds : undefined,
|
|
977
|
+
},
|
|
978
|
+
pagination: {
|
|
979
|
+
limit: idsFilterMode === 'exact' ? exactIds.length : limit,
|
|
980
|
+
offset: idsFilterMode === 'exact' ? 0 : offset,
|
|
971
981
|
},
|
|
972
|
-
pagination: { limit: ids && ids.length > 0 ? ids.length : limit, offset: ids ? 0 : offset },
|
|
973
982
|
});
|
|
974
|
-
setHasMore(offset + data.vehicles.length < data.vehiclesCount);
|
|
983
|
+
setHasMore(idsFilterMode === 'none' && offset + data.vehicles.length < data.vehiclesCount);
|
|
975
984
|
setTotalVehicles(data.vehiclesCount);
|
|
976
985
|
setVehicles(data.vehicles.map(mapVehicle));
|
|
977
986
|
} catch (fetchError) {
|
|
@@ -979,7 +988,65 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
979
988
|
} finally {
|
|
980
989
|
setIsLoading(false);
|
|
981
990
|
}
|
|
982
|
-
}, [
|
|
991
|
+
}, [
|
|
992
|
+
graphQLOptions,
|
|
993
|
+
limit,
|
|
994
|
+
offset,
|
|
995
|
+
search,
|
|
996
|
+
status,
|
|
997
|
+
vehicleType,
|
|
998
|
+
driverId,
|
|
999
|
+
idsFilterMode,
|
|
1000
|
+
exactIds,
|
|
1001
|
+
]);
|
|
1002
|
+
|
|
1003
|
+
const loadMore = useCallback(async () => {
|
|
1004
|
+
if (idsFilterMode === 'exact' || !hasMore || isLoading || isLoadingMore) return;
|
|
1005
|
+
const nextOffset = offset + vehicles.length;
|
|
1006
|
+
setIsLoadingMore(true);
|
|
1007
|
+
setError(null);
|
|
1008
|
+
try {
|
|
1009
|
+
const data = await executeGraphQL<{ vehicles: ApiVehicle[]; vehiclesCount: number }>(
|
|
1010
|
+
graphQLOptions,
|
|
1011
|
+
LIST_VEHICLES_QUERY,
|
|
1012
|
+
{
|
|
1013
|
+
filter: {
|
|
1014
|
+
search: search || undefined,
|
|
1015
|
+
status: status ? toApiVehicleStatus(status) : undefined,
|
|
1016
|
+
type: vehicleType?.toUpperCase(),
|
|
1017
|
+
driverId: driverId || undefined,
|
|
1018
|
+
},
|
|
1019
|
+
pagination: { limit, offset: nextOffset },
|
|
1020
|
+
}
|
|
1021
|
+
);
|
|
1022
|
+
const mapped = data.vehicles.map(mapVehicle);
|
|
1023
|
+
setTotalVehicles(data.vehiclesCount);
|
|
1024
|
+
setHasMore(nextOffset + mapped.length < data.vehiclesCount);
|
|
1025
|
+
setVehicles((previous) => {
|
|
1026
|
+
const existingIds = new Set(previous.map((vehicle) => vehicle.id));
|
|
1027
|
+
return [...previous, ...mapped.filter((vehicle) => !existingIds.has(vehicle.id))];
|
|
1028
|
+
});
|
|
1029
|
+
} catch (fetchError) {
|
|
1030
|
+
setError(
|
|
1031
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more vehicles')
|
|
1032
|
+
);
|
|
1033
|
+
} finally {
|
|
1034
|
+
setIsLoadingMore(false);
|
|
1035
|
+
}
|
|
1036
|
+
}, [
|
|
1037
|
+
driverId,
|
|
1038
|
+
graphQLOptions,
|
|
1039
|
+
hasMore,
|
|
1040
|
+
idsFilterMode,
|
|
1041
|
+
isLoading,
|
|
1042
|
+
isLoadingMore,
|
|
1043
|
+
limit,
|
|
1044
|
+
offset,
|
|
1045
|
+
search,
|
|
1046
|
+
status,
|
|
1047
|
+
vehicleType,
|
|
1048
|
+
vehicles.length,
|
|
1049
|
+
]);
|
|
983
1050
|
|
|
984
1051
|
useEffect(() => {
|
|
985
1052
|
void load();
|
|
@@ -1044,8 +1111,10 @@ export function useVehicles(options: UseVehiclesOptions = {}): UseVehiclesResult
|
|
|
1044
1111
|
totalVehicles,
|
|
1045
1112
|
hasMore,
|
|
1046
1113
|
isLoading,
|
|
1114
|
+
isLoadingMore,
|
|
1047
1115
|
error,
|
|
1048
1116
|
refetch,
|
|
1117
|
+
loadMore,
|
|
1049
1118
|
updateVehicleStatus,
|
|
1050
1119
|
assignDriver,
|
|
1051
1120
|
deleteVehicle,
|
|
@@ -1099,8 +1168,11 @@ interface UseDriversResult {
|
|
|
1099
1168
|
/** True when the last fetch returned a full page (rawCount === limit). */
|
|
1100
1169
|
hasMore: boolean;
|
|
1101
1170
|
isLoading: boolean;
|
|
1171
|
+
/** True while an additional server page is appended. */
|
|
1172
|
+
isLoadingMore: boolean;
|
|
1102
1173
|
error: Error | null;
|
|
1103
1174
|
refetch: () => void;
|
|
1175
|
+
loadMore: () => Promise<void>;
|
|
1104
1176
|
updateDriverStatus: (id: string, status: DriverStatus) => Promise<Driver>;
|
|
1105
1177
|
assignVehicle: (driverId: string, vehicleId: string | null) => Promise<Driver>;
|
|
1106
1178
|
}
|
|
@@ -1121,6 +1193,7 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
|
|
|
1121
1193
|
const [totalDrivers, setTotalDrivers] = useState(0);
|
|
1122
1194
|
const [hasMore, setHasMore] = useState(false);
|
|
1123
1195
|
const [isLoading, setIsLoading] = useState(false);
|
|
1196
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
1124
1197
|
const [error, setError] = useState<Error | null>(null);
|
|
1125
1198
|
|
|
1126
1199
|
const load = useCallback(async () => {
|
|
@@ -1149,6 +1222,51 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
|
|
|
1149
1222
|
}
|
|
1150
1223
|
}, [graphQLOptions, limit, offset, status, search, licenseClass, vehicleId]);
|
|
1151
1224
|
|
|
1225
|
+
const loadMore = useCallback(async () => {
|
|
1226
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
1227
|
+
const nextOffset = offset + drivers.length;
|
|
1228
|
+
setIsLoadingMore(true);
|
|
1229
|
+
setError(null);
|
|
1230
|
+
try {
|
|
1231
|
+
const data = await executeGraphQL<{ drivers: ApiDriver[]; driversCount: number }>(
|
|
1232
|
+
graphQLOptions,
|
|
1233
|
+
LIST_DRIVERS_QUERY,
|
|
1234
|
+
{
|
|
1235
|
+
filter: {
|
|
1236
|
+
status: status ? toApiDriverStatus(status) : undefined,
|
|
1237
|
+
search: search || undefined,
|
|
1238
|
+
licenseClass: licenseClass || undefined,
|
|
1239
|
+
vehicleId: vehicleId || undefined,
|
|
1240
|
+
},
|
|
1241
|
+
pagination: { limit, offset: nextOffset },
|
|
1242
|
+
}
|
|
1243
|
+
);
|
|
1244
|
+
const mapped = data.drivers.map(mapDriver);
|
|
1245
|
+
setTotalDrivers(data.driversCount);
|
|
1246
|
+
setHasMore(nextOffset + mapped.length < data.driversCount);
|
|
1247
|
+
setDrivers((previous) => {
|
|
1248
|
+
const existingIds = new Set(previous.map((driver) => driver.id));
|
|
1249
|
+
return [...previous, ...mapped.filter((driver) => !existingIds.has(driver.id))];
|
|
1250
|
+
});
|
|
1251
|
+
} catch (fetchError) {
|
|
1252
|
+
setError(fetchError instanceof Error ? fetchError : new Error('Failed to load more drivers'));
|
|
1253
|
+
} finally {
|
|
1254
|
+
setIsLoadingMore(false);
|
|
1255
|
+
}
|
|
1256
|
+
}, [
|
|
1257
|
+
drivers.length,
|
|
1258
|
+
graphQLOptions,
|
|
1259
|
+
hasMore,
|
|
1260
|
+
isLoading,
|
|
1261
|
+
isLoadingMore,
|
|
1262
|
+
limit,
|
|
1263
|
+
licenseClass,
|
|
1264
|
+
offset,
|
|
1265
|
+
search,
|
|
1266
|
+
status,
|
|
1267
|
+
vehicleId,
|
|
1268
|
+
]);
|
|
1269
|
+
|
|
1152
1270
|
useEffect(() => {
|
|
1153
1271
|
void load();
|
|
1154
1272
|
}, [load]);
|
|
@@ -1196,8 +1314,10 @@ export function useDrivers(options: UseDriversOptions = {}): UseDriversResult {
|
|
|
1196
1314
|
totalDrivers,
|
|
1197
1315
|
hasMore,
|
|
1198
1316
|
isLoading,
|
|
1317
|
+
isLoadingMore,
|
|
1199
1318
|
error,
|
|
1200
1319
|
refetch,
|
|
1320
|
+
loadMore,
|
|
1201
1321
|
updateDriverStatus,
|
|
1202
1322
|
assignVehicle,
|
|
1203
1323
|
};
|
|
@@ -849,6 +849,7 @@ export function useProducts(options: UseProductsOptions = {}) {
|
|
|
849
849
|
const [totalProducts, setTotalProducts] = useState(0);
|
|
850
850
|
const [hasMore, setHasMore] = useState(false);
|
|
851
851
|
const [isLoading, setIsLoading] = useState(false);
|
|
852
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
852
853
|
const [error, setError] = useState<Error | null>(null);
|
|
853
854
|
|
|
854
855
|
const load = useCallback(async () => {
|
|
@@ -872,6 +873,45 @@ export function useProducts(options: UseProductsOptions = {}) {
|
|
|
872
873
|
}
|
|
873
874
|
}, [graphQLOptions, limit, offset, search, category]);
|
|
874
875
|
|
|
876
|
+
const loadMore = useCallback(async () => {
|
|
877
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
878
|
+
const nextOffset = offset + products.length;
|
|
879
|
+
setIsLoadingMore(true);
|
|
880
|
+
setError(null);
|
|
881
|
+
try {
|
|
882
|
+
const data = await executeGraphQL<{
|
|
883
|
+
cargoItems: ApiProduct[];
|
|
884
|
+
cargoItemsCount: number;
|
|
885
|
+
}>(graphQLOptions, LIST_PRODUCTS_QUERY, {
|
|
886
|
+
filter: { search: search || undefined, category: category || undefined },
|
|
887
|
+
pagination: { limit, offset: nextOffset },
|
|
888
|
+
});
|
|
889
|
+
const mapped = data.cargoItems.map(mapProduct);
|
|
890
|
+
setTotalProducts(data.cargoItemsCount);
|
|
891
|
+
setHasMore(nextOffset + mapped.length < data.cargoItemsCount);
|
|
892
|
+
setProducts((previous) => {
|
|
893
|
+
const existingIds = new Set(previous.map((product) => product.id));
|
|
894
|
+
return [...previous, ...mapped.filter((product) => !existingIds.has(product.id))];
|
|
895
|
+
});
|
|
896
|
+
} catch (fetchError) {
|
|
897
|
+
setError(
|
|
898
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more products')
|
|
899
|
+
);
|
|
900
|
+
} finally {
|
|
901
|
+
setIsLoadingMore(false);
|
|
902
|
+
}
|
|
903
|
+
}, [
|
|
904
|
+
category,
|
|
905
|
+
graphQLOptions,
|
|
906
|
+
hasMore,
|
|
907
|
+
isLoading,
|
|
908
|
+
isLoadingMore,
|
|
909
|
+
limit,
|
|
910
|
+
offset,
|
|
911
|
+
products.length,
|
|
912
|
+
search,
|
|
913
|
+
]);
|
|
914
|
+
|
|
875
915
|
useEffect(() => {
|
|
876
916
|
void load();
|
|
877
917
|
}, [load]);
|
|
@@ -881,8 +921,10 @@ export function useProducts(options: UseProductsOptions = {}) {
|
|
|
881
921
|
totalProducts,
|
|
882
922
|
hasMore,
|
|
883
923
|
isLoading,
|
|
924
|
+
isLoadingMore,
|
|
884
925
|
error,
|
|
885
926
|
refetch: () => void load(),
|
|
927
|
+
loadMore,
|
|
886
928
|
};
|
|
887
929
|
}
|
|
888
930
|
|
|
@@ -140,11 +140,11 @@ const SHIPMENT_FIELDS = `
|
|
|
140
140
|
`;
|
|
141
141
|
|
|
142
142
|
const MULTI_MODAL_SHIPMENTS_QUERY = `
|
|
143
|
-
query ListMultiModalShipments($pagination: PaginationInput) {
|
|
144
|
-
shipments(filter: { isMultiModal: true }, pagination: $pagination) {
|
|
143
|
+
query ListMultiModalShipments($search: String, $pagination: PaginationInput) {
|
|
144
|
+
shipments(filter: { isMultiModal: true, search: $search }, pagination: $pagination) {
|
|
145
145
|
${SHIPMENT_FIELDS}
|
|
146
146
|
}
|
|
147
|
-
shipmentsCount(filter: { isMultiModal: true })
|
|
147
|
+
shipmentsCount(filter: { isMultiModal: true, search: $search })
|
|
148
148
|
}
|
|
149
149
|
`;
|
|
150
150
|
|
|
@@ -265,8 +265,10 @@ function useGraphqlOptions() {
|
|
|
265
265
|
);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
export function useMultiModalShipments(
|
|
269
|
-
|
|
268
|
+
export function useMultiModalShipments(
|
|
269
|
+
options: { limit?: number; offset?: number; search?: string } = {}
|
|
270
|
+
) {
|
|
271
|
+
const { limit = 10, offset = 0, search = '' } = options;
|
|
270
272
|
const graphQLOptions = useGraphqlOptions();
|
|
271
273
|
const [shipments, setShipments] = useState<MultiModalShipment[]>([]);
|
|
272
274
|
const [totalShipments, setTotalShipments] = useState(0);
|
|
@@ -281,7 +283,7 @@ export function useMultiModalShipments(options: { limit?: number; offset?: numbe
|
|
|
281
283
|
const data = await executeGraphQL<{ shipments: ApiShipment[]; shipmentsCount: number }>(
|
|
282
284
|
graphQLOptions,
|
|
283
285
|
MULTI_MODAL_SHIPMENTS_QUERY,
|
|
284
|
-
{ pagination: { limit, offset } }
|
|
286
|
+
{ search: search.trim() || undefined, pagination: { limit, offset } }
|
|
285
287
|
);
|
|
286
288
|
setShipments(data.shipments.map(mapShipment));
|
|
287
289
|
setTotalShipments(data.shipmentsCount);
|
|
@@ -293,7 +295,7 @@ export function useMultiModalShipments(options: { limit?: number; offset?: numbe
|
|
|
293
295
|
} finally {
|
|
294
296
|
setIsLoading(false);
|
|
295
297
|
}
|
|
296
|
-
}, [graphQLOptions, limit, offset]);
|
|
298
|
+
}, [graphQLOptions, limit, offset, search]);
|
|
297
299
|
|
|
298
300
|
useEffect(() => {
|
|
299
301
|
void load();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detail pages render their `/edit` form from the same route component, so the
|
|
5
|
+
* component (and its record hook) stays mounted across edit → detail. Without a
|
|
6
|
+
* refetch the detail view keeps showing the pre-edit record. Call `refetch`
|
|
7
|
+
* exactly when the edit route is left, never on the initial mount.
|
|
8
|
+
*/
|
|
9
|
+
export function useRefetchOnLeaveEdit(isEditRoute: boolean, refetch: () => void): void {
|
|
10
|
+
const wasEditing = useRef(isEditRoute);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (wasEditing.current && !isEditRoute) {
|
|
14
|
+
refetch();
|
|
15
|
+
}
|
|
16
|
+
wasEditing.current = isEditRoute;
|
|
17
|
+
}, [isEditRoute, refetch]);
|
|
18
|
+
}
|
|
@@ -18,8 +18,11 @@ interface UseRoutesResult {
|
|
|
18
18
|
/** True when the last fetch has another server page. */
|
|
19
19
|
hasMore: boolean;
|
|
20
20
|
isLoading: boolean;
|
|
21
|
+
/** True while an additional server page is appended. */
|
|
22
|
+
isLoadingMore: boolean;
|
|
21
23
|
error: Error | null;
|
|
22
24
|
refetch: () => void;
|
|
25
|
+
loadMore: () => Promise<void>;
|
|
23
26
|
createRoute: (route: Partial<Route>) => Promise<Route>;
|
|
24
27
|
updateRoute: (
|
|
25
28
|
id: string,
|
|
@@ -275,6 +278,7 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
275
278
|
const [totalRoutes, setTotalRoutes] = useState(0);
|
|
276
279
|
const [hasMore, setHasMore] = useState(false);
|
|
277
280
|
const [isLoading, setIsLoading] = useState(false);
|
|
281
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
278
282
|
const [error, setError] = useState<Error | null>(null);
|
|
279
283
|
|
|
280
284
|
const load = useCallback(async () => {
|
|
@@ -304,6 +308,51 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
304
308
|
}
|
|
305
309
|
}, [driverId, graphQLOptions, search, status, vehicleId, limit, offset]);
|
|
306
310
|
|
|
311
|
+
const loadMore = useCallback(async () => {
|
|
312
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
313
|
+
const nextOffset = offset + routes.length;
|
|
314
|
+
setIsLoadingMore(true);
|
|
315
|
+
setError(null);
|
|
316
|
+
try {
|
|
317
|
+
const data = await executeGraphQL<{ routes: ApiRoute[]; routesCount: number }>(
|
|
318
|
+
graphQLOptions,
|
|
319
|
+
LIST_ROUTES_QUERY,
|
|
320
|
+
{
|
|
321
|
+
filter: {
|
|
322
|
+
driverId,
|
|
323
|
+
vehicleId,
|
|
324
|
+
status: status ? toApiRouteStatus(status) : undefined,
|
|
325
|
+
search: search || undefined,
|
|
326
|
+
},
|
|
327
|
+
pagination: { limit, offset: nextOffset },
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
const mapped = data.routes.map(mapRoute);
|
|
331
|
+
setTotalRoutes(data.routesCount);
|
|
332
|
+
setHasMore(nextOffset + mapped.length < data.routesCount);
|
|
333
|
+
setRoutes((previous) => {
|
|
334
|
+
const existingIds = new Set(previous.map((route) => route.id));
|
|
335
|
+
return [...previous, ...mapped.filter((route) => !existingIds.has(route.id))];
|
|
336
|
+
});
|
|
337
|
+
} catch (fetchError) {
|
|
338
|
+
setError(fetchError instanceof Error ? fetchError : new Error('Failed to load more routes'));
|
|
339
|
+
} finally {
|
|
340
|
+
setIsLoadingMore(false);
|
|
341
|
+
}
|
|
342
|
+
}, [
|
|
343
|
+
driverId,
|
|
344
|
+
graphQLOptions,
|
|
345
|
+
hasMore,
|
|
346
|
+
isLoading,
|
|
347
|
+
isLoadingMore,
|
|
348
|
+
limit,
|
|
349
|
+
offset,
|
|
350
|
+
routes.length,
|
|
351
|
+
search,
|
|
352
|
+
status,
|
|
353
|
+
vehicleId,
|
|
354
|
+
]);
|
|
355
|
+
|
|
307
356
|
useEffect(() => {
|
|
308
357
|
void load();
|
|
309
358
|
}, [load]);
|
|
@@ -436,8 +485,10 @@ export function useRoutes(options: UseRoutesOptions = {}): UseRoutesResult {
|
|
|
436
485
|
totalRoutes,
|
|
437
486
|
hasMore,
|
|
438
487
|
isLoading,
|
|
488
|
+
isLoadingMore,
|
|
439
489
|
error,
|
|
440
490
|
refetch,
|
|
491
|
+
loadMore,
|
|
441
492
|
createRoute,
|
|
442
493
|
updateRoute,
|
|
443
494
|
deleteRoute,
|
|
@@ -48,6 +48,7 @@ import { CustomerPicker } from '../components/CustomerPicker';
|
|
|
48
48
|
import { EntityCombobox } from '../components/EntityCombobox';
|
|
49
49
|
import { OrderItemRow } from '../components/OrderItemRow';
|
|
50
50
|
import type { Customer } from '../types';
|
|
51
|
+
import { validateOrderItems, type OrderItemFieldErrors } from '../utils/orderValidation';
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* `<input type="datetime-local">` needs a `YYYY-MM-DDTHH:mm` string in LOCAL
|
|
@@ -77,8 +78,12 @@ export default function CreateOrderPage() {
|
|
|
77
78
|
customers,
|
|
78
79
|
createCustomer,
|
|
79
80
|
isLoading: customersLoading,
|
|
81
|
+
isLoadingMore: customersLoadingMore,
|
|
80
82
|
error: customersError,
|
|
81
83
|
refetch: refetchCustomers,
|
|
84
|
+
loadMore: loadMoreCustomers,
|
|
85
|
+
hasMore: customersHaveMore,
|
|
86
|
+
totalCustomers,
|
|
82
87
|
} = useCustomers({
|
|
83
88
|
limit: 20,
|
|
84
89
|
filters: { search: customerSearch || undefined },
|
|
@@ -91,13 +96,22 @@ export default function CreateOrderPage() {
|
|
|
91
96
|
error: routesError,
|
|
92
97
|
hasMore: routesHaveMore,
|
|
93
98
|
totalRoutes,
|
|
99
|
+
loadMore: loadMoreRoutes,
|
|
100
|
+
isLoadingMore: routesLoadingMore,
|
|
94
101
|
} = useRoutes({
|
|
95
102
|
status: 'planned',
|
|
96
103
|
search: routeSearch || undefined,
|
|
97
104
|
limit: 20,
|
|
98
105
|
});
|
|
99
106
|
const [productSearch, setProductSearch] = useState('');
|
|
100
|
-
const {
|
|
107
|
+
const {
|
|
108
|
+
products,
|
|
109
|
+
isLoading: productsLoading,
|
|
110
|
+
isLoadingMore: productsLoadingMore,
|
|
111
|
+
hasMore: productsHaveMore,
|
|
112
|
+
totalProducts,
|
|
113
|
+
loadMore: loadMoreProducts,
|
|
114
|
+
} = useProducts({
|
|
101
115
|
search: productSearch || undefined,
|
|
102
116
|
limit: 20,
|
|
103
117
|
});
|
|
@@ -124,6 +138,7 @@ export default function CreateOrderPage() {
|
|
|
124
138
|
country: order?.deliveryAddress.country || 'USA',
|
|
125
139
|
},
|
|
126
140
|
items: order?.items || [],
|
|
141
|
+
trackingNumber: order?.trackingNumber || '',
|
|
127
142
|
scheduledDelivery: toDateTimeLocalValue(order?.scheduledDelivery),
|
|
128
143
|
specialInstructions: order?.specialInstructions || '',
|
|
129
144
|
});
|
|
@@ -137,6 +152,7 @@ export default function CreateOrderPage() {
|
|
|
137
152
|
|
|
138
153
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
139
154
|
const [formError, setFormError] = useState<string | null>(null);
|
|
155
|
+
const [itemErrors, setItemErrors] = useState<Record<string, OrderItemFieldErrors>>({});
|
|
140
156
|
|
|
141
157
|
useEffect(() => {
|
|
142
158
|
if (!linkedCustomer) {
|
|
@@ -176,6 +192,7 @@ export default function CreateOrderPage() {
|
|
|
176
192
|
country: order.deliveryAddress.country || 'USA',
|
|
177
193
|
},
|
|
178
194
|
items: order.items || [],
|
|
195
|
+
trackingNumber: order.trackingNumber || '',
|
|
179
196
|
scheduledDelivery: toDateTimeLocalValue(order.scheduledDelivery),
|
|
180
197
|
specialInstructions: order.specialInstructions || '',
|
|
181
198
|
});
|
|
@@ -210,6 +227,11 @@ export default function CreateOrderPage() {
|
|
|
210
227
|
if (formData.items.length === 0) {
|
|
211
228
|
return 'Add at least one item to the order.';
|
|
212
229
|
}
|
|
230
|
+
const nextItemErrors = validateOrderItems(formData.items);
|
|
231
|
+
setItemErrors(nextItemErrors);
|
|
232
|
+
if (Object.keys(nextItemErrors).length > 0) {
|
|
233
|
+
return 'Fix the highlighted shipment item fields before continuing.';
|
|
234
|
+
}
|
|
213
235
|
if (formData.scheduledDelivery && Number.isNaN(Date.parse(formData.scheduledDelivery))) {
|
|
214
236
|
return 'Enter a valid scheduled delivery date and time.';
|
|
215
237
|
}
|
|
@@ -251,6 +273,7 @@ export default function CreateOrderPage() {
|
|
|
251
273
|
deliveryAddress: formData.deliveryAddress,
|
|
252
274
|
items: formData.items,
|
|
253
275
|
// `null` clears an existing slot on edit; the service field is nullable.
|
|
276
|
+
trackingNumber: formData.trackingNumber.trim() || undefined,
|
|
254
277
|
scheduledDelivery: formData.scheduledDelivery ? new Date(formData.scheduledDelivery) : null,
|
|
255
278
|
routeId: formData.routeId || undefined,
|
|
256
279
|
specialInstructions: formData.specialInstructions,
|
|
@@ -346,6 +369,14 @@ export default function CreateOrderPage() {
|
|
|
346
369
|
};
|
|
347
370
|
|
|
348
371
|
const removeItem = (index: number) => {
|
|
372
|
+
const itemId = formData.items[index]?.id;
|
|
373
|
+
if (itemId) {
|
|
374
|
+
setItemErrors((previous) => {
|
|
375
|
+
const next = { ...previous };
|
|
376
|
+
delete next[itemId];
|
|
377
|
+
return next;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
349
380
|
setFormData((prev) => ({
|
|
350
381
|
...prev,
|
|
351
382
|
items: prev.items.filter((_, i) => i !== index),
|
|
@@ -353,6 +384,14 @@ export default function CreateOrderPage() {
|
|
|
353
384
|
};
|
|
354
385
|
|
|
355
386
|
const updateItem = (index: number, field: string, value: unknown) => {
|
|
387
|
+
const itemId = formData.items[index]?.id;
|
|
388
|
+
if (itemId && itemErrors[itemId]) {
|
|
389
|
+
setItemErrors((previous) => {
|
|
390
|
+
const next = { ...previous };
|
|
391
|
+
delete next[itemId];
|
|
392
|
+
return next;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
356
395
|
setFormData((prev) => ({
|
|
357
396
|
...prev,
|
|
358
397
|
items: prev.items.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
|
@@ -489,6 +528,10 @@ export default function CreateOrderPage() {
|
|
|
489
528
|
onOpenCustomers={goToCustomerCreate}
|
|
490
529
|
selectedCustomer={selectedCustomer}
|
|
491
530
|
onSearchQueryChange={setCustomerSearch}
|
|
531
|
+
hasMore={customersHaveMore}
|
|
532
|
+
totalCount={totalCustomers}
|
|
533
|
+
onLoadMore={loadMoreCustomers}
|
|
534
|
+
isLoadingMore={customersLoadingMore}
|
|
492
535
|
/>
|
|
493
536
|
<div className="space-y-2">
|
|
494
537
|
<Label htmlFor="priority">Priority</Label>
|
|
@@ -555,6 +598,8 @@ export default function CreateOrderPage() {
|
|
|
555
598
|
onSearchChange={setRouteSearch}
|
|
556
599
|
hasMore={routesHaveMore}
|
|
557
600
|
totalCount={totalRoutes}
|
|
601
|
+
onLoadMore={loadMoreRoutes}
|
|
602
|
+
loadingMore={routesLoadingMore}
|
|
558
603
|
/>
|
|
559
604
|
<p className="mt-2 text-xs text-text-secondary" role="status">
|
|
560
605
|
{routesLoading
|
|
@@ -782,6 +827,11 @@ export default function CreateOrderPage() {
|
|
|
782
827
|
index={index}
|
|
783
828
|
products={products}
|
|
784
829
|
productsLoading={productsLoading}
|
|
830
|
+
productsLoadingMore={productsLoadingMore}
|
|
831
|
+
productsHasMore={productsHaveMore}
|
|
832
|
+
productsTotalCount={totalProducts}
|
|
833
|
+
onLoadMoreProducts={loadMoreProducts}
|
|
834
|
+
errors={itemErrors[item.id]}
|
|
785
835
|
onUpdate={updateItem}
|
|
786
836
|
onRemove={removeItem}
|
|
787
837
|
/>
|
|
@@ -803,6 +853,20 @@ export default function CreateOrderPage() {
|
|
|
803
853
|
description="Optional delivery timing and instructions for the carrier."
|
|
804
854
|
>
|
|
805
855
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
856
|
+
<div className="space-y-2">
|
|
857
|
+
<Label htmlFor="trackingNumber">Tracking / reference number</Label>
|
|
858
|
+
<Input
|
|
859
|
+
id="trackingNumber"
|
|
860
|
+
value={formData.trackingNumber}
|
|
861
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
862
|
+
updateField('trackingNumber', e.target.value)
|
|
863
|
+
}
|
|
864
|
+
placeholder="Optional carrier reference"
|
|
865
|
+
/>
|
|
866
|
+
<p className="text-xs text-text-secondary">
|
|
867
|
+
Use this when a carrier or customer already supplied a tracking reference.
|
|
868
|
+
</p>
|
|
869
|
+
</div>
|
|
806
870
|
<div className="space-y-2">
|
|
807
871
|
<Label htmlFor="scheduledDelivery">Scheduled delivery</Label>
|
|
808
872
|
<Input
|
|
@@ -817,7 +881,7 @@ export default function CreateOrderPage() {
|
|
|
817
881
|
Leave blank to dispatch as soon as a driver is available.
|
|
818
882
|
</p>
|
|
819
883
|
</div>
|
|
820
|
-
<div className="space-y-2 lg:col-span-
|
|
884
|
+
<div className="space-y-2 lg:col-span-1">
|
|
821
885
|
<Label htmlFor="specialInstructions">Special instructions</Label>
|
|
822
886
|
<Textarea
|
|
823
887
|
id="specialInstructions"
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
type UpdateDriverInput,
|
|
58
58
|
} from '../hooks/useFleet';
|
|
59
59
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
60
|
+
import { useRefetchOnLeaveEdit } from '../hooks/useRefetchOnLeaveEdit';
|
|
60
61
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
61
62
|
import { formatCurrency, formatDate } from '../utils/formatters';
|
|
62
63
|
import { joinPath } from '../utils/navigation';
|
|
@@ -132,7 +133,14 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
132
133
|
|
|
133
134
|
const [vehicleSearch, setVehicleSearch] = useState('');
|
|
134
135
|
const debouncedVehicleSearch = useDebouncedValue(vehicleSearch);
|
|
135
|
-
const {
|
|
136
|
+
const {
|
|
137
|
+
vehicles,
|
|
138
|
+
isLoading: vehiclesLoading,
|
|
139
|
+
isLoadingMore: vehiclesLoadingMore,
|
|
140
|
+
hasMore: vehiclesHaveMore,
|
|
141
|
+
totalVehicles,
|
|
142
|
+
loadMore: loadMoreVehicles,
|
|
143
|
+
} = useVehicles({
|
|
136
144
|
limit: 20,
|
|
137
145
|
search: debouncedVehicleSearch || undefined,
|
|
138
146
|
});
|
|
@@ -434,6 +442,10 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
434
442
|
)}
|
|
435
443
|
selectedOption={selectedVehicle}
|
|
436
444
|
selectedLoading={selectedVehicleLoading}
|
|
445
|
+
hasMore={vehiclesHaveMore}
|
|
446
|
+
totalCount={totalVehicles}
|
|
447
|
+
onLoadMore={loadMoreVehicles}
|
|
448
|
+
loadingMore={vehiclesLoadingMore}
|
|
437
449
|
placeholder="Unassigned"
|
|
438
450
|
emptyText="No vehicles match that search."
|
|
439
451
|
/>
|
|
@@ -620,6 +632,7 @@ export default function DriverDetailsPage() {
|
|
|
620
632
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
621
633
|
const { basePath, navigate } = useMoveTheWheels();
|
|
622
634
|
const { driver, isLoading, error, refetch } = useDriver(driverId);
|
|
635
|
+
useRefetchOnLeaveEdit(isEditRoute, refetch);
|
|
623
636
|
const { vehicle: assignedVehicle } = useVehicle(driver?.assignedVehicle);
|
|
624
637
|
|
|
625
638
|
const goBack = () => navigate?.(joinPath(basePath, 'fleet/drivers'));
|
|
@@ -299,7 +299,13 @@ export default function DriversPage() {
|
|
|
299
299
|
))}
|
|
300
300
|
</SelectContent>
|
|
301
301
|
</Select>
|
|
302
|
-
<Select
|
|
302
|
+
<Select
|
|
303
|
+
value={licenseClassFilter}
|
|
304
|
+
onValueChange={(value) => {
|
|
305
|
+
setLicenseClassFilter(value);
|
|
306
|
+
setPage(0);
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
303
309
|
<SelectTrigger className="w-[180px]">
|
|
304
310
|
<SelectValue placeholder="License class" />
|
|
305
311
|
</SelectTrigger>
|
|
@@ -81,6 +81,8 @@ function formatMoney(
|
|
|
81
81
|
export default function MultiModalShipmentPage() {
|
|
82
82
|
const [pageIndex, setPageIndex] = useState(0);
|
|
83
83
|
const pageSize = 10;
|
|
84
|
+
const [shipmentNumber, setShipmentNumber] = useState('');
|
|
85
|
+
const [shipmentSearch, setShipmentSearch] = useState('');
|
|
84
86
|
const {
|
|
85
87
|
shipments,
|
|
86
88
|
totalShipments,
|
|
@@ -92,9 +94,9 @@ export default function MultiModalShipmentPage() {
|
|
|
92
94
|
} = useMultiModalShipments({
|
|
93
95
|
limit: pageSize,
|
|
94
96
|
offset: pageIndex * pageSize,
|
|
97
|
+
search: shipmentSearch,
|
|
95
98
|
});
|
|
96
99
|
|
|
97
|
-
const [shipmentNumber, setShipmentNumber] = useState('');
|
|
98
100
|
const [originAddress, setOriginAddress] = useState('');
|
|
99
101
|
const [destinationAddress, setDestinationAddress] = useState('');
|
|
100
102
|
const [legs, setLegs] = useState<ShipmentLegDraft[]>([emptyLeg()]);
|
|
@@ -529,6 +531,18 @@ export default function MultiModalShipmentPage() {
|
|
|
529
531
|
</PageSection>
|
|
530
532
|
|
|
531
533
|
<PageSection title="Planned Multi-Modal Shipments">
|
|
534
|
+
<div className="mb-4 max-w-xl space-y-2">
|
|
535
|
+
<Label htmlFor="planned-shipment-search">Search planned shipments</Label>
|
|
536
|
+
<Input
|
|
537
|
+
id="planned-shipment-search"
|
|
538
|
+
value={shipmentSearch}
|
|
539
|
+
onChange={(event) => {
|
|
540
|
+
setShipmentSearch(event.target.value);
|
|
541
|
+
setPageIndex(0);
|
|
542
|
+
}}
|
|
543
|
+
placeholder="Search by shipment or tracking number"
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
532
546
|
{isLoading ? (
|
|
533
547
|
<LoadingState message="Loading multi-modal shipments..." />
|
|
534
548
|
) : error ? (
|