Merge pull request 'feat: integrate payment functionality with new payment sheet and enhance supplier payments UI' (#73) from dev into production
Reviewed-on: #73
This commit was merged in pull request #73.
This commit is contained in:
@@ -11,6 +11,7 @@ import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
||||
|
||||
import { OrderViewSheet } from './order-view-sheet'
|
||||
import { InvoiceActionDialog } from './invoice-action-dialog'
|
||||
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
@@ -41,6 +42,8 @@ export function OrdersClient() {
|
||||
const [selectedInvoice, setSelectedInvoice] = useState<InvoiceResponse | null>(null)
|
||||
const [actionOpen, setActionOpen] = useState(false)
|
||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||
const [paymentOpen, setPaymentOpen] = useState(false)
|
||||
const [paymentInvoice, setPaymentInvoice] = useState<InvoiceResponse | null>(null)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
@@ -76,6 +79,11 @@ export function OrdersClient() {
|
||||
setActionOpen(true)
|
||||
}
|
||||
|
||||
function handleRecordPayment(row: InvoiceResponse) {
|
||||
setPaymentInvoice(row)
|
||||
setPaymentOpen(true)
|
||||
}
|
||||
|
||||
const tableConfig = useMemo<TableConfig<InvoiceResponse>>(() => ({
|
||||
rowKey: 'id',
|
||||
serverSide: true,
|
||||
@@ -151,6 +159,12 @@ export function OrdersClient() {
|
||||
onClick: handleFinalize,
|
||||
visible: (target) => target.status === 'Printed',
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Record payment',
|
||||
onClick: handleRecordPayment,
|
||||
visible: (target) => target.paymentStatus !== 'Paid',
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize])
|
||||
@@ -190,6 +204,14 @@ export function OrdersClient() {
|
||||
invoice={selectedInvoice}
|
||||
mode={actionMode}
|
||||
/>
|
||||
|
||||
<PaymentSheet
|
||||
open={paymentOpen}
|
||||
onClose={() => setPaymentOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
invoices={paymentInvoice ? [paymentInvoice] : []}
|
||||
defaultInvoiceId={paymentInvoice?.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { payments as paymentsApi, getApiError } from '@/lib/api/index'
|
||||
import { payments as paymentsApi, bankAccounts as bankAccountsApi, getApiError } from '@/lib/api/index'
|
||||
import type { InvoiceResponse } from '@/types/api'
|
||||
import type { BankAccountResponse } from '@/types/payments'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -32,8 +33,15 @@ const schema = z
|
||||
amount: z.number().min(0.01, 'Amount must be greater than 0'),
|
||||
method: z.enum(['Cash', 'Cheque', 'Credit']),
|
||||
entryMode: z.enum(['RealTime', 'DayEndManual']),
|
||||
bankAccountId: z.string().optional(),
|
||||
chequeNumber: z.string().optional(),
|
||||
chequeDate: z.string().optional(),
|
||||
drawerBankName: z.string().optional(),
|
||||
accountHolderName: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.method !== 'Cash' || !!data.bankAccountId, {
|
||||
message: 'Bank/Cash account is required for cash payments',
|
||||
path: ['bankAccountId'],
|
||||
})
|
||||
.refine((data) => data.method !== 'Cheque' || !!data.chequeNumber, {
|
||||
message: 'Cheque number is required for cheque payments',
|
||||
@@ -51,21 +59,27 @@ interface PaymentSheetProps {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
invoices: InvoiceResponse[]
|
||||
defaultInvoiceId?: string
|
||||
}
|
||||
|
||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||
const truncId = (id: string) => id.substring(0, 8)
|
||||
|
||||
export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentSheetProps) {
|
||||
export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoiceId }: PaymentSheetProps) {
|
||||
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
invoiceId: '',
|
||||
invoiceId: defaultInvoiceId ?? '',
|
||||
amount: 0,
|
||||
method: 'Cash',
|
||||
entryMode: 'RealTime',
|
||||
bankAccountId: '',
|
||||
chequeNumber: '',
|
||||
chequeDate: '',
|
||||
drawerBankName: '',
|
||||
accountHolderName: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -74,23 +88,32 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({
|
||||
invoiceId: '',
|
||||
invoiceId: defaultInvoiceId ?? '',
|
||||
amount: 0,
|
||||
method: 'Cash',
|
||||
entryMode: 'RealTime',
|
||||
bankAccountId: '',
|
||||
chequeNumber: '',
|
||||
chequeDate: '',
|
||||
drawerBankName: '',
|
||||
accountHolderName: '',
|
||||
})
|
||||
}, [open])
|
||||
bankAccountsApi.list()
|
||||
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
|
||||
.catch(() => {})
|
||||
}, [open, defaultInvoiceId])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const { invoiceId, method, chequeNumber, chequeDate, ...rest } = values
|
||||
const { invoiceId, method, bankAccountId, chequeNumber, chequeDate, drawerBankName, accountHolderName, ...rest } = values
|
||||
try {
|
||||
await paymentsApi.create(invoiceId, {
|
||||
...rest,
|
||||
method,
|
||||
bankAccountId: method === 'Cash' ? bankAccountId : undefined,
|
||||
chequeNumber: method === 'Cheque' ? chequeNumber : undefined,
|
||||
chequeDate: method === 'Cheque' ? chequeDate : undefined,
|
||||
drawerBankName: method === 'Cheque' ? drawerBankName || undefined : undefined,
|
||||
accountHolderName: method === 'Cheque' ? accountHolderName || undefined : undefined,
|
||||
})
|
||||
toast.add({ title: 'Payment recorded', type: 'success' })
|
||||
onSuccess()
|
||||
@@ -118,12 +141,11 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
|
||||
label="Invoice"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={!!defaultInvoiceId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select invoice..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select invoice...</SelectItem>
|
||||
{pendingInvoices.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{inv.storeName} — {fmtAmount(inv.totalAmount)} ({inv.paymentStatus}) [{truncId(inv.id)}]
|
||||
@@ -174,6 +196,29 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
|
||||
)}
|
||||
/>
|
||||
|
||||
{method === 'Cash' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bankAccountId"
|
||||
label="Deposit Account"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={(field.value as string) ?? ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose where the money lands" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map((a) => (
|
||||
<SelectItem key={a.accountId} value={a.accountId}>
|
||||
{a.accountName}{a.bankName ? ` (${a.bankName})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === 'Cheque' && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -213,6 +258,40 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="drawerBankName"
|
||||
label="Drawer Bank (optional)"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="drawerBankName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Sampath Bank"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accountHolderName"
|
||||
label="Account Holder (optional)"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="accountHolderName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Name on the cheque"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+4
-2
@@ -30,6 +30,7 @@ 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'
|
||||
import { TopLoader } from '@/components/shared/top-loader'
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
@@ -70,7 +71,8 @@ export function SupplierPaymentsClient({ supplierId }: { supplierId: string }) {
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<TopLoader active={loading} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Supplier Payments</h1>
|
||||
@@ -132,7 +134,7 @@ export function SupplierPaymentsClient({ supplierId }: { supplierId: string }) {
|
||||
onCreated={() => { setCreateOpen(false); loadPayments() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
export function TopLoader({ active }: { active: boolean }) {
|
||||
if (!active) return null
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[2px] overflow-hidden">
|
||||
<div className="top-loader-bar">
|
||||
<span className="top-loader-head" />
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.top-loader-bar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 60, 90, 0.45) 50%,
|
||||
rgba(255, 90, 120, 0.9) 85%,
|
||||
#ff2e63 100%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: top-loader-slide 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
.top-loader-head {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -2px;
|
||||
height: 6px;
|
||||
width: 10px;
|
||||
border-radius: 9999px;
|
||||
background: #ff6b8a;
|
||||
box-shadow:
|
||||
0 0 6px 1px #ff2e63,
|
||||
0 0 14px 2px rgba(255, 46, 99, 0.75),
|
||||
0 0 24px 4px rgba(255, 46, 99, 0.35);
|
||||
}
|
||||
@keyframes top-loader-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,8 +97,11 @@ export interface PaymentBody {
|
||||
amount: number
|
||||
method: PaymentMethod
|
||||
entryMode?: EntryMode
|
||||
bankAccountId?: string | null
|
||||
chequeNumber?: string | null
|
||||
chequeDate?: string | null
|
||||
drawerBankName?: string | null
|
||||
accountHolderName?: string | null
|
||||
collectedByUserId?: string | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user