price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
@@ -0,0 +1,190 @@
"use client"
import { use, useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Ban } from "lucide-react"
import { bankDepositsApi } from "@/lib/api/bank-deposits"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { BankDeposit } from "@/types/bank-deposits"
import { BankDepositStatusBadge } from "@/components/accounts/bank-deposit-badges"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
export default function BankDepositDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const bankDepositId = Number(id)
const [deposit, setDeposit] = useState<BankDeposit | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(bankDepositId) ? loadError : `Invalid deposit id '${id}'.`
const [cancelOpen, setCancelOpen] = useState(false)
const [cancelReason, setCancelReason] = useState("")
const [cancelError, setCancelError] = useState<string | null>(null)
const [cancelling, setCancelling] = useState(false)
useEffect(() => {
if (!Number.isFinite(bankDepositId)) return
bankDepositsApi
.get(bankDepositId)
.then(setDeposit)
.catch((err) => setLoadError(errorMessage(err)))
}, [bankDepositId])
async function handleCancel() {
if (!deposit) return
if (!cancelReason.trim()) {
setCancelError("Enter a reason")
return
}
setCancelling(true)
try {
const cancelled = await bankDepositsApi.cancel(deposit.bankDepositId, cancelReason.trim())
setDeposit(cancelled)
setCancelOpen(false)
toast.success("Deposit cancelled", `Reversing journal ${cancelled.glReversalJournalNo ?? "—"} — the cash is back in hand.`)
} catch (err) {
setCancelError(errorMessage(err))
} finally {
setCancelling(false)
}
}
if (error) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
</div>
)
}
if (!deposit) return <Skeleton className="h-96 w-full" />
return (
<div className="flex flex-col gap-6">
<BackLink />
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{deposit.docNo}</h1>
<BankDepositStatusBadge status={deposit.status} />
</div>
<p className="text-base text-muted-foreground">
Cash of {formatReportDate(deposit.businessDate)} deposited to {deposit.toBankAccountName}
</p>
</div>
{deposit.status === "Posted" && (
<Button
variant="outline"
onClick={() => {
setCancelReason("")
setCancelError(null)
setCancelOpen(true)
}}
>
<Ban className="size-4" />
Cancel deposit
</Button>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm text-muted-foreground">Amount</p>
<p className={cn("text-xl font-bold tabular-nums", deposit.status === "Cancelled" ? "text-muted-foreground line-through" : "text-foreground")}>
{formatAmount(deposit.amount)}
</p>
</div>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm text-muted-foreground">From</p>
<p className="font-semibold text-foreground">{deposit.fromCashAccountName}</p>
<p className="text-xs text-muted-foreground">GL {deposit.fromCashAccountCode}</p>
</div>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm text-muted-foreground">To</p>
<p className="font-semibold text-foreground">{deposit.toBankAccountName}</p>
<p className="text-xs text-muted-foreground">
{[deposit.toBankName, deposit.toAccountNumber, `GL ${deposit.toBankAccountCode}`].filter(Boolean).join(" · ")}
</p>
</div>
</div>
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-3">
<Detail label="Business date" value={formatReportDate(deposit.businessDate)} />
<Detail label="Deposit date" value={formatReportDate(deposit.depositDate)} />
<Detail label="Branch" value={deposit.branchName ?? `#${deposit.branchId}`} />
<Detail label="Deposit slip no." value={deposit.depositSlipNo ?? "—"} />
<Detail label="Deposited by" value={deposit.depositedBy ?? "—"} />
<Detail label="Recorded" value={formatReportDate(deposit.createdAt)} />
<Detail label="Ledger journal" value={deposit.glJournalNo ?? "—"} />
{deposit.status === "Cancelled" && (
<>
<Detail label="Reversal journal" value={deposit.glReversalJournalNo ?? "—"} />
<Detail label="Cancelled" value={formatReportDate(deposit.cancelledAt)} />
<Detail label="Cancel reason" value={deposit.cancelReason ?? "—"} />
</>
)}
{deposit.remarks && (
<div className="sm:col-span-2 lg:col-span-3">
<dt className="text-muted-foreground">Remarks</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-foreground">{deposit.remarks}</dd>
</div>
)}
</dl>
</div>
<Dialog open={cancelOpen} onOpenChange={setCancelOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cancel deposit {deposit.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
A reversing journal entry will be posted (cash up, bank down) and {formatAmount(deposit.amount)} goes back into the cash in hand
for {formatReportDate(deposit.businessDate)}. This cannot be undone — record a new deposit if it was only entered wrongly.
</p>
<Field data-invalid={!!cancelError}>
<FieldLabel htmlFor="bd-cancel-reason" required>Reason</FieldLabel>
<Textarea id="bd-cancel-reason" value={cancelReason} maxLength={250} rows={3} onChange={(e) => setCancelReason(e.target.value)} />
<FieldError errors={[cancelError ? { message: cancelError } : undefined]} />
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={cancelling}>
Keep it
</Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Cancel deposit"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function BackLink() {
return (
<Link href="/dashboard/accounts/bank-deposits" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" />
Bank Deposits
</Link>
)
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-muted-foreground">{label}</dt>
<dd className="mt-0.5 font-medium text-foreground">{value}</dd>
</div>
)
}
@@ -0,0 +1,414 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { bankDepositsApi } from "@/lib/api/bank-deposits"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashInHand, CreateBankDepositRequest, maskAccountNumber } from "@/types/bank-deposits"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { localToday } from "@/components/sales/PaymentLinesEditor"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldGroup, 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 { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
type Errors = Partial<Record<"branch" | "businessDate" | "depositDate" | "from" | "to" | "amount", string>>
function addDays(iso: string, days: number) {
const d = new Date(`${iso}T00:00:00`)
d.setDate(d.getDate() + days)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Record till cash taken to the bank: pick the day, see its cash position, choose the cash and bank accounts. */
export default function NewBankDepositPage() {
const router = useRouter()
const { isAdmin, loading: authLoading, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const today = localToday()
const [branches, setBranches] = useState<Branch[]>([])
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [accountsError, setAccountsError] = useState<string | null>(null)
const [position, setPosition] = useState<CashInHand | null>(null)
const [positionError, setPositionError] = useState<string | null>(null)
const [branchPickId, setBranchPickId] = useState<number | null>(null)
const [businessDate, setBusinessDate] = useState(today)
const [depositDate, setDepositDate] = useState(today)
const [fromAccountId, setFromAccountId] = useState<number | null>(null)
const [toAccountId, setToAccountId] = useState<number | null>(null)
const [amount, setAmount] = useState("")
const [amountTouched, setAmountTouched] = useState(false)
const [depositSlipNo, setDepositSlipNo] = useState("")
const [depositedBy, setDepositedBy] = useState("")
const [remarks, setRemarks] = useState("")
const [errors, setErrors] = useState<Errors>({})
const [saving, setSaving] = useState(false)
// Same branch rules as the Expense form: a branch user is always their own branch; an admin uses the
// header's branch, or picks one here. With branching off the server uses the single default branch.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchPickId) : myBranchId) : null
const needsBranch = branchingEnabled && isAdmin && effectiveBranchId === null
const cashAccounts = accounts?.filter((a) => a.accountType === CashBankAccountType.Cash) ?? []
const bankAccounts = accounts?.filter((a) => a.accountType === CashBankAccountType.Bank) ?? []
useEffect(() => {
if (branchingEnabled && isAdmin) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled, isAdmin])
useEffect(() => {
if (accounts !== null) return
bankAccountsApi
.list("Both")
.then((rows) => {
setAccounts(rows)
setAccountsError(null)
})
.catch((err) => {
setAccounts([])
setAccountsError(errorMessage(err))
})
}, [accounts])
useEffect(() => {
if (authLoading || needsBranch || !businessDate) return
let stale = false
bankDepositsApi
.cashInHand({ date: businessDate, branchId: effectiveBranchId })
.then((res) => {
if (stale) return
setPosition(res)
setPositionError(null)
})
.catch((err) => {
if (stale) return
setPosition(null)
setPositionError(errorMessage(err))
})
return () => {
stale = true
}
}, [authLoading, needsBranch, businessDate, effectiveBranchId])
// Preselect the configured cash account (and the only bank account, when there is just one).
const defaultCashCode = position?.defaultCashAccountCode ?? null
if (fromAccountId === null && cashAccounts.length > 0) {
const preferred = cashAccounts.find((a) => a.glAccountCode === defaultCashCode) ?? (cashAccounts.length === 1 ? cashAccounts[0] : null)
if (preferred) setFromAccountId(preferred.accountId)
}
if (toAccountId === null && bankAccounts.length === 1) setToAccountId(bankAccounts[0].accountId)
// The amount follows what is available until the user types their own.
const available = position?.availableToDeposit ?? 0
const suggested = position ? available.toFixed(2) : ""
if (!amountTouched && amount !== suggested) setAmount(suggested)
function changeBusinessDate(value: string) {
setBusinessDate(value)
// Deposit date follows the business date unless it would fall before it.
if (!depositDate || depositDate < value) setDepositDate(value)
}
function validate(): Errors {
const next: Errors = {}
const value = Number(amount)
if (needsBranch) next.branch = "Select the branch whose cash is being deposited"
if (!businessDate) next.businessDate = "Select the business date"
else if (businessDate > today) next.businessDate = "The business date cannot be in the future"
else if (position?.trackingStartDate && businessDate < position.trackingStartDate)
next.businessDate = `Cash in hand is tracked from ${formatReportDate(position.trackingStartDate)}`
if (!depositDate) next.depositDate = "Select the deposit date"
else if (businessDate && depositDate < businessDate) next.depositDate = "Cannot be before the business date"
else if (depositDate > addDays(today, 1)) next.depositDate = "Cannot be in the future"
if (!fromAccountId) next.from = "Select the cash account the money leaves"
if (!toAccountId) next.to = "Select the bank account it goes into"
if (!amount || Number.isNaN(value) || value <= 0) next.amount = "Enter an amount greater than zero"
else if (position && value > available + 0.0001) next.amount = `Only ${formatAmount(available)} is available to deposit for this day`
return next
}
async function submit() {
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
const request: CreateBankDepositRequest = {
branchId: effectiveBranchId ?? 0,
businessDate,
depositDate,
amount: Number(amount),
fromGlCashAccountId: fromAccountId!,
toGlBankAccountId: toAccountId!,
depositSlipNo: depositSlipNo.trim() || undefined,
depositedBy: depositedBy.trim() || undefined,
remarks: remarks.trim() || undefined,
}
setSaving(true)
try {
const saved = await bankDepositsApi.create(request)
toast.success("Deposit recorded", `${saved.docNo} — ${formatAmount(saved.amount)} to ${saved.toBankAccountName} (journal ${saved.glJournalNo ?? "—"})`)
router.push(`/dashboard/accounts/bank-deposits/${saved.bankDepositId}`)
} catch (err) {
toast.error("Could not record the deposit", errorMessage(err))
setSaving(false)
}
}
if (authLoading) return <Skeleton className="h-96 w-full" />
return (
<div className="flex flex-col gap-6">
<Link href="/dashboard/accounts/bank-deposits" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" />
Bank Deposits
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Bank Deposit</h1>
<p className="text-base text-muted-foreground">
Deposit the day’s till cash to a bank account. It is posted to the ledger straight away (bank up, cash down).
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_20rem]">
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{branchingEnabled && isAdmin && (
<Field data-invalid={!!errors.branch}>
<FieldLabel required>Branch</FieldLabel>
{showBranchPicker ? (
<Select<number | null> value={branchPickId} onValueChange={(v) => setBranchPickId(v)}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.branch}>
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-11 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
</Field>
)}
<Field data-invalid={!!errors.businessDate}>
<FieldLabel htmlFor="bd-business-date" required>Business date</FieldLabel>
<Input
id="bd-business-date"
type="date"
max={today}
min={position?.trackingStartDate ?? undefined}
value={businessDate}
onChange={(e) => changeBusinessDate(e.target.value)}
aria-invalid={!!errors.businessDate}
/>
<FieldDescription>The day whose cash is being deposited.</FieldDescription>
<FieldError errors={[errors.businessDate ? { message: errors.businessDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.depositDate}>
<FieldLabel htmlFor="bd-deposit-date" required>Deposit date</FieldLabel>
<Input
id="bd-deposit-date"
type="date"
min={businessDate || undefined}
max={addDays(today, 1)}
value={depositDate}
onChange={(e) => setDepositDate(e.target.value)}
aria-invalid={!!errors.depositDate}
/>
<FieldDescription>When the bank received it — also the ledger entry date.</FieldDescription>
<FieldError errors={[errors.depositDate ? { message: errors.depositDate } : undefined]} />
</Field>
</div>
{accountsError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<span>Could not load cash/bank accounts from the ledger: {accountsError}</span>
<Button type="button" size="sm" variant="outline" onClick={() => setAccounts(null)}>
Retry
</Button>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.from}>
<FieldLabel htmlFor="bd-from" required>From cash account</FieldLabel>
<Select<number | null> value={fromAccountId} onValueChange={(v) => setFromAccountId(v)}>
<SelectTrigger id="bd-from" className="h-11! w-full text-base" aria-invalid={!!errors.from}>
<SelectValue placeholder={accounts === null ? "Loading…" : "Select cash account"} />
</SelectTrigger>
<SelectContent>
{cashAccounts.map((a) => (
<SelectItem key={a.accountId} value={a.accountId} className="text-base">
{a.accountName} <span className="text-muted-foreground">({a.glAccountCode})</span>
</SelectItem>
))}
</SelectContent>
</Select>
{accounts !== null && cashAccounts.length === 0 && !accountsError && (
<FieldDescription>No cash accounts in the ledger — add one under Accounts → Cash / Bank Accounts.</FieldDescription>
)}
<FieldError errors={[errors.from ? { message: errors.from } : undefined]} />
</Field>
<Field data-invalid={!!errors.to}>
<FieldLabel htmlFor="bd-to" required>To bank account</FieldLabel>
<Select<number | null> value={toAccountId} onValueChange={(v) => setToAccountId(v)}>
<SelectTrigger id="bd-to" className="h-11! w-full text-base" aria-invalid={!!errors.to}>
<SelectValue placeholder={accounts === null ? "Loading…" : "Select bank account"} />
</SelectTrigger>
<SelectContent>
{bankAccounts.map((a) => (
<SelectItem key={a.accountId} value={a.accountId} className="text-base">
{a.accountName}
<span className="text-muted-foreground">
{" "}
{[a.bankName, maskAccountNumber(a.accountNumber)].filter(Boolean).join(" · ")}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{accounts !== null && bankAccounts.length === 0 && !accountsError && (
<FieldDescription>No bank accounts in the ledger — add one under Accounts → Cash / Bank Accounts.</FieldDescription>
)}
<FieldError errors={[errors.to ? { message: errors.to } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="bd-amount" required>Amount</FieldLabel>
<Input
id="bd-amount"
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={amount}
onChange={(e) => {
setAmountTouched(true)
setAmount(e.target.value)
}}
aria-invalid={!!errors.amount}
className="text-base tabular-nums"
/>
<FieldDescription>
Defaults to everything available.
{amountTouched && position && (
<>
{" "}
<button type="button" className="font-medium text-primary hover:underline" onClick={() => setAmountTouched(false)}>
Use {formatAmount(available)}
</button>
</>
)}
</FieldDescription>
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="bd-slip">Deposit slip no. (optional)</FieldLabel>
<Input id="bd-slip" value={depositSlipNo} maxLength={50} onChange={(e) => setDepositSlipNo(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="bd-by">Deposited by (optional)</FieldLabel>
<Input id="bd-by" value={depositedBy} maxLength={150} onChange={(e) => setDepositedBy(e.target.value)} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="bd-remarks">Remarks (optional)</FieldLabel>
<Textarea id="bd-remarks" value={remarks} maxLength={500} rows={2} onChange={(e) => setRemarks(e.target.value)} />
</Field>
</FieldGroup>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Link href="/dashboard/accounts/bank-deposits" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" onClick={submit} disabled={saving || needsBranch}>
{saving ? "Recording…" : "Record deposit"}
</Button>
</div>
</div>
<CashPositionPanel position={position} error={positionError} needsBranch={needsBranch} />
</div>
</div>
)
}
function CashPositionPanel({ position, error, needsBranch }: { position: CashInHand | null; error: string | null; needsBranch: boolean }) {
return (
<aside className="flex h-fit flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h2 className="text-base font-semibold text-foreground">Cash position</h2>
{needsBranch ? (
<p className="text-sm text-muted-foreground">Select a branch to see its cash in hand.</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : !position ? (
<Skeleton className="h-48 w-full" />
) : (
<>
<p className="text-sm text-muted-foreground">{formatReportDate(position.position.date)}</p>
<dl className="flex flex-col gap-2 text-sm">
<Row label="Opening cash in hand" value={position.position.opening} />
<Row label="Cash in" value={position.position.cashIn} />
<Row label="Cash out (expenses)" value={-position.position.cashOut} />
<Row label="Already deposited" value={-position.position.deposited} />
<div className="border-t pt-2">
<Row label="Cash in hand" value={position.position.closing} strong />
</div>
</dl>
<div className="rounded-xl bg-primary/10 p-3">
<p className="text-sm text-muted-foreground">Available to deposit</p>
<p className="text-2xl font-bold tabular-nums text-primary">{formatAmount(position.availableToDeposit)}</p>
{position.availableToDeposit < position.position.closing && (
<p className="mt-1 text-xs text-muted-foreground">Lower than the day’s cash in hand: later days have already banked part of it.</p>
)}
</div>
{position.trackingStartDate && (
<p className="text-xs text-muted-foreground">Cash in hand is counted from {formatReportDate(position.trackingStartDate)}.</p>
)}
</>
)}
</aside>
)
}
function Row({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
return (
<div className="flex items-center justify-between gap-3">
<dt className={cn(strong ? "font-semibold text-foreground" : "text-muted-foreground")}>{label}</dt>
<dd className={cn("tabular-nums", strong ? "font-bold text-foreground" : "font-medium text-foreground")}>{formatAmount(value)}</dd>
</div>
)
}
@@ -0,0 +1,545 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { CalendarDays, Coins, Download, FileText, Landmark, PiggyBank, Plus, Wallet } from "lucide-react"
import { bankDepositsApi } from "@/lib/api/bank-deposits"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { downloadCsv } from "@/lib/csv-export"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
import {
BankDeposit,
BankDepositListFilterParams,
BankDepositStatus,
CashInHand,
CashInHandDaily,
maskAccountNumber,
} from "@/types/bank-deposits"
import { useAuth } from "@/components/auth/AuthProvider"
import { BankDepositStatusBadge } from "@/components/accounts/bank-deposit-badges"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { DateRangePicker } from "@/components/ui/date-picker"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type View = "deposits" | "cash"
type StatusFilter = BankDepositStatus | "All"
type DateRange = "Month" | "Today" | "7d" | "30d" | "All" | "Custom"
type CustomRange = { from: Date | undefined; to?: Date | undefined }
const statusOptions: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "Posted", label: "Posted" },
{ value: "Cancelled", label: "Cancelled" },
]
const dateRanges: { value: DateRange; label: string }[] = [
{ value: "Month", label: "This month" },
{ value: "Today", label: "Today" },
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
{ value: "All", label: "All time" },
{ value: "Custom", label: "Custom range" },
]
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** The cash-in-hand endpoint serves at most 92 days. */
const MAX_CASH_DAYS = 92
function toIsoDate(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
function addDays(iso: string, days: number) {
const d = new Date(`${iso}T00:00:00`)
d.setDate(d.getDate() + days)
return toIsoDate(d)
}
/** Inclusive business-date bounds ending today, as on the Expenses list. */
function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } {
const today = new Date()
if (range === "Custom") {
if (!custom?.from) return {}
return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) }
}
if (range === "All") return {}
if (range === "Month") return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
const days = range === "Today" ? 1 : range === "7d" ? 7 : 30
const from = new Date(today)
from.setDate(today.getDate() - (days - 1))
return { from: toIsoDate(from), to: toIsoDate(today) }
}
/** The cash view needs a bounded window: the filter's, else the last 30 days; never over 92 days (keeps the end). */
function cashWindow(from: string | undefined, to: string | undefined): { from: string; to: string } {
const end = to ?? toIsoDate(new Date())
let start = from ?? addDays(end, -29)
if (addDays(start, MAX_CASH_DAYS - 1) < end) start = addDays(end, -(MAX_CASH_DAYS - 1))
return { from: start, to: end }
}
function bankLabel(d: { toBankAccountName: string; toBankName: string | null; toAccountNumber: string | null }) {
return [d.toBankName, maskAccountNumber(d.toAccountNumber)].filter(Boolean).join(" · ")
}
export default function BankDepositsPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [view, setView] = useState<View>("deposits")
const [rows, setRows] = useState<BankDeposit[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [totalAmount, setTotalAmount] = useState(0)
const [error, setError] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[]>([])
const [todayCash, setTodayCash] = useState<CashInHand | null>(null)
const [daily, setDaily] = useState<CashInHandDaily | null>(null)
const [dailyError, setDailyError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [bankAccountId, setBankAccountId] = useState<number | null>(null)
const [dateRange, setDateRange] = useState<DateRange>("Month")
const [customRange, setCustomRange] = useState<CustomRange | undefined>(undefined)
const { from, to } = dateRangeBounds(dateRange, customRange)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
const [exporting, setExporting] = useState(false)
// Admins bypass branch scoping server-side, so the header's branch switcher is sent as the filter
// (the totals come from the server, so filtering client-side would leave them wrong).
const branchId = isAdmin && branchingEnabled ? activeBranchId : null
const showBranch = isAdmin && branchingEnabled && !activeBranchId
useEffect(() => {
bankAccountsApi.list(CashBankAccountType.Bank).then(setBankAccounts).catch(() => setBankAccounts([]))
}, [])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
const filters: BankDepositListFilterParams = useMemo(
() => ({
q: query || undefined,
status: status === "All" ? undefined : status,
bankAccountId: bankAccountId ?? undefined,
branchId: branchId ?? undefined,
from,
to,
}),
[query, status, bankAccountId, branchId, from, to]
)
const pagingKey = `${JSON.stringify(filters)}|${rowsPerPage}`
const [paging, setPaging] = useState({ key: "", page: 1, pageSize: rowsPerPage })
const { page, pageSize } = paging.key === pagingKey ? paging : { page: 1, pageSize: rowsPerPage }
const setPage = (p: number) => setPaging({ key: pagingKey, page: p, pageSize })
useEffect(() => {
bankDepositsApi
.list({ ...filters, page, pageSize })
.then((res) => {
setError(null)
setRows(res.items)
setPagination(res.pagination)
setTotalAmount(res.totalAmount)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoadingMore(false))
}, [filters, page, pageSize])
useEffect(() => {
bankDepositsApi.cashInHand({ branchId }).then(setTodayCash).catch(() => setTodayCash(null))
}, [branchId])
const cashRange = cashWindow(from, to)
useEffect(() => {
if (view !== "cash") return
bankDepositsApi
.cashInHandDaily({ from: cashRange.from, to: cashRange.to, branchId })
.then((res) => {
setDaily(res)
setDailyError(null)
})
.catch((err) => setDailyError(errorMessage(err)))
}, [view, cashRange.from, cashRange.to, branchId])
function openDeposit(row: BankDeposit) {
router.push(`/dashboard/accounts/bank-deposits/${row.bankDepositId}`)
}
function handleShowMore() {
setLoadingMore(true)
setPaging({ key: pagingKey, page: 1, pageSize: pageSize + rowsPerPage })
}
async function handleExport() {
setExporting(true)
try {
const all: BankDeposit[] = []
for (let p = 1; ; p++) {
const res = await bankDepositsApi.list({ ...filters, page: p, pageSize: 200 })
all.push(...res.items)
if (p >= res.pagination.totalPages) break
}
downloadCsv(`bank-deposits-${from ?? "all"}-to-${to ?? "all"}.csv`, all, [
{ header: "Doc no", value: (r) => r.docNo },
{ header: "Business date", value: (r) => r.businessDate },
{ header: "Deposit date", value: (r) => r.depositDate },
{ header: "Branch", value: (r) => r.branchName },
{ header: "From cash account", value: (r) => r.fromCashAccountName },
{ header: "Bank account", value: (r) => r.toBankAccountName },
{ header: "Bank", value: (r) => r.toBankName },
{ header: "Account no", value: (r) => r.toAccountNumber },
{ header: "Slip no", value: (r) => r.depositSlipNo },
{ header: "Deposited by", value: (r) => r.depositedBy },
{ header: "Amount", value: (r) => r.amount.toFixed(2) },
{ header: "Status", value: (r) => r.status },
{ header: "GL journal", value: (r) => r.glJournalNo },
{ header: "Reversal journal", value: (r) => r.glReversalJournalNo },
])
} catch (err) {
toast.error("Could not export deposits", errorMessage(err))
} finally {
setExporting(false)
}
}
const advancedActive = bankAccountId !== null
const hasFilters = status !== "All" || advancedActive || query.length > 0 || dateRange !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Bank Deposits</h1>
<p className="text-base text-muted-foreground">
Till cash taken to the bank after the day ends. Only cash is deposited — card, transfer and cheque receipts never sit in the till.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button variant="outline" size="lg" onClick={handleExport} disabled={exporting} className="w-full sm:w-auto">
<Download className="size-5" />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
<Link href="/dashboard/accounts/bank-deposits/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Deposit
</Link>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Deposited (filtered)" value={formatAmount(totalAmount)} icon={Landmark} />
{todayCash ? (
<>
<StatCard label="Cash in hand now" value={formatAmount(todayCash.position.closing)} icon={Wallet} />
<StatCard label="Net cash today" value={formatAmount(todayCash.position.netCash)} icon={Coins} />
</>
) : (
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
)}
</div>
<div className="inline-flex w-fit rounded-lg border bg-muted p-1" role="tablist">
{(
[
["deposits", "Deposits"],
["cash", "Cash in hand by day"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={view === value}
onClick={() => setView(value)}
className={cn(
"rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
view === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
))}
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<FilterBar
className="border-b px-4 py-4"
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Filter by doc no., slip no. or depositor"
advancedActive={advancedActive}
onClearAdvanced={() => setBankAccountId(null)}
advanced={
<Field>
<FieldLabel htmlFor="bd-filter-bank">Bank account</FieldLabel>
<Select<number | null> value={bankAccountId} onValueChange={(v) => setBankAccountId(v)}>
<SelectTrigger id="bd-filter-bank"><SelectValue placeholder="All bank accounts" /></SelectTrigger>
<SelectContent>
<SelectItem value={null}>All bank accounts</SelectItem>
{bankAccounts.map((a) => (
<SelectItem key={a.accountId} value={a.accountId}>
{a.accountName}
{a.accountNumber ? ` · ${maskAccountNumber(a.accountNumber)}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Select<DateRange> value={dateRange} onValueChange={(v) => setDateRange(v ?? "Month")}>
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Business date range">
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{dateRanges.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
</SelectContent>
</Select>
{dateRange === "Custom" && (
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
)}
</div>
</FilterBar>
{view === "cash" ? (
<CashByDay daily={daily} error={dailyError} />
) : (
<>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<PiggyBank className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No deposits match your filters." : "No deposits yet."}</p>
</div>
)}
{!error && rows !== null && rows.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
{rows.map((row) => (
<div
key={row.bankDepositId}
role="link"
tabIndex={0}
onClick={() => openDeposit(row)}
onKeyDown={(e) => {
if (e.key === "Enter") openDeposit(row)
}}
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">{row.docNo}</p>
<p className="truncate text-sm text-muted-foreground">{row.toBankAccountName}</p>
<p className="truncate text-sm text-muted-foreground">{bankLabel(row)}</p>
</div>
<BankDepositStatusBadge status={row.status} />
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Business date</p>
<p className="font-medium text-foreground">{formatReportDate(row.businessDate)}</p>
</div>
<div>
<p className="text-muted-foreground">Amount</p>
<p className="font-semibold text-foreground tabular-nums">{formatAmount(row.amount)}</p>
</div>
<div>
<p className="text-muted-foreground">Deposited on</p>
<p className="font-medium text-foreground">{formatReportDate(row.depositDate)}</p>
</div>
<div>
<p className="text-muted-foreground">Slip no.</p>
<p className="font-medium text-foreground">{row.depositSlipNo ?? "—"}</p>
</div>
</div>
</div>
))}
<div className="flex items-center justify-between rounded-xl border bg-muted/40 px-4 py-3 text-sm">
<span className="text-muted-foreground">Total ({pagination?.totalItems ?? rows.length})</span>
<span className="font-semibold tabular-nums text-foreground">{formatAmount(totalAmount)}</span>
</div>
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Deposit</TableHead>
<TableHead className="h-12 px-3 text-sm">Business date</TableHead>
<TableHead className="h-12 px-3 text-sm">Deposited on</TableHead>
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
<TableHead className="h-12 px-3 text-sm">Slip no.</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm"><span className="sr-only">Open</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.bankDepositId} className="cursor-pointer" onClick={() => openDeposit(row)}>
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<PiggyBank className="size-4 text-primary" />
</div>
<div>
<p>{row.docNo}</p>
{row.depositedBy && <p className="text-xs font-normal text-muted-foreground">By {row.depositedBy}</p>}
</div>
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(row.businessDate)}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(row.depositDate)}</TableCell>
{showBranch && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{row.branchName ?? `#${row.branchId}`}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5">
<p>{row.toBankAccountName}</p>
<p className="text-xs text-muted-foreground">{bankLabel(row)}</p>
</TableCell>
<TableCell className="px-3 py-3.5">{row.depositSlipNo ?? "—"}</TableCell>
<TableCell className={cn("px-3 py-3.5 text-right font-semibold tabular-nums", row.status === "Cancelled" && "text-muted-foreground line-through")}>
{formatAmount(row.amount)}
</TableCell>
<TableCell className="px-3 py-3.5"><BankDepositStatusBadge status={row.status} /></TableCell>
<TableCell className="px-3 py-3.5">
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Open ${row.docNo}`} title="Open">
<FileText className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={showBranch ? 6 : 5} className="px-3 py-3 text-sm text-muted-foreground">
Total of {pagination?.totalItems ?? rows.length} deposit{(pagination?.totalItems ?? rows.length) === 1 ? "" : "s"}
{status === "All" ? " (cancelled ones included)" : ""}
</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalAmount)}</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableFooter>
</Table>
</div>
{pagination && (
<div className="hidden px-4 pb-4 md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={setRowsPerPage} />
</div>
)}
</>
)}
</>
)}
</div>
</div>
)
}
/** Day-wise till cash: Opening · Cash in · Cash out · Deposited · Closing, newest day first. */
function CashByDay({ daily, error }: { daily: CashInHandDaily | null; error: string | null }) {
if (error) return <div className="mx-4 my-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!daily) {
return (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
)
}
const days = [...daily.days].reverse()
return (
<div className="flex flex-col gap-2 px-4 py-4">
<p className="text-sm text-muted-foreground">
{formatReportDate(daily.from)} – {formatReportDate(daily.to)} (at most {MAX_CASH_DAYS} days)
{daily.trackingStartDate ? ` · cash in hand counted from ${formatReportDate(daily.trackingStartDate)}` : ""}
</p>
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Opening</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Cash in</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Cash out</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Deposited</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{days.map((d) => (
<TableRow key={d.date}>
<TableCell className="px-3 py-2.5">{formatReportDate(d.date)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.opening, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.cashIn, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.cashOut, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.deposited, true)}</TableCell>
<TableCell className={cn("px-3 py-2.5 text-right font-semibold tabular-nums", d.closing > 0 && "text-warning")}>
{formatAmount(d.closing)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
@@ -0,0 +1,263 @@
"use client"
import { useEffect, useState } from "react"
import { Lock, Pencil, Plus, Tags } from "lucide-react"
import { expenseSetupApi } from "@/lib/api/expenses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { EntityStatus } from "@/types/common"
import { ExpenseCategory, ExpenseTitle } from "@/types/expenses"
import { AddTitleDialog } from "@/components/accounts/ExpenseForm"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/** What the edit dialog is editing — a category (no GL code) or a title. */
type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null
export default function ExpenseSetupPage() {
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [addTitleOpen, setAddTitleOpen] = useState(false)
const [editing, setEditing] = useState<Editing>(null)
function load() {
expenseSetupApi
.list(true)
.then((rows) => {
setCategories(rows)
setSelectedId((prev) => prev ?? rows[0]?.expenseCategoryId ?? null)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Expense Setup</h1>
<p className="text-base text-muted-foreground">
The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.
</p>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && categories === null && <Skeleton className="h-80 w-full" />}
{categories && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]">
<nav className="flex gap-2 overflow-x-auto lg:flex-col" aria-label="Expense categories">
{categories.map((c) => (
<button
key={c.expenseCategoryId}
onClick={() => setSelectedId(c.expenseCategoryId)}
className={cn(
"flex shrink-0 items-center justify-between gap-2 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground",
c.status === "Inactive" && "opacity-60"
)}
>
<span className="font-medium">{c.name}</span>
<span className="text-xs tabular-nums">{c.titles.length}</span>
</button>
))}
</nav>
{selected && (
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Tags className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">{selected.name}</h2>
<span className="text-xs text-muted-foreground">{selected.code}</span>
{selected.isSystem && <SystemBadge />}
{selected.status === "Inactive" && <InactiveBadge />}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setEditing({ kind: "category", row: selected })}>
<Pencil className="size-4" />
Edit category
</Button>
<Button size="sm" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-4" />
Add title
</Button>
</div>
</div>
{selected.code === "SALARIES" && (
<p className="mx-4 mt-4 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
Payroll runs in HRM don&apos;t post to the ledger yet. If they start to, record salaries there only, or they will be counted twice.
</p>
)}
{selected.titles.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p>
) : (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-4 text-sm">Title</TableHead>
<TableHead className="h-11 px-4 text-sm">GL expense account</TableHead>
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
<TableHead className="h-11 px-4 text-right text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selected.titles.map((t) => (
<TableRow key={t.expenseTitleId} className={cn(t.status === "Inactive" && "opacity-60")}>
<TableCell className="px-4 py-3">
<span className="inline-flex items-center gap-2">
{t.name}
{t.isSystem && <Lock className="size-3.5 text-muted-foreground" aria-label="Built-in" />}
</span>
</TableCell>
<TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell>
<TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
<TableCell className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setEditing({ kind: "title", row: t })}>
<Pencil className="size-4" />
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
)}
{selected && (
<AddTitleDialog
open={addTitleOpen}
onOpenChange={setAddTitleOpen}
category={selected}
onCreated={(title) =>
setCategories((prev) =>
prev?.map((c) => (c.expenseCategoryId === title.expenseCategoryId ? { ...c, titles: [...c.titles, title] } : c)) ?? prev
)
}
/>
)}
<EditDialog editing={editing} onClose={() => setEditing(null)} onSaved={load} />
</div>
)
}
function SystemBadge() {
return (
<Badge variant="outline" className="h-5 gap-1 border-transparent bg-muted px-2 text-xs text-muted-foreground">
<Lock className="size-3" />
Built-in
</Badge>
)
}
function InactiveBadge() {
return (
<Badge variant="outline" className="h-5 border-transparent bg-muted px-2 text-xs text-muted-foreground">
Inactive
</Badge>
)
}
function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) {
const [name, setName] = useState("")
const [glCode, setGlCode] = useState("")
const [active, setActive] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (key !== resetFor) {
setResetFor(key)
if (editing) {
setName(editing.row.name)
setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "")
setActive(editing.row.status === "Active")
setError(null)
}
}
if (!editing) return null
const isSystem = editing.row.isSystem
async function submit() {
if (!editing) return
if (!name.trim()) return setError("Enter a name")
if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code")
const status: EntityStatus = active ? "Active" : "Inactive"
setSaving(true)
try {
if (editing.kind === "category") {
await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status })
} else {
await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
}
toast.success("Saved", name.trim())
onSaved()
onClose()
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit {editing.kind === "category" ? "category" : "title"}</DialogTitle>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="edit-name" required>Name</FieldLabel>
<Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} />
{isSystem && <FieldDescription>Built-in names can&apos;t be changed.</FieldDescription>}
</Field>
{editing.kind === "title" && (
<Field>
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
</Field>
)}
<Field orientation="horizontal">
<Switch id="edit-active" checked={active} onCheckedChange={setActive} />
<FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel>
</Field>
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,57 @@
"use client"
import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { expensesApi } from "@/lib/api/expenses"
import { errorMessage } from "@/lib/error-map"
import { Expense } from "@/types/expenses"
import { ExpenseForm } from "@/components/accounts/ExpenseForm"
import { Skeleton } from "@/components/ui/skeleton"
export default function EditExpensePage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter()
const { id } = use(params)
const expenseId = Number(id)
const [loaded, setLoaded] = useState<{ expense: Expense; etag: string | null } | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(expenseId) ? loadError : `Invalid expense id '${id}'.`
useEffect(() => {
if (!Number.isFinite(expenseId)) return
expensesApi
.get(expenseId)
.then((res) => {
if (res.data.status !== "Draft") {
// Only drafts are editable — send anything else back to its detail page.
router.replace(`/dashboard/accounts/expenses/${expenseId}`)
return
}
setLoaded({ expense: res.data, etag: res.etag })
})
.catch((err) => setLoadError(errorMessage(err)))
}, [expenseId, id, router])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Edit Expense {loaded?.expense.docNo ?? ""}</h1>
<p className="text-base text-muted-foreground">Drafts stay editable until they are posted.</p>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && !loaded && <Skeleton className="h-96 w-full" />}
{loaded && (
<ExpenseForm
existing={loaded}
cancelHref={`/dashboard/accounts/expenses/${expenseId}`}
onSaved={(expense, action) =>
router.push(`/dashboard/accounts/expenses/${expense.expenseId}${action === "pay" ? "?pay=1" : ""}`)
}
/>
)}
</div>
)
}
@@ -0,0 +1,334 @@
"use client"
import { use, useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Ban, CheckCircle2, CircleDollarSign, Pencil, Trash2 } from "lucide-react"
import { expensesApi } from "@/lib/api/expenses"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { Expense, expensePaymentMethodLabels } from "@/types/expenses"
import { ExpensePaymentDialog } from "@/components/accounts/ExpensePaymentDialog"
import { ExpensePaymentStatusBadge, ExpenseStatusBadge, isExpenseOverdue } from "@/components/accounts/expense-badges"
import { localToday } from "@/components/sales/PaymentLinesEditor"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
export default function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter()
const { id } = use(params)
const expenseId = Number(id)
const [expense, setExpense] = useState<Expense | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(expenseId) ? loadError : `Invalid expense id '${id}'.`
const [busy, setBusy] = useState<"post" | "delete" | "cancel" | null>(null)
const [payOpen, setPayOpen] = useState(false)
const [cancelOpen, setCancelOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [cancelReason, setCancelReason] = useState("")
const [cancelError, setCancelError] = useState<string | null>(null)
const load = useCallback(() => {
if (!Number.isFinite(expenseId)) return
expensesApi
.get(expenseId)
.then((res) => {
setExpense(res.data)
if (consumePayFlag() && res.data.status !== "Cancelled" && res.data.balanceAmount > 0) setPayOpen(true)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [expenseId])
useEffect(load, [load])
async function handlePost() {
if (!expense) return
setBusy("post")
try {
const posted = await expensesApi.post(expense.expenseId)
setExpense(posted)
toast.success("Expense posted", `Journal ${posted.glJournalNo ?? "—"}`)
} catch (err) {
toast.error("Could not post expense", errorMessage(err))
} finally {
setBusy(null)
}
}
async function handleDelete() {
if (!expense) return
setBusy("delete")
try {
await expensesApi.remove(expense.expenseId)
toast.success("Draft deleted", expense.docNo)
router.push("/dashboard/accounts/expenses")
} catch (err) {
toast.error("Could not delete expense", errorMessage(err))
setBusy(null)
}
}
async function handleCancel() {
if (!expense) return
if (!cancelReason.trim()) {
setCancelError("Enter a reason")
return
}
setBusy("cancel")
try {
const cancelled = await expensesApi.cancel(expense.expenseId, cancelReason.trim())
setExpense(cancelled)
setCancelOpen(false)
toast.success(
"Expense cancelled",
cancelled.glReversalJournalNo ? `Reversing journal ${cancelled.glReversalJournalNo}` : cancelled.docNo
)
} catch (err) {
setCancelError(errorMessage(err))
} finally {
setBusy(null)
}
}
if (error) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
</div>
)
}
if (!expense) return <Skeleton className="h-96 w-full" />
const overdue = isExpenseOverdue(expense, localToday())
const canPay = expense.status !== "Cancelled" && expense.balanceAmount > 0
const canCancel = expense.status !== "Cancelled" && expense.paidAmount === 0
return (
<div className="flex flex-col gap-6">
<BackLink />
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{expense.docNo}</h1>
<ExpenseStatusBadge status={expense.status} />
{expense.status === "Posted" && <ExpensePaymentStatusBadge status={expense.paymentStatus} />}
</div>
<p className="text-base text-muted-foreground">
{expense.categoryName} · {expense.titleName}
{expense.payee ? ` — ${expense.payee}` : ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
{expense.status === "Draft" && (
<>
<Link href={`/dashboard/accounts/expenses/${expense.expenseId}/edit`} className={cn(buttonVariants({ variant: "outline" }))}>
<Pencil className="size-4" />
Edit
</Link>
<Button variant="outline" onClick={() => setDeleteOpen(true)} disabled={busy !== null}>
<Trash2 className="size-4" />
Delete
</Button>
<Button variant="outline" onClick={handlePost} disabled={busy !== null}>
<CheckCircle2 className="size-4" />
{busy === "post" ? "Posting…" : "Post"}
</Button>
</>
)}
{expense.status === "Posted" && canCancel && (
<Button
variant="outline"
onClick={() => {
setCancelReason("")
setCancelError(null)
setCancelOpen(true)
}}
disabled={busy !== null}
>
<Ban className="size-4" />
Cancel expense
</Button>
)}
{canPay && (
<Button onClick={() => setPayOpen(true)} disabled={busy !== null}>
<CircleDollarSign className="size-4" />
{expense.status === "Draft" ? "Post & Pay" : "Pay"}
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Amount label="Amount" value={expense.amount} />
<Amount label="Paid" value={expense.paidAmount} />
<Amount label="Balance" value={expense.balanceAmount} tone={overdue ? "danger" : undefined} hint={overdue ? "Overdue" : undefined} />
</div>
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-3">
<Detail label="Bill date" value={formatReportDate(expense.expenseDate)} />
<Detail label="Due date" value={expense.dueDate ? formatReportDate(expense.dueDate) : "—"} danger={overdue} />
<Detail label="Period covered" value={expense.periodMonth ?? "—"} />
<Detail label="Category" value={expense.categoryName} />
<Detail label="Title" value={expense.titleName} />
<Detail label="Payee" value={expense.payee ?? "—"} />
<Detail label="Vendor" value={expense.vendorName ?? "—"} />
<Detail label="Bill / account ref" value={expense.billReference ?? "—"} />
<Detail label="Created" value={formatReportDate(expense.createdAt)} />
{expense.status !== "Draft" && (
<>
<Detail label="GL expense account" value={expense.glExpenseAccountCode ?? "—"} />
<Detail label="Accrual journal" value={expense.glJournalNo ?? "—"} />
<Detail label="Posted" value={formatReportDate(expense.glPostedAt)} />
</>
)}
{expense.status === "Cancelled" && (
<>
<Detail label="Reversal journal" value={expense.glReversalJournalNo ?? "— (cancelled as draft)"} />
<Detail label="Cancel reason" value={expense.cancelReason ?? "—"} />
</>
)}
{expense.description && (
<div className="sm:col-span-2 lg:col-span-3">
<dt className="text-muted-foreground">Description</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-foreground">{expense.description}</dd>
</div>
)}
</dl>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="border-b px-4 py-3">
<h2 className="text-base font-semibold text-foreground">Payments</h2>
</div>
{expense.payments.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">No payments recorded yet.</p>
) : (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-4 text-sm">Date</TableHead>
<TableHead className="h-11 px-4 text-sm">Method</TableHead>
<TableHead className="h-11 px-4 text-sm">Account</TableHead>
<TableHead className="h-11 px-4 text-sm">Cheque / ref</TableHead>
<TableHead className="h-11 px-4 text-sm">Journal</TableHead>
<TableHead className="h-11 px-4 text-right text-sm">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expense.payments.map((p) => (
<TableRow key={p.expensePaymentId}>
<TableCell className="px-4 py-3">{formatReportDate(p.paymentDate)}</TableCell>
<TableCell className="px-4 py-3">{expensePaymentMethodLabels[p.paymentMethod]}</TableCell>
<TableCell className="px-4 py-3">{p.bankAccountName}</TableCell>
<TableCell className="px-4 py-3">{[p.chequeNo, p.reference].filter(Boolean).join(" · ") || "—"}</TableCell>
<TableCell className="px-4 py-3">{p.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(p.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<ExpensePaymentDialog
expense={expense}
open={payOpen}
onOpenChange={setPayOpen}
onDone={(updated) => (updated ? setExpense(updated) : load())}
/>
<Dialog open={cancelOpen} onOpenChange={setCancelOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cancel expense {expense.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
A reversing journal entry will be posted to the ledger. This cannot be undone.
</p>
<Field data-invalid={!!cancelError}>
<FieldLabel htmlFor="exp-cancel-reason" required>Reason</FieldLabel>
<Textarea id="exp-cancel-reason" value={cancelReason} maxLength={250} rows={3} onChange={(e) => setCancelReason(e.target.value)} />
<FieldError errors={[cancelError ? { message: cancelError } : undefined]} />
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={busy === "cancel"}>
Keep it
</Button>
<Button variant="destructive" onClick={handleCancel} disabled={busy === "cancel"}>
{busy === "cancel" ? "Cancelling…" : "Cancel expense"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete draft {expense.docNo}?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">The draft has not been posted, so nothing reaches the ledger. This cannot be undone.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={busy === "delete"}>
Keep it
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy === "delete"}>
{busy === "delete" ? "Deleting…" : "Delete draft"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** "Save & pay" on the form lands here with ?pay=1 — true once, then the flag is dropped from the URL. */
function consumePayFlag(): boolean {
const url = new URL(window.location.href)
if (url.searchParams.get("pay") !== "1") return false
url.searchParams.delete("pay")
window.history.replaceState(null, "", url.toString())
return true
}
function BackLink() {
return (
<Link href="/dashboard/accounts/expenses" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" />
Expenses
</Link>
)
}
function Amount({ label, value, tone, hint }: { label: string; value: number; tone?: "danger"; hint?: string }) {
return (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-xl font-bold tabular-nums", tone === "danger" ? "text-destructive" : "text-foreground")}>{formatAmount(value)}</p>
{hint && <p className="text-xs font-medium text-destructive">{hint}</p>}
</div>
)
}
function Detail({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
return (
<div>
<dt className="text-muted-foreground">{label}</dt>
<dd className={cn("mt-0.5 font-medium", danger ? "text-destructive" : "text-foreground")}>{value}</dd>
</div>
)
}
@@ -0,0 +1,27 @@
"use client"
import { useRouter } from "next/navigation"
import { ExpenseForm } from "@/components/accounts/ExpenseForm"
export default function NewExpensePage() {
const router = useRouter()
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">New Expense</h1>
<p className="text-base text-muted-foreground">
Record a bill — pick its category and title, then save it as a draft, post it, or post and pay it in one step.
</p>
</div>
<ExpenseForm
cancelHref="/dashboard/accounts/expenses"
onSaved={(expense, action) =>
router.push(`/dashboard/accounts/expenses/${expense.expenseId}${action === "pay" ? "?pay=1" : ""}`)
}
/>
</div>
)
}
@@ -0,0 +1,637 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
CalendarDays,
CheckCircle2,
CircleDollarSign,
Download,
FileText,
Loader2,
Plus,
Receipt,
Wallet,
} from "lucide-react"
import { expenseSetupApi, expensesApi } from "@/lib/api/expenses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { downloadCsv } from "@/lib/csv-export"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm"
import {
ExpenseCategory,
ExpenseListFilterParams,
ExpensePaymentStatus,
ExpenseStatus,
ExpenseSummary,
ExpenseSummaryReport,
} from "@/types/expenses"
import { useAuth } from "@/components/auth/AuthProvider"
import { ExpensePaymentDialog } from "@/components/accounts/ExpensePaymentDialog"
import { ExpensePaymentStatusBadge, ExpenseStatusBadge, isExpenseOverdue } from "@/components/accounts/expense-badges"
import { localToday } from "@/components/sales/PaymentLinesEditor"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { DateRangePicker } from "@/components/ui/date-picker"
import { Field, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type StatusFilter = ExpenseStatus | "All"
type PaymentFilter = ExpensePaymentStatus | "All" | "Overdue"
type SortKey = "docNo" | "date" | "title" | "payee" | "due" | "amount" | "balance"
type SortOrder = "asc" | "desc"
type DateRange = "Month" | "Today" | "7d" | "30d" | "All" | "Custom"
type CustomRange = { from: Date | undefined; to?: Date | undefined }
const statusOptions: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "Draft", label: "Draft" },
{ value: "Posted", label: "Posted" },
{ value: "Cancelled", label: "Cancelled" },
]
const paymentOptions: { value: PaymentFilter; label: string }[] = [
{ value: "All", label: "Any payment status" },
{ value: "Unpaid", label: "Unpaid" },
{ value: "PartiallyPaid", label: "Partially paid" },
{ value: "Paid", label: "Paid" },
{ value: "Overdue", label: "Overdue" },
]
const dateRanges: { value: DateRange; label: string }[] = [
{ value: "Month", label: "This month" },
{ value: "Today", label: "Today" },
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
{ value: "All", label: "All time" },
{ value: "Custom", label: "Custom range" },
]
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// "All time" still needs a lower bound for the period summary (the API defaults to this month).
const ALL_TIME_FROM = "2000-01-01"
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
function toIsoDate(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Inclusive bounds ending today, as on the Sales list. A custom range with only a start covers that day. */
function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } {
const today = new Date()
if (range === "Custom") {
if (!custom?.from) return {}
return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) }
}
if (range === "All") return {}
if (range === "Month") return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
const days = range === "Today" ? 1 : range === "7d" ? 7 : 30
const from = new Date(today)
from.setDate(today.getDate() - (days - 1))
return { from: toIsoDate(from), to: toIsoDate(today) }
}
/** Same clickable sort header as the Sales and Items tables. */
function SortableHeader({ label, active, order, onClick, alignRight }: { label: string; active: boolean; order: SortOrder; onClick: () => void; alignRight?: boolean }) {
return (
<button type="button" className={cn("flex items-center gap-1 hover:text-foreground", alignRight && "ml-auto")} onClick={onClick}>
{label}
{active ? (
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
export default function ExpensesPage() {
const router = useRouter()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const today = localToday()
const [rows, setRows] = useState<ExpenseSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [summary, setSummary] = useState<ExpenseSummaryReport | null>(null)
const [categories, setCategories] = useState<ExpenseCategory[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const [status, setStatus] = useState<StatusFilter>("All")
const [paymentFilter, setPaymentFilter] = useState<PaymentFilter>("All")
const [categoryId, setCategoryId] = useState<number | null>(null)
const [titleId, setTitleId] = useState<number | null>(null)
const [dateRange, setDateRange] = useState<DateRange>("Month")
const [customRange, setCustomRange] = useState<CustomRange | undefined>(undefined)
const { from, to } = dateRangeBounds(dateRange, customRange)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
const [reloadKey, setReloadKey] = useState(0)
const [postingId, setPostingId] = useState<number | null>(null)
const [payExpense, setPayExpense] = useState<ExpenseSummary | null>(null)
const [payOpen, setPayOpen] = useState(false)
const [exporting, setExporting] = useState(false)
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
useEffect(() => {
expenseSetupApi.list(true).then(setCategories).catch(() => setCategories([]))
}, [])
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
const filters: ExpenseListFilterParams = useMemo(
() => ({
q: query || undefined,
status: status === "All" ? undefined : status,
paymentStatus: paymentFilter === "All" || paymentFilter === "Overdue" ? undefined : paymentFilter,
overdueOnly: paymentFilter === "Overdue" ? true : undefined,
categoryId: categoryId ?? undefined,
titleId: titleId ?? undefined,
from,
to,
}),
[query, status, paymentFilter, categoryId, titleId, from, to]
)
// Page and fetched size belong to one filter combination: any filter change (or a new rows-per-page)
// drops back to page 1. pageSize grows on mobile "Show more", like the Sales list.
const pagingKey = `${JSON.stringify(filters)}|${rowsPerPage}`
const [paging, setPaging] = useState({ key: "", page: 1, pageSize: rowsPerPage })
const { page, pageSize } = paging.key === pagingKey ? paging : { page: 1, pageSize: rowsPerPage }
const setPage = (p: number) => setPaging({ key: pagingKey, page: p, pageSize })
useEffect(() => {
expensesApi
.list({ ...filters, page, pageSize })
.then((res) => {
setError(null)
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoadingMore(false))
}, [filters, page, pageSize, reloadKey, activeBranchId])
useEffect(() => {
expensesApi
.summary({ from: from ?? ALL_TIME_FROM, to: to ?? today })
.then(setSummary)
.catch(() => setSummary(null))
}, [from, to, today, reloadKey, activeBranchId])
const titleOptions = useMemo(
() => (categoryId ? categories.find((c) => c.expenseCategoryId === categoryId)?.titles ?? [] : categories.flatMap((c) => c.titles)),
[categories, categoryId]
)
// Admins bypass branch scoping server-side — the header's branch switcher filters client-side,
// as on Purchase Invoices (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
const visibleRows = useMemo(
() => rows?.filter((row) => !(isAdmin && branchingEnabled && activeBranchId) || row.branchId === activeBranchId) ?? [],
[rows, isAdmin, branchingEnabled, activeBranchId]
)
// Desktop sorting over the loaded page, client-side; null keeps the server's order (newest first).
function toggleSort(key: SortKey) {
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
else {
setSortKey(key)
setSortOrder("asc")
}
}
const sortedRows = useMemo(() => {
if (!sortKey) return visibleRows
const value = (r: ExpenseSummary): string | number => {
switch (sortKey) {
case "docNo": return r.docNo
case "date": return r.expenseDate
case "title": return `${r.categoryName} ${r.titleName}`
case "payee": return r.payee ?? ""
case "due": return r.dueDate ?? ""
case "amount": return r.amount
case "balance": return r.balanceAmount
}
}
return [...visibleRows].sort((a, b) => {
const va = value(a), vb = value(b)
const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb))
return sortOrder === "asc" ? cmp : -cmp
})
}, [visibleRows, sortKey, sortOrder])
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
function openExpense(row: ExpenseSummary) {
router.push(`/dashboard/accounts/expenses/${row.expenseId}`)
}
function openPay(row: ExpenseSummary) {
setPayExpense(row)
setPayOpen(true)
}
async function handlePost(row: ExpenseSummary) {
setPostingId(row.expenseId)
try {
const posted = await expensesApi.post(row.expenseId)
toast.success("Expense posted", `${posted.docNo} — journal ${posted.glJournalNo ?? "—"}`)
setReloadKey((k) => k + 1)
} catch (err) {
toast.error("Could not post expense", errorMessage(err))
} finally {
setPostingId(null)
}
}
function handleShowMore() {
setLoadingMore(true)
setPaging({ key: pagingKey, page: 1, pageSize: pageSize + rowsPerPage })
}
async function handleExport() {
setExporting(true)
try {
const all: ExpenseSummary[] = []
for (let p = 1; ; p++) {
const res = await expensesApi.list({ ...filters, page: p, pageSize: 200 })
all.push(...res.items)
if (p >= res.pagination.totalPages) break
}
downloadCsv(`expenses-${from ?? "all"}-to-${to ?? "all"}.csv`, all, [
{ header: "Doc no", value: (r) => r.docNo },
{ header: "Date", value: (r) => r.expenseDate },
{ header: "Due date", value: (r) => r.dueDate },
{ header: "Category", value: (r) => r.categoryName },
{ header: "Title", value: (r) => r.titleName },
{ header: "Payee", value: (r) => r.payee },
{ header: "Bill ref", value: (r) => r.billReference },
{ header: "Period", value: (r) => r.periodMonth },
{ header: "Amount", value: (r) => r.amount.toFixed(2) },
{ header: "Paid", value: (r) => r.paidAmount.toFixed(2) },
{ header: "Balance", value: (r) => r.balanceAmount.toFixed(2) },
{ header: "Status", value: (r) => r.status },
{ header: "Payment status", value: (r) => (r.status === "Posted" ? r.paymentStatus : "") },
{ header: "GL journal", value: (r) => r.glJournalNo },
])
} catch (err) {
toast.error("Could not export expenses", errorMessage(err))
} finally {
setExporting(false)
}
}
const advancedActive = paymentFilter !== "All" || categoryId !== null || titleId !== null
const hasFilters = status !== "All" || advancedActive || query.length > 0 || dateRange !== "All"
const showBranch = isAdmin && branchingEnabled
/** Icon-only row actions, same as the Sales list: open, post (drafts), pay / post & pay. */
function rowActions(row: ExpenseSummary, variant: "ghost" | "outline", size: "icon-sm" | "icon") {
const posting = postingId === row.expenseId
return (
<>
<Button type="button" variant={variant} size={size} onClick={() => openExpense(row)} aria-label={`Open ${row.docNo}`} title="Open">
<FileText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
type="button"
variant={variant}
size={size}
disabled={posting}
onClick={() => handlePost(row)}
aria-label={`Post ${row.docNo}`}
title={posting ? "Posting…" : "Post"}
>
{posting ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{row.status !== "Cancelled" && row.balanceAmount > 0 && (
<Button
type="button"
variant={variant}
size={size}
onClick={() => openPay(row)}
aria-label={`${row.status === "Draft" ? "Post and pay" : "Pay"} ${row.docNo}`}
title={row.status === "Draft" ? "Post & Pay" : "Pay"}
>
<CircleDollarSign className="size-4" />
</Button>
)}
</>
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Expenses</h1>
<p className="text-base text-muted-foreground">Utility bills, rent, salaries and other business expenses, with payment tracking.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button variant="outline" size="lg" onClick={handleExport} disabled={exporting} className="w-full sm:w-auto">
<Download className="size-5" />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
<Link href="/dashboard/accounts/expenses/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Expense
</Link>
</div>
</div>
{/* Amounts are passed as strings so StatCard shows full 2dp values rather than its compact "1.2K" form. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{summary ? (
<>
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} />
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} />
<StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} />
<StatCard label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<FilterBar
className="border-b px-4 py-4"
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Filter by doc no., payee or bill ref"
advancedActive={advancedActive}
onClearAdvanced={() => {
setPaymentFilter("All")
setCategoryId(null)
setTitleId(null)
}}
advanced={
<>
<Field>
<FieldLabel htmlFor="exp-filter-payment">Payment status</FieldLabel>
<Select<PaymentFilter> value={paymentFilter} onValueChange={(v) => setPaymentFilter(v ?? "All")}>
<SelectTrigger id="exp-filter-payment"><SelectValue /></SelectTrigger>
<SelectContent>
{paymentOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="exp-filter-category">Category</FieldLabel>
<Select<number | null>
value={categoryId}
onValueChange={(v) => {
setCategoryId(v)
setTitleId(null)
}}
>
<SelectTrigger id="exp-filter-category"><SelectValue placeholder="All categories" /></SelectTrigger>
<SelectContent>
<SelectItem value={null}>All categories</SelectItem>
{categories.map((c) => <SelectItem key={c.expenseCategoryId} value={c.expenseCategoryId}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="exp-filter-title">Title</FieldLabel>
<Select<number | null> value={titleId} onValueChange={(v) => setTitleId(v)}>
<SelectTrigger id="exp-filter-title"><SelectValue placeholder="All titles" /></SelectTrigger>
<SelectContent>
<SelectItem value={null}>All titles</SelectItem>
{titleOptions.map((t) => <SelectItem key={t.expenseTitleId} value={t.expenseTitleId}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
</>
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Select<DateRange> value={dateRange} onValueChange={(v) => setDateRange(v ?? "Month")}>
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Date range">
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{dateRanges.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
</SelectContent>
</Select>
{dateRange === "Custom" && (
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
)}
</div>
</FilterBar>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && (
<div className="flex flex-col gap-3 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<Receipt className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No expenses match your filters." : "No expenses yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
{visibleRows.map((row) => (
<div
key={row.expenseId}
role="link"
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openExpense(row)
}}
onKeyDown={(e) => {
if (e.key === "Enter") openExpense(row)
}}
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">{row.docNo}</p>
<p className="truncate text-sm text-muted-foreground">
{row.categoryName} · {row.titleName}
</p>
{row.payee && <p className="truncate text-sm text-muted-foreground">{row.payee}</p>}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<ExpenseStatusBadge status={row.status} />
{row.status === "Posted" && <ExpensePaymentStatusBadge status={row.paymentStatus} />}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Date / Due</p>
<p className="font-medium text-foreground">
{formatReportDate(row.expenseDate)} /{" "}
<span className={cn(isExpenseOverdue(row, today) && "text-destructive")}>
{row.dueDate ? formatReportDate(row.dueDate) : "—"}
</span>
</p>
</div>
<div>
<p className="text-muted-foreground">Amount</p>
<p className="font-semibold text-foreground tabular-nums">{formatAmount(row.amount)}</p>
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="font-medium text-foreground tabular-nums">{formatAmount(row.paidAmount)}</p>
</div>
<div>
<p className="text-muted-foreground">Balance</p>
<p className="font-medium text-foreground tabular-nums">{formatAmount(row.balanceAmount)}</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
{rowActions(row, "outline", "icon")}
</div>
</div>
))}
{pagination && rows.length < pagination.totalItems && (
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={loadingMore}>
{loadingMore ? "Loading…" : "Show more"}
</Button>
)}
</div>
{/* Desktop: table. */}
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Expense" active={sortKey === "docNo"} order={sortOrder} onClick={() => toggleSort("docNo")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Category / Title" active={sortKey === "title"} order={sortOrder} onClick={() => toggleSort("title")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Payee" active={sortKey === "payee"} order={sortOrder} onClick={() => toggleSort("payee")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Amount" active={sortKey === "amount"} order={sortOrder} onClick={() => toggleSort("amount")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Balance" active={sortKey === "balance"} order={sortOrder} onClick={() => toggleSort("balance")} alignRight /></TableHead>
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedRows.map((row) => (
<TableRow
key={row.expenseId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openExpense(row)
}}
>
<TableCell className="px-3 py-3.5 font-medium">
<div className="flex items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Receipt className="size-4 text-primary" />
</div>
<div>
<Link href={`/dashboard/accounts/expenses/${row.expenseId}`} className="hover:underline" data-no-row-nav>
{row.docNo}
</Link>
{row.billReference && <p className="text-xs font-normal text-muted-foreground">Ref {row.billReference}</p>}
</div>
</div>
</TableCell>
<TableCell className="px-3 py-3.5">
<p>{row.titleName}</p>
<p className="text-xs text-muted-foreground">
{row.categoryName}
{row.periodMonth ? ` · ${row.periodMonth}` : ""}
</p>
</TableCell>
<TableCell className="px-3 py-3.5">{row.payee ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(row.expenseDate)}</TableCell>
<TableCell className={cn("px-3 py-3.5", isExpenseOverdue(row, today) && "font-semibold text-destructive")}>
{row.dueDate ? formatReportDate(row.dueDate) : "—"}
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{formatAmount(row.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(row.balanceAmount)}</TableCell>
{showBranch && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
{branchName(row.branchId)}
</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1">
<ExpenseStatusBadge status={row.status} />
{row.status === "Posted" && <ExpensePaymentStatusBadge status={row.paymentStatus} />}
</div>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
<div className="flex items-center gap-1">{rowActions(row, "ghost", "icon-sm")}</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && (
<div className="hidden px-4 pb-4 md:block">
<PaginationFooter pagination={pagination} onPageChange={setPage} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={setRowsPerPage} />
</div>
)}
</>
)}
</div>
<ExpensePaymentDialog expense={payExpense} open={payOpen} onOpenChange={setPayOpen} onDone={() => setReloadKey((k) => k + 1)} />
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { BookText, Inbox, Wallet, type LucideIcon } from "lucide-react"
import { BookText, Inbox, PiggyBank, Receipt, Tags, Wallet, type LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -22,6 +22,24 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
href: "/dashboard/accounts/received-cheques",
icon: Inbox,
},
{
title: "Expenses",
description: "Utility bills, rent, salaries and other expenses — post to the ledger and track what is paid.",
href: "/dashboard/accounts/expenses",
icon: Receipt,
},
{
title: "Expense Setup",
description: "Expense categories and titles, and the GL account each title posts to.",
href: "/dashboard/accounts/expense-setup",
icon: Tags,
},
{
title: "Bank Deposits",
description: "Till cash taken to the bank after the day ends — see the cash in hand, deposit it, and track what is left.",
href: "/dashboard/accounts/bank-deposits",
icon: PiggyBank,
},
]
export default function AccountsHubPage() {
@@ -30,7 +48,7 @@ export default function AccountsHubPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Accounts</h1>
<p className="text-base text-muted-foreground">
Cash/Bank accounts and cheque management, from the General Ledger service.
Cash/Bank accounts, cheque management and business expenses, posted to the General Ledger service.
</p>
</div>
@@ -0,0 +1,197 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { ClipboardCheck, PackageMinus, Tag } from "lucide-react"
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PendingApproval, PendingApprovals } from "@/types/approvals"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
function hrefFor(p: PendingApproval) {
return p.docType === "ADJ" ? `/dashboard/stock/adjustments/${p.docId}` : `/dashboard/products/price-adjustments/${p.docId}`
}
/** How long a request has waited — "3 h", "2 days". */
function age(iso: string) {
const hours = Math.floor((Date.now() - new Date(iso).getTime()) / 3_600_000)
if (hours < 1) return "just now"
if (hours < 24) return `${hours} h`
const days = Math.floor(hours / 24)
return `${days} day${days === 1 ? "" : "s"}`
}
/**
* The Approvals inbox (docs/DUAL_APPROVAL_PLAN.md §10): pending stock and price adjustments the
* current user can approve — actionable first, oldest first — plus their own requests waiting on others.
*/
export default function ApprovalsInboxPage() {
const [data, setData] = useState<PendingApprovals | null>(null)
const [error, setError] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rejecting, setRejecting] = useState<PendingApproval | null>(null)
const [reason, setReason] = useState("")
const [reasonError, setReasonError] = useState<string | null>(null)
const load = useCallback(() => {
approvalsApi.pending().then((d) => { setData(d); setError(null) }).catch((err) => setError(errorMessage(err)))
}, [])
useEffect(load, [load])
async function approve(p: PendingApproval) {
setBusyKey(`${p.docType}-${p.docId}`)
try {
if (p.docType === "ADJ") {
const next = await stockAdjustmentsApi.approve(p.docId)
toast.success(next.status === "Posted" ? "Approved and posted" : "Approved", next.status === "Posted" ? `${next.docNo} — stock updated.` : `${next.docNo} now needs a second approval.`)
} else {
const next = await priceAdjustmentsApi.approve(p.docId)
toast.success(next.status === "Approved" ? "Approved — prices applied" : "Approved", next.status === "Approved" ? `${next.docNo} is in effect.` : `${next.docNo} now needs a second approval.`)
}
load()
} catch (err) {
toast.error(`Could not approve ${p.docNo}`, errorMessage(err))
} finally {
setBusyKey(null)
}
}
async function reject() {
if (!rejecting) return
if (!reason.trim()) {
setReasonError("Give a reason")
return
}
setBusyKey(`${rejecting.docType}-${rejecting.docId}`)
try {
if (rejecting.docType === "ADJ") await stockAdjustmentsApi.reject(rejecting.docId, reason.trim())
else await priceAdjustmentsApi.reject(rejecting.docId, reason.trim())
toast.success("Rejected", rejecting.docNo)
setRejecting(null)
setReason("")
load()
} catch (err) {
setReasonError(errorMessage(err))
} finally {
setBusyKey(null)
}
}
const actionable = data?.items.filter((i) => i.canApprove) ?? []
const waiting = data?.items.filter((i) => !i.canApprove) ?? []
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Approvals</h1>
<p className="text-base text-muted-foreground">
Stock and price adjustments waiting for approval. Nothing takes effect until it is fully approved — by people other than the requester, and two different people when two approvals are needed.
</p>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && data === null && <Skeleton className="h-64 w-full" />}
{data && (
<>
<Section title={`Waiting for you (${actionable.length})`} empty="Nothing needs your approval right now.">
{actionable.map((p) => (
<Row key={`${p.docType}-${p.docId}`} item={p}>
<Button variant="outline" size="sm" onClick={() => { setRejecting(p); setReason(""); setReasonError(null) }} disabled={busyKey !== null}>
Reject
</Button>
<Button size="sm" onClick={() => approve(p)} disabled={busyKey !== null}>
{busyKey === `${p.docType}-${p.docId}` ? "Approving…" : p.level >= p.requiredApprovals ? "Approve & apply" : "Approve"}
</Button>
</Row>
))}
</Section>
{waiting.length > 0 && (
<Section title={`Waiting for others (${waiting.length})`} empty="">
{waiting.map((p) => (
<Row key={`${p.docType}-${p.docId}`} item={p} note={p.whyNot} />
))}
</Section>
)}
</>
)}
<Dialog open={rejecting !== null} onOpenChange={(open) => !open && setRejecting(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reject {rejecting?.docNo}</DialogTitle>
<DialogDescription>Nothing will be applied. The requester is told your reason.</DialogDescription>
</DialogHeader>
<Field data-invalid={!!reasonError}>
<FieldLabel htmlFor="inbox-reject-reason" required>Reason</FieldLabel>
<Textarea id="inbox-reject-reason" rows={3} maxLength={250} value={reason} onChange={(e) => { setReason(e.target.value); setReasonError(null) }} />
<FieldError errors={[reasonError ? { message: reasonError } : undefined]} />
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setRejecting(null)} disabled={busyKey !== null}>Keep it</Button>
<Button variant="destructive" onClick={reject} disabled={busyKey !== null}>Reject</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function Section({ title, empty, children }: { title: string; empty: string; children: React.ReactNode }) {
const hasItems = Array.isArray(children) ? children.length > 0 : !!children
return (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
{hasItems ? (
<div className="flex flex-col gap-2">{children}</div>
) : (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-12 text-center">
<ClipboardCheck className="size-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{empty}</p>
</div>
)}
</div>
)
}
function Row({ item, note, children }: { item: PendingApproval; note?: string | null; children?: React.ReactNode }) {
const Icon = item.docType === "ADJ" ? PackageMinus : Tag
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-4.5 text-primary" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Link href={hrefFor(item)} className="font-semibold text-foreground hover:underline">{item.docNo}</Link>
<Badge variant="outline" className={cn("border-transparent text-xs", item.level >= 2 ? "bg-primary/10 text-primary" : "bg-warning/10 text-warning")}>
{item.requiredApprovals === 2 ? `Approval ${item.level} of 2` : "Needs 1 approval"}
</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{item.summary}</p>
<p className="text-xs text-muted-foreground">
Requested by {item.requestedByName ?? `#${item.requestedBy}`} · waiting {age(item.requestedAt)}
{note ? ` · ${note}` : ""}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Link href={hrefFor(item)} className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>Open</Link>
{children}
</div>
</div>
)
}
@@ -8,6 +8,7 @@ import { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react"
import { customersApi } from "@/lib/api/customers"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { generateCustomerCode } from "@/lib/customer-code"
import { EntityStatus, PaginationMeta } from "@/types/common"
@@ -43,6 +44,9 @@ 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
/** Unpaid balance across the customer's Posted invoices (0 against an older backend). */
const outstanding = (c: Customer) => c.outstandingAmount ?? 0
export default function CustomersPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
@@ -432,10 +436,24 @@ export default function CustomersPage() {
<p className="text-muted-foreground">Phone</p>
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
</div>
<div className="col-span-2">
<div>
<p className="text-muted-foreground">Credit limit</p>
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
</div>
<div>
<p className="text-muted-foreground">Last paid</p>
<p className="font-medium text-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Remaining payment</p>
<p className={cn("font-medium tabular-nums", outstanding(c) > 0 ? "text-foreground" : "text-muted-foreground")}>
{outstanding(c) > 0 ? `LKR ${formatAmount(outstanding(c))}` : "—"}
</p>
</div>
<div>
<p className="text-muted-foreground">Unpaid invoices</p>
<p className="font-medium text-foreground">{c.outstandingInvoiceCount ?? 0}</p>
</div>
</div>
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
@@ -493,6 +511,9 @@ export default function CustomersPage() {
<TableHead className="w-28">Type</TableHead>
<TableHead className="w-36">Phone</TableHead>
<TableHead className="w-36">Credit limit</TableHead>
<TableHead className="w-32">Last paid</TableHead>
<TableHead className="w-36 text-right">Remaining payment</TableHead>
<TableHead className="w-28 text-right">Unpaid invoices</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
@@ -515,6 +536,19 @@ export default function CustomersPage() {
</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</TableCell>
<TableCell className={cn("text-right text-sm tabular-nums", outstanding(c) > 0 ? "font-semibold text-foreground" : "text-muted-foreground")}>
{outstanding(c) > 0 ? `LKR ${formatAmount(outstanding(c))}` : "—"}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{(c.outstandingInvoiceCount ?? 0) > 0 ? (
<Badge variant="outline" className="h-5 border-transparent bg-warning/10 px-2 text-xs text-warning">
{c.outstandingInvoiceCount}
</Badge>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<Badge
variant="outline"
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation"
import Link from "next/link"
import { 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"
import { categoriesApi } from "@/lib/api/categories"
import { productConfig } from "@/lib/api/product-config"
@@ -15,8 +16,9 @@ import { stockApi } from "@/lib/api/stock"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { Item, ItemReorderSetting, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { LedgerEntry } from "@/types/stock"
import { ItemPriceHistory } from "@/types/approvals"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
@@ -37,6 +39,28 @@ import { toast } from "@/components/ui/toast"
import LineChart from "@/components/ui/line-chart"
import BarChart from "@/components/ui/bar-chart"
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
/**
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
* reorder qty, watching one warehouse (a single rule) or every warehouse (several rules).
* `uniform` is false when the rules differ per warehouse — those are edited in Reorder settings.
*/
function alertFromRules(rules: ItemReorderSetting[]) {
if (rules.length === 0) return { lowStockAt: "", reorderQty: "", warehouseId: null as number | null, uniform: true }
const first = rules[0]
const uniform = rules.every((r) => r.reorderPoint === first.reorderPoint && r.reorderQty === first.reorderQty)
return {
lowStockAt: String(first.reorderPoint),
reorderQty: String(first.reorderQty),
warehouseId: rules.length === 1 ? first.warehouseId : null,
uniform,
}
}
function fmtPrice(v: number | null) {
return v === null ? "—" : `LKR ${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function docLink(sourceDocType: string, sourceDocId: number): string | null {
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
@@ -107,9 +131,14 @@ export default function ItemDetailPage() {
// Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("")
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
// isn't sent on save — nothing to wire it to server-side.
// Low-stock alert (saved as reorder rules via PUT /items/{id}/reorder, not on the item itself):
// alert level + reorder qty, watching `warehouseId` or, when null, every warehouse.
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [lowStockAt, setLowStockAt] = useState("")
const [reorderQty, setReorderQty] = useState("")
// Selling prices change only through an approved price adjustment while approval is on (docs/DUAL_APPROVAL_PLAN.md §6.3).
const [pricesLocked, setPricesLocked] = useState(true)
const [priceHistory, setPriceHistory] = useState<ItemPriceHistory[] | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false)
@@ -141,6 +170,10 @@ export default function ItemDetailPage() {
setSellingFlatPrice(data.sellingFlatPrice === null ? "" : String(data.sellingFlatPrice))
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit)
const alert = alertFromRules(data.reorder ?? [])
setLowStockAt(alert.lowStockAt)
setReorderQty(alert.reorderQty)
setWarehouseId(alert.warehouseId)
}
function load() {
@@ -167,6 +200,9 @@ export default function ItemDetailPage() {
})
.catch(() => {})
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
priceAdjustmentsApi.itemHistory(itemId).then(setPriceHistory).catch(() => setPriceHistory([]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
@@ -265,10 +301,23 @@ export default function ItemDetailPage() {
async function handleSave() {
if (!item || !etag) return
setSaveError(null)
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
const nextErrors: Record<string, string> = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
if (lowStockAt.trim()) {
const point = Number(lowStockAt)
const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
else if (stockNature !== "Stocked") nextErrors.lowStockAt = "Only stocked items hold stock — clear the low stock alert or make it Stocked"
if (!reorderQty.trim() || Number.isNaN(qty) || qty <= 0) nextErrors.reorderQty = "Enter how many to reorder (more than 0)"
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
// Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse
// rules set under Reorder settings survive an unrelated edit (name, price, …).
const saved = alertFromRules(item.reorder ?? [])
const alertChanged =
lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || warehouseId !== saved.warehouseId))
setSaving(true)
try {
const result = await itemsApi.update(
@@ -285,7 +334,21 @@ export default function ItemDetailPage() {
},
etag
)
applyItem(result.data)
let saved = result.data
if (alertChanged) {
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
const settings = lowStockAt.trim()
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
: []
try {
const res = await itemsApi.updateReorder(item.itemId, { settings })
saved = { ...saved, reorder: res.settings }
} catch (err) {
// The item itself is saved; say the alert wasn't, rather than failing the whole edit.
toast.warning("Item saved, but the low stock alert was not", errorMessage(err))
}
}
applyItem(saved)
setEtag(result.etag)
setEditOpen(false)
setImagesVersion((v) => v + 1)
@@ -324,6 +387,12 @@ export default function ItemDetailPage() {
}
}
// Rules the simple alert fields can't show faithfully: levels that differ per warehouse, or
// several warehouses watched but not every one.
const savedRules = item?.reorder ?? []
const alertIsCustom =
!alertFromRules(savedRules).uniform || (savedRules.length > 1 && warehouses.length > 0 && savedRules.length < warehouses.length)
function uomName(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
@@ -419,6 +488,66 @@ export default function ItemDetailPage() {
dialog closes so this copy picks up what changed there. */}
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
{/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change
made through the Edit dialog's simple alert fields. */}
<ItemReorderPanel
key={JSON.stringify(item.reorder ?? [])}
itemId={item.itemId}
stockNature={item.stockNature}
settings={item.reorder ?? []}
warehouses={warehouses}
uomName={uomName(item.baseUomId)}
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
/>
{/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */}
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold text-foreground">Price history</h2>
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="text-sm font-medium text-primary hover:underline">
Request price change
</Link>
</div>
{priceHistory === null ? (
<Skeleton className="h-20 w-full" />
) : priceHistory.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">No approved price changes yet.</p>
) : (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Applied</TableHead>
<TableHead className="h-9 px-2 text-xs">Flat price</TableHead>
<TableHead className="h-9 px-2 text-xs">Min / max</TableHead>
<TableHead className="h-9 px-2 text-xs">Request</TableHead>
<TableHead className="h-9 px-2 text-xs">Approved by</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{priceHistory.map((h) => (
<TableRow key={h.itemPriceHistoryId}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(h.appliedAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2 tabular-nums">{fmtPrice(h.oldFlatPrice)} → <span className="font-semibold">{fmtPrice(h.newFlatPrice)}</span></TableCell>
<TableCell className="px-2 py-2 tabular-nums text-muted-foreground">
{fmtPrice(h.newMinPrice)} / {fmtPrice(h.newMaxPrice)}
</TableCell>
<TableCell className="px-2 py-2">
{h.priceAdjustmentId ? (
<Link href={`/dashboard/products/price-adjustments/${h.priceAdjustmentId}`} className="text-primary hover:underline">{h.docNo ?? `#${h.priceAdjustmentId}`}</Link>
) : "—"}
</TableCell>
<TableCell className="px-2 py-2 text-muted-foreground">
{[h.approvedLevel1ByName, h.approvedLevel2ByName].filter(Boolean).join(", ") || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-xl border p-5">
<div className="flex items-center gap-2">
@@ -660,7 +789,7 @@ export default function ItemDetailPage() {
onChange={(e) => setSellingMinPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
disabled={conflict || pricesLocked}
/>
</div>
<div className="flex flex-col gap-2">
@@ -674,7 +803,7 @@ export default function ItemDetailPage() {
onChange={(e) => setSellingMaxPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
disabled={conflict || pricesLocked}
/>
</div>
<div className="flex flex-col gap-2">
@@ -688,8 +817,16 @@ export default function ItemDetailPage() {
onChange={(e) => setSellingFlatPrice(e.target.value)}
placeholder="Optional"
className="h-12 text-base"
disabled={conflict}
disabled={conflict || pricesLocked}
/>
{pricesLocked && (
<p className="text-sm text-muted-foreground">
Selling prices need approval.{" "}
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
Request a price change
</Link>
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label>
@@ -818,6 +955,50 @@ export default function ItemDetailPage() {
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => setLowStockAt(e.target.value)}
placeholder="Alert when stock ≤"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
disabled={conflict}
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => setReorderQty(e.target.value)}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
disabled={conflict}
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{alertIsCustom
? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level."
: lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.`
: "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
@@ -827,9 +1008,10 @@ export default function ItemDetailPage() {
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
<SelectValue placeholder="Every warehouse" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} — {w.name}
@@ -837,6 +1019,7 @@ export default function ItemDetailPage() {
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
</div>
</div>
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
@@ -132,10 +132,14 @@ export default function NewItemPage() {
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
// isn't sent on submit — nothing to wire it to server-side.
// Items carry no warehouse; this picks which warehouse the low-stock alert below watches
// (none picked = every warehouse). Saved as reorder rules (PUT /items/{id}/reorder) after create.
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
// Low-stock alert (reorder point): when available stock falls to or below this, the item shows
// on Stock → Reorder Alerts and a "Low stock" notification is sent. Blank = no alert.
const [lowStockAt, setLowStockAt] = useState("")
const [reorderQty, setReorderQty] = useState("")
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, DimensionValue[]>>({})
@@ -562,6 +566,13 @@ export default function NewItemPage() {
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
if (lowStockAt.trim()) {
const point = Number(lowStockAt)
const qty = Number(reorderQty)
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
else if (stockNature !== "Stocked") nextErrors.lowStockAt = "Only stocked items hold stock — clear the low stock alert or make it Stocked"
if (!reorderQty.trim() || Number.isNaN(qty) || qty <= 0) nextErrors.reorderQty = "Enter how many to reorder (more than 0)"
}
// Should never fire — addValue is the real gate — so it catches stale state only.
const contentSweep = validateVariantContent(
variants.map((v) => v.key),
@@ -663,6 +674,21 @@ export default function NewItemPage() {
if (failed > 0)
toast.warning("Some images did not upload", `${failed} upload${failed === 1 ? "" : "s"} failed — add them from the item page.`)
}
// Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like
// images — the items exist either way, and the rules can be fixed under Reorder settings.
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
if (lowStockAt.trim() && watched.length > 0) {
const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
let failed = 0
for (const itemId of createdIds) {
await itemsApi.updateReorder(itemId, { settings }).catch(() => {
failed += 1
})
}
if (failed > 0)
toast.warning("Low stock alert not saved for every item", `${failed} item${failed === 1 ? "" : "s"} failed — set it under the item's Reorder settings.`)
}
router.push("/dashboard/products")
} catch (err) {
// Each row is its own POST with no transaction, so a failure partway (e.g. a
@@ -794,6 +820,52 @@ export default function NewItemPage() {
</Select>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Low stock alert (optional)</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={lowStockAt}
onChange={(e) => {
setLowStockAt(e.target.value)
clearErrors("lowStockAt", "reorderQty")
}}
placeholder="Alert when stock ≤"
aria-label="Alert when stock is at or below"
aria-invalid={!!errors.lowStockAt}
className="h-12! text-base"
/>
<Input
type="number"
min="0"
step="any"
inputMode="decimal"
value={reorderQty}
onChange={(e) => {
setReorderQty(e.target.value)
clearErrors("reorderQty")
}}
placeholder="Reorder qty"
aria-label="Reorder quantity"
aria-invalid={!!errors.reorderQty}
className="h-12! text-base"
/>
</div>
<FieldError
errors={[
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
errors.reorderQty ? { message: errors.reorderQty } : undefined,
]}
/>
<p className="text-sm text-muted-foreground">
{lowStockAt.trim()
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
@@ -812,6 +884,7 @@ export default function NewItemPage() {
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base" required>Base UOM</Label>
@@ -0,0 +1,173 @@
"use client"
import { use, useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft } from "lucide-react"
import { priceAdjustmentsApi } from "@/lib/api/approvals"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { PriceAdjustment, PriceAdjustmentLine } from "@/types/approvals"
import { ApprovalPanel, PriceAdjustmentStatusBadge } from "@/components/approvals/ApprovalPanel"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const price = (v: number | null) => (v === null ? "—" : formatAmount(v))
/** True while pending and the item has been repriced since the request — approving would be refused. */
function isStale(l: PriceAdjustmentLine) {
return l.currentMinPrice !== l.oldMinPrice || l.currentMaxPrice !== l.oldMaxPrice || l.currentFlatPrice !== l.oldFlatPrice
}
/** One price change request: old → new per item, the approval trail, and the actions (docs/DUAL_APPROVAL_PLAN.md §6). */
export default function PriceAdjustmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const priceAdjustmentId = Number(id)
const [adjustment, setAdjustment] = useState<PriceAdjustment | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(priceAdjustmentId) ? loadError : `Invalid request id '${id}'.`
useEffect(() => {
if (!Number.isFinite(priceAdjustmentId)) return
priceAdjustmentsApi.get(priceAdjustmentId).then(setAdjustment).catch((err) => setLoadError(errorMessage(err)))
}, [priceAdjustmentId])
if (error) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
</div>
)
}
if (!adjustment) return <Skeleton className="h-96 w-full" />
const p = adjustment
const pending = p.pendingLevel !== null
const outcome = p.status === "Approved" ? "applied" : p.status === "Rejected" ? "rejected" : p.status === "Cancelled" ? "withdrawn" : "pending"
const staleLines = pending ? p.lines.filter(isStale) : []
return (
<div className="flex flex-col gap-6">
<BackLink />
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{p.docNo}</h1>
<PriceAdjustmentStatusBadge status={p.status} />
</div>
<p className="text-base text-muted-foreground">{p.reason}</p>
{pending && <p className="text-sm text-warning">Prices have not changed yet — they change when this request is fully approved.</p>}
</div>
{staleLines.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
<AlertTriangle className="size-4 shrink-0" />
<span>
{staleLines.map((l) => l.sku).join(", ")} {staleLines.length === 1 ? "was" : "were"} repriced after this request was made, so it can no longer be approved.
Reject it and raise a new request against the current prices.
</span>
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_24rem]">
<div className="overflow-x-auto rounded-xl border">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Item</TableHead>
<TableHead className="px-3 text-right">Min</TableHead>
<TableHead className="px-3 text-right">Max</TableHead>
<TableHead className="px-3 text-right">Flat</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{p.lines.map((l) => (
<TableRow key={l.priceAdjustmentLineId} className={cn(pending && isStale(l) && "bg-warning/5")}>
<TableCell className="px-3 py-2">
<Link href={`/dashboard/products/${l.itemId}`} className="font-medium hover:underline">{l.sku}</Link>
<p className="text-xs text-muted-foreground">{l.itemName}</p>
</TableCell>
<Change from={l.oldMinPrice} to={l.newMinPrice} />
<Change from={l.oldMaxPrice} to={l.newMaxPrice} />
<Change from={l.oldFlatPrice} to={l.newFlatPrice} />
</TableRow>
))}
</TableBody>
</Table>
</div>
<ApprovalPanel
what="price adjustment"
requiredApprovals={p.requiredApprovals}
pendingLevel={p.pendingLevel}
history={p.approvalHistory}
requestedByLabel={p.createdByName ?? `user #${p.createdBy}`}
requestedAt={p.createdAt}
canApprove={p.canApprove}
whyNot={p.whyNot}
canWithdraw={p.canWithdraw}
finalEffect="The new prices take effect on the items straight away."
outcome={outcome}
outcomeAt={p.appliedAt}
rejectReason={p.rejectReason}
onApprove={async (comment) => {
try {
const next = await priceAdjustmentsApi.approve(p.priceAdjustmentId, comment)
setAdjustment(next)
toast.success(next.status === "Approved" ? "Approved — prices applied" : "Approved", next.status === "Approved" ? `${next.docNo} is in effect.` : `${next.docNo} now needs a second approval.`)
} catch (err) {
toast.error("Could not approve", errorMessage(err))
throw err
}
}}
onReject={async (reason) => {
try {
setAdjustment(await priceAdjustmentsApi.reject(p.priceAdjustmentId, reason))
toast.success("Request rejected", p.docNo)
} catch (err) {
toast.error("Could not reject", errorMessage(err))
throw err
}
}}
onWithdraw={async () => {
try {
setAdjustment(await priceAdjustmentsApi.cancel(p.priceAdjustmentId))
toast.success("Request withdrawn", p.docNo)
} catch (err) {
toast.error("Could not withdraw", errorMessage(err))
throw err
}
}}
/>
</div>
</div>
)
}
function Change({ from, to }: { from: number | null; to: number | null }) {
const changed = from !== to
return (
<TableCell className="px-3 py-2 text-right tabular-nums">
{changed ? (
<>
<span className="text-muted-foreground line-through">{price(from)}</span>
<span className="ml-2 font-semibold text-foreground">{price(to)}</span>
</>
) : (
<span className="text-muted-foreground">{price(from)}</span>
)}
</TableCell>
)
}
function BackLink() {
return (
<Link href="/dashboard/products/price-adjustments" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" />
Price Adjustments
</Link>
)
}
@@ -0,0 +1,388 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { ArrowLeft, ArrowRight, Send } from "lucide-react"
import { priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { Item, ItemPurchasePrice } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Combobox, ComboboxOption } from "@/components/ui/combobox"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
type PriceKey = "min" | "max" | "flat"
const PRICE_FIELDS: { key: PriceKey; label: string; hint: string }[] = [
{ key: "flat", label: "Selling price (flat)", hint: "The price sales use by default" },
{ key: "min", label: "Minimum price", hint: "Lowest a cashier may sell at" },
{ key: "max", label: "Maximum price", hint: "Highest a cashier may sell at" },
]
const asInput = (v: number | null) => (v === null ? "" : String(v))
const asPrice = (v: string) => (v.trim() === "" ? null : Number(v))
const current = (item: Item, key: PriceKey) =>
key === "min" ? item.sellingMinPrice : key === "max" ? item.sellingMaxPrice : item.sellingFlatPrice
export default function NewPriceAdjustmentPage() {
// useSearchParams needs a Suspense boundary, or the production build can't prerender the route.
return (
<Suspense>
<NewPriceAdjustmentForm />
</Suspense>
)
}
/**
* Request a selling-price change (docs/DUAL_APPROVAL_PLAN.md §6), step by step:
* 1. select the item, 2. see its current prices, 3. enter the new prices, 4. give a reason, 5. submit for approval.
* Prices don't change until the request is approved. Opening with ?itemId=… starts on that item.
*/
function NewPriceAdjustmentForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [item, setItem] = useState<Item | null>(null)
const [loadingItem, setLoadingItem] = useState(false)
const [options, setOptions] = useState<ComboboxOption<number>[] | null>(null)
const [prices, setPrices] = useState<Record<PriceKey, string>>({ min: "", max: "", flat: "" })
const [reason, setReason] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
// The item's latest GRN purchase prices, to price against (null while loading).
const [purchases, setPurchases] = useState<ItemPurchasePrice[] | null>(null)
function choose(data: Item) {
setItem(data)
setPrices({ min: asInput(data.sellingMinPrice), max: asInput(data.sellingMaxPrice), flat: asInput(data.sellingFlatPrice) })
setErrors({})
setPurchases(null)
itemsApi.purchasePrices(data.itemId).then(setPurchases).catch(() => setPurchases([]))
}
const lastCost = purchases && purchases.length > 0 ? purchases[0].netUnitCost : null
function selectItem(itemId: number | null) {
if (itemId === null) {
setItem(null)
return
}
if (itemId === item?.itemId) return
setLoadingItem(true)
itemsApi
.get(itemId)
.then(({ data }) => choose(data))
.catch((err) => toast.error("Could not load the item", errorMessage(err)))
.finally(() => setLoadingItem(false))
}
// Came from an item's "Request price change": start on that item.
const preselect = Number(searchParams.get("itemId"))
useEffect(() => {
if (!Number.isFinite(preselect) || preselect <= 0) return
itemsApi
.get(preselect)
.then(({ data }) => choose(data))
.catch((err) => toast.error("Could not load the item", errorMessage(err)))
}, [preselect])
// Every active item for the dropdown (the list endpoint caps a page at 200, so page through).
useEffect(() => {
let cancelled = false
;(async () => {
const all: ComboboxOption<number>[] = []
for (let page = 1; page <= 25; page++) {
const res = await itemsApi.list({ status: "Active", page, pageSize: 200 })
all.push(...res.items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })))
if (page >= res.pagination.totalPages) break
}
if (!cancelled) setOptions(all.sort((a, b) => a.label.localeCompare(b.label)))
})().catch((err) => {
if (!cancelled) {
setOptions([])
toast.error("Could not load items", errorMessage(err))
}
})
return () => {
cancelled = true
}
}, [])
function validate(): Record<string, string> {
const next: Record<string, string> = {}
if (!item) {
next.item = "Select an item first"
return next
}
const [min, max, flat] = [asPrice(prices.min), asPrice(prices.max), asPrice(prices.flat)]
if ([min, max, flat].some((v) => v !== null && (Number.isNaN(v) || v < 0))) next.prices = "Prices must be 0 or more"
else if (min !== null && max !== null && min > max) next.prices = "The minimum price is above the maximum"
else if (flat !== null && ((min !== null && flat < min) || (max !== null && flat > max))) next.prices = "The selling price must be between the minimum and maximum"
else if (min === item.sellingMinPrice && max === item.sellingMaxPrice && flat === item.sellingFlatPrice) next.prices = "Enter at least one new price"
if (!reason.trim()) next.reason = "Say why the price is changing"
return next
}
async function submit() {
const next = validate()
setErrors(next)
if (Object.keys(next).length > 0 || !item) return
setSaving(true)
try {
const created = await priceAdjustmentsApi.create({
reason: reason.trim(),
lines: [{ itemId: item.itemId, newMinPrice: asPrice(prices.min), newMaxPrice: asPrice(prices.max), newFlatPrice: asPrice(prices.flat) }],
})
toast.success(
created.status === "Approved" ? "Price updated" : "Sent for approval",
created.status === "Approved"
? `${created.docNo} applied.`
: `${created.docNo} — the new price takes effect once ${created.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`
)
router.push(`/dashboard/products/price-adjustments/${created.priceAdjustmentId}`)
} catch (err) {
toast.error("Could not send the price change", errorMessage(err))
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<Link href="/dashboard/products/price-adjustments" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" />
Price Adjustments
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Request price change</h1>
<p className="text-base text-muted-foreground">The item keeps its current price until this request is approved.</p>
</div>
<div className="flex max-w-3xl flex-col gap-5">
{/* 1. Item */}
<Step number={1} title="Select the item" done={!!item}>
{options === null ? (
<Skeleton className="h-12 w-full" />
) : (
<div className="flex flex-col gap-2">
<Combobox<number>
items={
// Keep the chosen item listed even if it isn't active (came in via ?itemId=…).
item && !options.some((o) => o.value === item.itemId)
? [{ value: item.itemId, label: `${item.sku} — ${item.name}` }, ...options]
: options
}
value={item?.itemId ?? null}
onValueChange={selectItem}
placeholder="Select an item — type to search by SKU or name"
emptyText="No matching active items."
className="h-12 text-base"
aria-invalid={!!errors.item}
/>
{loadingItem && <p className="text-sm text-muted-foreground">Loading the item’s prices…</p>}
<FieldError errors={[errors.item ? { message: errors.item } : undefined]} />
</div>
)}
</Step>
{/* 2. Current price */}
<Step number={2} title="Current price" muted={!item}>
{item ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{PRICE_FIELDS.map((f) => (
<div key={f.key} className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="text-lg font-bold tabular-nums text-foreground">{priceText(current(item, f.key))}</p>
</div>
))}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3">
<p className="text-xs text-muted-foreground">Last GRN cost</p>
{purchases === null ? (
<Skeleton className="mt-1 h-6 w-24" />
) : (
<>
<p className="text-lg font-bold tabular-nums text-foreground">{priceText(lastCost)}</p>
<p className="text-xs text-muted-foreground">
{lastCost === null ? "Never received" : <Margin price={item.sellingFlatPrice} cost={lastCost} label="Margin now" />}
</p>
</>
)}
</div>
</div>
{/* What it was bought at recently — the cost side of the new price. */}
{purchases !== null && purchases.length > 0 && (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">GRN</th>
<th className="px-3 py-2 text-left font-medium">Received</th>
<th className="px-3 py-2 text-left font-medium">Vendor</th>
<th className="px-3 py-2 text-right font-medium">Qty</th>
<th className="px-3 py-2 text-right font-medium">Unit cost</th>
<th className="px-3 py-2 text-right font-medium">Disc.</th>
<th className="px-3 py-2 text-right font-medium">Net cost</th>
</tr>
</thead>
<tbody>
{purchases.map((p, i) => (
<tr key={`${p.grnId}-${i}`} className="border-t">
<td className="px-3 py-2">
<Link href={`/dashboard/receiving/grn/${p.grnId}`} className="text-primary hover:underline">{p.grnDocNo}</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">{new Date(p.receivedAt).toLocaleDateString()}</td>
<td className="px-3 py-2">{p.vendorName ?? `#${p.vendorId}`}</td>
<td className="px-3 py-2 text-right tabular-nums">{p.qty}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(p.unitCost)}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{p.discountPct ? `${p.discountPct}%` : "—"}</td>
<td className="px-3 py-2 text-right font-semibold tabular-nums">{formatAmount(p.netUnitCost)}</td>
</tr>
))}
</tbody>
</table>
<p className="border-t px-3 py-2 text-xs text-muted-foreground">
Net cost = unit cost after discount, excluding VAT (the stock cost). Latest {purchases.length} receipt{purchases.length === 1 ? "" : "s"}.
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Shown once you select an item.</p>
)}
</Step>
{/* 3. New price */}
<Step number={3} title="New price" muted={!item}>
{item ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{PRICE_FIELDS.map((f) => {
const now = current(item, f.key)
const next = asPrice(prices[f.key])
const changed = next !== now && !(next !== null && Number.isNaN(next))
return (
<Field key={f.key}>
<FieldLabel htmlFor={`new-${f.key}`}>{f.label}</FieldLabel>
<Input
id={`new-${f.key}`}
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={prices[f.key]}
onChange={(e) => { setPrices((p) => ({ ...p, [f.key]: e.target.value })); setErrors((x) => ({ ...x, prices: "" })) }}
placeholder="No price"
aria-invalid={!!errors.prices}
className={cn("h-11 text-base tabular-nums", changed && "border-primary")}
/>
<p className="text-xs text-muted-foreground">
{changed ? <ChangeText from={now} to={next} /> : f.hint}
</p>
</Field>
)
})}
</div>
<FieldError errors={[errors.prices ? { message: errors.prices } : undefined]} />
{lastCost !== null && asPrice(prices.flat) !== null && !Number.isNaN(asPrice(prices.flat)) && (
<p className="text-sm">
<Margin price={asPrice(prices.flat)} cost={lastCost} label="Margin at the new selling price" />
{asPrice(prices.flat)! < lastCost && <span className="ml-2 font-medium text-destructive">— below the last GRN cost</span>}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Select an item first.</p>
)}
</Step>
{/* 4. Reason */}
<Step number={4} title="Reason" muted={!item}>
<Field data-invalid={!!errors.reason}>
<FieldLabel htmlFor="pa-reason" className="sr-only">Reason</FieldLabel>
<Textarea
id="pa-reason"
rows={3}
maxLength={250}
value={reason}
disabled={!item}
onChange={(e) => { setReason(e.target.value); setErrors((x) => ({ ...x, reason: "" })) }}
placeholder="e.g. Supplier price increase from 1 November"
/>
<FieldError errors={[errors.reason ? { message: errors.reason } : undefined]} />
</Field>
</Step>
{/* 5. Send for approval */}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">Approvers are notified. The price changes only after approval.</p>
<div className="flex gap-2">
<Link href="/dashboard/products/price-adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
<Button size="lg" onClick={submit} disabled={!item || saving}>
<Send className="size-4" />
{saving ? "Sending…" : "Send for approval"}
</Button>
</div>
</div>
</div>
</div>
)
}
function priceText(v: number | null) {
return v === null ? "—" : `LKR ${formatAmount(v)}`
}
/** "Margin now: LKR 30.00 (25.0%)" — selling price over the last GRN net cost, red when negative. */
function Margin({ price, cost, label }: { price: number | null; cost: number; label: string }) {
if (price === null) return <span className="text-muted-foreground">{label}: no selling price</span>
const margin = price - cost
const pct = price === 0 ? null : (margin / price) * 100
return (
<span className={cn(margin < 0 ? "text-destructive" : "text-success")}>
{label}: LKR {formatAmount(margin)}
{pct !== null && ` (${pct.toFixed(1)}%)`}
</span>
)
}
/** "100.00 → 120.00 (+20%)", or how a price is being set / cleared. */
function ChangeText({ from, to }: { from: number | null; to: number | null }) {
if (from === null) return <span className="text-primary">New price: {priceText(to)}</span>
if (to === null) return <span className="text-warning">Price will be cleared</span>
const pct = from === 0 ? null : ((to - from) / from) * 100
return (
<span className={cn(to > from ? "text-success" : "text-destructive")}>
{formatAmount(from)} <ArrowRight className="inline size-3" /> {formatAmount(to)}
{pct !== null && ` (${pct > 0 ? "+" : ""}${pct.toFixed(1)}%)`}
</span>
)
}
function Step({ number, title, done, muted, children }: { number: number; title: string; done?: boolean; muted?: boolean; children: React.ReactNode }) {
return (
<section className={cn("flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm sm:p-5", muted && "opacity-60")}>
<div className="flex items-center gap-3">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
done ? "bg-success/15 text-success" : "bg-primary/10 text-primary"
)}
>
{number}
</span>
<h2 className="text-base font-semibold text-foreground">{title}</h2>
</div>
{children}
</section>
)
}
@@ -0,0 +1,146 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Plus, Search, Tag } from "lucide-react"
import { priceAdjustmentsApi } from "@/lib/api/approvals"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PriceAdjustmentStatus, PriceAdjustmentSummary } from "@/types/approvals"
import { PaginationMeta } from "@/types/common"
import { PriceAdjustmentStatusBadge } from "@/components/approvals/ApprovalPanel"
import { ApprovalQueueButton } from "@/components/approvals/ApprovalQueueButton"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = PriceAdjustmentStatus | "All"
const statusOptions: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "PendingApproval", label: "Pending 1st approval" },
{ value: "PendingSecondApproval", label: "Pending 2nd approval" },
{ value: "Approved", label: "Applied" },
{ value: "Rejected", label: "Rejected" },
{ value: "Cancelled", label: "Withdrawn" },
]
const PAGE_SIZE_OPTIONS = [10, 20, 50]
/** Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6). Prices change only once a request is fully approved. */
export default function PriceAdjustmentsPage() {
const router = useRouter()
const [rows, setRows] = useState<PriceAdjustmentSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
priceAdjustmentsApi
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
setError(null)
})
.catch((err) => setError(errorMessage(err)))
}, [page, pageSize, query, status])
const open = (id: number) => router.push(`/dashboard/products/price-adjustments/${id}`)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Price Adjustments</h1>
<p className="text-base text-muted-foreground">
Requests to change selling prices. A new price takes effect only after it is approved — twice, by two different people, when dual approval is on.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<ApprovalQueueButton docType="PADJ" />
<Link href="/dashboard/products/price-adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
Request price change
</Link>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => { setSearchInput(e.target.value); setPage(1) }}
placeholder="Search doc no. or reason…"
className="h-12 w-full pl-11 text-base"
aria-label="Search price adjustments"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
<SelectTrigger className="h-12! w-full text-base sm:w-56" aria-label="Status"><SelectValue /></SelectTrigger>
<SelectContent>
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && rows === null && <Skeleton className="h-64 w-full" />}
{!error && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No price adjustments{status !== "All" || query ? " match your filters" : " yet"}.</p>
</div>
)}
{!error && rows !== null && rows.length > 0 && (
<>
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Request</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Items</TableHead>
<TableHead className="h-12 px-3 text-sm">Requested by</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.priceAdjustmentId} className="cursor-pointer" onClick={() => open(r.priceAdjustmentId)}>
<TableCell className="px-3 py-3 font-semibold">
<Link href={`/dashboard/products/price-adjustments/${r.priceAdjustmentId}`} className="hover:underline">{r.docNo}</Link>
<p className="text-xs font-normal text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</p>
</TableCell>
<TableCell className="max-w-72 truncate px-3 py-3 text-sm text-muted-foreground">{r.reason}</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3 text-sm">{r.createdByName ?? `#${r.createdBy}`}</TableCell>
<TableCell className="px-3 py-3"><PriceAdjustmentStatusBadge status={r.status} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && (
<PaginationFooter pagination={pagination} onPageChange={setPage} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={(s) => { setPage(1); setPageSize(s) }} />
)}
</>
)}
</div>
)
}
@@ -0,0 +1,576 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
Banknote,
CreditCard,
FileDown,
Hash,
Landmark,
PiggyBank,
Receipt,
ReceiptText,
TrendingDown,
TrendingUp,
Wallet,
} from "lucide-react"
import { dayEndReportApi } from "@/lib/api/day-end-report"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
import { cn } from "@/lib/utils"
import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
import { useAuth } from "@/components/auth/AuthProvider"
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const MAX_RANGE_DAYS = 92
/** Local yyyy-MM-dd, `offsetDays` from today. */
function localIso(offsetDays = 0): string {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
function daysBetween(from: string, to: string): number {
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
}
type Loaded<T> = { key: string; data: T | null; error: string | null }
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
{formatSignedAmount(value)}
</TableCell>
)
}
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
const p = profitOrLoss(value)
return (
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
{p.amount}
</TableCell>
)
}
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
function ProfitStatement({ day }: { day: DayEndReportDay }) {
const gross = profitOrLoss(day.grossProfit)
const net = profitOrLoss(day.netProfit)
const Icon = net.isLoss ? TrendingDown : TrendingUp
return (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Profit / loss</h2>
<div className="ml-auto grid w-full max-w-md grid-cols-[1fr_auto] gap-x-6 gap-y-1.5 rounded-xl border border-border p-4 text-sm">
<span className="text-muted-foreground">Total sales</span>
<span className="text-right tabular-nums">{formatAmount(day.totalSales)}</span>
<span className="text-muted-foreground">Less: cost of goods sold</span>
<span className="text-right tabular-nums">({formatAmount(day.costOfGoodsSold)})</span>
<span className="border-t border-border pt-1.5 font-medium">Gross {gross.label.toLowerCase()}</span>
<span className={cn("border-t border-border pt-1.5 text-right font-medium tabular-nums", gross.isLoss && "text-destructive")}>
{gross.amount}
</span>
<span className="text-muted-foreground">Less: expenses</span>
<span className="text-right tabular-nums">({formatAmount(day.expenseTotal)})</span>
<div
className={cn(
"col-span-2 mt-1.5 flex items-center justify-between rounded-lg px-3 py-2.5",
net.isLoss ? "bg-destructive/10 text-destructive" : "bg-success/10 text-success"
)}
>
<span className="flex items-center gap-2 text-base font-semibold">
<Icon className="size-4" aria-hidden />
Net {net.label.toLowerCase()}
</span>
<span className="text-xl font-bold tabular-nums">{net.amount}</span>
</div>
</div>
</div>
)
}
/**
* Till cash for the day (docs/BANK_DEPOSIT_MODULE_PLAN.md §9): what was carried in, what the day added, what was
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
*/
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
const remaining = day.closingCashInHand ?? 0
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Cash in hand</h2>
<Link href="/dashboard/accounts/bank-deposits/new" className="text-sm font-medium text-primary hover:underline">
Record a bank deposit
</Link>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} />
<StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
remaining > 0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border"
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Remaining cash in hand</p>
<PiggyBank className={cn("size-4", remaining > 0 ? "text-warning" : "text-muted-foreground")} aria-hidden />
</div>
<p className={cn("text-xl font-bold tracking-tight tabular-nums", remaining > 0 ? "text-warning" : "text-foreground")}>
{formatAmount(remaining)}
</p>
<p className="text-xs text-muted-foreground">{remaining > 0 ? "Not yet deposited" : "All cash banked"}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go
straight to the bank.
</p>
{deposits.length === 0 ? (
<p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Deposit</TableHead>
<TableHead>Bank account</TableHead>
<TableHead>Slip no.</TableHead>
<TableHead>Deposited on</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deposits.map((d) => (
<TableRow key={d.bankDepositId}>
<TableCell>
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link>
</TableCell>
<TableCell>
{d.bankAccountName}
{(d.bankName || d.accountNumber) && (
<span className="text-muted-foreground"> · {[d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")}</span>
)}
</TableCell>
<TableCell>{d.depositSlipNo ?? "—"}</TableCell>
<TableCell>{formatReportDate(d.depositDate)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.amount)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={4} className="font-semibold">Total deposited</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(day.cashDeposited ?? 0)}</TableCell>
</TableRow>
</TableFooter>
</Table>
)}
</div>
)
}
export default function DayEndReportPage() {
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
// their own branch server-side, whatever is sent.
const branchId = isAdmin ? activeBranchId : null
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
const [date, setDate] = useState(localIso())
const [from, setFrom] = useState(localIso(-29))
const [to, setTo] = useState(localIso())
const [downloading, setDownloading] = useState<"day" | "range" | null>(null)
const rangeDays = from && to ? daysBetween(from, to) : 0
const rangeError = !from || !to
? "Pick both dates"
: rangeDays < 1
? "'To' must be on or after 'From'"
: rangeDays > MAX_RANGE_DAYS
? `Pick at most ${MAX_RANGE_DAYS} days`
: null
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
const dayKey = `${date}|${branchId}`
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null)
const day = dayResult?.key === dayKey ? dayResult : null
const rangeKey = `${from}|${to}|${branchId}`
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
const range = rangeResult?.key === rangeKey ? rangeResult : null
useEffect(() => {
if (!date) return
let cancelled = false
dayEndReportApi
.getDay({ date, branchId })
.then((data) => !cancelled && setDayResult({ key: dayKey, data, error: null }))
.catch((err) => !cancelled && setDayResult({ key: dayKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [date, branchId, dayKey])
useEffect(() => {
if (rangeError) return
let cancelled = false
dayEndReportApi
.getDaily({ from, to, branchId })
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
return () => {
cancelled = true
}
}, [from, to, branchId, rangeKey, rangeError])
async function downloadDay() {
if (!day?.data) return
setDownloading("day")
try {
await downloadDayEndReportPdf(day.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(null)
}
}
async function downloadRange() {
if (!range?.data) return
setDownloading("range")
try {
await downloadDailyIncomePdf(range.data, branchLabel)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setDownloading(null)
}
}
const s = day?.data?.summary
// `?? []` keeps the page up against a backend that predates these lists.
const sales = day?.data?.sales ?? []
const expenses = day?.data?.expenses ?? []
const deposits = day?.data?.deposits ?? []
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
<p className="text-base text-muted-foreground">
A day&apos;s sales, how they were paid, its expenses and the resulting income — plus income day by day.
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
</p>
</div>
</div>
{/* --- One day ------------------------------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-2">
<Label htmlFor="der-date" className="text-base">Business date</Label>
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" />
</div>
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "day" ? "Creating PDF…" : "Download day PDF"}
</Button>
</div>
{day?.error && <p className="text-sm text-destructive">{day.error}</p>}
{!s && !day?.error && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-[84px] rounded-xl" />)}
</div>
)}
{s && (
<>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Sales</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} />
<StatCard label="Paid amount" value={formatAmount(s.paidAmount)} icon={Wallet} />
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} />
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} />
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Sales count" value={String(s.salesCount)} icon={Hash} />
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
<div
className={cn(
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground">Income</p>
{s.income >= 0
? <TrendingUp className="size-4 text-success" aria-hidden />
: <TrendingDown className="size-4 text-destructive" aria-hidden />}
</div>
<p className={cn("text-xl font-bold tracking-tight tabular-nums", s.income >= 0 ? "text-success" : "text-destructive")}>
{formatSignedAmount(s.income)}
</p>
{s.income < 0 ? (
<p className="text-xs font-semibold text-destructive">Loss — expenses are more than sales</p>
) : (
<p className="text-xs text-muted-foreground">Sales − expenses</p>
)}
</div>
</div>
<p className="text-sm text-muted-foreground">
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "}
<span className="font-medium text-foreground tabular-nums">{formatAmount(s.earlierInvoiceCollections)}</span>
</p>
</div>
<CashInHandSection day={s} deposits={deposits} date={date} />
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Sales of the day ({sales.length})</h2>
{sales.length === 0 ? (
<p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p>
) : (
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Time</TableHead>
<TableHead>No.</TableHead>
<TableHead>Type</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Paid by</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Profit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sales.map((row) => (
<TableRow key={`${row.docType}-${row.docId}`}>
<TableCell className="tabular-nums">
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</TableCell>
<TableCell>
{row.docType === "Invoice" ? (
<Link href={`/dashboard/sales/invoices/${row.docId}`} className="text-primary hover:underline">{row.docNo}</Link>
) : (
row.docNo
)}
</TableCell>
<TableCell>{row.docType}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.paidBy}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(row.total)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(row.paid)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(row.due, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(row.cost)}</TableCell>
<TableCell className={cn("text-right tabular-nums", row.grossProfit < 0 && "text-destructive")}>
{formatAmount(row.grossProfit)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5} className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.costOfGoodsSold)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.grossProfit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-foreground">Expenses of the day</h2>
{expenses.length === 0 ? (
<p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Doc no.</TableHead>
<TableHead>Category</TableHead>
<TableHead>Title</TableHead>
<TableHead>Payee</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-right">Paid</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expenses.map((e) => (
<TableRow key={e.expenseId}>
<TableCell>
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
</TableCell>
<TableCell>{e.categoryName}</TableCell>
<TableCell>{e.titleName}</TableCell>
<TableCell>{e.payee ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(e.amount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(e.paidAmount)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={4} className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.expenseTotal)}</TableCell>
<TableCell />
</TableRow>
</TableFooter>
</Table>
)}
</div>
<ProfitStatement day={s} />
</>
)}
</section>
{/* --- Day-wise income ----------------------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-from">From</Label>
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-44" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="der-to">To</Label>
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-44" />
</div>
<Button onClick={downloadRange} disabled={!range?.data || downloading !== null}>
<FileDown className="size-4" />
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
</Button>
</div>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
{!rangeError && range?.data && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
<StatCard
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
value={formatSignedAmount(range.data.totals.income)}
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
/>
<StatCard
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
value={profitOrLoss(range.data.totals.netProfit).amount}
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
/>
</div>
<DayEndIncomeChart days={range.data.days} />
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
<Table>
<TableHeader className="sticky top-0 bg-card">
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Sales</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Due</TableHead>
<TableHead className="text-right">Cash</TableHead>
<TableHead className="text-right">Card</TableHead>
<TableHead className="text-right">Count</TableHead>
<TableHead className="text-right">Expenses</TableHead>
<TableHead className="text-right">Income</TableHead>
<TableHead className="text-right">Profit / loss</TableHead>
<TableHead className="text-right">Deposited</TableHead>
<TableHead className="text-right">Cash in hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...range.data.days].reverse().map((d) => (
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
<TableCell>{formatReportDate(d.date)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
<IncomeCell value={d.income} />
<ProfitCell value={d.netProfit} />
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-semibold">Total</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
<IncomeCell value={range.data.totals.income} bold />
<ProfitCell value={range.data.totals.netProfit} bold />
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</>
)}
</section>
</div>
)
}
@@ -0,0 +1,144 @@
"use client"
import { useEffect, useState } from "react"
import { Save } from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { errorMessage } from "@/lib/error-map"
import { ApprovalConfig } from "@/types/approvals"
import { Button } from "@/components/ui/button"
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 { toast } from "@/components/ui/toast"
const levelOptions = [
{ value: "2", label: "Two approvals — two different people" },
{ value: "1", label: "One approval" },
{ value: "0", label: "No approval — applies immediately" },
]
/** Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7): how many approvals stock and price adjustments need. */
export default function ApprovalSettingsPage() {
const [config, setConfig] = useState<ApprovalConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [stockLevels, setStockLevels] = useState("2")
const [stockThreshold, setStockThreshold] = useState("0")
const [priceLevels, setPriceLevels] = useState("2")
const [priceThreshold, setPriceThreshold] = useState("0")
const [roles, setRoles] = useState("")
const [saving, setSaving] = useState(false)
function apply(c: ApprovalConfig) {
setConfig(c)
setStockLevels(String(c.stockAdjustmentLevels))
setStockThreshold(String(c.stockAdjustmentLevel2ThresholdValue))
setPriceLevels(String(c.priceAdjustmentLevels))
setPriceThreshold(String(c.priceAdjustmentLevel2ThresholdPercent))
setRoles(c.level2RoleCodes.join(", "))
}
useEffect(() => {
approvalsApi.getConfig().then(apply).catch((err) => setError(errorMessage(err)))
}, [])
async function save() {
const sv = Number(stockThreshold)
const pv = Number(priceThreshold)
if (Number.isNaN(sv) || sv < 0 || Number.isNaN(pv) || pv < 0) {
toast.error("Check the thresholds", "Thresholds must be 0 or more.")
return
}
setSaving(true)
try {
const saved = await approvalsApi.updateConfig({
stockAdjustmentLevels: Number(stockLevels),
stockAdjustmentLevel2ThresholdValue: sv,
priceAdjustmentLevels: Number(priceLevels),
priceAdjustmentLevel2ThresholdPercent: pv,
level2RoleCodes: roles.split(",").map((r) => r.trim()).filter(Boolean),
})
apply(saved)
toast.success("Approval settings saved", "They apply to requests made from now on.")
} catch (err) {
toast.error("Could not save approval settings", errorMessage(err))
} finally {
setSaving(false)
}
}
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!config) return <Skeleton className="h-96 w-full" />
return (
<div className="flex flex-col gap-6">
<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,
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>
<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>
<Field>
<FieldLabel>Approvals required</FieldLabel>
<Select<string> value={stockLevels} onValueChange={(v) => v && setStockLevels(v)}>
<SelectTrigger className="h-11! w-full text-base"><SelectValue /></SelectTrigger>
<SelectContent>{levelOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}</SelectContent>
</Select>
</Field>
{stockLevels === "2" && (
<Field>
<FieldLabel htmlFor="stock-threshold">Second approval only from value (LKR)</FieldLabel>
<Input id="stock-threshold" type="number" min="0" step="0.01" value={stockThreshold} onChange={(e) => setStockThreshold(e.target.value)} className="h-11 text-base" />
<FieldDescription>Adjustments worth less than this (at FIFO cost) need only one approval. 0 = always two.</FieldDescription>
</Field>
)}
</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">Selling-price changes. While this is not “No approval”, prices can’t be edited directly on an item.</p>
<Field>
<FieldLabel>Approvals required</FieldLabel>
<Select<string> value={priceLevels} onValueChange={(v) => v && setPriceLevels(v)}>
<SelectTrigger className="h-11! w-full text-base"><SelectValue /></SelectTrigger>
<SelectContent>{levelOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}</SelectContent>
</Select>
</Field>
{priceLevels === "2" && (
<Field>
<FieldLabel htmlFor="price-threshold">Second approval only from change of (%)</FieldLabel>
<Input id="price-threshold" type="number" min="0" step="0.1" value={priceThreshold} onChange={(e) => setPriceThreshold(e.target.value)} className="h-11 text-base" />
<FieldDescription>Changes smaller than this need only one approval. Setting or clearing a price counts as 100%. 0 = always two.</FieldDescription>
</Field>
)}
</section>
</div>
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<Field>
<FieldLabel htmlFor="level2-roles">Who can give the second approval (role codes)</FieldLabel>
<Input id="level2-roles" value={roles} onChange={(e) => setRoles(e.target.value)} placeholder="e.g. Manager, Accountant — leave empty for anyone with the Approve permission" className="h-11 text-base" />
<FieldDescription>
Comma-separated. Anyone giving an approval also needs the Approve permission on the screen (Settings → Roles).
Make sure at least two people per branch can approve, or requests will wait forever.
</FieldDescription>
</Field>
</section>
<div className="flex justify-end">
<Button size="lg" onClick={save} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save settings"}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,157 @@
"use client"
import { use, useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { StockAdjustment } from "@/types/stock"
import { ApprovalPanel } from "@/components/approvals/ApprovalPanel"
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/** One stock adjustment: its lines, its approval trail, and Approve / Reject / Withdraw (docs/DUAL_APPROVAL_PLAN.md §10). */
export default function AdjustmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const adjustmentId = Number(id)
const [adjustment, setAdjustment] = useState<StockAdjustment | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(adjustmentId) ? loadError : `Invalid adjustment id '${id}'.`
const [itemLabels, setItemLabels] = useState<Map<number, string>>(new Map())
const [warehouse, setWarehouse] = useState<string | null>(null)
const [reason, setReason] = useState<string | null>(null)
const load = useCallback(() => {
if (!Number.isFinite(adjustmentId)) return
stockAdjustmentsApi.get(adjustmentId).then(setAdjustment).catch((err) => setLoadError(errorMessage(err)))
}, [adjustmentId])
useEffect(load, [load])
useEffect(() => {
if (!adjustment) return
itemsApi.list({ pageSize: 200 }).then((r) => setItemLabels(new Map(r.items.map((i) => [i.itemId, `${i.sku} — ${i.name}`])))).catch(() => {})
warehousesApi.list().then((r) => setWarehouse(r.items.find((w) => w.warehouseId === adjustment.warehouseId)?.code ?? null)).catch(() => {})
reasonCodesApi.list("Adjustment").then((r) => setReason(r.items.find((c) => c.reasonCodeId === adjustment.reasonCodeId)?.description ?? null)).catch(() => {})
}, [adjustment])
if (error) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
</div>
)
}
if (!adjustment) return <Skeleton className="h-96 w-full" />
const a = adjustment
const outcome = a.status === "Posted" ? "applied" : a.status === "Rejected" ? "rejected" : a.status === "Cancelled" ? "withdrawn" : "pending"
return (
<div className="flex flex-col gap-6">
<BackLink />
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{a.docNo}</h1>
<AdjustmentStatusBadge status={a.status} />
</div>
<p className="text-base text-muted-foreground">
{warehouse ?? `Warehouse #${a.warehouseId}`} · {reason ?? `Reason #${a.reasonCodeId}`}
{a.glJournalNo ? ` · Journal ${a.glJournalNo}` : ""}
</p>
{outcome === "pending" && (
<p className="text-sm text-warning">This is a request — stock has not moved and nothing is posted until it is fully approved.</p>
)}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_24rem]">
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-4 text-sm">Item</TableHead>
<TableHead className="h-11 px-4 text-right text-sm">Qty change</TableHead>
<TableHead className="h-11 px-4 text-sm">Serials</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{a.lines.map((l) => (
<TableRow key={l.adjLineId}>
<TableCell className="px-4 py-3">{itemLabels.get(l.itemId) ?? `Item #${l.itemId}`}</TableCell>
<TableCell className={`px-4 py-3 text-right font-semibold tabular-nums ${l.qtyDelta < 0 ? "text-destructive" : "text-success"}`}>
{l.qtyDelta > 0 ? `+${l.qtyDelta}` : l.qtyDelta}
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
{[...(l.requestedSerialNos ?? []), ...(l.requestedSerialIds ?? []).map((s) => `#${s}`)].join(", ") || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ApprovalPanel
what="stock adjustment"
requiredApprovals={a.requiredApprovals}
pendingLevel={a.pendingLevel}
history={a.approvalHistory ?? []}
requestedByLabel={a.createdByName ?? `user #${a.createdBy}`}
requestedAt={a.createdAt}
canApprove={a.canApprove}
whyNot={a.whyNot}
canWithdraw={a.canWithdraw}
finalEffect="Stock moves and the inventory journal is posted to the ledger."
outcome={outcome}
outcomeAt={a.postedAt}
rejectReason={a.rejectReason}
onApprove={async (comment) => {
try {
const next = await stockAdjustmentsApi.approve(a.adjustmentId, comment)
setAdjustment(next)
toast.success(next.status === "Posted" ? "Approved and posted" : "Approved", next.status === "Posted" ? `${next.docNo} — stock updated.` : `${next.docNo} now needs a second approval.`)
} catch (err) {
toast.error("Could not approve", errorMessage(err))
throw err
}
}}
onReject={async (reasonText) => {
try {
setAdjustment(await stockAdjustmentsApi.reject(a.adjustmentId, reasonText))
toast.success("Adjustment rejected", a.docNo)
} catch (err) {
toast.error("Could not reject", errorMessage(err))
throw err
}
}}
onWithdraw={async () => {
try {
setAdjustment(await stockAdjustmentsApi.cancel(a.adjustmentId))
toast.success("Request withdrawn", a.docNo)
} catch (err) {
toast.error("Could not withdraw", errorMessage(err))
throw err
}
}}
/>
</div>
</div>
)
}
function BackLink() {
return (
<Link href="/dashboard/stock/adjustments" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" />
Stock Adjustments
</Link>
)
}
@@ -187,10 +187,11 @@ export default function NewAdjustmentPage() {
lines: payloadLines,
})
setResult(adjustment)
toast.success("Adjustment posted", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
if (adjustment.status === "Posted") toast.success("Adjustment posted", `${adjustment.docNo} — stock updated.`)
else toast.success("Submitted for approval", `${adjustment.docNo} moves stock once ${adjustment.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post adjustment", errorMessage(err))
toast.error("Could not submit adjustment", errorMessage(err))
} finally {
setSubmitting(false)
}
@@ -224,9 +225,20 @@ export default function NewAdjustmentPage() {
<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.docNo} posted</p>
<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 has not moved yet — it needs {result.requiredApprovals === 2 ? "two approvals from two different people" : "one approval"} (not yours).{" "}
<Link href={`/dashboard/stock/adjustments/${result.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
</>
)}
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
</div>
<div className="flex gap-3">
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
@@ -413,7 +425,7 @@ export default function NewAdjustmentPage() {
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Adjustment"}
{submitting ? "Submitting…" : "Submit for approval"}
</Button>
</div>
</>
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Plus, Search, SlidersHorizontal } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
@@ -10,24 +11,38 @@ import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { StockAdjustmentSummary } from "@/types/stock"
import { AdjustmentStatus, StockAdjustmentSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
import { ApprovalQueueButton } from "@/components/approvals/ApprovalQueueButton"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
type StatusFilter = AdjustmentStatus | "All"
const statusOptions: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "PendingApproval", label: "Pending 1st approval" },
{ value: "PendingSecondApproval", label: "Pending 2nd approval" },
{ value: "Posted", label: "Posted" },
{ value: "Rejected", label: "Rejected" },
{ value: "Cancelled", label: "Withdrawn" },
]
export default function AdjustmentsListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const router = useRouter()
const [status, setStatus] = useState<StatusFilter>("All")
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -46,19 +61,22 @@ export default function AdjustmentsListPage() {
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query])
useEffect(() => setPage(1), [query, status])
useEffect(() => {
setError(null)
stockAdjustmentsApi
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.list({
page, pageSize, q: query || undefined, status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
})
.then((res) => {
setAdjustments(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, query, activeBranchId, branchingEnabled])
}, [page, pageSize, query, status, activeBranchId, branchingEnabled])
function handleShowMore() {
setShowMoreLoading(true)
@@ -75,7 +93,8 @@ export default function AdjustmentsListPage() {
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
const hasFilters = query.length > 0
const hasFilters = query.length > 0 || status !== "All"
const open = (id: number) => router.push(`/dashboard/stock/adjustments/${id}`)
return (
<div className="flex flex-col gap-6">
@@ -83,13 +102,18 @@ export default function AdjustmentsListPage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
<p className="text-base text-muted-foreground">
Increase, decrease, or write off stock with a reason code (FR-STK-07). Each adjustment is a request — stock moves once it is approved.
</p>
</div>
</div>
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Adjustment
</Link>
<div className="flex flex-col gap-2 sm:flex-row">
<ApprovalQueueButton docType="ADJ" />
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
New Adjustment
</Link>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -103,6 +127,14 @@ export default function AdjustmentsListPage() {
aria-label="Search stock adjustments"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-56" aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{error && (
@@ -147,7 +179,14 @@ export default function AdjustmentsListPage() {
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 md:hidden">
{adjustments.map((a) => (
<div key={a.adjustmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div
key={a.adjustmentId}
role="link"
tabIndex={0}
onClick={() => open(a.adjustmentId)}
onKeyDown={(e) => { if (e.key === "Enter") open(a.adjustmentId) }}
className="flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-2">
<p className="truncate font-semibold text-foreground">{a.docNo}</p>
<AdjustmentStatusBadge status={a.status} />
@@ -197,8 +236,10 @@ export default function AdjustmentsListPage() {
</TableHeader>
<TableBody>
{adjustments.map((a) => (
<TableRow key={a.adjustmentId} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{a.docNo}</TableCell>
<TableRow key={a.adjustmentId} className="cursor-pointer align-top hover:bg-muted/20" onClick={() => open(a.adjustmentId)}>
<TableCell className="font-semibold text-foreground">
<Link href={`/dashboard/stock/adjustments/${a.adjustmentId}`} className="hover:underline">{a.docNo}</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{whCode(a.warehouseId)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
<TableCell>
@@ -1,6 +1,7 @@
"use client"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
@@ -103,7 +104,13 @@ export default function CountDetailPage() {
const result = await stockCountsApi.post(countId)
setPostResult(result)
setCount((prev) => (prev ? { ...prev, status: result.status } : prev))
toast.success("Count posted", `Variance adjustment ${result.adjustmentId} created.`)
const pending = result.adjustmentStatus && result.adjustmentStatus !== "Posted"
toast.success(
"Count posted",
result.adjustmentId === null ? "No variances — nothing to adjust."
: pending ? `Variances submitted for approval as ${result.adjustmentDocNo ?? `adjustment #${result.adjustmentId}`}.`
: `Variance adjustment ${result.adjustmentDocNo ?? result.adjustmentId} posted.`
)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not post count", errorMessage(err))
@@ -171,9 +178,22 @@ export default function CountDetailPage() {
<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">Posted — variance adjustment #{postResult.adjustmentId} created</p>
<p className="text-base font-semibold">
{postResult.adjustmentId === null
? "Posted — no variances"
: postResult.adjustmentStatus && postResult.adjustmentStatus !== "Posted"
? `Posted — variances submitted for approval (${postResult.adjustmentDocNo ?? `#${postResult.adjustmentId}`})`
: `Posted — variance adjustment ${postResult.adjustmentDocNo ?? `#${postResult.adjustmentId}`} created`}
</p>
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
{postResult.adjustmentId !== null && postResult.adjustmentStatus && postResult.adjustmentStatus !== "Posted" ? (
<div className="text-sm text-muted-foreground">
Stock moves once the variance adjustment is approved.{" "}
<Link href={`/dashboard/stock/adjustments/${postResult.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
</div>
) : (
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
)}
{postResult.glJournalNo && (
<div className="text-sm text-muted-foreground">GL journal: <span className="font-medium text-foreground">{postResult.glJournalNo}</span></div>
)}
@@ -111,7 +111,8 @@ export default function NewWastagePage() {
reasonCodeId: reasonCodeId as number,
})
setResult(adjustment)
toast.success("Wastage recorded", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
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"}.`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not record wastage", errorMessage(err))
@@ -152,9 +153,20 @@ export default function NewWastagePage() {
<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.docNo} posted</p>
<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 className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
</div>
<div className="flex gap-3">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
@@ -10,6 +10,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ApprovalQueueButton } from "@/components/approvals/ApprovalQueueButton"
import { ReasonCode } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
@@ -92,10 +93,13 @@ export default function WastagePage() {
</p>
</div>
</div>
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
Record Wastage
</Link>
<div className="flex flex-col gap-2 sm:flex-row">
<ApprovalQueueButton docType="ADJ" />
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
<Plus className="size-5" />
Record Wastage
</Link>
</div>
</div>
{error && (
@@ -200,7 +204,7 @@ export default function WastagePage() {
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Doc No</p>
<p className="font-medium text-foreground">{r.docNo}</p>
<p className="font-medium text-foreground">{r.docNo}{r.status !== "Posted" && <span className="ml-2 rounded bg-warning/10 px-1.5 py-0.5 text-xs text-warning">Pending approval</span>}</p>
</div>
<div>
<p className="text-muted-foreground">Warehouse</p>
@@ -253,7 +257,7 @@ export default function WastagePage() {
const reason = reasonById.get(r.reasonCodeId)
return (
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`} className="align-top hover:bg-muted/20">
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
<TableCell className="font-semibold text-foreground">{r.docNo}{r.status !== "Posted" && <span className="ml-2 rounded bg-warning/10 px-1.5 py-0.5 text-xs font-normal text-warning">Pending approval</span>}</TableCell>
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${r.itemId}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item?.name}</TableCell>