Add cheque to payment UI

This commit is contained in:
2026-09-18 11:47:34 +05:30
parent 63e7ddba82
commit 1f6d63a0b3
4 changed files with 168 additions and 18 deletions
@@ -11,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -25,11 +26,23 @@ import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse } from '@/types/api'
const schema = z.object({
invoiceId: z.string().min(1, 'Invoice is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'),
method: z.enum(['Cash', 'Credit']),
})
const schema = z
.object({
invoiceId: z.string().min(1, 'Invoice is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'),
method: z.enum(['Cash', 'Cheque', 'Credit']),
entryMode: z.enum(['RealTime', 'DayEndManual']),
chequeNumber: z.string().optional(),
chequeDate: z.string().optional(),
})
.refine((data) => data.method !== 'Cheque' || !!data.chequeNumber, {
message: 'Cheque number is required for cheque payments',
path: ['chequeNumber'],
})
.refine((data) => data.method !== 'Cheque' || !!data.chequeDate, {
message: 'Cheque date is required for cheque payments',
path: ['chequeDate'],
})
type FormValues = z.infer<typeof schema>
@@ -50,18 +63,35 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
invoiceId: '',
amount: 0,
method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
},
})
const method = form.watch('method')
useEffect(() => {
if (!open) return
form.reset({ invoiceId: '', amount: 0, method: 'Cash' })
form.reset({
invoiceId: '',
amount: 0,
method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
})
}, [open])
async function onSubmit(values: FormValues) {
const { invoiceId, ...rest } = values
const { invoiceId, method, chequeNumber, chequeDate, ...rest } = values
try {
await paymentsApi.create(invoiceId, rest)
await paymentsApi.create(invoiceId, {
...rest,
method,
chequeNumber: method === 'Cheque' ? chequeNumber : undefined,
chequeDate: method === 'Cheque' ? chequeDate : undefined,
})
toast.add({ title: 'Payment recorded', type: 'success' })
onSuccess()
onClose()
@@ -77,7 +107,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Record Payment</DialogTitle>
<DialogDescription>Record a cash or credit payment against an invoice.</DialogDescription>
<DialogDescription>Record a cash, cheque, or credit payment against an invoice.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -137,11 +167,71 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
</SelectTrigger>
<SelectContent>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Cheque">Cheque</SelectItem>
<SelectItem value="Credit">Credit</SelectItem>
</SelectContent>
</Select>
)}
/>
{method === 'Cheque' && (
<>
<FormField
control={form.control}
name="chequeNumber"
label="Cheque Number"
required
render={({ field, invalid }) => (
<Input
id="chequeNumber"
name={field.name}
ref={field.ref}
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 000123"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="chequeDate"
label="Cheque Date"
required
render={({ field, invalid }) => (
<Input
id="chequeDate"
name={field.name}
ref={field.ref}
type="date"
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</>
)}
<FormField
control={form.control}
name="entryMode"
label="Entry Mode"
required
render={({ field }) => (
<ToggleGroup
variant="outline"
value={[field.value as string]}
onValueChange={(value: string[]) => value[0] && field.onChange(value[0])}
>
<ToggleGroupItem value="RealTime">Real-time</ToggleGroupItem>
<ToggleGroupItem value="DayEndManual">Day-end entry</ToggleGroupItem>
</ToggleGroup>
)}
/>
</FieldGroup>
<DialogFooter>
@@ -7,13 +7,19 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { PaymentResponse, PaymentMethod } from '@/types/api'
import type { PaymentResponse, PaymentMethod, EntryMode } from '@/types/api'
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = {
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default',
Cheque: 'outline',
Credit: 'secondary',
}
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
@@ -22,6 +28,9 @@ const fmtDateTime = (v: string) =>
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface PaymentViewSheetProps {
open: boolean
onClose: () => void
@@ -53,8 +62,18 @@ export function PaymentViewSheet({ open, onClose, payment, invoiceName }: Paymen
<Row label="Invoice" value={invoiceName ?? <span className="font-mono">{truncId(payment.invoiceId)}</span>} />
<Row label="Amount" value={fmtAmount(payment.amount)} />
<Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} />
{payment.method === 'Cheque' && (
<>
<Row label="Cheque Number" value={payment.chequeNumber ?? '—'} />
<Row label="Cheque Date" value={payment.chequeDate ? fmtDate(payment.chequeDate) : '—'} />
</>
)}
<Row label="Entry Mode" value={<Badge variant="secondary">{ENTRY_MODE_LABEL[payment.entryMode]}</Badge>} />
<Row label="Collected At" value={fmtDateTime(payment.collectedAt)} />
<Row label="Collected By" value={<span className="font-mono">{truncId(payment.collectedBy)}</span>} />
{payment.recordedBy && (
<Row label="Recorded By" value={<span className="font-mono">{truncId(payment.recordedBy)}</span>} />
)}
<Row label="Created At" value={fmtDateTime(payment.createdAt)} />
</div>
</DialogContent>
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import { invoices as invoicesApi, payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PaymentResponse, PaymentMethod, InvoiceResponse } from '@/types/api'
import type { PaymentResponse, PaymentMethod, EntryMode, InvoiceResponse } from '@/types/api'
import { PaymentSheet } from './payment-sheet'
import { PaymentViewSheet } from './payment-view-sheet'
@@ -22,11 +22,17 @@ const fmtDate = (v: string) =>
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = {
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default',
Cheque: 'outline',
Credit: 'secondary',
}
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
export function PaymentsClient() {
const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([])
const [selectedInvoiceId, setSelectedInvoiceId] = useState('')
@@ -119,6 +125,15 @@ export function PaymentsClient() {
<Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge>
),
},
{
key: 'entryMode',
title: 'Entry Mode',
fields: ['entryMode'],
width: 130,
cellRender: (row) => (
<Badge variant="secondary">{ENTRY_MODE_LABEL[row.entryMode]}</Badge>
),
},
{
key: 'collectedAt',
title: 'Collected At',
@@ -143,7 +158,7 @@ export function PaymentsClient() {
<div>
<h1 className="text-2xl font-semibold text-foreground">Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Cash and credit payments collected against invoices
Cash, cheque, and credit payments collected against invoices
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>