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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user