@burdenoff/microfe-movethewheels 2026.930.2 → 2026.930.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.
- package/dist/components/StockActionDialogs.js +385 -0
- package/dist/hooks/useFleet.js +112 -109
- package/dist/hooks/useInventory.js +248 -124
- package/dist/index.js +34 -34
- package/dist/movethewheels/components/StockActionDialogs.d.ts +24 -0
- package/dist/movethewheels/hooks/index.d.ts +1 -1
- package/dist/movethewheels/hooks/useFleet.d.ts +1 -0
- package/dist/movethewheels/hooks/useInventory.d.ts +30 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/package-plus.js +33 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/sliders-horizontal.js +69 -0
- package/dist/pages/DriverDetailsPage.js +401 -347
- package/dist/pages/InventoryPage.js +279 -200
- package/dist/pages/RouteDetailsPage.js +4 -4
- package/package.json +1 -1
- package/src/movethewheels/components/StockActionDialogs.tsx +512 -0
- package/src/movethewheels/hooks/index.ts +3 -0
- package/src/movethewheels/hooks/useFleet.ts +18 -0
- package/src/movethewheels/hooks/useInventory.ts +294 -35
- package/src/movethewheels/pages/DriverDetailsPage.tsx +72 -1
- package/src/movethewheels/pages/InventoryPage.tsx +98 -1
- package/src/movethewheels/pages/RouteDetailsPage.tsx +5 -4
|
@@ -348,6 +348,45 @@ const UPDATE_WAREHOUSE_MUTATION = `
|
|
|
348
348
|
}
|
|
349
349
|
`;
|
|
350
350
|
|
|
351
|
+
const INVENTORY_LINE_FIELDS = `
|
|
352
|
+
id
|
|
353
|
+
cargoItemId
|
|
354
|
+
warehouseId
|
|
355
|
+
onHand
|
|
356
|
+
available
|
|
357
|
+
allocated
|
|
358
|
+
damaged
|
|
359
|
+
reorderPoint
|
|
360
|
+
createdAt
|
|
361
|
+
updatedAt
|
|
362
|
+
cargoItem {
|
|
363
|
+
id
|
|
364
|
+
name
|
|
365
|
+
sku
|
|
366
|
+
}
|
|
367
|
+
warehouse {
|
|
368
|
+
id
|
|
369
|
+
name
|
|
370
|
+
code
|
|
371
|
+
}
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
const FIND_STOCK_LINE_QUERY = `
|
|
375
|
+
query FindStockLine($filter: InventoryFilterInput, $pagination: PaginationInput) {
|
|
376
|
+
inventoryItems(filter: $filter, pagination: $pagination) {
|
|
377
|
+
${INVENTORY_LINE_FIELDS}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
const CREATE_INVENTORY_ITEM_MUTATION = `
|
|
383
|
+
mutation CreateInventoryItem($input: CreateInventoryItemInput!) {
|
|
384
|
+
createInventoryItem(input: $input) {
|
|
385
|
+
${INVENTORY_LINE_FIELDS}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
`;
|
|
389
|
+
|
|
351
390
|
const ADJUST_INVENTORY_MUTATION = `
|
|
352
391
|
mutation AdjustInventoryStock($input: AdjustInventoryStockInput!) {
|
|
353
392
|
adjustInventoryStock(input: $input) {
|
|
@@ -1012,6 +1051,7 @@ export function useWarehouses(options: UseWarehousesOptions = {}) {
|
|
|
1012
1051
|
const [totalWarehouses, setTotalWarehouses] = useState(0);
|
|
1013
1052
|
const [hasMore, setHasMore] = useState(false);
|
|
1014
1053
|
const [isLoading, setIsLoading] = useState(false);
|
|
1054
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
1015
1055
|
const [error, setError] = useState<Error | null>(null);
|
|
1016
1056
|
|
|
1017
1057
|
const load = useCallback(async () => {
|
|
@@ -1035,6 +1075,46 @@ export function useWarehouses(options: UseWarehousesOptions = {}) {
|
|
|
1035
1075
|
}
|
|
1036
1076
|
}, [graphQLOptions, limit, offset, search, status]);
|
|
1037
1077
|
|
|
1078
|
+
/** Appends the next server page (pickers); list pages keep using `offset`. */
|
|
1079
|
+
const loadMore = useCallback(async () => {
|
|
1080
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
1081
|
+
const nextOffset = offset + warehouses.length;
|
|
1082
|
+
setIsLoadingMore(true);
|
|
1083
|
+
setError(null);
|
|
1084
|
+
try {
|
|
1085
|
+
const data = await executeGraphQL<{
|
|
1086
|
+
warehouses: ApiWarehouse[];
|
|
1087
|
+
warehousesCount: number;
|
|
1088
|
+
}>(graphQLOptions, LIST_WAREHOUSES_QUERY, {
|
|
1089
|
+
filter: { search: search || undefined, status: status || undefined },
|
|
1090
|
+
pagination: { limit, offset: nextOffset },
|
|
1091
|
+
});
|
|
1092
|
+
const mapped = data.warehouses.map(mapWarehouse);
|
|
1093
|
+
setTotalWarehouses(data.warehousesCount);
|
|
1094
|
+
setHasMore(nextOffset + mapped.length < data.warehousesCount);
|
|
1095
|
+
setWarehouses((previous) => {
|
|
1096
|
+
const existingIds = new Set(previous.map((warehouse) => warehouse.id));
|
|
1097
|
+
return [...previous, ...mapped.filter((warehouse) => !existingIds.has(warehouse.id))];
|
|
1098
|
+
});
|
|
1099
|
+
} catch (fetchError) {
|
|
1100
|
+
setError(
|
|
1101
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more warehouses')
|
|
1102
|
+
);
|
|
1103
|
+
} finally {
|
|
1104
|
+
setIsLoadingMore(false);
|
|
1105
|
+
}
|
|
1106
|
+
}, [
|
|
1107
|
+
graphQLOptions,
|
|
1108
|
+
hasMore,
|
|
1109
|
+
isLoading,
|
|
1110
|
+
isLoadingMore,
|
|
1111
|
+
limit,
|
|
1112
|
+
offset,
|
|
1113
|
+
search,
|
|
1114
|
+
status,
|
|
1115
|
+
warehouses.length,
|
|
1116
|
+
]);
|
|
1117
|
+
|
|
1038
1118
|
useEffect(() => {
|
|
1039
1119
|
void load();
|
|
1040
1120
|
}, [load]);
|
|
@@ -1044,8 +1124,10 @@ export function useWarehouses(options: UseWarehousesOptions = {}) {
|
|
|
1044
1124
|
totalWarehouses,
|
|
1045
1125
|
hasMore,
|
|
1046
1126
|
isLoading,
|
|
1127
|
+
isLoadingMore,
|
|
1047
1128
|
error,
|
|
1048
1129
|
refetch: () => void load(),
|
|
1130
|
+
loadMore,
|
|
1049
1131
|
};
|
|
1050
1132
|
}
|
|
1051
1133
|
|
|
@@ -1161,45 +1243,49 @@ export function useInventoryStats() {
|
|
|
1161
1243
|
const [isLoading, setIsLoading] = useState(false);
|
|
1162
1244
|
const [error, setError] = useState<Error | null>(null);
|
|
1163
1245
|
|
|
1246
|
+
const load = useCallback(async () => {
|
|
1247
|
+
setIsLoading(true);
|
|
1248
|
+
setError(null);
|
|
1249
|
+
try {
|
|
1250
|
+
const data = await executeGraphQL<{
|
|
1251
|
+
inventoryStats: {
|
|
1252
|
+
totalOnHand: number;
|
|
1253
|
+
totalAvailable: number;
|
|
1254
|
+
totalAllocated: number;
|
|
1255
|
+
totalDamaged: number;
|
|
1256
|
+
lowStockCount: number;
|
|
1257
|
+
};
|
|
1258
|
+
cargoItemsCount: number;
|
|
1259
|
+
warehousesCount: number;
|
|
1260
|
+
}>(graphQLOptions, INVENTORY_STATS_QUERY);
|
|
1261
|
+
|
|
1262
|
+
setStats({
|
|
1263
|
+
totalOnHand: data.inventoryStats.totalOnHand,
|
|
1264
|
+
totalAvailable: data.inventoryStats.totalAvailable,
|
|
1265
|
+
totalAllocated: data.inventoryStats.totalAllocated,
|
|
1266
|
+
totalDamaged: data.inventoryStats.totalDamaged,
|
|
1267
|
+
lowStockCount: data.inventoryStats.lowStockCount,
|
|
1268
|
+
totalProducts: data.cargoItemsCount,
|
|
1269
|
+
totalWarehouses: data.warehousesCount,
|
|
1270
|
+
});
|
|
1271
|
+
} catch (fetchError) {
|
|
1272
|
+
setError(
|
|
1273
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load inventory stats')
|
|
1274
|
+
);
|
|
1275
|
+
} finally {
|
|
1276
|
+
setIsLoading(false);
|
|
1277
|
+
}
|
|
1278
|
+
}, [graphQLOptions]);
|
|
1279
|
+
|
|
1164
1280
|
useEffect(() => {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
setError(null);
|
|
1168
|
-
try {
|
|
1169
|
-
const data = await executeGraphQL<{
|
|
1170
|
-
inventoryStats: {
|
|
1171
|
-
totalOnHand: number;
|
|
1172
|
-
totalAvailable: number;
|
|
1173
|
-
totalAllocated: number;
|
|
1174
|
-
totalDamaged: number;
|
|
1175
|
-
lowStockCount: number;
|
|
1176
|
-
};
|
|
1177
|
-
cargoItemsCount: number;
|
|
1178
|
-
warehousesCount: number;
|
|
1179
|
-
}>(graphQLOptions, INVENTORY_STATS_QUERY);
|
|
1180
|
-
|
|
1181
|
-
setStats({
|
|
1182
|
-
totalOnHand: data.inventoryStats.totalOnHand,
|
|
1183
|
-
totalAvailable: data.inventoryStats.totalAvailable,
|
|
1184
|
-
totalAllocated: data.inventoryStats.totalAllocated,
|
|
1185
|
-
totalDamaged: data.inventoryStats.totalDamaged,
|
|
1186
|
-
lowStockCount: data.inventoryStats.lowStockCount,
|
|
1187
|
-
totalProducts: data.cargoItemsCount,
|
|
1188
|
-
totalWarehouses: data.warehousesCount,
|
|
1189
|
-
});
|
|
1190
|
-
} catch (fetchError) {
|
|
1191
|
-
setError(
|
|
1192
|
-
fetchError instanceof Error ? fetchError : new Error('Failed to load inventory stats')
|
|
1193
|
-
);
|
|
1194
|
-
} finally {
|
|
1195
|
-
setIsLoading(false);
|
|
1196
|
-
}
|
|
1197
|
-
};
|
|
1281
|
+
void load();
|
|
1282
|
+
}, [load]);
|
|
1198
1283
|
|
|
1284
|
+
const refetch = useCallback(() => {
|
|
1199
1285
|
void load();
|
|
1200
|
-
}, [
|
|
1286
|
+
}, [load]);
|
|
1201
1287
|
|
|
1202
|
-
return { stats, isLoading, error };
|
|
1288
|
+
return { stats, isLoading, error, refetch };
|
|
1203
1289
|
}
|
|
1204
1290
|
|
|
1205
1291
|
/**
|
|
@@ -1445,3 +1531,176 @@ export function useInventoryTransactions(options: UseInventoryTransactionsOption
|
|
|
1445
1531
|
|
|
1446
1532
|
return { transactions, isLoading, error, refetch };
|
|
1447
1533
|
}
|
|
1534
|
+
|
|
1535
|
+
export interface ReceiveStockInput {
|
|
1536
|
+
productId: string;
|
|
1537
|
+
warehouseId: string;
|
|
1538
|
+
/** Whole units received; must be > 0. */
|
|
1539
|
+
quantity: number;
|
|
1540
|
+
/** Only applied when the receipt creates a new stock line. */
|
|
1541
|
+
reorderPoint?: number | null;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
export interface TransferStockInput {
|
|
1545
|
+
sourceInventoryId: string;
|
|
1546
|
+
destinationWarehouseId: string;
|
|
1547
|
+
/** Whole units to move out of the source line's available stock; must be > 0. */
|
|
1548
|
+
quantity: number;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function assertWholeUnits(quantity: number, label: string): void {
|
|
1552
|
+
if (!Number.isInteger(quantity) || quantity <= 0) {
|
|
1553
|
+
throw new Error(`${label} must be a positive whole number.`);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Stock movements that do not depend on the list currently on screen: every
|
|
1559
|
+
* write re-reads the line first, so a stale table row can never overwrite
|
|
1560
|
+
* someone else's change (the service also rejects a mid-flight change).
|
|
1561
|
+
*/
|
|
1562
|
+
export function useStockActions() {
|
|
1563
|
+
const graphQLOptions = useGraphqlOptions();
|
|
1564
|
+
|
|
1565
|
+
const findLine = useCallback(
|
|
1566
|
+
async (filter: {
|
|
1567
|
+
inventoryItemId?: string;
|
|
1568
|
+
cargoItemId?: string;
|
|
1569
|
+
warehouseId?: string;
|
|
1570
|
+
}): Promise<Inventory | null> => {
|
|
1571
|
+
const data = await executeGraphQL<{ inventoryItems: ApiInventoryItem[] }>(
|
|
1572
|
+
graphQLOptions,
|
|
1573
|
+
FIND_STOCK_LINE_QUERY,
|
|
1574
|
+
{ filter, pagination: { limit: 1 } }
|
|
1575
|
+
);
|
|
1576
|
+
const line = data.inventoryItems[0];
|
|
1577
|
+
return line ? mapInventory(line) : null;
|
|
1578
|
+
},
|
|
1579
|
+
[graphQLOptions]
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1582
|
+
/** Applies a signed change to on-hand and available; allocated/damaged are untouched. */
|
|
1583
|
+
const applyChange = useCallback(
|
|
1584
|
+
async (line: Inventory, change: number): Promise<Inventory> => {
|
|
1585
|
+
const onHand = line.quantity + change;
|
|
1586
|
+
const available = line.availableQuantity + change;
|
|
1587
|
+
if (available < 0) {
|
|
1588
|
+
throw new Error(
|
|
1589
|
+
`Only ${line.availableQuantity.toLocaleString()} unit(s) are available to remove.`
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
if (onHand < 0) {
|
|
1593
|
+
throw new Error('On-hand stock cannot go below zero.');
|
|
1594
|
+
}
|
|
1595
|
+
const data = await executeGraphQL<{ adjustInventoryStock: ApiInventoryItem }>(
|
|
1596
|
+
graphQLOptions,
|
|
1597
|
+
ADJUST_INVENTORY_MUTATION,
|
|
1598
|
+
{
|
|
1599
|
+
input: {
|
|
1600
|
+
id: line.id,
|
|
1601
|
+
onHand,
|
|
1602
|
+
available,
|
|
1603
|
+
allocated: line.reservedQuantity,
|
|
1604
|
+
damaged: line.damagedQuantity,
|
|
1605
|
+
},
|
|
1606
|
+
}
|
|
1607
|
+
);
|
|
1608
|
+
return mapInventory(data.adjustInventoryStock);
|
|
1609
|
+
},
|
|
1610
|
+
[graphQLOptions]
|
|
1611
|
+
);
|
|
1612
|
+
|
|
1613
|
+
const adjustStock = useCallback(
|
|
1614
|
+
async (inventoryId: string, change: number): Promise<Inventory> => {
|
|
1615
|
+
if (!Number.isInteger(change) || change === 0) {
|
|
1616
|
+
throw new Error('Enter a whole-number change other than zero.');
|
|
1617
|
+
}
|
|
1618
|
+
const line = await findLine({ inventoryItemId: inventoryId });
|
|
1619
|
+
if (!line) throw new Error('This stock line no longer exists.');
|
|
1620
|
+
return applyChange(line, change);
|
|
1621
|
+
},
|
|
1622
|
+
[applyChange, findLine]
|
|
1623
|
+
);
|
|
1624
|
+
|
|
1625
|
+
const receiveStock = useCallback(
|
|
1626
|
+
async (input: ReceiveStockInput): Promise<Inventory> => {
|
|
1627
|
+
assertWholeUnits(input.quantity, 'Quantity received');
|
|
1628
|
+
const existing = await findLine({
|
|
1629
|
+
cargoItemId: input.productId,
|
|
1630
|
+
warehouseId: input.warehouseId,
|
|
1631
|
+
});
|
|
1632
|
+
if (existing) return applyChange(existing, input.quantity);
|
|
1633
|
+
|
|
1634
|
+
// New line: the service records the opening stock as a RECEIPT ledger entry.
|
|
1635
|
+
const data = await executeGraphQL<{ createInventoryItem: ApiInventoryItem }>(
|
|
1636
|
+
graphQLOptions,
|
|
1637
|
+
CREATE_INVENTORY_ITEM_MUTATION,
|
|
1638
|
+
{
|
|
1639
|
+
input: {
|
|
1640
|
+
cargoItemId: input.productId,
|
|
1641
|
+
warehouseId: input.warehouseId,
|
|
1642
|
+
onHand: input.quantity,
|
|
1643
|
+
available: input.quantity,
|
|
1644
|
+
...(input.reorderPoint !== null && input.reorderPoint !== undefined
|
|
1645
|
+
? { reorderPoint: input.reorderPoint }
|
|
1646
|
+
: {}),
|
|
1647
|
+
},
|
|
1648
|
+
}
|
|
1649
|
+
);
|
|
1650
|
+
return mapInventory(data.createInventoryItem);
|
|
1651
|
+
},
|
|
1652
|
+
[applyChange, findLine, graphQLOptions]
|
|
1653
|
+
);
|
|
1654
|
+
|
|
1655
|
+
const transferStock = useCallback(
|
|
1656
|
+
async (input: TransferStockInput): Promise<{ source: Inventory; destination: Inventory }> => {
|
|
1657
|
+
assertWholeUnits(input.quantity, 'Transfer quantity');
|
|
1658
|
+
const source = await findLine({ inventoryItemId: input.sourceInventoryId });
|
|
1659
|
+
if (!source) throw new Error('The source stock line no longer exists.');
|
|
1660
|
+
if (source.warehouseId === input.destinationWarehouseId) {
|
|
1661
|
+
throw new Error('Source and destination warehouses must be different.');
|
|
1662
|
+
}
|
|
1663
|
+
if (input.quantity > source.availableQuantity) {
|
|
1664
|
+
throw new Error(
|
|
1665
|
+
`Only ${source.availableQuantity.toLocaleString()} unit(s) are available to transfer.`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
let destination = await findLine({
|
|
1670
|
+
cargoItemId: source.productId,
|
|
1671
|
+
warehouseId: input.destinationWarehouseId,
|
|
1672
|
+
});
|
|
1673
|
+
if (!destination) {
|
|
1674
|
+
// An empty line records no movement; the transfer below writes both ledger entries.
|
|
1675
|
+
const created = await executeGraphQL<{ createInventoryItem: ApiInventoryItem }>(
|
|
1676
|
+
graphQLOptions,
|
|
1677
|
+
CREATE_INVENTORY_ITEM_MUTATION,
|
|
1678
|
+
{
|
|
1679
|
+
input: {
|
|
1680
|
+
cargoItemId: source.productId,
|
|
1681
|
+
warehouseId: input.destinationWarehouseId,
|
|
1682
|
+
},
|
|
1683
|
+
}
|
|
1684
|
+
);
|
|
1685
|
+
destination = mapInventory(created.createInventoryItem);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const data = await executeGraphQL<{
|
|
1689
|
+
transferInventoryStock: { source: ApiInventoryItem; destination: ApiInventoryItem };
|
|
1690
|
+
}>(graphQLOptions, TRANSFER_INVENTORY_STOCK_MUTATION, {
|
|
1691
|
+
input: {
|
|
1692
|
+
sourceInventoryItemId: source.id,
|
|
1693
|
+
destinationInventoryItemId: destination.id,
|
|
1694
|
+
quantity: input.quantity,
|
|
1695
|
+
},
|
|
1696
|
+
});
|
|
1697
|
+
return {
|
|
1698
|
+
source: mapInventory(data.transferInventoryStock.source),
|
|
1699
|
+
destination: mapInventory(data.transferInventoryStock.destination),
|
|
1700
|
+
};
|
|
1701
|
+
},
|
|
1702
|
+
[findLine, graphQLOptions]
|
|
1703
|
+
);
|
|
1704
|
+
|
|
1705
|
+
return { receiveStock, adjustStock, transferStock };
|
|
1706
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
MapPin,
|
|
16
16
|
Save,
|
|
17
17
|
UserX,
|
|
18
|
+
Trash2,
|
|
18
19
|
Truck,
|
|
19
20
|
StickyNote,
|
|
20
21
|
} from 'lucide-react';
|
|
@@ -120,7 +121,7 @@ const emptyForm: DriverFormState = {
|
|
|
120
121
|
|
|
121
122
|
function NewDriverForm({ editId }: { editId?: string }) {
|
|
122
123
|
const { basePath, navigate } = useMoveTheWheels();
|
|
123
|
-
const { createDriver, updateDriver } = useFleet();
|
|
124
|
+
const { createDriver, updateDriver, deleteDriver } = useFleet();
|
|
124
125
|
const { driver, refetch: refetchDriver } = useDriver(editId);
|
|
125
126
|
const isEditing = !!editId;
|
|
126
127
|
const [form, setForm] = useState<DriverFormState>(emptyForm);
|
|
@@ -130,6 +131,9 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
130
131
|
const [dismissReason, setDismissReason] = useState('');
|
|
131
132
|
const [dismissing, setDismissing] = useState(false);
|
|
132
133
|
const [dismissError, setDismissError] = useState<string | null>(null);
|
|
134
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
135
|
+
const [deleting, setDeleting] = useState(false);
|
|
136
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
133
137
|
|
|
134
138
|
const [vehicleSearch, setVehicleSearch] = useState('');
|
|
135
139
|
const debouncedVehicleSearch = useDebouncedValue(vehicleSearch);
|
|
@@ -250,6 +254,21 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
250
254
|
}
|
|
251
255
|
};
|
|
252
256
|
|
|
257
|
+
const handleDelete = async () => {
|
|
258
|
+
if (!editId) return;
|
|
259
|
+
setDeleting(true);
|
|
260
|
+
setDeleteError(null);
|
|
261
|
+
try {
|
|
262
|
+
await deleteDriver(editId);
|
|
263
|
+
setDeleteOpen(false);
|
|
264
|
+
navigate?.(joinPath(basePath, 'fleet/drivers'));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
setDeleteError(err instanceof Error ? err.message : 'Failed to delete driver.');
|
|
267
|
+
} finally {
|
|
268
|
+
setDeleting(false);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
253
272
|
const alreadyDismissed = isEditing && Boolean(driver?.terminatedAt);
|
|
254
273
|
|
|
255
274
|
return (
|
|
@@ -585,8 +604,60 @@ function NewDriverForm({ editId }: { editId?: string }) {
|
|
|
585
604
|
</CardContent>
|
|
586
605
|
</Card>
|
|
587
606
|
)}
|
|
607
|
+
|
|
608
|
+
{isEditing && (
|
|
609
|
+
<Card className="border-status-error-border">
|
|
610
|
+
<CardHeader>
|
|
611
|
+
<CardTitle className="flex items-center gap-2 text-status-error-text">
|
|
612
|
+
<Trash2 size={18} /> Delete Driver
|
|
613
|
+
</CardTitle>
|
|
614
|
+
</CardHeader>
|
|
615
|
+
<CardContent>
|
|
616
|
+
<p className="mb-3 text-sm text-text-secondary">
|
|
617
|
+
Removes this driver record, for example one entered by mistake, and unlinks them
|
|
618
|
+
from any vehicle. To keep delivery history for someone who has left, dismiss them
|
|
619
|
+
instead. This cannot be undone from here.
|
|
620
|
+
</p>
|
|
621
|
+
<Button
|
|
622
|
+
type="button"
|
|
623
|
+
variant="outline"
|
|
624
|
+
className="border-status-error-border text-status-error-text hover:bg-status-error-bg-subtle"
|
|
625
|
+
onClick={() => setDeleteOpen(true)}
|
|
626
|
+
>
|
|
627
|
+
<Trash2 size={16} /> Delete this driver
|
|
628
|
+
</Button>
|
|
629
|
+
</CardContent>
|
|
630
|
+
</Card>
|
|
631
|
+
)}
|
|
588
632
|
</form>
|
|
589
633
|
|
|
634
|
+
<ResponsiveDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
635
|
+
<ResponsiveDialogContent>
|
|
636
|
+
<ResponsiveDialogHeader>
|
|
637
|
+
<ResponsiveDialogTitle>Delete this driver?</ResponsiveDialogTitle>
|
|
638
|
+
<ResponsiveDialogDescription>
|
|
639
|
+
This removes {`${form.firstName} ${form.lastName}`.trim() || 'this driver'} and
|
|
640
|
+
unlinks them from any vehicle. It cannot be undone from here.
|
|
641
|
+
</ResponsiveDialogDescription>
|
|
642
|
+
</ResponsiveDialogHeader>
|
|
643
|
+
<div className="px-1">
|
|
644
|
+
<FormError message={deleteError} />
|
|
645
|
+
</div>
|
|
646
|
+
<ResponsiveDialogFooter>
|
|
647
|
+
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleting}>
|
|
648
|
+
Cancel
|
|
649
|
+
</Button>
|
|
650
|
+
<Button
|
|
651
|
+
className="bg-status-error-text text-text-inverse hover:opacity-90"
|
|
652
|
+
onClick={handleDelete}
|
|
653
|
+
disabled={deleting}
|
|
654
|
+
>
|
|
655
|
+
{deleting ? 'Deleting...' : 'Delete driver'}
|
|
656
|
+
</Button>
|
|
657
|
+
</ResponsiveDialogFooter>
|
|
658
|
+
</ResponsiveDialogContent>
|
|
659
|
+
</ResponsiveDialog>
|
|
660
|
+
|
|
590
661
|
<ResponsiveDialog open={dismissOpen} onOpenChange={setDismissOpen}>
|
|
591
662
|
<ResponsiveDialogContent>
|
|
592
663
|
<ResponsiveDialogHeader>
|
|
@@ -16,11 +16,20 @@ import {
|
|
|
16
16
|
ArrowDownRight,
|
|
17
17
|
ArrowUpRight,
|
|
18
18
|
ArrowLeftRight,
|
|
19
|
+
PackagePlus,
|
|
20
|
+
SlidersHorizontal,
|
|
19
21
|
} from 'lucide-react';
|
|
20
22
|
import { PageLayout, PageSection } from '../components/PageLayout';
|
|
21
23
|
import { MetricCard, MetricGrid, MetricCardSkeleton } from '../components/MetricCard';
|
|
22
24
|
import { DataTable, type Column } from '../components/DataTable';
|
|
23
25
|
import { StatusBadge } from '../components/StatusBadge';
|
|
26
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
27
|
+
import {
|
|
28
|
+
AdjustStockDialog,
|
|
29
|
+
ReceiveStockDialog,
|
|
30
|
+
TransferStockDialog,
|
|
31
|
+
type StockLineTarget,
|
|
32
|
+
} from '../components/StockActionDialogs';
|
|
24
33
|
import {
|
|
25
34
|
Card,
|
|
26
35
|
CardContent,
|
|
@@ -49,6 +58,7 @@ interface StockRow {
|
|
|
49
58
|
productId: string;
|
|
50
59
|
productName: string;
|
|
51
60
|
sku: string;
|
|
61
|
+
warehouseId: string;
|
|
52
62
|
warehouseName: string;
|
|
53
63
|
onHand: number;
|
|
54
64
|
allocated: number;
|
|
@@ -110,12 +120,35 @@ export default function InventoryPage() {
|
|
|
110
120
|
limit: pageSize,
|
|
111
121
|
offset: pageIndex * pageSize,
|
|
112
122
|
});
|
|
113
|
-
const { stats } = useInventoryStats();
|
|
123
|
+
const { stats, refetch: refetchStats } = useInventoryStats();
|
|
114
124
|
const {
|
|
115
125
|
transactions,
|
|
116
126
|
isLoading: transactionsLoading,
|
|
117
127
|
error: transactionsError,
|
|
128
|
+
refetch: refetchTransactions,
|
|
118
129
|
} = useInventoryTransactions({ limit: 8 });
|
|
130
|
+
const { message: notice, notify } = useNotice(6000);
|
|
131
|
+
const [receiveOpen, setReceiveOpen] = useState(false);
|
|
132
|
+
const [adjustTarget, setAdjustTarget] = useState<StockLineTarget | null>(null);
|
|
133
|
+
const [transferTarget, setTransferTarget] = useState<StockLineTarget | null>(null);
|
|
134
|
+
|
|
135
|
+
const toTarget = (row: StockRow): StockLineTarget => ({
|
|
136
|
+
id: row.id,
|
|
137
|
+
productName: row.productName,
|
|
138
|
+
sku: row.sku,
|
|
139
|
+
warehouseId: row.warehouseId,
|
|
140
|
+
warehouseName: row.warehouseName,
|
|
141
|
+
onHand: row.onHand,
|
|
142
|
+
available: row.available,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** Reload everything a stock movement changes: the table, the cards and the ledger. */
|
|
146
|
+
const handleStockChanged = (message: string) => {
|
|
147
|
+
notify(message);
|
|
148
|
+
refetch();
|
|
149
|
+
refetchStats();
|
|
150
|
+
refetchTransactions();
|
|
151
|
+
};
|
|
119
152
|
|
|
120
153
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
121
154
|
const onRowKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
|
@@ -135,6 +168,7 @@ export default function InventoryPage() {
|
|
|
135
168
|
productId: item.productId,
|
|
136
169
|
productName: item.productName ?? unknownProductLabel,
|
|
137
170
|
sku: item.productSku ?? '—',
|
|
171
|
+
warehouseId: item.warehouseId,
|
|
138
172
|
warehouseName: item.warehouseName ?? '—',
|
|
139
173
|
onHand: item.quantity,
|
|
140
174
|
allocated: item.reservedQuantity,
|
|
@@ -220,6 +254,36 @@ export default function InventoryPage() {
|
|
|
220
254
|
/>
|
|
221
255
|
),
|
|
222
256
|
},
|
|
257
|
+
{
|
|
258
|
+
id: 'actions',
|
|
259
|
+
header: t('movethewheels.inventory.stock.actions', 'Actions'),
|
|
260
|
+
cell: (row) => (
|
|
261
|
+
// Row click navigates to the product, so keep these from bubbling to it.
|
|
262
|
+
<div
|
|
263
|
+
className="flex flex-wrap gap-2"
|
|
264
|
+
onClick={(e) => e.stopPropagation()}
|
|
265
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
266
|
+
>
|
|
267
|
+
<Button
|
|
268
|
+
type="button"
|
|
269
|
+
variant="outline"
|
|
270
|
+
size="sm"
|
|
271
|
+
onClick={() => setAdjustTarget(toTarget(row))}
|
|
272
|
+
>
|
|
273
|
+
<SlidersHorizontal size={14} /> {t('movethewheels.inventory.stock.adjust', 'Adjust')}
|
|
274
|
+
</Button>
|
|
275
|
+
<Button
|
|
276
|
+
type="button"
|
|
277
|
+
variant="outline"
|
|
278
|
+
size="sm"
|
|
279
|
+
onClick={() => setTransferTarget(toTarget(row))}
|
|
280
|
+
disabled={row.available <= 0}
|
|
281
|
+
>
|
|
282
|
+
<ArrowLeftRight size={14} /> {t('movethewheels.inventory.stock.transfer', 'Transfer')}
|
|
283
|
+
</Button>
|
|
284
|
+
</div>
|
|
285
|
+
),
|
|
286
|
+
},
|
|
223
287
|
];
|
|
224
288
|
|
|
225
289
|
return (
|
|
@@ -235,6 +299,11 @@ export default function InventoryPage() {
|
|
|
235
299
|
onSelect: () => goTo('inventory/products/new'),
|
|
236
300
|
}}
|
|
237
301
|
actions={[
|
|
302
|
+
{
|
|
303
|
+
label: t('movethewheels.inventory.actions.receiveStock', 'Receive stock'),
|
|
304
|
+
icon: <PackagePlus size={16} />,
|
|
305
|
+
onSelect: () => setReceiveOpen(true),
|
|
306
|
+
},
|
|
238
307
|
{
|
|
239
308
|
label: t('movethewheels.inventory.actions.products', 'Products'),
|
|
240
309
|
onSelect: () => goTo('inventory/products'),
|
|
@@ -254,6 +323,12 @@ export default function InventoryPage() {
|
|
|
254
323
|
)}
|
|
255
324
|
</PagePurpose>
|
|
256
325
|
|
|
326
|
+
{notice && (
|
|
327
|
+
<div className="mb-2">
|
|
328
|
+
<Notice message={notice} />
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
|
|
257
332
|
{error && !isLoading ? (
|
|
258
333
|
<Card className="border-status-error-border">
|
|
259
334
|
<CardContent className="p-6 text-center space-y-3">
|
|
@@ -582,6 +657,28 @@ export default function InventoryPage() {
|
|
|
582
657
|
</Card>
|
|
583
658
|
</PageSection>
|
|
584
659
|
</div>
|
|
660
|
+
|
|
661
|
+
<ReceiveStockDialog
|
|
662
|
+
open={receiveOpen}
|
|
663
|
+
onOpenChange={setReceiveOpen}
|
|
664
|
+
onDone={handleStockChanged}
|
|
665
|
+
/>
|
|
666
|
+
<AdjustStockDialog
|
|
667
|
+
line={adjustTarget}
|
|
668
|
+
open={adjustTarget !== null}
|
|
669
|
+
onOpenChange={(open) => {
|
|
670
|
+
if (!open) setAdjustTarget(null);
|
|
671
|
+
}}
|
|
672
|
+
onDone={handleStockChanged}
|
|
673
|
+
/>
|
|
674
|
+
<TransferStockDialog
|
|
675
|
+
line={transferTarget}
|
|
676
|
+
open={transferTarget !== null}
|
|
677
|
+
onOpenChange={(open) => {
|
|
678
|
+
if (!open) setTransferTarget(null);
|
|
679
|
+
}}
|
|
680
|
+
onDone={handleStockChanged}
|
|
681
|
+
/>
|
|
585
682
|
</PageLayout>
|
|
586
683
|
);
|
|
587
684
|
}
|
|
@@ -252,7 +252,7 @@ function NewRouteForm({ editId }: { editId?: string }) {
|
|
|
252
252
|
<PagePurpose icon={<Navigation size={18} />}>
|
|
253
253
|
Plan a new delivery route — name it, list the stops to visit, and optionally assign a
|
|
254
254
|
vehicle and driver. Once created you can optimize it to sequence the stops into the shortest
|
|
255
|
-
run.
|
|
255
|
+
run. Optimizing also marks the route In Progress.
|
|
256
256
|
</PagePurpose>
|
|
257
257
|
|
|
258
258
|
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
|
|
@@ -535,7 +535,7 @@ export default function RouteDetailsPage() {
|
|
|
535
535
|
</Button>
|
|
536
536
|
{!route.optimized && (
|
|
537
537
|
<Button variant="outline" onClick={handleOptimize} disabled={optimizing}>
|
|
538
|
-
<Zap size={16} /> {optimizing ? 'Optimizing…' : 'Optimize'}
|
|
538
|
+
<Zap size={16} /> {optimizing ? 'Optimizing…' : 'Optimize & start'}
|
|
539
539
|
</Button>
|
|
540
540
|
)}
|
|
541
541
|
<Button onClick={() => navigate?.(joinPath(basePath, `routes/${routeId}/edit`))}>
|
|
@@ -569,8 +569,9 @@ export default function RouteDetailsPage() {
|
|
|
569
569
|
? [
|
|
570
570
|
{
|
|
571
571
|
id: 'optimize',
|
|
572
|
-
label: 'Optimize this route',
|
|
573
|
-
description:
|
|
572
|
+
label: 'Optimize & start this route',
|
|
573
|
+
description:
|
|
574
|
+
'Reorder stops to cut distance and driving time, then mark the route In Progress.',
|
|
574
575
|
icon: <Zap size={16} />,
|
|
575
576
|
onClick: handleOptimize,
|
|
576
577
|
},
|