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:
@@ -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'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 "Unused".
|
||||
</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'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 company’s 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'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 & 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'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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user