feat: add supplier payment and allocation models, API, and frontend components

- Created `SupplierPayment` and `SupplierPaymentGRN` models for managing supplier payments and their allocations.
- Implemented API endpoints for listing and creating supplier payments.
- Developed frontend components for displaying and recording supplier payments, including a dialog for payment creation.
- Integrated GRN allocations into the payment creation process, ensuring total allocations match the payment amount.
- Added pagination and loading states for the payments list.
This commit is contained in:
Kalana B. Thilakarathna
2026-10-19 21:36:01 +05:30
parent 94db2a311c
commit b992d07689
25 changed files with 7744 additions and 57 deletions
@@ -0,0 +1,353 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
bankAccounts as bankAccountsApi,
chequeBooks as chequeBooksApi,
getApiError,
grn as grnApi,
suppliers as suppliersApi,
supplierPayments as supplierPaymentsApi,
} from '@/lib/api/index'
import type { GrnResponse, SupplierResponse } from '@/types/api'
import {
BankAccountType,
ChequePageIssueStatus,
type BankAccountResponse,
type ChequeBookResponse,
type ChequePageResponse,
type CreateSupplierPaymentRequest,
type SupplierPaymentAllocation,
type SupplierPaymentResponse,
} from '@/types/payments'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { toast } from '@/components/ui/toast'
function formatDate(value: string) {
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function formatMoney(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function SupplierPaymentsClient({ supplierId }: { supplierId: string }) {
const [supplier, setSupplier] = useState<SupplierResponse | null>(null)
const [payments, setPayments] = useState<SupplierPaymentResponse[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const loadPayments = useCallback(async () => {
setLoading(true)
try {
const result = await supplierPaymentsApi.list(supplierId, { page, pageSize })
setPayments(result.items)
setTotal(result.total)
} catch (err) {
toast.add({ title: 'Failed to load payments', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [supplierId, page, pageSize])
useEffect(() => {
suppliersApi.get(supplierId).then(setSupplier).catch(() => {})
}, [supplierId])
useEffect(() => { loadPayments() }, [loadPayments])
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Supplier Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{supplier ? supplier.name : 'Supplier'} — outgoing payments against received GRNs
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>Record Payment</Button>
</div>
<div className="rounded-md border mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Paid At</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Cheque / Account</TableHead>
<TableHead className="text-right">GRNs</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={6}>Loading…</TableCell></TableRow>
) : payments.length === 0 ? (
<TableRow><TableCell colSpan={6}>No payments yet.</TableCell></TableRow>
) : (
payments.map(p => (
<TableRow key={p.id}>
<TableCell>{formatDate(p.paidAt)}</TableCell>
<TableCell><Badge variant="secondary">{p.method}</Badge></TableCell>
<TableCell className="text-right font-mono">{formatMoney(p.amount)}</TableCell>
<TableCell className="text-xs">
{p.method === 'Cheque' ? (p.chequeNo ?? '—') : (p.bankAccountId ?? '—')}
</TableCell>
<TableCell className="text-right">{p.allocations.length}</TableCell>
<TableCell className="text-sm text-muted-foreground">{p.notes ?? '—'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mt-3 text-sm">
<span className="text-muted-foreground">{total} payment(s)</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</Button>
<span className="px-2 py-1">{page} / {pageCount}</span>
<Button variant="outline" size="sm" disabled={page >= pageCount} onClick={() => setPage(p => p + 1)}>Next</Button>
</div>
</div>
{createOpen && (
<CreatePaymentDialog
supplierId={supplierId}
onClose={() => setCreateOpen(false)}
onCreated={() => { setCreateOpen(false); loadPayments() }}
/>
)}
</>
)
}
interface AllocationDraft {
goodsReceiptId: string
allocatedAmount: string
}
function CreatePaymentDialog({
supplierId,
onClose,
onCreated,
}: {
supplierId: string
onClose: () => void
onCreated: () => void
}) {
const [method, setMethod] = useState<'Cash' | 'Cheque'>('Cash')
const [amount, setAmount] = useState('')
const [notes, setNotes] = useState('')
const [bankAccountId, setBankAccountId] = useState<string>('')
const [chequePageId, setChequePageId] = useState<string>('')
const [allocations, setAllocations] = useState<AllocationDraft[]>([{ goodsReceiptId: '', allocatedAmount: '' }])
const [submitting, setSubmitting] = useState(false)
const [grns, setGrns] = useState<GrnResponse[]>([])
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
const [unusedPages, setUnusedPages] = useState<ChequePageResponse[]>([])
useEffect(() => {
grnApi.list({ page: 1, pageSize: 100 })
.then(r => setGrns(r.items.filter(g => g.supplierId === supplierId)))
.catch(err => toast.add({ title: 'Failed to load GRNs', description: getApiError(err), type: 'error' }))
bankAccountsApi.list()
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
.catch(() => {})
}, [supplierId])
useEffect(() => {
if (method !== 'Cheque') return
chequeBooksApi.list({ status: 'Active' as never, page: 1, pageSize: 50 })
.then(async res => {
const expanded = await Promise.all(res.items.map((b: ChequeBookResponse) => chequeBooksApi.get(b.chequeBookNo, true)))
const pages: ChequePageResponse[] = []
expanded.forEach(book => {
book.pages
.filter(p => p.issueStatus === ChequePageIssueStatus.Unused)
.forEach(p => pages.push(p))
})
setUnusedPages(pages)
})
.catch(err => toast.add({ title: 'Failed to load cheque pages', description: getApiError(err), type: 'error' }))
}, [method])
const allocationTotal = useMemo(
() => allocations.reduce((sum, a) => sum + (parseFloat(a.allocatedAmount) || 0), 0),
[allocations],
)
const amountNum = parseFloat(amount) || 0
const mismatch = Math.abs(allocationTotal - amountNum) > 0.001
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (amountNum <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return }
if (mismatch) { toast.add({ title: `Allocation total (${formatMoney(allocationTotal)}) must equal amount (${formatMoney(amountNum)})`, type: 'error' }); return }
const cleanAllocations: SupplierPaymentAllocation[] = allocations
.filter(a => a.goodsReceiptId && a.allocatedAmount)
.map(a => ({ goodsReceiptId: a.goodsReceiptId, allocatedAmount: parseFloat(a.allocatedAmount) }))
if (cleanAllocations.length === 0) { toast.add({ title: 'Add at least one GRN allocation', type: 'error' }); return }
const body: CreateSupplierPaymentRequest = {
amount: amountNum,
method,
allocations: cleanAllocations,
notes: notes || undefined,
bankAccountId: method === 'Cash' ? bankAccountId || undefined : undefined,
chequePageId: method === 'Cheque' ? chequePageId || undefined : undefined,
}
setSubmitting(true)
try {
await supplierPaymentsApi.create(supplierId, body)
toast.add({ title: 'Payment recorded', type: 'success' })
onCreated()
} catch (err) {
toast.add({ title: 'Failed to record payment', description: getApiError(err), type: 'error' })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Record Supplier Payment</DialogTitle>
<DialogDescription>
Allocate the payment across one or more GRNs. Allocation total must equal the paid amount.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>Amount</FieldLabel>
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} required />
</Field>
<Field>
<FieldLabel>Method</FieldLabel>
<Select value={method} onValueChange={v => setMethod((v ?? 'Cash') as 'Cash' | 'Cheque')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Cheque">Cheque</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
{method === 'Cash' && (
<Field>
<FieldLabel>Bank / Cash Account</FieldLabel>
<Select value={bankAccountId} onValueChange={v => setBankAccountId(v ?? '')}>
<SelectTrigger><SelectValue placeholder="Choose account" /></SelectTrigger>
<SelectContent>
{accounts.map(a => (
<SelectItem key={a.accountId} value={a.accountId}>
{a.accountName}
{a.accountType === BankAccountType.Bank && a.bankName ? ` (${a.bankName})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{method === 'Cheque' && (
<Field>
<FieldLabel>Cheque Page</FieldLabel>
<Select value={chequePageId} onValueChange={v => setChequePageId(v ?? '')}>
<SelectTrigger><SelectValue placeholder="Pick an unused cheque page" /></SelectTrigger>
<SelectContent>
{unusedPages.map(p => (
<SelectItem key={p.id} value={p.id}>
{p.chequeBookNo} / {p.chequeNo}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<div>
<div className="flex items-center justify-between mb-2">
<FieldLabel>GRN Allocations</FieldLabel>
<Button
type="button" size="sm" variant="outline"
onClick={() => setAllocations(a => [...a, { goodsReceiptId: '', allocatedAmount: '' }])}
>
Add GRN
</Button>
</div>
<div className="space-y-2">
{allocations.map((a, i) => (
<div key={i} className="grid grid-cols-[1fr,180px,80px] gap-2 items-start">
<Select
value={a.goodsReceiptId}
onValueChange={v => setAllocations(prev => prev.map((x, idx) => idx === i ? { ...x, goodsReceiptId: v ?? '' } : x))}
>
<SelectTrigger><SelectValue placeholder="Pick a GRN" /></SelectTrigger>
<SelectContent>
{grns.map(g => (
<SelectItem key={g.id} value={g.id}>
GRN {g.id.slice(0, 8)} — {formatDate(g.receivedAt)} — total {formatMoney(g.totalAmount)}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number" step="0.01" placeholder="Amount"
value={a.allocatedAmount}
onChange={e => setAllocations(prev => prev.map((x, idx) => idx === i ? { ...x, allocatedAmount: e.target.value } : x))}
/>
<Button
type="button" variant="ghost" size="sm"
disabled={allocations.length === 1}
onClick={() => setAllocations(prev => prev.filter((_, idx) => idx !== i))}
>
Remove
</Button>
</div>
))}
</div>
<div className={`text-sm mt-2 ${mismatch ? 'text-destructive' : 'text-muted-foreground'}`}>
Allocation total: {formatMoney(allocationTotal)} {mismatch && amount ? `(mismatch with amount ${formatMoney(amountNum)})` : ''}
</div>
</div>
<Field>
<FieldLabel>Notes</FieldLabel>
<Input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Optional" />
</Field>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={submitting}>{submitting ? 'Saving…' : 'Record Payment'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,10 @@
import { SupplierPaymentsClient } from './_components/supplier-payments-client'
export default async function SupplierPaymentsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <SupplierPaymentsClient supplierId={id} />
}
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -16,6 +17,7 @@ import { SupplierSheet } from './supplier-sheet'
import { SupplierViewSheet } from './supplier-view-sheet'
export function SuppliersClient() {
const router = useRouter()
const [data, setData] = useState<SupplierResponse[]>([])
const [loading, setLoading] = useState(true)
const [pageSize, setPageSize] = useState(20)
@@ -125,6 +127,11 @@ export function SuppliersClient() {
label: 'Edit supplier',
onClick: openEdit,
},
{
action: 'view',
label: 'Payments',
onClick: (s: SupplierResponse) => router.push(`/suppliers/${s.id}/payments`),
},
{
action: 'cancel',
label: 'Delete supplier',
+1
View File
@@ -15,6 +15,7 @@ export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GR
export { bankAccounts } from './bank-accounts'
export { chequeBooks, chequePages } from './cheques'
export { receivedCheques } from './received-cheques'
export { supplierPayments } from './supplier-payments'
export { getPaginatedItems } from './paginated'
export type { PageParams } from './master'
@@ -0,0 +1,18 @@
import { api } from '@/lib/api'
import type {
CreateSupplierPaymentRequest,
PagedSupplierPaymentsResponse,
SupplierPaymentResponse,
} from '@/types/payments'
export const supplierPayments = {
list: (supplierId: string, params?: { page?: number; pageSize?: number }) =>
api
.get<PagedSupplierPaymentsResponse>(`/suppliers/${supplierId}/payments`, { params })
.then(r => r.data),
create: (supplierId: string, body: CreateSupplierPaymentRequest) =>
api
.post<SupplierPaymentResponse>(`/suppliers/${supplierId}/payments`, body)
.then(r => r.data),
}
+40
View File
@@ -69,6 +69,7 @@ export enum PayeeType {
}
export interface ChequePageResponse {
id: string
chequeNo: string
chequeBookNo: string
issueStatus: ChequePageIssueStatus
@@ -221,3 +222,42 @@ export interface UpdateReceivedChequeStatusRequest {
notes?: string
performedBy?: string
}
// Supplier Payments
export interface SupplierPaymentAllocation {
goodsReceiptId: string
allocatedAmount: number
}
export interface SupplierPaymentResponse {
id: string
supplierId: string
amount: number
method: 'Cash' | 'Cheque'
bankAccountId: string | null
chequePageId: string | null
chequeNo: string | null
paidAt: string
paidByUserId: string | null
notes: string | null
allocations: SupplierPaymentAllocation[]
createdAt: string
}
export interface PagedSupplierPaymentsResponse {
items: SupplierPaymentResponse[]
page: number
pageSize: number
total: number
}
export interface CreateSupplierPaymentRequest {
amount: number
method: 'Cash' | 'Cheque'
allocations: SupplierPaymentAllocation[]
bankAccountId?: string
chequePageId?: string
notes?: string
paidByUserId?: string
}