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>
)
}
@@ -5,26 +5,36 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import {
Banknote,
BadgeDollarSign,
BookOpen,
BookText,
Boxes,
Building2,
CalendarCheck,
CalendarClock,
ChevronRight,
ClipboardList,
CreditCard,
Factory,
FileBarChart,
FileText,
HelpCircle,
IdCard,
Inbox,
Landmark,
LayoutGrid,
LayoutTemplate,
LineChart,
ListTree,
Menu,
Package,
PackageCheck,
PackageX,
PlayCircle,
PieChart,
Receipt,
Ruler,
Scale,
Settings,
ShieldCheck,
ShoppingCart,
@@ -32,6 +42,7 @@ import {
Tag,
Truck,
Users,
Wallet,
Warehouse,
X,
type LucideIcon,
@@ -120,6 +131,34 @@ const navItems: {
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
],
},
{
title: "Ledgers",
code: "ledgers",
href: "/dashboard/ledgers",
icon: Landmark,
chevron: true,
children: [
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
{ title: "General Ledger", code: "ledgers.general-ledger", href: "/dashboard/ledgers/general-ledger", icon: BookOpen },
{ title: "Profit & Loss", code: "ledgers.profit-and-loss", href: "/dashboard/ledgers/profit-and-loss", icon: LineChart },
{ title: "Cash Flow", code: "ledgers.cash-flow", href: "/dashboard/ledgers/cash-flow", icon: PieChart },
{ title: "Budget vs Actual", code: "ledgers.budget-vs-actual", href: "/dashboard/ledgers/budget-vs-actual", icon: BadgeDollarSign },
{ title: "Tax Report", code: "ledgers.tax-report", href: "/dashboard/ledgers/tax-report", icon: Receipt },
],
},
{
title: "Accounts",
code: "accounts",
href: "/dashboard/accounts",
icon: CreditCard,
chevron: true,
children: [
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
{ title: "Received Cheques", code: "accounts.received-cheques", href: "/dashboard/accounts/received-cheques", icon: Inbox },
],
},
{
title: "Settings",
code: "settings",
@@ -157,7 +196,13 @@ function SidebarContent({
// route auto-expanded; user toggles are preserved across navigation.
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
useEffect(() => {
// Adjusted during render (not in an effect) each time pathname or the
// available item set changes — `items` starts empty while auth/nav codes
// are loading, so this also needs to re-run once the real list arrives.
const autoExpandKey = `${pathname}::${items.map((i) => i.code).join(",")}`
const [lastAutoExpandKey, setLastAutoExpandKey] = useState<string | null>(null)
if (autoExpandKey !== lastAutoExpandKey) {
setLastAutoExpandKey(autoExpandKey)
const parent = items.find((i) => {
if (!i.children?.length) return false
if (i.children.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))) return true
@@ -168,7 +213,7 @@ function SidebarContent({
if (parent) {
setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true }))
}
}, [pathname, items])
}
const toggleExpand = (code: string) =>
setExpanded((prev) => ({ ...prev, [code]: !prev[code] }))
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
@@ -41,6 +41,27 @@ const PROCUREMENT_TITLES: Record<string, string> = {
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
}
const LEDGER_TITLES: Record<string, string> = {
"/dashboard/ledgers": "Ledgers",
"/dashboard/ledgers/trial-balance": "Trial Balance",
"/dashboard/ledgers/balance-sheet": "Balance Sheet",
"/dashboard/ledgers/general-ledger": "General Ledger",
"/dashboard/ledgers/profit-and-loss": "Profit & Loss",
"/dashboard/ledgers/cash-flow": "Cash Flow",
"/dashboard/ledgers/budget-vs-actual": "Budget vs Actual",
"/dashboard/ledgers/tax-report": "Tax Report",
}
const ACCOUNTS_TITLES: Record<string, string> = {
"/dashboard/accounts": "Accounts",
"/dashboard/accounts/bank-accounts": "Cash / Bank Accounts",
"/dashboard/accounts/bank-accounts/new": "New Bank Account",
"/dashboard/accounts/cheque-books": "Cheque Books",
"/dashboard/accounts/cheque-books/new": "New Cheque Book",
"/dashboard/accounts/received-cheques": "Received Cheques",
"/dashboard/accounts/received-cheques/new": "New Received Cheque",
}
const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock": "Stock Management",
"/dashboard/stock/enquiry": "Stock Enquiry",
@@ -62,6 +83,11 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
if (ACCOUNTS_TITLES[pathname]) return ACCOUNTS_TITLES[pathname]
if (/^\/dashboard\/accounts\/cheque-books\/[^/]+$/.test(pathname)) return "Cheque Book"
if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname]
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
@@ -140,8 +166,7 @@ export function Header() {
// Read after mount, not during render: localStorage doesn't exist on the server, and
// reading it while rendering would desync the hydration pass.
const [user, setUser] = useState<AuthUser | null>(null)
useEffect(() => setUser(getStoredUser()), [])
const [user] = useState<AuthUser | null>(() => getStoredUser())
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
@@ -0,0 +1,381 @@
"use client"
import { useState } from "react"
import { chequePagesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { validateIssueChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { ChequePage, ChequePageIssueStatus, ChequePageStatusAction, PayeeType } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
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",
}
type PendingAction = "Issue" | ChequePageStatusAction | null
interface ChequePageDialogProps {
page: ChequePage | null
open: boolean
onOpenChange: (open: boolean) => void
/** Called with the server's response after a successful issue/status-update, so the caller's list stays in sync. */
onUpdated: (updated: ChequePage) => void
}
/** View a single cheque page's details, and (from `Unused`/`Issued`) issue it or move it through
* Clear/Bounce/Cancel/Void — a modal rather than a separate page, so acting on several leaves from
* a book's page list doesn't lose scroll position/context each time (docs/21 §7). */
export function ChequePageDialog({ page, open, onOpenChange, onUpdated }: ChequePageDialogProps) {
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [payeeType, setPayeeType] = useState<PayeeType>(PayeeType.Supplier)
const [payeeName, setPayeeName] = useState("")
const [payeeId, setPayeeId] = useState("")
const [issueDate, setIssueDate] = useState("")
const [amount, setAmount] = useState("")
const [currencyCode, setCurrencyCode] = useState("LKR")
const [voucherId, setVoucherId] = useState("")
const [referenceNo, setReferenceNo] = useState("")
const [purpose, setPurpose] = useState("")
const [isCrossCheque, setIsCrossCheque] = useState(false)
const [isAccountPayee, setIsAccountPayee] = useState(false)
const [isPostDated, setIsPostDated] = useState(false)
const [notes, setNotes] = useState("")
const [printedBy, setPrintedBy] = useState("")
const [clearedDate, setClearedDate] = useState("")
const [cancelReason, setCancelReason] = useState("")
const [performedBy, setPerformedBy] = useState("")
function resetActionState() {
setPendingAction(null)
setErrors({})
setPayeeType(PayeeType.Supplier)
setPayeeName("")
setPayeeId("")
setIssueDate("")
setAmount("")
setCurrencyCode("LKR")
setVoucherId("")
setReferenceNo("")
setPurpose("")
setIsCrossCheque(false)
setIsAccountPayee(false)
setIsPostDated(false)
setNotes("")
setPrintedBy("")
setClearedDate("")
setCancelReason("")
setPerformedBy("")
}
function handleOpenChange(next: boolean) {
if (!next) resetActionState()
onOpenChange(next)
}
async function submitIssue() {
if (!page) return
const nextErrors = validateIssueChequeForm({ payeeName, issueDate, amount })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const updated = await chequePagesApi.issue(page.chequeNo, {
payeeType,
payeeId: payeeId ? Number(payeeId) : undefined,
payeeName,
issueDate,
amount: Number(amount),
currencyCode: currencyCode || undefined,
voucherId: voucherId ? Number(voucherId) : undefined,
referenceNo: referenceNo || undefined,
purpose: purpose || undefined,
isCrossCheque,
isAccountPayee,
isPostDated,
notes: notes || undefined,
printedBy: printedBy || undefined,
})
toast.success("Cheque issued", `${updated.chequeNo}${updated.payeeName}`)
onUpdated(updated)
resetActionState()
} catch (err) {
toast.error("Could not issue cheque", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function submitStatusAction(action: ChequePageStatusAction) {
if (!page) return
if (action === ChequePageStatusAction.Clear && !clearedDate) {
setErrors({ clearedDate: "Cleared date is required" })
return
}
if (action === ChequePageStatusAction.Cancel && !cancelReason.trim()) {
setErrors({ cancelReason: "Cancel reason is required" })
return
}
setSubmitting(true)
try {
const updated = await chequePagesApi.updateStatus(page.chequeNo, {
action,
clearedDate: action === ChequePageStatusAction.Clear ? clearedDate : undefined,
cancelReason: action === ChequePageStatusAction.Cancel ? cancelReason : undefined,
performedBy: performedBy || undefined,
})
toast.success(`Cheque ${action.toLowerCase()}d`, updated.chequeNo)
onUpdated(updated)
resetActionState()
} catch (err) {
toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (!page) return null
const availableActions: ChequePageStatusAction[] =
page.issueStatus === ChequePageIssueStatus.Unused
? [ChequePageStatusAction.Cancel, ChequePageStatusAction.Void]
: page.issueStatus === ChequePageIssueStatus.Issued
? [ChequePageStatusAction.Clear, ChequePageStatusAction.Bounce, ChequePageStatusAction.Cancel]
: []
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
Cheque {page.chequeNo}
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[page.issueStatus])}>
{page.issueStatus}
</Badge>
</DialogTitle>
</DialogHeader>
{!pendingAction && (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Payee</dt>
<dd>{page.payeeName ?? "—"}</dd>
<dt className="text-muted-foreground">Payee type</dt>
<dd>{page.payeeType ?? "—"}</dd>
<dt className="text-muted-foreground">Issue date</dt>
<dd>{formatReportDate(page.issueDate)}</dd>
<dt className="text-muted-foreground">Amount</dt>
<dd>{page.amount !== null ? formatAmount(page.amount) : "—"}</dd>
<dt className="text-muted-foreground">Reference no.</dt>
<dd>{page.referenceNo ?? "—"}</dd>
<dt className="text-muted-foreground">Purpose</dt>
<dd>{page.purpose ?? "—"}</dd>
<dt className="text-muted-foreground">Notes</dt>
<dd>{page.notes ?? "—"}</dd>
{page.issueStatus === ChequePageIssueStatus.Cleared && (
<>
<dt className="text-muted-foreground">Cleared date</dt>
<dd>{formatReportDate(page.clearedDate)}</dd>
</>
)}
{page.issueStatus === ChequePageIssueStatus.Cancelled && (
<>
<dt className="text-muted-foreground">Cancel reason</dt>
<dd>{page.cancelReason ?? "—"}</dd>
</>
)}
</dl>
{availableActions.length > 0 && (
<div className="flex flex-wrap gap-2 border-t pt-4">
{page.issueStatus === ChequePageIssueStatus.Unused && (
<Button size="sm" onClick={() => setPendingAction("Issue")}>
Issue Cheque
</Button>
)}
{availableActions.map((action) => (
<Button key={action} size="sm" variant="outline" onClick={() => setPendingAction(action)}>
{action}
</Button>
))}
</div>
)}
</div>
)}
{pendingAction === "Issue" && (
<FieldGroup>
<Field data-invalid={!!errors.payeeName}>
<FieldLabel htmlFor="cp-payee-name">Payee name</FieldLabel>
<Input id="cp-payee-name" value={payeeName} onChange={(e) => setPayeeName(e.target.value)} aria-invalid={!!errors.payeeName} />
<FieldError errors={[errors.payeeName ? { message: errors.payeeName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-payee-type">Payee type</FieldLabel>
<Select<PayeeType> value={payeeType} onValueChange={(v) => setPayeeType(v ?? PayeeType.Supplier)}>
<SelectTrigger id="cp-payee-type" className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(PayeeType).map((t) => (
<SelectItem key={t} value={t} label={t} className="text-base">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="cp-payee-id">Payee ID (optional)</FieldLabel>
<Input id="cp-payee-id" type="number" value={payeeId} onChange={(e) => setPayeeId(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={!!errors.issueDate}>
<FieldLabel htmlFor="cp-issue-date">Issue date</FieldLabel>
<Input
id="cp-issue-date"
type="date"
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
aria-invalid={!!errors.issueDate}
/>
<FieldError errors={[errors.issueDate ? { message: errors.issueDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="cp-amount">Amount</FieldLabel>
<Input id="cp-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="cp-currency">Currency</FieldLabel>
<Input id="cp-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} />
</Field>
<Field>
<FieldLabel htmlFor="cp-voucher">Voucher ID (optional)</FieldLabel>
<Input id="cp-voucher" type="number" value={voucherId} onChange={(e) => setVoucherId(e.target.value)} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="cp-ref">Reference no. (optional)</FieldLabel>
<Input id="cp-ref" value={referenceNo} onChange={(e) => setReferenceNo(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-purpose">Purpose (optional)</FieldLabel>
<Input id="cp-purpose" value={purpose} onChange={(e) => setPurpose(e.target.value)} />
</Field>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={isCrossCheque} onCheckedChange={(v) => setIsCrossCheque(v === true)} />
Cross cheque
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={isAccountPayee} onCheckedChange={(v) => setIsAccountPayee(v === true)} />
Account payee
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={isPostDated} onCheckedChange={(v) => setIsPostDated(v === true)} />
Post-dated
</label>
</div>
<Field>
<FieldLabel htmlFor="cp-notes">Notes (optional)</FieldLabel>
<Input id="cp-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-printed-by">Printed by (optional)</FieldLabel>
<Input id="cp-printed-by" value={printedBy} onChange={(e) => setPrintedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction === ChequePageStatusAction.Clear && (
<FieldGroup>
<Field data-invalid={!!errors.clearedDate}>
<FieldLabel htmlFor="cp-cleared-date">Cleared date</FieldLabel>
<Input
id="cp-cleared-date"
type="date"
value={clearedDate}
onChange={(e) => setClearedDate(e.target.value)}
aria-invalid={!!errors.clearedDate}
/>
<FieldError errors={[errors.clearedDate ? { message: errors.clearedDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction === ChequePageStatusAction.Cancel && (
<FieldGroup>
<Field data-invalid={!!errors.cancelReason}>
<FieldLabel htmlFor="cp-cancel-reason">Cancel reason</FieldLabel>
<Input
id="cp-cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
aria-invalid={!!errors.cancelReason}
/>
<FieldError errors={[errors.cancelReason ? { message: errors.cancelReason } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{(pendingAction === ChequePageStatusAction.Bounce || pendingAction === ChequePageStatusAction.Void) && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction && (
<DialogFooter>
<Button variant="outline" onClick={() => resetActionState()} disabled={submitting}>
Back
</Button>
<Button onClick={() => (pendingAction === "Issue" ? submitIssue() : submitStatusAction(pendingAction))} disabled={submitting}>
{submitting ? "Submitting…" : pendingAction === "Issue" ? "Issue Cheque" : pendingAction}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,236 @@
"use client"
import { useEffect, useState } from "react"
import { bankAccountsApi, receivedChequesApi } 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,
ReceivedCheque,
ReceivedChequeStatus,
ReceivedChequeStatusAction,
} from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
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",
}
interface ReceivedChequeDialogProps {
cheque: ReceivedCheque | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: (updated: ReceivedCheque) => void
}
/** View a received cheque's details and (from `Received`/`Deposited`) move it through
* Deposit/Clear/Return/Cancel — a modal, same posture as `ChequePageDialog`. */
export function ReceivedChequeDialog({ cheque, open, onOpenChange, onUpdated }: ReceivedChequeDialogProps) {
const [pendingAction, setPendingAction] = useState<ReceivedChequeStatusAction | null>(null)
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [depositBankAccountId, setDepositBankAccountId] = useState("")
const [depositDate, setDepositDate] = useState("")
const [performedBy, setPerformedBy] = useState("")
const [notes, setNotes] = useState("")
useEffect(() => {
if (pendingAction !== ReceivedChequeStatusAction.Deposit || bankAccounts !== null) return
bankAccountsApi.list(CashBankAccountType.Bank).then(setBankAccounts).catch(() => setBankAccounts([]))
// Only fetched once, lazily, the first time Deposit is chosen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingAction])
function resetActionState() {
setPendingAction(null)
setErrors({})
setDepositBankAccountId("")
setDepositDate("")
setPerformedBy("")
setNotes("")
}
function handleOpenChange(next: boolean) {
if (!next) resetActionState()
onOpenChange(next)
}
async function submitStatusAction(action: ReceivedChequeStatusAction) {
if (!cheque) return
if (action === ReceivedChequeStatusAction.Deposit) {
const nextErrors: Record<string, string> = {}
if (!depositBankAccountId) nextErrors.depositBankAccountId = "Select a deposit bank account"
if (!depositDate) nextErrors.depositDate = "Deposit date is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
}
setSubmitting(true)
try {
const updated = await receivedChequesApi.updateStatus(cheque.receivedChequeId, {
action,
depositBankAccountId: action === ReceivedChequeStatusAction.Deposit ? Number(depositBankAccountId) : undefined,
depositDate: action === ReceivedChequeStatusAction.Deposit ? depositDate : undefined,
notes: notes || undefined,
performedBy: performedBy || undefined,
})
toast.success(`Cheque ${action.toLowerCase()}ed`, updated.chequeNo)
onUpdated(updated)
resetActionState()
} catch (err) {
toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (!cheque) return null
const availableActions: ReceivedChequeStatusAction[] =
cheque.status === ReceivedChequeStatus.Received
? [ReceivedChequeStatusAction.Deposit, ReceivedChequeStatusAction.Cancel]
: cheque.status === ReceivedChequeStatus.Deposited
? [ReceivedChequeStatusAction.Clear, ReceivedChequeStatusAction.Return]
: []
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
Cheque {cheque.chequeNo}
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[cheque.status])}>
{cheque.status}
</Badge>
</DialogTitle>
</DialogHeader>
{!pendingAction && (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Received from</dt>
<dd>{cheque.receivedFromName}</dd>
<dt className="text-muted-foreground">Type</dt>
<dd>{cheque.receivedFromType}</dd>
<dt className="text-muted-foreground">Cheque date</dt>
<dd>{formatReportDate(cheque.chequeDate)}</dd>
<dt className="text-muted-foreground">Amount</dt>
<dd>{formatAmount(cheque.amount)}</dd>
<dt className="text-muted-foreground">Received date</dt>
<dd>{formatReportDate(cheque.receivedDate)}</dd>
<dt className="text-muted-foreground">Drawer bank</dt>
<dd>{cheque.drawerBankName ?? "—"}</dd>
<dt className="text-muted-foreground">Drawer branch</dt>
<dd>{cheque.drawerBankBranch ?? "—"}</dd>
<dt className="text-muted-foreground">Account holder</dt>
<dd>{cheque.accountHolderName ?? "—"}</dd>
<dt className="text-muted-foreground">Reference</dt>
<dd>{cheque.referenceType ?? "—"}</dd>
<dt className="text-muted-foreground">Notes</dt>
<dd>{cheque.notes ?? "—"}</dd>
{cheque.status === ReceivedChequeStatus.Deposited && (
<>
<dt className="text-muted-foreground">Deposited to</dt>
<dd>#{cheque.depositBankAccountId}</dd>
<dt className="text-muted-foreground">Deposit date</dt>
<dd>{formatReportDate(cheque.depositDate)}</dd>
</>
)}
</dl>
{availableActions.length > 0 && (
<div className="flex flex-wrap gap-2 border-t pt-4">
{availableActions.map((action) => (
<Button key={action} size="sm" variant="outline" onClick={() => setPendingAction(action)}>
{action}
</Button>
))}
</div>
)}
</div>
)}
{pendingAction === ReceivedChequeStatusAction.Deposit && (
<FieldGroup>
<Field data-invalid={!!errors.depositBankAccountId}>
<FieldLabel htmlFor="rc-deposit-bank">Deposit bank account</FieldLabel>
<Select<string> value={depositBankAccountId} onValueChange={(v) => setDepositBankAccountId(v ?? "")}>
<SelectTrigger id="rc-deposit-bank" className="w-full text-base" aria-invalid={!!errors.depositBankAccountId}>
<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}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.depositBankAccountId ? { message: errors.depositBankAccountId } : undefined]} />
</Field>
<Field data-invalid={!!errors.depositDate}>
<FieldLabel htmlFor="rc-deposit-date">Deposit date</FieldLabel>
<Input
id="rc-deposit-date"
type="date"
value={depositDate}
onChange={(e) => setDepositDate(e.target.value)}
aria-invalid={!!errors.depositDate}
/>
<FieldError errors={[errors.depositDate ? { message: errors.depositDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-performed-by">Performed by (optional)</FieldLabel>
<Input id="rc-performed-by" value={performedBy} onChange={(e) => setPerformedBy(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>
</FieldGroup>
)}
{(pendingAction === ReceivedChequeStatusAction.Clear ||
pendingAction === ReceivedChequeStatusAction.Return ||
pendingAction === ReceivedChequeStatusAction.Cancel) && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="rc-performed-by">Performed by (optional)</FieldLabel>
<Input id="rc-performed-by" value={performedBy} onChange={(e) => setPerformedBy(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>
</FieldGroup>
)}
{pendingAction && (
<DialogFooter>
<Button variant="outline" onClick={() => resetActionState()} disabled={submitting}>
Back
</Button>
<Button onClick={() => submitStatusAction(pendingAction)} disabled={submitting}>
{submitting ? "Submitting…" : pendingAction}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,42 @@
"use client"
import { useState } from "react"
import { FileSpreadsheet } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { ReportType } from "@/types/general-ledger"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
interface DownloadCsvButtonProps {
reportType: ReportType
/** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */
params: Record<string, string | number | undefined>
/** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */
disabled?: boolean
}
/** Same mechanism as DownloadPdfButton, `outputFormat=Csv` — GL's bytes are downloaded unmodified. */
export function DownloadCsvButton({ reportType, params, disabled }: DownloadCsvButtonProps) {
const [downloading, setDownloading] = useState(false)
async function handleDownload() {
setDownloading(true)
try {
await reportsApi.downloadCsv(reportType, params)
} catch (err) {
toast.error("Could not download report", errorMessage(err))
} finally {
setDownloading(false)
}
}
return (
<Button variant="outline" onClick={handleDownload} disabled={disabled || downloading}>
<FileSpreadsheet className="size-4" />
{downloading ? "Preparing…" : "Download CSV"}
</Button>
)
}
@@ -0,0 +1,41 @@
"use client"
import { useState } from "react"
import { Download } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { ReportType } from "@/types/general-ledger"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
interface DownloadPdfButtonProps {
reportType: ReportType
/** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */
params: Record<string, string | number | undefined>
/** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */
disabled?: boolean
}
export function DownloadPdfButton({ reportType, params, disabled }: DownloadPdfButtonProps) {
const [downloading, setDownloading] = useState(false)
async function handleDownload() {
setDownloading(true)
try {
await reportsApi.downloadPdf(reportType, params)
} catch (err) {
toast.error("Could not download report", errorMessage(err))
} finally {
setDownloading(false)
}
}
return (
<Button variant="outline" onClick={handleDownload} disabled={disabled || downloading}>
<Download className="size-4" />
{downloading ? "Preparing…" : "Download PDF"}
</Button>
)
}
@@ -0,0 +1,27 @@
// Statutory-style report header (docs/21-GENERAL-LEDGER-FRONTEND.md "Sri Lankan Standard report
// UI"): centered title block, LKAS-aligned statement names, period/as-at line, currency note —
// the same shape whether the report renders on screen or the downloaded PDF (the PDF itself is
// rendered server-side by the GL service; this header is the on-screen equivalent).
interface ReportHeaderProps {
title: string
subtitle: string
currencyNote?: string
}
export function ReportHeader({
title,
subtitle,
currencyNote = "All amounts in Sri Lankan Rupees (LKR) unless stated otherwise.",
}: ReportHeaderProps) {
return (
<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">{title}</h2>
<p className="text-base text-muted-foreground">{subtitle}</p>
<p className="text-sm text-muted-foreground">{currencyNote}</p>
</div>
)
}
@@ -0,0 +1,42 @@
import { formatAmount } from "@/lib/format"
import { Table, TableBody, TableCell, TableFooter, TableRow } from "@/components/ui/table"
export interface ReportSectionLine {
label: React.ReactNode
amount: number
}
interface ReportSectionProps {
title: string
lines: ReportSectionLine[]
/** Omit to hide the total row entirely (e.g. a single-line section that would just repeat itself). */
total?: number
}
/** One bordered statement section: its lines, then an optional bold total row. Renders nothing when there are no lines. */
export function ReportSection({ title, lines, total }: ReportSectionProps) {
if (lines.length === 0) return null
return (
<div className="mb-4 rounded-lg border p-4">
<p className="mb-2 text-sm font-semibold tracking-wide text-muted-foreground uppercase">{title}</p>
<Table className="text-base">
<TableBody>
{lines.map((line, i) => (
<TableRow key={i} className="border-0 hover:bg-transparent">
<TableCell className="px-0 py-1.5">{line.label}</TableCell>
<TableCell className="px-0 py-1.5 text-right tabular-nums">{formatAmount(line.amount)}</TableCell>
</TableRow>
))}
</TableBody>
{total !== undefined && (
<TableFooter className="border-t bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-0 py-2 font-semibold">Total {title}</TableCell>
<TableCell className="px-0 py-2 text-right font-semibold tabular-nums">{formatAmount(total)}</TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</div>
)
}
@@ -0,0 +1,23 @@
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
interface ReportSubtotalProps {
label: string
amount: number
large?: boolean
}
/** A bold, unbordered subtotal/total line (Gross Profit, Net Cash From Operations, the final total, etc.). */
export function ReportSubtotal({ label, amount, large }: ReportSubtotalProps) {
return (
<div
className={cn(
"mb-4 flex items-center justify-between rounded-lg bg-muted/40 px-4 py-3 font-bold",
large && "text-lg"
)}
>
<span>{label}</span>
<span className="tabular-nums">{formatAmount(amount)}</span>
</div>
)
}
@@ -0,0 +1,322 @@
// Client for the external General Ledger service, reached through ERPCore's generic
// reverse-proxy at /api/v1/gl/* (docs/12-GENERAL-LEDGER-INTEGRATION.md). Deliberately NOT
// built on lib/api-client.ts's apiRequest/apiRequestWithETag: those assume ERPCore's own
// RFC 7807 ProblemDetails error shape and a bare-DTO success body. GL wraps every response
// (success AND error) in its own `{ statusCode, success, message, data }` envelope instead,
// and — a documented GL quirk — success bodies are camelCase while error bodies are
// PascalCase, so this module unwraps both forms itself rather than trusting one casing.
import {
CashAccountType,
CashAndBankAccountDto,
CashBankAccountType,
CreateBankAccountRequest,
CreateCashAccountRequest,
CreateCashOrBankAccountResponse,
GlAccountListResult,
GlBudget,
GlFilePayload,
ReportOutputFormat,
ReportType,
TrialBalanceRow,
BalanceSheetResponse,
GeneralLedgerRow,
ProfitAndLossResponse,
CashFlowResponse,
BudgetVsActualRow,
TaxSummaryResponse,
TaxSummaryParams,
GlPagedResult,
ChequeBook,
ChequeBookStatus,
ChequePage,
CreateChequeBookRequest,
IssueChequePageRequest,
UpdateChequePageStatusRequest,
ReceivedCheque,
ReceivedChequeStatus,
ReceivedFromType,
CreateReceivedChequeRequest,
UpdateReceivedChequeStatusRequest,
} from "@/types/general-ledger"
const GL_BASE = "/api/v1/gl"
/** Duck-type compatible with lib/error-map.ts's ApiErrorLike — `detail` carries GL's own message. */
export class GlApiError extends Error {
status: number
detail: string
constructor(status: number, message: string) {
super(message)
this.status = status
this.detail = message
}
}
interface GlEnvelope<T> {
statusCode?: number
StatusCode?: number
success?: boolean
Success?: boolean
message?: string
Message?: string
data?: T
Data?: T
// Surfaces only when the proxy itself fails before reaching GL (e.g. ERPCore's own
// 503 GL_SERVICE_UNAVAILABLE ProblemDetails) rather than GL's own envelope.
title?: string
detail?: string
}
type GlQueryValue = string | number | undefined
async function glRequest<T>(
path: string,
options: { method?: string; query?: Record<string, GlQueryValue>; body?: unknown } = {}
): Promise<T> {
const { method = "GET", query, body } = options
const search = new URLSearchParams()
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === "") continue
search.set(key, String(value))
}
}
const qs = search.toString()
const response = await fetch(`${GL_BASE}${path}${qs ? `?${qs}` : ""}`, {
method,
credentials: "include", // the proxy is ErpAccess-gated, same as every other v1 endpoint
headers: {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
let envelope: GlEnvelope<T> | null = null
try {
envelope = (await response.json()) as GlEnvelope<T>
} catch {
// Non-JSON body — e.g. an unreachable proxy hop. Falls through to the generic message below.
}
const success = envelope?.success ?? envelope?.Success ?? false
if (!response.ok || !success) {
const message =
envelope?.message ?? envelope?.Message ?? envelope?.detail ?? envelope?.title ??
response.statusText ?? "General Ledger service request failed"
throw new GlApiError(response.status, message)
}
return (envelope?.data ?? envelope?.Data) as T
}
/** Decodes a base64 payload and triggers a browser download — no server round-trip needed. */
function downloadBase64File(base64: string, fileName: string, contentType: string) {
const byteChars = atob(base64)
const byteNumbers = new Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i)
const blob = new Blob([new Uint8Array(byteNumbers)], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/** Shared by downloadPdf/downloadCsv — same report call, only `outputFormat` differs. */
async function downloadReportFile(
reportType: ReportType,
outputFormat: ReportOutputFormat.Pdf | ReportOutputFormat.Csv,
params: Record<string, GlQueryValue>
): Promise<void> {
const payload = await glRequest<GlFilePayload>("/reports", {
query: { reportType, outputFormat, ...params },
})
downloadBase64File(payload.contentBase64, payload.fileName, payload.contentType)
}
export const reportsApi = {
trialBalance(asOfDate: string) {
return glRequest<TrialBalanceRow[]>("/reports", {
query: { reportType: ReportType.TrialBalance, outputFormat: ReportOutputFormat.Json, asOfDate },
})
},
/** Confirmed classified-statement shape (2026-07-31 rework) — see types/general-ledger.ts's BalanceSheetResponse note. */
balanceSheet(asOfDate: string) {
return glRequest<BalanceSheetResponse>("/reports", {
query: { reportType: ReportType.BalanceSheet, outputFormat: ReportOutputFormat.Json, asOfDate },
})
},
// GL's `accountCode` param is optional (renamed from `accountId` in GL's 2026-07-22 revision,
// CLAUDE.md Rule 8.2 on GL's side — behavior unchanged): omitted, this returns the true General
// Ledger — every postable account's own transactions together, each with its own running
// balance (resets per account), sorted by accountCode then entryDate. Supplying accountCode
// switches to "Account Ledger" mode (one account + its descendants, one running balance) — not
// used by this page; add it back with an accountCode param if a single-account view is needed later.
generalLedger(periodStart: string, periodEnd: string) {
return glRequest<GeneralLedgerRow[]>("/reports", {
query: { reportType: ReportType.GeneralLedger, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
})
},
/** Nested-sections shape as of the 2026-07-22 rework — see types/general-ledger.ts's ProfitAndLossResponse note. */
profitAndLoss(periodStart: string, periodEnd: string) {
return glRequest<ProfitAndLossResponse>("/reports", {
query: { reportType: ReportType.ProfitAndLoss, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
})
},
/** Confirmed structured-statement shape (2026-07-22 rework) — see types/general-ledger.ts's CashFlowResponse note; everything nests under `operatingActivities`. */
cashFlow(periodStart: string, periodEnd: string) {
return glRequest<CashFlowResponse>("/reports", {
query: { reportType: ReportType.CashFlow, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
})
},
budgetVsActual(budgetId: number) {
return glRequest<BudgetVsActualRow[]>("/reports", {
query: { reportType: ReportType.BudgetVsActual, outputFormat: ReportOutputFormat.Json, budgetId },
})
},
/** Income Tax Computation — new report (2026-07-22). Optional params get no client-side default; an untouched field sends nothing, letting GL's own server-side defaulting be the single source of truth. */
taxSummary(params: TaxSummaryParams) {
return glRequest<TaxSummaryResponse>("/reports", {
query: { reportType: ReportType.TaxSummary, outputFormat: ReportOutputFormat.Json, ...params },
})
},
/** Same report call as the Json variants above, only `outputFormat` differs — the PDF bytes come from the same endpoint. */
downloadPdf(reportType: ReportType, params: Record<string, GlQueryValue>): Promise<void> {
return downloadReportFile(reportType, ReportOutputFormat.Pdf, params)
},
/** Same mechanism as downloadPdf, `outputFormat=Csv` — GL's bytes are downloaded unmodified, not reshaped/reformatted client-side. */
downloadCsv(reportType: ReportType, params: Record<string, GlQueryValue>): Promise<void> {
return downloadReportFile(reportType, ReportOutputFormat.Csv, params)
},
}
/**
* Chart of Accounts — used by the Cash/Bank Accounts list page to resolve each row's `glAccountId`
* into a readable account code/name (retrofit 2026-07-31: the create form no longer needs this at
* all, since `glAccountCode` was removed from the create request — the GL account is auto-created).
* The General Ledger **report** page deliberately does NOT use this: it always calls the report in
* full-ledger mode (no `accountCode`), so every account's code/name shown come from the report's own
* rows (`GeneralLedgerRow.accountCode`/`accountName`), not a separate `/accounts` call — see
* docs/21-GENERAL-LEDGER-FRONTEND.md.
*/
export const glAccountsApi = {
list(): Promise<GlAccountListResult> {
return glRequest<GlAccountListResult>("/accounts")
},
}
/** Used only to populate the Budget vs Actual report's budget picker. */
export const glBudgetsApi = {
list(): Promise<GlBudget[]> {
return glRequest<GlBudget[]>("/budgets")
},
}
export const bankAccountsApi = {
/** GL's own server-side union of both tables (2026-07-22 rework) — `accountType` narrows which table(s) contribute rows; client-side filters still layer on top. */
list(accountType?: CashBankAccountType | "Both"): Promise<CashAndBankAccountDto[]> {
return glRequest<CashAndBankAccountDto[]>("/bank-accounts", { query: { accountType } })
},
createBank(request: CreateBankAccountRequest): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>("/bank-accounts", { method: "POST", body: request })
},
createCash(request: CreateCashAccountRequest): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
},
// No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
}
/** Feeds the Cash/Bank create form's Cash Account Type picker; a name with no match creates a new type on the fly server-side (nothing to pre-create from this list). */
export const cashAccountTypesApi = {
list(): Promise<CashAccountType[]> {
return glRequest<CashAccountType[]>("/cash-account-types")
},
}
/**
* Cheque Books/Pages — cheques issued from this company's own cheque books (Cheque Management
* module, added to GL 2026-07-30). `chequeBookNo` is the identifying value GL uses in its own
* routes, not a numeric id. No `list()`/`get()` for pages standalone — a book's pages are always
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
*/
export const chequeBooksApi = {
list(params?: {
bankAccountId?: number
branchId?: number
status?: ChequeBookStatus
page?: number
pageSize?: number
}): Promise<GlPagedResult<ChequeBook>> {
return glRequest<GlPagedResult<ChequeBook>>("/cheque-books", { query: { ...params } })
},
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
query: expandPages ? { expand: "pages" } : undefined,
})
},
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
},
}
export const chequePagesApi = {
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
method: "PUT",
body: request,
})
},
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
method: "PUT",
body: request,
})
},
}
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
export const receivedChequesApi = {
list(params?: {
companyId?: number
branchId?: number
status?: ReceivedChequeStatus
receivedFromType?: ReceivedFromType
page?: number
pageSize?: number
}): Promise<GlPagedResult<ReceivedCheque>> {
return glRequest<GlPagedResult<ReceivedCheque>>("/received-cheques", { query: { ...params } })
},
create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
},
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
},
}
+35
View File
@@ -0,0 +1,35 @@
// Formatting helpers for statutory-style financial reports (docs/21-GENERAL-LEDGER-FRONTEND.md) —
// comma-grouped thousands, fixed 2 decimals, negatives in parentheses (standard financial-statement
// convention), rather than the plain `.toFixed(2)` used by the inventory-side stock screens.
const AMOUNT_FORMATTER = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
/** `1234.5` -> "1,234.50"; `-1234.5` -> "(1,234.50)"; `0`/`null`/`undefined` -> the given fallback. */
export function formatAmount(value: number | null | undefined, zeroDash = false): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—"
if (zeroDash && value === 0) return "—"
const formatted = AMOUNT_FORMATTER.format(Math.abs(value))
return value < 0 ? `(${formatted})` : formatted
}
/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */
export function formatReportDate(value: string | null | undefined): string {
if (!value) return "—"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
}
/** Today's date as `YYYY-MM-DD`, for default report filter values. */
export function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
/** The first day of the current month as `YYYY-MM-DD`, for default period-start filter values. */
export function startOfMonthIso(): string {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
}
@@ -0,0 +1,55 @@
// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields the browser
// already knows about. Everything else is server-authoritative and surfaced via the GL
// service's own error message (lib/error-map.ts).
export function validateBankAccountForm(input: { accountName: string }): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.accountName.trim()) errors.accountName = "Account name is required"
return errors
}
export function validateChequeBookForm(input: {
branchId: string
bankAccountId: string
chequeBookNo: string
startChequeNo: string
endChequeNo: string
totalLeaves: string
receivedDate: string
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.branchId.trim()) errors.branchId = "Branch ID is required"
if (!input.bankAccountId) errors.bankAccountId = "Select a bank account"
if (!input.chequeBookNo.trim()) errors.chequeBookNo = "Cheque book number is required"
if (!input.startChequeNo.trim()) errors.startChequeNo = "Start cheque number is required"
if (!input.endChequeNo.trim()) errors.endChequeNo = "End cheque number is required"
if (!input.totalLeaves.trim()) errors.totalLeaves = "Total leaves is required"
if (!input.receivedDate) errors.receivedDate = "Received date is required"
return errors
}
export function validateIssueChequeForm(input: { payeeName: string; issueDate: string; amount: string }): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.payeeName.trim()) errors.payeeName = "Payee name is required"
if (!input.issueDate) errors.issueDate = "Issue date is required"
if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0"
return errors
}
export function validateReceivedChequeForm(input: {
companyId: string
receivedFromName: string
chequeNo: string
chequeDate: string
amount: string
receivedDate: string
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.companyId.trim()) errors.companyId = "Company ID is required"
if (!input.receivedFromName.trim()) errors.receivedFromName = "Received-from name is required"
if (!input.chequeNo.trim()) errors.chequeNo = "Cheque number is required"
if (!input.chequeDate) errors.chequeDate = "Cheque date is required"
if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0"
if (!input.receivedDate) errors.receivedDate = "Received date is required"
return errors
}
+558
View File
@@ -0,0 +1,558 @@
// Mirrors the external General Ledger service's own contract (04_API_Reference_And_Scenarios.md
// in that service's repo — ERPCore does not re-document it, see docs/12-GENERAL-LEDGER-INTEGRATION.md).
// Every field here is read through ERPCore's generic proxy (`lib/api/general-ledger.ts`), which
// forwards byte-for-byte, so these types describe GL's response `data` shape directly, not an
// ERPCore DTO. As of GL's 2026-07-22 revision (docs/21-GENERAL-LEDGER-FRONTEND.md §0/§3/§4).
/** `account_type` seed rows — the block each drives in a generated account code (1000/2000/3000/4000/5000). */
export enum GlAccountTypeId {
Asset = 1,
Liability = 2,
Equity = 3,
Income = 4,
Expense = 5,
}
export interface GlAccount {
accountId: number
accountCode: string
accountName: string
accountTypeId: GlAccountTypeId
parentAccountId: number | null
isControlAccount: boolean
isPostable: boolean
isActive: boolean
currencyCode: string
cashFlowCategory?: string | null
}
export interface GlAccountListResult {
items: GlAccount[]
totalCount: number
page: number | null
pageSize: number | null
}
export interface GlBudget {
budgetId: number
fiscalYearId: number
name: string
lines: unknown[]
}
export enum ReportOutputFormat {
Json = "Json",
Pdf = "Pdf",
Csv = "Csv",
}
export enum ReportType {
TrialBalance = "TrialBalance",
BalanceSheet = "BalanceSheet",
GeneralLedger = "GeneralLedger",
ProfitAndLoss = "ProfitAndLoss",
CashFlow = "CashFlow",
BudgetVsActual = "BudgetVsActual",
TaxSummary = "TaxSummary",
}
/** Flat as of the 2026-07-22 revision — GL dropped the hierarchy (`depth`/`indentedCode`) this report used to carry. */
export interface TrialBalanceRow {
accountCode: string
accountName: string
debit: number
credit: number
}
/** One leaf account within a Balance Sheet section — no `depth`/hierarchy anymore (2026-07-31 rework, see BalanceSheetResponse). */
export interface BalanceSheetLine {
accountCode: string
accountName: string
balance: number
}
export interface BalanceSheetSection {
lines: BalanceSheetLine[]
total: number
}
/**
* Confirmed shape (04_API_Reference_And_Scenarios.md, Module: Reporting, retrofit 2026-07-31) —
* replaces the old flat recursive-rollup array (`{depth, lineItem, accountType, balance}`) entirely
* with a classified LKAS 1 Statement of Financial Position: Non-Current/Current split for both
* Assets and Liabilities, driven by GL's new `accounts.balance_sheet_classification` tag.
* `equity.lines[]` always includes a synthetic `{ accountCode: "", accountName: "Current Year
* Earnings", balance }` line (even at `0.00`). Untagged leaf accounts land in
* `unclassifiedAssets`/`unclassifiedLiabilities` rather than being silently dropped.
*
* Every section is optional — same defensive posture as `CashFlowResponse`/`ProfitAndLossResponse`
* (confirmed live: GL omits an empty section from the JSON entirely rather than sending
* `{ lines: [], total: 0 }`), applied here pre-emptively since this exact shape hasn't been
* live-verified against this frontend yet.
*/
export interface BalanceSheetResponse {
asOfDate: string
nonCurrentAssets?: BalanceSheetSection
currentAssets?: BalanceSheetSection
unclassifiedAssets?: BalanceSheetSection
totalAssets: number
equity?: BalanceSheetSection
nonCurrentLiabilities?: BalanceSheetSection
currentLiabilities?: BalanceSheetSection
unclassifiedLiabilities?: BalanceSheetSection
totalEquityAndLiabilities: number
}
export interface GeneralLedgerRow {
entryDate: string
journalNo: string
accountCode: string
accountName: string
narration: string | null
debitAmount: number
creditAmount: number
runningBalance: number
}
/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */
export interface ProfitAndLossLine {
accountCode: string
accountName: string
amount: number
}
export interface ProfitAndLossSection {
lines: ProfitAndLossLine[]
total: number
}
/**
* Nested-sections shape (2026-07-22 rework, replaces the old flat `ProfitAndLossRow[]`). GL's own
* reference names the sections and the two top-level totals but does not spell out each line's
* exact field names — `ProfitAndLossLine` above is an **inferred** shape (matching every other
* report's `accountCode`/`accountName` convention), not a confirmed contract. `unclassified` is
* only present with lines if the Chart of Accounts has untagged Income/Expense accounts.
*
* Every section is optional — **confirmed live** (2026-07-31, via the identical bug on
* `CashFlowResponse`'s list fields below): GL's serializer omits a section from the JSON
* entirely when it has nothing to report for the period, rather than sending `{ lines: [], total: 0 }`.
* Every consumer must optional-chain (`report.sales?.lines`), never assume presence.
*/
export interface ProfitAndLossResponse {
sales?: ProfitAndLossSection
costOfSales?: ProfitAndLossSection
grossProfit: number
otherIncome?: ProfitAndLossSection
distributionExpenses?: ProfitAndLossSection
administrationExpenses?: ProfitAndLossSection
otherExpenses?: ProfitAndLossSection
financialExpenses?: ProfitAndLossSection
unclassified?: ProfitAndLossSection
netProfitForPeriod: number
}
export interface CashFlowNonCashAdjustment {
description: string
amount: number
}
/** `changeAmount`, not `amount` — confirmed field name (04_API_Reference_And_Scenarios.md, Module: Reporting). */
export interface CashFlowWorkingCapitalChange {
accountCode: string
accountName: string
direction: "Increase" | "Decrease"
changeAmount: number
}
/** One line inside an Investing/Financing section — GL's reference confirms `lines[]` exists but not
* this line's own field names; `{description, amount}` here matches every other report's line-item
* convention but is not verified verbatim. */
export interface CashFlowActivityLine {
description: string
amount: number
}
export interface CashFlowOperatingActivities {
profitForPeriod: number
nonCashAdjustments: CashFlowNonCashAdjustment[]
workingCapitalChanges: CashFlowWorkingCapitalChange[]
netCashFromOperatingActivities: number
}
export interface CashFlowInvestingActivities {
lines: CashFlowActivityLine[]
netCashFromInvestingActivities: number
}
export interface CashFlowFinancingActivities {
lines: CashFlowActivityLine[]
netCashFromFinancingActivities: number
}
/**
* Confirmed shape (04_API_Reference_And_Scenarios.md, Module: Reporting — GL's own API reference,
* not inferred). **Everything nests under `operatingActivities`** — the previous version of this
* type had `netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` as
* flat top-level fields, which was wrong and caused a live runtime crash (`Cannot read properties
* of undefined (reading 'map')` on `nonCashAdjustments` — it was never at the top level to begin
* with). `investingActivities`/`financingActivities` each have their own differently-named total
* field (`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`.
* `openingCashBalance`/`closingCashBalance` are returned for validation but deliberately not
* rendered on screen — GL's own PDF/CSV doesn't print them either.
*/
export interface CashFlowResponse {
periodStart: string
periodEnd: string
operatingActivities: CashFlowOperatingActivities
investingActivities: CashFlowInvestingActivities
financingActivities: CashFlowFinancingActivities
netIncreaseDecreaseInCash: number
openingCashBalance: number
closingCashBalance: number
}
export interface BudgetVsActualRow {
budgetLineId: number
accountCode: string
accountName: string
periodId: number
budgetedAmount: number
actualAmount: number
variance: number
}
/**
* Income Tax Computation (2026-07-22 redesign). Confirmed shape (04_API_Reference_And_Scenarios.md,
* Module: Reporting) — not inferred. The previous version of this type was missing
* `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`, `whtCredit`, and
* `quarterlyTaxPayments` entirely, which meant the Tax Report screen was silently dropping real
* GL-computed figures rather than a made-up guess just being wrong. Full row order,
* `profitBeforeTax` → `balanceTaxPayable`, with bold reconciliation checkpoints at
* `adjustedBusinessProfit`/`assessableIncome`/`taxableIncome`/`grossTaxLiability`/the final total.
*/
export interface TaxSummaryResponse {
periodStart: string
periodEnd: string
profitBeforeTax: number
nonDeductibleExpenses: number
allowableDeductions: number
adjustedBusinessProfit: number
otherTaxableIncome: number
assessableIncome: number
qualifyingPaymentsReliefs: number
taxableIncome: number
taxRatePercent: number
corporateIncomeTax: number
surchargeAmount: number
grossTaxLiability: number
apitCredit: number
whtCredit: number
quarterlyTaxPayments: number
balanceTaxPayable: number
}
export interface TaxSummaryParams {
periodStart: string
periodEnd: string
allowableDeductions?: number
otherTaxableIncome?: number
qualifyingPaymentsReliefs?: number
surchargeAmount?: number
taxRateOverride?: number
}
/** `outputFormat=Pdf`/`Csv` response shape — base64-encoded bytes inside the normal envelope either way. */
export interface GlFilePayload {
fileName: string
contentType: string
contentBase64: string
}
/** Cash and Bank are two separate GL tables/endpoints (2026-07-22 rework) — this discriminates the unified list row and the create-form toggle, not a database column on either side. */
export enum CashBankAccountType {
Cash = "Cash",
Bank = "Bank",
}
/**
* `GET /bank-accounts?accountType=Cash|Bank|Both` row shape (2026-07-22 rework) — GL's own reference
* documents this explicitly (`docs/21` §4), unlike the old single-table `BankAccount` shape it
* replaces, which was inferred. `bankName` is `null` on `Cash` rows, `cashAccountTypeName` is `null`
* on `Bank` rows.
*/
export interface CashAndBankAccountDto {
accountType: CashBankAccountType
accountId: number
accountName: string
bankName: string | null
cashAccountTypeName: string | null
accountNumber: string | null
glAccountId: number
currencyCode: string
createdAt: string
}
/**
* Confirmed (04_API_Reference_And_Scenarios.md, Module: Bank, retrofit 2026-07-31) —
* `glAccountCode` was removed from this request entirely. The backing GL account is now always
* auto-created server-side (a root "Bank" header account is found-or-created, then a postable leaf
* named after `accountName` is created under it) — the caller never selects or supplies a GL account.
*/
export interface CreateBankAccountRequest {
accountName: string
bankName?: string | null
accountNumber?: string | null
currencyCode?: string
}
/**
* Confirmed (retrofit 2026-07-31) — same `glAccountCode` removal as `CreateBankAccountRequest`, plus
* one more auto-created level: a root "Cash" header, then a per-`cashAccountTypeName` header (created
* once, reused thereafter), then a postable leaf named after `accountName`.
*/
export interface CreateCashAccountRequest {
accountName: string
cashAccountTypeName: string
accountNumber?: string | null
currencyCode?: string
}
/**
* `POST /bank-accounts` / `POST /cash-accounts` response (retrofit 2026-07-31) — since the GL account
* is now auto-created rather than caller-supplied, the created leaf account (nested under its
* auto-created/reused header via `parentAccount`) is returned under `glAccount` so the caller can see
* exactly what was generated. `glAccount`/`glAccountId` are confirmed from the reference doc; the
* other fields are inferred (they mirror the create request's own fields plus an id, following this
* project's usual `<entity>` response convention).
*/
export interface CreateCashOrBankAccountResponse {
accountName: string
bankName?: string | null
cashAccountTypeName?: string | null
accountNumber?: string | null
currencyCode: string
glAccountId: number
glAccount: GlAccount & { parentAccount?: GlAccount | null }
}
/** `GET /cash-account-types` row — flat reference list (seeded Petty Cash / Till Cash / Safe Cash / Cash in Transit, grows over time via on-the-fly creation from the create form). */
export interface CashAccountType {
cashAccountTypeId: number
name: string
}
/** Shared `{ items, totalCount, page, pageSize }` list envelope used by every Cheque Management list endpoint. */
export interface GlPagedResult<T> {
items: T[]
totalCount: number
page: number | null
pageSize: number | null
}
// ---------------------------------------------------------------------------
// Cheque Management (new GL module, added 2026-07-30, beyond the original plan).
// Purely operational tracking — no endpoint here ever creates/touches a journal entry itself.
// `branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are documented as deliberately
// "loose references" (plain unvalidated numbers) — no Branch/Company/Customer/Supplier table
// exists in this GL service for them to point at, so the frontend takes them as free-entry
// numbers rather than picker dropdowns, matching GL's own stated design.
// ---------------------------------------------------------------------------
export enum PayeeType {
Supplier = "Supplier",
Customer = "Customer",
Employee = "Employee",
Other = "Other",
}
export enum ReceivedFromType {
Customer = "Customer",
Supplier = "Supplier",
Other = "Other",
}
export enum ChequeBookStatus {
Active = "Active",
Completed = "Completed",
Cancelled = "Cancelled",
}
export enum ChequePageIssueStatus {
Unused = "Unused",
Issued = "Issued",
Cleared = "Cleared",
Bounced = "Bounced",
Cancelled = "Cancelled",
Void = "Void",
}
/** `PUT /cheque-pages/{chequeNo}/status`'s `action` values. */
export enum ChequePageStatusAction {
Clear = "Clear",
Bounce = "Bounce",
Cancel = "Cancel",
Void = "Void",
}
export enum ReceivedChequeStatus {
Received = "Received",
Deposited = "Deposited",
Cleared = "Cleared",
Returned = "Returned",
Cancelled = "Cancelled",
}
/** `PUT /received-cheques/{id}/status`'s `action` values. */
export enum ReceivedChequeStatusAction {
Deposit = "Deposit",
Clear = "Clear",
Return = "Return",
Cancel = "Cancel",
}
/**
* A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue`
* request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in
* prose — this type is built from those, not a guessed shape. `chequeNo` (not a numeric id) is
* the documented identifying value for `GET/PUT /cheque-pages/{chequeNo}`, so it's used as the
* key/URL param throughout rather than an unconfirmed `chequePageId`.
*/
export interface ChequePage {
chequeNo: string
chequeBookNo?: string
issueStatus: ChequePageIssueStatus
payeeType: PayeeType | null
payeeId: number | null
payeeName: string | null
issueDate: string | null
amount: number | null
currencyCode: string | null
voucherId: number | null
referenceNo: string | null
purpose: string | null
isCrossCheque: boolean | null
isAccountPayee: boolean | null
isPostDated: boolean | null
notes: string | null
printedBy: string | null
printedAt: string | null
clearedDate: string | null
clearedByBank: boolean | null
cancelReason: string | null
}
/**
* A cheque book issued from this company's own supply. `chequeBookNo` (caller-supplied, unique)
* is the documented identifying value for `GET /cheque-books/{chequeBookNo}`, used as the
* key/URL param throughout. `pages[]` is only populated when fetched with `?expand=pages`.
*/
export interface ChequeBook {
chequeBookNo: string
branchId: number
bankAccountId: number
startChequeNo: string
endChequeNo: string
totalLeaves: number
receivedDate: string
description: string | null
createdBy: string | null
status: ChequeBookStatus
pages: ChequePage[]
}
export interface CreateChequeBookRequest {
branchId: number
bankAccountId: number
chequeBookNo: string
startChequeNo: string
endChequeNo: string
totalLeaves: number
receivedDate: string
description?: string
createdBy?: string
}
export interface IssueChequePageRequest {
payeeType: PayeeType
payeeId?: number
payeeName: string
issueDate: string
amount: number
currencyCode?: string
voucherId?: number
referenceNo?: string
purpose?: string
isCrossCheque?: boolean
isAccountPayee?: boolean
isPostDated?: boolean
notes?: string
printedBy?: string
}
export interface UpdateChequePageStatusRequest {
action: ChequePageStatusAction
/** Required only for `action: "Clear"`. */
clearedDate?: string
/** Required only for `action: "Cancel"`. */
cancelReason?: string
performedBy?: string
}
/**
* A cheque received from a customer/supplier/other party — deliberately has no link to a
* `ChequeBook` (it isn't one of this company's own). GL's reference uses a numeric `{id}` in the
* URL for `GET/PUT /received-cheques/{id}` without spelling out the JSON field's exact name —
* `receivedChequeId` follows this project's consistent `<entity>Id` convention (e.g. `assetId`,
* `disposalId`), not a confirmed literal.
*/
export interface ReceivedCheque {
receivedChequeId: number
companyId: number
branchId: number | null
receivedFromType: ReceivedFromType
receivedFromId: number | null
receivedFromName: string
drawerBankName: string | null
drawerBankBranch: string | null
accountHolderName: string | null
chequeNo: string
chequeDate: string
amount: number
receivedDate: string
referenceType: string | null
referenceId: number | null
notes: string | null
createdBy: string | null
status: ReceivedChequeStatus
depositBankAccountId: number | null
depositDate: string | null
}
export interface CreateReceivedChequeRequest {
companyId: number
branchId?: number
receivedFromType: ReceivedFromType
receivedFromId?: number
receivedFromName: string
drawerBankName?: string
drawerBankBranch?: string
accountHolderName?: string
chequeNo: string
chequeDate: string
amount: number
receivedDate: string
referenceType?: string
referenceId?: number
notes?: string
createdBy?: string
}
export interface UpdateReceivedChequeStatusRequest {
action: ReceivedChequeStatusAction
/** Required only for `action: "Deposit"`. */
depositBankAccountId?: number
/** Required only for `action: "Deposit"`. */
depositDate?: string
notes?: string
performedBy?: string
}