ui bug fixers
This commit is contained in:
@@ -13,8 +13,8 @@ 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"
|
||||
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
|
||||
|
||||
interface PurchaseInvoicePaymentDialogProps {
|
||||
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
|
||||
@@ -24,23 +24,34 @@ interface PurchaseInvoicePaymentDialogProps {
|
||||
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. */
|
||||
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
|
||||
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
|
||||
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
|
||||
/** Called once, after the dialog closes, when this submit cleared the whole balance. */
|
||||
onFullyPaid?: () => 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) {
|
||||
/** Record a vendor payment against a Purchase Invoice's balance — full or partial, split across
|
||||
* one or more payment lines (e.g. part cash, part bank), each from an existing GL cash/bank
|
||||
* account. Same shape as SalesInvoicePaymentDialog: each line is posted as its own payment (the
|
||||
* backend already allows several installments). 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. If a line fails part-way, the lines already recorded are removed from the form and
|
||||
* the rest stay for a retry. */
|
||||
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid, onFullyPaid }: PurchaseInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||
const [reference, setReference] = useState("")
|
||||
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// What this dialog has already done to the invoice in this session — the `invoice` prop isn't
|
||||
// guaranteed to be refreshed by the parent between a partial failure and the retry.
|
||||
const [postedHere, setPostedHere] = useState(false)
|
||||
const [paidHere, setPaidHere] = useState(0)
|
||||
// Balance as it stood when the dialog opened. The parent pages apply each `onPaid` to the
|
||||
// invoice they pass in, so subtracting `paidHere` from the live prop would count it twice.
|
||||
const [openingBalance, setOpeningBalance] = useState(0)
|
||||
|
||||
// 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.
|
||||
@@ -48,10 +59,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
|
||||
setReference("")
|
||||
setPaymentDate(localToday())
|
||||
setErrors({})
|
||||
setPostedHere(false)
|
||||
setPaidHere(0)
|
||||
setOpeningBalance(invoice!.totals.balanceAmount)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,98 +75,120 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const isDraft = invoice.status === "Draft" && !postedHere
|
||||
const balance = round2(openingBalance - paidHere)
|
||||
|
||||
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)})`
|
||||
const nextErrors = validatePaymentLines(lines, balance)
|
||||
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
|
||||
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
|
||||
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
|
||||
}
|
||||
if (isDraft) {
|
||||
try {
|
||||
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
|
||||
setPostedHere(true)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
|
||||
// keep the rest so the user can fix and retry without double-paying.
|
||||
const recorded: PurchaseInvoicePayment[] = []
|
||||
let failure: unknown = null
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
|
||||
amount: Number(line.amount),
|
||||
glBankAccountId: Number(line.glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
paymentDate,
|
||||
})
|
||||
recorded.push(payment)
|
||||
onPaid(payment, "Posted")
|
||||
} catch (err) {
|
||||
failure = err
|
||||
break
|
||||
}
|
||||
}
|
||||
setSubmitting(false)
|
||||
|
||||
const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0))
|
||||
if (failure === null) {
|
||||
const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ")
|
||||
const fullyPaid = recordedTotal >= balance
|
||||
toast.success(
|
||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||
`${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${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)
|
||||
if (fullyPaid) onFullyPaid?.()
|
||||
return
|
||||
}
|
||||
|
||||
setPaidHere((p) => round2(p + recordedTotal))
|
||||
setLines((prev) => prev.slice(recorded.length))
|
||||
toast.error(
|
||||
recorded.length > 0
|
||||
? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be`
|
||||
: invoice.status === "Draft"
|
||||
? "Invoice was confirmed, but the payment could not be recorded"
|
||||
: "Could not record payment",
|
||||
errorMessage(failure)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
||||
<DialogTitle>{isDraft ? "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>
|
||||
{isDraft && "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(balance)}</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>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pinv-pay-invoice-no">Invoice no.</FieldLabel>
|
||||
<Input
|
||||
id="pinv-pay-invoice-no"
|
||||
value={invoice.docNo}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||
/>
|
||||
</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 data-invalid={!!errors.paymentDate}>
|
||||
<FieldLabel htmlFor="pinv-pay-date" required>Payment date</FieldLabel>
|
||||
<Input
|
||||
id="pinv-pay-date"
|
||||
type="date"
|
||||
max={localToday()}
|
||||
value={paymentDate}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
aria-invalid={!!errors.paymentDate}
|
||||
/>
|
||||
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -160,11 +196,7 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting
|
||||
? "Recording…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react"
|
||||
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
formatDate,
|
||||
InvoicePreviewFrame,
|
||||
money,
|
||||
PaperSize,
|
||||
printColorExact,
|
||||
RECEIPT_SHEET_BASE_CLASS,
|
||||
RECEIPT_SHEET_CLASS,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
ReceiptWidth,
|
||||
SectionTitle,
|
||||
templateNotes,
|
||||
} from "@/components/invoice-template/InvoicePrintKit"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { PurchaseInvoice, PurchaseInvoicePayment } from "@/types/purchase-invoices"
|
||||
|
||||
/*
|
||||
* Printable purchase invoice (vendor bill) in the same three paper sizes as the sales invoice —
|
||||
* A4, 80mm and 58mm receipt — sharing its preview frame, PDF export and print plumbing. The
|
||||
* lines are the GRNs the bill covers; "From" is the vendor and "Bill To" is our company.
|
||||
*/
|
||||
|
||||
/** Everything both layouts render, resolved once. */
|
||||
interface PurchaseInvoiceDoc {
|
||||
invoice: PurchaseInvoice
|
||||
vendor: Vendor | null
|
||||
payments: PurchaseInvoicePayment[]
|
||||
company: CompanyProfile | null
|
||||
template: InvoiceTemplateConfig | null
|
||||
notes: string[]
|
||||
showLogo: boolean
|
||||
}
|
||||
|
||||
function A4Sheet({ invoice, vendor, payments, company, template, notes, showLogo }: PurchaseInvoiceDoc) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||
{/* Title + meta */}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">PURCHASE INVOICE</h1>
|
||||
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
|
||||
<dt className="text-xs font-bold text-neutral-900">Invoice No.</dt>
|
||||
<dd>{invoice.docNo}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Vendor Invoice No.</dt>
|
||||
<dd>{invoice.vendorInvoiceNo}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
|
||||
<dd>{formatDate(invoice.vendorInvoiceDate)}</dd>
|
||||
{invoice.status !== "Posted" && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: ACCENT }}>{invoice.status.toUpperCase()}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{showLogo && (
|
||||
// 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-16 w-auto max-w-45 object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header rule: heavy dark bar over a thin accent line */}
|
||||
<div className="mt-5 mb-7">
|
||||
<div className="h-1 bg-neutral-900" />
|
||||
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
|
||||
</div>
|
||||
|
||||
{/* From (vendor) / Bill To (us) */}
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<SectionTitle>From</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{invoice.vendorSnapshotName}</p>
|
||||
{vendor?.code && <p>Vendor code: {vendor.code}</p>}
|
||||
<div className="mt-2">
|
||||
{invoice.vendorSnapshotTaxNo && <p>Tax No: {invoice.vendorSnapshotTaxNo}</p>}
|
||||
{vendor?.terms && <p>Terms: {vendor.terms}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>Bill To</SectionTitle>
|
||||
{company ? (
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
|
||||
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
|
||||
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
|
||||
<div className="mt-2">
|
||||
{company.phone && <p>{company.phone}</p>}
|
||||
{company.email && <p>{company.email}</p>}
|
||||
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-neutral-500 print:hidden">Company details aren't set up yet (Settings → Company Profile).</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRN lines */}
|
||||
<div className="mt-8">
|
||||
<SectionTitle>Goods Received</SectionTitle>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||
<th className="px-2 py-2.5 text-left">GRN</th>
|
||||
<th className="px-2 py-2.5 text-left">Received</th>
|
||||
<th className="px-2 py-2.5 text-right">Subtotal</th>
|
||||
<th className="px-2 py-2.5 text-right">VAT</th>
|
||||
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.lines.map((line) => (
|
||||
<tr key={line.purchaseInvoiceLineId} className="border-b border-neutral-300">
|
||||
<td className="px-2 py-2.5">{line.grnDocNo}</td>
|
||||
<td className="px-2 py-2.5">{formatDate(line.grnPostedAt)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.subtotal)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{line.vatTotal > 0 ? money(line.vatTotal) : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Notes / totals */}
|
||||
<div className="mt-6 grid grid-cols-[1fr_290px] items-start gap-8 print:break-inside-avoid">
|
||||
<div>
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<SectionTitle>Notes / Terms</SectionTitle>
|
||||
<ul className="text-xs">
|
||||
{notes.map((note, i) => (
|
||||
<li key={i} className="border-b border-neutral-300 py-1.5">{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-neutral-900">
|
||||
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(t.subtotal)}</span></div>
|
||||
{t.vatTotal > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(t.vatTotal)}</span></div>}
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums">{money(t.grandTotal)}</span>
|
||||
</div>
|
||||
{t.paidAmount > 0 && (
|
||||
<div className="space-y-1.5 border-t border-neutral-300 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between"><span>Paid</span><span className="tabular-nums">{money(t.paidAmount)}</span></div>
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Balance Due</span><span className="tabular-nums">{money(t.balanceAmount)}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments */}
|
||||
<div className="mt-8 print:break-inside-avoid">
|
||||
<SectionTitle>Payments</SectionTitle>
|
||||
{payments.length > 0 ? (
|
||||
<ul className="text-xs">
|
||||
{payments.map((p) => (
|
||||
<li key={p.purchaseInvoicePaymentId} className="flex justify-between gap-4 border-b border-neutral-300 py-1.5">
|
||||
<span>
|
||||
{formatDate(p.paymentDate)} · {p.bankAccountName}
|
||||
{p.reference && <span className="text-neutral-500"> · Ref {p.reference}</span>}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{money(p.amount)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">No payment recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, payments, company, template, notes, showLogo, width }: PurchaseInvoiceDoc & { width: ReceiptWidth }) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
|
||||
<div className="text-center">
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||
)}
|
||||
{company && (
|
||||
<>
|
||||
<p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>
|
||||
<p>{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}</p>
|
||||
{company.phone && <p>Tel: {company.phone}</p>}
|
||||
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg: {company.taxRegistrationNo}</p>}
|
||||
</>
|
||||
)}
|
||||
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">PURCHASE INVOICE</p>
|
||||
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Invoice No." value={invoice.docNo} />
|
||||
<ReceiptRow label="Vendor Inv." value={invoice.vendorInvoiceNo} />
|
||||
<ReceiptRow label="Date" value={formatDate(invoice.vendorInvoiceDate)} />
|
||||
<ReceiptRow label="Vendor" value={invoice.vendorSnapshotName} />
|
||||
|
||||
<ReceiptRule />
|
||||
<div className="flex justify-between font-bold">
|
||||
<span>GRN</span>
|
||||
<span>Amount</span>
|
||||
</div>
|
||||
<ReceiptRule />
|
||||
<div className="space-y-1.5">
|
||||
{invoice.lines.map((line) => (
|
||||
<div key={line.purchaseInvoiceLineId}>
|
||||
<p className="font-medium">{line.grnDocNo}</p>
|
||||
<div className="flex justify-between gap-2 tabular-nums">
|
||||
<span>{formatDate(line.grnPostedAt)}</span>
|
||||
<span>{money(line.lineTotal)}</span>
|
||||
</div>
|
||||
{line.vatTotal > 0 && <p className="text-right tabular-nums">incl. VAT {money(line.vatTotal)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
|
||||
{t.vatTotal > 0 && <ReceiptRow label="VAT" value={money(t.vatTotal)} />}
|
||||
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||
<ReceiptRow label="TOTAL" value={money(t.grandTotal)} bold />
|
||||
</div>
|
||||
{t.paidAmount > 0 && (
|
||||
<>
|
||||
<ReceiptRow label="Paid" value={money(t.paidAmount)} />
|
||||
<ReceiptRow label="Balance" value={money(t.balanceAmount)} bold />
|
||||
</>
|
||||
)}
|
||||
|
||||
{payments.length > 0 && (
|
||||
<>
|
||||
<ReceiptRule />
|
||||
<p className="font-bold">Payment</p>
|
||||
{payments.map((p) => (
|
||||
<ReceiptRow key={p.purchaseInvoicePaymentId} label={`${formatDate(p.paymentDate)} ${p.bankAccountName}`} value={money(p.amount)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<ReceiptRule />
|
||||
{notes.map((note, i) => <p key={i} className="text-center">{note}</p>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the invoice plus the optional decoration around it (vendor, payments, company, template).
|
||||
* Only the invoice is required — any other failure just leaves that block out. */
|
||||
function usePurchaseInvoiceDoc(purchaseInvoiceId: number, refreshKey = 0) {
|
||||
const [doc, setDoc] = useState<PurchaseInvoiceDoc | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(purchaseInvoiceId) ? loadError : `Invalid purchase invoice id '${purchaseInvoiceId}'.`
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(purchaseInvoiceId)) return
|
||||
let cancelled = false
|
||||
purchaseInvoicesApi
|
||||
.get(purchaseInvoiceId)
|
||||
.then(async ({ data: invoice }) => {
|
||||
const [vendorRes, payments, companyRes, templateRes] = await Promise.all([
|
||||
vendorsApi.get(invoice.vendorId).catch(() => null),
|
||||
invoice.status === "Posted" ? purchaseInvoicesApi.listPayments(purchaseInvoiceId).catch(() => []) : Promise.resolve([]),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
if (cancelled) return
|
||||
const company = companyRes.data
|
||||
const template = templateRes.data
|
||||
setDoc({
|
||||
invoice,
|
||||
vendor: vendorRes?.data ?? null,
|
||||
payments,
|
||||
company,
|
||||
template,
|
||||
notes: templateNotes(template?.termsAndConditions, template?.footerNote),
|
||||
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// refreshKey: bumped by the caller after a payment so the sheet shows the new state.
|
||||
}, [purchaseInvoiceId, refreshKey])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: PurchaseInvoiceDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/** Purchase invoice preview + paper-size bar underneath + Share / Download / Print. */
|
||||
export function PurchaseInvoicePrintPreview({
|
||||
purchaseInvoiceId,
|
||||
className,
|
||||
refreshKey,
|
||||
}: {
|
||||
purchaseInvoiceId: number
|
||||
className?: string
|
||||
refreshKey?: number
|
||||
}) {
|
||||
const { doc, error } = usePurchaseInvoiceDoc(purchaseInvoiceId, refreshKey)
|
||||
return (
|
||||
<InvoicePreviewFrame
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
docNo={doc?.invoice.docNo ?? `purchase-invoice-${purchaseInvoiceId}`}
|
||||
shareTitle={`Purchase invoice ${doc?.invoice.docNo ?? ""}`.trim()}
|
||||
sharePath={`/dashboard/procurement/purchase-invoices/${purchaseInvoiceId}`}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Popup form of the print preview — same frame as SalesInvoicePrintDialog. Opens on A4 every time. */
|
||||
export function PurchaseInvoicePrintDialog({
|
||||
purchaseInvoiceId,
|
||||
invoiceNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
actions,
|
||||
refreshKey,
|
||||
}: {
|
||||
purchaseInvoiceId: number | null
|
||||
invoiceNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Optional buttons shown in the header (e.g. Record payment). */
|
||||
actions?: ReactNode
|
||||
/** Bump to reload the invoice (after a payment, …). */
|
||||
refreshKey?: number
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||
<div className="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-semibold">Purchase invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{purchaseInvoiceId !== null && (
|
||||
<PurchaseInvoicePrintPreview purchaseInvoiceId={purchaseInvoiceId} refreshKey={refreshKey} className="flex-1" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user