price adjustment and stock adjustments
This commit is contained in:
@@ -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'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'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'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>
|
||||
|
||||
Reference in New Issue
Block a user