feat: add cheque books and received cheques management
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m8s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m49s

- Implemented ChequeBooksPage for viewing and filtering cheque books.
- Added NewReceivedChequePage for recording received cheques.
- Created ReceivedChequesPage for listing and managing received cheques.
- Introduced API endpoints for bank accounts, cheque books, and received cheques.
- Defined types for payments including bank accounts, cheque books, and received cheques.
This commit is contained in:
Kalana B. Thilakarathna
2026-10-19 16:09:05 +05:30
parent f3f2b71bd4
commit 87c807c16c
39 changed files with 10095 additions and 50 deletions
@@ -0,0 +1,180 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { bankAccounts as bankAccountsApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { BankAccountType, type CashAccountTypeDto } from '@/types/payments'
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<BankAccountType>(BankAccountType.Bank)
const [cashAccountTypes, setCashAccountTypes] = useState<CashAccountTypeDto[] | 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 !== BankAccountType.Cash || cashAccountTypes !== null) return
bankAccountsApi.cashAccountTypes()
.then(setCashAccountTypes)
.catch(err => setCashAccountTypesError(getApiError(err)))
}, [accountType, cashAccountTypes])
const cashAccountTypeName =
cashAccountTypeChoice === OTHER_CASH_TYPE ? customCashAccountTypeName.trim() : cashAccountTypeChoice
async function handleSubmit() {
const next: Record<string, string> = {}
if (!accountName.trim()) next.accountName = 'Account name is required'
if (accountType === BankAccountType.Cash && !cashAccountTypeName) {
next.cashAccountTypeName = 'Select or enter a cash account type'
}
setErrors(next)
if (Object.keys(next).length > 0) return
setSubmitting(true)
try {
const created =
accountType === BankAccountType.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.add({ title: `${accountType} account created`, description: created.accountName, type: 'success' })
router.push('/payments/bank-accounts')
} catch (err) {
toast.add({ title: `Could not create ${accountType.toLowerCase()} account`, description: getApiError(err), type: 'error' })
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/payments/bank-accounts" className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground">New Cash / Bank Account</h1>
<p className="text-sm text-muted-foreground mt-0.5">Record a new cash or bank account for use in payments.</p>
</div>
</div>
<div className="rounded-xl border bg-card p-6">
<div className="mb-6 flex max-w-sm gap-2">
<Button
type="button"
variant={accountType === BankAccountType.Bank ? 'default' : 'outline'}
className="flex-1"
onClick={() => setAccountType(BankAccountType.Bank)}
>Bank</Button>
<Button
type="button"
variant={accountType === BankAccountType.Cash ? 'default' : 'outline'}
className="flex-1"
onClick={() => setAccountType(BankAccountType.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 === BankAccountType.Bank ? 'Main Account' : 'Head Office Petty Cash'}
aria-invalid={!!errors.accountName}
/>
<FieldError errors={[errors.accountName ? { message: errors.accountName } : undefined]} />
</Field>
{accountType === BankAccountType.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" aria-invalid={!!errors.cashAccountTypeName}>
<SelectValue placeholder={cashAccountTypes === null ? 'Loading…' : 'Select a type'} />
</SelectTrigger>
<SelectContent>
{(cashAccountTypes ?? []).map(t => (
<SelectItem key={t.name} value={t.name} label={t.name}>{t.name}</SelectItem>
))}
<SelectItem value={OTHER_CASH_TYPE} label="Other, please specify">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)} />
</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="/payments/bank-accounts" className={cn(buttonVariants({ variant: 'outline' }))}>Cancel</Link>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? 'Creating…' : 'Create'}</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,276 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { CheckCircle2, Pencil, Plus, Search, Trash2, Wallet, XCircle } from 'lucide-react'
import { bankAccounts as bankAccountsApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { BankAccountType, type BankAccountResponse } from '@/types/payments'
import { Badge } from '@/components/ui/badge'
import { Button, buttonVariants } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, 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 { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { toast } from '@/components/ui/toast'
type AccountTypeFilter = BankAccountType | 'All'
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<BankAccountResponse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [accountType, setAccountType] = useState<AccountTypeFilter>('All')
const [editTarget, setEditTarget] = useState<BankAccountResponse | null>(null)
const [editName, setEditName] = useState('')
const [editError, setEditError] = useState<string | null>(null)
const [editSubmitting, setEditSubmitting] = useState(false)
const [statusPendingId, setStatusPendingId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<BankAccountResponse | null>(null)
function load() {
const filter = accountType === 'All' ? undefined : accountType
bankAccountsApi
.list(filter)
.then(setAccounts)
.catch(err => setError(getApiError(err)))
}
useEffect(load, [accountType])
const filtered = useMemo(() => {
if (!accounts) return null
const q = search.trim().toLowerCase()
if (!q) return accounts
return accounts.filter(a =>
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),
)
}, [accounts, search])
function openEdit(a: BankAccountResponse) {
setEditTarget(a)
setEditName(a.accountName)
setEditError(null)
}
async function handleRename() {
if (!editTarget) return
if (!editName.trim()) {
setEditError('Account name is required')
return
}
setEditSubmitting(true)
try {
await bankAccountsApi.updateCash(editTarget.accountId, editName.trim())
toast.add({ title: 'Cash account renamed', type: 'success' })
setEditTarget(null)
load()
} catch (err) {
setEditError(getApiError(err))
} finally {
setEditSubmitting(false)
}
}
async function toggleStatus(a: BankAccountResponse) {
setStatusPendingId(a.accountId)
try {
await bankAccountsApi.setCashStatus(a.accountId, !a.isActive)
toast.add({ title: a.isActive ? 'Cash account deactivated' : 'Cash account activated', type: 'success' })
load()
} catch (err) {
toast.add({ title: 'Could not update status', description: getApiError(err), type: 'error' })
} finally {
setStatusPendingId(null)
}
}
async function handleDelete(a: BankAccountResponse) {
setDeletingId(a.accountId)
try {
await bankAccountsApi.deleteCash(a.accountId)
toast.add({ title: 'Cash account deleted', type: 'success' })
load()
} catch (err) {
toast.add({ title: 'Could not delete cash account', description: getApiError(err), type: 'error' })
} finally {
setDeletingId(null)
setConfirmDelete(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Cash / Bank Accounts</h1>
<p className="text-sm text-muted-foreground mt-0.5">Cash and bank accounts used for payments, deposits, and cheques.</p>
</div>
<Link href="/payments/bank-accounts/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
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-3 size-4 -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="pl-9"
aria-label="Search cash/bank accounts"
/>
</div>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? 'All')}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All types">All types</SelectItem>
<SelectItem value={BankAccountType.Bank} label="Bank">Bank</SelectItem>
<SelectItem value={BankAccountType.Cash} label="Cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>
)}
{!error && filtered === null && (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
)}
{!error && filtered !== null && filtered.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Wallet className="size-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{search ? 'No accounts match your search.' : 'No cash/bank accounts yet.'}
</p>
</div>
)}
{!error && filtered !== null && filtered.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Account name</TableHead>
<TableHead>Bank / Cash type</TableHead>
<TableHead>Account no.</TableHead>
<TableHead>Currency</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(a => (
<TableRow key={`${a.accountType}-${a.accountId}`}>
<TableCell>
<Badge variant={a.accountType === BankAccountType.Cash ? 'secondary' : 'default'}>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="font-medium">{a.accountName}</TableCell>
<TableCell>{a.bankName ?? a.cashAccountTypeName ?? '—'}</TableCell>
<TableCell>{a.accountNumber ?? '—'}</TableCell>
<TableCell>{a.currencyCode}</TableCell>
<TableCell>{formatDate(a.createdAt)}</TableCell>
<TableCell>
{a.accountType === BankAccountType.Cash ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(a)} aria-label={`Edit ${a.accountName}`}>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(a.isActive ? 'text-destructive hover:bg-destructive/10' : 'text-emerald-600 hover:bg-emerald-100')}
aria-label={a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
title={a.isDefault && a.isActive ? "Default cash account can't be deactivated" : undefined}
disabled={statusPendingId === a.accountId || (a.isDefault === true && a.isActive === true)}
onClick={() => toggleStatus(a)}
>
{a.isActive ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${a.accountName}`}
title={a.isDefault ? "Default cash account can't be deleted" : undefined}
disabled={deletingId === a.accountId || a.isDefault === true}
onClick={() => setConfirmDelete(a)}
>
<Trash2 className="size-4" />
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={!!editTarget} onOpenChange={(v) => { if (!v) setEditTarget(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Rename cash account</DialogTitle>
<DialogDescription>
{editTarget?.isDefault ? 'This is the default cash account — only its name can be changed.' : "Update this cash account's display name."}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editError}>
<FieldLabel htmlFor="cash-account-name">Account name</FieldLabel>
<Input id="cash-account-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editError} />
<FieldError errors={[editError ? { message: editError } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setEditTarget(null)} disabled={editSubmitting}>Cancel</Button>
<Button onClick={handleRename} disabled={editSubmitting}>{editSubmitting ? 'Saving…' : 'Save'}</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!confirmDelete} onOpenChange={(v) => { if (!v) setConfirmDelete(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete {confirmDelete?.accountName}?</DialogTitle>
<DialogDescription>This permanently removes the cash account. Accounts referenced elsewhere cannot be deleted.</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setConfirmDelete(null)} disabled={!!deletingId}>Cancel</Button>
<Button variant="destructive" onClick={() => confirmDelete && handleDelete(confirmDelete)} disabled={!!deletingId}>
{deletingId ? 'Deleting…' : 'Delete'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}