intigrate others sales , return and implement day end duntinalities
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface SalesInvoicePaymentDialogProps {
|
||||
invoice: SalesInvoice | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPaid: (invoice: SalesInvoice, payment: SalesInvoicePayment) => void
|
||||
}
|
||||
|
||||
/** Record a customer payment against a posted invoice's balance — full or partial, into an
|
||||
* existing GL cash/bank account. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
|
||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [reference, setReference] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||
// "adjust state while rendering" pattern as GrnPaymentDialog, not inside an effect.
|
||||
const openKey = open && invoice ? `${invoice.salesInvoiceId}` : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setReference("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || accounts !== null) return
|
||||
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [open, accounts])
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
async function submit() {
|
||||
if (!invoice) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const amountNum = Number(amount)
|
||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
|
||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
|
||||
onPaid(invoice, payment)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="inv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="inv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={invoice.totals.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
/>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
|
||||
import { ChevronDownIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ComboboxOption<Value> {
|
||||
value: Value
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ComboboxProps<Value> {
|
||||
items: ComboboxOption<Value>[]
|
||||
value: Value | null
|
||||
onValueChange: (value: Value | null) => void
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
"aria-invalid"?: boolean
|
||||
}
|
||||
|
||||
/** Searchable dropdown for long option lists (items, vendors, …) — a `Select` swap-in
|
||||
* built on Base UI's Combobox, styled to match `select.tsx`'s trigger/popup/item look. */
|
||||
export function Combobox<Value>({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Search…",
|
||||
emptyText = "No results found.",
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: ComboboxProps<Value>) {
|
||||
const itemToStringLabel = React.useCallback(
|
||||
(v: Value) => items.find((i) => i.value === v)?.label ?? "",
|
||||
[items]
|
||||
)
|
||||
|
||||
return (
|
||||
<ComboboxPrimitive.Root<Value>
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(v) => onValueChange(v ?? null)}
|
||||
itemToStringLabel={itemToStringLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxPrimitive.InputGroup
|
||||
data-slot="combobox-input-group"
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-1.5 rounded-lg border border-input bg-transparent pr-2 pl-2.5 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-disabled:cursor-not-allowed has-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<ComboboxPrimitive.Input
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="h-full w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
<ComboboxPrimitive.Icon
|
||||
render={<ChevronDownIcon className="pointer-events-none size-4 shrink-0 text-muted-foreground" />}
|
||||
/>
|
||||
</ComboboxPrimitive.InputGroup>
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner side="bottom" sideOffset={4} className="isolate z-50">
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
className="max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||
>
|
||||
<ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</ComboboxPrimitive.Empty>
|
||||
<ComboboxPrimitive.List>
|
||||
{(item: ComboboxOption<Value>) => (
|
||||
<ComboboxPrimitive.Item
|
||||
key={String(item.value)}
|
||||
value={item.value}
|
||||
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
|
||||
>
|
||||
{item.label}
|
||||
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)}
|
||||
</ComboboxPrimitive.List>
|
||||
</ComboboxPrimitive.Popup>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
</ComboboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user