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:
2026-09-01 15:25:32 +05:30
parent 160c687db3
commit bebec10eb3
70 changed files with 12446 additions and 589 deletions
@@ -214,7 +214,7 @@ const navItems: {
children: [
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
],
},
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
@@ -34,6 +34,7 @@ const SEGMENT_LABELS: Record<string, string> = {
requisitions: "Requisitions",
rfqs: "RFQs",
"purchase-orders": "Purchase Orders",
"purchase-invoices": "Purchase Invoices",
"purchase-returns": "Purchase Returns",
production: "Production",
templates: "Templates",
@@ -0,0 +1,52 @@
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig, InvoiceTemplateStyle } from "@/types/invoice-template"
interface InvoicePrintFooterProps {
company: CompanyProfile | null
template: InvoiceTemplateConfig | null
}
// A real (not purely cosmetic) effect of the template style: how much breathing room and
// accent the footer block gets, from denser to more formal.
const styleClass: Record<InvoiceTemplateStyle, string> = {
Compact: "border-t pt-3 gap-2",
Classic: "border-t pt-5 gap-3",
Modern: "border-t-2 border-t-primary pt-5 gap-3",
}
/** Bank details, terms & conditions, and footer note for an invoice print view — sourced from
* the shared Invoice Template Config and Company Profile. Used by both the Sales Invoice and
* Purchase Invoice print pages. */
export function InvoicePrintFooter({ company, template }: InvoicePrintFooterProps) {
if (!template) return null
const hasBankDetails =
template.showBankDetails && company && (company.bankName || company.accountNumber)
if (!hasBankDetails && !template.termsAndConditions.trim() && !template.footerNote.trim()) return null
return (
<div className={`invoice-footer flex flex-col ${styleClass[template.templateStyle]}`}>
{hasBankDetails && (
<div className="grid gap-1 text-sm">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bank details</div>
{company!.bankName && <div className="text-muted-foreground">Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</div>}
{company!.accountName && <div className="text-muted-foreground">Account name: {company!.accountName}</div>}
{company!.accountNumber && <div className="text-muted-foreground">Account number: {company!.accountNumber}</div>}
{company!.swiftCode && <div className="text-muted-foreground">SWIFT: {company!.swiftCode}</div>}
</div>
)}
{template.termsAndConditions.trim() && (
<div className="grid gap-1 text-sm">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Terms & conditions</div>
<p className="whitespace-pre-wrap text-muted-foreground">{template.termsAndConditions}</p>
</div>
)}
{template.footerNote.trim() && (
<p className="whitespace-pre-wrap text-xs text-muted-foreground">{template.footerNote}</p>
)}
</div>
)
}
@@ -0,0 +1,53 @@
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig, LogoPlacement } from "@/types/invoice-template"
interface InvoicePrintHeaderProps {
company: CompanyProfile | null
template: InvoiceTemplateConfig | null
}
/** Company identity block for an invoice print view — logo (per template.logoPlacement),
* name/address/contact, and tax reg no (only when template.showTaxRegNo). Used by both the
* Sales Invoice and Purchase Invoice print pages so they render from one shared source. */
export function InvoicePrintHeader({ company, template }: InvoicePrintHeaderProps) {
if (!company) {
return (
<div className="grid gap-2 text-sm md:justify-items-end">
<div className="font-semibold text-foreground">Company details are not configured yet.</div>
<div className="text-muted-foreground">Set them up under Settings → Company Profile.</div>
</div>
)
}
const placement: LogoPlacement = template?.logoPlacement ?? "None"
const showTaxRegNo = template?.showTaxRegNo ?? false
const hasLogo = placement !== "None" && !!company.logoUrl
// Logo always sits on its own line above the address block (never beside it) — only
// its horizontal position changes with placement, and the address text below matches
// that same alignment so the whole block reads as one column, not two misaligned ones.
const alignmentClasses: Record<LogoPlacement, string> = {
Left: "justify-items-start text-left",
Center: "justify-items-center text-center",
Right: "justify-items-end text-right",
None: "md:justify-items-end md:text-right",
}
return (
<div className={`grid gap-2 text-sm ${alignmentClasses[placement]}`}>
{hasLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company.logoUrl!} alt={company.legalName} className="h-12 w-auto object-contain" />
)}
<div className="font-semibold text-foreground">{company.tradeName || company.legalName}</div>
<div className="text-muted-foreground">{company.addressLine1}</div>
{company.addressLine2 && <div className="text-muted-foreground">{company.addressLine2}</div>}
<div className="text-muted-foreground">{[company.city, company.country].filter(Boolean).join(", ")}</div>
{company.phone && <div className="text-muted-foreground">{company.phone}</div>}
{company.email && <div className="text-muted-foreground">{company.email}</div>}
{showTaxRegNo && company.taxRegistrationNo && (
<div className="text-muted-foreground">Tax Reg No: {company.taxRegistrationNo}</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
import { InvoiceTemplateStyle } from "@/types/invoice-template"
/** Which document structure a style renders as — not just color/spacing, the actual
* placement of blocks on the page:
* - "standard": title+doc no. on the left, company block on the right, then a 3-up
* info row (party, second party detail, totals card), then the line table.
* - "compact": title and company stacked into one dense strip, info collapsed into a
* single inline summary line instead of three cards, to fit more on one page.
* - "highlight": company block leads (left), title/doc no. trails (right) — reversed
* from standard — then a full-width totals banner ahead of the line table instead of
* a totals card buried in the 3-up row, with party/second-party as a 2-up row below it.
*/
export type InvoiceArrangement = "standard" | "compact" | "highlight"
/** Layout classes + arrangement that make each `InvoiceTemplateStyle` a genuinely
* different print template (structure, density, accenting) rather than a cosmetic
* tweak. Shared by the Sales Invoice and Purchase Invoice print pages so a style change
* rearranges both document types identically. */
export interface InvoiceLayoutClasses {
arrangement: InvoiceArrangement
sheet: string
header: string
eyebrow: string
title: string
meta: string
infoGrid: string
card: string
cardLabel: string
cardValue: string
tableWrap: string
tableHeaderRow: string
totalsHighlight: string
totalsBanner: string
totalsBannerLabel: string
totalsBannerValue: string
}
const presets: Record<InvoiceTemplateStyle, InvoiceLayoutClasses> = {
Classic: {
arrangement: "standard",
sheet: "gap-6 p-6",
header: "gap-4 border-b pb-5",
eyebrow: "text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground",
title: "mt-2 text-3xl font-semibold text-foreground",
meta: "mt-2 text-sm text-muted-foreground",
infoGrid: "gap-4",
card: "rounded-2xl border bg-muted/20 p-4",
cardLabel: "text-xs font-semibold uppercase tracking-wide text-muted-foreground",
cardValue: "mt-2 text-lg font-semibold text-foreground",
tableWrap: "overflow-hidden rounded-2xl border",
tableHeaderRow: "",
totalsHighlight: "font-semibold text-foreground",
totalsBanner: "hidden",
totalsBannerLabel: "",
totalsBannerValue: "",
},
Compact: {
arrangement: "compact",
sheet: "gap-3 p-4",
header: "gap-1 border-b pb-2",
eyebrow: "text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
title: "text-xl font-semibold text-foreground",
meta: "text-xs text-muted-foreground",
infoGrid: "gap-x-6 gap-y-1",
card: "rounded-md border bg-muted/10 p-2.5",
cardLabel: "text-[10px] font-semibold uppercase tracking-wide text-muted-foreground",
cardValue: "mt-1 text-sm font-semibold text-foreground",
tableWrap: "overflow-hidden rounded-md border",
tableHeaderRow: "",
totalsHighlight: "font-semibold text-foreground",
totalsBanner: "hidden",
totalsBannerLabel: "",
totalsBannerValue: "",
},
Modern: {
arrangement: "highlight",
sheet: "gap-6 p-6",
header: "gap-4 border-b-2 border-b-primary pb-5",
eyebrow: "text-xs font-semibold uppercase tracking-[0.3em] text-primary",
title: "mt-2 text-3xl font-bold text-foreground",
meta: "mt-2 text-sm text-muted-foreground",
infoGrid: "gap-4",
card: "rounded-2xl border border-primary/20 bg-primary/5 p-4",
cardLabel: "text-xs font-semibold uppercase tracking-wide text-primary",
cardValue: "mt-2 text-lg font-semibold text-foreground",
tableWrap: "overflow-hidden rounded-2xl border border-primary/20",
tableHeaderRow: "bg-primary/10 [&_th]:text-primary",
totalsHighlight: "font-semibold text-primary",
totalsBanner: "flex items-center justify-between rounded-2xl bg-primary px-5 py-4 text-primary-foreground",
totalsBannerLabel: "text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80",
totalsBannerValue: "text-2xl font-bold text-primary-foreground",
},
}
export function getInvoiceLayoutClasses(style: InvoiceTemplateStyle | undefined): InvoiceLayoutClasses {
return presets[style ?? "Classic"]
}
@@ -3,11 +3,11 @@
import { useEffect, useState } from "react"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { grnsApi } from "@/lib/api/grns"
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 { Grn, GrnPayment } from "@/types/grn"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -16,16 +16,25 @@ import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
interface GrnPaymentDialogProps {
grn: Grn | null
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
onPaid: (grn: Grn, payment: GrnPayment) => 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
}
/** Pay the vendor against a confirmed GRN's balance — full or partial, from an existing
* GL cash/bank account. Modeled on ReceivedChequeDialog's "pick an account, submit" shape. */
export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPaymentDialogProps) {
/** 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("")
@@ -33,15 +42,13 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Reset the form fields for a fresh open (or a different GRN) during render, React's own
// sanctioned "adjust state while rendering" pattern (see the General Ledger report page's
// periodKey comment) — not inside the effect below, which would be a synchronous
// setState-in-effect (react-hooks/set-state-in-effect).
const openKey = open && grn ? `${grn.grnId}` : null
// 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(grn!.balanceAmount.toFixed(2))
setAmount(invoice!.totals.balanceAmount.toFixed(2))
setGlBankAccountId("")
setReference("")
setErrors({})
@@ -52,30 +59,46 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
}, [open, accounts])
if (!grn) return null
if (!invoice) return null
async function submit() {
if (!grn) return
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 > grn.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(grn.balanceAmount)})`
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 grnsApi.pay(grn.grnId, {
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,
})
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
onPaid(grn, payment)
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("Could not record payment", errorMessage(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)
}
@@ -85,18 +108,19 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Pay GRN {grn.docNo}</DialogTitle>
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(grn.balanceAmount)}</span>
{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="grn-pay-account">Pay from</FieldLabel>
<FieldLabel htmlFor="pinv-pay-account">Pay from</FieldLabel>
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
<SelectTrigger id="grn-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
<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>
@@ -111,13 +135,13 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="grn-pay-amount">Amount</FieldLabel>
<FieldLabel htmlFor="pinv-pay-amount">Amount</FieldLabel>
<Input
id="grn-pay-amount"
id="pinv-pay-amount"
type="number"
step="0.01"
min="0.01"
max={grn.balanceAmount}
max={invoice.totals.balanceAmount}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-invalid={!!errors.amount}
@@ -126,8 +150,8 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
</Field>
<Field>
<FieldLabel htmlFor="grn-pay-reference">Reference (optional)</FieldLabel>
<Input id="grn-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
</FieldGroup>
@@ -136,7 +160,11 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : "Record payment"}
{submitting
? "Recording…"
: invoice.status === "Draft"
? "Confirm & record payment"
: "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
@@ -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>
)
}
@@ -32,32 +32,3 @@ export function HoldStatusBadge({ status }: { status: HoldStatus }) {
</Badge>
)
}
export type GrnPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
export function grnPaymentStatus(paidAmount: number, balanceAmount: number): GrnPaymentStatus {
if (balanceAmount <= 0) return "Paid"
if (paidAmount > 0) return "PartiallyPaid"
return "Unpaid"
}
function paymentClass(status: GrnPaymentStatus) {
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 paymentLabel: Record<GrnPaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partial",
Paid: "Paid",
}
export function GrnPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
const status = grnPaymentStatus(paidAmount, balanceAmount)
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
@@ -32,7 +32,8 @@ interface SalesInvoicePaymentDialogProps {
/** Record a customer payment against an 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 GrnPaymentDialog's "pick an account, submit" shape. */
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
* PurchaseInvoicePaymentDialog on the AP side. */
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("")
@@ -42,7 +43,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
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.
// "adjust state while rendering" pattern, not inside an effect.
const openKey = open && invoice ? `${invoice.salesInvoiceId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"min-h-20 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }