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.
This commit is contained in:
Dhananjaya99
2026-10-18 14:49:26 +05:30
parent 7da8834ce9
commit ce35973b58
12 changed files with 347 additions and 91 deletions
@@ -91,7 +91,8 @@ public class LowStockAlertServiceTests
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<PagedResponse<StockLedgerRowDto>> 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<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
}
+97
View File
@@ -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;
/// <summary>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).</summary>
public class StockLedgerTests
{
private static StockService Svc(ErpDbContext db) =>
new(null!, new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<StockTransferLine>(db),
new Repository<Warehouse>(db), new Repository<Item>(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<ErpDbContext> 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<PagedResponse<ERPCore.Dtos.Stock.StockLedgerRowDto>> 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);
}
}
@@ -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)]
+2 -1
View File
@@ -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);
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
@@ -17,7 +17,8 @@ public interface IStockService
Task<PagedResponse<StockLedgerRowDto>> 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<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
+57 -7
View File
@@ -15,16 +15,19 @@ public sealed class StockService : IStockService
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses,
IRepository<Item> items)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
_warehouses = warehouses;
_items = items;
}
/// <summary>
@@ -146,10 +149,19 @@ public sealed class StockService : IStockService
public async Task<PagedResponse<StockLedgerRowDto>> 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<StockLedgerRowDto>.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<int, decimal>();
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<StockLedgerRowDto>.Create(dtos, query.Page, query.PageSize, total);
}
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
+5
View File
@@ -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).
@@ -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 (
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{name ?? sku ?? `#${entry.itemId}`}</p>
{name && sku && <p className="truncate text-xs text-muted-foreground">{sku}</p>}
{(entry.categoryName || entry.brandName) && (
<p className="truncate text-xs text-muted-foreground">{[entry.categoryName, entry.brandName].filter(Boolean).join(" · ")}</p>
)}
</div>
)
}
export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth()
@@ -36,7 +73,8 @@ export default function StockLedgerPage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [itemId, setItemId] = useState<number | "All">("All")
// 0 = every item.
const [itemId, setItemId] = useState(0)
const [warehouseId, setWarehouseId] = useState<number | "All">("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<SuggestOption[]>(
() => (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 (
<div className="flex flex-col gap-6">
@@ -111,19 +165,24 @@ export default function StockLedgerPage() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label className="text-base">Item</Label>
<Select<number | "All"> value={itemId} onValueChange={(v) => setItemId(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All items</SelectItem>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
{itemId ? (
<div className="flex h-11 items-center justify-between gap-2 rounded-lg border border-input bg-card px-3 text-base">
<span className="min-w-0 truncate">{pickedItem ? `${pickedItem.sku} — ${pickedItem.name}` : `Item #${itemId}`}</span>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setItemId(0)} aria-label="Show all items" title="Show all items">
<X className="size-4" />
</Button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => 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"
/>
)}
</div>
<div className="flex flex-col gap-1.5">
@@ -136,7 +195,7 @@ export default function StockLedgerPage() {
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
@@ -192,8 +251,8 @@ export default function StockLedgerPage() {
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</p>
<p className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</p>
{itemCell(entry, item)}
<p className="mt-0.5 text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</p>
</div>
<Badge
variant="outline"
@@ -211,23 +270,25 @@ export default function StockLedgerPage() {
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty / Unit cost</p>
<p className="font-medium text-foreground">{entry.qtyBase} @ {entry.unitCost.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Value</p>
<p className="font-medium text-foreground">{entry.value.toFixed(2)}</p>
<p className="font-medium text-foreground">{formatQty(entry.qtyBase, entry.direction)} @ {formatUnitCost(entry.unitCost)}</p>
</div>
{SHOW_VALUE && (
<div>
<p className="text-muted-foreground">Value</p>
<p className="font-medium text-foreground">{formatValue(entry.value, entry.direction)}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Running balance</p>
<p className="font-semibold text-foreground">{entry.runningBalance}</p>
<p className="font-semibold text-foreground">{formatQty(entry.runningBalance)}</p>
</div>
<div>
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">{entry.sourceDocType} #{entry.sourceDocId}</p>
<span className="inline-block rounded-md bg-primary/10 px-2 py-0.5 font-bold text-primary">{entry.sourceDocType} #{entry.sourceDocId}</span>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleString()}</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
@@ -247,15 +308,13 @@ export default function StockLedgerPage() {
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-40">Date</TableHead>
<TableHead className="w-32">Item</TableHead>
<TableHead className="w-48">Date / Source</TableHead>
<TableHead className="w-56">Item</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-28">Direction</TableHead>
<TableHead className="w-24 text-right">Qty</TableHead>
<TableHead className="w-40">Direction / Qty</TableHead>
<TableHead className="w-28 text-right">Unit cost</TableHead>
<TableHead className="w-28 text-right">Value</TableHead>
{SHOW_VALUE && <TableHead className="w-28 text-right">Value</TableHead>}
<TableHead className="w-36 text-right">Running balance</TableHead>
<TableHead className="w-36">Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -264,29 +323,37 @@ export default function StockLedgerPage() {
const wh = warehousesById.get(entry.warehouseId)
return (
<TableRow key={entry.ledgerId} className="align-top hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</TableCell>
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</TableCell>
<TableCell>
<p className="text-sm text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
<span className="mt-1 inline-block rounded-md bg-primary/10 px-2 py-0.5 text-xs font-bold text-primary">
{entry.sourceDocType} #{entry.sourceDocId}
</span>
</TableCell>
<TableCell>{itemCell(entry, item)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-20 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.qtyBase}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.value.toFixed(2)}</TableCell>
<TableCell className="text-right font-semibold text-foreground tabular-nums">{entry.runningBalance}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={cn(
"h-6 w-14 shrink-0 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
<span className={cn("text-sm font-medium tabular-nums", entry.direction === "In" ? "text-success" : "text-destructive")}>
{formatQty(entry.qtyBase, entry.direction)}
</span>
</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{formatUnitCost(entry.unitCost)}</TableCell>
{SHOW_VALUE && (
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{formatValue(entry.value, entry.direction)}</TableCell>
)}
<TableCell className="text-right font-semibold text-foreground tabular-nums">{formatQty(entry.runningBalance)}</TableCell>
</TableRow>
)
})}
@@ -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<StockAdjustment | null>(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<SuggestOption[]>(
() => (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() {
<div className="flex flex-col gap-2">
<Label className="text-base">Item</Label>
<Select<number | null> value={itemId} onValueChange={(v) => setItemId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
{itemId ? (
<div className="flex h-12 items-center justify-between gap-2 rounded-lg border border-input bg-card px-3 text-base">
<span className="min-w-0 truncate">{pickedItem ? `${pickedItem.sku} — ${pickedItem.name}` : `Item #${itemId}`}</span>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setItemId(null)} aria-label="Change item" title="Change item">
<X className="size-4" />
</Button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => 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"
/>
)}
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div>
+6 -3
View File
@@ -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))
}
+3
View File
@@ -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
+5
View File
@@ -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 ------------------------------------------------------------------