feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management

- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management.
- Introduced dedicated GL client for API interactions, handling response envelopes and error management.
- Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report.
- Implemented CSV download functionality alongside existing PDF downloads for all report screens.
- Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view.
- Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section.
- Updated RBAC navigation to include new permissions and sub-navigation items for the added features.
- Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes.
- Addressed various bugs and presentation issues, enhancing user experience across the new module.
This commit is contained in:
2026-07-31 17:57:55 +05:30
parent 76484c7268
commit 22657f0910
49 changed files with 5707 additions and 27 deletions
@@ -0,0 +1,204 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, cashAccountTypesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { validateBankAccountForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { CashAccountType, CashBankAccountType } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
const OTHER_CASH_TYPE = "__other__"
export default function NewBankAccountPage() {
const router = useRouter()
const [accountType, setAccountType] = useState<CashBankAccountType>(CashBankAccountType.Bank)
const [cashAccountTypes, setCashAccountTypes] = useState<CashAccountType[] | null>(null)
const [cashAccountTypesError, setCashAccountTypesError] = useState<string | null>(null)
const [accountName, setAccountName] = useState("")
const [bankName, setBankName] = useState("")
const [cashAccountTypeChoice, setCashAccountTypeChoice] = useState("")
const [customCashAccountTypeName, setCustomCashAccountTypeName] = useState("")
const [accountNumber, setAccountNumber] = useState("")
const [currencyCode, setCurrencyCode] = useState("LKR")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (accountType !== CashBankAccountType.Cash || cashAccountTypes !== null) return
cashAccountTypesApi
.list()
.then(setCashAccountTypes)
.catch((err) => setCashAccountTypesError(errorMessage(err)))
// Only fetched once, lazily, the first time "Cash" is selected.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountType])
const cashAccountTypeName =
cashAccountTypeChoice === OTHER_CASH_TYPE ? customCashAccountTypeName.trim() : cashAccountTypeChoice
async function handleSubmit() {
const nextErrors = validateBankAccountForm({ accountName })
if (accountType === CashBankAccountType.Cash && !cashAccountTypeName) {
nextErrors.cashAccountTypeName = "Select or enter a cash account type"
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created =
accountType === CashBankAccountType.Bank
? await bankAccountsApi.createBank({
accountName,
bankName: bankName || null,
accountNumber: accountNumber || null,
currencyCode: currencyCode || undefined,
})
: await bankAccountsApi.createCash({
accountName,
cashAccountTypeName,
accountNumber: accountNumber || null,
currencyCode: currencyCode || undefined,
})
toast.success(`${accountType} account created`, `${created.accountName} — GL account ${created.glAccount.accountCode}`)
router.push("/dashboard/accounts/bank-accounts")
} catch (err) {
toast.error(`Could not create ${accountType.toLowerCase()} account`, errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Cash / Bank Account</h1>
<p className="text-base text-muted-foreground">
Its ledger account is created automatically no need to pick one.
</p>
</div>
</div>
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div className="mb-6 flex max-w-sm gap-2">
<Button
type="button"
variant={accountType === CashBankAccountType.Bank ? "default" : "outline"}
className="flex-1"
onClick={() => setAccountType(CashBankAccountType.Bank)}
>
Bank
</Button>
<Button
type="button"
variant={accountType === CashBankAccountType.Cash ? "default" : "outline"}
className="flex-1"
onClick={() => setAccountType(CashBankAccountType.Cash)}
>
Cash
</Button>
</div>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<Field data-invalid={!!errors.accountName}>
<FieldLabel htmlFor="ba-name">Account name</FieldLabel>
<Input
id="ba-name"
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder={accountType === CashBankAccountType.Bank ? "Main Account" : "Head Office Petty Cash"}
aria-invalid={!!errors.accountName}
/>
<FieldError errors={[errors.accountName ? { message: errors.accountName } : undefined]} />
</Field>
{accountType === CashBankAccountType.Bank ? (
<>
<Field>
<FieldLabel htmlFor="ba-bank">Bank name (optional)</FieldLabel>
<Input id="ba-bank" value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="Commercial Bank" />
</Field>
<Field>
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
<Input id="ba-acct-no" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="8001234567" />
</Field>
</>
) : (
<>
<Field data-invalid={!!errors.cashAccountTypeName}>
<FieldLabel htmlFor="ba-cash-type">Cash account type</FieldLabel>
<Select<string> value={cashAccountTypeChoice} onValueChange={(v) => setCashAccountTypeChoice(v ?? "")}>
<SelectTrigger id="ba-cash-type" className="w-full text-base" aria-invalid={!!errors.cashAccountTypeName}>
<SelectValue placeholder={cashAccountTypes === null ? "Loading…" : "Select a type"} />
</SelectTrigger>
<SelectContent>
{(cashAccountTypes ?? []).map((t) => (
<SelectItem key={t.cashAccountTypeId} value={t.name} label={t.name} className="text-base">
{t.name}
</SelectItem>
))}
<SelectItem value={OTHER_CASH_TYPE} label="Other, please specify" className="text-base">
Other, please specify
</SelectItem>
</SelectContent>
</Select>
{cashAccountTypeChoice === OTHER_CASH_TYPE && (
<Input
value={customCashAccountTypeName}
onChange={(e) => setCustomCashAccountTypeName(e.target.value)}
placeholder="e.g. Site Cash"
className="mt-2"
/>
)}
{cashAccountTypesError && (
<p className="text-sm text-destructive">{cashAccountTypesError}</p>
)}
<FieldError errors={[errors.cashAccountTypeName ? { message: errors.cashAccountTypeName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
<Input
id="ba-acct-no"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
placeholder="Auto-generated if left blank"
/>
</Field>
</>
)}
<Field>
<FieldLabel htmlFor="ba-currency">Currency</FieldLabel>
<Input id="ba-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} placeholder="LKR" />
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
Cancel
</Link>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both"
export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
useEffect(() => {
let cancelled = false
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
.then(([banks, gl]) => {
if (cancelled) return
setAccounts(banks)
setGlAccounts(gl.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [accountType])
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
const filtered = useMemo(() => {
if (!accounts) return null
const q = search.trim().toLowerCase()
if (!q) return accounts
return accounts.filter((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
a.accountName.toLowerCase().includes(q) ||
(a.bankName ?? "").toLowerCase().includes(q) ||
(a.cashAccountTypeName ?? "").toLowerCase().includes(q) ||
(a.accountNumber ?? "").toLowerCase().includes(q) ||
a.currencyCode.toLowerCase().includes(q) ||
(gl?.accountCode ?? "").toLowerCase().includes(q)
)
})
}, [accounts, search, glAccountsById])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
</div>
</div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Account
</Link>
</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={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, bank/type, account no. or currency…"
className="h-14 w-full pl-11 text-base"
aria-label="Search cash/bank accounts"
/>
</div>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger className="h-14! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</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 && filtered === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && filtered !== null && filtered.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Wallet className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
</p>
</div>
)}
{!error && filtered !== null && filtered.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<TableRow key={`${a.accountType}-${a.accountId}`}>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{gl ? `${gl.accountCode}${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet the General Ledger service has no update endpoint for
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,159 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, BookText } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
[ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary",
[ChequePageIssueStatus.Cleared]: "bg-success/10 text-success",
[ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive",
[ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive",
[ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground",
}
export default function ChequeBookDetailPage() {
const params = useParams<{ chequeBookNo: string }>()
const chequeBookNo = decodeURIComponent(params.chequeBookNo)
const [book, setBook] = useState<ChequeBook | null>(null)
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
let cancelled = false
chequeBooksApi
.get(chequeBookNo, true)
.then((res) => {
if (cancelled) return
setBook(res)
return bankAccountsApi.list(CashBankAccountType.Bank).then((banks) => {
if (cancelled) return
setBankAccount(banks.find((b) => b.accountId === res.bankAccountId) ?? null)
})
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [chequeBookNo])
function handlePageUpdated(updated: ChequePage) {
setBook((prev) => (prev ? { ...prev, pages: prev.pages.map((p) => (p.chequeNo === updated.chequeNo ? updated : p)) } : prev))
setSelectedPage(updated)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cheque Book {chequeBookNo}</h1>
<p className="text-base text-muted-foreground">Every leaf in this book click one to view details or take an action.</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && book === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && book !== null && (
<>
<div className="grid grid-cols-2 gap-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 sm:grid-cols-4">
<div>
<p className="text-sm text-muted-foreground">Bank account</p>
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">#{book.branchId}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Range</p>
<p className="text-base font-medium">
{book.startChequeNo} {book.endChequeNo}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Received</p>
<p className="text-base font-medium">{formatReportDate(book.receivedDate)}</p>
</div>
</div>
{book.pages.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No pages found for this book.</p>
</div>
) : (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Payee</TableHead>
<TableHead className="h-12 px-3 text-sm">Issue date</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{book.pages.map((p) => (
<TableRow
key={p.chequeNo}
className="cursor-pointer"
onClick={() => {
setSelectedPage(p)
setDialogOpen(true)
}}
>
<TableCell className="px-3 py-3.5 font-medium">{p.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[p.issueStatus])}>
{p.issueStatus}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
<ChequePageDialog page={selectedPage} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handlePageUpdated} />
</div>
)
}
@@ -0,0 +1,216 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { validateChequeBookForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
export default function NewChequeBookPage() {
const router = useRouter()
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [bankAccountsError, setBankAccountsError] = useState<string | null>(null)
const [branchId, setBranchId] = useState("")
const [bankAccountId, setBankAccountId] = useState("")
const [chequeBookNo, setChequeBookNo] = useState("")
const [startChequeNo, setStartChequeNo] = useState("")
const [endChequeNo, setEndChequeNo] = useState("")
const [totalLeaves, setTotalLeaves] = useState("")
const [receivedDate, setReceivedDate] = useState("")
const [description, setDescription] = useState("")
const [createdBy, setCreatedBy] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Cheque books can only be tied to a real bank account — GL's own module note (§5.8) says
// statement import/reconcile, and by extension cheque books, are bank_account-only, never cash_account.
useEffect(() => {
bankAccountsApi
.list(CashBankAccountType.Bank)
.then(setBankAccounts)
.catch((err) => setBankAccountsError(errorMessage(err)))
}, [])
async function handleSubmit() {
const nextErrors = validateChequeBookForm({
branchId,
bankAccountId,
chequeBookNo,
startChequeNo,
endChequeNo,
totalLeaves,
receivedDate,
})
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created = await chequeBooksApi.create({
branchId: Number(branchId),
bankAccountId: Number(bankAccountId),
chequeBookNo,
startChequeNo,
endChequeNo,
totalLeaves: Number(totalLeaves),
receivedDate,
description: description || undefined,
createdBy: createdBy || undefined,
})
toast.success("Cheque book created", `${created.chequeBookNo}${created.totalLeaves} leaves`)
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(created.chequeBookNo)}`)
} catch (err) {
toast.error("Could not create cheque book", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Cheque Book</h1>
<p className="text-base text-muted-foreground">
Every leaf from the start to end cheque number is generated automatically, all &quot;Unused&quot;.
</p>
</div>
</div>
{bankAccountsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{bankAccountsError}</div>
)}
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<Field data-invalid={!!errors.bankAccountId}>
<FieldLabel htmlFor="cb-bank">Bank account</FieldLabel>
<Select<string> value={bankAccountId} onValueChange={(v) => setBankAccountId(v ?? "")}>
<SelectTrigger id="cb-bank" className="w-full text-base" aria-invalid={!!errors.bankAccountId}>
<SelectValue placeholder={bankAccounts === null ? "Loading…" : "Select a bank account"} />
</SelectTrigger>
<SelectContent>
{(bankAccounts ?? []).map((a) => (
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
{a.accountName}
{a.bankName ? `${a.bankName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
</Field>
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel>
<Input
id="cb-branch"
type="number"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
placeholder="1"
aria-invalid={!!errors.branchId}
/>
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
<Field data-invalid={!!errors.chequeBookNo}>
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
<Input
id="cb-no"
value={chequeBookNo}
onChange={(e) => setChequeBookNo(e.target.value)}
placeholder="CB-0001"
aria-invalid={!!errors.chequeBookNo}
/>
<FieldError errors={[errors.chequeBookNo ? { message: errors.chequeBookNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.startChequeNo}>
<FieldLabel htmlFor="cb-start">Start cheque no.</FieldLabel>
<Input
id="cb-start"
value={startChequeNo}
onChange={(e) => setStartChequeNo(e.target.value)}
placeholder="000001"
aria-invalid={!!errors.startChequeNo}
/>
<FieldError errors={[errors.startChequeNo ? { message: errors.startChequeNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.endChequeNo}>
<FieldLabel htmlFor="cb-end">End cheque no.</FieldLabel>
<Input
id="cb-end"
value={endChequeNo}
onChange={(e) => setEndChequeNo(e.target.value)}
placeholder="000025"
aria-invalid={!!errors.endChequeNo}
/>
<FieldError errors={[errors.endChequeNo ? { message: errors.endChequeNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.totalLeaves}>
<FieldLabel htmlFor="cb-leaves">Total leaves</FieldLabel>
<Input
id="cb-leaves"
type="number"
value={totalLeaves}
onChange={(e) => setTotalLeaves(e.target.value)}
placeholder="25"
aria-invalid={!!errors.totalLeaves}
/>
<p className="text-sm text-muted-foreground">Must equal end start + 1.</p>
<FieldError errors={[errors.totalLeaves ? { message: errors.totalLeaves } : undefined]} />
</Field>
<Field data-invalid={!!errors.receivedDate}>
<FieldLabel htmlFor="cb-received">Received date</FieldLabel>
<Input
id="cb-received"
type="date"
value={receivedDate}
onChange={(e) => setReceivedDate(e.target.value)}
aria-invalid={!!errors.receivedDate}
/>
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cb-desc">Description (optional)</FieldLabel>
<Input id="cb-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cb-by">Created by (optional)</FieldLabel>
<Input id="cb-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
Cancel
</Link>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,154 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
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 = ChequeBookStatus | "All"
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
[ChequeBookStatus.Active]: "bg-success/10 text-success",
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
export default function ChequeBooksPage() {
const router = useRouter()
const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
useEffect(() => {
let cancelled = false
Promise.all([
chequeBooksApi.list(status === "All" ? undefined : { status }),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
])
.then(([result, banks]) => {
if (cancelled) return
setBooks(result.items)
setBankAccounts(banks)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
<p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p>
</div>
</div>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Cheque Book
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</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 && books === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && books !== null && books.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No cheque books yet.</p>
</div>
)}
{!error && books !== null && books.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{books.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<TableRow
key={b.chequeBookNo}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} {b.endChequeNo}
</TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,58 @@
import Link from "next/link"
import { BookText, Inbox, Wallet, type LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Cash / Bank Accounts",
description: "Cash and Bank accounts linked to a GL account — list, create, and reconcile against them.",
href: "/dashboard/accounts/bank-accounts",
icon: Wallet,
},
{
title: "Cheque Books",
description: "Cheque books issued from this companys own supply — issue, clear, bounce, cancel or void a leaf.",
href: "/dashboard/accounts/cheque-books",
icon: BookText,
},
{
title: "Received Cheques",
description: "Cheques received from customers/suppliers — deposit, clear, return, or cancel.",
href: "/dashboard/accounts/received-cheques",
icon: Inbox,
},
]
export default function AccountsHubPage() {
return (
<div className="flex flex-col gap-6">
<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.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,215 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { validateReceivedChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { ReceivedFromType } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
export default function NewReceivedChequePage() {
const router = useRouter()
const [companyId, setCompanyId] = useState("")
const [branchId, setBranchId] = useState("")
const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer)
const [receivedFromId, setReceivedFromId] = useState("")
const [receivedFromName, setReceivedFromName] = useState("")
const [drawerBankName, setDrawerBankName] = useState("")
const [drawerBankBranch, setDrawerBankBranch] = useState("")
const [accountHolderName, setAccountHolderName] = useState("")
const [chequeNo, setChequeNo] = useState("")
const [chequeDate, setChequeDate] = useState("")
const [amount, setAmount] = useState("")
const [receivedDate, setReceivedDate] = useState("")
const [referenceType, setReferenceType] = useState("")
const [referenceId, setReferenceId] = useState("")
const [notes, setNotes] = useState("")
const [createdBy, setCreatedBy] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
async function handleSubmit() {
const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created = await receivedChequesApi.create({
companyId: Number(companyId),
branchId: branchId ? Number(branchId) : undefined,
receivedFromType,
receivedFromId: receivedFromId ? Number(receivedFromId) : undefined,
receivedFromName,
drawerBankName: drawerBankName || undefined,
drawerBankBranch: drawerBankBranch || undefined,
accountHolderName: accountHolderName || undefined,
chequeNo,
chequeDate,
amount: Number(amount),
receivedDate,
referenceType: referenceType || undefined,
referenceId: referenceId ? Number(referenceId) : undefined,
notes: notes || undefined,
createdBy: createdBy || undefined,
})
toast.success("Received cheque recorded", `${created.chequeNo}${created.receivedFromName}`)
router.push("/dashboard/accounts/received-cheques")
} catch (err) {
toast.error("Could not record received cheque", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Received Cheque</h1>
<p className="text-base text-muted-foreground">Record a cheque received from a customer, supplier, or other party.</p>
</div>
</div>
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<Field data-invalid={!!errors.companyId}>
<FieldLabel htmlFor="rc-company">Company ID</FieldLabel>
<Input
id="rc-company"
type="number"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
placeholder="1"
aria-invalid={!!errors.companyId}
/>
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel>
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-from-type">Received from type</FieldLabel>
<Select<ReceivedFromType> value={receivedFromType} onValueChange={(v) => setReceivedFromType(v ?? ReceivedFromType.Customer)}>
<SelectTrigger id="rc-from-type" className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(ReceivedFromType).map((t) => (
<SelectItem key={t} value={t} label={t} className="text-base">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={!!errors.receivedFromName}>
<FieldLabel htmlFor="rc-from-name">Received from name</FieldLabel>
<Input
id="rc-from-name"
value={receivedFromName}
onChange={(e) => setReceivedFromName(e.target.value)}
aria-invalid={!!errors.receivedFromName}
/>
<FieldError errors={[errors.receivedFromName ? { message: errors.receivedFromName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-from-id">Received from ID (optional)</FieldLabel>
<Input id="rc-from-id" type="number" value={receivedFromId} onChange={(e) => setReceivedFromId(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-drawer-bank">Drawer bank (optional)</FieldLabel>
<Input id="rc-drawer-bank" value={drawerBankName} onChange={(e) => setDrawerBankName(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-drawer-branch">Drawer branch (optional)</FieldLabel>
<Input id="rc-drawer-branch" value={drawerBankBranch} onChange={(e) => setDrawerBankBranch(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-holder">Account holder name (optional)</FieldLabel>
<Input id="rc-holder" value={accountHolderName} onChange={(e) => setAccountHolderName(e.target.value)} />
</Field>
<Field data-invalid={!!errors.chequeNo}>
<FieldLabel htmlFor="rc-cheque-no">Cheque number</FieldLabel>
<Input id="rc-cheque-no" value={chequeNo} onChange={(e) => setChequeNo(e.target.value)} aria-invalid={!!errors.chequeNo} />
<FieldError errors={[errors.chequeNo ? { message: errors.chequeNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.chequeDate}>
<FieldLabel htmlFor="rc-cheque-date">Cheque date</FieldLabel>
<Input
id="rc-cheque-date"
type="date"
value={chequeDate}
onChange={(e) => setChequeDate(e.target.value)}
aria-invalid={!!errors.chequeDate}
/>
<FieldError errors={[errors.chequeDate ? { message: errors.chequeDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="rc-amount">Amount</FieldLabel>
<Input id="rc-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field data-invalid={!!errors.receivedDate}>
<FieldLabel htmlFor="rc-received-date">Received date</FieldLabel>
<Input
id="rc-received-date"
type="date"
value={receivedDate}
onChange={(e) => setReceivedDate(e.target.value)}
aria-invalid={!!errors.receivedDate}
/>
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-ref-type">Reference type (optional)</FieldLabel>
<Input id="rc-ref-type" value={referenceType} onChange={(e) => setReferenceType(e.target.value)} placeholder="Invoice" />
</Field>
<Field>
<FieldLabel htmlFor="rc-ref-id">Reference ID (optional)</FieldLabel>
<Input id="rc-ref-id" type="number" value={referenceId} onChange={(e) => setReferenceId(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-created-by">Created by (optional)</FieldLabel>
<Input id="rc-created-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
Cancel
</Link>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Recording…" : "Record"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,151 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
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 { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
type StatusFilter = ReceivedChequeStatus | "All"
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
[ReceivedChequeStatus.Cleared]: "bg-success/10 text-success",
[ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive",
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
export default function ReceivedChequesPage() {
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
let cancelled = false
receivedChequesApi
.list(status === "All" ? undefined : { status })
.then((res) => {
if (!cancelled) setCheques(res.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [status])
function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
setSelected(updated)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
</div>
</div>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Received Cheque
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</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 && cheques === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && cheques !== null && cheques.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Inbox className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No received cheques yet.</p>
</div>
)}
{!error && cheques !== null && cheques.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cheques.map((c) => (
<TableRow
key={c.receivedChequeId}
className="cursor-pointer"
onClick={() => {
setSelected(c)
setDialogOpen(true)
}}
>
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
</div>
)
}
@@ -0,0 +1,156 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Landmark } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { BalanceSheetResponse, BalanceSheetSection, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
import { ReportSection, ReportSectionLine } from "@/components/reports/ReportSection"
import { ReportSubtotal } from "@/components/reports/ReportSubtotal"
// GL's leaf rows carry `accountCode` too, but a classified Statement of Financial Position
// (matching the reference template this screen follows) shows plain line-item names only, no
// codes — same convention as the Balance Sheet's account-code-free presentation elsewhere.
function sectionLines(section?: BalanceSheetSection): ReportSectionLine[] {
return (section?.lines ?? []).map((l) => ({ label: l.accountName, amount: l.balance }))
}
export default function BalanceSheetPage() {
const [asOfDate, setAsOfDate] = useState(todayIso())
const [report, setReport] = useState<BalanceSheetResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant asOfDate changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== asOfDate) {
setLoadedFor(asOfDate)
setReport(null)
setError(null)
}
useEffect(() => {
if (!asOfDate) return
let cancelled = false
reportsApi
.balanceSheet(asOfDate)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [asOfDate])
const isEmpty =
report !== null &&
(report.nonCurrentAssets?.lines?.length ?? 0) === 0 &&
(report.currentAssets?.lines?.length ?? 0) === 0 &&
(report.unclassifiedAssets?.lines?.length ?? 0) === 0 &&
(report.equity?.lines?.length ?? 0) === 0 &&
(report.nonCurrentLiabilities?.lines?.length ?? 0) === 0 &&
(report.currentLiabilities?.lines?.length ?? 0) === 0 &&
(report.unclassifiedLiabilities?.lines?.length ?? 0) === 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Balance Sheet</h1>
<p className="text-base text-muted-foreground">Statement of Financial Position Assets, Liabilities, Equity.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1.5">
<Label className="text-base">As at</Label>
<Input type="date" value={asOfDate} onChange={(e) => setAsOfDate(e.target.value)} className="h-11 w-full text-base sm:w-60" />
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.BalanceSheet} params={{ asOfDate }} disabled={!report} />
<DownloadCsvButton reportType={ReportType.BalanceSheet} params={{ asOfDate }} disabled={!report} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Landmark className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No Asset/Liability/Equity accounts as at this date.</p>
</div>
)}
{!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4">
<p className="mb-2 text-sm font-bold tracking-wide text-foreground uppercase">Assets</p>
<ReportSection
title="Non-Current Assets"
lines={sectionLines(report.nonCurrentAssets)}
total={report.nonCurrentAssets?.total}
/>
<ReportSection title="Current Assets" lines={sectionLines(report.currentAssets)} total={report.currentAssets?.total} />
<ReportSection
title="Unclassified Assets"
lines={sectionLines(report.unclassifiedAssets)}
total={report.unclassifiedAssets?.total}
/>
<ReportSubtotal label="Total Assets" amount={report.totalAssets} large />
<p className="mt-6 mb-2 text-sm font-bold tracking-wide text-foreground uppercase">Equity and Liabilities</p>
<ReportSection title="Equity" lines={sectionLines(report.equity)} total={report.equity?.total} />
<ReportSection
title="Non-Current Liabilities"
lines={sectionLines(report.nonCurrentLiabilities)}
total={report.nonCurrentLiabilities?.total}
/>
<ReportSection
title="Current Liabilities"
lines={sectionLines(report.currentLiabilities)}
total={report.currentLiabilities?.total}
/>
<ReportSection
title="Unclassified Liabilities"
lines={sectionLines(report.unclassifiedLiabilities)}
total={report.unclassifiedLiabilities?.total}
/>
<ReportSubtotal label="Total Equity and Liabilities" amount={report.totalEquityAndLiabilities} large />
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,179 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
import { glBudgetsApi, reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { BudgetVsActualRow, GlBudget, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
export default function BudgetVsActualPage() {
const [budgets, setBudgets] = useState<GlBudget[] | null>(null)
const [budgetsError, setBudgetsError] = useState<string | null>(null)
const [budgetId, setBudgetId] = useState<number | "">("")
const [rows, setRows] = useState<BudgetVsActualRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
glBudgetsApi
.list()
.then((res) => setBudgets(res))
.catch((err) => setBudgetsError(errorMessage(err)))
}, [])
// Clears stale results the instant budgetId changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<number | "">("")
if (loadedFor !== budgetId) {
setLoadedFor(budgetId)
setRows(null)
setError(null)
}
useEffect(() => {
if (!budgetId) return
let cancelled = false
reportsApi
.budgetVsActual(budgetId)
.then((res) => {
if (!cancelled) setRows(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [budgetId])
const selectedBudget = useMemo(() => (budgets ?? []).find((b) => b.budgetId === budgetId) ?? null, [budgets, budgetId])
const totalBudgeted = (rows ?? []).reduce((sum, r) => sum + r.budgetedAmount, 0)
const totalActual = (rows ?? []).reduce((sum, r) => sum + r.actualAmount, 0)
const totalVariance = (rows ?? []).reduce((sum, r) => sum + r.variance, 0)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Budget vs Actual</h1>
<p className="text-base text-muted-foreground">Budgeted amounts against real postings per account/period.</p>
</div>
</div>
{budgetsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{budgetsError}</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1.5 sm:w-96">
<Label className="text-base">Budget</Label>
<Select<number | ""> value={budgetId} onValueChange={(v) => setBudgetId(v ?? "")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder={budgets === null ? "Loading…" : "Select a budget"} />
</SelectTrigger>
<SelectContent>
{(budgets ?? []).map((b) => (
<SelectItem key={b.budgetId} value={b.budgetId} label={b.name} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.BudgetVsActual} params={{ budgetId: budgetId || undefined }} disabled={!rows} />
<DownloadCsvButton reportType={ReportType.BudgetVsActual} params={{ budgetId: budgetId || undefined }} disabled={!rows} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !budgetId && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BadgeDollarSign className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">Select a budget to compare against actuals.</p>
</div>
)}
{!error && budgetId && rows === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && budgetId && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BadgeDollarSign className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">This budget has no lines yet.</p>
</div>
)}
{!error && budgetId && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Budget vs Actual" subtitle={selectedBudget?.name ?? ""} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Budgeted</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Actual</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Variance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.budgetLineId}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.budgetedAmount)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.actualAmount)}</TableCell>
<TableCell
className={cn(
"px-3 py-2.5 text-right font-medium tabular-nums",
row.variance < 0 ? "text-destructive" : "text-success"
)}
>
{formatAmount(row.variance)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalBudgeted)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalActual)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalVariance)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}
@@ -0,0 +1,184 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Wallet } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import {
CashFlowFinancingActivities,
CashFlowInvestingActivities,
CashFlowResponse,
ReportType,
} from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
import { ReportSection, ReportSectionLine } from "@/components/reports/ReportSection"
import { ReportSubtotal } from "@/components/reports/ReportSubtotal"
/**
* Combines `operatingActivities.nonCashAdjustments` + `.workingCapitalChanges` into one labeled
* set, then buckets by sign — per docs/21-GENERAL-LEDGER-FRONTEND.md §3: "Additions to Cash"
* (amount >= 0) and "Subtractions From Cash" (amount < 0). A working-capital line's label uses its
* `direction` field ("Decrease in Trade Receivables"); a non-cash-adjustment line just prints its
* plain `description` ("Depreciation"), no Increase/Decrease prefix.
*/
function bucketOperatingLines(report: CashFlowResponse): { additions: ReportSectionLine[]; subtractions: ReportSectionLine[] } {
// GL omits these list fields entirely (rather than sending `[]`) when there's nothing to
// report for the period, instead of an empty array — confirmed live, not just a type-safety guard.
const operating = report.operatingActivities
const combined: ReportSectionLine[] = [
...(operating?.nonCashAdjustments ?? []).map((a) => ({ label: a.description, amount: a.amount })),
...(operating?.workingCapitalChanges ?? []).map((w) => ({
label: `${w.direction} in ${w.accountName}`,
amount: w.changeAmount,
})),
]
return {
additions: combined.filter((l) => l.amount >= 0),
subtractions: combined.filter((l) => l.amount < 0),
}
}
/** Same "GL omits empty list fields" defense as bucketOperatingLines — the section itself,
* or just its `lines[]`, may be missing entirely rather than `{ lines: [], ...: 0 }`. */
function activitySectionLines(
section?: CashFlowInvestingActivities | CashFlowFinancingActivities | null
): ReportSectionLine[] {
return (section?.lines ?? []).map((l) => ({ label: l.description, amount: l.amount }))
}
export default function CashFlowPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [report, setReport] = useState<CashFlowResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant the period changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const periodKey = `${periodStart}|${periodEnd}`
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== periodKey) {
setLoadedFor(periodKey)
setReport(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.cashFlow(periodStart, periodEnd)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [periodStart, periodEnd])
const buckets = report ? bucketOperatingLines(report) : null
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cash Flow</h1>
<p className="text-base text-muted-foreground">Statement of Cash Flows for a period.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.CashFlow} params={{ periodStart, periodEnd }} disabled={!report} />
<DownloadCsvButton reportType={ReportType.CashFlow} params={{ periodStart, periodEnd }} disabled={!report} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && report !== null && buckets !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader
title="Statement of Cash Flows"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
/>
<div className="mt-4">
<ReportSubtotal label="Net Earnings" amount={report.operatingActivities?.profitForPeriod ?? 0} />
<ReportSection title="Additions to Cash" lines={buckets.additions} />
<ReportSection title="Subtractions From Cash" lines={buckets.subtractions} />
<ReportSubtotal
label="Net Cash From Operations"
amount={report.operatingActivities?.netCashFromOperatingActivities ?? 0}
/>
<ReportSection
title="Investing Activities"
lines={activitySectionLines(report.investingActivities)}
total={
(report.investingActivities?.lines?.length ?? 0) > 1
? report.investingActivities?.netCashFromInvestingActivities
: undefined
}
/>
<ReportSection
title="Financing Activities"
lines={activitySectionLines(report.financingActivities)}
total={
(report.financingActivities?.lines?.length ?? 0) > 1
? report.financingActivities?.netCashFromFinancingActivities
: undefined
}
/>
<ReportSubtotal label="Net Increase / Decrease in Cash" amount={report.netIncreaseDecreaseInCash} large />
</div>
<p className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
<Wallet className="size-4" />
Opening/closing cash balances are computed by the General Ledger service but not shown on this
screen, matching GL&apos;s own PDF/CSV output.
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,148 @@
"use client"
import { Fragment, useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, BookOpen } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { GeneralLedgerRow, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
export default function GeneralLedgerReportPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [rows, setRows] = useState<GeneralLedgerRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant the period changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const periodKey = `${periodStart}|${periodEnd}`
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== periodKey) {
setLoadedFor(periodKey)
setRows(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.generalLedger(periodStart, periodEnd)
.then((res) => {
if (!cancelled) setRows(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [periodStart, periodEnd])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">General Ledger</h1>
<p className="text-base text-muted-foreground">Every posted movement on every account, with a running balance per account.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.GeneralLedger} params={{ periodStart, periodEnd }} disabled={!rows} />
<DownloadCsvButton reportType={ReportType.GeneralLedger} params={{ periodStart, periodEnd }} disabled={!rows} />
</div>
</div>
{error && (
<div className="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">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookOpen className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No postings on any account in the selected period.</p>
</div>
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Journal No.</TableHead>
<TableHead className="h-11 px-3 text-sm">Narration</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Running balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => {
const isNewAccount = i === 0 || row.accountCode !== rows[i - 1].accountCode
return (
<Fragment key={i}>
{isNewAccount && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={6} className="px-3 py-2 font-semibold">
{row.accountCode} {row.accountName}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 font-medium">{row.journalNo}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-medium tabular-nums">{formatAmount(row.runningBalance)}</TableCell>
</TableRow>
</Fragment>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
)
}
@@ -0,0 +1,91 @@
import Link from "next/link"
import {
BadgeDollarSign,
BookOpen,
Landmark,
LineChart,
PieChart,
Receipt,
Scale,
type LucideIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Trial Balance",
description: "Every postable account's debit/credit balance as at a given date.",
href: "/dashboard/ledgers/trial-balance",
icon: Scale,
},
{
title: "Balance Sheet",
description: "Statement of Financial Position — Assets, Liabilities and Equity as at a date.",
href: "/dashboard/ledgers/balance-sheet",
icon: Landmark,
},
{
title: "General Ledger",
description: "Every posted movement on one account across a date range, with running balance.",
href: "/dashboard/ledgers/general-ledger",
icon: BookOpen,
},
{
title: "Profit & Loss",
description: "Statement of Profit or Loss — Income and Expense for a period.",
href: "/dashboard/ledgers/profit-and-loss",
icon: LineChart,
},
{
title: "Cash Flow",
description: "Statement of Cash Flows — operating, investing and financing movement for a period.",
href: "/dashboard/ledgers/cash-flow",
icon: PieChart,
},
{
title: "Budget vs Actual",
description: "Budgeted amounts against real postings per account/period, with variance.",
href: "/dashboard/ledgers/budget-vs-actual",
icon: BadgeDollarSign,
},
{
title: "Tax Report",
description: "Income Tax Computation for a period, with optional adjustments.",
href: "/dashboard/ledgers/tax-report",
icon: Receipt,
},
]
export default function LedgersHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Ledgers</h1>
<p className="text-base text-muted-foreground">
Statutory-format financial reports from the General Ledger service.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,166 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, LineChart } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ProfitAndLossResponse, ProfitAndLossSection, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
import { ReportSection } from "@/components/reports/ReportSection"
import { ReportSubtotal } from "@/components/reports/ReportSubtotal"
function pnlSectionLines(section: ProfitAndLossSection | undefined) {
return (section?.lines ?? []).map((line) => ({
label: (
<>
<span className="text-muted-foreground">{line.accountCode}</span> <span>{line.accountName}</span>
</>
),
amount: line.amount,
}))
}
export default function ProfitAndLossPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [report, setReport] = useState<ProfitAndLossResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant the period changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const periodKey = `${periodStart}|${periodEnd}`
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== periodKey) {
setLoadedFor(periodKey)
setReport(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.profitAndLoss(periodStart, periodEnd)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [periodStart, periodEnd])
// GL omits an empty/zero section entirely rather than sending `{ lines: [], total: 0 }` —
// confirmed on Cash Flow's equivalent fields, same inferred nested-section shape here.
const isEmpty =
report !== null &&
(report.sales?.lines?.length ?? 0) === 0 &&
(report.costOfSales?.lines?.length ?? 0) === 0 &&
(report.otherIncome?.lines?.length ?? 0) === 0 &&
(report.distributionExpenses?.lines?.length ?? 0) === 0 &&
(report.administrationExpenses?.lines?.length ?? 0) === 0 &&
(report.otherExpenses?.lines?.length ?? 0) === 0 &&
(report.financialExpenses?.lines?.length ?? 0) === 0 &&
(report.unclassified?.lines?.length ?? 0) === 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Profit &amp; Loss</h1>
<p className="text-base text-muted-foreground">Statement of Profit or Loss Income and Expense for a period.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.ProfitAndLoss} params={{ periodStart, periodEnd }} disabled={!report} />
<DownloadCsvButton reportType={ReportType.ProfitAndLoss} params={{ periodStart, periodEnd }} disabled={!report} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<LineChart className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No Income/Expense accounts posted in this period.</p>
</div>
)}
{!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader
title="Statement of Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
/>
<div className="mt-4">
<ReportSection title="Sales" lines={pnlSectionLines(report.sales)} total={report.sales?.total} />
<ReportSection title="Cost of Sales" lines={pnlSectionLines(report.costOfSales)} total={report.costOfSales?.total} />
<ReportSubtotal label="Gross Profit" amount={report.grossProfit} />
<ReportSection title="Other Income" lines={pnlSectionLines(report.otherIncome)} total={report.otherIncome?.total} />
<ReportSection
title="Distribution Expenses"
lines={pnlSectionLines(report.distributionExpenses)}
total={report.distributionExpenses?.total}
/>
<ReportSection
title="Administration Expenses"
lines={pnlSectionLines(report.administrationExpenses)}
total={report.administrationExpenses?.total}
/>
<ReportSection title="Other Expenses" lines={pnlSectionLines(report.otherExpenses)} total={report.otherExpenses?.total} />
<ReportSection
title="Financial Expenses"
lines={pnlSectionLines(report.financialExpenses)}
total={report.financialExpenses?.total}
/>
{report.unclassified && (
<ReportSection title="Unclassified" lines={pnlSectionLines(report.unclassified)} total={report.unclassified?.total} />
)}
<ReportSubtotal label="Net Profit for the Period" amount={report.netProfitForPeriod} large />
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Receipt } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReportType, TaxSummaryResponse } from "@/types/general-ledger"
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 { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
// Fixed row order + Add:/Less: labels per the confirmed GL contract
// (04_API_Reference_And_Scenarios.md, Module: Reporting — `profitBeforeTax` through
// `balanceTaxPayable`) — GL sends plain numbers, not pre-formatted rows; the prefix/bold treatment
// lives here, not derived from sign. `taxRatePercent` is shown separately below the table (it's a
// percentage, not a currency amount) rather than run through `formatAmount` here.
const ROWS: { key: keyof TaxSummaryResponse; label: string; bold?: boolean }[] = [
{ key: "profitBeforeTax", label: "Profit Before Tax" },
{ key: "nonDeductibleExpenses", label: "Add: Non-Deductible Expenses" },
{ key: "allowableDeductions", label: "Less: Allowable Deductions" },
{ key: "adjustedBusinessProfit", label: "Adjusted Business Profit", bold: true },
{ key: "otherTaxableIncome", label: "Add: Other Taxable Income" },
{ key: "assessableIncome", label: "Assessable Income", bold: true },
{ key: "qualifyingPaymentsReliefs", label: "Less: Qualifying Payments / Reliefs" },
{ key: "taxableIncome", label: "Taxable Income", bold: true },
{ key: "corporateIncomeTax", label: "Corporate Income Tax" },
{ key: "surchargeAmount", label: "Add: Surcharge / Education Levy" },
{ key: "grossTaxLiability", label: "Gross Tax Liability", bold: true },
{ key: "apitCredit", label: "Less: APIT Credit" },
{ key: "whtCredit", label: "Less: WHT Credit" },
{ key: "quarterlyTaxPayments", label: "Less: Quarterly Tax Payments" },
]
export default function TaxReportPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [adjustmentsOpen, setAdjustmentsOpen] = useState(false)
const [allowableDeductions, setAllowableDeductions] = useState("")
const [otherTaxableIncome, setOtherTaxableIncome] = useState("")
const [qualifyingPaymentsReliefs, setQualifyingPaymentsReliefs] = useState("")
const [surchargeAmount, setSurchargeAmount] = useState("")
const [taxRateOverride, setTaxRateOverride] = useState("")
const [report, setReport] = useState<TaxSummaryResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Optional inputs get no client-side default — an untouched field sends nothing (undefined,
// dropped by lib/api/general-ledger.ts's query builder), letting GL's own server-side
// defaulting be the single source of truth for what "not supplied" means.
const params = {
periodStart,
periodEnd,
allowableDeductions: allowableDeductions === "" ? undefined : Number(allowableDeductions),
otherTaxableIncome: otherTaxableIncome === "" ? undefined : Number(otherTaxableIncome),
qualifyingPaymentsReliefs: qualifyingPaymentsReliefs === "" ? undefined : Number(qualifyingPaymentsReliefs),
surchargeAmount: surchargeAmount === "" ? undefined : Number(surchargeAmount),
taxRateOverride: taxRateOverride === "" ? undefined : Number(taxRateOverride),
}
const paramsKey = JSON.stringify(params)
// Clears stale results the instant a param changes, during the render that reacts to it — not
// inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== paramsKey) {
setLoadedFor(paramsKey)
setReport(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.taxSummary(params)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
// paramsKey covers every field inside params; re-running on params itself would compare by
// reference and fire every render, since it's a fresh object literal each time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paramsKey, periodStart, periodEnd])
const isRefund = report !== null && report.balanceTaxPayable < 0
const finalLabel = isRefund ? "BALANCE TAX REFUNDABLE" : "BALANCE TAX PAYABLE"
const finalAmount = report ? Math.abs(report.balanceTaxPayable) : 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Tax Report</h1>
<p className="text-base text-muted-foreground">Income Tax Computation for a period.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.TaxSummary} params={params} disabled={!report} />
<DownloadCsvButton reportType={ReportType.TaxSummary} params={params} disabled={!report} />
</div>
</div>
<div className="rounded-xl border">
<button
type="button"
onClick={() => setAdjustmentsOpen((v) => !v)}
className="flex w-full items-center gap-2 px-4 py-3 text-left text-base font-semibold"
>
{adjustmentsOpen ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
Adjustments (optional)
</button>
{adjustmentsOpen && (
<div className="grid grid-cols-1 gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Allowable Deductions</Label>
<Input
type="number"
value={allowableDeductions}
onChange={(e) => setAllowableDeductions(e.target.value)}
placeholder="0"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Other Taxable Income</Label>
<Input
type="number"
value={otherTaxableIncome}
onChange={(e) => setOtherTaxableIncome(e.target.value)}
placeholder="0"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Qualifying Payments / Reliefs</Label>
<Input
type="number"
value={qualifyingPaymentsReliefs}
onChange={(e) => setQualifyingPaymentsReliefs(e.target.value)}
placeholder="System default"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Surcharge / Education Levy</Label>
<Input
type="number"
value={surchargeAmount}
onChange={(e) => setSurchargeAmount(e.target.value)}
placeholder="0"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Tax Rate Override (%)</Label>
<Input
type="number"
value={taxRateOverride}
onChange={(e) => setTaxRateOverride(e.target.value)}
placeholder="System default"
className="h-10 text-base"
/>
</div>
{(allowableDeductions ||
otherTaxableIncome ||
qualifyingPaymentsReliefs ||
surchargeAmount ||
taxRateOverride) && (
<div className="flex items-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setAllowableDeductions("")
setOtherTaxableIncome("")
setQualifyingPaymentsReliefs("")
setSurchargeAmount("")
setTaxRateOverride("")
}}
>
Clear adjustments
</Button>
</div>
)}
</div>
)}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && report !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
{/* Own header, not the shared ReportHeader — GL's own PDF gives this report a fuller
identity block (company name/address/TIN/BRN) that today lives only in GL's own
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here —
see docs/21-GENERAL-LEDGER-FRONTEND.md §3's open question. */}
<div className="flex flex-col items-center gap-1 border-b-2 border-foreground/70 px-4 pb-5 text-center">
<p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
General Ledger
</p>
<h2 className="text-xl font-bold tracking-tight text-foreground uppercase">Income Tax Computation</h2>
<p className="text-base text-muted-foreground">
For the period {formatReportDate(periodStart)} to {formatReportDate(periodEnd)}
</p>
<p className="text-sm text-muted-foreground">
Company identity (name/address/TIN/BRN) isn&apos;t shown here GL exposes no endpoint for it yet.
</p>
</div>
<div className="flex items-center gap-2 py-3 text-muted-foreground">
<Receipt className="size-4" />
<p className="flex-1 text-sm">
All amounts in Sri Lankan Rupees (LKR) unless stated otherwise.
</p>
</div>
<Table className="text-base">
<TableBody>
{ROWS.map((row) => (
<TableRow key={row.key} className={row.bold ? "bg-muted/40 hover:bg-muted/40" : undefined}>
<TableCell className={cn("px-3 py-2.5", row.bold && "font-semibold")}>{row.label}</TableCell>
<TableCell className={cn("px-3 py-2.5 text-right tabular-nums", row.bold && "font-semibold")}>
{formatAmount(report[row.key] as number)}
</TableCell>
</TableRow>
))}
<TableRow className="border-t-2 border-foreground/70 bg-muted/60 hover:bg-muted/60">
<TableCell className="px-3 py-3 text-base font-bold">{finalLabel}</TableCell>
<TableCell className="px-3 py-3 text-right text-base font-bold tabular-nums">
{formatAmount(finalAmount)}
</TableCell>
</TableRow>
</TableBody>
</Table>
<p className="mt-3 text-sm text-muted-foreground">
Tax rate applied: {report.taxRatePercent}%
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,134 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Scale } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReportType, TrialBalanceRow } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
export default function TrialBalancePage() {
const [asOfDate, setAsOfDate] = useState(todayIso())
const [rows, setRows] = useState<TrialBalanceRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant asOfDate changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== asOfDate) {
setLoadedFor(asOfDate)
setRows(null)
setError(null)
}
useEffect(() => {
if (!asOfDate) return
let cancelled = false
reportsApi
.trialBalance(asOfDate)
.then((res) => {
if (!cancelled) setRows(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [asOfDate])
const totalDebit = (rows ?? []).reduce((sum, r) => sum + r.debit, 0)
const totalCredit = (rows ?? []).reduce((sum, r) => sum + r.credit, 0)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Trial Balance</h1>
<p className="text-base text-muted-foreground">Every postable account&apos;s balance as at a date.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1.5">
<Label className="text-base">As at</Label>
<Input type="date" value={asOfDate} onChange={(e) => setAsOfDate(e.target.value)} className="h-11 w-full text-base sm:w-60" />
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.TrialBalance} params={{ asOfDate }} disabled={!rows} />
<DownloadCsvButton reportType={ReportType.TrialBalance} params={{ asOfDate }} disabled={!rows} />
</div>
</div>
{error && (
<div className="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">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Scale className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No postable accounts as at this date.</p>
</div>
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}