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