Add migration to include AccruedExpensesCode in gl_account_config table
- Introduced a new column 'AccruedExpensesCode' of type varchar(20) to the 'gl_account_config' table. - Added SQL command to update existing records with a default value of '2300' for the 'AccruedExpensesCode' where it is currently null.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams, usePathname, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
|
||||
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
@@ -14,6 +14,8 @@ import { uomsApi } from "@/lib/api/uoms"
|
||||
import { taxRatesApi } from "@/lib/api/tax-rates"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { WastageRecord, wastageApi } from "@/lib/api/wastage"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -104,6 +106,9 @@ function ItemDetailPage() {
|
||||
|
||||
// Profile activity: full recent movement history for this item, across every warehouse.
|
||||
const [ledger, setLedger] = useState<LedgerEntry[] | null>(null)
|
||||
// Damage / theft / expiry write-offs of this item (stock adjustments with a wastage reason), and the reason names.
|
||||
const [wastage, setWastage] = useState<WastageRecord[] | null>(null)
|
||||
const [reasonNames, setReasonNames] = useState<Map<number, string>>(new Map())
|
||||
const [stockValue, setStockValue] = useState<number | null>(null)
|
||||
|
||||
// Basic info form
|
||||
@@ -218,6 +223,11 @@ function ItemDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(itemId)) return
|
||||
stockApi.ledger({ itemId, pageSize: 500 }).then((res) => setLedger(res.items)).catch(() => setLedger([]))
|
||||
wastageApi.list({ itemId, pageSize: 100 }).then(setWastage).catch(() => setWastage([]))
|
||||
reasonCodesApi
|
||||
.list("Adjustment", { pageSize: 200 })
|
||||
.then((r) => setReasonNames(new Map(r.items.map((x) => [x.reasonCodeId, x.description]))))
|
||||
.catch(() => {})
|
||||
stockApi
|
||||
.onHandList({ itemId, pageSize: 200 })
|
||||
.then((res) => res.items.filter((r) => r.onHand > 0))
|
||||
@@ -280,6 +290,8 @@ function ItemDetailPage() {
|
||||
.reduce((sum, r) => sum + r.qtyBase, 0),
|
||||
[sortedLedger, cutoff30]
|
||||
)
|
||||
// Wastage that has actually reduced stock counts; a request still waiting for approval is listed but not counted.
|
||||
const wastedQty = useMemo(() => (wastage ?? []).filter((w) => w.status === "Posted").reduce((sum, w) => sum + w.qty, 0), [wastage])
|
||||
const recentSales = useMemo(
|
||||
() => sortedLedger.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType)).slice(-8).reverse(),
|
||||
[sortedLedger]
|
||||
@@ -626,7 +638,7 @@ function ItemDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
|
||||
<StatChip
|
||||
icon={BadgeDollarSign}
|
||||
@@ -635,6 +647,7 @@ function ItemDetailPage() {
|
||||
/>
|
||||
<StatChip icon={ShoppingCart} label="Sold, last 30 days" value={salesQty30d.toLocaleString()} />
|
||||
<StatChip icon={PackageCheck} label="Purchases on record" value={recentGrns.length > 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
|
||||
<StatChip icon={AlertOctagon} label="Wasted, all time" value={wastage === null ? "…" : wastedQty.toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
@@ -919,6 +932,76 @@ function ItemDetailPage() {
|
||||
|
||||
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-foreground">Recent wastage</h2>
|
||||
{wastage === null ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : wastage.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No wastage recorded for this item.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
{wastage.slice(0, 8).map((w) => (
|
||||
<div key={`${w.adjustmentId}-${w.adjLineId}`} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link href={`/dashboard/stock/adjustments/${w.adjustmentId}`} className="truncate font-medium text-primary hover:underline">
|
||||
{w.docNo}
|
||||
</Link>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{new Date(w.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{reasonNames.get(w.reasonCodeId) ?? `Reason #${w.reasonCodeId}`} · Qty {w.qty.toLocaleString()}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{w.status === "Posted" ? `LKR ${w.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : "Pending approval"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Reason</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{wastage.slice(0, 8).map((w) => (
|
||||
<TableRow key={`${w.adjustmentId}-${w.adjLineId}`}>
|
||||
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(w.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-2 py-2">
|
||||
<Link href={`/dashboard/stock/adjustments/${w.adjustmentId}`} className="font-medium text-primary hover:underline">
|
||||
{w.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-2 text-muted-foreground">{reasonNames.get(w.reasonCodeId) ?? `#${w.reasonCodeId}`}</TableCell>
|
||||
<TableCell className="px-2 py-2 font-medium text-destructive">-{w.qty.toLocaleString()}</TableCell>
|
||||
<TableCell className="px-2 py-2">
|
||||
{w.status === "Posted" ? (
|
||||
`LKR ${w.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
|
||||
) : (
|
||||
<span className="rounded bg-warning/10 px-1.5 py-0.5 text-xs text-warning">Pending approval</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/wastage" className="self-end text-sm font-medium text-primary hover:underline">
|
||||
All wastage
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
|
||||
There are no conversions: a differently sized pack is a different item.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const levelOptions = [
|
||||
@@ -28,6 +29,7 @@ export default function ApprovalSettingsPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stockLevels, setStockLevels] = useState("2")
|
||||
const [stockThreshold, setStockThreshold] = useState("0")
|
||||
const [wastageDual, setWastageDual] = useState(false)
|
||||
const [priceLevels, setPriceLevels] = useState("2")
|
||||
const [priceThreshold, setPriceThreshold] = useState("0")
|
||||
const [level2Roles, setLevel2Roles] = useState<number[]>([])
|
||||
@@ -38,6 +40,7 @@ export default function ApprovalSettingsPage() {
|
||||
setConfig(c)
|
||||
setStockLevels(String(c.stockAdjustmentLevels))
|
||||
setStockThreshold(String(c.stockAdjustmentLevel2ThresholdValue))
|
||||
setWastageDual((c.wastageLevels ?? 0) > 0)
|
||||
setPriceLevels(String(c.priceAdjustmentLevels))
|
||||
setPriceThreshold(String(c.priceAdjustmentLevel2ThresholdPercent))
|
||||
setLevel2Roles(c.level2RoleIds)
|
||||
@@ -60,6 +63,7 @@ export default function ApprovalSettingsPage() {
|
||||
const saved = await approvalsApi.updateConfig({
|
||||
stockAdjustmentLevels: Number(stockLevels),
|
||||
stockAdjustmentLevel2ThresholdValue: sv,
|
||||
wastageLevels: wastageDual ? 2 : 0,
|
||||
priceAdjustmentLevels: Number(priceLevels),
|
||||
priceAdjustmentLevel2ThresholdPercent: pv,
|
||||
level2RoleIds: level2Roles,
|
||||
@@ -81,7 +85,7 @@ export default function ApprovalSettingsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Approvals</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
How many people must approve a stock or price adjustment before it takes effect. The requester can never approve their own request,
|
||||
How many people must approve a stock adjustment, wastage or price adjustment before it takes effect. The requester can never approve their own request,
|
||||
and with two approvals the second must be a different person from the first. Changes apply to new requests; pending ones keep their rule.
|
||||
</p>
|
||||
</div>
|
||||
@@ -89,7 +93,7 @@ export default function ApprovalSettingsPage() {
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-foreground">Stock adjustments</h2>
|
||||
<p className="text-sm text-muted-foreground">Adjustments, wastage and stock-count variances.</p>
|
||||
<p className="text-sm text-muted-foreground">Manual adjustments and stock-count variances. Wastage has its own switch below.</p>
|
||||
<Field>
|
||||
<FieldLabel>Approvals required</FieldLabel>
|
||||
<Select<string> value={stockLevels} onValueChange={(v) => v && setStockLevels(v)}>
|
||||
@@ -106,6 +110,27 @@ export default function ApprovalSettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-foreground">Wastage</h2>
|
||||
<p className="text-sm text-muted-foreground">Damage, theft / loss and expiry write-offs.</p>
|
||||
<div className="flex items-start gap-4 py-1">
|
||||
<Switch
|
||||
checked={wastageDual}
|
||||
onCheckedChange={setWastageDual}
|
||||
disabled={saving}
|
||||
aria-label="Wastage needs dual approval"
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Wastage needs dual approval</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Off: recording wastage reduces stock immediately. On: two different approvers must approve before stock is reduced
|
||||
(the request waits in the Approval Queue until then).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-foreground">Price adjustments</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertOctagon, CheckCircle2, X } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AlertOctagon, X } from "lucide-react"
|
||||
|
||||
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { approvalsApi } from "@/lib/api/approvals"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { OnHand, ReasonCode, StockAdjustment } from "@/types/stock"
|
||||
import { OnHand, ReasonCode } from "@/types/stock"
|
||||
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
@@ -25,6 +27,7 @@ import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function NewWastagePage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
@@ -45,7 +48,12 @@ export default function NewWastagePage() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [result, setResult] = useState<StockAdjustment | null>(null)
|
||||
// Settings -> Approvals "Wastage needs dual approval". Null until read; the server decides either way.
|
||||
const [needsApproval, setNeedsApproval] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
approvalsApi.getConfig().then((c) => setNeedsApproval((c.wastageLevels ?? 0) > 0)).catch(() => setNeedsApproval(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const allActiveItems = async () => {
|
||||
@@ -119,9 +127,10 @@ export default function NewWastagePage() {
|
||||
qty: Number(qty),
|
||||
reasonCodeId: reasonCodeId as number,
|
||||
})
|
||||
setResult(adjustment)
|
||||
if (adjustment.status === "Posted") toast.success("Wastage recorded", `${adjustment.docNo} — stock written off.`)
|
||||
else toast.success("Wastage submitted for approval", `${adjustment.docNo} is written off once ${adjustment.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`)
|
||||
// Straight back to the wastage table, where the new record is listed.
|
||||
router.push("/dashboard/stock/wastage")
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not record wastage", errorMessage(err))
|
||||
@@ -130,16 +139,6 @@ export default function NewWastagePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function startAnother() {
|
||||
setResult(null)
|
||||
setItemId(null)
|
||||
setQty("")
|
||||
setReasonCodeId(null)
|
||||
setSubmitError(null)
|
||||
setOnHand(null)
|
||||
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}` })),
|
||||
@@ -154,7 +153,11 @@ export default function NewWastagePage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
|
||||
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) — a reason code is mandatory.</p>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{needsApproval
|
||||
? "Needs two approvals before stock is reduced (set in Settings → Approvals) — a reason code is mandatory."
|
||||
: "Reduces stock immediately, as a stock adjustment (FR-STK-07) — a reason code is mandatory."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,38 +167,7 @@ export default function NewWastagePage() {
|
||||
|
||||
{loading && !loadError && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && result && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">
|
||||
{result.status === "Posted" ? `${result.docNo} posted` : `${result.docNo} submitted for approval`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{result.status === "Posted" ? (
|
||||
<>Ledger refs: {result.ledgerRefs.join(", ")}</>
|
||||
) : (
|
||||
<>
|
||||
Stock is written off once approved.{" "}
|
||||
<Link href={`/dashboard/stock/adjustments/${result.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to wastage report
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={startAnother}>
|
||||
Record another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !result && (
|
||||
{!loading && (
|
||||
<>
|
||||
{(reasonCodes ?? []).length === 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
|
||||
|
||||
@@ -40,10 +40,17 @@ export default function WastagePage() {
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
|
||||
const allItems = 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 })),
|
||||
)
|
||||
return [first, ...rest].flatMap((r) => r.items)
|
||||
}
|
||||
Promise.all([reasonCodesApi.list("Adjustment"), allItems(), warehousesApi.list()])
|
||||
.then(([rc, it, wh]) => {
|
||||
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
|
||||
setItems(it.items)
|
||||
setItems(it)
|
||||
setWarehouses(wh.items)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
@@ -72,11 +79,16 @@ export default function WastagePage() {
|
||||
: null
|
||||
const pagedRecords = records?.slice((page - 1) * pageSize, page * pageSize) ?? null
|
||||
|
||||
// Only wastage that has actually reduced stock counts towards the totals; requests still waiting for approval are
|
||||
// counted apart (they have no cost yet — FIFO costs are fixed when the stock is written off).
|
||||
const totals = useMemo(() => {
|
||||
if (!records) return { qty: 0, value: 0 }
|
||||
if (!records) return { qty: 0, value: 0, pending: 0 }
|
||||
return records.reduce(
|
||||
(acc, r) => ({ qty: acc.qty + r.qty, value: Math.round((acc.value + r.value) * 100) / 100 }),
|
||||
{ qty: 0, value: 0 }
|
||||
(acc, r) =>
|
||||
r.status === "Posted"
|
||||
? { ...acc, qty: acc.qty + r.qty, value: Math.round((acc.value + r.value) * 100) / 100 }
|
||||
: { ...acc, pending: acc.pending + 1 },
|
||||
{ qty: 0, value: 0, pending: 0 }
|
||||
)
|
||||
}, [records])
|
||||
|
||||
@@ -107,7 +119,7 @@ export default function WastagePage() {
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
|
||||
<p className="text-sm text-muted-foreground">Total quantity wasted</p>
|
||||
<p className="text-2xl font-semibold text-destructive">{totals.qty}</p>
|
||||
@@ -116,6 +128,10 @@ export default function WastagePage() {
|
||||
<p className="text-sm text-muted-foreground">Total value wasted (LKR)</p>
|
||||
<p className="text-2xl font-semibold text-destructive">{totals.value.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-warning/30 bg-warning/5 p-5">
|
||||
<p className="text-sm text-muted-foreground">Waiting for approval</p>
|
||||
<p className="text-2xl font-semibold text-warning">{totals.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -216,7 +232,7 @@ export default function WastagePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Value (LKR)</p>
|
||||
<p className="font-medium text-foreground">{r.value.toFixed(2)}</p>
|
||||
<p className="font-medium text-foreground">{r.status === "Posted" ? r.value.toFixed(2) : "—"}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Date</p>
|
||||
@@ -268,7 +284,7 @@ export default function WastagePage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold text-destructive tabular-nums">-{r.qty}</TableCell>
|
||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.value.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.status === "Posted" ? r.value.toFixed(2) : "—"}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface ListAdjustmentsParams {
|
||||
branchId?: number
|
||||
reasonCodeId?: number
|
||||
status?: AdjustmentStatus
|
||||
/** Only wastage: adjustments with a damage / theft / expiry reason code. */
|
||||
wastage?: boolean
|
||||
/** Only adjustments with a line for this item. */
|
||||
itemId?: number
|
||||
sort?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface WastageRecord {
|
||||
}
|
||||
|
||||
export interface ListWastageParams {
|
||||
/** Only this item's wastage (the product page). */
|
||||
itemId?: number
|
||||
warehouseId?: number
|
||||
branchId?: number
|
||||
reasonCodeId?: number
|
||||
@@ -71,20 +73,19 @@ export const wastageApi = {
|
||||
* because the adjustment page is already bounded.
|
||||
*/
|
||||
async list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
|
||||
const wastageIds = new Set(await wastageReasonCodeIds())
|
||||
if (wastageIds.size === 0) return []
|
||||
|
||||
// The API filters by a single reasonCodeId, so a specific pick can be pushed down;
|
||||
// otherwise fetch the page and keep the loss-type rows.
|
||||
// The server returns only wastage (damage / theft / expiry reasons), newest first, so a busy adjustment history
|
||||
// can't push the wastage out of the page the way a browser-side filter over mixed adjustments could.
|
||||
const res = await stockAdjustmentsApi.list({
|
||||
warehouseId: params.warehouseId,
|
||||
branchId: params.branchId,
|
||||
reasonCodeId: params.reasonCodeId,
|
||||
itemId: params.itemId,
|
||||
wastage: true,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize ?? 50,
|
||||
})
|
||||
// A rejected or withdrawn request never wrote anything off.
|
||||
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId) && a.status !== "Rejected" && a.status !== "Cancelled")
|
||||
const summaries = res.items.filter((a) => a.status !== "Rejected" && a.status !== "Cancelled")
|
||||
|
||||
const detailed = await Promise.all(
|
||||
summaries.map(async (summary) => {
|
||||
@@ -94,7 +95,7 @@ export const wastageApi = {
|
||||
])
|
||||
|
||||
return adjustment.lines
|
||||
.filter((line) => line.qtyDelta < 0) // wastage is always a decrease
|
||||
.filter((line) => line.qtyDelta < 0 && (params.itemId === undefined || line.itemId === params.itemId)) // wastage is always a decrease
|
||||
.map<WastageRecord>((line) => {
|
||||
const value = ledger.items
|
||||
.filter((l) => l.itemId === line.itemId && l.direction === "Out")
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface ApprovalConfig {
|
||||
stockAdjustmentLevels: number
|
||||
/** With 2 levels: adjustments worth less than this need one approval. 0 = always two. */
|
||||
stockAdjustmentLevel2ThresholdValue: number
|
||||
/** Approvals wastage needs: 0 = none (stock reduces on save), 2 = two approvers first. */
|
||||
wastageLevels: number
|
||||
priceAdjustmentLevels: number
|
||||
/** With 2 levels: price changes smaller than this % need one approval. 0 = always two. */
|
||||
priceAdjustmentLevel2ThresholdPercent: number
|
||||
|
||||
@@ -21,6 +21,7 @@ export type GlAccountRole =
|
||||
| "Cogs"
|
||||
| "InventoryLoss"
|
||||
| "Cash"
|
||||
| "AccruedExpenses"
|
||||
|
||||
export interface GlAccountOption {
|
||||
accountCode: string
|
||||
@@ -62,6 +63,7 @@ export interface UpdateGlAccountConfigRequest {
|
||||
cogsAccountCode: string | null
|
||||
inventoryLossAccountCode: string | null
|
||||
cashAccountCode: string | null
|
||||
accruedExpensesCode: string | null
|
||||
}
|
||||
|
||||
/** Request-field name per role, so the page can build the PUT body from the mapping list. */
|
||||
@@ -78,6 +80,7 @@ export const GL_ROLE_FIELD: Record<GlAccountRole, keyof UpdateGlAccountConfigReq
|
||||
Cogs: "cogsAccountCode",
|
||||
InventoryLoss: "inventoryLossAccountCode",
|
||||
Cash: "cashAccountCode",
|
||||
AccruedExpenses: "accruedExpensesCode",
|
||||
}
|
||||
|
||||
/** What each role means in accounting terms, and which flows post to it. */
|
||||
@@ -94,6 +97,7 @@ export const GL_ROLE_LABELS: Record<GlAccountRole, { label: string; description:
|
||||
Cogs: { label: "Cost of Goods Sold", description: "Cost of sales — day-end, sales returns" },
|
||||
InventoryLoss: { label: "Inventory Loss", description: "Stock written off by an adjustment or count" },
|
||||
Cash: { label: "Cash", description: "Cash takings at day-end close" },
|
||||
AccruedExpenses: { label: "Accrued Expenses", description: "Expenses booked but not yet paid — Accounts → Expenses (post and pay)" },
|
||||
}
|
||||
|
||||
/** Display order: assets, liabilities, income, expense — the order an accountant reads them in. */
|
||||
@@ -105,6 +109,7 @@ export const GL_ROLE_ORDER: GlAccountRole[] = [
|
||||
"Clearing",
|
||||
"TaxPayable",
|
||||
"AccountsPayable",
|
||||
"AccruedExpenses",
|
||||
"SalesRevenue",
|
||||
"SalesDiscount",
|
||||
"InventoryGain",
|
||||
|
||||
Reference in New Issue
Block a user