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:
+353
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user