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
@@ -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"]
}