From ce35973b5804fe07691a1c5069bdb5e0a920e4c5 Mon Sep 17 00:00:00 2001 From: Dhananjaya99 <152056742+ashanruu@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:49:26 +0530 Subject: [PATCH] feat: Enhance Stock Ledger functionality with item details and filters - Updated StockService to compute running balance on read and include item SKU, name, category, and brand in StockLedgerRowDto. - Modified GetLedgerAsync method to accept categoryId and brandId parameters for filtering. - Enhanced StockController to support new query parameters for ledger retrieval. - Updated StockDtos to include additional item information in StockLedgerRowDto. - Adjusted frontend Stock Ledger page to display item details and implement searchable item picker. - Added tests for StockLedger to verify running balance calculations and filtering by category and brand. - Updated appsettings for service URLs and modified login page navigation logic. --- .../LowStockAlertServiceTests.cs | 3 +- Backend/ERPCore.Tests/StockLedgerTests.cs | 97 +++++++++ .../ERPCore/Controllers/StockController.cs | 5 +- Backend/ERPCore/Dtos/Stock/StockDtos.cs | 3 +- .../Services/Interfaces/IStockService.cs | 3 +- .../ERPCore/Services/Stock/StockService.cs | 64 +++++- Backend/PROGRESS.md | 5 + .../app/dashboard/stock/ledger/page.tsx | 187 ++++++++++++------ .../app/dashboard/stock/wastage/new/page.tsx | 54 +++-- Frontend/erp-system/app/login/page.tsx | 9 +- Frontend/erp-system/lib/api/stock.ts | 3 + Frontend/erp-system/types/stock.ts | 5 + 12 files changed, 347 insertions(+), 91 deletions(-) create mode 100644 Backend/ERPCore.Tests/StockLedgerTests.cs diff --git a/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs b/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs index 9360a77..561cccb 100644 --- a/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs +++ b/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs @@ -91,7 +91,8 @@ public class LowStockAlertServiceTests public Task> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, - string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException(); + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default, + int? categoryId = null, int? brandId = null) => throw new NotSupportedException(); public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default) => throw new NotSupportedException(); } diff --git a/Backend/ERPCore.Tests/StockLedgerTests.cs b/Backend/ERPCore.Tests/StockLedgerTests.cs new file mode 100644 index 0000000..bab7de9 --- /dev/null +++ b/Backend/ERPCore.Tests/StockLedgerTests.cs @@ -0,0 +1,97 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories; +using ERPCore.Services.Stock; +using static ERPCore.Tests.TestSupport.BankDepositTestSupport; + +namespace ERPCore.Tests; + +/// The Stock Ledger screen: item name/SKU/category/brand on every row, category and brand filters, and a running +/// balance that is the real balance after each movement (not whatever a posting stored). +public class StockLedgerTests +{ + private static StockService Svc(ErpDbContext db) => + new(null!, new Repository(db), new Repository(db), new Repository(db), + new Repository(db), new Repository(db)); + + private static StockLedger Row(int id, int item, int warehouse, Direction dir, decimal qty, decimal unitCost, decimal storedBalance = 0m) => new() + { + LedgerId = id, ItemId = item, WarehouseId = warehouse, Direction = dir, QtyBase = qty, UnitCost = unitCost, + Value = qty * unitCost, RunningBalance = storedBalance, SourceDocType = "T", SourceDocId = id, UserId = 1, + CreatedAt = new DateTime(2026, 10, 18, 4, 0, 0, DateTimeKind.Utc).AddMinutes(id) + }; + + private static async Task SeedAsync() + { + var db = CreateContext(); + db.Categories.AddRange(new Category { CategoryId = 1, Name = "Beverages" }, new Category { CategoryId = 2, Name = "Snacks" }); + db.Brands.AddRange(new Brand { BrandId = 1, Name = "Milo" }, new Brand { BrandId = 2, Name = "Munchee" }); + db.Items.AddRange( + new Item { ItemId = 1, Sku = "MILO-240", Name = "Milo 240ml", CategoryId = 1, BrandId = 1, BaseUomId = 1 }, + new Item { ItemId = 2, Sku = "BISC-1", Name = "Biscuit", CategoryId = 2, BrandId = 2, BaseUomId = 1 }); + db.StockLedger.AddRange( + Row(1, 1, Warehouse1, Direction.In, 100m, 200m, storedBalance: 100m), + Row(2, 1, Warehouse1, Direction.Out, 30m, 200m, storedBalance: 0m), // a sales issue: stored balance was never filled in + Row(3, 1, Warehouse1, Direction.In, 50m, 210m, storedBalance: 120m), + Row(4, 1, Warehouse2, Direction.In, 10m, 200m, storedBalance: 10m), + Row(5, 2, Warehouse1, Direction.In, 5m, 40m, storedBalance: 5m)); + await db.SaveChangesAsync(); + return db; + } + + private static Task> Ledger( + ErpDbContext db, int page = 1, int pageSize = 50, int? itemId = null, int? warehouseId = null, int? categoryId = null, int? brandId = null) => + Svc(db).GetLedgerAsync(itemId, warehouseId, null, null, null, null, null, + new PageQuery { Page = page, PageSize = pageSize }, CancellationToken.None, categoryId, brandId); + + [Fact] + public async Task RunningBalance_IsTheRealBalanceAfterEachMovement_PerItemAndWarehouse() + { + await using var db = await SeedAsync(); + + var rows = (await Ledger(db)).Items.ToDictionary(r => r.LedgerId); + + Assert.Equal(100m, rows[1].RunningBalance); + Assert.Equal(70m, rows[2].RunningBalance); // the stored 0 is ignored + Assert.Equal(120m, rows[3].RunningBalance); + Assert.Equal(10m, rows[4].RunningBalance); // another warehouse starts from its own zero + Assert.Equal(5m, rows[5].RunningBalance); // another item too + } + + [Fact] + public async Task RunningBalance_IsStillRight_OnALaterPage_AndWithFiltersApplied() + { + await using var db = await SeedAsync(); + + // Newest first, 2 per page: page 2 holds ledger 3 and 2, whose balances depend on rows before and after it. + var page2 = (await Ledger(db, page: 2, pageSize: 2)).Items; + Assert.Equal(new long[] { 3, 2 }, page2.Select(r => (long)r.LedgerId)); + Assert.Equal(new[] { 120m, 70m }, page2.Select(r => r.RunningBalance)); + + // Filtering to one warehouse and item changes which rows show, not what the balance is. + var only = (await Ledger(db, itemId: 1, warehouseId: Warehouse1)).Items.OrderBy(r => r.LedgerId).ToList(); + Assert.Equal(new[] { 100m, 70m, 120m }, only.Select(r => r.RunningBalance)); + } + + [Fact] + public async Task EveryRow_CarriesTheItemsSkuNameCategoryAndBrand() + { + await using var db = await SeedAsync(); + + var row = (await Ledger(db, itemId: 1)).Items.First(); + + Assert.Equal(("MILO-240", "Milo 240ml", "Beverages", "Milo"), (row.ItemSku, row.ItemName, row.CategoryName, row.BrandName)); + } + + [Fact] + public async Task CategoryAndBrandFilters_NarrowTheLedgerToThoseItems() + { + await using var db = await SeedAsync(); + + Assert.All((await Ledger(db, categoryId: 2)).Items, r => Assert.Equal(2, r.ItemId)); + Assert.Equal(4, (await Ledger(db, brandId: 1)).Items.Count); + Assert.Empty((await Ledger(db, categoryId: 2, brandId: 1)).Items); + } +} diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs index 01e5363..d0902a4 100644 --- a/Backend/ERPCore/Controllers/StockController.cs +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -58,8 +58,9 @@ public sealed class StockController : ApiControllerBase [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, - [FromQuery] PageQuery query, CancellationToken ct) - => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct)); + [FromQuery] PageQuery query, CancellationToken ct, + [FromQuery] int? categoryId = null, [FromQuery] int? brandId = null) + => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct, categoryId, brandId)); [HttpGet("valuation")] [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Dtos/Stock/StockDtos.cs b/Backend/ERPCore/Dtos/Stock/StockDtos.cs index 9a1b3b2..be5310f 100644 --- a/Backend/ERPCore/Dtos/Stock/StockDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/StockDtos.cs @@ -11,7 +11,8 @@ public sealed record StockOnHandDto( public sealed record StockLedgerRowDto( int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId, Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance, - string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt); + string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt, + string? ItemSku = null, string? ItemName = null, string? CategoryName = null, string? BrandName = null); /// An open FIFO layer in a valuation (docs/11 §5.3). public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate); diff --git a/Backend/ERPCore/Services/Interfaces/IStockService.cs b/Backend/ERPCore/Services/Interfaces/IStockService.cs index 9011500..6bae2a6 100644 --- a/Backend/ERPCore/Services/Interfaces/IStockService.cs +++ b/Backend/ERPCore/Services/Interfaces/IStockService.cs @@ -17,7 +17,8 @@ public interface IStockService Task> GetLedgerAsync( int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, - string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default); + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default, + int? categoryId = null, int? brandId = null); Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs index 59d283a..caef68f 100644 --- a/Backend/ERPCore/Services/Stock/StockService.cs +++ b/Backend/ERPCore/Services/Stock/StockService.cs @@ -15,16 +15,19 @@ public sealed class StockService : IStockService private readonly IRepository _ledger; private readonly IRepository _transferLines; private readonly IRepository _warehouses; + private readonly IRepository _items; public StockService( IFifoCostingService fifo, IRepository layers, - IRepository ledger, IRepository transferLines, IRepository warehouses) + IRepository ledger, IRepository transferLines, IRepository warehouses, + IRepository items) { _fifo = fifo; _layers = layers; _ledger = ledger; _transferLines = transferLines; _warehouses = warehouses; + _items = items; } /// @@ -146,10 +149,19 @@ public sealed class StockService : IStockService public async Task> GetLedgerAsync( int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, - string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default, + int? categoryId = null, int? brandId = null) { var q = _ledger.Query().AsNoTracking(); if (itemId is not null) q = q.Where(l => l.ItemId == itemId); + // Category / brand narrow the ledger to the items in them. + if (categoryId is not null || brandId is not null) + { + var itemIds = _items.Query().Where(i => (categoryId == null || i.CategoryId == categoryId) + && (brandId == null || i.BrandId == brandId)) + .Select(i => i.ItemId); + q = q.Where(l => itemIds.Contains(l.ItemId)); + } if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct); if (branchWarehouseIds is not null) q = q.Where(l => branchWarehouseIds.Contains(l.WarehouseId)); @@ -164,13 +176,51 @@ public sealed class StockService : IStockService var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(l => l.LedgerId) .Skip(query.Skip).Take(query.PageSize) - .Select(l => new StockLedgerRowDto( - l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId, - l.Direction, l.QtyBase, l.UnitCost, l.Value, l.RunningBalance, - l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt)) .ToListAsync(ct); - return PagedResponse.Create(rows, query.Page, query.PageSize, total); + // The running balance is the item's balance in that warehouse right after the movement: the sum of every + // movement of the same (item, warehouse) up to and including this one (In adds, Out subtracts). It is worked out + // here from the movements themselves rather than read from the stored column, which some postings (sales + // issues) never filled in correctly - so it is right for old rows too and whatever filters are applied. + var balances = new Dictionary(); + foreach (var pair in rows.GroupBy(r => (r.ItemId, r.WarehouseId))) + { + var (itemKey, whKey) = pair.Key; + var minId = pair.Min(r => r.LedgerId); + var maxId = pair.Max(r => r.LedgerId); + // Everything before the earliest row on this page, then step through the movements up to the latest one. + var running = await _ledger.Query().AsNoTracking() + .Where(x => x.ItemId == itemKey && x.WarehouseId == whKey && x.LedgerId < minId) + .SumAsync(x => (decimal?)(x.Direction == Direction.In ? x.QtyBase : -x.QtyBase), ct) ?? 0m; + var movements = await _ledger.Query().AsNoTracking() + .Where(x => x.ItemId == itemKey && x.WarehouseId == whKey && x.LedgerId >= minId && x.LedgerId <= maxId) + .OrderBy(x => x.LedgerId) + .Select(x => new { x.LedgerId, Signed = x.Direction == Direction.In ? x.QtyBase : -x.QtyBase }) + .ToListAsync(ct); + foreach (var m in movements) + { + running += m.Signed; + balances[m.LedgerId] = running; + } + } + + var ids = rows.Select(r => r.ItemId).Distinct().ToList(); + var items = await _items.Query().AsNoTracking() + .Where(i => ids.Contains(i.ItemId)) + .Select(i => new { i.ItemId, i.Sku, i.Name, CategoryName = i.Category != null ? i.Category.Name : null, BrandName = i.Brand != null ? i.Brand.Name : null }) + .ToDictionaryAsync(i => i.ItemId, ct); + + var dtos = rows.Select(l => + { + items.TryGetValue(l.ItemId, out var item); + return new StockLedgerRowDto( + l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId, + l.Direction, l.QtyBase, l.UnitCost, l.Value, balances[l.LedgerId], + l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt, + item?.Sku, item?.Name, item?.CategoryName, item?.BrandName); + }).ToList(); + + return PagedResponse.Create(dtos, query.Page, query.PageSize, total); } public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default) diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 80d2588..b90982c 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -643,3 +643,8 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike - **Server rules (`CustomerService`):** a new value for a switched-off field is refused (`422 CONFIG_DISABLED`); on update a switched-off field **keeps its stored value** (a blank/zero from a form that no longer has the field can't wipe it). Tax registration and display name are not configurable. - **Where stored:** seven `bool` columns on `sales_config` (migration `AddCustomerFieldSwitches`, additive, default true, **not applied**). `useCustomerConfig` reads them from `GET /sales-config`; until it loads, the UI assumes all on. - **Tests:** `CustomerFieldSwitchTests` (12 cases). Suite 214/214. + +### 2026-10-18 (5) — Stock Ledger: item columns, filters, running balance +- **Running balance is now computed on read** (`StockService.GetLedgerAsync`): the sum of every movement of the same item + warehouse up to that row (In adds, Out subtracts). The stored `RunningBalance` column was wrong for sales issues (`SalesPostingService` posts `0`), so those rows showed 0; the read-time value is right for old rows, later pages and any filter. The column is no longer read. +- **Rows carry** `itemSku`, `itemName`, `categoryName`, `brandName`; the endpoint takes `categoryId` and `brandId` (items in them). The page shows the item NAME with SKU, category and brand underneath, signed quantities (+In / −Out), unit cost with up to 4 decimals, Out values negative, and Category / Brand / searchable Item (SKU — name) / Warehouse filters; the item picker follows the category and brand. +- **Tests:** `StockLedgerTests` (4). Suite 218/218. Not changed: `SalesPostingService` still writes `RunningBalance = 0` to the stored column (nothing reads it). diff --git a/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx b/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx index b1a4568..84409a1 100644 --- a/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/ledger/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { ScrollText } from "lucide-react" +import { ScrollText, X } from "lucide-react" import { stockApi } from "@/lib/api/stock" import { itemsApi } from "@/lib/api/items" @@ -20,6 +20,7 @@ import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] @@ -27,6 +28,42 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] // re-fetches from page 1, so previously loaded cards stay put and more append below. const MOBILE_PAGE_SIZE_STEP = 10 +/** The Value column (and card line) is hidden for now; flip to true to bring it back. */ +const SHOW_VALUE = false + +/** Quantities as they are: no trailing zeros, thousands separators, a sign for the direction of the movement. */ +function formatQty(n: number, signed?: "In" | "Out") { + const text = Math.abs(n).toLocaleString(undefined, { maximumFractionDigits: 4 }) + if (!signed) return n < 0 ? `-${text}` : text + return `${signed === "In" ? "+" : "−"}${text}` +} + +/** Unit costs keep their precision (FIFO costs are not always whole cents) but never show fewer than 2 places. */ +function formatUnitCost(n: number) { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }) +} + +/** A movement's value: positive stock coming in, negative going out, always 2 places. */ +function formatValue(n: number, direction: "In" | "Out") { + const text = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + return direction === "Out" && n !== 0 ? `−${text}` : text +} + +/** "Name" with the SKU under it — the same item cell on the table and the cards. */ +function itemCell(entry: LedgerEntry, fallback?: ItemListItem) { + const name = entry.itemName ?? fallback?.name + const sku = entry.itemSku ?? fallback?.sku + return ( +
+

{name ?? sku ?? `#${entry.itemId}`}

+ {name && sku &&

{sku}

} + {(entry.categoryName || entry.brandName) && ( +

{[entry.categoryName, entry.brandName].filter(Boolean).join(" · ")}

+ )} +
+ ) +} + export default function StockLedgerPage() { const { activeBranchId, branchingEnabled } = useAuth() @@ -36,7 +73,8 @@ export default function StockLedgerPage() { const [warehouses, setWarehouses] = useState(null) const [error, setError] = useState(null) - const [itemId, setItemId] = useState("All") + // 0 = every item. + const [itemId, setItemId] = useState(0) const [warehouseId, setWarehouseId] = useState("All") const [from, setFrom] = useState("") const [to, setTo] = useState("") @@ -45,12 +83,22 @@ export default function StockLedgerPage() { const [showMoreLoading, setShowMoreLoading] = useState(false) useEffect(() => { - Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()]) - .then(([it, wh]) => { - setItems(it.items) - setWarehouses(wh.items) - }) - .catch((err) => setError(errorMessage(err))) + warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch((err) => setError(errorMessage(err))) + }, []) + + // The item picker searches the whole catalogue (the API returns at most 200 per page, so read every page). + useEffect(() => { + let cancelled = false + ;(async () => { + const first = await itemsApi.list({ pageSize: 200 }) + const rest = await Promise.all( + Array.from({ length: Math.max(first.pagination.totalPages - 1, 0) }, (_, i) => itemsApi.list({ pageSize: 200, page: i + 2 })), + ) + if (!cancelled) setItems([first, ...rest].flatMap((r) => r.items)) + })().catch((err) => !cancelled && setError(errorMessage(err))) + return () => { + cancelled = true + } }, []) useEffect(() => { @@ -61,7 +109,7 @@ export default function StockLedgerPage() { let cancelled = false stockApi .ledger({ - itemId: itemId === "All" ? undefined : itemId, + itemId: itemId || undefined, warehouseId: warehouseId === "All" ? undefined : warehouseId, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, from: from || undefined, @@ -90,10 +138,16 @@ export default function StockLedgerPage() { setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } + // Same picker as Create Sale / New GRN: type a SKU or name, pick from the list. + const itemOptions = useMemo( + () => (items ?? []).map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })), + [items], + ) const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items]) const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) - const loading = entries === null || items === null || warehouses === null + const loading = entries === null || warehouses === null + const pickedItem = itemId ? itemsById.get(itemId) : undefined return (
@@ -111,19 +165,24 @@ export default function StockLedgerPage() {
- value={itemId} onValueChange={(v) => setItemId(v ?? "All")}> - - - - - All items - {(items ?? []).map((i) => ( - - {i.sku} - - ))} - - + {itemId ? ( +
+ {pickedItem ? `${pickedItem.sku} — ${pickedItem.name}` : `Item #${itemId}`} + +
+ ) : ( + setItemId(Number(v))} + browsable + placeholder="All items — type SKU or name…" + emptyText="No items match." + listClassName="min-w-[min(32rem,calc(100vw-2rem))]" + aria-label="Filter by item" + /> + )}
@@ -136,7 +195,7 @@ export default function StockLedgerPage() { All warehouses {(warehouses ?? []).map((w) => ( - {w.code} + {w.code} — {w.name} ))} @@ -192,8 +251,8 @@ export default function StockLedgerPage() {
-

{item?.sku ?? `#${entry.itemId}`}

-

{wh?.code ?? `#${entry.warehouseId}`}

+ {itemCell(entry, item)} +

{wh?.code ?? `#${entry.warehouseId}`}

Qty / Unit cost

-

{entry.qtyBase} @ {entry.unitCost.toFixed(2)}

-
-
-

Value

-

{entry.value.toFixed(2)}

+

{formatQty(entry.qtyBase, entry.direction)} @ {formatUnitCost(entry.unitCost)}

+ {SHOW_VALUE && ( +
+

Value

+

{formatValue(entry.value, entry.direction)}

+
+ )}

Running balance

-

{entry.runningBalance}

+

{formatQty(entry.runningBalance)}

Source

-

{entry.sourceDocType} #{entry.sourceDocId}

+ {entry.sourceDocType} #{entry.sourceDocId}

Date

-

{new Date(entry.createdAt).toLocaleString()}

+

{new Date(entry.createdAt).toLocaleDateString()}

@@ -247,15 +308,13 @@ export default function StockLedgerPage() { - Date - Item + Date / Source + Item Warehouse - Direction - Qty + Direction / Qty Unit cost - Value + {SHOW_VALUE && Value} Running balance - Source @@ -264,29 +323,37 @@ export default function StockLedgerPage() { const wh = warehousesById.get(entry.warehouseId) return ( - {new Date(entry.createdAt).toLocaleString()} - {item?.sku ?? `#${entry.itemId}`} + +

{new Date(entry.createdAt).toLocaleDateString()}

+ + {entry.sourceDocType} #{entry.sourceDocId} + +
+ {itemCell(entry, item)} {wh?.code ?? `#${entry.warehouseId}`} - - {entry.direction} - - - {entry.qtyBase} - {entry.unitCost.toFixed(2)} - {entry.value.toFixed(2)} - {entry.runningBalance} - - {entry.sourceDocType} #{entry.sourceDocId} +
+ + {entry.direction} + + + {formatQty(entry.qtyBase, entry.direction)} + +
+ {formatUnitCost(entry.unitCost)} + {SHOW_VALUE && ( + {formatValue(entry.value, entry.direction)} + )} + {formatQty(entry.runningBalance)}
) })} diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx index bdc3268..4718a10 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx @@ -1,8 +1,8 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { AlertOctagon, CheckCircle2 } from "lucide-react" +import { AlertOctagon, CheckCircle2, X } from "lucide-react" import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage" import { reasonCodesApi } from "@/lib/api/reason-codes" @@ -21,6 +21,7 @@ import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input" import { toast } from "@/components/ui/toast" export default function NewWastagePage() { @@ -47,10 +48,18 @@ export default function NewWastagePage() { const [result, setResult] = useState(null) useEffect(() => { - Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")]) + const allActiveItems = async () => { + const first = await itemsApi.list({ pageSize: 200, status: "Active" }) + const rest = await Promise.all( + Array.from({ length: Math.max(first.pagination.totalPages - 1, 0) }, (_, i) => + itemsApi.list({ pageSize: 200, status: "Active", page: i + 2 })), + ) + return [first, ...rest].flatMap((r) => r.items) + } + Promise.all([warehousesApi.list(), allActiveItems(), reasonCodesApi.list("Adjustment")]) .then(([wh, it, rc]) => { setWarehouses(wh.items) - setItems(it.items) + setItems(it) setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code))) }) .catch((err) => setLoadError(errorMessage(err))) @@ -131,6 +140,13 @@ export default function NewWastagePage() { setOnHandError(null) } + // Same picker as Create Sale / New GRN: type a SKU or name, pick from the list. + const itemOptions = useMemo( + () => (items ?? []).map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })), + [items], + ) + const pickedItem = itemId ? (items ?? []).find((i) => i.itemId === itemId) : undefined + const loading = !warehouses || !items || !reasonCodes return ( @@ -224,18 +240,24 @@ export default function NewWastagePage() {
- value={itemId} onValueChange={(v) => setItemId(v)}> - - - - - {(items ?? []).map((i) => ( - - {i.sku} — {i.name} - - ))} - - + {itemId ? ( +
+ {pickedItem ? `${pickedItem.sku} — ${pickedItem.name}` : `Item #${itemId}`} + +
+ ) : ( + setItemId(Number(v))} + browsable + placeholder="Search item — SKU or name…" + emptyText="No items match." + listClassName="min-w-[min(32rem,calc(100vw-2rem))]" + aria-label="Item" + /> + )}
diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx index 4bd142b..808c3e2 100644 --- a/Frontend/erp-system/app/login/page.tsx +++ b/Frontend/erp-system/app/login/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useState } from "react" import Link from "next/link" -import { useRouter, useSearchParams } from "next/navigation" +import { useSearchParams } from "next/navigation" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { Eye, EyeOff } from "lucide-react" @@ -26,7 +26,6 @@ export default function LoginPage() { } function LoginForm() { - const router = useRouter() const searchParams = useSearchParams() const [showPassword, setShowPassword] = useState(false) const [remember, setRemember] = useState(false) @@ -48,7 +47,11 @@ function LoginForm() { // Honour the path the guard bounced us from, but only same-origin paths — an // attacker-supplied absolute URL here would be an open redirect. const next = searchParams.get("next") - router.push(next?.startsWith("/") && !next.startsWith("//") ? next : "/dashboard") + const target = next?.startsWith("/") && !next.startsWith("//") ? next : "/dashboard" + // A full page load, not router.push: when the guard bounced a click on a dashboard link here, the app router has + // cached that link's redirect-to-login, so a soft navigation back to it lands on the login page again even though + // the sign-in worked. A hard navigation starts clean and carries the fresh cookies. + window.location.assign(target) } catch (err) { setSubmitError(errorMessage(err)) } diff --git a/Frontend/erp-system/lib/api/stock.ts b/Frontend/erp-system/lib/api/stock.ts index 417dfe5..3da9673 100644 --- a/Frontend/erp-system/lib/api/stock.ts +++ b/Frontend/erp-system/lib/api/stock.ts @@ -7,6 +7,9 @@ export interface LedgerQuery { itemId?: number warehouseId?: number branchId?: number + /** Only items in this category / of this brand. */ + categoryId?: number + brandId?: number /** `YYYY-MM-DD`. */ from?: string to?: string diff --git a/Frontend/erp-system/types/stock.ts b/Frontend/erp-system/types/stock.ts index 6278a17..5e93314 100644 --- a/Frontend/erp-system/types/stock.ts +++ b/Frontend/erp-system/types/stock.ts @@ -37,11 +37,16 @@ export interface LedgerEntry { qtyBase: number unitCost: number value: number + /** The item's balance in that warehouse right after this movement (computed by the server from the movements). */ runningBalance: number sourceDocType: string sourceDocId: number userId: number createdAt: string + itemSku?: string | null + itemName?: string | null + categoryName?: string | null + brandName?: string | null } // --- 5.3 Valuation ------------------------------------------------------------------