Add cheque to payment UI
This commit is contained in:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user