feat: add purchase invoice batch print page and related components
- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices. - Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout. - Added invoice layout presets for different styles (Classic, Compact, Modern). - Developed PurchaseInvoicePaymentDialog for handling payments against invoices. - Introduced API mocks for company profile and invoice template configuration. - Added validation functions for company profile and purchase invoices. - Defined types for company profile, invoice template configuration, and purchase invoices.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
||||
|
||||
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 PurchaseInvoicePaymentDialogProps {
|
||||
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
|
||||
// touches (id/no/status/balance) is already on the summary, so a list row can open it
|
||||
// without a separate get() round trip. PurchaseInvoice extends PurchaseInvoiceSummary, so
|
||||
// the detail page's full invoice still satisfies this as-is.
|
||||
invoice: PurchaseInvoiceSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
||||
* it started that way or this dialog auto-posted it first. */
|
||||
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
|
||||
}
|
||||
|
||||
/** Record a vendor payment against a Purchase Invoice's balance — full or partial, into an
|
||||
* existing GL cash/bank account. A Draft invoice is posted first (the backend only accepts
|
||||
* payments against a Posted invoice) so this doubles as "confirm and pay in one step" from a
|
||||
* list row; an already-Posted invoice skips straight to payment. Modeled on
|
||||
* SalesInvoicePaymentDialog's "pick an account, submit" shape. */
|
||||
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: PurchaseInvoicePaymentDialogProps) {
|
||||
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 SalesInvoicePaymentDialog, not inside an effect.
|
||||
const openKey = open && invoice ? `${invoice.purchaseInvoiceId}` : 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 from"
|
||||
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 {
|
||||
if (invoice.status === "Draft") {
|
||||
try {
|
||||
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
toast.success(
|
||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||
)
|
||||
onPaid(payment, "Posted")
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
||||
errorMessage(err)
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="pinv-pay-account">Pay from</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="pinv-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="pinv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="pinv-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="pinv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="pinv-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…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
|
||||
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
|
||||
|
||||
// Fixed width (not w-fit) so every status pill is the same size regardless of
|
||||
// label length, same convention as components/receiving/status-badges.tsx.
|
||||
@@ -43,3 +44,53 @@ export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
|
||||
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Posted") return "bg-success/10 text-success border-transparent"
|
||||
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
|
||||
}
|
||||
|
||||
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
|
||||
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
|
||||
Draft: "Draft",
|
||||
Posted: "Approved",
|
||||
Cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
|
||||
{purchaseInvoiceStatusLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
|
||||
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
|
||||
if (balanceAmount <= 0) return "Paid"
|
||||
if (paidAmount > 0) return "PartiallyPaid"
|
||||
return "Unpaid"
|
||||
}
|
||||
|
||||
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
|
||||
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||
}
|
||||
|
||||
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
|
||||
Unpaid: "Unpaid",
|
||||
PartiallyPaid: "Partially paid",
|
||||
Paid: "Paid",
|
||||
}
|
||||
|
||||
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
|
||||
{purchaseInvoicePaymentLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user