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,189 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { receivedCheques as receivedChequesApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { ReceivedFromType } 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'
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 next: Record<string, string> = {}
const companyNum = Number(companyId)
if (!companyId || Number.isNaN(companyNum) || companyNum <= 0) next.companyId = 'Company ID is required'
if (!receivedFromName.trim()) next.receivedFromName = 'Received from name is required'
if (!chequeNo.trim()) next.chequeNo = 'Cheque number is required'
if (!chequeDate) next.chequeDate = 'Cheque date is required'
const amountNum = Number(amount)
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) next.amount = 'Amount must be greater than zero'
if (!receivedDate) next.receivedDate = 'Received date is required'
setErrors(next)
if (Object.keys(next).length > 0) return
setSubmitting(true)
try {
const created = await receivedChequesApi.create({
companyId: companyNum,
branchId: branchId ? Number(branchId) : undefined,
receivedFromType,
receivedFromId: receivedFromId ? Number(receivedFromId) : undefined,
receivedFromName,
drawerBankName: drawerBankName || undefined,
drawerBankBranch: drawerBankBranch || undefined,
accountHolderName: accountHolderName || undefined,
chequeNo,
chequeDate,
amount: amountNum,
receivedDate,
referenceType: referenceType || undefined,
referenceId: referenceId ? Number(referenceId) : undefined,
notes: notes || undefined,
createdBy: createdBy || undefined,
})
toast.add({ title: 'Received cheque recorded', description: `${created.chequeNo} — ${created.receivedFromName}`, type: 'success' })
router.push('/payments/received-cheques')
} catch (err) {
toast.add({ title: 'Could not record received cheque', 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/received-cheques" className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground">New Received Cheque</h1>
<p className="text-sm text-muted-foreground mt-0.5">Record a cheque received from a customer, supplier, or other party.</p>
</div>
</div>
<div className="rounded-xl border bg-card p-6">
<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 (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"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.values(ReceivedFromType).map(t => (
<SelectItem key={t} value={t} label={t}>{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="/payments/received-cheques" className={cn(buttonVariants({ variant: 'outline' }))}>Cancel</Link>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? 'Recording…' : 'Record'}</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,287 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Inbox, Plus } from 'lucide-react'
import { bankAccounts as bankAccountsApi, receivedCheques as receivedChequesApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import {
BankAccountType,
ReceivedChequeStatus,
ReceivedChequeStatusAction,
type BankAccountResponse,
type ReceivedChequeResponse,
} 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, 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 StatusFilter = ReceivedChequeStatus | 'All'
const STATUS_BADGE_VARIANT: Record<ReceivedChequeStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
[ReceivedChequeStatus.Received]: 'outline',
[ReceivedChequeStatus.Deposited]: 'default',
[ReceivedChequeStatus.Cleared]: 'secondary',
[ReceivedChequeStatus.Returned]: 'destructive',
[ReceivedChequeStatus.Cancelled]: 'destructive',
}
function formatDate(value: string | null): string {
if (!value) return '—'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function formatAmount(value: number): string {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function ReceivedChequesPage() {
const [cheques, setCheques] = useState<ReceivedChequeResponse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>('All')
const [selected, setSelected] = useState<ReceivedChequeResponse | null>(null)
function load() {
receivedChequesApi
.list({ status: status === 'All' ? undefined : status, pageSize: 100 })
.then(res => setCheques(res.items))
.catch(err => setError(getApiError(err)))
}
useEffect(load, [status])
function handleUpdated(updated: ReceivedChequeResponse) {
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>
<h1 className="text-2xl font-semibold text-foreground">Received Cheques</h1>
<p className="text-sm text-muted-foreground mt-0.5">Cheques received from customers, suppliers, or others.</p>
</div>
<Link href="/payments/received-cheques/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
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="w-full sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses">All statuses</SelectItem>
{Object.values(ReceivedChequeStatus).map(s => (
<SelectItem key={s} value={s} label={s}>{s}</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 && cheques === null && (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
)}
{!error && cheques !== null && cheques.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Inbox className="size-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No received cheques yet.</p>
</div>
)}
{!error && cheques !== null && cheques.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cheque no.</TableHead>
<TableHead>Received from</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Received</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cheques.map(c => (
<TableRow key={c.receivedChequeId} className="cursor-pointer" onClick={() => setSelected(c)}>
<TableCell className="font-medium">{c.chequeNo}</TableCell>
<TableCell>{c.receivedFromName}</TableCell>
<TableCell>{c.receivedFromType}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell>{formatDate(c.receivedDate)}</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE_VARIANT[c.status]}>{c.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<ReceivedChequeDialog cheque={selected} onClose={() => setSelected(null)} onUpdated={handleUpdated} />
</div>
)
}
function ReceivedChequeDialog({
cheque,
onClose,
onUpdated,
}: {
cheque: ReceivedChequeResponse | null
onClose: () => void
onUpdated: (c: ReceivedChequeResponse) => void
}) {
const open = cheque !== null
const [mode, setMode] = useState<'view' | 'action'>('view')
const [action, setAction] = useState<ReceivedChequeStatusAction>(ReceivedChequeStatusAction.Deposit)
const [depositBankAccountId, setDepositBankAccountId] = useState('')
const [depositDate, setDepositDate] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const [bankOptions, setBankOptions] = useState<BankAccountResponse[]>([])
useEffect(() => {
if (!cheque) return
setMode('view')
setAction(ReceivedChequeStatusAction.Deposit)
setDepositBankAccountId('')
setDepositDate('')
setNotes('')
}, [cheque])
useEffect(() => {
if (mode !== 'action' || action !== ReceivedChequeStatusAction.Deposit || bankOptions.length > 0) return
bankAccountsApi.list(BankAccountType.Bank).then(setBankOptions).catch(() => setBankOptions([]))
}, [mode, action, bankOptions.length])
if (!cheque) return null
const availableActions: ReceivedChequeStatusAction[] =
cheque.status === ReceivedChequeStatus.Received
? [ReceivedChequeStatusAction.Deposit, ReceivedChequeStatusAction.Cancel]
: cheque.status === ReceivedChequeStatus.Deposited
? [ReceivedChequeStatusAction.Clear, ReceivedChequeStatusAction.Return]
: []
async function handleSubmit() {
if (!cheque) return
if (action === ReceivedChequeStatusAction.Deposit && (!depositBankAccountId || !depositDate)) {
toast.add({ title: 'Deposit bank account and date are required', type: 'error' })
return
}
setSubmitting(true)
try {
const updated = await receivedChequesApi.updateStatus(cheque.receivedChequeId, {
action,
depositBankAccountId: action === ReceivedChequeStatusAction.Deposit ? depositBankAccountId : undefined,
depositDate: action === ReceivedChequeStatusAction.Deposit ? depositDate : undefined,
notes: notes || undefined,
})
toast.add({ title: `Cheque ${action.toLowerCase()}ed`, type: 'success' })
onUpdated(updated)
setMode('view')
} catch (err) {
toast.add({ title: 'Could not update status', description: getApiError(err), type: 'error' })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cheque {cheque.chequeNo}</DialogTitle>
<DialogDescription>
Status: <Badge variant={STATUS_BADGE_VARIANT[cheque.status]}>{cheque.status}</Badge>
</DialogDescription>
</DialogHeader>
{mode === 'view' && (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><p className="text-xs text-muted-foreground">Received from</p><p>{cheque.receivedFromName}</p></div>
<div><p className="text-xs text-muted-foreground">Type</p><p>{cheque.receivedFromType}</p></div>
<div><p className="text-xs text-muted-foreground">Cheque date</p><p>{formatDate(cheque.chequeDate)}</p></div>
<div><p className="text-xs text-muted-foreground">Amount</p><p className="tabular-nums">{formatAmount(cheque.amount)}</p></div>
<div><p className="text-xs text-muted-foreground">Drawer bank</p><p>{cheque.drawerBankName ?? '—'}</p></div>
<div><p className="text-xs text-muted-foreground">Deposit date</p><p>{formatDate(cheque.depositDate)}</p></div>
{cheque.notes && (
<div className="col-span-2"><p className="text-xs text-muted-foreground">Notes</p><p>{cheque.notes}</p></div>
)}
</div>
{availableActions.length > 0 && (
<div className="pt-2">
<Button onClick={() => { setAction(availableActions[0]); setMode('action') }}>Change status</Button>
</div>
)}
</div>
)}
{mode === 'action' && (
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="rc-action">Action</FieldLabel>
<Select<ReceivedChequeStatusAction> value={action} onValueChange={(v) => setAction(v ?? availableActions[0])}>
<SelectTrigger id="rc-action" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{availableActions.map(a => (
<SelectItem key={a} value={a} label={a}>{a}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{action === ReceivedChequeStatusAction.Deposit && (
<>
<Field>
<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">
<SelectValue placeholder="Select a bank account" />
</SelectTrigger>
<SelectContent>
{bankOptions.map(b => (
<SelectItem key={b.accountId} value={b.accountId} label={b.accountName}>{b.accountName}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="rc-deposit-date">Deposit date</FieldLabel>
<Input id="rc-deposit-date" type="date" value={depositDate} onChange={(e) => setDepositDate(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>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setMode('view')} disabled={submitting}>Back</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? 'Saving…' : 'Confirm'}</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}