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
@@ -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))
}