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>
@@ -25,7 +25,10 @@ import {
HelpCircle,
IdCard,
Inbox,
ClipboardCheck,
Landmark,
PiggyBank,
Tag,
LayoutGrid,
LayoutList,
LayoutTemplate,
@@ -38,7 +41,6 @@ import {
PackageCheck,
PackageSearch,
PackageX,
Percent,
PlayCircle,
PieChart,
Receipt,
@@ -50,6 +52,7 @@ import {
ShieldCheck,
ShoppingCart,
SlidersHorizontal,
Tags,
Truck,
Undo2,
Users,
@@ -59,6 +62,7 @@ import {
type LucideIcon,
} from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
@@ -102,9 +106,11 @@ const navItems: {
icon: ListTree,
altCodes: ["products.brand", "products.uom"],
},
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
],
},
// Open to everyone signed in: the inbox only lists what the user may approve and their own requests.
{ title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck },
{
title: "Procurement",
code: "procurement",
@@ -135,7 +141,8 @@ const navItems: {
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
{ title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList },
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
@@ -215,6 +222,9 @@ const navItems: {
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
{ title: "Received Cheques", code: "accounts.received-cheques", href: "/dashboard/accounts/received-cheques", icon: Inbox },
{ title: "Expenses", code: "accounts.expenses", href: "/dashboard/accounts/expenses", icon: ReceiptText },
{ title: "Expense Setup", code: "accounts.expense-setup", href: "/dashboard/accounts/expense-setup", icon: Tags },
{ title: "Bank Deposits", code: "accounts.bank-deposits", href: "/dashboard/accounts/bank-deposits", icon: PiggyBank },
],
},
{
@@ -226,6 +236,7 @@ const navItems: {
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
{ title: "Approvals", code: "settings.approvals", href: "/dashboard/settings/approvals", icon: ClipboardCheck },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
],
},
@@ -239,6 +250,7 @@ function SidebarContent({
onClose,
pathname,
isMobile,
approvalCount,
}: {
items: typeof navItems
collapsed: boolean
@@ -246,6 +258,8 @@ function SidebarContent({
onClose?: () => void
pathname: string
isMobile: boolean
/** Requests the user can approve — the badge on Approvals. */
approvalCount: number
}) {
const iconOnly = !isMobile && collapsed
@@ -367,6 +381,11 @@ function SidebarContent({
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && <span className="flex-1">{item.title}</span>}
{item.code === "approvals" && approvalCount > 0 && (
<span className="ml-auto rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums">
{approvalCount}
</span>
)}
</Link>
)}
</div>
@@ -449,7 +468,16 @@ export function AppSidebar() {
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side.
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
// "approvals" is not a permission at all — the inbox is for everyone (see its nav entry).
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"])
// Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when
// someone has most likely just approved or submitted something.
const [approvalCount, setApprovalCount] = useState(0)
useEffect(() => {
if (loading) return
approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0))
}, [loading, pathname])
const visibleItems = loading
? []
: navItems
@@ -489,6 +517,7 @@ export function AppSidebar() {
onCollapse={() => setCollapsed((v) => !v)}
pathname={pathname}
isMobile={false}
approvalCount={approvalCount}
/>
</div>
@@ -525,6 +554,7 @@ export function AppSidebar() {
onClose={() => setMobileOpen(false)}
pathname={pathname}
isMobile={true}
approvalCount={approvalCount}
/>
</div>
</>
@@ -72,6 +72,9 @@ const ACCOUNTS_TITLES: Record<string, string> = {
"/dashboard/accounts/cheque-books/new": "New Cheque Book",
"/dashboard/accounts/received-cheques": "Received Cheques",
"/dashboard/accounts/received-cheques/new": "New Received Cheque",
"/dashboard/accounts/expenses": "Expenses",
"/dashboard/accounts/expenses/new": "New Expense",
"/dashboard/accounts/expense-setup": "Expense Setup",
}
const STOCK_TITLES: Record<string, string> = {
@@ -104,6 +107,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
@@ -113,6 +117,8 @@ function titleFromPath(pathname: string) {
if (ACCOUNTS_TITLES[pathname]) return ACCOUNTS_TITLES[pathname]
if (/^\/dashboard\/accounts\/cheque-books\/[^/]+$/.test(pathname)) return "Cheque Book"
if (/^\/dashboard\/accounts\/expenses\/[^/]+\/edit$/.test(pathname)) return "Edit Expense"
if (/^\/dashboard\/accounts\/expenses\/[^/]+$/.test(pathname)) return "Expense"
if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname]
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
@@ -0,0 +1,438 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Plus } from "lucide-react"
import { expenseSetupApi, expensesApi } from "@/lib/api/expenses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Branch } from "@/types/hrm"
import { Vendor } from "@/types/master-data"
import { CreateExpenseRequest, Expense, ExpenseCategory, ExpenseTitle } from "@/types/expenses"
import { useAuth } from "@/components/auth/AuthProvider"
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, 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"
/** What to do once the draft is saved: just keep it, post it, or open the Post & Pay dialog. */
export type ExpenseSaveAction = "draft" | "post" | "pay"
interface ExpenseFormProps {
/** The draft being edited, with its ETag; omitted for a new expense. */
existing?: { expense: Expense; etag: string | null }
/** Called with the saved (and, for "post", posted) expense. */
onSaved: (expense: Expense, action: ExpenseSaveAction) => void
cancelHref: string
}
type Errors = Partial<Record<"expenseDate" | "dueDate" | "categoryId" | "titleId" | "amount" | "periodMonth", string>>
/** Create/edit form for a Draft expense: Category → Title (filtered), payee/vendor, amount, dates. */
export function ExpenseForm({ existing, onSaved, cancelHref }: ExpenseFormProps) {
const { isAdmin, loading: authLoading, navCodes, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const initial = existing?.expense
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [branchPickId, setBranchPickId] = useState<number | null>(initial?.branchId ?? null)
const [expenseDate, setExpenseDate] = useState(initial?.expenseDate ?? localToday())
const [dueDate, setDueDate] = useState(initial?.dueDate ?? "")
const [categoryId, setCategoryId] = useState<number | null>(initial?.expenseCategoryId ?? null)
const [titleId, setTitleId] = useState<number | null>(initial?.expenseTitleId ?? null)
const [vendorId, setVendorId] = useState<number | null>(initial?.vendorId ?? null)
const [payee, setPayee] = useState(initial?.payee ?? "")
const [billReference, setBillReference] = useState(initial?.billReference ?? "")
const [periodMonth, setPeriodMonth] = useState(initial?.periodMonth ?? "")
const [amount, setAmount] = useState(initial ? initial.amount.toFixed(2) : "")
const [description, setDescription] = useState(initial?.description ?? "")
const [errors, setErrors] = useState<Errors>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState<ExpenseSaveAction | null>(null)
const [addTitleOpen, setAddTitleOpen] = useState(false)
const canAddTitle = isAdmin || navCodes.includes("accounts.expense-setup")
useEffect(() => {
expenseSetupApi
.list()
.then(setCategories)
.catch((err) => setLoadError(errorMessage(err)))
vendorsApi.list({ pageSize: 200 }).then((r) => setVendors(r.items)).catch(() => setVendors([]))
}, [])
useEffect(() => {
if (branchingEnabled && isAdmin) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled, isAdmin])
const category = categories?.find((c) => c.expenseCategoryId === categoryId) ?? null
const titles: ExpenseTitle[] = useMemo(() => category?.titles ?? [], [category])
// A draft whose category/title has since been deactivated still shows it, so the user can see what to change.
const inactiveSelection = !!initial && categories !== null && categoryId !== null && !titles.some((t) => t.expenseTitleId === titleId)
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchPickId) : myBranchId) : null
function validate(): Errors {
const next: Errors = {}
const value = Number(amount)
if (!expenseDate) next.expenseDate = "Select the expense date"
else if (expenseDate > localToday()) next.expenseDate = "Expense date cannot be in the future"
if (dueDate && expenseDate && dueDate < expenseDate) next.dueDate = "Due date cannot be before the expense date"
if (!categoryId) next.categoryId = "Select a category"
if (!titleId) next.titleId = "Select a title"
else if (inactiveSelection) next.titleId = "This title is inactive — pick another"
if (!amount || Number.isNaN(value) || value <= 0) next.amount = "Enter an amount greater than zero"
if (periodMonth && !/^\d{4}-(0[1-9]|1[0-2])$/.test(periodMonth)) next.periodMonth = "Use the month picker"
return next
}
async function save(action: ExpenseSaveAction) {
setSubmitError(null)
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
const request: CreateExpenseRequest = {
branchId: effectiveBranchId ?? 0,
expenseDate,
dueDate: dueDate || null,
expenseCategoryId: categoryId!,
expenseTitleId: titleId!,
vendorId,
payee: payee.trim() || null,
billReference: billReference.trim() || null,
description: description.trim() || null,
periodMonth: periodMonth || null,
amount: Number(amount),
}
setSaving(action)
let saved: Expense
try {
saved = existing
? (await expensesApi.update(existing.expense.expenseId, request, existing.etag ?? "")).data
: (await expensesApi.create(request)).data
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not save expense", errorMessage(err))
setSaving(null)
return
}
if (action === "post") {
try {
saved = await expensesApi.post(saved.expenseId)
toast.success("Expense posted", `${saved.docNo} — journal ${saved.glJournalNo ?? "—"}`)
} catch (err) {
// The draft is saved either way; say so, then carry on to it.
toast.error(`${saved.docNo} was saved as a draft, but could not be posted`, errorMessage(err))
}
} else if (action === "draft") {
toast.success("Expense saved", `${saved.docNo} is a draft — post it when ready.`)
}
setSaving(null)
onSaved(saved, action)
}
if (loadError) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
if (categories === null || authLoading) return <Skeleton className="h-96 w-full" />
return (
<div className="flex flex-col gap-6">
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<FieldGroup>
{branchingEnabled && isAdmin && !existing && (
<Field className="sm:max-w-xs">
<FieldLabel>Branch</FieldLabel>
{showBranchPicker ? (
<Select<number | null> value={branchPickId} onValueChange={(v) => setBranchPickId(v)}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Default 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>
)}
</Field>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.categoryId}>
<FieldLabel htmlFor="exp-category" required>Category</FieldLabel>
<Select<number | null>
value={categoryId}
onValueChange={(v) => {
setCategoryId(v)
setTitleId(null)
}}
>
<SelectTrigger id="exp-category" className="h-11! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder={inactiveSelection ? initial?.categoryName : "Select category"} />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.expenseCategoryId} value={c.expenseCategoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</Field>
<Field data-invalid={!!errors.titleId}>
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor="exp-title" required>Title</FieldLabel>
{canAddTitle && category && (
<button type="button" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-3.5" />
Add title
</button>
)}
</div>
<Select<number | null> value={titleId} onValueChange={(v) => setTitleId(v)} disabled={!category}>
<SelectTrigger id="exp-title" className="h-11! w-full text-base" aria-invalid={!!errors.titleId}>
<SelectValue placeholder={inactiveSelection ? initial?.titleName : category ? "Select title" : "Pick a category first"} />
</SelectTrigger>
<SelectContent>
{titles.map((t) => (
<SelectItem key={t.expenseTitleId} value={t.expenseTitleId} className="text-base">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.titleId ? { message: errors.titleId } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="exp-amount" required>Amount</FieldLabel>
<Input
id="exp-amount"
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-invalid={!!errors.amount}
className="h-11 text-base tabular-nums"
/>
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field data-invalid={!!errors.expenseDate}>
<FieldLabel htmlFor="exp-date" required>Bill date</FieldLabel>
<Input
id="exp-date"
type="date"
max={localToday()}
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
aria-invalid={!!errors.expenseDate}
className="h-11 text-base"
/>
<FieldError errors={[errors.expenseDate ? { message: errors.expenseDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.dueDate}>
<FieldLabel htmlFor="exp-due">Due date</FieldLabel>
<Input
id="exp-due"
type="date"
min={expenseDate || undefined}
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
aria-invalid={!!errors.dueDate}
className="h-11 text-base"
/>
<FieldError errors={[errors.dueDate ? { message: errors.dueDate } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor="exp-vendor">Vendor (optional)</FieldLabel>
<Select<number | null> value={vendorId} onValueChange={(v) => setVendorId(v)}>
<SelectTrigger id="exp-vendor" className="h-11! w-full text-base">
<SelectValue placeholder="No vendor" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">No vendor</SelectItem>
{vendors.map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="exp-payee">Payee</FieldLabel>
<Input
id="exp-payee"
value={payee}
maxLength={150}
placeholder={vendorId ? "Defaults to the vendor" : "e.g. CEB, landlord"}
onChange={(e) => setPayee(e.target.value)}
className="h-11 text-base"
/>
</Field>
<Field>
<FieldLabel htmlFor="exp-ref">Bill / account ref</FieldLabel>
<Input id="exp-ref" value={billReference} maxLength={50} onChange={(e) => setBillReference(e.target.value)} className="h-11 text-base" />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.periodMonth}>
<FieldLabel htmlFor="exp-period">Period covered</FieldLabel>
<Input
id="exp-period"
type="month"
value={periodMonth}
onChange={(e) => setPeriodMonth(e.target.value)}
aria-invalid={!!errors.periodMonth}
className="h-11 text-base"
/>
<FieldError errors={[errors.periodMonth ? { message: errors.periodMonth } : undefined]} />
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="exp-desc">Description</FieldLabel>
<Textarea id="exp-desc" value={description} maxLength={500} rows={2} onChange={(e) => setDescription(e.target.value)} className="text-base" />
</Field>
</div>
</FieldGroup>
</div>
{submitError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href={cancelHref} className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" variant="outline" onClick={() => save("draft")} disabled={saving !== null}>
{saving === "draft" ? "Saving…" : "Save draft"}
</Button>
<Button size="lg" variant="outline" onClick={() => save("post")} disabled={saving !== null}>
{saving === "post" ? "Posting…" : "Save & post"}
</Button>
<Button size="lg" onClick={() => save("pay")} disabled={saving !== null}>
{saving === "pay" ? "Saving…" : "Save & pay"}
</Button>
</div>
{category && (
<AddTitleDialog
open={addTitleOpen}
onOpenChange={setAddTitleOpen}
category={category}
onCreated={(title) => {
setCategories((prev) =>
prev?.map((c) => (c.expenseCategoryId === title.expenseCategoryId ? { ...c, titles: [...c.titles, title] } : c)) ?? prev
)
setTitleId(title.expenseTitleId)
}}
/>
)}
</div>
)
}
/** Quick-add a custom title under the chosen category, defaulting its GL code to the category's first title's. */
export function AddTitleDialog({
open,
onOpenChange,
category,
onCreated,
}: {
open: boolean
onOpenChange: (open: boolean) => void
category: ExpenseCategory
onCreated: (title: ExpenseTitle) => void
}) {
const [name, setName] = useState("")
const [glCode, setGlCode] = useState("")
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const openKey = open ? category.expenseCategoryId : null
const [resetFor, setResetFor] = useState<number | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setName("")
setGlCode(category.titles[0]?.glExpenseAccountCode ?? "")
setError(null)
}
if (!open && resetFor !== null) setResetFor(null)
async function submit() {
if (!name.trim()) return setError("Enter a title name")
if (!glCode.trim()) return setError("Enter the GL expense account code")
setSaving(true)
try {
const title = await expenseSetupApi.createTitle(category.expenseCategoryId, { name: name.trim(), glExpenseAccountCode: glCode.trim() })
toast.success("Title added", `${title.name} under ${category.name}`)
onCreated(title)
onOpenChange(false)
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add title to {category.name}</DialogTitle>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-title-name" required>Title</FieldLabel>
<Input id="new-title-name" value={name} maxLength={100} autoFocus onChange={(e) => setName(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="new-title-gl" required>GL expense account code</FieldLabel>
<Input id="new-title-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
</Field>
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving ? "Adding…" : "Add title"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,234 @@
"use client"
import { useEffect, useState } from "react"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { expensesApi } from "@/lib/api/expenses"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { Expense, ExpensePaymentMethod, ExpenseSummary, expensePaymentMethodLabels } from "@/types/expenses"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import {
localToday,
PaymentLine,
PaymentLinesEditor,
round2,
validatePaymentLines,
} from "@/components/sales/PaymentLinesEditor"
interface ExpensePaymentDialogProps {
// ExpenseSummary is enough (id/docNo/status/amount/balance), so a list row can open it directly.
expense: ExpenseSummary | null
open: boolean
onOpenChange: (open: boolean) => void
/** Called after a successful submit; the caller reloads whatever it shows. */
onDone: (updated: Expense | null) => void
}
const methods = Object.keys(expensePaymentMethodLabels) as ExpensePaymentMethod[]
/** Pay an expense, in full or partially — split across one or more GL cash/bank accounts, posted as
* one GL entry. A Draft expense is posted and paid in a single server transaction (Post & Pay), so a
* failure never leaves it half-done; any unpaid remainder stays as the balance for a later payment. */
export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: ExpensePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
// The list comes from the external GL service, which can be slow or fail; keep the error so the
// user sees why the list is empty and can retry, instead of a silently blank dropdown.
const [accountsError, setAccountsError] = useState<string | null>(null)
const [lines, setLines] = useState<PaymentLine[]>([])
const [method, setMethod] = useState<ExpensePaymentMethod>("Cash")
const [chequeNo, setChequeNo] = useState("")
const [reference, setReference] = useState("")
const [paymentDate, setPaymentDate] = useState(localToday)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Reset for a fresh open (or a different expense) during render — the same "adjust state while
// rendering" pattern as PurchaseInvoicePaymentDialog.
const openKey = open && expense ? `${expense.expenseId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setLines([{ key: 0, glBankAccountId: "", amount: expense!.balanceAmount.toFixed(2) }])
setMethod("Cash")
setChequeNo("")
setReference("")
setPaymentDate(localToday())
setErrors({})
// A failed load is retried on the next open rather than cached as an empty list.
if (accountsError) {
setAccounts(null)
setAccountsError(null)
}
}
if (!open && resetFor !== null) setResetFor(null)
useEffect(() => {
if (!open || accounts !== null) return
bankAccountsApi
.list("Both")
.then((rows) => {
setAccounts(rows)
setAccountsError(null)
})
.catch((err) => {
setAccounts([])
setAccountsError(errorMessage(err))
})
}, [open, accounts])
function retryAccounts() {
setAccounts(null)
setAccountsError(null)
}
if (!expense) return null
const isDraft = expense.status === "Draft"
const balance = expense.balanceAmount
async function submit() {
if (!expense) return
const nextErrors = validatePaymentLines(lines, balance)
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
if (method === "Cheque" && !chequeNo.trim()) nextErrors.chequeNo = "Enter the cheque number"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
const request = {
lines: lines.map((l) => ({ glBankAccountId: Number(l.glBankAccountId), amount: Number(l.amount) })),
paymentMethod: method,
chequeNo: method === "Cheque" ? chequeNo.trim() : undefined,
reference: reference.trim() || undefined,
paymentDate,
}
setSubmitting(true)
try {
if (isDraft) {
const updated = await expensesApi.postAndPay(expense.expenseId, request)
toast.success(
"Expense posted and paid",
`${updated.docNo} — ${formatAmount(updated.paidAmount)} paid${updated.balanceAmount > 0 ? `, ${formatAmount(updated.balanceAmount)} still due` : " in full"}.`
)
onDone(updated)
} else {
const rows = await expensesApi.pay(expense.expenseId, request)
const paid = round2(rows.reduce((sum, r) => sum + r.amount, 0))
toast.success(
"Payment recorded",
`${formatAmount(paid)} posted to the ledger (${rows[0]?.glJournalNo ?? "—"}) — ${paid >= balance ? "fully paid" : "partially paid"}.`
)
onDone(null)
}
onOpenChange(false)
} catch (err) {
toast.error(isDraft ? "Could not post and pay the expense" : "Could not record payment", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
Pay the full balance or change the amount to pay part of it now.{" "}
{expense.categoryName} · {expense.titleName} — balance due:{" "}
<span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
</p>
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.paymentDate}>
<FieldLabel htmlFor="exp-pay-date" required>Payment date</FieldLabel>
<Input
id="exp-pay-date"
type="date"
max={localToday()}
value={paymentDate}
onChange={(e) => setPaymentDate(e.target.value)}
aria-invalid={!!errors.paymentDate}
/>
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="exp-pay-method" required>Payment method</FieldLabel>
<Select<ExpensePaymentMethod> value={method} onValueChange={(v) => v && setMethod(v)}>
<SelectTrigger id="exp-pay-method" className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{methods.map((m) => (
<SelectItem key={m} value={m} className="text-base">
{expensePaymentMethodLabels[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{method === "Cheque" && (
<Field data-invalid={!!errors.chequeNo}>
<FieldLabel htmlFor="exp-pay-cheque" required>Cheque no.</FieldLabel>
<Input
id="exp-pay-cheque"
value={chequeNo}
maxLength={30}
onChange={(e) => setChequeNo(e.target.value)}
aria-invalid={!!errors.chequeNo}
/>
<FieldError errors={[errors.chequeNo ? { message: errors.chequeNo } : undefined]} />
</Field>
)}
<Field>
<FieldLabel htmlFor="exp-pay-reference">Reference (optional)</FieldLabel>
<Input id="exp-pay-reference" value={reference} maxLength={100} onChange={(e) => setReference(e.target.value)} />
</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={retryAccounts}>
Retry
</Button>
</div>
)}
{accounts !== null && accounts.length === 0 && !accountsError && (
<p className="text-sm text-muted-foreground">
No cash/bank accounts exist in the ledger yet — add one under Accounts → Cash / Bank Accounts.
</p>
)}
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,14 @@
import { Badge } from "@/components/ui/badge"
import { BankDepositStatus } from "@/types/bank-deposits"
// Same fixed-width pill as expense-badges.tsx.
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
export function BankDepositStatusBadge({ status }: { status: BankDepositStatus }) {
const tone = status === "Posted" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
return (
<Badge variant="outline" className={`${badgeSize} border-transparent ${tone}`}>
{status}
</Badge>
)
}
@@ -0,0 +1,44 @@
import { Badge } from "@/components/ui/badge"
import { ExpensePaymentStatus, ExpenseStatus } from "@/types/expenses"
// Fixed width so every pill lines up — same convention as components/procurement/status-badges.tsx.
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
function statusClass(status: ExpenseStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Cancelled
}
export function ExpenseStatusBadge({ status }: { status: ExpenseStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${statusClass(status)}`}>
{status}
</Badge>
)
}
function paymentClass(status: ExpensePaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Unpaid
}
const paymentLabel: Record<ExpensePaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function ExpensePaymentStatusBadge({ status }: { status: ExpensePaymentStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
/** Posted with a balance and a due date already passed (compared as local `YYYY-MM-DD`). */
export function isExpenseOverdue(e: { status: ExpenseStatus; balanceAmount: number; dueDate: string | null }, today: string): boolean {
return e.status === "Posted" && e.balanceAmount > 0 && e.dueDate !== null && e.dueDate < today
}
@@ -0,0 +1,240 @@
"use client"
import { useState } from "react"
import { Check, CircleDashed, ShieldCheck, Undo2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { ApprovalStep, PriceAdjustmentStatus } from "@/types/approvals"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
interface ApprovalPanelProps {
/** "stock adjustment" / "price adjustment" — used in the wording. */
what: string
requiredApprovals: number
/** The approval it waits for (1 or 2); null when it's no longer pending. */
pendingLevel: number | null
history: ApprovalStep[]
requestedByLabel: string
requestedAt: string
canApprove: boolean
whyNot: string | null
canWithdraw: boolean
/** What happens on the final approval, e.g. "Stock moves and the ledger is posted." */
finalEffect: string
outcome: "pending" | "applied" | "rejected" | "withdrawn"
outcomeAt?: string | null
rejectReason?: string | null
onApprove: (comment: string | undefined) => Promise<void>
onReject: (reason: string) => Promise<void>
onWithdraw: () => Promise<void>
}
/**
* The approval trail and the actions for the current user (docs/DUAL_APPROVAL_PLAN.md §10): who
* requested it, each level's decision, and Approve / Reject / Withdraw when the user may.
*/
export function ApprovalPanel(props: ApprovalPanelProps) {
const { what, requiredApprovals, pendingLevel, history, canApprove, whyNot, canWithdraw, outcome } = props
const [busy, setBusy] = useState<"approve" | "reject" | "withdraw" | null>(null)
const [comment, setComment] = useState("")
const [rejectOpen, setRejectOpen] = useState(false)
const [reason, setReason] = useState("")
const [reasonError, setReasonError] = useState<string | null>(null)
const isFinal = pendingLevel !== null && pendingLevel >= requiredApprovals
const levels = Array.from({ length: requiredApprovals }, (_, i) => i + 1)
async function run(kind: "approve" | "withdraw", action: () => Promise<void>) {
setBusy(kind)
try {
await action()
setComment("")
} catch {
// The caller has already shown the error.
} finally {
setBusy(null)
}
}
async function reject() {
if (!reason.trim()) {
setReasonError("Give a reason")
return
}
setBusy("reject")
try {
await props.onReject(reason.trim())
setRejectOpen(false)
setReason("")
} catch {
// The caller has already shown the error; keep the dialog open to retry.
} finally {
setBusy(null)
}
}
if (requiredApprovals === 0) {
return (
<div className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm">
<ShieldCheck className="size-4" />
Applied without approval (approvals were switched off when it was made).
</div>
)
}
return (
<div className="flex flex-col gap-4 rounded-xl border bg-card p-4 shadow-sm sm:p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">Approval</h2>
<Badge variant="outline" className="border-transparent bg-muted text-xs">
{requiredApprovals === 2 ? "Two approvals required" : "One approval required"}
</Badge>
</div>
<ol className="flex flex-col gap-3">
<Step
state="done"
title={`Requested by ${props.requestedByLabel}`}
detail={new Date(props.requestedAt).toLocaleString()}
/>
{levels.map((level) => {
const step = history.find((s) => s.level === level)
if (step) {
return (
<Step
key={level}
state={step.decision === "Approved" ? "done" : "rejected"}
title={`${step.decision === "Approved" ? "Approved" : "Rejected"} (${ordinal(level)} approval) by ${step.decidedByName ?? `#${step.decidedBy}`}`}
detail={`${new Date(step.decidedAt).toLocaleString()}${step.comment ? ` — “${step.comment}”` : ""}`}
/>
)
}
const waiting = outcome === "pending" && pendingLevel === level
return (
<Step
key={level}
state={waiting ? "waiting" : "idle"}
title={`${ordinal(level)} approval`}
detail={waiting ? (level === 2 ? "Waiting — must be a different person from the first approver" : "Waiting for an approver") : "—"}
/>
)
})}
</ol>
{outcome === "rejected" && props.rejectReason && (
<p className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">Rejected: {props.rejectReason}</p>
)}
{outcome === "applied" && (
<p className="rounded-lg bg-success/10 p-3 text-sm text-success">
Fully approved{props.outcomeAt ? ` and applied ${new Date(props.outcomeAt).toLocaleString()}` : ""}.
</p>
)}
{outcome === "withdrawn" && <p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">Withdrawn by the requester.</p>}
{outcome === "pending" && (
<div className="flex flex-col gap-3 border-t pt-4">
{canApprove ? (
<>
<Field>
<FieldLabel htmlFor="approval-comment">Comment (optional)</FieldLabel>
<Textarea id="approval-comment" rows={2} maxLength={250} value={comment} onChange={(e) => setComment(e.target.value)} />
</Field>
<p className="text-xs text-muted-foreground">
{isFinal ? `This is the final approval. ${props.finalEffect}` : "After you approve, a second, different person must approve it too."}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => { setReasonError(null); setRejectOpen(true) }} disabled={busy !== null}>
<X className="size-4" />
Reject
</Button>
<Button onClick={() => run("approve", () => props.onApprove(comment.trim() || undefined))} disabled={busy !== null}>
<Check className="size-4" />
{busy === "approve" ? "Approving…" : isFinal ? "Approve & apply" : "Approve"}
</Button>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{whyNot ?? "You can't approve this."}</p>
)}
{canWithdraw && (
<Button variant="ghost" size="sm" className="self-end" onClick={() => run("withdraw", props.onWithdraw)} disabled={busy !== null}>
<Undo2 className="size-4" />
{busy === "withdraw" ? "Withdrawing…" : "Withdraw my request"}
</Button>
)}
</div>
)}
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reject this {what}</DialogTitle>
<DialogDescription>Nothing will be applied. The requester is told your reason.</DialogDescription>
</DialogHeader>
<Field data-invalid={!!reasonError}>
<FieldLabel htmlFor="reject-reason" required>Reason</FieldLabel>
<Textarea id="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={() => setRejectOpen(false)} disabled={busy === "reject"}>Keep it</Button>
<Button variant="destructive" onClick={reject} disabled={busy === "reject"}>{busy === "reject" ? "Rejecting…" : "Reject"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function ordinal(level: number) {
return level === 1 ? "1st" : level === 2 ? "2nd" : `${level}th`
}
function Step({ state, title, detail }: { state: "done" | "rejected" | "waiting" | "idle"; title: string; detail: string }) {
const Icon = state === "done" ? Check : state === "rejected" ? X : CircleDashed
return (
<li className="flex items-start gap-3">
<span
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full",
state === "done" && "bg-success/15 text-success",
state === "rejected" && "bg-destructive/15 text-destructive",
state === "waiting" && "bg-warning/15 text-warning",
state === "idle" && "bg-muted text-muted-foreground"
)}
>
<Icon className="size-3.5" />
</span>
<div className="min-w-0">
<p className={cn("text-sm font-medium", state === "idle" ? "text-muted-foreground" : "text-foreground")}>{title}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</div>
</li>
)
}
const priceLabel: Record<PriceAdjustmentStatus, string> = {
PendingApproval: "Pending",
PendingSecondApproval: "Pending 2nd",
Approved: "Applied",
Rejected: "Rejected",
Cancelled: "Withdrawn",
}
export function PriceAdjustmentStatusBadge({ status }: { status: PriceAdjustmentStatus }) {
const tone =
status === "Approved" ? "bg-success/10 text-success"
: status === "Rejected" ? "bg-destructive/10 text-destructive"
: status === "Cancelled" ? "bg-muted text-muted-foreground"
: "bg-warning/10 text-warning"
return (
<Badge variant="outline" className={cn("h-6 w-28 justify-center border-transparent px-2.5 py-1 text-sm", tone)}>
{priceLabel[status]}
</Badge>
)
}
@@ -0,0 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ClipboardCheck } from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { ApprovalDocType } from "@/types/approvals"
import { buttonVariants } from "@/components/ui/button"
/**
* "Approval queue" button for list pages: opens the Approvals inbox and shows how many requests of
* this kind are waiting for the current user (docs/DUAL_APPROVAL_PLAN.md §10).
*/
export function ApprovalQueueButton({ docType, className }: { docType?: ApprovalDocType; className?: string }) {
const [count, setCount] = useState<number | null>(null)
useEffect(() => {
approvalsApi
.pending()
.then((p) => setCount(p.items.filter((i) => i.canApprove && (!docType || i.docType === docType)).length))
.catch(() => setCount(null))
}, [docType])
return (
<Link href="/dashboard/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto", className)}>
<ClipboardCheck className="size-5" />
Approval queue
{count !== null && count > 0 && (
<span className="rounded-full bg-primary px-2 text-xs font-semibold leading-5 text-primary-foreground tabular-nums">{count}</span>
)}
</Link>
)
}
@@ -0,0 +1,76 @@
"use client"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js"
import { useTheme } from "next-themes"
import { Line } from "react-chartjs-2"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { DayEndReportDay } from "@/types/sales"
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
const SERIES = {
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
dark: { income: "#3987e5", sales: "#d95926", expenses: "#199e70", profit: "#c98500", grid: "rgba(255,255,255,0.1)", text: "#c3c2b7" },
}
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
const { resolvedTheme } = useTheme()
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
const labels = days.map((d) => formatReportDate(d.date))
const line = (label: string, data: number[], color: string, width: number) => ({
label,
data,
borderColor: color,
backgroundColor: color,
borderWidth: width,
pointRadius: days.length > 45 ? 0 : 3,
pointHoverRadius: 5,
tension: 0.25,
fill: false,
})
return (
<div className="h-72 w-full">
<Line
data={{
labels,
datasets: [
line("Income", days.map((d) => d.income), c.income, 2.5),
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } },
tooltip: {
callbacks: {
label: (ctx) => {
const y = ctx.parsed.y ?? 0
if (ctx.dataset.label === "Income")
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
const p = profitOrLoss(y)
return `${p.label}: ${p.amount}`
},
},
},
},
scales: {
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } },
},
}}
/>
</div>
)
}
@@ -95,6 +95,15 @@ function SerialPicker({
const shown = (available ?? []).filter((s) => !query.trim() || s.serialNo.toLowerCase().includes(query.trim().toLowerCase()))
// "Select all" ticks the listed (search-filtered) serials, but never more than the line still needs:
// a serial line must carry exactly `units` serials.
const unselectedShown = shown.filter((s) => !selectedIds.has(s.serialId))
const toAdd = unselectedShown.slice(0, Math.max(0, units - selected.length))
function selectAll() {
onChange({ ...value, serials: [...selected, ...toAdd.map((s) => ({ serialId: s.serialId, serialNo: s.serialNo }))] })
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
@@ -127,9 +136,22 @@ function SerialPicker({
className="h-9 text-sm"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<p className={cn("text-xs", complete ? "text-success" : "text-muted-foreground")}>
{selected.length} of {units} selected
</p>
<div className="flex items-center justify-between gap-2">
<p className={cn("text-xs", complete ? "text-success" : "text-muted-foreground")}>
{selected.length} of {units} selected
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={toAdd.length === 0}
onClick={selectAll}
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined}
>
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"}
</Button>
</div>
<div className="max-h-60 overflow-y-auto rounded-md border">
{available === null && !error && <p className="p-3 text-xs text-muted-foreground">Loading…</p>}
{available !== null && shown.length === 0 && (
@@ -10,8 +10,9 @@ import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
/** One split of a payment: how it was paid (the GL cash/bank account) and how much. */
export type PaymentLine = { key: number; glBankAccountId: string; amount: string }
/** One split of a payment: how it was paid (the GL cash/bank account) and how much. `method` is only
* used when the editor is given `methodOptions` (sales payments: cash vs card for the Day-End Report). */
export type PaymentLine = { key: number; glBankAccountId: string; amount: string; method?: string }
export const round2 = (n: number) => Math.round(n * 100) / 100
@@ -21,6 +22,12 @@ export function localToday(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Sales payments' default method for an account — mirrors the backend's fallback: money into a Cash
* account is cash, into a Bank account a card payment. The user can still change it per line. */
export function salesMethodForAccount(account: CashAndBankAccountDto): string {
return account.accountType === "Cash" ? "Cash" : "Card"
}
export function paymentLinesTotal(lines: PaymentLine[]): number {
return round2(lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0))
}
@@ -46,17 +53,28 @@ interface PaymentLinesEditorProps {
/** What is owed — the fixed "Total"; Paid is the lines' sum and Remaining the difference. */
balance: number
errors: Record<string, string>
/** When given, each line also gets a payment-method select (value → label). */
methodOptions?: Record<string, string>
/** The method a line switches to when its account is picked (e.g. a Cash account ⇒ "Cash"). */
methodForAccount?: (account: CashAndBankAccountDto) => string
}
/** Payment type + paid amount rows (add/remove), with a Total / Paid amount / Remaining summary. */
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors }: PaymentLinesEditorProps) {
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
const paid = paymentLinesTotal(lines)
const remaining = round2(balance - paid)
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]"
function updateLine(key: number, patch: Partial<PaymentLine>) {
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function selectAccount(key: number, glBankAccountId: string) {
const account = accounts?.find((a) => String(a.accountId) === glBankAccountId)
const method = account && methodForAccount ? methodForAccount(account) : undefined
updateLine(key, method ? { glBankAccountId, method } : { glBankAccountId })
}
function addLine() {
// A new line starts with whatever is still unpaid, so a two-way split is one edit.
const key = Math.max(-1, ...lines.map((l) => l.key)) + 1
@@ -65,16 +83,17 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-[1fr_8rem_8rem] gap-2 text-sm font-medium">
<div className={`grid ${cols} gap-2 text-sm font-medium`}>
<span>Payment type</span>
{methodOptions && <span>Method</span>}
<span>Paid amount</span>
<span />
</div>
{lines.map((line, idx) => (
<div key={line.key} className="grid grid-cols-[1fr_8rem_8rem] items-start gap-2">
<div key={line.key} className={`grid ${cols} items-start gap-2`}>
<div>
<Select<string> value={line.glBankAccountId} onValueChange={(v) => updateLine(line.key, { glBankAccountId: v ?? "" })}>
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
<SelectTrigger
className="w-full text-base"
aria-label={`Payment type, line ${idx + 1}`}
@@ -92,6 +111,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors
</Select>
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
</div>
{methodOptions && (
<div>
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
<SelectTrigger className="w-full text-base" aria-label={`Payment method, line ${idx + 1}`}>
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
{Object.entries(methodOptions).map(([value, label]) => (
<SelectItem key={value} value={value} label={label} className="text-base">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Input
type="number"
@@ -7,7 +7,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoicePayment, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceSummary, SalesPaymentMethod, salesPaymentMethodLabels } from "@/types/sales"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -20,6 +20,7 @@ import {
PaymentLinesEditor,
paymentLinesTotal,
round2,
salesMethodForAccount,
validatePaymentLines,
} from "@/components/sales/PaymentLinesEditor"
@@ -114,7 +115,11 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
try {
const payments = await salesApi.payInvoicesBulk({
salesInvoiceIds: ordered.map((i) => i.salesInvoiceId),
lines: lines.map((l) => ({ amount: Number(l.amount), glBankAccountId: Number(l.glBankAccountId) })),
lines: lines.map((l) => ({
amount: Number(l.amount),
glBankAccountId: Number(l.glBankAccountId),
paymentMethod: l.method as SalesPaymentMethod | undefined,
})),
reference: reference || undefined,
paymentDate,
})
@@ -135,7 +140,7 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
Pay {ordered.length} invoices — {customerName}
@@ -204,7 +209,15 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
<Input id="bulk-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
<PaymentLinesEditor
lines={lines}
onChange={setLines}
accounts={accounts}
balance={balance}
errors={errors}
methodOptions={salesPaymentMethodLabels}
methodForAccount={salesMethodForAccount}
/>
</FieldGroup>
<DialogFooter>
@@ -7,14 +7,21 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary, SalesPaymentMethod, salesPaymentMethodLabels } from "@/types/sales"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
import {
localToday,
PaymentLine,
PaymentLinesEditor,
round2,
salesMethodForAccount,
validatePaymentLines,
} from "@/components/sales/PaymentLinesEditor"
interface SalesInvoicePaymentDialogProps {
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
@@ -100,6 +107,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
amount: Number(line.amount),
glBankAccountId: Number(line.glBankAccountId),
paymentMethod: line.method as SalesPaymentMethod | undefined,
reference: reference || undefined,
paymentDate,
})
@@ -137,7 +145,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader>
@@ -179,7 +187,15 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
<PaymentLinesEditor
lines={lines}
onChange={setLines}
accounts={accounts}
balance={balance}
errors={errors}
methodOptions={salesPaymentMethodLabels}
methodForAccount={salesMethodForAccount}
/>
</FieldGroup>
<DialogFooter>
@@ -26,10 +26,24 @@ export function CountStatusBadge({ status }: { status: CountStatus }) {
)
}
const adjustmentLabel: Record<AdjustmentStatus, string> = {
Draft: "Draft",
PendingApproval: "Pending",
PendingSecondApproval: "Pending 2nd",
Posted: "Posted",
Rejected: "Rejected",
Cancelled: "Withdrawn",
}
export function AdjustmentStatusBadge({ status }: { status: AdjustmentStatus }) {
const tone =
status === "Posted" ? success
: status === "Rejected" ? destructive
: status === "Cancelled" ? "bg-muted text-muted-foreground border-transparent"
: warning
return (
<Badge variant="outline" className={`${badgeSize} ${success}`}>
{status}
<Badge variant="outline" className={`${badgeSize} ${tone}`}>
{adjustmentLabel[status]}
</Badge>
)
}
+62
View File
@@ -0,0 +1,62 @@
// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10), plus price adjustments (§6).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ApprovalConfig,
CreatePriceAdjustmentRequest,
ItemPriceHistory,
PendingApprovals,
PriceAdjustment,
PriceAdjustmentStatus,
PriceAdjustmentSummary,
UpdateApprovalConfigRequest,
} from "@/types/approvals"
export const approvalsApi = {
/** What you can approve, plus your own requests that are waiting for others. */
pending(): Promise<PendingApprovals> {
return apiRequest<PendingApprovals>("/approvals/pending")
},
getConfig(): Promise<ApprovalConfig> {
return apiRequest<ApprovalConfig>("/approvals/config")
},
/** Needs settings.approvals. */
updateConfig(request: UpdateApprovalConfigRequest): Promise<ApprovalConfig> {
return apiRequest<ApprovalConfig>("/approvals/config", { method: "PUT", body: request })
},
}
export const priceAdjustmentsApi = {
list(params: { page?: number; pageSize?: number; q?: string; status?: PriceAdjustmentStatus; itemId?: number } = {}): Promise<PagedResponse<PriceAdjustmentSummary>> {
return apiRequest<PagedResponse<PriceAdjustmentSummary>>(`/price-adjustments${buildQuery(params)}`)
},
get(priceAdjustmentId: number): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}`)
},
/** 409 PRICE_ADJUSTMENT_ITEM_PENDING when an item already has a pending request. */
create(request: CreatePriceAdjustmentRequest): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>("/price-adjustments", { method: "POST", body: request })
},
/** The final approval applies the prices; 409 PRICE_ADJUSTMENT_STALE if an item was repriced meanwhile. */
approve(priceAdjustmentId: number, comment?: string): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/approve`, { method: "POST", body: { comment } })
},
reject(priceAdjustmentId: number, reason: string): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/reject`, { method: "POST", body: { reason } })
},
/** Requester only, while pending. */
cancel(priceAdjustmentId: number): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/cancel`, { method: "POST" })
},
itemHistory(itemId: number): Promise<ItemPriceHistory[]> {
return apiRequest<ItemPriceHistory[]>(`/items/${itemId}/price-history`)
},
}
@@ -0,0 +1,43 @@
// Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3). create() posts GL (Debit bank /
// Credit cash) and is rejected above the cash in hand; cancel() posts the reversing entry.
import { apiRequest, buildQuery } from "@/lib/api-client"
import {
BankDeposit,
BankDepositList,
BankDepositListFilterParams,
CashInHand,
CashInHandDaily,
CreateBankDepositRequest,
} from "@/types/bank-deposits"
export const bankDepositsApi = {
list(params: BankDepositListFilterParams = {}): Promise<BankDepositList> {
return apiRequest<BankDepositList>(`/bank-deposits${buildQuery(params)}`)
},
get(bankDepositId: number): Promise<BankDeposit> {
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}`)
},
/** 400 BANK_DEPOSIT_EXCEEDS_CASH_IN_HAND when the amount is more than can still be deposited for that day. */
create(request: CreateBankDepositRequest): Promise<BankDeposit> {
return apiRequest<BankDeposit>("/bank-deposits", { method: "POST", body: request })
},
/** Posted only; puts the cash back in hand. */
cancel(bankDepositId: number, reason: string): Promise<BankDeposit> {
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}/cancel`, { method: "POST", body: { reason } })
},
/** `date` omitted ⇒ today in the business time zone. `branchId` is ignored for branch users. */
cashInHand(params: { date?: string; branchId?: number | null } = {}): Promise<CashInHand> {
return apiRequest<CashInHand>(`/bank-deposits/cash-in-hand${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
},
/** At most 92 days. */
cashInHandDaily(params: { from: string; to: string; branchId?: number | null }): Promise<CashInHandDaily> {
return apiRequest<CashInHandDaily>(
`/bank-deposits/cash-in-hand/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
)
},
}
@@ -0,0 +1,17 @@
// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Read-only: one day's sales,
// payments by method, expenses and income, and the same day-wise over a range (≤ 92 days).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { DayEndDailyReport, DayEndReport } from "@/types/sales"
export const dayEndReportApi = {
/** `date` omitted ⇒ today in the report's time zone. `branchId` is ignored for non-admins. */
getDay(params: { date?: string; branchId?: number | null } = {}): Promise<DayEndReport> {
return apiRequest<DayEndReport>(`/day-end-report${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
},
getDaily(params: { from: string; to: string; branchId?: number | null }): Promise<DayEndDailyReport> {
return apiRequest<DayEndDailyReport>(
`/day-end-report/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
)
},
}
+93
View File
@@ -0,0 +1,93 @@
// One typed client method per Expense endpoint (docs/EXPENSE_MODULE_PLAN.md §6). Draft
// expenses are editable via update(); post() accrues to GL, pay()/postAndPay() settle it.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreateExpensePaymentRequest,
CreateExpenseRequest,
CreateExpenseTitleRequest,
Expense,
ExpenseCategory,
ExpenseListFilterParams,
ExpensePayment,
ExpenseSummary,
ExpenseSummaryReport,
ExpenseTitle,
UpdateExpenseCategoryRequest,
UpdateExpenseRequest,
UpdateExpenseTitleRequest,
} from "@/types/expenses"
export const expenseSetupApi = {
/** Categories with their titles, in display order. Inactive rows only when asked for. */
list(includeInactive = false): Promise<ExpenseCategory[]> {
return apiRequest<ExpenseCategory[]>(`/expense-categories${buildQuery({ includeInactive: includeInactive || undefined })}`)
},
/** Categories are fixed (no create); in practice only the active flag changes — they're all built-in. */
updateCategory(expenseCategoryId: number, request: UpdateExpenseCategoryRequest): Promise<ExpenseCategory> {
return apiRequest<ExpenseCategory>(`/expense-categories/${expenseCategoryId}`, { method: "PUT", body: request })
},
createTitle(expenseCategoryId: number, request: CreateExpenseTitleRequest): Promise<ExpenseTitle> {
return apiRequest<ExpenseTitle>(`/expense-categories/${expenseCategoryId}/titles`, { method: "POST", body: request })
},
/** 409 EXPENSE_SYSTEM_ROW_READONLY when renaming a built-in title (GL code and status stay editable). */
updateTitle(expenseTitleId: number, request: UpdateExpenseTitleRequest): Promise<ExpenseTitle> {
return apiRequest<ExpenseTitle>(`/expense-titles/${expenseTitleId}`, { method: "PUT", body: request })
},
}
export const expensesApi = {
list(params: ExpenseListFilterParams = {}): Promise<PagedResponse<ExpenseSummary>> {
return apiRequest<PagedResponse<ExpenseSummary>>(`/expenses${buildQuery(params)}`)
},
/** Posted expenses dated in [from, to] (default: this month), plus the unpaid/overdue position. */
summary(params: { from?: string; to?: string } = {}): Promise<ExpenseSummaryReport> {
return apiRequest<ExpenseSummaryReport>(`/expenses/summary${buildQuery(params)}`)
},
get(expenseId: number): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`)
},
create(request: CreateExpenseRequest): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>("/expenses", { method: "POST", body: request })
},
/** Draft only (409 EXPENSE_NOT_DRAFT otherwise). Echo the ETag from get()/create() as ifMatch. */
update(expenseId: number, request: UpdateExpenseRequest, ifMatch: string): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`, { method: "PUT", body: request, ifMatch })
},
/** Draft only. */
remove(expenseId: number): Promise<void> {
return apiRequest<void>(`/expenses/${expenseId}`, { method: "DELETE" })
},
/** Accrue to GL: Debit the title's expense account / Credit Accrued Expenses. */
post(expenseId: number): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/post`, { method: "POST" })
},
/** Draft only; posts and pays in one transaction — full or partial (the rest stays as the balance). */
postAndPay(expenseId: number, request: CreateExpensePaymentRequest): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/post-and-pay`, { method: "POST", body: request })
},
/** Draft, or Posted with no payments (posts a reversing GL entry). */
cancel(expenseId: number, reason: string): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/cancel`, { method: "POST", body: { reason } })
},
/** Posted only; total capped at the balance. Returns one row per payment line. */
pay(expenseId: number, request: CreateExpensePaymentRequest): Promise<ExpensePayment[]> {
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`, { method: "POST", body: request })
},
listPayments(expenseId: number): Promise<ExpensePayment[]> {
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`)
},
}
+6
View File
@@ -8,6 +8,7 @@ import {
ItemImage,
ItemListItem,
ItemOverview,
ItemPurchasePrice,
ItemReorderSetting,
ItemScanResult,
StockNature,
@@ -95,6 +96,11 @@ export const itemsApi = {
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
},
/** Latest posted GRN receipts of the item (newest first) — what it was bought at. */
purchasePrices(itemId: number, take = 5): Promise<ItemPurchasePrice[]> {
return apiRequest<ItemPurchasePrice[]>(`/items/${itemId}/purchase-prices${buildQuery({ take })}`)
},
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
method: "PUT",
@@ -1,8 +1,9 @@
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
// Auto-posts on create with a mandatory reason code (FR-STK-07).
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5). Creating
// submits a request with a mandatory reason code; it moves stock once approved
// (docs/DUAL_APPROVAL_PLAN.md §5), or at once when approvals are switched off.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
export interface ListAdjustmentsParams {
page?: number
@@ -11,6 +12,7 @@ export interface ListAdjustmentsParams {
warehouseId?: number
branchId?: number
reasonCodeId?: number
status?: AdjustmentStatus
sort?: string
}
@@ -30,4 +32,18 @@ export const stockAdjustmentsApi = {
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
},
/** Next approval; the final one posts (409 STOCK_NEGATIVE_BLOCKED if stock no longer allows it). */
approve(adjustmentId: number, comment?: string): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/approve`, { method: "POST", body: { comment } })
},
reject(adjustmentId: number, reason: string): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/reject`, { method: "POST", body: { reason } })
},
/** Requester only, while pending. */
cancel(adjustmentId: number): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/cancel`, { method: "POST" })
},
}
+7 -3
View File
@@ -7,7 +7,7 @@
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { stockApi } from "@/lib/api/stock"
import { StockAdjustment } from "@/types/stock"
import { AdjustmentStatus, StockAdjustment } from "@/types/stock"
/**
* Reason codes treated as "wastage" (loss-type) causes. These match the seeded codes in
@@ -37,8 +37,10 @@ export interface WastageRecord {
reasonCodeId: number
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
qty: number
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted. */
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted (0 until posted). */
value: number
/** Posted, or still waiting for approval (rejected/withdrawn requests are left out). */
status: AdjustmentStatus
}
export interface ListWastageParams {
@@ -81,7 +83,8 @@ export const wastageApi = {
page: params.page,
pageSize: params.pageSize ?? 50,
})
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId))
// A rejected or withdrawn request never wrote anything off.
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId) && a.status !== "Rejected" && a.status !== "Cancelled")
const detailed = await Promise.all(
summaries.map(async (summary) => {
@@ -107,6 +110,7 @@ export const wastageApi = {
reasonCodeId: adjustment.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
status: adjustment.status,
}
})
}),
+12
View File
@@ -15,6 +15,18 @@ export function formatAmount(value: number | null | undefined, zeroDash = false)
return value < 0 ? `(${formatted})` : formatted
}
/** Like {@link formatAmount} but a negative keeps a leading minus instead of parentheses: `-1234.5` -> "-1,234.50". */
export function formatSignedAmount(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—"
return value < 0 ? `-${AMOUNT_FORMATTER.format(Math.abs(value))}` : AMOUNT_FORMATTER.format(value)
}
/** A profit figure as words + amount: `1200` -> { label: "Profit", amount: "1,200.00" }, `-300` -> { label: "Loss", amount: "300.00" }. */
export function profitOrLoss(value: number): { label: "Profit" | "Loss"; amount: string; isLoss: boolean } {
const isLoss = value < 0
return { label: isLoss ? "Loss" : "Profit", amount: formatAmount(Math.abs(value)), isLoss }
}
/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */
export function formatReportDate(value: string | null | undefined): string {
if (!value) return "—"
@@ -0,0 +1,428 @@
// Day-End Sales Report PDFs (docs/DAY_END_SALES_REPORT_PLAN.md §6) — built in the browser with
// jsPDF (the backend has no PDF library). The table is drawn directly rather than through
// jspdf-autotable, so no extra dependency; the chart is rendered on an offscreen canvas in light
// colours, so the printout never depends on the on-screen theme.
import type { jsPDF as JsPdf } from "jspdf"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay } from "@/types/sales"
const PAGE = { width: 210, height: 297, margin: 14 }
const BOTTOM = PAGE.height - 16
type Column = { header: string; width: number; align?: "left" | "right" }
async function newDoc(): Promise<JsPdf> {
const { jsPDF } = await import("jspdf")
return new jsPDF({ unit: "mm", format: "a4" })
}
/** Title block; returns the y where content starts. */
function header(doc: JsPdf, title: string, subtitle: string, branchLabel: string): number {
doc.setFont("helvetica", "bold")
doc.setFontSize(16)
doc.text(title, PAGE.margin, 20)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
doc.setTextColor(90)
doc.text(subtitle, PAGE.margin, 27)
doc.text(`Branch: ${branchLabel}`, PAGE.margin, 32)
doc.text(`Generated ${new Date().toLocaleString()}`, PAGE.width - PAGE.margin, 32, { align: "right" })
doc.setTextColor(0)
doc.setDrawColor(200)
doc.line(PAGE.margin, 35, PAGE.width - PAGE.margin, 35)
return 42
}
function footer(doc: JsPdf) {
const pages = doc.getNumberOfPages()
for (let i = 1; i <= pages; i++) {
doc.setPage(i)
doc.setFontSize(8)
doc.setTextColor(120)
doc.text(`Page ${i} of ${pages}`, PAGE.width - PAGE.margin, PAGE.height - 8, { align: "right" })
doc.setTextColor(0)
}
}
/** Label/value pairs in a grid of boxes. */
function summaryGrid(doc: JsPdf, y: number, items: [string, string][], perRow = 4): number {
const gap = 3
const w = (PAGE.width - PAGE.margin * 2 - gap * (perRow - 1)) / perRow
const h = 14
items.forEach(([label, value], i) => {
const x = PAGE.margin + (i % perRow) * (w + gap)
const top = y + Math.floor(i / perRow) * (h + gap)
doc.setDrawColor(215)
doc.roundedRect(x, top, w, h, 1.5, 1.5)
doc.setFontSize(8)
doc.setTextColor(100)
doc.text(label, x + 2.5, top + 5)
doc.setFontSize(11)
doc.setTextColor(0)
doc.setFont("helvetica", "bold")
doc.text(value, x + 2.5, top + 11)
doc.setFont("helvetica", "normal")
})
return y + Math.ceil(items.length / perRow) * (h + gap) + 2
}
function note(doc: JsPdf, y: number, text: string): number {
doc.setFontSize(9)
doc.setTextColor(110)
doc.text(text, PAGE.margin, y + 4)
doc.setTextColor(0)
return y + 8
}
function sectionTitle(doc: JsPdf, y: number, text: string): number {
doc.setFont("helvetica", "bold")
doc.setFontSize(11)
doc.text(text, PAGE.margin, y)
doc.setFont("helvetica", "normal")
return y + 4
}
/** Cut `text` with "..." so it fits `maxWidth` mm at the current font. */
function fit(doc: JsPdf, text: string, maxWidth: number): string {
if (doc.getTextWidth(text) <= maxWidth) return text
let cut = text
while (cut.length > 1 && doc.getTextWidth(`${cut}...`) > maxWidth) cut = cut.slice(0, -1)
return `${cut}...`
}
/** A table that breaks across pages, repeating its header; `totals` is a bold last row. */
function table(doc: JsPdf, y: number, columns: Column[], rows: string[][], totals?: string[]): number {
const rowH = 6.5
const drawHeader = (top: number) => {
doc.setFillColor(238, 238, 236)
doc.rect(PAGE.margin, top, PAGE.width - PAGE.margin * 2, rowH, "F")
doc.setFont("helvetica", "bold")
drawRow(top, columns.map((c) => c.header))
doc.setFont("helvetica", "normal")
return top + rowH
}
const drawRow = (top: number, cells: string[]) => {
doc.setFontSize(8.5)
let x = PAGE.margin
columns.forEach((col, i) => {
const text = fit(doc, cells[i] ?? "", col.width - 4)
if (col.align === "right") doc.text(text, x + col.width - 2, top + 4.5, { align: "right" })
else doc.text(text, x + 2, top + 4.5)
x += col.width
})
}
let top = drawHeader(y)
const all = totals ? [...rows, totals] : rows
all.forEach((cells, idx) => {
if (top + rowH > BOTTOM) {
doc.addPage()
top = drawHeader(20)
}
const isTotals = totals !== undefined && idx === all.length - 1
if (isTotals) {
doc.setDrawColor(150)
doc.line(PAGE.margin, top, PAGE.width - PAGE.margin, top)
doc.setFont("helvetica", "bold")
} else if (idx % 2 === 1) {
doc.setFillColor(248, 248, 246)
doc.rect(PAGE.margin, top, PAGE.width - PAGE.margin * 2, rowH, "F")
}
drawRow(top, cells)
doc.setFont("helvetica", "normal")
top += rowH
})
return top + 4
}
/** The income/sales/expenses line chart as a PNG, drawn offscreen in the light palette. */
async function chartImage(days: DayEndReportDay[]): Promise<string> {
const { Chart, CategoryScale, LinearScale, PointElement, LineElement, Legend } = await import("chart.js")
Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Legend)
const canvas = document.createElement("canvas")
canvas.width = 1600
canvas.height = 640
const series = (label: string, data: number[], color: string) => ({
label, data, borderColor: color, backgroundColor: color, borderWidth: 3,
pointRadius: days.length > 45 ? 0 : 4, tension: 0.25, fill: false,
})
const chart = new Chart(canvas, {
type: "line",
data: {
labels: days.map((d) => formatReportDate(d.date)),
datasets: [
series("Income", days.map((d) => d.income), "#2a78d6"),
series("Sales", days.map((d) => d.totalSales), "#eb6834"),
series("Expenses", days.map((d) => d.expenseTotal), "#1baf7a"),
series("Profit / loss", days.map((d) => d.netProfit), "#eda100"),
],
},
options: {
responsive: false,
animation: false,
devicePixelRatio: 1,
plugins: { legend: { position: "top", align: "start", labels: { font: { size: 22 }, usePointStyle: true } } },
scales: {
x: { grid: { display: false }, ticks: { font: { size: 18 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
y: { grid: { color: "rgba(0,0,0,0.08)" }, ticks: { font: { size: 18 }, callback: (v) => formatAmount(Number(v)) } },
},
},
})
const png = chart.toBase64Image("image/png", 1)
chart.destroy()
return png
}
const amount = (n: number) => formatAmount(n)
/** Income with a minus sign when negative, marked as a loss: "Loss -300.00". */
const incomeText = (n: number) => (n < 0 ? `Loss ${formatSignedAmount(n)}` : formatSignedAmount(n))
/** "Profit 1,200.00" / "Loss 300.00" — a loss is labelled, not just negative. */
const profitText = (n: number) => {
const p = profitOrLoss(n)
return `${p.label} ${p.amount}`
}
function totalsGrid(t: DayEndReportDay): [string, string][] {
const net = profitOrLoss(t.netProfit)
return [
["Total sales", amount(t.totalSales)],
["Paid", amount(t.paidAmount)],
["Due", amount(t.dueAmount)],
["Expenses", amount(t.expenseTotal)],
["Paid by cash", amount(t.paidByCash)],
["Paid by card", amount(t.paidByCard)],
[t.income < 0 ? "Income (loss)" : "Income", formatSignedAmount(t.income)],
[`Net ${net.label.toLowerCase()}`, net.amount],
]
}
const DAILY_COLUMNS: Column[] = [
{ header: "Date", width: 21 },
{ header: "Sales", width: 20, align: "right" },
{ header: "Paid", width: 19, align: "right" },
{ header: "Due", width: 17, align: "right" },
{ header: "Cash", width: 18, align: "right" },
{ header: "Card", width: 18, align: "right" },
{ header: "Expenses", width: 19, align: "right" },
{ header: "Income", width: 20, align: "right" },
{ header: "Profit / loss", width: 30, align: "right" },
]
const dailyCells = (d: DayEndReportDay, label: string) => [
label, amount(d.totalSales), amount(d.paidAmount), amount(d.dueAmount),
amount(d.paidByCash), amount(d.paidByCard), amount(d.expenseTotal), incomeText(d.income), profitText(d.netProfit),
]
const CASH_COLUMNS: Column[] = [
{ header: "Date", width: 30 },
{ header: "Opening cash", width: 38, align: "right" },
{ header: "Net cash", width: 38, align: "right" },
{ header: "Deposited to bank", width: 38, align: "right" },
{ header: "Cash in hand", width: 38, align: "right" },
]
const cashCells = (d: DayEndReportDay, label: string) => [
label, amount(d.openingCashInHand ?? 0), amount(d.netCash), amount(d.cashDeposited ?? 0), amount(d.closingCashInHand ?? 0),
]
/** Sales − cost = gross, − expenses = net profit/loss, as a right-aligned statement. */
function profitStatement(doc: JsPdf, y: number, s: DayEndReportDay): number {
const gross = profitOrLoss(s.grossProfit)
const net = profitOrLoss(s.netProfit)
const left = PAGE.width - PAGE.margin - 95
const right = PAGE.width - PAGE.margin
const rows: [string, string][] = [
["Total sales", amount(s.totalSales)],
["Less: cost of goods sold", `(${amount(s.costOfGoodsSold)})`],
[`Gross ${gross.label.toLowerCase()}`, gross.amount],
["Less: expenses", `(${amount(s.expenseTotal)})`],
]
doc.setFontSize(10)
rows.forEach(([label, value]) => {
doc.text(label, left, y)
doc.text(value, right, y, { align: "right" })
y += 6
})
doc.setDrawColor(150)
doc.line(left, y - 3.5, right, y - 3.5)
doc.setFont("helvetica", "bold")
doc.setFontSize(12)
if (net.isLoss) doc.setTextColor(200, 40, 40)
else doc.setTextColor(0, 120, 0)
doc.text(`Net ${net.label.toLowerCase()}`, left, y + 2)
doc.text(net.amount, right, y + 2, { align: "right" })
doc.setTextColor(0)
doc.setFont("helvetica", "normal")
return y + 10
}
/** Day-wise income for a range: totals, the chart, then the daily table (with a totals row). */
export async function downloadDailyIncomePdf(report: DayEndDailyReport, branchLabel: string) {
const doc = await newDoc()
let y = header(
doc,
"Day-wise Income Report",
`${formatReportDate(report.from)} to ${formatReportDate(report.to)} (${report.days.length} days)`,
branchLabel
)
y = summaryGrid(doc, y, totalsGrid(report.totals))
y = sectionTitle(doc, y + 2, "Daily income")
const chartW = PAGE.width - PAGE.margin * 2
const chartH = chartW * (640 / 1600)
doc.addImage(await chartImage(report.days), "PNG", PAGE.margin, y, chartW, chartH)
y += chartH + 6
if (y + 20 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y, "Day-wise breakdown")
y = table(
doc,
y,
DAILY_COLUMNS,
report.days.map((d) => dailyCells(d, formatReportDate(d.date))),
dailyCells(report.totals, "Total")
)
// Its own table: the breakdown above already fills the page width.
if (y + 20 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y + 2, "Cash in hand and bank deposits")
const t = report.totals
table(
doc,
y,
CASH_COLUMNS,
report.days.map((d) => cashCells(d, formatReportDate(d.date))),
// Opening and closing are balances: the range opens with its first day and closes with its last.
["Total", amount(t.openingCashInHand ?? 0), amount(t.netCash), amount(t.cashDeposited ?? 0), amount(t.closingCashInHand ?? 0)]
)
footer(doc)
doc.save(`daily-income_${report.from}_${report.to}.pdf`)
}
/** One business day: its figures, sale counts by payment method, the sales and expense lists, then profit/loss. */
export async function downloadDayEndReportPdf(report: DayEndReport, branchLabel: string) {
const s = report.summary
const sales = report.sales ?? []
const expenses = report.expenses ?? []
const deposits = report.deposits ?? []
const doc = await newDoc()
let y = header(doc, "Day-End Sales Report", formatReportDate(s.date), branchLabel)
y = sectionTitle(doc, y, "Sales")
y = summaryGrid(doc, y + 1, [
["Total sales", amount(s.totalSales)],
["Paid amount", amount(s.paidAmount)],
["Due amount", amount(s.dueAmount)],
["Paid by cash", amount(s.paidByCash)],
["Paid by card", amount(s.paidByCard)],
["Paid other ways", amount(s.paidByOther)],
["Earlier invoices collected", amount(s.earlierInvoiceCollections)],
])
y = sectionTitle(doc, y + 2, "Cash in hand")
y = summaryGrid(doc, y + 1, [
["Opening cash in hand", amount(s.openingCashInHand ?? 0)],
["Net cash today", amount(s.netCash)],
["Deposited to bank", amount(s.cashDeposited ?? 0)],
["Remaining cash in hand", amount(s.closingCashInHand ?? 0)],
])
if (deposits.length > 0) {
y = table(
doc,
y,
[
{ header: "Deposit", width: 32 },
{ header: "Bank account", width: 62 },
{ header: "Slip no.", width: 30 },
{ header: "Deposited on", width: 28 },
{ header: "Amount", width: 30, align: "right" },
],
deposits.map((d) => [
d.docNo,
[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · "),
d.depositSlipNo ?? "-",
formatReportDate(d.depositDate),
amount(d.amount),
]),
["Total", "", "", "", amount(s.cashDeposited ?? 0)]
)
}
y = sectionTitle(doc, y + 2, "Sales count")
y = summaryGrid(doc, y + 1, [
["All sales", String(s.salesCount)],
["Cash sales", String(s.cashSalesCount)],
["Card sales", String(s.cardSalesCount)],
["Mixed / other / unpaid", `${s.mixedSalesCount} / ${s.otherSalesCount} / ${s.unpaidSalesCount}`],
])
y = sectionTitle(doc, y + 2, `Sales of the day (${sales.length})`)
if (sales.length === 0) {
y = note(doc, y, "No posted sales on this day.")
} else {
y = table(
doc,
y,
[
{ header: "Time", width: 13 },
{ header: "No.", width: 28 },
{ header: "Customer", width: 37 },
{ header: "Paid by", width: 20 },
{ header: "Total", width: 22, align: "right" },
{ header: "Due", width: 20, align: "right" },
{ header: "Cost", width: 20, align: "right" },
{ header: "Profit", width: 22, align: "right" },
],
sales.map((r) => [
new Date(r.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
r.docNo, r.customerName, r.paidBy, amount(r.total), amount(r.due), amount(r.cost), amount(r.grossProfit),
]),
["", "Total", "", "", amount(s.totalSales), amount(s.dueAmount), amount(s.costOfGoodsSold), amount(s.grossProfit)]
)
}
if (y + 20 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y + 2, `Expenses of the day (${expenses.length})`)
if (expenses.length === 0) {
y = note(doc, y, "No posted expenses on this day.")
} else {
y = table(
doc,
y,
[
{ header: "Doc no.", width: 34 },
{ header: "Category", width: 30 },
{ header: "Title", width: 40 },
{ header: "Payee", width: 46 },
{ header: "Amount", width: 32, align: "right" },
],
expenses.map((e) => [e.docNo, e.categoryName, e.titleName, e.payee ?? "-", amount(e.amount)]),
["Total", "", "", "", amount(s.expenseTotal)]
)
}
if (y + 44 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y + 2, "Profit / loss")
profitStatement(doc, y + 4, s)
footer(doc)
doc.save(`day-end-report_${s.date}.pdf`)
}
+148
View File
@@ -0,0 +1,148 @@
// Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md).
// Mirrors Backend/ERPCore/Dtos/Approvals/ApprovalDtos.cs and Dtos/Products/PriceAdjustmentDtos.cs.
export type ApprovalDecision = "Approved" | "Rejected"
/** One decision in a document's approval trail. */
export interface ApprovalStep {
level: number
decision: ApprovalDecision
decidedBy: number
decidedByName: string | null
decidedAt: string
comment: string | null
}
/** "ADJ" = stock adjustment, "PADJ" = price adjustment. */
export type ApprovalDocType = "ADJ" | "PADJ"
export interface PendingApproval {
docType: ApprovalDocType
docId: number
docNo: string
summary: string
/** The approval it waits for: 1, or 2 after the first. */
level: number
requiredApprovals: number
requestedBy: number
requestedByName: string | null
requestedAt: string
/** Whether you may give that approval; `whyNot` says why not (e.g. it's your own request). */
canApprove: boolean
whyNot: string | null
}
export interface PendingApprovals {
items: PendingApproval[]
/** How many you can act on — the sidebar badge. */
actionableCount: number
}
/** Settings → Approvals. Levels: 0 = applies on create, 1 = one approver, 2 = two different approvers. */
export interface ApprovalConfig {
stockAdjustmentLevels: number
/** With 2 levels: adjustments worth less than this need one approval. 0 = always two. */
stockAdjustmentLevel2ThresholdValue: number
priceAdjustmentLevels: number
/** With 2 levels: price changes smaller than this % need one approval. 0 = always two. */
priceAdjustmentLevel2ThresholdPercent: number
/** Only these roles may give the second approval; empty = anyone with the Approve action. */
level2RoleCodes: string[]
updatedAt: string | null
}
export type UpdateApprovalConfigRequest = Omit<ApprovalConfig, "updatedAt">
// --- Price adjustments ---------------------------------------------------------------------------
export type PriceAdjustmentStatus = "PendingApproval" | "PendingSecondApproval" | "Approved" | "Rejected" | "Cancelled"
/** One item's requested change. current* differ from old* when the item was repriced since (the request is then stale). */
export interface PriceAdjustmentLine {
priceAdjustmentLineId: number
itemId: number
sku: string
itemName: string
oldMinPrice: number | null
oldMaxPrice: number | null
oldFlatPrice: number | null
newMinPrice: number | null
newMaxPrice: number | null
newFlatPrice: number | null
currentMinPrice: number | null
currentMaxPrice: number | null
currentFlatPrice: number | null
}
export interface PriceAdjustmentSummary {
priceAdjustmentId: number
docNo: string
reason: string
status: PriceAdjustmentStatus
requiredApprovals: number
pendingLevel: number | null
lineCount: number
createdBy: number
createdByName: string | null
createdAt: string
appliedAt: string | null
}
export interface PriceAdjustment {
priceAdjustmentId: number
docNo: string
reason: string
status: PriceAdjustmentStatus
requiredApprovals: number
pendingLevel: number | null
createdBy: number
createdByName: string | null
createdAt: string
approvedLevel1By: number | null
approvedLevel1At: string | null
approvedLevel2By: number | null
approvedLevel2At: string | null
rejectedBy: number | null
rejectedAt: string | null
rejectReason: string | null
cancelledAt: string | null
appliedAt: string | null
lines: PriceAdjustmentLine[]
approvalHistory: ApprovalStep[]
canApprove: boolean
whyNot: string | null
canWithdraw: boolean
}
export interface PriceAdjustmentLineInput {
itemId: number
/** The prices the item should have afterwards — null clears that price. */
newMinPrice: number | null
newMaxPrice: number | null
newFlatPrice: number | null
}
export interface CreatePriceAdjustmentRequest {
reason: string
lines: PriceAdjustmentLineInput[]
}
export interface ItemPriceHistory {
itemPriceHistoryId: number
itemId: number
priceAdjustmentId: number | null
docNo: string | null
oldMinPrice: number | null
oldMaxPrice: number | null
oldFlatPrice: number | null
newMinPrice: number | null
newMaxPrice: number | null
newFlatPrice: number | null
requestedBy: number
requestedByName: string | null
approvedLevel1By: number | null
approvedLevel1ByName: string | null
approvedLevel2By: number | null
approvedLevel2ByName: string | null
appliedAt: string
}
+108
View File
@@ -0,0 +1,108 @@
// Bank deposits of till cash and the cash in hand behind them (docs/BANK_DEPOSIT_MODULE_PLAN.md).
// Mirrors Backend/ERPCore/Dtos/Accounts/BankDepositDtos.cs.
import { PaginationMeta } from "@/types/common"
export type BankDepositStatus = "Posted" | "Cancelled"
/**
* One business day's till cash. cashIn = slip/bundle cash + cash invoice payments; cashOut = cash expense
* payments; deposited = Posted deposits for that business day. closing = opening + netCash − deposited,
* and becomes the next day's opening. Days before tracking starts have opening 0.
*/
export interface CashPosition {
date: string
opening: number
cashIn: number
cashOut: number
netCash: number
deposited: number
closing: number
}
export interface CashInHand {
position: CashPosition
/** Lowest closing from that day up to today — a back-dated deposit lowers every later day too. */
availableToDeposit: number
branchId: number | null
trackingStartDate: string | null
timeZone: string
/** GL code of the cash account the deposit form preselects. */
defaultCashAccountCode: string | null
}
export interface CashInHandDaily {
from: string
to: string
days: CashPosition[]
branchId: number | null
trackingStartDate: string | null
timeZone: string
}
export interface BankDeposit {
bankDepositId: number
docNo: string
branchId: number
branchName: string | null
businessDate: string
depositDate: string
amount: number
fromGlCashAccountId: number
fromCashAccountCode: string
fromCashAccountName: string
toGlBankAccountId: number
toBankAccountCode: string
toBankAccountName: string
toBankName: string | null
toAccountNumber: string | null
depositSlipNo: string | null
depositedBy: string | null
remarks: string | null
status: BankDepositStatus
glJournalNo: string | null
glPostedAt: string | null
glReversalJournalNo: string | null
cancelReason: string | null
cancelledAt: string | null
createdBy: number
createdAt: string
}
/** A page of deposits plus the total of every row matching the filter. */
export interface BankDepositList {
items: BankDeposit[]
pagination: PaginationMeta
totalAmount: number
}
export interface BankDepositListFilterParams {
page?: number
pageSize?: number
q?: string
/** Business date range, inclusive. */
from?: string
to?: string
branchId?: number
bankAccountId?: number
status?: BankDepositStatus
}
export interface CreateBankDepositRequest {
/** Required when branching is enabled; ignored for branch users (always their own branch). */
branchId: number
businessDate: string
/** Omitted ⇒ the business date. */
depositDate?: string
amount: number
fromGlCashAccountId: number
toGlBankAccountId: number
depositSlipNo?: string
depositedBy?: string
remarks?: string
}
/** "0001234567" → "••••4567"; short or missing numbers pass through. */
export function maskAccountNumber(accountNumber: string | null | undefined): string {
if (!accountNumber) return ""
return accountNumber.length > 4 ? `••••${accountNumber.slice(-4)}` : accountNumber
}
+3
View File
@@ -92,6 +92,9 @@ export interface BundleSale extends BundleSaleSummary, BundleSaleTotals {
bundleSaleTemplateId: number
updatedAt: string | null
lines: BundleSaleLine[]
/** cashAmount + cardAmount === grandTotal (a bundle sale is always fully paid). */
cashAmount: number
cardAmount: number
}
export interface BundleSalePostingIssue {
+6
View File
@@ -23,6 +23,12 @@ export interface Customer {
status: EntityStatus
createdAt: string
updatedAt: string | null
/** Unpaid balance across Posted sales invoices. Only filled by the list endpoint (0 elsewhere). */
outstandingAmount?: number
/** Posted sales invoices with a balance still due. Only filled by the list endpoint. */
outstandingInvoiceCount?: number
/** Latest invoice payment; null if never paid. Only filled by the list endpoint. */
lastPaymentDate?: string | null
}
export interface CreateCustomerRequest {
+177
View File
@@ -0,0 +1,177 @@
// Expense module types — mirror Backend/ERPCore/Dtos/Expenses/ExpenseDtos.cs
// (docs/EXPENSE_MODULE_PLAN.md). Dates without a time (expenseDate, dueDate, from/to) are
// `YYYY-MM-DD`; periodMonth is `YYYY-MM`.
import { EntityStatus } from "@/types/common"
export type ExpenseStatus = "Draft" | "Posted" | "Cancelled"
export type ExpensePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export type ExpensePaymentMethod = "Cash" | "BankTransfer" | "Cheque" | "Card"
export const expensePaymentMethodLabels: Record<ExpensePaymentMethod, string> = {
Cash: "Cash",
BankTransfer: "Bank transfer",
Cheque: "Cheque",
Card: "Card",
}
export interface ExpenseTitle {
expenseTitleId: number
expenseCategoryId: number
name: string
glExpenseAccountCode: string
/** Seeded rows keep their name; they can only be deactivated (and have their GL code corrected). */
isSystem: boolean
status: EntityStatus
sortOrder: number
}
export interface ExpenseCategory {
expenseCategoryId: number
code: string
name: string
isSystem: boolean
status: EntityStatus
sortOrder: number
titles: ExpenseTitle[]
}
export interface UpdateExpenseCategoryRequest {
name: string
status: EntityStatus
}
export interface CreateExpenseTitleRequest {
name: string
glExpenseAccountCode: string
}
export interface UpdateExpenseTitleRequest {
name: string
glExpenseAccountCode: string
status: EntityStatus
}
export interface ExpenseSummary {
expenseId: number
docNo: string
branchId: number
expenseDate: string
dueDate: string | null
expenseCategoryId: number
categoryName: string
expenseTitleId: number
titleName: string
payee: string | null
billReference: string | null
periodMonth: string | null
amount: number
paidAmount: number
balanceAmount: number
status: ExpenseStatus
paymentStatus: ExpensePaymentStatus
glJournalNo: string | null
createdAt: string
}
export interface ExpensePayment {
expensePaymentId: number
expenseId: number
amount: number
paymentDate: string
paymentMethod: ExpensePaymentMethod
glBankAccountId: number
bankAccountName: string
chequeNo: string | null
reference: string | null
/** Shared by every line of one payment submission (one GL entry per submission). */
glJournalNo: string | null
createdAt: string
}
export interface Expense extends ExpenseSummary {
vendorId: number | null
vendorName: string | null
description: string | null
glExpenseAccountCode: string | null
glPostedAt: string | null
glReversalJournalNo: string | null
cancelReason: string | null
createdBy: number
updatedAt: string | null
payments: ExpensePayment[]
}
export interface CreateExpenseRequest {
/** 0 ⇒ the server resolves the default branch. */
branchId: number
expenseDate: string
dueDate?: string | null
expenseCategoryId: number
expenseTitleId: number
payee?: string | null
vendorId?: number | null
billReference?: string | null
description?: string | null
periodMonth?: string | null
amount: number
}
export type UpdateExpenseRequest = CreateExpenseRequest
export interface ExpensePaymentLineRequest {
glBankAccountId: number
amount: number
}
export interface CreateExpensePaymentRequest {
lines: ExpensePaymentLineRequest[]
paymentMethod: ExpensePaymentMethod
/** Required when paymentMethod is Cheque. */
chequeNo?: string | null
reference?: string | null
/** `YYYY-MM-DD`; omitted ⇒ today; never future. */
paymentDate?: string
}
export interface ExpenseListFilterParams {
page?: number
pageSize?: number
q?: string
status?: ExpenseStatus
paymentStatus?: ExpensePaymentStatus
categoryId?: number
titleId?: number
from?: string
to?: string
overdueOnly?: boolean
}
export interface ExpenseTitleTotal {
expenseTitleId: number
titleName: string
amount: number
count: number
}
export interface ExpenseCategoryTotal {
expenseCategoryId: number
categoryName: string
amount: number
count: number
titles: ExpenseTitleTotal[]
}
export interface ExpenseSummaryReport {
from: string
to: string
totalAmount: number
paidAmount: number
unpaidAmount: number
count: number
/** All-time unpaid balance of Posted expenses (not limited to from/to). */
outstandingAmount: number
overdueAmount: number
overdueCount: number
draftCount: number
categories: ExpenseCategoryTotal[]
}
+15
View File
@@ -82,6 +82,21 @@ export interface ItemOverview {
salesQtyLast30Days: number
}
/** One posted GRN receipt of an item. `netUnitCost` = unit cost after trade discount, VAT excluded — the stock cost. */
export interface ItemPurchasePrice {
grnId: number
grnDocNo: string
receivedAt: string
vendorId: number
vendorName: string | null
warehouseCode: string | null
qty: number
unitCost: number
discountPct: number
netUnitCost: number
vatPct: number
}
export interface ItemReorderSetting {
warehouseId: number
reorderPoint: number
+113 -1
View File
@@ -167,11 +167,24 @@ export interface SalesInvoicePayment {
reference: string | null
glJournalNo: string | null
createdAt: string
paymentMethod: SalesPaymentMethod
}
/** How a customer paid — drives the Day-End Report's cash/card split. */
export type SalesPaymentMethod = "Cash" | "Card" | "BankTransfer" | "Cheque"
export const salesPaymentMethodLabels: Record<SalesPaymentMethod, string> = {
Cash: "Cash",
Card: "Card",
BankTransfer: "Bank transfer",
Cheque: "Cheque",
}
export interface CreateSalesInvoicePaymentRequest {
amount: number
glBankAccountId: number
/** Omitted ⇒ the backend derives it from the account (Cash account ⇒ Cash, Bank ⇒ Card). */
paymentMethod?: SalesPaymentMethod
reference?: string | null
/** `YYYY-MM-DD`; when the customer paid (also the GL entry date). Omitted ⇒ today; never future. */
paymentDate?: string
@@ -180,7 +193,7 @@ export interface CreateSalesInvoicePaymentRequest {
/** Pay several Posted invoices of one (non-cash) customer at once; applied oldest invoice first. */
export interface CreateBulkSalesInvoicePaymentRequest {
salesInvoiceIds: number[]
lines: { amount: number; glBankAccountId: number }[]
lines: { amount: number; glBankAccountId: number; paymentMethod?: SalesPaymentMethod }[]
reference?: string | null
/** `YYYY-MM-DD`; omitted ⇒ today; never future. */
paymentDate?: string
@@ -250,6 +263,9 @@ export interface SalesSlipTotals {
grandTotal: number
paidAmount: number
balanceAmount: number
/** cashAmount + cardAmount === paidAmount. */
cashAmount: number
cardAmount: number
}
export interface SalesSlipSummary {
@@ -308,6 +324,8 @@ export interface CreateSalesSlipRequest {
lines: CreateSalesSlipLineRequest[]
/** Omit for the default: fully paid (paidAmount === grandTotal, balanceAmount === 0). */
paidAmount?: number | null
/** The part of paidAmount tendered by card (the rest is cash). Omit for all cash. */
cardAmount?: number | null
}
export type UpdateSalesSlipRequest = CreateSalesSlipRequest
@@ -595,3 +613,97 @@ export interface CreateSalesDayEndRequest {
cashierUserId: number
businessDate?: string | null
}
// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md) ---------------------------------------
/** One business day's figures — or, with `date` null, a range's totals. Income = totalSales − expenseTotal. */
export interface DayEndReportDay {
date: string | null
totalSales: number
paidAmount: number
dueAmount: number
paidByCash: number
paidByCard: number
/** Bank transfer + cheque. */
paidByOther: number
salesCount: number
cashSalesCount: number
cardSalesCount: number
/** Paid more than one way (e.g. part cash, part card). */
mixedSalesCount: number
otherSalesCount: number
unpaidSalesCount: number
expenseTotal: number
income: number
/** FIFO cost of the stock the day's sales issued. */
costOfGoodsSold: number
/** totalSales − costOfGoodsSold. */
grossProfit: number
/** grossProfit − expenseTotal; negative is a loss. */
netProfit: number
/** Payments received this day on invoices from earlier days — not part of paidAmount. */
earlierInvoiceCollections: number
/** Cash in this day minus expenses paid in cash this day — a till check. */
netCash: number
/** Cash left in the till from earlier days (Bank Deposit module). For a range's totals, the first day's. */
openingCashInHand: number
/** Posted bank deposits made against this business day. */
cashDeposited: number
/** openingCashInHand + netCash − cashDeposited: still in hand. For a range's totals, the last day's. */
closingCashInHand: number
}
/** A Posted bank deposit of the day's cash. */
export interface DayEndReportDeposit {
bankDepositId: number
docNo: string
bankAccountName: string
bankName: string | null
accountNumber: string | null
depositSlipNo: string | null
depositDate: string
amount: number
}
export interface DayEndReportExpense {
expenseId: number
docNo: string
categoryName: string
titleName: string
payee: string | null
amount: number
paidAmount: number
}
export interface DayEndReportSale {
docType: "Invoice" | "Slip" | "Bundle"
docId: number
docNo: string
at: string
customerName: string
total: number
paid: number
due: number
/** Cash, Card, Bank transfer, Cheque, Mixed or Unpaid. */
paidBy: string
cost: number
grossProfit: number
}
export interface DayEndReport {
summary: DayEndReportDay
sales: DayEndReportSale[]
expenses: DayEndReportExpense[]
deposits: DayEndReportDeposit[]
branchId: number | null
timeZone: string
}
export interface DayEndDailyReport {
from: string
to: string
days: DayEndReportDay[]
totals: DayEndReportDay
branchId: number | null
timeZone: string
}
+33 -4
View File
@@ -1,6 +1,7 @@
// Stock Management types (docs/11-BACKEND-PHASE1.md §5; FR-STK-01..14, FR-WH-*).
// Mirrors the live backend DTOs. The list endpoints (GET /stock-transfers,
// /stock-adjustments, /stock-counts, /stock/on-hand/list) all exist as of 2026-07-16.
import { ApprovalStep } from "@/types/approvals"
import { Requisition } from "@/types/procurement"
export type Currency = "LKR"
@@ -165,9 +166,12 @@ export interface ReceiveTransferResponse {
// --- 5.5 Adjustments ------------------------------------------------------------------
/** Phase 1 auto-posts, so `Posted` is what you see; the other two exist for the deferred
* approval workflow (FR-STK-07) and are in the server enum. */
export type AdjustmentStatus = "Draft" | "PendingApproval" | "Posted"
/**
* An adjustment is a request until approved (docs/DUAL_APPROVAL_PLAN.md §5): PendingApproval →
* (PendingSecondApproval →) Posted, or Rejected / Cancelled. It moves stock only when Posted.
* With approvals switched off it is Posted straight away.
*/
export type AdjustmentStatus = "Draft" | "PendingApproval" | "PendingSecondApproval" | "Posted" | "Rejected" | "Cancelled"
export interface AdjustmentLine {
adjLineId: number
@@ -177,6 +181,9 @@ export interface AdjustmentLine {
/** Signed: negative FIFO-consumes, positive creates a layer. */
qtyDelta: number
serialId: number | null
/** Serials named on the request (shown while it waits for approval). */
requestedSerialIds?: number[] | null
requestedSerialNos?: string[] | null
}
export interface StockAdjustment {
@@ -191,6 +198,23 @@ export interface StockAdjustment {
glPostedAt: string | null
lines: AdjustmentLine[]
ledgerRefs: number[]
/** 0 = posted without approval, 1 or 2. */
requiredApprovals: number
/** The approval it waits for (1 or 2); null once decided. */
pendingLevel: number | null
approvedLevel1By: number | null
approvedLevel1At: string | null
approvedLevel2By: number | null
approvedLevel2At: string | null
rejectedBy: number | null
rejectedAt: string | null
rejectReason: string | null
postedAt: string | null
approvalHistory: ApprovalStep[] | null
canApprove: boolean
whyNot: string | null
canWithdraw: boolean
createdByName: string | null
}
export interface StockAdjustmentSummary {
@@ -203,6 +227,8 @@ export interface StockAdjustmentSummary {
createdAt: string
lineCount: number
glJournalNo: string | null
requiredApprovals: number
pendingLevel: number | null
}
export interface CreateAdjustmentLineInput {
@@ -287,9 +313,12 @@ export interface PostCountResponse {
status: CountStatus
/** Null when the count had no variance to post. */
adjustmentId: number | null
/** Null when there was no variance, or the variance's value was 0 either way. */
/** Null when there was no variance, or the variance's value was 0 either way — and while it waits for approval. */
glJournalNo: string | null
ledgerRefs: number[]
/** The variance adjustment's status: PendingApproval when it must be approved before stock moves. */
adjustmentStatus?: AdjustmentStatus | null
adjustmentDocNo?: string | null
}
// --- 5.7 Reorder alerts ------------------------------------------------------------------