@burdenoff/microfe-movethewheels 2026.930.3 → 2026.930.5
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/useInventory.js +259 -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/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/InventoryPage.js +284 -201
- 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/useInventory.ts +296 -55
- package/src/movethewheels/pages/InventoryPage.tsx +114 -3
- 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) {
|
|
@@ -368,28 +407,10 @@ const TRANSFER_INVENTORY_STOCK_MUTATION = `
|
|
|
368
407
|
mutation TransferInventoryStock($input: TransferInventoryStockInput!) {
|
|
369
408
|
transferInventoryStock(input: $input) {
|
|
370
409
|
source {
|
|
371
|
-
|
|
372
|
-
cargoItemId
|
|
373
|
-
warehouseId
|
|
374
|
-
onHand
|
|
375
|
-
available
|
|
376
|
-
allocated
|
|
377
|
-
damaged
|
|
378
|
-
reorderPoint
|
|
379
|
-
createdAt
|
|
380
|
-
updatedAt
|
|
410
|
+
${INVENTORY_LINE_FIELDS}
|
|
381
411
|
}
|
|
382
412
|
destination {
|
|
383
|
-
|
|
384
|
-
cargoItemId
|
|
385
|
-
warehouseId
|
|
386
|
-
onHand
|
|
387
|
-
available
|
|
388
|
-
allocated
|
|
389
|
-
damaged
|
|
390
|
-
reorderPoint
|
|
391
|
-
createdAt
|
|
392
|
-
updatedAt
|
|
413
|
+
${INVENTORY_LINE_FIELDS}
|
|
393
414
|
}
|
|
394
415
|
}
|
|
395
416
|
}
|
|
@@ -1012,6 +1033,7 @@ export function useWarehouses(options: UseWarehousesOptions = {}) {
|
|
|
1012
1033
|
const [totalWarehouses, setTotalWarehouses] = useState(0);
|
|
1013
1034
|
const [hasMore, setHasMore] = useState(false);
|
|
1014
1035
|
const [isLoading, setIsLoading] = useState(false);
|
|
1036
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
1015
1037
|
const [error, setError] = useState<Error | null>(null);
|
|
1016
1038
|
|
|
1017
1039
|
const load = useCallback(async () => {
|
|
@@ -1035,6 +1057,46 @@ export function useWarehouses(options: UseWarehousesOptions = {}) {
|
|
|
1035
1057
|
}
|
|
1036
1058
|
}, [graphQLOptions, limit, offset, search, status]);
|
|
1037
1059
|
|
|
1060
|
+
/** Appends the next server page (pickers); list pages keep using `offset`. */
|
|
1061
|
+
const loadMore = useCallback(async () => {
|
|
1062
|
+
if (!hasMore || isLoading || isLoadingMore) return;
|
|
1063
|
+
const nextOffset = offset + warehouses.length;
|
|
1064
|
+
setIsLoadingMore(true);
|
|
1065
|
+
setError(null);
|
|
1066
|
+
try {
|
|
1067
|
+
const data = await executeGraphQL<{
|
|
1068
|
+
warehouses: ApiWarehouse[];
|
|
1069
|
+
warehousesCount: number;
|
|
1070
|
+
}>(graphQLOptions, LIST_WAREHOUSES_QUERY, {
|
|
1071
|
+
filter: { search: search || undefined, status: status || undefined },
|
|
1072
|
+
pagination: { limit, offset: nextOffset },
|
|
1073
|
+
});
|
|
1074
|
+
const mapped = data.warehouses.map(mapWarehouse);
|
|
1075
|
+
setTotalWarehouses(data.warehousesCount);
|
|
1076
|
+
setHasMore(nextOffset + mapped.length < data.warehousesCount);
|
|
1077
|
+
setWarehouses((previous) => {
|
|
1078
|
+
const existingIds = new Set(previous.map((warehouse) => warehouse.id));
|
|
1079
|
+
return [...previous, ...mapped.filter((warehouse) => !existingIds.has(warehouse.id))];
|
|
1080
|
+
});
|
|
1081
|
+
} catch (fetchError) {
|
|
1082
|
+
setError(
|
|
1083
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load more warehouses')
|
|
1084
|
+
);
|
|
1085
|
+
} finally {
|
|
1086
|
+
setIsLoadingMore(false);
|
|
1087
|
+
}
|
|
1088
|
+
}, [
|
|
1089
|
+
graphQLOptions,
|
|
1090
|
+
hasMore,
|
|
1091
|
+
isLoading,
|
|
1092
|
+
isLoadingMore,
|
|
1093
|
+
limit,
|
|
1094
|
+
offset,
|
|
1095
|
+
search,
|
|
1096
|
+
status,
|
|
1097
|
+
warehouses.length,
|
|
1098
|
+
]);
|
|
1099
|
+
|
|
1038
1100
|
useEffect(() => {
|
|
1039
1101
|
void load();
|
|
1040
1102
|
}, [load]);
|
|
@@ -1044,8 +1106,10 @@ export function useWarehouses(options: UseWarehousesOptions = {}) {
|
|
|
1044
1106
|
totalWarehouses,
|
|
1045
1107
|
hasMore,
|
|
1046
1108
|
isLoading,
|
|
1109
|
+
isLoadingMore,
|
|
1047
1110
|
error,
|
|
1048
1111
|
refetch: () => void load(),
|
|
1112
|
+
loadMore,
|
|
1049
1113
|
};
|
|
1050
1114
|
}
|
|
1051
1115
|
|
|
@@ -1161,45 +1225,49 @@ export function useInventoryStats() {
|
|
|
1161
1225
|
const [isLoading, setIsLoading] = useState(false);
|
|
1162
1226
|
const [error, setError] = useState<Error | null>(null);
|
|
1163
1227
|
|
|
1228
|
+
const load = useCallback(async () => {
|
|
1229
|
+
setIsLoading(true);
|
|
1230
|
+
setError(null);
|
|
1231
|
+
try {
|
|
1232
|
+
const data = await executeGraphQL<{
|
|
1233
|
+
inventoryStats: {
|
|
1234
|
+
totalOnHand: number;
|
|
1235
|
+
totalAvailable: number;
|
|
1236
|
+
totalAllocated: number;
|
|
1237
|
+
totalDamaged: number;
|
|
1238
|
+
lowStockCount: number;
|
|
1239
|
+
};
|
|
1240
|
+
cargoItemsCount: number;
|
|
1241
|
+
warehousesCount: number;
|
|
1242
|
+
}>(graphQLOptions, INVENTORY_STATS_QUERY);
|
|
1243
|
+
|
|
1244
|
+
setStats({
|
|
1245
|
+
totalOnHand: data.inventoryStats.totalOnHand,
|
|
1246
|
+
totalAvailable: data.inventoryStats.totalAvailable,
|
|
1247
|
+
totalAllocated: data.inventoryStats.totalAllocated,
|
|
1248
|
+
totalDamaged: data.inventoryStats.totalDamaged,
|
|
1249
|
+
lowStockCount: data.inventoryStats.lowStockCount,
|
|
1250
|
+
totalProducts: data.cargoItemsCount,
|
|
1251
|
+
totalWarehouses: data.warehousesCount,
|
|
1252
|
+
});
|
|
1253
|
+
} catch (fetchError) {
|
|
1254
|
+
setError(
|
|
1255
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load inventory stats')
|
|
1256
|
+
);
|
|
1257
|
+
} finally {
|
|
1258
|
+
setIsLoading(false);
|
|
1259
|
+
}
|
|
1260
|
+
}, [graphQLOptions]);
|
|
1261
|
+
|
|
1164
1262
|
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
|
-
};
|
|
1263
|
+
void load();
|
|
1264
|
+
}, [load]);
|
|
1198
1265
|
|
|
1266
|
+
const refetch = useCallback(() => {
|
|
1199
1267
|
void load();
|
|
1200
|
-
}, [
|
|
1268
|
+
}, [load]);
|
|
1201
1269
|
|
|
1202
|
-
return { stats, isLoading, error };
|
|
1270
|
+
return { stats, isLoading, error, refetch };
|
|
1203
1271
|
}
|
|
1204
1272
|
|
|
1205
1273
|
/**
|
|
@@ -1445,3 +1513,176 @@ export function useInventoryTransactions(options: UseInventoryTransactionsOption
|
|
|
1445
1513
|
|
|
1446
1514
|
return { transactions, isLoading, error, refetch };
|
|
1447
1515
|
}
|
|
1516
|
+
|
|
1517
|
+
export interface ReceiveStockInput {
|
|
1518
|
+
productId: string;
|
|
1519
|
+
warehouseId: string;
|
|
1520
|
+
/** Whole units received; must be > 0. */
|
|
1521
|
+
quantity: number;
|
|
1522
|
+
/** Only applied when the receipt creates a new stock line. */
|
|
1523
|
+
reorderPoint?: number | null;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
export interface TransferStockInput {
|
|
1527
|
+
sourceInventoryId: string;
|
|
1528
|
+
destinationWarehouseId: string;
|
|
1529
|
+
/** Whole units to move out of the source line's available stock; must be > 0. */
|
|
1530
|
+
quantity: number;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function assertWholeUnits(quantity: number, label: string): void {
|
|
1534
|
+
if (!Number.isInteger(quantity) || quantity <= 0) {
|
|
1535
|
+
throw new Error(`${label} must be a positive whole number.`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Stock movements that do not depend on the list currently on screen: every
|
|
1541
|
+
* write re-reads the line first, so a stale table row can never overwrite
|
|
1542
|
+
* someone else's change (the service also rejects a mid-flight change).
|
|
1543
|
+
*/
|
|
1544
|
+
export function useStockActions() {
|
|
1545
|
+
const graphQLOptions = useGraphqlOptions();
|
|
1546
|
+
|
|
1547
|
+
const findLine = useCallback(
|
|
1548
|
+
async (filter: {
|
|
1549
|
+
inventoryItemId?: string;
|
|
1550
|
+
cargoItemId?: string;
|
|
1551
|
+
warehouseId?: string;
|
|
1552
|
+
}): Promise<Inventory | null> => {
|
|
1553
|
+
const data = await executeGraphQL<{ inventoryItems: ApiInventoryItem[] }>(
|
|
1554
|
+
graphQLOptions,
|
|
1555
|
+
FIND_STOCK_LINE_QUERY,
|
|
1556
|
+
{ filter, pagination: { limit: 1 } }
|
|
1557
|
+
);
|
|
1558
|
+
const line = data.inventoryItems[0];
|
|
1559
|
+
return line ? mapInventory(line) : null;
|
|
1560
|
+
},
|
|
1561
|
+
[graphQLOptions]
|
|
1562
|
+
);
|
|
1563
|
+
|
|
1564
|
+
/** Applies a signed change to on-hand and available; allocated/damaged are untouched. */
|
|
1565
|
+
const applyChange = useCallback(
|
|
1566
|
+
async (line: Inventory, change: number): Promise<Inventory> => {
|
|
1567
|
+
const onHand = line.quantity + change;
|
|
1568
|
+
const available = line.availableQuantity + change;
|
|
1569
|
+
if (available < 0) {
|
|
1570
|
+
throw new Error(
|
|
1571
|
+
`Only ${line.availableQuantity.toLocaleString()} unit(s) are available to remove.`
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
if (onHand < 0) {
|
|
1575
|
+
throw new Error('On-hand stock cannot go below zero.');
|
|
1576
|
+
}
|
|
1577
|
+
const data = await executeGraphQL<{ adjustInventoryStock: ApiInventoryItem }>(
|
|
1578
|
+
graphQLOptions,
|
|
1579
|
+
ADJUST_INVENTORY_MUTATION,
|
|
1580
|
+
{
|
|
1581
|
+
input: {
|
|
1582
|
+
id: line.id,
|
|
1583
|
+
onHand,
|
|
1584
|
+
available,
|
|
1585
|
+
allocated: line.reservedQuantity,
|
|
1586
|
+
damaged: line.damagedQuantity,
|
|
1587
|
+
},
|
|
1588
|
+
}
|
|
1589
|
+
);
|
|
1590
|
+
return mapInventory(data.adjustInventoryStock);
|
|
1591
|
+
},
|
|
1592
|
+
[graphQLOptions]
|
|
1593
|
+
);
|
|
1594
|
+
|
|
1595
|
+
const adjustStock = useCallback(
|
|
1596
|
+
async (inventoryId: string, change: number): Promise<Inventory> => {
|
|
1597
|
+
if (!Number.isInteger(change) || change === 0) {
|
|
1598
|
+
throw new Error('Enter a whole-number change other than zero.');
|
|
1599
|
+
}
|
|
1600
|
+
const line = await findLine({ inventoryItemId: inventoryId });
|
|
1601
|
+
if (!line) throw new Error('This stock line no longer exists.');
|
|
1602
|
+
return applyChange(line, change);
|
|
1603
|
+
},
|
|
1604
|
+
[applyChange, findLine]
|
|
1605
|
+
);
|
|
1606
|
+
|
|
1607
|
+
const receiveStock = useCallback(
|
|
1608
|
+
async (input: ReceiveStockInput): Promise<Inventory> => {
|
|
1609
|
+
assertWholeUnits(input.quantity, 'Quantity received');
|
|
1610
|
+
const existing = await findLine({
|
|
1611
|
+
cargoItemId: input.productId,
|
|
1612
|
+
warehouseId: input.warehouseId,
|
|
1613
|
+
});
|
|
1614
|
+
if (existing) return applyChange(existing, input.quantity);
|
|
1615
|
+
|
|
1616
|
+
// New line: the service records the opening stock as a RECEIPT ledger entry.
|
|
1617
|
+
const data = await executeGraphQL<{ createInventoryItem: ApiInventoryItem }>(
|
|
1618
|
+
graphQLOptions,
|
|
1619
|
+
CREATE_INVENTORY_ITEM_MUTATION,
|
|
1620
|
+
{
|
|
1621
|
+
input: {
|
|
1622
|
+
cargoItemId: input.productId,
|
|
1623
|
+
warehouseId: input.warehouseId,
|
|
1624
|
+
onHand: input.quantity,
|
|
1625
|
+
available: input.quantity,
|
|
1626
|
+
...(input.reorderPoint !== null && input.reorderPoint !== undefined
|
|
1627
|
+
? { reorderPoint: input.reorderPoint }
|
|
1628
|
+
: {}),
|
|
1629
|
+
},
|
|
1630
|
+
}
|
|
1631
|
+
);
|
|
1632
|
+
return mapInventory(data.createInventoryItem);
|
|
1633
|
+
},
|
|
1634
|
+
[applyChange, findLine, graphQLOptions]
|
|
1635
|
+
);
|
|
1636
|
+
|
|
1637
|
+
const transferStock = useCallback(
|
|
1638
|
+
async (input: TransferStockInput): Promise<{ source: Inventory; destination: Inventory }> => {
|
|
1639
|
+
assertWholeUnits(input.quantity, 'Transfer quantity');
|
|
1640
|
+
const source = await findLine({ inventoryItemId: input.sourceInventoryId });
|
|
1641
|
+
if (!source) throw new Error('The source stock line no longer exists.');
|
|
1642
|
+
if (source.warehouseId === input.destinationWarehouseId) {
|
|
1643
|
+
throw new Error('Source and destination warehouses must be different.');
|
|
1644
|
+
}
|
|
1645
|
+
if (input.quantity > source.availableQuantity) {
|
|
1646
|
+
throw new Error(
|
|
1647
|
+
`Only ${source.availableQuantity.toLocaleString()} unit(s) are available to transfer.`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
let destination = await findLine({
|
|
1652
|
+
cargoItemId: source.productId,
|
|
1653
|
+
warehouseId: input.destinationWarehouseId,
|
|
1654
|
+
});
|
|
1655
|
+
if (!destination) {
|
|
1656
|
+
// An empty line records no movement; the transfer below writes both ledger entries.
|
|
1657
|
+
const created = await executeGraphQL<{ createInventoryItem: ApiInventoryItem }>(
|
|
1658
|
+
graphQLOptions,
|
|
1659
|
+
CREATE_INVENTORY_ITEM_MUTATION,
|
|
1660
|
+
{
|
|
1661
|
+
input: {
|
|
1662
|
+
cargoItemId: source.productId,
|
|
1663
|
+
warehouseId: input.destinationWarehouseId,
|
|
1664
|
+
},
|
|
1665
|
+
}
|
|
1666
|
+
);
|
|
1667
|
+
destination = mapInventory(created.createInventoryItem);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const data = await executeGraphQL<{
|
|
1671
|
+
transferInventoryStock: { source: ApiInventoryItem; destination: ApiInventoryItem };
|
|
1672
|
+
}>(graphQLOptions, TRANSFER_INVENTORY_STOCK_MUTATION, {
|
|
1673
|
+
input: {
|
|
1674
|
+
sourceInventoryItemId: source.id,
|
|
1675
|
+
destinationInventoryItemId: destination.id,
|
|
1676
|
+
quantity: input.quantity,
|
|
1677
|
+
},
|
|
1678
|
+
});
|
|
1679
|
+
return {
|
|
1680
|
+
source: mapInventory(data.transferInventoryStock.source),
|
|
1681
|
+
destination: mapInventory(data.transferInventoryStock.destination),
|
|
1682
|
+
};
|
|
1683
|
+
},
|
|
1684
|
+
[findLine, graphQLOptions]
|
|
1685
|
+
);
|
|
1686
|
+
|
|
1687
|
+
return { receiveStock, adjustStock, transferStock };
|
|
1688
|
+
}
|
|
@@ -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;
|
|
@@ -84,6 +94,21 @@ const TRANSACTION_LABELS: Record<InventoryTransactionType, string> = {
|
|
|
84
94
|
ADJUSTMENT: 'Adjustment',
|
|
85
95
|
};
|
|
86
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Ledger quantity with its sign. RECEIPT/RETURN/ISSUE have a fixed direction, so
|
|
99
|
+
* the magnitude is shown with that sign. TRANSFER and ADJUSTMENT are neutral —
|
|
100
|
+
* the sign stored on the row is the only thing that says whether stock came in
|
|
101
|
+
* or went out (a transfer writes one row of each), so it must never be dropped.
|
|
102
|
+
*/
|
|
103
|
+
function formatLedgerQuantity(direction: 'in' | 'out' | 'neutral', quantity: number): string {
|
|
104
|
+
const magnitude = Math.abs(quantity).toLocaleString();
|
|
105
|
+
if (direction === 'in') return `+${magnitude}`;
|
|
106
|
+
if (direction === 'out') return `−${magnitude}`;
|
|
107
|
+
if (quantity > 0) return `+${magnitude}`;
|
|
108
|
+
if (quantity < 0) return `−${magnitude}`;
|
|
109
|
+
return magnitude;
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
export default function InventoryPage() {
|
|
88
113
|
const { t: translate } = useI18n();
|
|
89
114
|
const t = (key: string, fallback: string): string => {
|
|
@@ -110,12 +135,35 @@ export default function InventoryPage() {
|
|
|
110
135
|
limit: pageSize,
|
|
111
136
|
offset: pageIndex * pageSize,
|
|
112
137
|
});
|
|
113
|
-
const { stats } = useInventoryStats();
|
|
138
|
+
const { stats, refetch: refetchStats } = useInventoryStats();
|
|
114
139
|
const {
|
|
115
140
|
transactions,
|
|
116
141
|
isLoading: transactionsLoading,
|
|
117
142
|
error: transactionsError,
|
|
143
|
+
refetch: refetchTransactions,
|
|
118
144
|
} = useInventoryTransactions({ limit: 8 });
|
|
145
|
+
const { message: notice, notify } = useNotice(6000);
|
|
146
|
+
const [receiveOpen, setReceiveOpen] = useState(false);
|
|
147
|
+
const [adjustTarget, setAdjustTarget] = useState<StockLineTarget | null>(null);
|
|
148
|
+
const [transferTarget, setTransferTarget] = useState<StockLineTarget | null>(null);
|
|
149
|
+
|
|
150
|
+
const toTarget = (row: StockRow): StockLineTarget => ({
|
|
151
|
+
id: row.id,
|
|
152
|
+
productName: row.productName,
|
|
153
|
+
sku: row.sku,
|
|
154
|
+
warehouseId: row.warehouseId,
|
|
155
|
+
warehouseName: row.warehouseName,
|
|
156
|
+
onHand: row.onHand,
|
|
157
|
+
available: row.available,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Reload everything a stock movement changes: the table, the cards and the ledger. */
|
|
161
|
+
const handleStockChanged = (message: string) => {
|
|
162
|
+
notify(message);
|
|
163
|
+
refetch();
|
|
164
|
+
refetchStats();
|
|
165
|
+
refetchTransactions();
|
|
166
|
+
};
|
|
119
167
|
|
|
120
168
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
121
169
|
const onRowKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
|
@@ -135,6 +183,7 @@ export default function InventoryPage() {
|
|
|
135
183
|
productId: item.productId,
|
|
136
184
|
productName: item.productName ?? unknownProductLabel,
|
|
137
185
|
sku: item.productSku ?? '—',
|
|
186
|
+
warehouseId: item.warehouseId,
|
|
138
187
|
warehouseName: item.warehouseName ?? '—',
|
|
139
188
|
onHand: item.quantity,
|
|
140
189
|
allocated: item.reservedQuantity,
|
|
@@ -220,6 +269,36 @@ export default function InventoryPage() {
|
|
|
220
269
|
/>
|
|
221
270
|
),
|
|
222
271
|
},
|
|
272
|
+
{
|
|
273
|
+
id: 'actions',
|
|
274
|
+
header: t('movethewheels.inventory.stock.actions', 'Actions'),
|
|
275
|
+
cell: (row) => (
|
|
276
|
+
// Row click navigates to the product, so keep these from bubbling to it.
|
|
277
|
+
<div
|
|
278
|
+
className="flex flex-wrap gap-2"
|
|
279
|
+
onClick={(e) => e.stopPropagation()}
|
|
280
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
281
|
+
>
|
|
282
|
+
<Button
|
|
283
|
+
type="button"
|
|
284
|
+
variant="outline"
|
|
285
|
+
size="sm"
|
|
286
|
+
onClick={() => setAdjustTarget(toTarget(row))}
|
|
287
|
+
>
|
|
288
|
+
<SlidersHorizontal size={14} /> {t('movethewheels.inventory.stock.adjust', 'Adjust')}
|
|
289
|
+
</Button>
|
|
290
|
+
<Button
|
|
291
|
+
type="button"
|
|
292
|
+
variant="outline"
|
|
293
|
+
size="sm"
|
|
294
|
+
onClick={() => setTransferTarget(toTarget(row))}
|
|
295
|
+
disabled={row.available <= 0}
|
|
296
|
+
>
|
|
297
|
+
<ArrowLeftRight size={14} /> {t('movethewheels.inventory.stock.transfer', 'Transfer')}
|
|
298
|
+
</Button>
|
|
299
|
+
</div>
|
|
300
|
+
),
|
|
301
|
+
},
|
|
223
302
|
];
|
|
224
303
|
|
|
225
304
|
return (
|
|
@@ -235,6 +314,11 @@ export default function InventoryPage() {
|
|
|
235
314
|
onSelect: () => goTo('inventory/products/new'),
|
|
236
315
|
}}
|
|
237
316
|
actions={[
|
|
317
|
+
{
|
|
318
|
+
label: t('movethewheels.inventory.actions.receiveStock', 'Receive stock'),
|
|
319
|
+
icon: <PackagePlus size={16} />,
|
|
320
|
+
onSelect: () => setReceiveOpen(true),
|
|
321
|
+
},
|
|
238
322
|
{
|
|
239
323
|
label: t('movethewheels.inventory.actions.products', 'Products'),
|
|
240
324
|
onSelect: () => goTo('inventory/products'),
|
|
@@ -254,6 +338,12 @@ export default function InventoryPage() {
|
|
|
254
338
|
)}
|
|
255
339
|
</PagePurpose>
|
|
256
340
|
|
|
341
|
+
{notice && (
|
|
342
|
+
<div className="mb-2">
|
|
343
|
+
<Notice message={notice} />
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
|
|
257
347
|
{error && !isLoading ? (
|
|
258
348
|
<Card className="border-status-error-border">
|
|
259
349
|
<CardContent className="p-6 text-center space-y-3">
|
|
@@ -462,8 +552,7 @@ export default function InventoryPage() {
|
|
|
462
552
|
: 'font-medium text-text-primary'
|
|
463
553
|
}
|
|
464
554
|
>
|
|
465
|
-
{direction
|
|
466
|
-
{Math.abs(transaction.quantity).toLocaleString()}
|
|
555
|
+
{formatLedgerQuantity(direction, transaction.quantity)}
|
|
467
556
|
</span>
|
|
468
557
|
</li>
|
|
469
558
|
);
|
|
@@ -582,6 +671,28 @@ export default function InventoryPage() {
|
|
|
582
671
|
</Card>
|
|
583
672
|
</PageSection>
|
|
584
673
|
</div>
|
|
674
|
+
|
|
675
|
+
<ReceiveStockDialog
|
|
676
|
+
open={receiveOpen}
|
|
677
|
+
onOpenChange={setReceiveOpen}
|
|
678
|
+
onDone={handleStockChanged}
|
|
679
|
+
/>
|
|
680
|
+
<AdjustStockDialog
|
|
681
|
+
line={adjustTarget}
|
|
682
|
+
open={adjustTarget !== null}
|
|
683
|
+
onOpenChange={(open) => {
|
|
684
|
+
if (!open) setAdjustTarget(null);
|
|
685
|
+
}}
|
|
686
|
+
onDone={handleStockChanged}
|
|
687
|
+
/>
|
|
688
|
+
<TransferStockDialog
|
|
689
|
+
line={transferTarget}
|
|
690
|
+
open={transferTarget !== null}
|
|
691
|
+
onOpenChange={(open) => {
|
|
692
|
+
if (!open) setTransferTarget(null);
|
|
693
|
+
}}
|
|
694
|
+
onDone={handleStockChanged}
|
|
695
|
+
/>
|
|
585
696
|
</PageLayout>
|
|
586
697
|
);
|
|
587
698
|
}
|
|
@@ -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
|
},
|