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