feat: add cheque books and received cheques management
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user