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>
)
}
@@ -0,0 +1,397 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { ArrowLeft, BookText } from 'lucide-react'
import { bankAccounts as bankAccountsApi, chequeBooks as chequeBooksApi, chequePages as chequePagesApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import {
BankAccountType,
ChequePageIssueStatus,
ChequePageStatusAction,
PayeeType,
type BankAccountResponse,
type ChequeBookResponse,
type ChequePageResponse,
} 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, 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'
const STATUS_BADGE_VARIANT: Record<ChequePageIssueStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
[ChequePageIssueStatus.Unused]: 'outline',
[ChequePageIssueStatus.Issued]: 'default',
[ChequePageIssueStatus.Cleared]: 'secondary',
[ChequePageIssueStatus.Bounced]: 'destructive',
[ChequePageIssueStatus.Cancelled]: 'destructive',
[ChequePageIssueStatus.Void]: 'outline',
}
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 | null): string {
if (value === null) return '—'
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function ChequeBookDetailPage() {
const params = useParams<{ chequeBookNo: string }>()
const chequeBookNo = decodeURIComponent(params.chequeBookNo)
const [book, setBook] = useState<ChequeBookResponse | null>(null)
const [bankAccount, setBankAccount] = useState<BankAccountResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePageResponse | null>(null)
useEffect(() => {
let cancelled = false
chequeBooksApi
.get(chequeBookNo, true)
.then(async res => {
if (cancelled) return
setBook(res)
try {
const banks = await bankAccountsApi.list(BankAccountType.Bank)
if (!cancelled) setBankAccount(banks.find(b => b.accountId === res.bankAccountId) ?? null)
} catch { /* ignore */ }
})
.catch(err => { if (!cancelled) setError(getApiError(err)) })
return () => { cancelled = true }
}, [chequeBookNo])
function handlePageUpdated(updated: ChequePageResponse) {
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="/payments/cheque-books" className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground">Cheque Book {chequeBookNo}</h1>
<p className="text-sm text-muted-foreground mt-0.5">Every leaf in this book — click one to view or take an action.</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>
)}
{!error && book === null && (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
)}
{!error && book !== null && (
<>
<div className="grid grid-cols-2 gap-4 rounded-xl border bg-card p-5 sm:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">Bank account</p>
<p className="text-sm font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId.slice(0, 8)}`}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="text-sm font-medium">{book.branchId}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Range</p>
<p className="text-sm font-medium">{book.startChequeNo} – {book.endChequeNo}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Received</p>
<p className="text-sm font-medium">{formatDate(book.receivedDate)}</p>
</div>
</div>
{book.pages.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<BookText className="size-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No pages found for this book.</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cheque no.</TableHead>
<TableHead>Status</TableHead>
<TableHead>Payee</TableHead>
<TableHead>Issue date</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{book.pages.map(p => (
<TableRow key={p.chequeNo} className="cursor-pointer" onClick={() => setSelectedPage(p)}>
<TableCell className="font-medium">{p.chequeNo}</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE_VARIANT[p.issueStatus]}>{p.issueStatus}</Badge>
</TableCell>
<TableCell>{p.payeeName ?? '—'}</TableCell>
<TableCell>{formatDate(p.issueDate)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
<ChequePageDialog page={selectedPage} onClose={() => setSelectedPage(null)} onUpdated={handlePageUpdated} />
</div>
)
}
// Inline cheque page action dialog — Distribution doesn't ship a shared ChequePageDialog component.
function ChequePageDialog({
page,
onClose,
onUpdated,
}: {
page: ChequePageResponse | null
onClose: () => void
onUpdated: (p: ChequePageResponse) => void
}) {
const open = page !== null
const [mode, setMode] = useState<'view' | 'issue' | 'status'>('view')
const [submitting, setSubmitting] = useState(false)
// Issue form
const [payeeType, setPayeeType] = useState<PayeeType>(PayeeType.Supplier)
const [payeeName, setPayeeName] = useState('')
const [issueDate, setIssueDate] = useState('')
const [amount, setAmount] = useState('')
const [purpose, setPurpose] = useState('')
const [issueErrors, setIssueErrors] = useState<Record<string, string>>({})
// Status form
const [action, setAction] = useState<ChequePageStatusAction>(ChequePageStatusAction.Clear)
const [clearedDate, setClearedDate] = useState('')
const [cancelReason, setCancelReason] = useState('')
useEffect(() => {
if (!page) return
setMode('view')
setPayeeType(page.payeeType ?? PayeeType.Supplier)
setPayeeName(page.payeeName ?? '')
setIssueDate(page.issueDate ? page.issueDate.slice(0, 10) : '')
setAmount(page.amount != null ? String(page.amount) : '')
setPurpose(page.purpose ?? '')
setIssueErrors({})
setAction(ChequePageStatusAction.Clear)
setClearedDate('')
setCancelReason('')
}, [page])
if (!page) return null
async function handleIssue() {
if (!page) return
const next: Record<string, string> = {}
if (!payeeName.trim()) next.payeeName = 'Payee name is required'
if (!issueDate) next.issueDate = 'Issue date is required'
const amountNum = Number(amount)
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) next.amount = 'Amount must be greater than zero'
setIssueErrors(next)
if (Object.keys(next).length > 0) return
setSubmitting(true)
try {
const updated = await chequePagesApi.issue(page.chequeNo, {
payeeType,
payeeName,
issueDate,
amount: amountNum,
purpose: purpose || undefined,
})
toast.add({ title: 'Cheque issued', description: `${updated.chequeNo} — ${updated.payeeName}`, type: 'success' })
onUpdated(updated)
setMode('view')
} catch (err) {
toast.add({ title: 'Could not issue cheque', description: getApiError(err), type: 'error' })
} finally {
setSubmitting(false)
}
}
async function handleStatus() {
if (!page) return
if (action === ChequePageStatusAction.Clear && !clearedDate) {
toast.add({ title: 'Cleared date is required', type: 'error' })
return
}
if (action === ChequePageStatusAction.Cancel && !cancelReason.trim()) {
toast.add({ title: 'Cancel reason is required', type: 'error' })
return
}
setSubmitting(true)
try {
const updated = await chequePagesApi.updateStatus(page.chequeNo, {
action,
clearedDate: action === ChequePageStatusAction.Clear ? clearedDate : undefined,
cancelReason: action === ChequePageStatusAction.Cancel ? cancelReason : 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)
}
}
const canIssue = page.issueStatus === ChequePageIssueStatus.Unused
const canChangeStatus = page.issueStatus === ChequePageIssueStatus.Issued || page.issueStatus === ChequePageIssueStatus.Unused
const availableActions: ChequePageStatusAction[] =
page.issueStatus === ChequePageIssueStatus.Issued
? [ChequePageStatusAction.Clear, ChequePageStatusAction.Bounce, ChequePageStatusAction.Cancel]
: page.issueStatus === ChequePageIssueStatus.Unused
? [ChequePageStatusAction.Void]
: []
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cheque {page.chequeNo}</DialogTitle>
<DialogDescription>
Status: <Badge variant={STATUS_BADGE_VARIANT[page.issueStatus]}>{page.issueStatus}</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">Payee</p>
<p>{page.payeeName ?? '—'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Payee type</p>
<p>{page.payeeType ?? '—'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Issue date</p>
<p>{formatDate(page.issueDate)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Amount</p>
<p className="tabular-nums">{formatAmount(page.amount)}</p>
</div>
{page.purpose && (
<div className="col-span-2">
<p className="text-xs text-muted-foreground">Purpose</p>
<p>{page.purpose}</p>
</div>
)}
{page.cancelReason && (
<div className="col-span-2">
<p className="text-xs text-muted-foreground">Cancel reason</p>
<p>{page.cancelReason}</p>
</div>
)}
</div>
<div className="flex gap-2 pt-2">
{canIssue && <Button onClick={() => setMode('issue')}>Issue cheque</Button>}
{canChangeStatus && availableActions.length > 0 && (
<Button variant="outline" onClick={() => { setAction(availableActions[0]); setMode('status') }}>
Change status
</Button>
)}
</div>
</div>
)}
{mode === 'issue' && (
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="pt">Payee type</FieldLabel>
<Select<PayeeType> value={payeeType} onValueChange={(v) => setPayeeType(v ?? PayeeType.Supplier)}>
<SelectTrigger id="pt" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.values(PayeeType).map(t => (
<SelectItem key={t} value={t} label={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={!!issueErrors.payeeName}>
<FieldLabel htmlFor="pn">Payee name</FieldLabel>
<Input id="pn" value={payeeName} onChange={(e) => setPayeeName(e.target.value)} aria-invalid={!!issueErrors.payeeName} />
<FieldError errors={[issueErrors.payeeName ? { message: issueErrors.payeeName } : undefined]} />
</Field>
<Field data-invalid={!!issueErrors.issueDate}>
<FieldLabel htmlFor="id">Issue date</FieldLabel>
<Input id="id" type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} aria-invalid={!!issueErrors.issueDate} />
<FieldError errors={[issueErrors.issueDate ? { message: issueErrors.issueDate } : undefined]} />
</Field>
<Field data-invalid={!!issueErrors.amount}>
<FieldLabel htmlFor="am">Amount</FieldLabel>
<Input id="am" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!issueErrors.amount} />
<FieldError errors={[issueErrors.amount ? { message: issueErrors.amount } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="pp">Purpose (optional)</FieldLabel>
<Input id="pp" value={purpose} onChange={(e) => setPurpose(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={handleIssue} disabled={submitting}>{submitting ? 'Issuing…' : 'Issue'}</Button>
</div>
</div>
)}
{mode === 'status' && (
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="ac">Action</FieldLabel>
<Select<ChequePageStatusAction> value={action} onValueChange={(v) => setAction(v ?? availableActions[0])}>
<SelectTrigger id="ac" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{availableActions.map(a => (
<SelectItem key={a} value={a} label={a}>{a}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{action === ChequePageStatusAction.Clear && (
<Field>
<FieldLabel htmlFor="cd">Cleared date</FieldLabel>
<Input id="cd" type="date" value={clearedDate} onChange={(e) => setClearedDate(e.target.value)} />
</Field>
)}
{action === ChequePageStatusAction.Cancel && (
<Field>
<FieldLabel htmlFor="cr">Cancel reason</FieldLabel>
<Input id="cr" value={cancelReason} onChange={(e) => setCancelReason(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={handleStatus} disabled={submitting}>{submitting ? 'Saving…' : 'Confirm'}</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,167 @@
'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, chequeBooks as chequeBooksApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { BankAccountType, type BankAccountResponse } 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 NewChequeBookPage() {
const router = useRouter()
const [bankAccounts, setBankAccounts] = useState<BankAccountResponse[] | 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)
useEffect(() => {
bankAccountsApi.list(BankAccountType.Bank)
.then(setBankAccounts)
.catch(err => setBankAccountsError(getApiError(err)))
}, [])
async function handleSubmit() {
const next: Record<string, string> = {}
if (!bankAccountId) next.bankAccountId = 'Bank account is required'
if (!branchId) next.branchId = 'Branch is required'
if (!chequeBookNo.trim()) next.chequeBookNo = 'Cheque book number is required'
if (!startChequeNo.trim()) next.startChequeNo = 'Start cheque number is required'
if (!endChequeNo.trim()) next.endChequeNo = 'End cheque number is required'
const leavesNum = Number(totalLeaves)
if (!totalLeaves || Number.isNaN(leavesNum) || leavesNum <= 0) next.totalLeaves = 'Total leaves 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 chequeBooksApi.create({
branchId: Number(branchId),
bankAccountId,
chequeBookNo,
startChequeNo,
endChequeNo,
totalLeaves: Number(totalLeaves),
receivedDate,
description: description || undefined,
createdBy: createdBy || undefined,
})
toast.add({ title: 'Cheque book created', description: `${created.chequeBookNo} — ${created.totalLeaves} leaves`, type: 'success' })
router.push(`/payments/cheque-books/${encodeURIComponent(created.chequeBookNo)}`)
} catch (err) {
toast.add({ title: 'Could not create cheque book', 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/cheque-books" className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground">New Cheque Book</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Every leaf from the start to end cheque number is generated automatically, all &quot;Unused&quot;.
</p>
</div>
</div>
{bankAccountsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{bankAccountsError}</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.bankAccountId}>
<FieldLabel htmlFor="cb-bank">Bank account</FieldLabel>
<Select<string> value={bankAccountId} onValueChange={(v) => setBankAccountId(v ?? '')}>
<SelectTrigger id="cb-bank" className="w-full" aria-invalid={!!errors.bankAccountId}>
<SelectValue placeholder={bankAccounts === null ? 'Loading…' : 'Select a bank account'} />
</SelectTrigger>
<SelectContent>
{(bankAccounts ?? []).map(a => (
<SelectItem key={a.accountId} value={a.accountId} label={a.accountName}>
{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</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-xs 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="/payments/cheque-books" className={cn(buttonVariants({ variant: 'outline' }))}>Cancel</Link>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? 'Creating…' : 'Create'}</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,143 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { BookText, Plus } from 'lucide-react'
import { bankAccounts as bankAccountsApi, chequeBooks as chequeBooksApi, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import {
BankAccountType,
ChequeBookStatus,
type BankAccountResponse,
type ChequeBookResponse,
} from '@/types/payments'
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_VARIANT: Record<ChequeBookStatus, 'default' | 'secondary' | 'destructive'> = {
[ChequeBookStatus.Active]: 'default',
[ChequeBookStatus.Completed]: 'secondary',
[ChequeBookStatus.Cancelled]: 'destructive',
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
export default function ChequeBooksPage() {
const router = useRouter()
const [books, setBooks] = useState<ChequeBookResponse[] | null>(null)
const [bankAccountMap, setBankAccountMap] = useState<Map<string, BankAccountResponse>>(new Map())
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>('All')
useEffect(() => {
bankAccountsApi
.list(BankAccountType.Bank)
.then(list => setBankAccountMap(new Map(list.map(a => [a.accountId, a]))))
.catch(() => setBankAccountMap(new Map()))
}, [])
useEffect(() => {
let cancelled = false
chequeBooksApi
.list({ status: status === 'All' ? undefined : status, pageSize: 100 })
.then(res => { if (!cancelled) setBooks(res.items) })
.catch(err => { if (!cancelled) setError(getApiError(err)) })
return () => { cancelled = true }
}, [status])
const sortedBooks = useMemo(() => books, [books])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Cheque Books</h1>
<p className="text-sm text-muted-foreground mt-0.5">Cheque books issued from the company&apos;s own supply.</p>
</div>
<Link href="/payments/cheque-books/new" className={cn(buttonVariants())}>
<Plus className="size-4" />
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="w-full sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active} label="Active">Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed} label="Completed">Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled">Cancelled</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 && sortedBooks === null && (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
)}
{!error && sortedBooks !== null && sortedBooks.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<BookText className="size-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No cheque books yet.</p>
</div>
)}
{!error && sortedBooks !== null && sortedBooks.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cheque book no.</TableHead>
<TableHead>Bank account</TableHead>
<TableHead>Branch</TableHead>
<TableHead>Range</TableHead>
<TableHead>Leaves</TableHead>
<TableHead>Received</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedBooks.map(b => {
const bank = bankAccountMap.get(b.bankAccountId)
return (
<TableRow
key={b.chequeBookNo}
className="cursor-pointer"
onClick={() => router.push(`/payments/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
>
<TableCell className="font-medium">{b.chequeBookNo}</TableCell>
<TableCell>{bank ? bank.accountName : `#${b.bankAccountId.slice(0, 8)}`}</TableCell>
<TableCell>{b.branchId || '—'}</TableCell>
<TableCell>{b.startChequeNo} – {b.endChequeNo}</TableCell>
<TableCell>{b.totalLeaves}</TableCell>
<TableCell>{formatDate(b.receivedDate)}</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE_VARIANT[b.status]}>{b.status}</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -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>
)
}